import { UnauthorizedException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { JwtService } from "@nestjs/jwt"; import { Test, TestingModule } from "@nestjs/testing"; import { AuthMailService } from "../src/auth/auth-mail.service"; import { AuthService } from "../src/auth/auth.service"; import { PrismaService } from "../src/prisma/prisma.service"; import { DataEncryptionService } from "../src/security/data-encryption.service"; type UserRecord = { id: string; email: string; emailHash: string; nickname: string | null; avatarUrl: string | null; }; type RefreshTokenRecord = { id: string; userId: string; tokenHash: string; expiresAt: Date; revokedAt: Date | null; createdAt: Date; }; type UserSecurityRecord = { userId: string; twoFactorEnabled: boolean; twoFactorSecret: string | null; }; class InMemoryAuthPrismaService { private userIdSequence = 1; private refreshTokenIdSequence = 1; private users: UserRecord[] = []; private refreshTokens: RefreshTokenRecord[] = []; private userSecurities: UserSecurityRecord[] = []; readonly user = { upsert: async (args: { where: { emailHash: string; }; update: Record; create: { email: string; emailHash: string; }; select: { id: true; email: true; }; }) => { const existingUser = this.users.find((user) => user.emailHash === args.where.emailHash); if (existingUser) { return { id: existingUser.id, email: existingUser.email }; } const createdUser: UserRecord = { id: `user_${this.userIdSequence++}`, email: args.create.email, emailHash: args.create.emailHash, nickname: null, avatarUrl: null }; this.users.push(createdUser); return { id: createdUser.id, email: createdUser.email }; } }; readonly refreshToken = { create: async (args: { data: { userId: string; tokenHash: string; expiresAt: Date; }; }) => { const refreshToken: RefreshTokenRecord = { id: `refresh_${this.refreshTokenIdSequence++}`, userId: args.data.userId, tokenHash: args.data.tokenHash, expiresAt: args.data.expiresAt, revokedAt: null, createdAt: new Date() }; this.refreshTokens.push(refreshToken); return refreshToken; }, findUnique: async (args: { where: { tokenHash: string; }; include: { user: { select: { id: true; email: true; }; }; }; }) => { const refreshToken = this.refreshTokens.find( (item) => item.tokenHash === args.where.tokenHash ); if (!refreshToken) { return null; } const user = this.users.find((item) => item.id === refreshToken.userId); if (!user) { throw new Error("user not found"); } return { ...refreshToken, user: { id: user.id, email: user.email } }; }, update: async (args: { where: { id: string; }; data: { revokedAt: Date; }; }) => { const refreshToken = this.refreshTokens.find((item) => item.id === args.where.id); if (!refreshToken) { throw new Error("refresh token not found"); } refreshToken.revokedAt = args.data.revokedAt; return refreshToken; }, updateMany: async (args: { where: { tokenHash: string; revokedAt: null; }; data: { revokedAt: Date; }; }) => { let count = 0; for (const refreshToken of this.refreshTokens) { if (refreshToken.tokenHash !== args.where.tokenHash || refreshToken.revokedAt !== null) { continue; } refreshToken.revokedAt = args.data.revokedAt; count += 1; } return { count }; } }; readonly userSecurity = { upsert: async (args: { where: { userId: string; }; update: { twoFactorSecret: string; twoFactorEnabled: boolean; }; create: { userId: string; twoFactorSecret: string; twoFactorEnabled: boolean; }; }) => { const existingSecurity = this.userSecurities.find( (item) => item.userId === args.where.userId ); if (existingSecurity) { existingSecurity.twoFactorSecret = args.update.twoFactorSecret; existingSecurity.twoFactorEnabled = args.update.twoFactorEnabled; return existingSecurity; } const createdSecurity: UserSecurityRecord = { userId: args.create.userId, twoFactorSecret: args.create.twoFactorSecret, twoFactorEnabled: args.create.twoFactorEnabled }; this.userSecurities.push(createdSecurity); return createdSecurity; }, findUnique: async (args: { where: { userId: string; }; select: { twoFactorSecret: true; }; }) => { const security = this.userSecurities.find((item) => item.userId === args.where.userId); if (!security) { return null; } return { twoFactorSecret: security.twoFactorSecret }; }, update: async (args: { where: { userId: string; }; data: { twoFactorEnabled: boolean; }; }) => { const security = this.userSecurities.find((item) => item.userId === args.where.userId); if (!security) { throw new Error("user security not found"); } security.twoFactorEnabled = args.data.twoFactorEnabled; return security; } }; getUsers(): UserRecord[] { return [...this.users]; } } class MockAuthMailService { readonly sentMessages: Array<{ email: string; code: string; ttlSeconds: number; }> = []; async sendLoginCode(email: string, code: string, ttlSeconds: number): Promise { this.sentMessages.push({ email, code, ttlSeconds }); } } describe("AuthService", () => { let authService: AuthService; let prismaService: InMemoryAuthPrismaService; let authMailService: MockAuthMailService; beforeEach(async () => { prismaService = new InMemoryAuthPrismaService(); authMailService = new MockAuthMailService(); const moduleRef: TestingModule = await Test.createTestingModule({ providers: [ AuthService, DataEncryptionService, { provide: PrismaService, useValue: prismaService }, { provide: AuthMailService, useValue: authMailService }, { provide: JwtService, useValue: { signAsync: async (payload: Record) => `signed-${String(payload["sub"])}-${String(payload["email"])}` } }, { provide: ConfigService, useValue: { get: (key: string) => { switch (key) { case "AUTH_EMAIL_CODE_TTL_SECONDS": return "300"; case "AUTH_ACCESS_EXPIRES_IN_SECONDS": return "900"; case "AUTH_REFRESH_EXPIRES_IN_SECONDS": return "2592000"; case "AUTH_TOTP_ISSUER": return "TodoList"; case "DATA_ENCRYPTION_SECRET": return "test-data-encryption-secret"; default: return undefined; } } } } ] }).compile(); authService = moduleRef.get(AuthService); }); it("should encrypt user email in database while keeping login flow available", async () => { await authService.sendEmailCode("User@Example.com"); expect(authMailService.sentMessages).toHaveLength(1); expect(authMailService.sentMessages[0]?.email).toBe("user@example.com"); const loginResult = await authService.loginWithEmailCode( "USER@example.com", authMailService.sentMessages[0]?.code ?? "" ); expect(loginResult.user.email).toBe("user@example.com"); expect(loginResult.accessToken).toContain("user@example.com"); const storedUser = prismaService.getUsers()[0]; expect(storedUser?.email).not.toBe("user@example.com"); expect(storedUser?.emailHash).toMatch(/^[a-f0-9]{64}$/); }); it("should decrypt user email when refreshing token", async () => { await authService.sendEmailCode("refresh@example.com"); const loginResult = await authService.loginWithEmailCode( "refresh@example.com", authMailService.sentMessages[0]?.code ?? "" ); const refreshResult = await authService.refreshTokens(loginResult.refreshToken); expect(refreshResult.user.email).toBe("refresh@example.com"); expect(refreshResult.accessToken).toContain("refresh@example.com"); }); it("should reject invalid verification code", async () => { await authService.sendEmailCode("invalid@example.com"); await expect( authService.loginWithEmailCode("invalid@example.com", "000000") ).rejects.toBeInstanceOf(UnauthorizedException); }); });