From 1a1af83375d1e75ab0201c3d21508a8a4a36ef17 Mon Sep 17 00:00:00 2001 From: forehalo Date: Tue, 26 Mar 2024 02:24:17 +0000 Subject: [PATCH] test(server): auth tests (#6135) --- .../server/src/core/auth/controller.ts | 28 +- .../backend/server/src/core/auth/index.ts | 4 +- .../backend/server/src/core/auth/resolver.ts | 6 +- .../backend/server/src/core/auth/service.ts | 76 ++-- .../backend/server/src/core/auth/token.ts | 12 + .../server/src/fundamentals/config/def.ts | 6 - .../server/src/fundamentals/prisma/index.ts | 8 +- .../server/src/fundamentals/prisma/service.ts | 5 +- .../server/src/plugins/oauth/controller.ts | 2 +- .../server/src/plugins/oauth/register.ts | 2 +- .../server/tests/auth/controller.spec.ts | 164 +++++++++ .../backend/server/tests/auth/guard.spec.ts | 131 +++++++ .../backend/server/tests/auth/service.spec.ts | 219 +++++++++++ .../backend/server/tests/auth/token.spec.ts | 93 +++++ .../server/tests/oauth/controller.spec.ts | 345 ++++++++++++++++++ packages/backend/server/tests/utils/user.ts | 29 +- packages/backend/server/tests/utils/utils.ts | 1 + .../server/tests/workspace-invite.e2e.ts | 11 +- packages/frontend/templates/templates.gen.ts | 12 +- 19 files changed, 1058 insertions(+), 96 deletions(-) create mode 100644 packages/backend/server/tests/auth/controller.spec.ts create mode 100644 packages/backend/server/tests/auth/guard.spec.ts create mode 100644 packages/backend/server/tests/auth/service.spec.ts create mode 100644 packages/backend/server/tests/auth/token.spec.ts create mode 100644 packages/backend/server/tests/oauth/controller.spec.ts diff --git a/packages/backend/server/src/core/auth/controller.ts b/packages/backend/server/src/core/auth/controller.ts index 0cce8cd4d852..96980e769e4f 100644 --- a/packages/backend/server/src/core/auth/controller.ts +++ b/packages/backend/server/src/core/auth/controller.ts @@ -6,6 +6,7 @@ import { Controller, Get, Header, + HttpStatus, Post, Query, Req, @@ -13,11 +14,7 @@ import { } from '@nestjs/common'; import type { Request, Response } from 'express'; -import { - Config, - PaymentRequiredException, - URLHelper, -} from '../../fundamentals'; +import { PaymentRequiredException, URLHelper } from '../../fundamentals'; import { UserService } from '../user'; import { validators } from '../utils/validators'; import { CurrentUser } from './current-user'; @@ -33,7 +30,6 @@ class SignInCredential { @Controller('/api/auth') export class AuthController { constructor( - private readonly config: Config, private readonly url: URLHelper, private readonly auth: AuthService, private readonly user: UserService, @@ -64,7 +60,7 @@ export class AuthController { ); await this.auth.setCookie(req, res, user); - res.send(user); + res.status(HttpStatus.OK).send(user); } else { // send email magic link const user = await this.user.findUserByEmail(credential.email); @@ -77,7 +73,7 @@ export class AuthController { throw new Error('Failed to send sign-in email.'); } - res.send({ + res.status(HttpStatus.OK).send({ email: credential.email, }); } @@ -162,22 +158,6 @@ export class AuthController { return this.url.safeRedirect(res, redirectUri); } - @Get('/authorize') - async authorize( - @CurrentUser() user: CurrentUser, - @Query('redirect_uri') redirect_uri?: string - ) { - const session = await this.auth.createUserSession( - user, - undefined, - this.config.auth.accessToken.ttl - ); - - this.url.link(redirect_uri ?? '/open-app/redirect', { - token: session.sessionId, - }); - } - @Public() @Get('/session') async currentSessionUser(@CurrentUser() user?: CurrentUser) { diff --git a/packages/backend/server/src/core/auth/index.ts b/packages/backend/server/src/core/auth/index.ts index b557ba65cc8c..318075f74532 100644 --- a/packages/backend/server/src/core/auth/index.ts +++ b/packages/backend/server/src/core/auth/index.ts @@ -5,7 +5,7 @@ import { UserModule } from '../user'; import { AuthController } from './controller'; import { AuthResolver } from './resolver'; import { AuthService } from './service'; -import { TokenService } from './token'; +import { TokenService, TokenType } from './token'; @Module({ imports: [FeatureModule, UserModule], @@ -17,5 +17,5 @@ export class AuthModule {} export * from './guard'; export { ClientTokenType } from './resolver'; -export { AuthService }; +export { AuthService, TokenService, TokenType }; export * from './current-user'; diff --git a/packages/backend/server/src/core/auth/resolver.ts b/packages/backend/server/src/core/auth/resolver.ts index 58969d0b7be9..1869ce22d827 100644 --- a/packages/backend/server/src/core/auth/resolver.ts +++ b/packages/backend/server/src/core/auth/resolver.ts @@ -17,6 +17,7 @@ import { import type { Request, Response } from 'express'; import { CloudThrottlerGuard, Config, Throttle } from '../../fundamentals'; +import { UserService } from '../user'; import { UserType } from '../user/types'; import { validators } from '../utils/validators'; import { CurrentUser } from './current-user'; @@ -48,6 +49,7 @@ export class AuthResolver { constructor( private readonly config: Config, private readonly auth: AuthService, + private readonly user: UserService, private readonly token: TokenService ) {} @@ -165,7 +167,7 @@ export class AuthResolver { throw new ForbiddenException('Invalid token'); } - await this.auth.changePassword(user.email, newPassword); + await this.auth.changePassword(user.id, newPassword); return user; } @@ -319,7 +321,7 @@ export class AuthResolver { throw new ForbiddenException('Invalid token'); } - const hasRegistered = await this.auth.getUserByEmail(email); + const hasRegistered = await this.user.findUserByEmail(email); if (hasRegistered) { if (hasRegistered.id !== user.id) { diff --git a/packages/backend/server/src/core/auth/service.ts b/packages/backend/server/src/core/auth/service.ts index 75246f4e778a..6fe0c6082b96 100644 --- a/packages/backend/server/src/core/auth/service.ts +++ b/packages/backend/server/src/core/auth/service.ts @@ -2,7 +2,6 @@ import { BadRequestException, Injectable, NotAcceptableException, - NotFoundException, OnApplicationBootstrap, } from '@nestjs/common'; import type { User } from '@prisma/client'; @@ -10,30 +9,32 @@ import { PrismaClient } from '@prisma/client'; import type { CookieOptions, Request, Response } from 'express'; import { assign, omit } from 'lodash-es'; -import { - Config, - CryptoHelper, - MailService, - SessionCache, -} from '../../fundamentals'; +import { Config, CryptoHelper, MailService } from '../../fundamentals'; import { FeatureManagementService } from '../features/management'; import { UserService } from '../user/service'; import type { CurrentUser } from './current-user'; export function parseAuthUserSeqNum(value: any) { + let seq: number = 0; switch (typeof value) { case 'number': { - return value; + seq = value; + break; } case 'string': { - value = Number.parseInt(value); - return Number.isNaN(value) ? 0 : value; + const result = value.match(/^([\d{0, 10}])$/); + if (result?.[1]) { + seq = Number(result[1]); + } + break; } default: { - return 0; + seq = 0; } } + + return Math.max(0, seq); } export function sessionUser( @@ -57,7 +58,6 @@ export class AuthService implements OnApplicationBootstrap { sameSite: 'lax', httpOnly: true, path: '/', - domain: this.config.host, secure: this.config.https, }; static readonly sessionCookieName = 'sid'; @@ -69,8 +69,7 @@ export class AuthService implements OnApplicationBootstrap { private readonly mailer: MailService, private readonly feature: FeatureManagementService, private readonly user: UserService, - private readonly crypto: CryptoHelper, - private readonly cache: SessionCache + private readonly crypto: CryptoHelper ) {} async onApplicationBootstrap() { @@ -90,7 +89,7 @@ export class AuthService implements OnApplicationBootstrap { email: string, password: string ): Promise { - const user = await this.getUserByEmail(email); + const user = await this.user.findUserByEmail(email); if (user) { throw new BadRequestException('Email was taken'); @@ -111,12 +110,12 @@ export class AuthService implements OnApplicationBootstrap { const user = await this.user.findUserWithHashedPasswordByEmail(email); if (!user) { - throw new NotFoundException('User Not Found'); + throw new NotAcceptableException('Invalid sign in credentials'); } if (!user.password) { throw new NotAcceptableException( - 'User Password is not set. Should login throw email link.' + 'User Password is not set. Should login through email link.' ); } @@ -126,28 +125,12 @@ export class AuthService implements OnApplicationBootstrap { ); if (!passwordMatches) { - throw new NotAcceptableException('Incorrect Password'); + throw new NotAcceptableException('Invalid sign in credentials'); } return sessionUser(user); } - async getUserWithCache(token: string, seq = 0) { - const cacheKey = `session:${token}:${seq}`; - let user = await this.cache.get(cacheKey); - if (user) { - return user; - } - - user = await this.getUser(token, seq); - - if (user) { - await this.cache.set(cacheKey, user); - } - - return user; - } - async getUser(token: string, seq = 0): Promise { const session = await this.getSession(token); @@ -198,7 +181,16 @@ export class AuthService implements OnApplicationBootstrap { // Session // | { user: LimitedUser { email, avatarUrl }, expired: true } // | { user: User, expired: false } - return users.map(sessionUser); + return session.userSessions + .map(userSession => { + // keep users in the same order as userSessions + const user = users.find(({ id }) => id === userSession.userId); + if (!user) { + return null; + } + return sessionUser(user); + }) + .filter(Boolean) as CurrentUser[]; } async signOut(token: string, seq = 0) { @@ -319,12 +311,8 @@ export class AuthService implements OnApplicationBootstrap { }); } - async getUserByEmail(email: string) { - return this.user.findUserByEmail(email); - } - - async changePassword(email: string, newPassword: string): Promise { - const user = await this.getUserByEmail(email); + async changePassword(id: string, newPassword: string): Promise { + const user = await this.user.findUserById(id); if (!user) { throw new BadRequestException('Invalid email'); @@ -343,11 +331,7 @@ export class AuthService implements OnApplicationBootstrap { } async changeEmail(id: string, newEmail: string): Promise { - const user = await this.db.user.findUnique({ - where: { - id, - }, - }); + const user = await this.user.findUserById(id); if (!user) { throw new BadRequestException('Invalid email'); diff --git a/packages/backend/server/src/core/auth/token.ts b/packages/backend/server/src/core/auth/token.ts index b154b7cbd6cd..3027a7b90a46 100644 --- a/packages/backend/server/src/core/auth/token.ts +++ b/packages/backend/server/src/core/auth/token.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import { Injectable } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; import { PrismaClient } from '@prisma/client'; import { CryptoHelper } from '../../fundamentals/helpers'; @@ -81,4 +82,15 @@ export class TokenService { return valid ? record : null; } + + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + cleanExpiredTokens() { + return this.db.verificationToken.deleteMany({ + where: { + expiresAt: { + lte: new Date(), + }, + }, + }); + } } diff --git a/packages/backend/server/src/fundamentals/config/def.ts b/packages/backend/server/src/fundamentals/config/def.ts index 203ab66eec32..48ca191f936d 100644 --- a/packages/backend/server/src/fundamentals/config/def.ts +++ b/packages/backend/server/src/fundamentals/config/def.ts @@ -13,12 +13,6 @@ declare global { } } -export enum ExternalAccount { - github = 'github', - google = 'google', - firebase = 'firebase', -} - export type ServerFlavor = 'allinone' | 'graphql' | 'sync'; export type AFFINE_ENV = 'dev' | 'beta' | 'production'; export type NODE_ENV = 'development' | 'test' | 'production'; diff --git a/packages/backend/server/src/fundamentals/prisma/index.ts b/packages/backend/server/src/fundamentals/prisma/index.ts index 517997fec525..91f440657526 100644 --- a/packages/backend/server/src/fundamentals/prisma/index.ts +++ b/packages/backend/server/src/fundamentals/prisma/index.ts @@ -6,7 +6,13 @@ import { PrismaService } from './service'; // only `PrismaClient` can be injected const clientProvider: Provider = { provide: PrismaClient, - useClass: PrismaService, + useFactory: () => { + if (PrismaService.INSTANCE) { + return PrismaService.INSTANCE; + } + + return new PrismaService(); + }, }; @Global() diff --git a/packages/backend/server/src/fundamentals/prisma/service.ts b/packages/backend/server/src/fundamentals/prisma/service.ts index 76fb92b39293..3f326e50d4fa 100644 --- a/packages/backend/server/src/fundamentals/prisma/service.ts +++ b/packages/backend/server/src/fundamentals/prisma/service.ts @@ -19,6 +19,9 @@ export class PrismaService } async onModuleDestroy(): Promise { - await this.$disconnect(); + if (!AFFiNE.node.test) { + await this.$disconnect(); + PrismaService.INSTANCE = null; + } } } diff --git a/packages/backend/server/src/plugins/oauth/controller.ts b/packages/backend/server/src/plugins/oauth/controller.ts index 8be8d69a5a87..3d4ac90f65c2 100644 --- a/packages/backend/server/src/plugins/oauth/controller.ts +++ b/packages/backend/server/src/plugins/oauth/controller.ts @@ -40,7 +40,7 @@ export class OAuthController { const provider = this.providerFactory.get(providerName); if (!provider) { - throw new BadRequestException('Invalid provider'); + throw new BadRequestException('Invalid OAuth provider'); } const state = await this.oauth.saveOAuthState({ diff --git a/packages/backend/server/src/plugins/oauth/register.ts b/packages/backend/server/src/plugins/oauth/register.ts index d6c53c57d266..3eeccae7c6b4 100644 --- a/packages/backend/server/src/plugins/oauth/register.ts +++ b/packages/backend/server/src/plugins/oauth/register.ts @@ -16,7 +16,7 @@ export function registerOAuthProvider( @Injectable() export class OAuthProviderFactory { get providers() { - return PROVIDERS.keys(); + return Array.from(PROVIDERS.keys()); } get(name: OAuthProviderName): OAuthProvider | undefined { diff --git a/packages/backend/server/tests/auth/controller.spec.ts b/packages/backend/server/tests/auth/controller.spec.ts new file mode 100644 index 000000000000..b8a967957c43 --- /dev/null +++ b/packages/backend/server/tests/auth/controller.spec.ts @@ -0,0 +1,164 @@ +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import ava, { TestFn } from 'ava'; +import Sinon from 'sinon'; +import request from 'supertest'; + +import { AuthModule, CurrentUser } from '../../src/core/auth'; +import { AuthService } from '../../src/core/auth/service'; +import { FeatureModule } from '../../src/core/features'; +import { UserModule, UserService } from '../../src/core/user'; +import { MailService } from '../../src/fundamentals'; +import { createTestingApp, getSession, sessionCookie } from '../utils'; + +const test = ava as TestFn<{ + auth: AuthService; + user: UserService; + u1: CurrentUser; + db: PrismaClient; + mailer: Sinon.SinonStubbedInstance; + app: INestApplication; +}>; + +test.beforeEach(async t => { + const { app } = await createTestingApp({ + imports: [FeatureModule, UserModule, AuthModule], + tapModule: m => { + m.overrideProvider(MailService).useValue( + Sinon.createStubInstance(MailService) + ); + }, + }); + + t.context.auth = app.get(AuthService); + t.context.user = app.get(UserService); + t.context.db = app.get(PrismaClient); + t.context.mailer = app.get(MailService); + t.context.app = app; + + t.context.u1 = await t.context.auth.signUp('u1', 'u1@affine.pro', '1'); +}); + +test.afterEach.always(async t => { + await t.context.app.close(); +}); + +test('should be able to sign in with credential', async t => { + const { app, u1 } = t.context; + + const res = await request(app.getHttpServer()) + .post('/api/auth/sign-in') + .send({ email: u1.email, password: '1' }) + .expect(200); + + const session = await getSession(app, res); + t.is(session.user!.id, u1.id); +}); + +test('should be able to sign in with email', async t => { + const { app, u1, mailer } = t.context; + + // @ts-expect-error mock + mailer.sendSignInMail.resolves({ rejected: [] }); + + const res = await request(app.getHttpServer()) + .post('/api/auth/sign-in') + .send({ email: u1.email }) + .expect(200); + + t.is(res.body.email, u1.email); + t.true(mailer.sendSignInMail.calledOnce); + + let [signInLink] = mailer.sendSignInMail.firstCall.args; + const url = new URL(signInLink); + signInLink = url.pathname + url.search; + + const signInRes = await request(app.getHttpServer()) + .get(signInLink) + .expect(302); + + const session = await getSession(app, signInRes); + t.is(session.user!.id, u1.id); +}); + +test('should be able to sign up with email', async t => { + const { app, mailer } = t.context; + + // @ts-expect-error mock + mailer.sendSignUpMail.resolves({ rejected: [] }); + + const res = await request(app.getHttpServer()) + .post('/api/auth/sign-in') + .send({ email: 'u2@affine.pro' }) + .expect(200); + + t.is(res.body.email, 'u2@affine.pro'); + t.true(mailer.sendSignUpMail.calledOnce); + + let [signUpLink] = mailer.sendSignUpMail.firstCall.args; + const url = new URL(signUpLink); + signUpLink = url.pathname + url.search; + + const signInRes = await request(app.getHttpServer()) + .get(signUpLink) + .expect(302); + + const session = await getSession(app, signInRes); + t.is(session.user!.email, 'u2@affine.pro'); +}); + +test('should not be able to sign in if email is invalid', async t => { + const { app } = t.context; + + const res = await request(app.getHttpServer()) + .post('/api/auth/sign-in') + .send({ email: '' }) + .expect(400); + + t.is(res.body.message, 'Invalid email address'); +}); + +test('should not be able to sign in if forbidden', async t => { + const { app, auth, u1, mailer } = t.context; + + const canSignInStub = Sinon.stub(auth, 'canSignIn').resolves(false); + + await request(app.getHttpServer()) + .post('/api/auth/sign-in') + .send({ email: u1.email }) + .expect(HttpStatus.PAYMENT_REQUIRED); + + t.true(mailer.sendSignInMail.notCalled); + + canSignInStub.restore(); +}); + +test('should be able to sign out', async t => { + const { app, u1 } = t.context; + + const signInRes = await request(app.getHttpServer()) + .post('/api/auth/sign-in') + .send({ email: u1.email, password: '1' }) + .expect(200); + + const cookie = sessionCookie(signInRes.headers); + + await request(app.getHttpServer()) + .get('/api/auth/sign-out') + .set('cookie', cookie) + .expect(200); + + const session = await getSession(app, signInRes); + + t.falsy(session.user); +}); + +test('should not be able to sign out if not signed in', async t => { + const { app } = t.context; + + await request(app.getHttpServer()) + .get('/api/auth/sign-out') + .expect(HttpStatus.UNAUTHORIZED); + + t.assert(true); +}); diff --git a/packages/backend/server/tests/auth/guard.spec.ts b/packages/backend/server/tests/auth/guard.spec.ts new file mode 100644 index 000000000000..78dccc8905ae --- /dev/null +++ b/packages/backend/server/tests/auth/guard.spec.ts @@ -0,0 +1,131 @@ +import { Controller, Get, HttpStatus, INestApplication } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import ava, { TestFn } from 'ava'; +import Sinon from 'sinon'; +import request from 'supertest'; + +import { + AuthGuard, + AuthModule, + CurrentUser, + Public, +} from '../../src/core/auth'; +import { AuthService } from '../../src/core/auth/service'; +import { createTestingApp } from '../utils'; + +@Controller('/') +class TestController { + @Public() + @Get('/public') + home(@CurrentUser() user?: CurrentUser) { + return { user }; + } + + @Get('/private') + private(@CurrentUser() user: CurrentUser) { + return { user }; + } +} + +const test = ava as TestFn<{ + app: INestApplication; + auth: Sinon.SinonStubbedInstance; +}>; + +test.beforeEach(async t => { + const { app } = await createTestingApp({ + imports: [AuthModule], + providers: [ + { + provide: APP_GUARD, + useClass: AuthGuard, + }, + ], + controllers: [TestController], + tapModule: m => { + m.overrideProvider(AuthService).useValue( + Sinon.createStubInstance(AuthService) + ); + }, + }); + + t.context.auth = app.get(AuthService); + t.context.app = app; +}); + +test.afterEach.always(async t => { + await t.context.app.close(); +}); + +test('should be able to visit public api if not signed in', async t => { + const { app } = t.context; + + const res = await request(app.getHttpServer()).get('/public').expect(200); + + t.is(res.body.user, undefined); +}); + +test('should be able to visit public api if signed in', async t => { + const { app, auth } = t.context; + + // @ts-expect-error mock + auth.getUser.resolves({ id: '1' }); + + const res = await request(app.getHttpServer()) + .get('/public') + .set('Cookie', 'sid=1') + .expect(HttpStatus.OK); + + t.is(res.body.user.id, '1'); +}); + +test('should not be able to visit private api if not signed in', async t => { + const { app } = t.context; + + await request(app.getHttpServer()) + .get('/private') + .expect(HttpStatus.UNAUTHORIZED) + .expect({ + statusCode: 401, + message: 'You are not signed in.', + error: 'Unauthorized', + }); + + t.assert(true); +}); + +test('should be able to visit private api if signed in', async t => { + const { app, auth } = t.context; + + // @ts-expect-error mock + auth.getUser.resolves({ id: '1' }); + + const res = await request(app.getHttpServer()) + .get('/private') + .set('Cookie', 'sid=1') + .expect(HttpStatus.OK); + + t.is(res.body.user.id, '1'); +}); + +test('should be able to parse session cookie', async t => { + const { app, auth } = t.context; + + await request(app.getHttpServer()) + .get('/public') + .set('cookie', 'sid=1') + .expect(200); + + t.deepEqual(auth.getUser.firstCall.args, ['1', 0]); +}); + +test('should be able to parse bearer token', async t => { + const { app, auth } = t.context; + + await request(app.getHttpServer()) + .get('/public') + .auth('1', { type: 'bearer' }) + .expect(200); + + t.deepEqual(auth.getUser.firstCall.args, ['1', 0]); +}); diff --git a/packages/backend/server/tests/auth/service.spec.ts b/packages/backend/server/tests/auth/service.spec.ts new file mode 100644 index 000000000000..55fbc24dd149 --- /dev/null +++ b/packages/backend/server/tests/auth/service.spec.ts @@ -0,0 +1,219 @@ +import { TestingModule } from '@nestjs/testing'; +import { PrismaClient } from '@prisma/client'; +import ava, { TestFn } from 'ava'; + +import { CurrentUser } from '../../src/core/auth'; +import { AuthService, parseAuthUserSeqNum } from '../../src/core/auth/service'; +import { FeatureModule } from '../../src/core/features'; +import { UserModule, UserService } from '../../src/core/user'; +import { createTestingModule } from '../utils'; + +const test = ava as TestFn<{ + auth: AuthService; + user: UserService; + u1: CurrentUser; + db: PrismaClient; + m: TestingModule; +}>; + +test.beforeEach(async t => { + const m = await createTestingModule({ + imports: [FeatureModule, UserModule], + providers: [AuthService], + }); + + t.context.auth = m.get(AuthService); + t.context.user = m.get(UserService); + t.context.db = m.get(PrismaClient); + t.context.m = m; + + t.context.u1 = await t.context.auth.signUp('u1', 'u1@affine.pro', '1'); +}); + +test.afterEach.always(async t => { + await t.context.m.close(); +}); + +test('should be able to parse auth user seq num', t => { + t.deepEqual( + [ + '1', + '2', + 3, + -3, + '-4', + '1.1', + 'str', + '1111111111111111111111111111111111111111111', + ].map(parseAuthUserSeqNum), + [1, 2, 3, 0, 0, 0, 0, 0] + ); +}); + +test('should be able to sign up', async t => { + const { auth } = t.context; + const u2 = await auth.signUp('u2', 'u2@affine.pro', '1'); + + t.is(u2.email, 'u2@affine.pro'); + + const signedU2 = await auth.signIn(u2.email, '1'); + + t.is(u2.email, signedU2.email); +}); + +test('should throw if email duplicated', async t => { + const { auth } = t.context; + + await t.throwsAsync(() => auth.signUp('u1', 'u1@affine.pro', '1'), { + message: 'Email was taken', + }); +}); + +test('should be able to sign in', async t => { + const { auth } = t.context; + + const signedInUser = await auth.signIn('u1@affine.pro', '1'); + + t.is(signedInUser.email, 'u1@affine.pro'); +}); + +test('should throw if user not found', async t => { + const { auth } = t.context; + + await t.throwsAsync(() => auth.signIn('u2@affine.pro', '1'), { + message: 'Invalid sign in credentials', + }); +}); + +test('should throw if password not set', async t => { + const { user, auth } = t.context; + + await user.createUser({ + email: 'u2@affine.pro', + name: 'u2', + }); + + await t.throwsAsync(() => auth.signIn('u2@affine.pro', '1'), { + message: 'User Password is not set. Should login through email link.', + }); +}); + +test('should throw if password not match', async t => { + const { auth } = t.context; + + await t.throwsAsync(() => auth.signIn('u1@affine.pro', '2'), { + message: 'Invalid sign in credentials', + }); +}); + +test('should be able to change password', async t => { + const { auth, u1 } = t.context; + + let signedInU1 = await auth.signIn('u1@affine.pro', '1'); + t.is(signedInU1.email, u1.email); + + await auth.changePassword(u1.id, '2'); + + await t.throwsAsync( + () => auth.signIn('u1@affine.pro', '1' /* old password */), + { + message: 'Invalid sign in credentials', + } + ); + + signedInU1 = await auth.signIn('u1@affine.pro', '2'); + t.is(signedInU1.email, u1.email); +}); + +test('should be able to change email', async t => { + const { auth, u1 } = t.context; + + let signedInU1 = await auth.signIn('u1@affine.pro', '1'); + t.is(signedInU1.email, u1.email); + + await auth.changeEmail(u1.id, 'u2@affine.pro'); + + await t.throwsAsync(() => auth.signIn('u1@affine.pro' /* old email */, '1'), { + message: 'Invalid sign in credentials', + }); + + signedInU1 = await auth.signIn('u2@affine.pro', '1'); + t.is(signedInU1.email, 'u2@affine.pro'); +}); + +// Tests for Session +test('should be able to create user session', async t => { + const { auth, u1 } = t.context; + + const session = await auth.createUserSession(u1); + + t.is(session.userId, u1.id); +}); + +test('should be able to get user from session', async t => { + const { auth, u1 } = t.context; + + const session = await auth.createUserSession(u1); + + const user = await auth.getUser(session.sessionId); + + t.not(user, null); + t.is(user!.id, u1.id); +}); + +test('should be able to sign out session', async t => { + const { auth, u1 } = t.context; + + const session = await auth.createUserSession(u1); + + const signedOutSession = await auth.signOut(session.sessionId); + + t.is(signedOutSession, null); +}); + +// Tests for Multi-Accounts Session +test('should be able to sign in different user in a same session', async t => { + const { auth, u1 } = t.context; + + const u2 = await auth.signUp('u2', 'u2@affine.pro', '1'); + + const session = await auth.createUserSession(u1); + await auth.createUserSession(u2, session.sessionId); + + const [signedU1, signedU2] = await auth.getUserList(session.sessionId); + + t.not(signedU1, null); + t.not(signedU2, null); + t.is(signedU1!.id, u1.id); + t.is(signedU2!.id, u2.id); +}); + +test('should be able to signout multi accounts session', async t => { + const { auth, u1 } = t.context; + + const u2 = await auth.signUp('u2', 'u2@affine.pro', '1'); + + const session = await auth.createUserSession(u1); + await auth.createUserSession(u2, session.sessionId); + + // sign out user at seq(0) + let signedOutSession = await auth.signOut(session.sessionId); + + t.not(signedOutSession, null); + + const signedU2 = await auth.getUser(session.sessionId, 0); + const noUser = await auth.getUser(session.sessionId, 1); + + t.is(noUser, null); + t.not(signedU2, null); + + t.is(signedU2!.id, u2.id); + + // sign out user at seq(0) + signedOutSession = await auth.signOut(session.sessionId); + + t.is(signedOutSession, null); + + const noUser2 = await auth.getUser(session.sessionId, 0); + t.is(noUser2, null); +}); diff --git a/packages/backend/server/tests/auth/token.spec.ts b/packages/backend/server/tests/auth/token.spec.ts new file mode 100644 index 000000000000..b7818e89d942 --- /dev/null +++ b/packages/backend/server/tests/auth/token.spec.ts @@ -0,0 +1,93 @@ +import { TestingModule } from '@nestjs/testing'; +import { PrismaClient } from '@prisma/client'; +import ava, { TestFn } from 'ava'; + +import { TokenService, TokenType } from '../../src/core/auth'; +import { createTestingModule } from '../utils'; + +const test = ava as TestFn<{ + ts: TokenService; + m: TestingModule; +}>; + +test.beforeEach(async t => { + const m = await createTestingModule({ + providers: [TokenService], + }); + + t.context.ts = m.get(TokenService); + t.context.m = m; +}); + +test.afterEach.always(async t => { + await t.context.m.close(); +}); + +test('should be able to create token', async t => { + const { ts } = t.context; + const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro'); + + t.truthy( + await ts.verifyToken(TokenType.SignIn, token, { + credential: 'user@affine.pro', + }) + ); +}); + +test('should fail the verification if the token is invalid', async t => { + const { ts } = t.context; + + const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro'); + + // wrong type + t.falsy( + await ts.verifyToken(TokenType.ChangeEmail, token, { + credential: 'user@affine.pro', + }) + ); + + // no credential + t.falsy(await ts.verifyToken(TokenType.SignIn, token)); + + // wrong credential + t.falsy( + await ts.verifyToken(TokenType.SignIn, token, { + credential: 'wrong@affine.pro', + }) + ); +}); + +test('should fail if the token expired', async t => { + const { ts } = t.context; + const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro'); + + await t.context.m.get(PrismaClient).verificationToken.updateMany({ + data: { + expiresAt: new Date(Date.now() - 1000), + }, + }); + + t.falsy( + await ts.verifyToken(TokenType.SignIn, token, { + credential: 'user@affine.pro', + }) + ); +}); + +test('should be able to verify only once', async t => { + const { ts } = t.context; + const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro'); + + t.truthy( + await ts.verifyToken(TokenType.SignIn, token, { + credential: 'user@affine.pro', + }) + ); + + // will be invalid after the first time of verification + t.falsy( + await ts.verifyToken(TokenType.SignIn, token, { + credential: 'user@affine.pro', + }) + ); +}); diff --git a/packages/backend/server/tests/oauth/controller.spec.ts b/packages/backend/server/tests/oauth/controller.spec.ts new file mode 100644 index 000000000000..542de3a0c1fe --- /dev/null +++ b/packages/backend/server/tests/oauth/controller.spec.ts @@ -0,0 +1,345 @@ +import '../../src/plugins/config'; + +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import ava, { TestFn } from 'ava'; +import Sinon from 'sinon'; +import request from 'supertest'; + +import { AppModule } from '../../src/app.module'; +import { CurrentUser } from '../../src/core/auth'; +import { AuthService } from '../../src/core/auth/service'; +import { UserService } from '../../src/core/user'; +import { Config, ConfigModule } from '../../src/fundamentals/config'; +import { GoogleOAuthProvider } from '../../src/plugins/oauth/providers/google'; +import { OAuthService } from '../../src/plugins/oauth/service'; +import { OAuthProviderName } from '../../src/plugins/oauth/types'; +import { createTestingApp, getSession } from '../utils'; + +const test = ava as TestFn<{ + auth: AuthService; + oauth: OAuthService; + user: UserService; + u1: CurrentUser; + db: PrismaClient; + app: INestApplication; +}>; + +test.beforeEach(async t => { + const { app } = await createTestingApp({ + imports: [ + ConfigModule.forRoot({ + plugins: { + oauth: { + providers: { + google: { + clientId: 'google-client-id', + clientSecret: 'google-client-secret', + }, + }, + }, + }, + }), + AppModule, + ], + }); + + t.context.auth = app.get(AuthService); + t.context.oauth = app.get(OAuthService); + t.context.user = app.get(UserService); + t.context.db = app.get(PrismaClient); + t.context.app = app; + + t.context.u1 = await t.context.auth.signUp('u1', 'u1@affine.pro', '1'); +}); + +test.afterEach.always(async t => { + await t.context.app.close(); +}); + +test("should be able to redirect to oauth provider's login page", async t => { + const { app } = t.context; + + const res = await request(app.getHttpServer()) + .get('/oauth/login?provider=Google') + .expect(HttpStatus.FOUND); + + const redirect = new URL(res.header.location); + t.is(redirect.origin, 'https://accounts.google.com'); + + t.is(redirect.pathname, '/o/oauth2/v2/auth'); + t.is(redirect.searchParams.get('client_id'), 'google-client-id'); + t.is( + redirect.searchParams.get('redirect_uri'), + app.get(Config).baseUrl + '/oauth/callback' + ); + t.is(redirect.searchParams.get('response_type'), 'code'); + t.is(redirect.searchParams.get('prompt'), 'select_account'); + t.truthy(redirect.searchParams.get('state')); +}); + +test('should throw if provider is invalid', async t => { + const { app } = t.context; + + await request(app.getHttpServer()) + .get('/oauth/login?provider=Invalid') + .expect(HttpStatus.BAD_REQUEST) + .expect({ + statusCode: 400, + message: 'Invalid OAuth provider', + error: 'Bad Request', + }); + + t.assert(true); +}); + +test('should be able to save oauth state', async t => { + const { oauth } = t.context; + + const id = await oauth.saveOAuthState({ + redirectUri: 'https://example.com', + provider: OAuthProviderName.Google, + }); + + const state = await oauth.getOAuthState(id); + + t.truthy(state); + t.is(state!.provider, OAuthProviderName.Google); + t.is(state!.redirectUri, 'https://example.com'); +}); + +test('should be able to get registered oauth providers', async t => { + const { oauth } = t.context; + + const providers = oauth.availableOAuthProviders(); + + t.deepEqual(providers, [OAuthProviderName.Google]); +}); + +test('should throw if code is missing in callback uri', async t => { + const { app } = t.context; + + await request(app.getHttpServer()) + .get('/oauth/callback') + .expect(HttpStatus.BAD_REQUEST) + .expect({ + statusCode: 400, + message: 'Missing query parameter `code`', + error: 'Bad Request', + }); + + t.assert(true); +}); + +test('should throw if state is missing in callback uri', async t => { + const { app } = t.context; + + await request(app.getHttpServer()) + .get('/oauth/callback?code=1') + .expect(HttpStatus.BAD_REQUEST) + .expect({ + statusCode: 400, + message: 'Invalid callback state parameter', + error: 'Bad Request', + }); + + t.assert(true); +}); + +test('should throw if state is expired', async t => { + const { app } = t.context; + + await request(app.getHttpServer()) + .get('/oauth/callback?code=1&state=1') + .expect(HttpStatus.BAD_REQUEST) + .expect({ + statusCode: 400, + message: 'OAuth state expired, please try again.', + error: 'Bad Request', + }); + + t.assert(true); +}); + +test('should throw if provider is missing in state', async t => { + const { app, oauth } = t.context; + + // @ts-expect-error mock + Sinon.stub(oauth, 'getOAuthState').resolves({}); + + await request(app.getHttpServer()) + .get(`/oauth/callback?code=1&state=1`) + .expect(HttpStatus.BAD_REQUEST) + .expect({ + statusCode: 400, + message: 'Missing callback state parameter `provider`', + error: 'Bad Request', + }); + + t.assert(true); +}); + +test('should throw if provider is invalid in callback uri', async t => { + const { app, oauth } = t.context; + + // @ts-expect-error mock + Sinon.stub(oauth, 'getOAuthState').resolves({ provider: 'Invalid' }); + + await request(app.getHttpServer()) + .get(`/oauth/callback?code=1&state=1`) + .expect(HttpStatus.BAD_REQUEST) + .expect({ + statusCode: 400, + message: 'Invalid provider', + error: 'Bad Request', + }); + + t.assert(true); +}); + +function mockOAuthProvider(app: INestApplication, email: string) { + const provider = app.get(GoogleOAuthProvider); + const oauth = app.get(OAuthService); + + Sinon.stub(oauth, 'getOAuthState').resolves({ + provider: OAuthProviderName.Google, + redirectUri: '/', + }); + + // @ts-expect-error mock + Sinon.stub(provider, 'getToken').resolves({ accessToken: '1' }); + Sinon.stub(provider, 'getUser').resolves({ + id: '1', + email, + avatarUrl: 'avatar', + }); +} + +test('should be able to sign up with oauth', async t => { + const { app, db } = t.context; + + mockOAuthProvider(app, 'u2@affine.pro'); + + const res = await request(app.getHttpServer()) + .get(`/oauth/callback?code=1&state=1`) + .expect(HttpStatus.FOUND); + + const session = await getSession(app, res); + + t.truthy(session.user); + t.is(session.user!.email, 'u2@affine.pro'); + + const user = await db.user.findFirst({ + select: { + email: true, + connectedAccounts: true, + }, + where: { + email: 'u2@affine.pro', + }, + }); + + t.truthy(user); + t.is(user!.email, 'u2@affine.pro'); + t.is(user!.connectedAccounts[0].providerAccountId, '1'); +}); + +test('should throw if account register in another way', async t => { + const { app, u1 } = t.context; + + mockOAuthProvider(app, u1.email); + + const res = await request(app.getHttpServer()) + .get(`/oauth/callback?code=1&state=1`) + .expect(HttpStatus.FOUND); + + const link = new URL(res.headers.location); + + t.is(link.pathname, '/signIn'); + t.is( + link.searchParams.get('error'), + 'The account with provided email is not register in the same way.' + ); +}); + +test('should be able to fullfil user with oauth sign in', async t => { + const { app, user, db } = t.context; + + const u3 = await user.createUser({ + name: 'u3', + email: 'u3@affine.pro', + registered: false, + }); + + mockOAuthProvider(app, u3.email); + + const res = await request(app.getHttpServer()) + .get(`/oauth/callback?code=1&state=1`) + .expect(HttpStatus.FOUND); + + const session = await getSession(app, res); + + t.truthy(session.user); + t.is(session.user!.email, u3.email); + + const account = await db.connectedAccount.findFirst({ + where: { + userId: u3.id, + }, + }); + + t.truthy(account); +}); + +test('should throw if oauth account already connected', async t => { + const { app, db, u1, auth } = t.context; + + await db.connectedAccount.create({ + data: { + userId: u1.id, + provider: OAuthProviderName.Google, + providerAccountId: '1', + }, + }); + + // @ts-expect-error mock + Sinon.stub(auth, 'getUser').resolves({ id: 'u2-id' }); + + mockOAuthProvider(app, 'u2@affine.pro'); + + const res = await request(app.getHttpServer()) + .get(`/oauth/callback?code=1&state=1`) + .set('cookie', 'sid=1') + .expect(HttpStatus.FOUND); + + const link = new URL(res.headers.location); + + t.is(link.pathname, '/signIn'); + t.is( + link.searchParams.get('error'), + 'The third-party account has already been connected to another user.' + ); +}); + +test('should be able to connect oauth account', async t => { + const { app, u1, auth, db } = t.context; + + // @ts-expect-error mock + Sinon.stub(auth, 'getUser').resolves({ id: u1.id }); + + mockOAuthProvider(app, u1.email); + + await request(app.getHttpServer()) + .get(`/oauth/callback?code=1&state=1`) + .set('cookie', 'sid=1') + .expect(HttpStatus.FOUND); + + const account = await db.connectedAccount.findFirst({ + where: { + userId: u1.id, + }, + }); + + t.truthy(account); + t.is(account!.userId, u1.id); +}); diff --git a/packages/backend/server/tests/utils/user.ts b/packages/backend/server/tests/utils/user.ts index f57dce3e39f5..8f238f5d0366 100644 --- a/packages/backend/server/tests/utils/user.ts +++ b/packages/backend/server/tests/utils/user.ts @@ -1,11 +1,36 @@ import type { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; -import request from 'supertest'; +import request, { type Response } from 'supertest'; -import type { ClientTokenType } from '../../src/core/auth'; +import type { ClientTokenType, CurrentUser } from '../../src/core/auth'; import type { UserType } from '../../src/core/user'; import { gql } from './common'; +export function sessionCookie(headers: any) { + const cookie = headers['set-cookie']?.find((c: string) => + c.startsWith('sid=') + ); + + if (!cookie) { + return null; + } + + return cookie.split(';')[0]; +} + +export async function getSession( + app: INestApplication, + signInRes: Response +): Promise<{ user?: CurrentUser }> { + const cookie = sessionCookie(signInRes.headers); + const res = await request(app.getHttpServer()) + .get('/api/auth/session') + .set('cookie', cookie) + .expect(200); + + return res.body; +} + export async function signUp( app: INestApplication, name: string, diff --git a/packages/backend/server/tests/utils/utils.ts b/packages/backend/server/tests/utils/utils.ts index 8d3c32bf55c8..88351d2df9b6 100644 --- a/packages/backend/server/tests/utils/utils.ts +++ b/packages/backend/server/tests/utils/utils.ts @@ -113,6 +113,7 @@ export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) { cors: true, bodyParser: true, rawBody: true, + logger: ['warn'], }); app.use( diff --git a/packages/backend/server/tests/workspace-invite.e2e.ts b/packages/backend/server/tests/workspace-invite.e2e.ts index 0a5de885faf0..c92f2c66c0c8 100644 --- a/packages/backend/server/tests/workspace-invite.e2e.ts +++ b/packages/backend/server/tests/workspace-invite.e2e.ts @@ -9,6 +9,7 @@ import ava from 'ava'; import { AppModule } from '../src/app.module'; import { AuthService } from '../src/core/auth/service'; +import { UserService } from '../src/core/user'; import { MailService } from '../src/fundamentals/mailer'; import { acceptInviteById, @@ -26,6 +27,7 @@ const test = ava as TestFn<{ client: PrismaClient; auth: AuthService; mail: MailService; + user: UserService; }>; test.beforeEach(async t => { @@ -36,6 +38,7 @@ test.beforeEach(async t => { t.context.client = app.get(PrismaClient); t.context.auth = app.get(AuthService); t.context.mail = app.get(MailService); + t.context.user = app.get(UserService); }); test.afterEach.always(async t => { @@ -96,16 +99,16 @@ test('should revoke a user', async t => { }); test('should create user if not exist', async t => { - const { app, auth } = t.context; + const { app, user } = t.context; const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1'); const workspace = await createWorkspace(app, u1.token.token); await inviteUser(app, u1.token.token, workspace.id, 'u2@affine.pro', 'Admin'); - const user = await auth.getUserByEmail('u2@affine.pro'); - t.not(user, undefined, 'failed to create user'); - t.is(user?.name, 'u2', 'failed to create user'); + const u2 = await user.findUserByEmail('u2@affine.pro'); + t.not(u2, undefined, 'failed to create user'); + t.is(u2?.name, 'u2', 'failed to create user'); }); test('should invite a user by link', async t => { diff --git a/packages/frontend/templates/templates.gen.ts b/packages/frontend/templates/templates.gen.ts index d95d5c0eed64..6858f0b2b3a4 100644 --- a/packages/frontend/templates/templates.gen.ts +++ b/packages/frontend/templates/templates.gen.ts @@ -1,11 +1,11 @@ /* eslint-disable simple-import-sort/imports */ // Auto generated, do not edit manually -import json_0 from './onboarding/W-d9_llZ6rE-qoTiHKTk4.snapshot.json'; -import json_1 from './onboarding/info.json'; -import json_2 from './onboarding/blob.json'; +import json_0 from './onboarding/info.json'; +import json_1 from './onboarding/blob.json'; +import json_2 from './onboarding/W-d9_llZ6rE-qoTiHKTk4.snapshot.json'; export const onboarding = { - 'W-d9_llZ6rE-qoTiHKTk4.snapshot.json': json_0, - 'info.json': json_1, - 'blob.json': json_2 + 'info.json': json_0, + 'blob.json': json_1, + 'W-d9_llZ6rE-qoTiHKTk4.snapshot.json': json_2 } \ No newline at end of file