import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import Fastify from 'fastify'; import type { FastifyInstance } from 'fastify'; import { registerAuthRoutes } from '../src/routes/auth.js'; import { errorHandler } from '../src/middleware/error-handler.js'; import type { AuthService, LoginResult } from '../src/services/auth.service.js'; import type { UserService } from '../src/services/user.service.js'; import type { GroupService } from '../src/services/group.service.js'; import type { RbacDefinitionService } from '../src/services/rbac-definition.service.js'; import type { RbacService, RbacAction } from '../src/services/rbac.service.js'; import type { SafeUser } from '../src/repositories/user.repository.js'; import type { RbacDefinition } from '@prisma/client'; let app: FastifyInstance; afterEach(async () => { if (app) await app.close(); }); function makeLoginResult(overrides?: Partial): LoginResult { return { token: 'test-token-123', expiresAt: new Date(Date.now() + 86400_000), user: { id: 'user-1', email: 'admin@example.com', role: 'user' }, ...overrides, }; } function makeSafeUser(overrides?: Partial): SafeUser { return { id: 'user-1', email: 'admin@example.com', name: null, role: 'user', provider: 'local', externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date(), ...overrides, }; } function makeRbacDef(overrides?: Partial): RbacDefinition { return { id: 'rbac-1', name: 'bootstrap-admin', subjects: [{ kind: 'Group', name: 'admin' }], roleBindings: [ { role: 'edit', resource: '*' }, { role: 'run', resource: '*' }, { role: 'run', action: 'impersonate' }, { role: 'run', action: 'logs' }, { role: 'run', action: 'backup' }, { role: 'run', action: 'restore' }, { role: 'run', action: 'audit-purge' }, ], version: 1, createdAt: new Date(), updatedAt: new Date(), ...overrides, }; } interface MockDeps { authService: { login: ReturnType; logout: ReturnType; findSession: ReturnType; impersonate: ReturnType; }; userService: { count: ReturnType; create: ReturnType; list: ReturnType; getById: ReturnType; getByEmail: ReturnType; delete: ReturnType; }; groupService: { create: ReturnType; list: ReturnType; getById: ReturnType; getByName: ReturnType; update: ReturnType; delete: ReturnType; }; rbacDefinitionService: { create: ReturnType; list: ReturnType; getById: ReturnType; getByName: ReturnType; update: ReturnType; delete: ReturnType; }; rbacService: { canAccess: ReturnType; canRunOperation: ReturnType; getPermissions: ReturnType; }; } function createMockDeps(): MockDeps { return { authService: { login: vi.fn(async () => makeLoginResult()), logout: vi.fn(async () => {}), findSession: vi.fn(async () => null), impersonate: vi.fn(async () => makeLoginResult({ token: 'impersonated-token' })), }, userService: { count: vi.fn(async () => 0), create: vi.fn(async () => makeSafeUser()), list: vi.fn(async () => []), getById: vi.fn(async () => makeSafeUser()), getByEmail: vi.fn(async () => makeSafeUser()), delete: vi.fn(async () => {}), }, groupService: { create: vi.fn(async () => ({ id: 'grp-1', name: 'admin', description: 'Bootstrap admin group', members: [] })), list: vi.fn(async () => []), getById: vi.fn(async () => null), getByName: vi.fn(async () => null), update: vi.fn(async () => null), delete: vi.fn(async () => {}), }, rbacDefinitionService: { create: vi.fn(async () => makeRbacDef()), list: vi.fn(async () => []), getById: vi.fn(async () => makeRbacDef()), getByName: vi.fn(async () => null), update: vi.fn(async () => makeRbacDef()), delete: vi.fn(async () => {}), }, rbacService: { canAccess: vi.fn(async () => false), canRunOperation: vi.fn(async () => false), getPermissions: vi.fn(async () => []), }, }; } function createApp(deps: MockDeps): Promise { app = Fastify({ logger: false }); app.setErrorHandler(errorHandler); registerAuthRoutes(app, deps as unknown as { authService: AuthService; userService: UserService; groupService: GroupService; rbacDefinitionService: RbacDefinitionService; rbacService: RbacService; }); return app.ready(); } describe('Auth Bootstrap', () => { describe('GET /api/v1/auth/status', () => { it('returns hasUsers: false when no users exist', async () => { const deps = createMockDeps(); deps.userService.count.mockResolvedValue(0); await createApp(deps); const res = await app.inject({ method: 'GET', url: '/api/v1/auth/status' }); expect(res.statusCode).toBe(200); expect(res.json<{ hasUsers: boolean }>().hasUsers).toBe(false); }); it('returns hasUsers: true when users exist', async () => { const deps = createMockDeps(); deps.userService.count.mockResolvedValue(1); await createApp(deps); const res = await app.inject({ method: 'GET', url: '/api/v1/auth/status' }); expect(res.statusCode).toBe(200); expect(res.json<{ hasUsers: boolean }>().hasUsers).toBe(true); }); }); describe('POST /api/v1/auth/bootstrap', () => { it('creates admin user, admin group, RBAC definition targeting group, and returns session token', async () => { const deps = createMockDeps(); deps.userService.count.mockResolvedValue(0); await createApp(deps); const res = await app.inject({ method: 'POST', url: '/api/v1/auth/bootstrap', payload: { email: 'admin@example.com', password: 'securepass123' }, }); expect(res.statusCode).toBe(201); const body = res.json(); expect(body.token).toBe('test-token-123'); expect(body.user.email).toBe('admin@example.com'); // Verify user was created expect(deps.userService.create).toHaveBeenCalledWith({ email: 'admin@example.com', password: 'securepass123', }); // Verify admin group was created with the user as member expect(deps.groupService.create).toHaveBeenCalledWith({ name: 'admin', description: 'Bootstrap admin group', members: ['admin@example.com'], }); // Verify RBAC definition targets the Group, not the User expect(deps.rbacDefinitionService.create).toHaveBeenCalledWith({ name: 'bootstrap-admin', subjects: [{ kind: 'Group', name: 'admin' }], roleBindings: [ { role: 'edit', resource: '*' }, { role: 'run', resource: '*' }, { role: 'run', action: 'impersonate' }, { role: 'run', action: 'logs' }, { role: 'run', action: 'backup' }, { role: 'run', action: 'restore' }, { role: 'run', action: 'audit-purge' }, ], }); // Verify auto-login was called expect(deps.authService.login).toHaveBeenCalledWith('admin@example.com', 'securepass123'); }); it('passes name when provided', async () => { const deps = createMockDeps(); deps.userService.count.mockResolvedValue(0); await createApp(deps); await app.inject({ method: 'POST', url: '/api/v1/auth/bootstrap', payload: { email: 'admin@example.com', password: 'securepass123', name: 'Admin User' }, }); expect(deps.userService.create).toHaveBeenCalledWith({ email: 'admin@example.com', password: 'securepass123', name: 'Admin User', }); }); it('returns 409 when users already exist', async () => { const deps = createMockDeps(); deps.userService.count.mockResolvedValue(1); await createApp(deps); const res = await app.inject({ method: 'POST', url: '/api/v1/auth/bootstrap', payload: { email: 'admin@example.com', password: 'securepass123' }, }); expect(res.statusCode).toBe(409); expect(res.json<{ error: string }>().error).toContain('Users already exist'); // Should NOT have created user, group, or RBAC expect(deps.userService.create).not.toHaveBeenCalled(); expect(deps.groupService.create).not.toHaveBeenCalled(); expect(deps.rbacDefinitionService.create).not.toHaveBeenCalled(); }); it('validates email and password via UserService', async () => { const deps = createMockDeps(); deps.userService.count.mockResolvedValue(0); // Simulate Zod validation error from UserService deps.userService.create.mockRejectedValue( Object.assign(new Error('Validation error'), { statusCode: 400, issues: [] }), ); await createApp(deps); const res = await app.inject({ method: 'POST', url: '/api/v1/auth/bootstrap', payload: { email: 'not-an-email', password: 'short' }, }); // The error handler should handle the validation error expect(res.statusCode).toBeGreaterThanOrEqual(400); }); }); describe('POST /api/v1/auth/login', () => { it('logs in successfully', async () => { const deps = createMockDeps(); await createApp(deps); const res = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { email: 'admin@example.com', password: 'securepass123' }, }); expect(res.statusCode).toBe(200); expect(res.json().token).toBe('test-token-123'); }); }); describe('POST /api/v1/auth/logout', () => { it('logs out with valid token', async () => { const deps = createMockDeps(); deps.authService.findSession.mockResolvedValue({ userId: 'user-1', expiresAt: new Date(Date.now() + 86400_000), }); await createApp(deps); const res = await app.inject({ method: 'POST', url: '/api/v1/auth/logout', headers: { authorization: 'Bearer valid-token' }, }); expect(res.statusCode).toBe(200); expect(res.json<{ success: boolean }>().success).toBe(true); expect(deps.authService.logout).toHaveBeenCalledWith('valid-token'); }); it('returns 401 without auth', async () => { const deps = createMockDeps(); await createApp(deps); const res = await app.inject({ method: 'POST', url: '/api/v1/auth/logout', }); expect(res.statusCode).toBe(401); }); }); describe('POST /api/v1/auth/impersonate', () => { it('creates session for target user when caller is admin', async () => { const deps = createMockDeps(); // Auth: valid session deps.authService.findSession.mockResolvedValue({ userId: 'admin-user-id', expiresAt: new Date(Date.now() + 86400_000), }); // RBAC: allow impersonate operation deps.rbacService.canRunOperation.mockResolvedValue(true); // Impersonate returns token for target deps.authService.impersonate.mockResolvedValue( makeLoginResult({ token: 'impersonated-token', user: { id: 'user-2', email: 'target@example.com', role: 'user' } }), ); await createApp(deps); const res = await app.inject({ method: 'POST', url: '/api/v1/auth/impersonate', headers: { authorization: 'Bearer admin-token' }, payload: { email: 'target@example.com' }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.token).toBe('impersonated-token'); expect(body.user.email).toBe('target@example.com'); expect(deps.authService.impersonate).toHaveBeenCalledWith('target@example.com'); }); it('returns 401 without auth', async () => { const deps = createMockDeps(); await createApp(deps); const res = await app.inject({ method: 'POST', url: '/api/v1/auth/impersonate', payload: { email: 'target@example.com' }, }); expect(res.statusCode).toBe(401); }); it('returns 403 when caller lacks admin permission on users', async () => { const deps = createMockDeps(); // Auth: valid session deps.authService.findSession.mockResolvedValue({ userId: 'non-admin-id', expiresAt: new Date(Date.now() + 86400_000), }); // RBAC: deny deps.rbacService.canRunOperation.mockResolvedValue(false); await createApp(deps); const res = await app.inject({ method: 'POST', url: '/api/v1/auth/impersonate', headers: { authorization: 'Bearer regular-token' }, payload: { email: 'target@example.com' }, }); expect(res.statusCode).toBe(403); }); it('returns 401 when impersonation target does not exist', async () => { const deps = createMockDeps(); // Auth: valid session deps.authService.findSession.mockResolvedValue({ userId: 'admin-user-id', expiresAt: new Date(Date.now() + 86400_000), }); // RBAC: allow deps.rbacService.canRunOperation.mockResolvedValue(true); // Impersonate fails — user not found const authError = new Error('User not found'); (authError as Error & { statusCode: number }).statusCode = 401; deps.authService.impersonate.mockRejectedValue(authError); await createApp(deps); const res = await app.inject({ method: 'POST', url: '/api/v1/auth/impersonate', headers: { authorization: 'Bearer admin-token' }, payload: { email: 'nonexistent@example.com' }, }); expect(res.statusCode).toBe(401); }); }); });