import gql from 'graphql-tag'; import { execute, signIn, signUp, startSession } from 'test/graphql'; import { REFRESH_TOKEN_COOKIE_NAME } from '@/constants'; import { User } from '@/entities'; import TransactionalEmailSender from '../../services/TransactionalEmailSender/TransactionalEmailSender'; jest.mock('../../services/s3'); const email = 'test_user@example.com'; const username = 'test_user'; const password = 'K$D4@i$HbkNNDmm!'; // must use a secure password let mockSetCookie: jest.Mock; let mockClearCookie: jest.Mock; beforeEach(async () => { mockSetCookie = jest.fn(); mockClearCookie = jest.fn(); }); afterEach(() => { jest.restoreAllMocks(); }); const logout = (accessToken?: string) => { return execute( { query: gql` mutation { logout { success } } `, variables: {}, }, { accessToken }, { res: { cookie: mockSetCookie, clearCookie: mockClearCookie, }, } ); }; const signUpMutation = gql` mutation ($input: SignUpInput!) { signUp(input: $input) { accessToken user { id isVerified } } } `; describe('Mutation:signUp', () => { test('can sign up when no user has signed up', async () => { const emailSendMock = jest.spyOn( TransactionalEmailSender.prototype, 'send' ); const { data, errors } = await execute({ query: signUpMutation, variables: { input: { username, password, email } }, }); expect(errors?.length).toBeFalsy(); expect(typeof data?.signUp?.accessToken).toBe('string'); const userId = data?.signUp?.user?.id; const user = await User.findOne( { id: userId }, { relations: ['passwordAuth'] } ); expect(user).toBeDefined(); expect(user?.id).toBe(userId); expect(user?.username).toBe(username); expect(user?.passwordAuth?.email).toBe(email); expect(user?.passwordAuth?.isVerified).toBe(false); // sent a verification code email expect(emailSendMock).toHaveBeenCalledTimes(1); expect(emailSendMock.mock.calls[0][0]).toMatchObject({ to: user?.passwordAuth?.email, subject: expect.stringMatching(/verification code/gi), }); }); test('cannot sign up if the password is weak', async () => { const { errors } = await signUp({ username, password: 'weak', email, }); expect(errors?.length).toBe(1); expect(errors?.[0].message).toMatchInlineSnapshot( `"Password is too easily guessable"` ); }); test('can not sign up no username', async () => { const { errors } = await execute({ query: signUpMutation, variables: { input: { email, password, }, }, }); expect(errors?.length).toBe(1); expect(errors?.[0]?.message).toMatchInlineSnapshot( `"Variable \\"$input\\" got invalid value { email: \\"test_user@example.com\\", password: \\"K$D4@i$HbkNNDmm!\\" }; Field \\"username\\" of required type \\"String!\\" was not provided."` ); }); test('cannot sign up with no email', async () => { const { errors } = await execute({ query: signUpMutation, variables: { input: { username, password, }, }, }); expect(errors?.length).toBe(1); expect(errors?.[0].message).toMatchInlineSnapshot( `"Variable \\"$input\\" got invalid value { username: \\"test_user\\", password: \\"K$D4@i$HbkNNDmm!\\" }; Field \\"email\\" of required type \\"String!\\" was not provided."` ); }); test('cannot sign up if already signed up with email', async () => { await signUp({ email, username, password }); const { errors } = await signUp({ username: 'newusername', password, email, }); expect(errors?.length).toBe(1); expect(errors?.[0].message).toMatchInlineSnapshot( `"Invalid sign up: test_user@example.com or newusername already taken"` ); }); test('cannot sign up if already signed up with email even without username', async () => { await signUp({ email, password }); const { errors } = await signUp({ password, email, username, }); expect(errors?.length).toBe(1); expect(errors?.[0].message).toMatchInlineSnapshot( `"Invalid sign up: test_user@example.com or test_user already taken"` ); }); test('cannot sign up if already signed in', async () => { const { execute } = await startSession({ username, password, email, }); const { errors } = await execute({ query: signUpMutation, variables: { input: { username, email, password, }, }, }); expect(errors?.length).toBe(1); expect(errors?.[0].message).toMatchInlineSnapshot( `"Cannot sign up if already signed in"` ); }); it('signing up with agree to be contacted is persisted', async () => { const { data, errors } = await execute({ query: signUpMutation, variables: { input: { email, username, password, }, }, }); expect(errors?.length).toBeFalsy(); const userId = data?.signUp?.user?.id; const user = await User.find({ id: userId }); expect(user.length).toBe(1); }); }); describe('Mutation:signIn', () => { test('can sign in after signing up', async () => { await signUp({ email, username, password }); const { errors, data } = await signIn({ username, password }); expect(errors?.length).toBeFalsy(); const userId = data?.signIn?.user?.id; expect(typeof data?.signIn?.accessToken).toBe('string'); const user = await User.find({ id: userId }); expect(user.length).toBe(1); }); test('cannot sign in if using incorrect password', async () => { await signUp({ email, username, password }); const { errors, data } = await signIn({ username, password: 'wrongpassword', }); expect(data).toBeFalsy(); expect(errors?.length).toBe(1); expect(errors?.[0].message).toMatchInlineSnapshot( `"Invalid Username/Password"` ); }); test('cannot sign in if no one has signed up', async () => { const { errors } = await signIn({ username, password }); expect(errors?.length).toBe(1); expect(errors?.[0].message).toMatchInlineSnapshot( `"Invalid Username/Password"` ); }); test('cannot sign in if already signed in', async () => { const { execute } = await startSession({ username, password, email, }); const { errors } = await execute({ query: gql` mutation ($input: SignInInput!) { signIn(input: $input) { accessToken user { id } } } `, variables: { input: { username, password, }, }, }); expect(errors?.length).toBe(1); expect(errors?.[0].message).toMatchInlineSnapshot( `"Cannot sign in if already signed in"` ); }); }); describe('Mutation:logout', () => { test('logging out is successful', async () => { const { context } = await startSession(); const { errors, data } = await logout(context?.accessToken); expect(errors?.length).toBeFalsy(); expect(data?.logout?.success).toBe(true); }); test('cannot logout if not signed in', async () => { const { errors, data } = await logout(); expect(errors?.length).toBe(1); expect(errors?.[0].message).toMatchInlineSnapshot( `"Cannot sign out if not signed in"` ); expect(data).toBeFalsy(); }); }); describe('JWT Tokens', () => { const signUpWithSetCookieMock = async () => { return execute( { query: signUpMutation, variables: { input: { email, username, password, }, }, }, undefined, { res: { cookie: mockSetCookie, clearCookie: mockClearCookie, }, } ); }; const getMe = async (accessToken?: string, refreshToken?: string) => { return execute( { query: gql` query { me { id username } } `, }, { accessToken, refreshToken, }, { res: { cookie: mockSetCookie, clearCookie: mockClearCookie, }, } ); }; test('cannot access data from query if not using access token', async () => { const { data } = await signUpWithSetCookieMock(); const { accessToken } = data?.signUp; expect(accessToken).toBeTruthy(); const { errors } = await getMe(); expect(errors?.length).toBe(1); expect(errors?.[0]?.message).toMatchInlineSnapshot( `"You are not authorized to access this."` ); }); test('fails with invalid access token', async () => { await signUpWithSetCookieMock(); const { errors } = await getMe('invalid-access-token'); expect(errors?.length).toBe(1); expect(errors?.[0]?.message).toMatchInlineSnapshot( `"Access token was invalid"` ); }); test('access token provided during signUp mutation can be used to run other queries', async () => { const { data } = await signUpWithSetCookieMock(); const { accessToken } = data?.signUp; expect(accessToken).toBeTruthy(); const { data: meData, errors } = await getMe(accessToken); expect(errors?.length).toBeFalsy(); expect(meData?.me?.id).toBe(data?.signUp?.user.id); expect(meData?.me?.username).toBe(username); // ensure we're getting a new refreshToken }); test('can provide just a refresh token to run other queries', async () => { const { data: signUpData } = await signUpWithSetCookieMock(); expect(mockSetCookie).toHaveBeenCalledTimes(1); // arguments const setCookieArgs = mockSetCookie.mock.calls[0]; expect(setCookieArgs[0]).toBe(REFRESH_TOKEN_COOKIE_NAME); const refreshToken = setCookieArgs[1]; expect(typeof refreshToken).toBe('string'); const { data: meData, errors } = await getMe(undefined, refreshToken); expect(errors?.length).toBeFalsy(); expect(meData?.me?.id).toBe(signUpData?.signUp?.user?.id); }); test('fails with invalid refresh token', async () => { await signUpWithSetCookieMock(); const { errors } = await getMe(undefined, 'invalid-refresh-token'); expect(errors?.length).toBe(1); expect(errors?.[0]?.message).toMatchInlineSnapshot( `"Invalid refresh token"` ); }); test('fails with invalid access token and valid refresh token', async () => { await signUpWithSetCookieMock(); const setCookieArgs = mockSetCookie.mock.calls[0]; expect(setCookieArgs[0]).toBe(REFRESH_TOKEN_COOKIE_NAME); const refreshToken = setCookieArgs[1]; const { errors } = await getMe('invalid-acces-token', refreshToken); expect(errors?.length).toBe(1); expect(errors?.[0]?.message).toMatchInlineSnapshot( `"Access token was invalid"` ); }); test('provides a new refresh token on each request', async () => { const { data } = await signUpWithSetCookieMock(); expect(mockSetCookie).toHaveBeenCalledTimes(1); const setCookieArgs = mockSetCookie.mock.calls[0]; expect(setCookieArgs[0]).toBe(REFRESH_TOKEN_COOKIE_NAME); const originalRefreshToken = setCookieArgs[1]; // need to wait briefly to ensure we get a different value await new Promise((resolve) => setTimeout(resolve, 1000)); const { errors, data: meData } = await getMe( data?.signUp?.accessToken, originalRefreshToken ); expect(mockSetCookie).toHaveBeenCalledTimes(2); const nextSetCookieArgs = mockSetCookie.mock.calls[1]; const nextRefreshToken = nextSetCookieArgs[1]; expect(typeof nextRefreshToken).toBe('string'); /** * This makes sure the refresh tokens are different * @NOTE: {@link AccessTokenManager#shouldGrantNewRefreshToken} controls * if new access tokens are granted each time. If they aren't, then this * test will fail. If a different strategy is tried, then this can be * tested via jest timeouts */ expect(originalRefreshToken).not.toBe(nextRefreshToken); expect(errors?.length).toBeFalsy(); expect(meData).toBeTruthy(); // double-checks we can use next refresh token const { errors: secondQueryErrors } = await getMe( undefined, nextRefreshToken ); expect(secondQueryErrors?.length).toBeFalsy(); }); test('removes refresh token when logging out', async () => { const { data } = await signUpWithSetCookieMock(); expect(mockSetCookie).toHaveBeenCalledTimes(1); // arguments const setCookieArgs = mockSetCookie.mock.calls[0]; expect(setCookieArgs[0]).toBe(REFRESH_TOKEN_COOKIE_NAME); const refreshToken = setCookieArgs[1]; expect(typeof refreshToken).toBe('string'); expect(refreshToken.length).toBeGreaterThan(0); const accessToken = data?.signUp?.accessToken; expect(accessToken?.length).toBeGreaterThan(0); const { errors } = await logout(accessToken); expect(errors?.length).toBeFalsy(); expect(mockSetCookie).toHaveBeenCalledTimes(1); expect(mockClearCookie).toHaveBeenCalledTimes(1); const clearCookieArgs = mockClearCookie.mock.calls[0]; expect(clearCookieArgs[0]).toBe(REFRESH_TOKEN_COOKIE_NAME); }); });