diff --git a/packages/integration-tests/src/api/connector.ts b/packages/integration-tests/src/api/connector.ts index 0a151d7cdca..e19fa5e2f01 100644 --- a/packages/integration-tests/src/api/connector.ts +++ b/packages/integration-tests/src/api/connector.ts @@ -4,6 +4,7 @@ import type { ConnectorResponse, CreateConnector, } from '@logto/schemas'; +import { type KyInstance } from 'ky'; import { authedAdminApi } from './api.js'; @@ -15,29 +16,32 @@ import { authedAdminApi } from './api.js'; * that contain metadata (considered connectors' FIXED properties) and code implementation (which determines how connectors work). */ -export const listConnectors = async () => - authedAdminApi.get('connectors').json(); +export const listConnectors = async (api: KyInstance = authedAdminApi) => + api.get('connectors').json(); -export const getConnector = async (id: string) => - authedAdminApi.get(`connectors/${id}`).json(); +export const getConnector = async (id: string, api: KyInstance = authedAdminApi) => + api.get(`connectors/${id}`).json(); -export const listConnectorFactories = async () => - authedAdminApi.get('connector-factories').json(); +export const listConnectorFactories = async (api: KyInstance = authedAdminApi) => + api.get('connector-factories').json(); -export const getConnectorFactory = async (connectorFactoryId: string) => - authedAdminApi.get(`connector-factories/${connectorFactoryId}`).json(); +export const getConnectorFactory = async ( + connectorFactoryId: string, + api: KyInstance = authedAdminApi +) => api.get(`connector-factories/${connectorFactoryId}`).json(); export const postConnector = async ( - payload: Pick + payload: Pick, + api: KyInstance = authedAdminApi ) => - authedAdminApi + api .post('connectors', { json: payload, }) .json(); -export const deleteConnectorById = async (id: string) => - authedAdminApi.delete(`connectors/${id}`).json(); +export const deleteConnectorById = async (id: string, api: KyInstance = authedAdminApi) => + api.delete(`connectors/${id}`).json(); export const updateConnectorConfig = async ( id: string, @@ -75,9 +79,10 @@ const sendTestMessage = async ( export const getConnectorAuthorizationUri = async ( connectorId: string, state: string, - redirectUri: string + redirectUri: string, + api: KyInstance = authedAdminApi ) => - authedAdminApi + api .post(`connectors/${connectorId}/authorization-uri`, { json: { state, redirectUri }, }) diff --git a/packages/integration-tests/src/api/sign-in-experience.ts b/packages/integration-tests/src/api/sign-in-experience.ts index b270b897283..7d0aafddfb4 100644 --- a/packages/integration-tests/src/api/sign-in-experience.ts +++ b/packages/integration-tests/src/api/sign-in-experience.ts @@ -1,12 +1,16 @@ import type { SignInExperience } from '@logto/schemas'; +import { type KyInstance } from 'ky'; import { authedAdminApi } from './api.js'; -export const getSignInExperience = async () => - authedAdminApi.get('sign-in-exp').json(); +export const getSignInExperience = async (api: KyInstance = authedAdminApi) => + api.get('sign-in-exp').json(); -export const updateSignInExperience = async (signInExperience: Partial) => - authedAdminApi +export const updateSignInExperience = async ( + signInExperience: Partial, + api: KyInstance = authedAdminApi +) => + api .patch('sign-in-exp', { json: signInExperience, }) diff --git a/packages/integration-tests/src/client/experience/index.ts b/packages/integration-tests/src/client/experience/index.ts index 13480d493ee..592e89070db 100644 --- a/packages/integration-tests/src/client/experience/index.ts +++ b/packages/integration-tests/src/client/experience/index.ts @@ -10,32 +10,22 @@ import { import MockClient from '#src/client/index.js'; -import api from '../../api/api.js'; - import { experienceRoutes } from './const.js'; type RedirectResponse = { redirectTo: string; }; -export const identifyUser = async (cookie: string, payload: IdentificationApiPayload) => - api - .post(experienceRoutes.identification, { - headers: { cookie }, - json: payload, - }) - .json(); - export class ExperienceClient extends MockClient { public async identifyUser(payload: IdentificationApiPayload = {}) { - return api.post(experienceRoutes.identification, { + return this.api.post(experienceRoutes.identification, { headers: { cookie: this.interactionCookie }, json: payload, }); } public async updateInteractionEvent(payload: { interactionEvent: InteractionEvent }) { - return api + return this.api .put(`${experienceRoutes.prefix}/interaction-event`, { headers: { cookie: this.interactionCookie }, json: payload, @@ -44,7 +34,7 @@ export class ExperienceClient extends MockClient { } public async initInteraction(payload: CreateExperienceApiPayload) { - return api + return this.api .put(experienceRoutes.prefix, { headers: { cookie: this.interactionCookie }, json: payload, @@ -53,13 +43,13 @@ export class ExperienceClient extends MockClient { } public override async submitInteraction(): Promise { - return api + return this.api .post(`${experienceRoutes.prefix}/submit`, { headers: { cookie: this.interactionCookie } }) .json(); } public async verifyPassword(payload: PasswordVerificationPayload) { - return api + return this.api .post(`${experienceRoutes.verification}/password`, { headers: { cookie: this.interactionCookie }, json: payload, @@ -71,7 +61,7 @@ export class ExperienceClient extends MockClient { identifier: VerificationCodeIdentifier; interactionEvent: InteractionEvent; }) { - return api + return this.api .post(`${experienceRoutes.verification}/verification-code`, { headers: { cookie: this.interactionCookie }, json: payload, @@ -84,7 +74,7 @@ export class ExperienceClient extends MockClient { verificationId: string; code: string; }) { - return api + return this.api .post(`${experienceRoutes.verification}/verification-code/verify`, { headers: { cookie: this.interactionCookie }, json: payload, @@ -99,7 +89,7 @@ export class ExperienceClient extends MockClient { state: string; } ) { - return api + return this.api .post(`${experienceRoutes.verification}/social/${connectorId}/authorization-uri`, { headers: { cookie: this.interactionCookie }, json: payload, @@ -114,7 +104,7 @@ export class ExperienceClient extends MockClient { connectorData: Record; } ) { - return api + return this.api .post(`${experienceRoutes.verification}/social/${connectorId}/verify`, { headers: { cookie: this.interactionCookie }, json: payload, @@ -129,7 +119,7 @@ export class ExperienceClient extends MockClient { state: string; } ) { - return api + return this.api .post(`${experienceRoutes.verification}/sso/${connectorId}/authorization-uri`, { headers: { cookie: this.interactionCookie }, json: payload, @@ -144,7 +134,7 @@ export class ExperienceClient extends MockClient { connectorData: Record; } ) { - return api + return this.api .post(`${experienceRoutes.verification}/sso/${connectorId}/verify`, { headers: { cookie: this.interactionCookie }, json: payload, @@ -153,7 +143,7 @@ export class ExperienceClient extends MockClient { } public async getAvailableSsoConnectors(email: string) { - return api + return this.api .get(`${experienceRoutes.prefix}/sso-connectors`, { headers: { cookie: this.interactionCookie }, searchParams: { email }, @@ -162,7 +152,7 @@ export class ExperienceClient extends MockClient { } public async createTotpSecret() { - return api + return this.api .post(`${experienceRoutes.verification}/totp/secret`, { headers: { cookie: this.interactionCookie }, }) @@ -170,7 +160,7 @@ export class ExperienceClient extends MockClient { } public async verifyTotp(payload: { verificationId?: string; code: string }) { - return api + return this.api .post(`${experienceRoutes.verification}/totp/verify`, { headers: { cookie: this.interactionCookie }, json: payload, @@ -179,7 +169,7 @@ export class ExperienceClient extends MockClient { } public async generateMfaBackupCodes() { - return api + return this.api .post(`${experienceRoutes.verification}/backup-code/generate`, { headers: { cookie: this.interactionCookie }, }) @@ -187,7 +177,7 @@ export class ExperienceClient extends MockClient { } public async verifyBackupCode(payload: { code: string }) { - return api + return this.api .post(`${experienceRoutes.verification}/backup-code/verify`, { headers: { cookie: this.interactionCookie }, json: payload, @@ -198,7 +188,7 @@ export class ExperienceClient extends MockClient { public async createNewPasswordIdentityVerification( payload: Pick & { password?: string } ) { - return api + return this.api .post(`${experienceRoutes.verification}/new-password-identity`, { headers: { cookie: this.interactionCookie }, json: payload, @@ -207,27 +197,27 @@ export class ExperienceClient extends MockClient { } public async resetPassword(payload: { password: string }) { - return api.put(`${experienceRoutes.profile}/password`, { + return this.api.put(`${experienceRoutes.profile}/password`, { headers: { cookie: this.interactionCookie }, json: payload, }); } public async updateProfile(payload: UpdateProfileApiPayload) { - return api.post(`${experienceRoutes.profile}`, { + return this.api.post(`${experienceRoutes.profile}`, { headers: { cookie: this.interactionCookie }, json: payload, }); } public async skipMfaBinding() { - return api.post(`${experienceRoutes.mfa}/mfa-skipped`, { + return this.api.post(`${experienceRoutes.mfa}/mfa-skipped`, { headers: { cookie: this.interactionCookie }, }); } public async bindMfa(type: MfaFactor, verificationId: string) { - return api.post(`${experienceRoutes.mfa}`, { + return this.api.post(`${experienceRoutes.mfa}`, { headers: { cookie: this.interactionCookie }, json: { type, verificationId }, }); diff --git a/packages/integration-tests/src/client/index.ts b/packages/integration-tests/src/client/index.ts index 6b250a7e5eb..0c923ea0ee2 100644 --- a/packages/integration-tests/src/client/index.ts +++ b/packages/integration-tests/src/client/index.ts @@ -20,14 +20,14 @@ export default class MockClient { protected readonly config: LogtoConfig; protected readonly storage: MemoryStorage; protected readonly logto: LogtoClient; + protected readonly api: KyInstance; private navigateUrl?: string; - private readonly api: KyInstance; - constructor(config?: Partial) { + constructor(config?: Partial, api?: KyInstance) { this.storage = new MemoryStorage(); this.config = { ...defaultConfig, ...config }; - this.api = ky.extend({ prefixUrl: this.config.endpoint + '/api' }); + this.api = api ?? ky.extend({ prefixUrl: this.config.endpoint + '/api' }); this.logto = new LogtoClient(this.config, { navigate: (url: string) => { diff --git a/packages/integration-tests/src/helpers/admin-tenant.ts b/packages/integration-tests/src/helpers/admin-tenant.ts index cd744888990..cbc2e88691a 100644 --- a/packages/integration-tests/src/helpers/admin-tenant.ts +++ b/packages/integration-tests/src/helpers/admin-tenant.ts @@ -1,6 +1,7 @@ // To refactor: should combine into other similar utils // Since they are just different in URLs +import { type SocialUserInfo } from '@logto/connector-kit'; import type { LogtoConfig } from '@logto/node'; import { PredefinedScope, @@ -17,9 +18,14 @@ import { import { authedAdminTenantApi as api, adminTenantApi } from '#src/api/api.js'; import type { InteractionPayload } from '#src/api/interaction.js'; import { adminConsoleRedirectUri, logtoConsoleUrl } from '#src/constants.js'; -import { initClient } from '#src/helpers/client.js'; +import { initClient, initExperienceClient, processSession } from '#src/helpers/client.js'; import { generatePassword, generateUsername } from '#src/utils.js'; +import { + successFullyCreateSocialVerification, + successFullyVerifySocialAuthorization, +} from './experience/social-verification.js'; + export const resourceDefault = getManagementApiResourceIndicator(defaultTenantId); export const resourceMe = getManagementApiResourceIndicator(adminTenantId, 'me'); @@ -67,6 +73,15 @@ export const putInteraction = async (cookie: string, payload: InteractionPayload }) .json(); +export const initAdminExperienceClient = async (config?: Partial) => + initExperienceClient( + InteractionEvent.SignIn, + { endpoint: logtoConsoleUrl, appId: adminConsoleApplicationId, ...config }, + adminConsoleRedirectUri, + undefined, + adminTenantApi + ); + export const initClientAndSignIn = async ( username: string, password: string, @@ -102,3 +117,56 @@ export const createUserWithAllRolesAndSignInToClient = async () => { return { id, client, username, password }; }; + +export const signUpWithSocialAndSignInToClient = async ( + connectorId: string, + socialUserInfo: SocialUserInfo +) => { + const state = 'state'; + const redirectUri = 'http://localhost:3000'; + + const client = await initAdminExperienceClient({ + resources: [resourceDefault, resourceMe], + scopes: [PredefinedScope.All], + }); + + const { verificationId } = await successFullyCreateSocialVerification(client, connectorId, { + redirectUri, + state, + }); + + const { id, ...rest } = socialUserInfo; + + await successFullyVerifySocialAuthorization(client, connectorId, { + verificationId, + connectorData: { + state, + redirectUri, + code: 'fake_code', + userId: socialUserInfo.id, + ...rest, + }, + }); + + await client.updateInteractionEvent({ interactionEvent: InteractionEvent.Register }); + await client.identifyUser({ verificationId }); + + const { redirectTo } = await client.submitInteraction(); + const userId = await processSession(client, redirectTo); + const existingUserRoles = await api.get(`users/${userId}/roles`).json(); + const existingUserRoleIds = new Set(existingUserRoles.map(({ id }) => id)); + + // Should have roles for default tenant Management API and admin tenant Me API + const roles = await api.get('roles').json(); + await Promise.all( + roles + .filter(({ id, type }) => !existingUserRoleIds.has(id) && type !== RoleType.MachineToMachine) + .map(async ({ id }) => + api.post(`roles/${id}/users`, { + json: { userIds: [userId] }, + }) + ) + ); + + return { id: userId, client }; +}; diff --git a/packages/integration-tests/src/helpers/client.ts b/packages/integration-tests/src/helpers/client.ts index 12fe8235255..45cc64b844f 100644 --- a/packages/integration-tests/src/helpers/client.ts +++ b/packages/integration-tests/src/helpers/client.ts @@ -1,6 +1,7 @@ import type { LogtoConfig, SignInOptions } from '@logto/node'; import { InteractionEvent } from '@logto/schemas'; import { assert } from '@silverhand/essentials'; +import { type KyInstance } from 'ky'; import { ExperienceClient } from '#src/client/experience/index.js'; import MockClient from '#src/client/index.js'; @@ -21,9 +22,10 @@ export const initExperienceClient = async ( interactionEvent: InteractionEvent = InteractionEvent.SignIn, config?: Partial, redirectUri?: string, - options: Omit = {} + options: Omit = {}, + api?: KyInstance ) => { - const client = new ExperienceClient(config); + const client = new ExperienceClient(config, api); await client.initSession(redirectUri, options); assert(client.interactionCookie, new Error('Session not found')); await client.initInteraction({ interactionEvent }); diff --git a/packages/integration-tests/src/helpers/connector.ts b/packages/integration-tests/src/helpers/connector.ts index c28240db1bc..e68ef0f8af1 100644 --- a/packages/integration-tests/src/helpers/connector.ts +++ b/packages/integration-tests/src/helpers/connector.ts @@ -1,4 +1,5 @@ import { ConnectorType } from '@logto/schemas'; +import { type KyInstance } from 'ky'; import { mockEmailConnectorConfig, @@ -11,10 +12,12 @@ import { import { deleteConnectorById, listConnectors, postConnector } from '#src/api/index.js'; import { deleteSsoConnectorById, getSsoConnectors } from '#src/api/sso-connector.js'; -export const clearConnectorsByTypes = async (types: ConnectorType[]) => { - const connectors = await listConnectors(); +export const clearConnectorsByTypes = async (types: ConnectorType[], api?: KyInstance) => { + const connectors = await listConnectors(api); const targetConnectors = connectors.filter((connector) => types.includes(connector.type)); - await Promise.all(targetConnectors.map(async (connector) => deleteConnectorById(connector.id))); + await Promise.all( + targetConnectors.map(async (connector) => deleteConnectorById(connector.id, api)) + ); }; export const clearSsoConnectors = async () => { @@ -24,24 +27,33 @@ export const clearSsoConnectors = async () => { export const clearConnectorById = async (id: string) => deleteConnectorById(id); -export const setEmailConnector = async () => - postConnector({ - connectorId: mockEmailConnectorId, - config: mockEmailConnectorConfig, - }); - -export const setSmsConnector = async () => - postConnector({ - connectorId: mockSmsConnectorId, - config: mockSmsConnectorConfig, - }); - -export const setSocialConnector = async () => - postConnector({ - connectorId: mockSocialConnectorId, - config: mockSocialConnectorConfig, - syncProfile: true, - }); +export const setEmailConnector = async (api?: KyInstance) => + postConnector( + { + connectorId: mockEmailConnectorId, + config: mockEmailConnectorConfig, + }, + api + ); + +export const setSmsConnector = async (api?: KyInstance) => + postConnector( + { + connectorId: mockSmsConnectorId, + config: mockSmsConnectorConfig, + }, + api + ); + +export const setSocialConnector = async (api?: KyInstance) => + postConnector( + { + connectorId: mockSocialConnectorId, + config: mockSocialConnectorConfig, + syncProfile: true, + }, + api + ); export const resetPasswordlessConnectors = async () => { await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); diff --git a/packages/integration-tests/src/helpers/sign-in-experience.ts b/packages/integration-tests/src/helpers/sign-in-experience.ts index 5d756d2e120..76b371002fe 100644 --- a/packages/integration-tests/src/helpers/sign-in-experience.ts +++ b/packages/integration-tests/src/helpers/sign-in-experience.ts @@ -5,13 +5,32 @@ import { updateSignInExperience } from '#src/api/index.js'; import { clearConnectorsByTypes } from './connector.js'; +export const defaultSignInSignUpConfigs = { + signInMode: SignInMode.SignInAndRegister, + signUp: { + identifiers: [SignInIdentifier.Username], + password: true, + verify: false, + }, + signIn: { + methods: [ + { + identifier: SignInIdentifier.Username, + isPasswordPrimary: true, + password: true, + verificationCode: false, + }, + ], + }, +}; + export const defaultSignUpMethod = { identifiers: [], password: false, verify: false, }; -const defaultPasswordSignInMethods = [ +export const defaultPasswordSignInMethods = [ { identifier: SignInIdentifier.Username, password: true, diff --git a/packages/integration-tests/src/tests/api/me.test.ts b/packages/integration-tests/src/tests/api/me.test.ts index c412e8830fd..6dba31ba2ba 100644 --- a/packages/integration-tests/src/tests/api/me.test.ts +++ b/packages/integration-tests/src/tests/api/me.test.ts @@ -1,14 +1,26 @@ +import { ConnectorType } from '@logto/connector-kit'; +import { SignInMode, SignInIdentifier } from '@logto/schemas'; +import { generateStandardId } from '@logto/shared'; import ky from 'ky'; +import { authedAdminTenantApi as api } from '#src/api/api.js'; +import { updateSignInExperience } from '#src/api/sign-in-experience.js'; import { logtoConsoleUrl, logtoUrl } from '#src/constants.js'; import { createUserWithAllRolesAndSignInToClient, deleteUser, resourceDefault, resourceMe, + signUpWithSocialAndSignInToClient, } from '#src/helpers/admin-tenant.js'; +import { + clearConnectorsByTypes, + setEmailConnector, + setSocialConnector, +} from '#src/helpers/connector.js'; import { expectRejects } from '#src/helpers/index.js'; -import { generatePassword } from '#src/utils.js'; +import { defaultSignInSignUpConfigs } from '#src/helpers/sign-in-experience.js'; +import { generateEmail, generatePassword } from '#src/utils.js'; describe('me', () => { it('should only be available in admin tenant', async () => { @@ -100,4 +112,68 @@ describe('me', () => { await deleteUser(id); }); + + describe('social sign-up user who has no password', () => { + const context = new (class Context { + socialConnectorId?: string; + userId?: string; + })(); + + beforeAll(async () => { + // Prepare social sign-up and sign-in configs in SIE + await clearConnectorsByTypes([ConnectorType.Social, ConnectorType.Email], api); + const { id: socialConnectorId } = await setSocialConnector(api); + await setEmailConnector(api); + await updateSignInExperience( + { + signInMode: SignInMode.SignInAndRegister, + signUp: { + identifiers: [SignInIdentifier.Email], + password: false, + verify: true, + }, + signIn: { + methods: [ + { + identifier: SignInIdentifier.Email, + password: false, + verificationCode: true, + isPasswordPrimary: false, + }, + ], + }, + }, + api + ); + // eslint-disable-next-line @silverhand/fp/no-mutation + context.socialConnectorId = socialConnectorId; + }); + + afterAll(async () => { + // Clean up + await updateSignInExperience(defaultSignInSignUpConfigs, api); + await clearConnectorsByTypes([ConnectorType.Social, ConnectorType.Email], api); + await deleteUser(context.userId!); + }); + + it('should allow no-password user to set password', async () => { + const { id: userId, client } = await signUpWithSocialAndSignInToClient( + context.socialConnectorId!, + { + id: generateStandardId(), + email: generateEmail(), + } + ); + // eslint-disable-next-line @silverhand/fp/no-mutation + context.userId = userId; + + const headers = { authorization: `Bearer ${await client.getAccessToken(resourceMe)}` }; + await expect( + ky.post(logtoConsoleUrl + '/me/password', { + headers, + json: { password: generatePassword() }, + }) + ).resolves.toHaveProperty('status', 204); + }); + }); });