From 6bead4a319bd23467cd1394eaecc5ca559599a9a Mon Sep 17 00:00:00 2001 From: simeng-li Date: Thu, 15 Dec 2022 11:38:15 +0800 Subject: [PATCH] feat(test): add password and passcode identifier tests (#2664) --- packages/integration-tests/package.json | 3 +- .../integration-tests/src/api/connector.ts | 8 +- .../integration-tests/src/api/interaction.ts | 49 ++-- .../src/tests/api/admin-user.test.ts | 3 +- .../src/tests/api/connector.test.ts | 15 +- .../src/tests/api/session.test.ts | 10 +- .../src/tests/api/social-session.test.ts | 2 +- .../sign-in-with-passcode-identifier.test.ts | 213 ++++++++++++++++++ .../sign-in-with-password-identifier.test.ts | 98 ++++++++ .../src/tests/interaction/utils/client.ts | 18 ++ .../src/tests/interaction/utils/connector.ts | 27 +++ .../interaction/utils/sign-in-experience.ts | 74 ++++++ .../src/tests/interaction/utils/user.ts | 40 ++++ 13 files changed, 522 insertions(+), 38 deletions(-) create mode 100644 packages/integration-tests/src/tests/interaction/sign-in-with-passcode-identifier.test.ts create mode 100644 packages/integration-tests/src/tests/interaction/sign-in-with-password-identifier.test.ts create mode 100644 packages/integration-tests/src/tests/interaction/utils/client.ts create mode 100644 packages/integration-tests/src/tests/interaction/utils/connector.ts create mode 100644 packages/integration-tests/src/tests/interaction/utils/sign-in-experience.ts create mode 100644 packages/integration-tests/src/tests/interaction/utils/user.ts diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index a537a10a8..464924d89 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -12,9 +12,10 @@ "scripts": { "build": "rm -rf lib/ && tsc -p tsconfig.test.json --sourcemap", "test:only": "NODE_OPTIONS=--experimental-vm-modules jest", - "test": "pnpm build && pnpm test:api && pnpm test:ui", + "test": "pnpm build && pnpm test:api && pnpm test:ui && pnpm test:interaction", "test:api": "pnpm test:only -i ./lib/tests/api", "test:ui": "pnpm test:only -i --config=jest.config.ui.js ./lib/tests/ui", + "test:interaction": "pnpm test:only -i ./lib/tests/interaction", "lint": "eslint --ext .ts src", "lint:report": "pnpm lint --format json --output-file report.json", "start": "pnpm test" diff --git a/packages/integration-tests/src/api/connector.ts b/packages/integration-tests/src/api/connector.ts index 2f82d2f66..7ce8eea86 100644 --- a/packages/integration-tests/src/api/connector.ts +++ b/packages/integration-tests/src/api/connector.ts @@ -1,4 +1,4 @@ -import type { Connector, ConnectorResponse } from '@logto/schemas'; +import type { Connector, ConnectorResponse, CreateConnector } from '@logto/schemas'; import { authedAdminApi } from './api.js'; @@ -9,11 +9,13 @@ export const getConnector = async (connectorId: string) => authedAdminApi.get(`connectors/${connectorId}`).json(); // FIXME @Darcy: correct use of `id` and `connectorId`. -export const postConnector = async (connectorId: string, metadata?: Record) => +export const postConnector = async ( + payload: Pick +) => authedAdminApi .post({ url: `connectors`, - json: { connectorId, metadata }, + json: payload, }) .json(); diff --git a/packages/integration-tests/src/api/interaction.ts b/packages/integration-tests/src/api/interaction.ts index e71437f19..4280d7101 100644 --- a/packages/integration-tests/src/api/interaction.ts +++ b/packages/integration-tests/src/api/interaction.ts @@ -1,11 +1,4 @@ -import { Event } from '@logto/schemas'; -import type { - IdentifierPayload, - PhonePasswordPayload, - EmailPasswordPayload, - Profile, - UsernamePasswordPayload, -} from '@logto/schemas'; +import type { Event, IdentifierPayload, Profile } from '@logto/schemas'; import api from './api.js'; @@ -19,19 +12,37 @@ export type interactionPayload = { profile?: Profile; }; -export const signInWithPasswordIdentifiers = async ( - identifier: UsernamePasswordPayload | EmailPasswordPayload | PhonePasswordPayload, - cookie: string -) => +export const putInteraction = async (payload: interactionPayload, cookie: string) => api .put('interaction', { - headers: { - cookie, - }, - json: { - event: Event.SignIn, - identifier, - }, + headers: { cookie }, + json: payload, followRedirect: false, }) .json(); + +export const patchInteraction = async (payload: interactionPayload, cookie: string) => + api + .patch('interaction', { + headers: { cookie }, + json: payload, + followRedirect: false, + }) + .json(); + +export type VerificationPasscodePayload = + | { + event: Event; + email: string; + } + | { event: Event; phone: string }; + +export const sendVerificationPasscode = async ( + payload: VerificationPasscodePayload, + cookie: string +) => + api.post('verification/passcode', { + headers: { cookie }, + json: payload, + followRedirect: false, + }); diff --git a/packages/integration-tests/src/tests/api/admin-user.test.ts b/packages/integration-tests/src/tests/api/admin-user.test.ts index 3c7898d17..bf99e4ffb 100644 --- a/packages/integration-tests/src/tests/api/admin-user.test.ts +++ b/packages/integration-tests/src/tests/api/admin-user.test.ts @@ -69,7 +69,8 @@ describe('admin console user management', () => { }); it('should delete user identities successfully', async () => { - const { id } = await postConnector(mockSocialConnectorId); + // @darcy FIXME: merge post and update + const { id } = await postConnector({ connectorId: mockSocialConnectorId }); await updateConnectorConfig(id, mockSocialConnectorConfig); const createdUserId = await bindSocialToNewCreatedUser(id); diff --git a/packages/integration-tests/src/tests/api/connector.test.ts b/packages/integration-tests/src/tests/api/connector.test.ts index bbf5d4208..33ad7b12d 100644 --- a/packages/integration-tests/src/tests/api/connector.test.ts +++ b/packages/integration-tests/src/tests/api/connector.test.ts @@ -45,7 +45,8 @@ test('connector set-up flow', async () => { { connectorId: mockEmailConnectorId, config: mockEmailConnectorConfig }, { connectorId: mockSocialConnectorId, config: mockSocialConnectorConfig }, ].map(async ({ connectorId, config }) => { - const { id } = await postConnector(connectorId); + // @darcy FIXME: should call post method directly + const { id } = await postConnector({ connectorId }); connectorIdMap.set(connectorId, id); const updatedConnector = await updateConnectorConfig(id, config); expect(updatedConnector.config).toEqual(config); @@ -71,12 +72,14 @@ test('connector set-up flow', async () => { /* * Change to another SMS/Email connector */ - const { id } = await postConnector(mockStandardEmailConnectorId, { - target: 'mock-standard-mail', - }); // TODO [LOG-4862]: update mock connector + // @darcy FIXME: should call post method directly + const { id } = await postConnector({ + connectorId: mockStandardEmailConnectorId, + metadata: { target: 'mock-standard-mail' }, + }); await updateConnectorConfig(id, mockStandardEmailConnectorConfig, { target: 'mock-standard-mail', - }); // TODO [LOG-4862]: update mock connector + }); connectorIdMap.set(mockStandardEmailConnectorId, id); const currentConnectors = await listConnectors(); expect( @@ -133,7 +136,7 @@ test('send SMS/email test message', async () => { await Promise.all( [{ connectorId: mockSmsConnectorId }, { connectorId: mockEmailConnectorId }].map( async ({ connectorId }) => { - const { id } = await postConnector(connectorId); + const { id } = await postConnector({ connectorId }); connectorIdMap.set(connectorId, id); } ) diff --git a/packages/integration-tests/src/tests/api/session.test.ts b/packages/integration-tests/src/tests/api/session.test.ts index a581a021b..2b07e0eca 100644 --- a/packages/integration-tests/src/tests/api/session.test.ts +++ b/packages/integration-tests/src/tests/api/session.test.ts @@ -68,11 +68,7 @@ describe('email and password flow', () => { assert(localPart && domain, new Error('Email address local part or domain is empty')); beforeAll(async () => { - const { id } = await postConnector(mockEmailConnectorId); - await updateConnectorConfig(id, mockEmailConnectorConfig); - connectorIdMap.set(mockEmailConnectorId, id); - - await setSignUpIdentifier(signUpIdentifiers.email, true); + await setSignUpIdentifier(signUpIdentifiers.none, true); await setSignInMethod([ { identifier: SignInIdentifier.Email, @@ -109,7 +105,7 @@ describe('email passwordless flow', () => { ); connectorIdMap.clear(); - const { id } = await postConnector(mockEmailConnectorId); + const { id } = await postConnector({ connectorId: mockEmailConnectorId }); await updateConnectorConfig(id, mockEmailConnectorConfig); connectorIdMap.set(mockEmailConnectorId, id); @@ -208,7 +204,7 @@ describe('sms passwordless flow', () => { ); connectorIdMap.clear(); - const { id } = await postConnector(mockSmsConnectorId); + const { id } = await postConnector({ connectorId: mockSmsConnectorId }); await updateConnectorConfig(id, mockSmsConnectorConfig); connectorIdMap.set(mockSmsConnectorId, id); diff --git a/packages/integration-tests/src/tests/api/social-session.test.ts b/packages/integration-tests/src/tests/api/social-session.test.ts index 35e198f90..70e320ba1 100644 --- a/packages/integration-tests/src/tests/api/social-session.test.ts +++ b/packages/integration-tests/src/tests/api/social-session.test.ts @@ -32,7 +32,7 @@ describe('social sign-in and register', () => { const socialUserId = crypto.randomUUID(); beforeAll(async () => { - const { id } = await postConnector(mockSocialConnectorId); + const { id } = await postConnector({ connectorId: mockSocialConnectorId }); connectorIdMap.set(mockSocialConnectorId, id); await updateConnectorConfig(id, mockSocialConnectorConfig); diff --git a/packages/integration-tests/src/tests/interaction/sign-in-with-passcode-identifier.test.ts b/packages/integration-tests/src/tests/interaction/sign-in-with-passcode-identifier.test.ts new file mode 100644 index 000000000..743674a96 --- /dev/null +++ b/packages/integration-tests/src/tests/interaction/sign-in-with-passcode-identifier.test.ts @@ -0,0 +1,213 @@ +import { ConnectorType, Event, SignInIdentifier } from '@logto/schemas'; +import { assert } from '@silverhand/essentials'; + +import { + sendVerificationPasscode, + putInteraction, + patchInteraction, + deleteUser, + updateSignInExperience, +} from '#src/api/index.js'; +import { readPasscode } from '#src/helpers.js'; +import { generateEmail, generatePhone } from '#src/utils.js'; + +import { initClient, processSessionAndLogout } from './utils/client.js'; +import { clearConnectorsByTypes, setEmailConnector, setSmsConnector } from './utils/connector.js'; +import { enableAllPasscodeSignInMethods } from './utils/sign-in-experience.js'; +import { generateNewUser } from './utils/user.js'; + +describe('Sign-In flow using passcode identifiers', () => { + beforeAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); + await setEmailConnector(); + await setSmsConnector(); + await enableAllPasscodeSignInMethods(); + }); + afterAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); + }); + + it('sign-in with email and passcode', async () => { + const { userProfile, user } = await generateNewUser({ primaryEmail: true }); + const client = await initClient(); + assert(client.interactionCookie, new Error('Session not found')); + + await expect( + sendVerificationPasscode( + { + event: Event.SignIn, + email: userProfile.primaryEmail, + }, + client.interactionCookie + ) + ).resolves.not.toThrow(); + + const passcodeRecord = await readPasscode(); + + expect(passcodeRecord).toMatchObject({ + address: userProfile.primaryEmail, + type: Event.SignIn, + }); + + const { code } = passcodeRecord; + + const { redirectTo } = await putInteraction( + { + event: Event.SignIn, + identifier: { + email: userProfile.primaryEmail, + passcode: code, + }, + }, + client.interactionCookie + ); + + await processSessionAndLogout(client, redirectTo); + + await deleteUser(user.id); + }); + + it('sign-in with phone and passcode', async () => { + const { userProfile, user } = await generateNewUser({ primaryPhone: true }); + const client = await initClient(); + assert(client.interactionCookie, new Error('Session not found')); + + await expect( + sendVerificationPasscode( + { + event: Event.SignIn, + phone: userProfile.primaryPhone, + }, + client.interactionCookie + ) + ).resolves.not.toThrow(); + + const passcodeRecord = await readPasscode(); + + expect(passcodeRecord).toMatchObject({ + phone: userProfile.primaryPhone, + type: Event.SignIn, + }); + + const { code } = passcodeRecord; + + const { redirectTo } = await putInteraction( + { + event: Event.SignIn, + identifier: { + phone: userProfile.primaryPhone, + passcode: code, + }, + }, + client.interactionCookie + ); + + await processSessionAndLogout(client, redirectTo); + + await deleteUser(user.id); + }); + + it('sign-in with non-exist email account with passcode', async () => { + const newEmail = generateEmail(); + + // Enable email sign-up + await updateSignInExperience({ + signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true }, + }); + + const client = await initClient(); + assert(client.interactionCookie, new Error('Session not found')); + + await expect( + sendVerificationPasscode( + { + event: Event.SignIn, + email: newEmail, + }, + client.interactionCookie + ) + ).resolves.not.toThrow(); + + const passcodeRecord = await readPasscode(); + + const { code } = passcodeRecord; + + // TODO: @simeng use expectRequestError after https://github.com/logto-io/logto/pull/2639/ PR merged + await expect( + putInteraction( + { + event: Event.SignIn, + identifier: { + email: newEmail, + passcode: code, + }, + }, + client.interactionCookie + ) + ).rejects.toThrow(); + + const { redirectTo } = await patchInteraction( + { + event: Event.Register, + profile: { + email: newEmail, + }, + }, + client.interactionCookie + ); + + await processSessionAndLogout(client, redirectTo); + }); + + it('sign-in with non-exist phone account with passcode', async () => { + const newPhone = generatePhone(); + + // Enable phone sign-up + await updateSignInExperience({ + signUp: { identifiers: [SignInIdentifier.Sms], password: false, verify: true }, + }); + + const client = await initClient(); + assert(client.interactionCookie, new Error('Session not found')); + + await expect( + sendVerificationPasscode( + { + event: Event.SignIn, + phone: newPhone, + }, + client.interactionCookie + ) + ).resolves.not.toThrow(); + + const passcodeRecord = await readPasscode(); + + const { code } = passcodeRecord; + + // TODO: @simeng use expectRequestError after https://github.com/logto-io/logto/pull/2639/ PR merged + await expect( + putInteraction( + { + event: Event.SignIn, + identifier: { + phone: newPhone, + passcode: code, + }, + }, + client.interactionCookie + ) + ).rejects.toThrow(); + + const { redirectTo } = await patchInteraction( + { + event: Event.Register, + profile: { + phone: newPhone, + }, + }, + client.interactionCookie + ); + + await processSessionAndLogout(client, redirectTo); + }); +}); diff --git a/packages/integration-tests/src/tests/interaction/sign-in-with-password-identifier.test.ts b/packages/integration-tests/src/tests/interaction/sign-in-with-password-identifier.test.ts new file mode 100644 index 000000000..f01da415f --- /dev/null +++ b/packages/integration-tests/src/tests/interaction/sign-in-with-password-identifier.test.ts @@ -0,0 +1,98 @@ +import { Event } from '@logto/schemas'; +import { assert } from '@silverhand/essentials'; + +import { putInteraction, deleteUser } from '#src/api/index.js'; +import MockClient from '#src/client/index.js'; + +import { enableAllPasswordSignInMethods } from './utils/sign-in-experience.js'; +import { generateNewUser } from './utils/user.js'; + +describe('Sign-In flow using password identifiers', () => { + beforeAll(async () => { + await enableAllPasswordSignInMethods(); + }); + + it('sign-in with username and password', async () => { + const { userProfile, user } = await generateNewUser({ username: true }); + const client = new MockClient(); + await client.initSession(); + assert(client.interactionCookie, new Error('Session not found')); + + const { redirectTo } = await putInteraction( + { + event: Event.SignIn, + identifier: { + username: userProfile.username, + password: userProfile.password, + }, + }, + client.interactionCookie + ); + + await client.processSession(redirectTo); + + await expect(client.isAuthenticated()).resolves.toBe(true); + + await client.signOut(); + + await expect(client.isAuthenticated()).resolves.toBe(false); + + await deleteUser(user.id); + }); + + it('sign-in with email and password', async () => { + const { userProfile, user } = await generateNewUser({ primaryEmail: true }); + const client = new MockClient(); + await client.initSession(); + assert(client.interactionCookie, new Error('Session not found')); + + const { redirectTo } = await putInteraction( + { + event: Event.SignIn, + identifier: { + email: userProfile.primaryEmail, + password: userProfile.password, + }, + }, + client.interactionCookie + ); + + await client.processSession(redirectTo); + + await expect(client.isAuthenticated()).resolves.toBe(true); + + await client.signOut(); + + await expect(client.isAuthenticated()).resolves.toBe(false); + + await deleteUser(user.id); + }); + + it('sign-in with phone and password', async () => { + const { userProfile, user } = await generateNewUser({ primaryPhone: true }); + const client = new MockClient(); + await client.initSession(); + assert(client.interactionCookie, new Error('Session not found')); + + const { redirectTo } = await putInteraction( + { + event: Event.SignIn, + identifier: { + phone: userProfile.primaryPhone, + password: userProfile.password, + }, + }, + client.interactionCookie + ); + + await client.processSession(redirectTo); + + await expect(client.isAuthenticated()).resolves.toBe(true); + + await client.signOut(); + + await expect(client.isAuthenticated()).resolves.toBe(false); + + await deleteUser(user.id); + }); +}); diff --git a/packages/integration-tests/src/tests/interaction/utils/client.ts b/packages/integration-tests/src/tests/interaction/utils/client.ts new file mode 100644 index 000000000..19c24261f --- /dev/null +++ b/packages/integration-tests/src/tests/interaction/utils/client.ts @@ -0,0 +1,18 @@ +import MockClient from '#src/client/index.js'; + +export const initClient = async () => { + const client = new MockClient(); + await client.initSession(); + + return client; +}; + +export const processSessionAndLogout = async (client: MockClient, redirectTo: string) => { + await client.processSession(redirectTo); + + await expect(client.isAuthenticated()).resolves.toBe(true); + + await client.signOut(); + + await expect(client.isAuthenticated()).resolves.toBe(false); +}; diff --git a/packages/integration-tests/src/tests/interaction/utils/connector.ts b/packages/integration-tests/src/tests/interaction/utils/connector.ts new file mode 100644 index 000000000..25b484f4c --- /dev/null +++ b/packages/integration-tests/src/tests/interaction/utils/connector.ts @@ -0,0 +1,27 @@ +import type { ConnectorType } from '@logto/schemas'; + +import { + mockEmailConnectorConfig, + mockEmailConnectorId, + mockSmsConnectorConfig, + mockSmsConnectorId, +} from '#src/__mocks__/connectors-mock.js'; +import { listConnectors, deleteConnectorById, postConnector } from '#src/api/index.js'; + +export const clearConnectorsByTypes = async (types: ConnectorType[]) => { + const connectors = await listConnectors(); + const targetConnectors = connectors.filter((connector) => types.includes(connector.type)); + await Promise.all(targetConnectors.map(async (connector) => deleteConnectorById(connector.id))); +}; + +export const setEmailConnector = async () => + postConnector({ + connectorId: mockEmailConnectorId, + config: mockEmailConnectorConfig, + }); + +export const setSmsConnector = async () => + postConnector({ + connectorId: mockSmsConnectorId, + config: mockSmsConnectorConfig, + }); diff --git a/packages/integration-tests/src/tests/interaction/utils/sign-in-experience.ts b/packages/integration-tests/src/tests/interaction/utils/sign-in-experience.ts new file mode 100644 index 000000000..e7178e951 --- /dev/null +++ b/packages/integration-tests/src/tests/interaction/utils/sign-in-experience.ts @@ -0,0 +1,74 @@ +import type { SignInExperience } from '@logto/schemas'; +import { SignInMode, SignInIdentifier } from '@logto/schemas'; + +import { updateSignInExperience } from '#src/api/index.js'; + +const defaultSignUpMethod = { + identifiers: [], + password: false, + verify: false, +}; + +const defaultPasswordSignInMethods = [ + { + identifier: SignInIdentifier.Username, + password: true, + verificationCode: false, + isPasswordPrimary: false, + }, + { + identifier: SignInIdentifier.Email, + password: true, + verificationCode: false, + isPasswordPrimary: false, + }, + { + identifier: SignInIdentifier.Sms, + password: true, + verificationCode: false, + isPasswordPrimary: false, + }, +]; + +const defaultPasscodeSignInMethods = [ + { + identifier: SignInIdentifier.Username, + password: true, + verificationCode: false, + isPasswordPrimary: false, + }, + { + identifier: SignInIdentifier.Email, + password: true, + verificationCode: true, + isPasswordPrimary: false, + }, + { + identifier: SignInIdentifier.Sms, + password: true, + verificationCode: true, + isPasswordPrimary: false, + }, +]; + +export const enableAllPasswordSignInMethods = async ( + signUp: SignInExperience['signUp'] = defaultSignUpMethod +) => + updateSignInExperience({ + signInMode: SignInMode.SignInAndRegister, + signUp, + signIn: { + methods: defaultPasswordSignInMethods, + }, + }); + +export const enableAllPasscodeSignInMethods = async ( + signUp: SignInExperience['signUp'] = defaultSignUpMethod +) => + updateSignInExperience({ + signInMode: SignInMode.SignInAndRegister, + signUp, + signIn: { + methods: defaultPasscodeSignInMethods, + }, + }); diff --git a/packages/integration-tests/src/tests/interaction/utils/user.ts b/packages/integration-tests/src/tests/interaction/utils/user.ts new file mode 100644 index 000000000..92f3b4d96 --- /dev/null +++ b/packages/integration-tests/src/tests/interaction/utils/user.ts @@ -0,0 +1,40 @@ +import { createUser } from '#src/api/index.js'; +import { + generateUsername, + generateEmail, + generatePhone, + generatePassword, + generateName, +} from '#src/utils.js'; + +export type NewUserProfileOptions = { + username?: true; + primaryEmail?: true; + primaryPhone?: true; +}; + +export const generateNewUser = async ({ + username, + primaryEmail, + primaryPhone, +}: T) => { + type UserProfile = { + password: string; + name: string; + } & { + [K in keyof T]: T[K] extends true ? string : never; + }; + + // @ts-expect-error - TS can't map the type of userProfile to the UserProfile defined above + const userProfile: UserProfile = { + password: generatePassword(), + name: generateName(), + ...(username ? { username: generateUsername() } : {}), + ...(primaryEmail ? { primaryEmail: generateEmail() } : {}), + ...(primaryPhone ? { primaryPhone: generatePhone() } : {}), + }; + + const user = await createUser(userProfile); + + return { user, userProfile }; +};