diff --git a/packages/core/src/lib/social.ts b/packages/core/src/lib/social.ts index ffe79ddab..4533516bc 100644 --- a/packages/core/src/lib/social.ts +++ b/packages/core/src/lib/social.ts @@ -46,7 +46,7 @@ export const getUserInfoByAuthCode = async ( export const getUserInfoFromInteractionResult = async ( connectorId: string, - interactionResult?: InteractionResults + interactionResult: InteractionResults ): Promise => { const parse = z .object({ diff --git a/packages/core/src/routes/session.test.ts b/packages/core/src/routes/session.test.ts index dbe66822a..7b549af48 100644 --- a/packages/core/src/routes/session.test.ts +++ b/packages/core/src/routes/session.test.ts @@ -5,38 +5,326 @@ import { createRequester } from '@/utils/test-utils'; import sessionRoutes from './session'; jest.mock('oidc-provider'); +jest.mock('@/lib/user', () => ({ + async findUserByUsernameAndPassword(username: string) { + if (username === 'notexistuser') { + throw new Error(' '); + } + + return { id: 'user1' }; + }, +})); +jest.mock('@/lib/social', () => ({ + ...jest.requireActual('@/lib/social'), + async findSocialRelatedUser() { + return ['phone', { id: 'user1', identities: {} }]; + }, +})); +const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); +jest.mock('@/queries/user', () => ({ + findUserById: async () => ({ id: 'id ' }), + findUserByPhone: async () => ({ id: 'id ' }), + findUserByEmail: async () => ({ id: 'id ' }), + updateUserById: async (...args: unknown[]) => updateUserById(...args), + hasUserWithPhone: async (phone: string) => phone === '13000000000', + hasUserWithEmail: async (email: string) => email === 'a@a.com', +})); +const sendPasscode = jest.fn(); +jest.mock('@/lib/passcode', () => ({ + createPasscode: async () => ({ id: 'id' }), + sendPasscode: () => { + sendPasscode(); + }, + verifyPasscode: async (_a: unknown, _b: unknown, code: string) => { + if (code !== '1234') { + throw new Error(' '); + } + }, +})); + const MockedProvider = Provider as jest.MockedClass; +const getProvider = (): Provider => { + const provider = MockedProvider.mock.instances[0]; + + if (!provider) { + throw new Error('Provider is not initialized'); + } + + return provider; +}; + +const getInteractionDetails = () => { + return getProvider().interactionDetails as unknown as jest.MockedFunction<() => Promise>; +}; + describe('sessionRoutes', () => { const sessionRequest = createRequester({ anonymousRoutes: sessionRoutes, provider: new Provider(''), + middlewares: [ + async (ctx, next) => { + ctx.userLog = {}; + + return next(); + }, + ], }); afterAll(() => jest.clearAllMocks()); - it('POST /session with consent prompt name', async () => { - ( - MockedProvider.mock.instances[0]?.interactionDetails as unknown as jest.MockedFunction< - () => Promise<{ prompt: { name: string } }> - > - ).mockResolvedValue({ - prompt: { name: 'consent' }, - }); - const response = await sessionRequest.post('/session'); + beforeAll(() => { + const provider = getProvider(); + const { interactionResult } = provider; - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('redirectTo', expect.stringContaining('/session/consent')); + (interactionResult as jest.MockedFunction).mockResolvedValue( + 'redirectTo' + ); }); - it('POST /session with invalid prompt name', async () => { - ( - MockedProvider.mock.instances[0]?.interactionDetails as unknown as jest.MockedFunction< - () => Promise<{ prompt: { name: string } }> - > - ).mockResolvedValue({ - prompt: { name: 'invalid' }, + describe('POST /session', () => { + it('should redirect to /session/consent with consent prompt name', async () => { + getInteractionDetails().mockResolvedValue({ + prompt: { name: 'consent' }, + }); + const response = await sessionRequest.post('/session'); + + expect(response.statusCode).toEqual(200); + expect(response.body).toHaveProperty( + 'redirectTo', + expect.stringContaining('/session/consent') + ); }); - await expect(sessionRequest.post('/session').send({})).resolves.toHaveProperty('status', 400); + + it('throw error with other prompt name', async () => { + getInteractionDetails().mockResolvedValue({ + prompt: { name: 'invalid' }, + }); + await expect(sessionRequest.post('/session').send({})).resolves.toHaveProperty('status', 400); + }); + }); + + describe('POST /session/sign-in/username-password', () => { + it('assign result and redirect', async () => { + const response = await sessionRequest.post('/session/sign-in/username-password').send({ + username: 'username', + password: 'password', + }); + expect(response.statusCode).toEqual(200); + expect(response.body).toHaveProperty('redirectTo'); + expect(getProvider().interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ login: { accountId: 'user1' } }), + expect.anything() + ); + }); + + it('throw if user not found', async () => { + const response = await sessionRequest.post('/session/sign-in/username-password').send({ + username: 'notexistuser', + password: 'password', + }); + expect(response.statusCode).toEqual(500); + }); + }); + + describe('POST /session/sign-in/passwordless/phone/send-passcode', () => { + beforeAll(() => { + getInteractionDetails().mockResolvedValue({ + jti: 'jti', + }); + }); + it('call sendPasscode', async () => { + const response = await sessionRequest + .post('/session/sign-in/passwordless/phone/send-passcode') + .send({ phone: '13000000000' }); + expect(response.statusCode).toEqual(204); + expect(sendPasscode).toHaveBeenCalled(); + }); + it('throw error if phone not exists', async () => { + const response = await sessionRequest + .post('/session/sign-in/passwordless/phone/send-passcode') + .send({ phone: '13000000001' }); + expect(response.statusCode).toEqual(422); + }); + }); + + describe('POST /session/sign-in/passwordless/phone/verify-passcode', () => { + it('assign result and redirect', async () => { + const response = await sessionRequest + .post('/session/sign-in/passwordless/phone/verify-passcode') + .send({ phone: '13000000000', code: '1234' }); + expect(response.statusCode).toEqual(200); + expect(response.body).toHaveProperty('redirectTo'); + expect(getProvider().interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ login: { accountId: 'user1' } }), + expect.anything() + ); + }); + it('throw error if phone not exists', async () => { + const response = await sessionRequest + .post('/session/sign-in/passwordless/phone/send-passcode') + .send({ phone: '13000000001' }); + expect(response.statusCode).toEqual(422); + }); + it('throw error if verifyPasscode failed', async () => { + const response = await sessionRequest + .post('/session/sign-in/passwordless/phone/verify-passcode') + .send({ phone: '13000000000', code: '1231' }); + expect(response.statusCode).toEqual(500); + }); + }); + + describe('POST /session/sign-in/passwordless/email/send-passcode', () => { + beforeAll(() => { + getInteractionDetails().mockResolvedValue({ + jti: 'jti', + }); + }); + it('call sendPasscode', async () => { + const response = await sessionRequest + .post('/session/sign-in/passwordless/email/send-passcode') + .send({ email: 'a@a.com' }); + expect(response.statusCode).toEqual(204); + expect(sendPasscode).toHaveBeenCalled(); + }); + it('throw error if email not exists', async () => { + const response = await sessionRequest + .post('/session/sign-in/passwordless/email/send-passcode') + .send({ email: 'b@a.com' }); + expect(response.statusCode).toEqual(422); + }); + }); + + describe('POST /session/sign-in/passwordless/email/verify-passcode', () => { + it('assign result and redirect', async () => { + const response = await sessionRequest + .post('/session/sign-in/passwordless/email/verify-passcode') + .send({ email: 'a@a.com', code: '1234' }); + expect(response.statusCode).toEqual(200); + expect(response.body).toHaveProperty('redirectTo'); + expect(getProvider().interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ login: { accountId: 'user1' } }), + expect.anything() + ); + }); + it('throw error if verifyPasscode failed', async () => { + const response = await sessionRequest + .post('/session/sign-in/passwordless/email/verify-passcode') + .send({ email: 'a@a.com', code: '1231' }); + expect(response.statusCode).toEqual(500); + }); + }); + + describe('POST /session/sign-in/bind-social-related-user', () => { + it('throw if session is not authorized', async () => { + await expect( + sessionRequest + .post('/session/sign-in/bind-social-related-user') + .send({ connectorId: 'connectorId' }) + ).resolves.toHaveProperty('statusCode', 400); + }); + it('throw if no social info in session', async () => { + getInteractionDetails().mockResolvedValue({ + result: { login: { accountId: 'user1' } }, + }); + await expect( + sessionRequest + .post('/session/sign-in/bind-social-related-user') + .send({ connectorId: 'connectorId' }) + ).resolves.toHaveProperty('statusCode', 400); + }); + it('updates user identities and sign in', async () => { + getInteractionDetails().mockResolvedValue({ + result: { + login: { accountId: 'user1' }, + socialUserInfo: { + connectorId: 'connectorId', + userInfo: { id: 'connectorUser', phone: 'phone' }, + }, + }, + }); + const response = await sessionRequest.post('/session/sign-in/bind-social-related-user').send({ + connectorId: 'connectorId', + }); + expect(response.statusCode).toEqual(200); + expect(updateUserById).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + identities: { + connectorId: { + details: { id: 'connectorUser', phone: 'phone' }, + userId: 'connectorUser', + }, + }, + }) + ); + expect(response.body).toHaveProperty('redirectTo'); + expect(getProvider().interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ login: { accountId: 'user1' } }), + expect.anything() + ); + }); + }); + + describe('POST /session/bind-social', () => { + it('throw if session is not authorized', async () => { + getInteractionDetails().mockResolvedValue({}); + await expect( + sessionRequest.post('/session/bind-social').send({ connectorId: 'connectorId' }) + ).resolves.toHaveProperty('statusCode', 400); + }); + it('throw if no social info in session', async () => { + getInteractionDetails().mockResolvedValue({ + result: { login: { accountId: 'user1' } }, + }); + await expect( + sessionRequest.post('/session/bind-social').send({ connectorId: 'connectorId' }) + ).resolves.toHaveProperty('statusCode', 400); + }); + it('updates user identities', async () => { + getInteractionDetails().mockResolvedValue({ + result: { + login: { accountId: 'user1' }, + socialUserInfo: { + connectorId: 'connectorId', + userInfo: { id: 'connectorUser', phone: 'phone' }, + }, + }, + }); + const response = await sessionRequest.post('/session/bind-social').send({ + connectorId: 'connectorId', + }); + expect(response.statusCode).toEqual(200); + expect(updateUserById).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + identities: { + connectorId: { + details: { id: 'connectorUser', phone: 'phone' }, + userId: 'connectorUser', + }, + }, + }) + ); + }); + }); + + it('DELETE /session', async () => { + const response = await sessionRequest.delete('/session'); + expect(response.body).toHaveProperty('redirectTo'); + expect(getProvider().interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ error: 'oidc.aborted' }), + expect.anything() + ); }); }); diff --git a/packages/core/src/routes/session.ts b/packages/core/src/routes/session.ts index a1887831e..e6e4224a3 100644 --- a/packages/core/src/routes/session.ts +++ b/packages/core/src/routes/session.ts @@ -85,7 +85,7 @@ export default function sessionRoutes(router: T, prov const passcode = await createPasscode(jti, PasscodeType.SignIn, { phone }); await sendPasscode(passcode); - ctx.state = 204; + ctx.status = 204; return next(); } @@ -131,7 +131,7 @@ export default function sessionRoutes(router: T, prov const passcode = await createPasscode(jti, PasscodeType.SignIn, { email }); await sendPasscode(passcode); - ctx.state = 204; + ctx.status = 204; return next(); } @@ -214,7 +214,7 @@ export default function sessionRoutes(router: T, prov ); router.post( - '/session/sign-in/bind-social-related-user-and-sign-in', + '/session/sign-in/bind-social-related-user', koaGuard({ body: object({ connectorId: string() }), }), diff --git a/packages/core/src/utils/test-utils.ts b/packages/core/src/utils/test-utils.ts index 810cc8443..3571c5e88 100644 --- a/packages/core/src/utils/test-utils.ts +++ b/packages/core/src/utils/test-utils.ts @@ -1,5 +1,5 @@ import { createMockContext, Options } from '@shopify/jest-koa-mocks'; -import Koa, { MiddlewareType, Context } from 'koa'; +import Koa, { MiddlewareType, Context, Middleware } from 'koa'; import Router, { IRouterParamContext } from 'koa-router'; import { Provider } from 'oidc-provider'; import { createMockPool, createMockQueryResult, QueryResultRowType } from 'slonik'; @@ -72,12 +72,14 @@ export function createRequester( | { anonymousRoutes?: RouteLauncher | Array>; authedRoutes?: RouteLauncher | Array>; + middlewares?: Middleware[]; } | { anonymousRoutes?: | ProviderRouteLauncher | Array>; authedRoutes?: RouteLauncher | Array>; + middlewares?: Middleware[]; provider: Provider; } ): request.SuperTest; @@ -86,6 +88,7 @@ export function createRequester({ anonymousRoutes, authedRoutes, provider, + middlewares, }: { anonymousRoutes?: | RouteLauncher @@ -94,9 +97,16 @@ export function createRequester({ | Array>; authedRoutes?: RouteLauncher | Array>; provider?: Provider; + middlewares?: Middleware[]; }): request.SuperTest { const app = new Koa(); + if (middlewares) { + for (const middleware of middlewares) { + app.use(middleware); + } + } + if (anonymousRoutes) { const anonymousRouter: AnonymousRouter = new Router();