import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { ConflictException, ForbiddenException, InternalServerErrorException, UnauthorizedException, } from "@nestjs/common"; import { hash } from "bcryptjs"; import { LocalAuthService } from "./local-auth.service"; import { PrismaService } from "../../prisma/prisma.service"; describe("LocalAuthService", () => { let service: LocalAuthService; const mockTxSession = { create: vi.fn(), }; const mockTxWorkspace = { findFirst: vi.fn(), create: vi.fn(), }; const mockTxWorkspaceMember = { create: vi.fn(), }; const mockTxUser = { create: vi.fn(), findUnique: vi.fn(), }; const mockTx = { user: mockTxUser, workspace: mockTxWorkspace, workspaceMember: mockTxWorkspaceMember, session: mockTxSession, }; const mockPrismaService = { user: { findUnique: vi.fn(), }, session: { create: vi.fn(), }, $transaction: vi .fn() .mockImplementation((fn: (tx: typeof mockTx) => Promise) => fn(mockTx)), }; const originalEnv = { BREAKGLASS_SETUP_TOKEN: process.env.BREAKGLASS_SETUP_TOKEN, }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ LocalAuthService, { provide: PrismaService, useValue: mockPrismaService, }, ], }).compile(); service = module.get(LocalAuthService); vi.clearAllMocks(); }); afterEach(() => { vi.restoreAllMocks(); if (originalEnv.BREAKGLASS_SETUP_TOKEN !== undefined) { process.env.BREAKGLASS_SETUP_TOKEN = originalEnv.BREAKGLASS_SETUP_TOKEN; } else { delete process.env.BREAKGLASS_SETUP_TOKEN; } }); describe("setup", () => { const validSetupArgs = { email: "admin@example.com", name: "Break Glass Admin", password: "securePassword123!", setupToken: "valid-token-123", }; const mockCreatedUser = { id: "user-uuid-123", email: "admin@example.com", name: "Break Glass Admin", isLocalAuth: true, createdAt: new Date("2026-02-28T00:00:00Z"), }; const mockWorkspace = { id: "workspace-uuid-123", }; beforeEach(() => { process.env.BREAKGLASS_SETUP_TOKEN = "valid-token-123"; mockPrismaService.user.findUnique.mockResolvedValue(null); mockTxUser.create.mockResolvedValue(mockCreatedUser); mockTxWorkspace.findFirst.mockResolvedValue(mockWorkspace); mockTxWorkspaceMember.create.mockResolvedValue({}); mockTxSession.create.mockResolvedValue({}); }); it("should create a local auth user with hashed password", async () => { const result = await service.setup( validSetupArgs.email, validSetupArgs.name, validSetupArgs.password, validSetupArgs.setupToken ); expect(result.user).toEqual(mockCreatedUser); expect(result.session.token).toBeDefined(); expect(result.session.token.length).toBeGreaterThan(0); expect(result.session.expiresAt).toBeInstanceOf(Date); expect(result.session.expiresAt.getTime()).toBeGreaterThan(Date.now()); expect(mockTxUser.create).toHaveBeenCalledWith({ data: expect.objectContaining({ email: "admin@example.com", name: "Break Glass Admin", isLocalAuth: true, emailVerified: true, passwordHash: expect.any(String) as string, }), select: { id: true, email: true, name: true, isLocalAuth: true, createdAt: true, }, }); }); it("should assign OWNER role on default workspace", async () => { await service.setup( validSetupArgs.email, validSetupArgs.name, validSetupArgs.password, validSetupArgs.setupToken ); expect(mockTxWorkspaceMember.create).toHaveBeenCalledWith({ data: { workspaceId: "workspace-uuid-123", userId: "user-uuid-123", role: "OWNER", }, }); }); it("should create a new workspace if none exists", async () => { mockTxWorkspace.findFirst.mockResolvedValue(null); mockTxWorkspace.create.mockResolvedValue({ id: "new-workspace-uuid" }); await service.setup( validSetupArgs.email, validSetupArgs.name, validSetupArgs.password, validSetupArgs.setupToken ); expect(mockTxWorkspace.create).toHaveBeenCalledWith({ data: { name: "Default Workspace", ownerId: "user-uuid-123", settings: {}, }, select: { id: true }, }); expect(mockTxWorkspaceMember.create).toHaveBeenCalledWith({ data: { workspaceId: "new-workspace-uuid", userId: "user-uuid-123", role: "OWNER", }, }); }); it("should create a BetterAuth-compatible session", async () => { await service.setup( validSetupArgs.email, validSetupArgs.name, validSetupArgs.password, validSetupArgs.setupToken, "192.168.1.1", "TestAgent/1.0" ); expect(mockTxSession.create).toHaveBeenCalledWith({ data: { userId: "user-uuid-123", token: expect.any(String) as string, expiresAt: expect.any(Date) as Date, ipAddress: "192.168.1.1", userAgent: "TestAgent/1.0", }, }); }); it("should reject when BREAKGLASS_SETUP_TOKEN is not set", async () => { delete process.env.BREAKGLASS_SETUP_TOKEN; await expect( service.setup( validSetupArgs.email, validSetupArgs.name, validSetupArgs.password, validSetupArgs.setupToken ) ).rejects.toThrow(ForbiddenException); }); it("should reject when BREAKGLASS_SETUP_TOKEN is empty", async () => { process.env.BREAKGLASS_SETUP_TOKEN = ""; await expect( service.setup( validSetupArgs.email, validSetupArgs.name, validSetupArgs.password, validSetupArgs.setupToken ) ).rejects.toThrow(ForbiddenException); }); it("should reject when setup token does not match", async () => { await expect( service.setup( validSetupArgs.email, validSetupArgs.name, validSetupArgs.password, "wrong-token" ) ).rejects.toThrow(ForbiddenException); }); it("should reject when email already exists", async () => { mockPrismaService.user.findUnique.mockResolvedValue({ id: "existing-user", email: "admin@example.com", }); await expect( service.setup( validSetupArgs.email, validSetupArgs.name, validSetupArgs.password, validSetupArgs.setupToken ) ).rejects.toThrow(ConflictException); }); it("should return session token and expiry", async () => { const result = await service.setup( validSetupArgs.email, validSetupArgs.name, validSetupArgs.password, validSetupArgs.setupToken ); expect(typeof result.session.token).toBe("string"); expect(result.session.token.length).toBe(64); // 32 bytes hex expect(result.session.expiresAt).toBeInstanceOf(Date); }); }); describe("login", () => { const validPasswordHash = "$2a$12$LJ3m4ys3Lz/YgP7xYz5k5uU6b5F6X1234567890abcdefghijkl"; beforeEach(async () => { // Create a real bcrypt hash for testing const realHash = await hash("securePassword123!", 4); // Low rounds for test speed mockPrismaService.user.findUnique.mockResolvedValue({ id: "user-uuid-123", email: "admin@example.com", name: "Break Glass Admin", isLocalAuth: true, passwordHash: realHash, deactivatedAt: null, }); mockPrismaService.session.create.mockResolvedValue({}); }); it("should authenticate a valid local auth user", async () => { const result = await service.login("admin@example.com", "securePassword123!"); expect(result.user).toEqual({ id: "user-uuid-123", email: "admin@example.com", name: "Break Glass Admin", }); expect(result.session.token).toBeDefined(); expect(result.session.expiresAt).toBeInstanceOf(Date); }); it("should create a session with ip and user agent", async () => { await service.login("admin@example.com", "securePassword123!", "10.0.0.1", "Mozilla/5.0"); expect(mockPrismaService.session.create).toHaveBeenCalledWith({ data: { userId: "user-uuid-123", token: expect.any(String) as string, expiresAt: expect.any(Date) as Date, ipAddress: "10.0.0.1", userAgent: "Mozilla/5.0", }, }); }); it("should reject when user does not exist", async () => { mockPrismaService.user.findUnique.mockResolvedValue(null); await expect(service.login("nonexistent@example.com", "password123456")).rejects.toThrow( UnauthorizedException ); }); it("should reject when user is not a local auth user", async () => { mockPrismaService.user.findUnique.mockResolvedValue({ id: "user-uuid-123", email: "admin@example.com", name: "OIDC User", isLocalAuth: false, passwordHash: null, deactivatedAt: null, }); await expect(service.login("admin@example.com", "password123456")).rejects.toThrow( UnauthorizedException ); }); it("should reject when user is deactivated", async () => { const realHash = await hash("securePassword123!", 4); mockPrismaService.user.findUnique.mockResolvedValue({ id: "user-uuid-123", email: "admin@example.com", name: "Deactivated User", isLocalAuth: true, passwordHash: realHash, deactivatedAt: new Date("2026-01-01"), }); await expect(service.login("admin@example.com", "securePassword123!")).rejects.toThrow( new UnauthorizedException("Account has been deactivated") ); }); it("should reject when password is incorrect", async () => { await expect(service.login("admin@example.com", "wrongPassword123!")).rejects.toThrow( UnauthorizedException ); }); it("should throw InternalServerError when local auth user has no password hash", async () => { mockPrismaService.user.findUnique.mockResolvedValue({ id: "user-uuid-123", email: "admin@example.com", name: "Broken User", isLocalAuth: true, passwordHash: null, deactivatedAt: null, }); await expect(service.login("admin@example.com", "securePassword123!")).rejects.toThrow( InternalServerErrorException ); }); it("should not reveal whether email exists in error messages", async () => { mockPrismaService.user.findUnique.mockResolvedValue(null); try { await service.login("nonexistent@example.com", "password123456"); } catch (error) { expect(error).toBeInstanceOf(UnauthorizedException); expect((error as UnauthorizedException).message).toBe("Invalid email or password"); } }); }); });