import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { HttpException } from '@nestjs/common'; import type { Request, Response } from 'express'; vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') })); vi.mock('../../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) })); import { AuthPublicController } from '../../../src/nest/auth/auth-public.controller'; import { AuthController } from '../../../src/nest/auth/auth.controller'; import { RateLimitService } from '../../../src/nest/auth/rate-limit.service'; import type { AuthService } from '../../../src/nest/auth/auth.service'; import { writeAudit } from '../../../src/services/auditLog'; import { isDemoEmail } from '../../../src/services/demo'; import type { User } from '../../../src/types'; const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User; const req = { ip: '9.9.9.9', headers: {} } as Request; const res = {} as Response; function asvc(o: Partial = {}): AuthService { return { setAuthCookie: vi.fn(), clearAuthCookie: vi.fn(), getAppUrl: vi.fn(() => 'https://x'), sendPasswordResetEmail: vi.fn(), ...o } as unknown as AuthService; } function rl(): RateLimitService { return new RateLimitService(); } function thrown(fn: () => unknown): { status: number; body: unknown } { try { fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; return { status: e.getStatus(), body: e.getResponse() }; } throw new Error('expected throw'); } async function thrownAsync(fn: () => Promise): Promise<{ status: number; body: unknown }> { try { await fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; return { status: e.getStatus(), body: e.getResponse() }; } throw new Error('expected throw'); } beforeEach(() => vi.clearAllMocks()); afterEach(() => { delete process.env.DEMO_MODE; }); describe('RateLimitService', () => { it('allows up to max then blocks within the window; buckets are isolated', () => { const s = rl(); expect(s.check('login', 'ip', 2, 1000, 0)).toBe(true); expect(s.check('login', 'ip', 2, 1000, 10)).toBe(true); expect(s.check('login', 'ip', 2, 1000, 20)).toBe(false); // 3rd within window expect(s.check('mfa', 'ip', 2, 1000, 20)).toBe(true); // different bucket expect(s.check('login', 'ip', 2, 1000, 2000)).toBe(true); // window elapsed -> reset }); }); describe('AuthPublicController', () => { it('demo-login maps error, else sets the cookie + returns token/user', () => { expect(thrown(() => new AuthPublicController(asvc({ demoLogin: vi.fn().mockReturnValue({ error: 'Demo disabled', status: 403 }) } as Partial), rl()).demoLogin(req, res))).toEqual({ status: 403, body: { error: 'Demo disabled' } }); const setAuthCookie = vi.fn(); const c = new AuthPublicController(asvc({ demoLogin: vi.fn().mockReturnValue({ token: 'tk', user }), setAuthCookie } as Partial), rl()); expect(c.demoLogin(req, res)).toEqual({ token: 'tk', user }); expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req); }); it('register audits + sets cookie; maps error', () => { expect(thrown(() => new AuthPublicController(asvc({ registerUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial), rl()).register({}, req, res))).toEqual({ status: 409, body: { error: 'Email taken' } }); const setAuthCookie = vi.fn(); const c = new AuthPublicController(asvc({ registerUser: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1, auditDetails: {} }), setAuthCookie } as Partial), rl()); expect(c.register({ email: 'a@b.c', password: 'p' }, req, res)).toEqual({ token: 'tk', user }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.register' })); expect(setAuthCookie).toHaveBeenCalled(); }); it('invite 429 when rate-limited', () => { const s = rl(); s.check('login', '9.9.9.9', 10, 15 * 60 * 1000, Date.now()); // not exhausted yet const c = new AuthPublicController(asvc({ validateInviteToken: vi.fn().mockReturnValue({ valid: true, max_uses: 1, used_count: 0, expires_at: null }) } as Partial), s); expect(c.invite('tok', req)).toEqual({ valid: true, max_uses: 1, used_count: 0, expires_at: null }); }); it('login: mfa branch, success cookie, error mapping', async () => { const setAuthCookie = vi.fn(); const mfa = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt' }) } as Partial), rl()); expect(await mfa.login({}, req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' }); const ok = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ token: 'tk', user }), setAuthCookie } as Partial), rl()); expect(await ok.login({}, req, res)).toEqual({ token: 'tk', user }); expect(setAuthCookie).toHaveBeenCalled(); const bad = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ error: 'Bad creds', status: 401, auditAction: 'user.login_fail' }) } as Partial), rl()); expect(await thrownAsync(() => bad.login({}, req, res))).toEqual({ status: 401, body: { error: 'Bad creds' } }); }, 10000); it('forgot-password issues a reset email then returns the generic ok', async () => { const sendPasswordResetEmail = vi.fn().mockResolvedValue({ delivered: true }); const c = new AuthPublicController(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'issued', tokenForDelivery: 'rt', userEmail: 'a@b.c', userId: 1 }), sendPasswordResetEmail } as Partial), rl()); expect(await c.forgotPassword({ email: 'a@b.c' }, req)).toEqual({ ok: true }); expect(sendPasswordResetEmail).toHaveBeenCalledWith('a@b.c', 'https://x/reset-password?token=rt', 1); }, 10000); it('reset-password: error audits a fail, mfa branch, success', () => { expect(thrown(() => new AuthPublicController(asvc({ resetPassword: vi.fn().mockReturnValue({ error: 'Invalid token', status: 400 }) } as Partial), rl()).resetPassword({}, req))).toEqual({ status: 400, body: { error: 'Invalid token' } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_reset_fail' })); expect(new AuthPublicController(asvc({ resetPassword: vi.fn().mockReturnValue({ mfa_required: true }) } as Partial), rl()).resetPassword({}, req)).toEqual({ mfa_required: true }); expect(new AuthPublicController(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial), rl()).resetPassword({}, req)).toEqual({ success: true }); }); it('mfa/verify-login sets cookie + audits; logout clears cookie', () => { const setAuthCookie = vi.fn(); const c = new AuthPublicController(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1 }), setAuthCookie } as Partial), rl()); expect(c.verifyMfaLogin({}, req, res)).toEqual({ token: 'tk', user }); expect(setAuthCookie).toHaveBeenCalled(); const clearAuthCookie = vi.fn(); expect(new AuthPublicController(asvc({ clearAuthCookie } as Partial), rl()).logout(req, res)).toEqual({ success: true }); expect(clearAuthCookie).toHaveBeenCalledWith(res, req); }); }); describe('AuthController (authenticated)', () => { it('GET /me 404 when missing, else returns the loaded user', () => { expect(thrown(() => new AuthController(asvc({ getCurrentUser: vi.fn().mockReturnValue(undefined) } as Partial), rl()).me(user))).toEqual({ status: 404, body: { error: 'User not found' } }); expect(new AuthController(asvc({ getCurrentUser: vi.fn().mockReturnValue({ id: 1 }) } as Partial), rl()).me(user)).toEqual({ user: { id: 1 } }); }); it('change-password maps error, else audits', () => { expect(thrown(() => new AuthController(asvc({ changePassword: vi.fn().mockReturnValue({ error: 'Wrong', status: 400 }) } as Partial), rl()).changePassword(user, {}, req))).toEqual({ status: 400, body: { error: 'Wrong' } }); expect(new AuthController(asvc({ changePassword: vi.fn().mockReturnValue({}) } as Partial), rl()).changePassword(user, {}, req)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_change' })); }); it('avatar 403 in demo mode, 400 without a file, else saves', async () => { process.env.DEMO_MODE = 'true'; vi.mocked(isDemoEmail).mockReturnValue(true); expect(await thrownAsync(() => new AuthController(asvc(), rl()).avatar(user, { filename: 'a.jpg' } as Express.Multer.File))).toEqual({ status: 403, body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' } }); vi.mocked(isDemoEmail).mockReturnValue(false); delete process.env.DEMO_MODE; expect(await thrownAsync(() => new AuthController(asvc(), rl()).avatar(user, undefined))).toEqual({ status: 400, body: { error: 'No image uploaded' } }); const saveAvatar = vi.fn().mockResolvedValue({ avatar: '/a.jpg' }); expect(await new AuthController(asvc({ saveAvatar } as Partial), rl()).avatar(user, { filename: 'a.jpg' } as Express.Multer.File)).toEqual({ avatar: '/a.jpg' }); }); it('mfa/setup awaits the QR promise, maps a generation failure to 500', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}); const ok = new AuthController(asvc({ setupMfa: vi.fn().mockReturnValue({ secret: 's', otpauth_url: 'o', qrPromise: Promise.resolve('') }) } as Partial), rl()); expect(await ok.mfaSetup(user)).toEqual({ secret: 's', otpauth_url: 'o', qr_svg: '' }); const fail = new AuthController(asvc({ setupMfa: vi.fn().mockReturnValue({ secret: 's', otpauth_url: 'o', qrPromise: Promise.reject(new Error('x')) }) } as Partial), rl()); expect(await thrownAsync(() => fail.mfaSetup(user))).toEqual({ status: 500, body: { error: 'Could not generate QR code' } }); }); it('mfa/enable audits + returns backup codes; mcp-tokens create 201', () => { const enable = new AuthController(asvc({ enableMfa: vi.fn().mockReturnValue({ mfa_enabled: true, backup_codes: ['a', 'b'] }) } as Partial), rl()); expect(enable.mfaEnable(user, { code: '123456' }, req)).toEqual({ success: true, mfa_enabled: true, backup_codes: ['a', 'b'] }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_enable' })); const tok = new AuthController(asvc({ createMcpToken: vi.fn().mockReturnValue({ token: 'mcp_x' }) } as Partial), rl()); expect(tok.createMcpToken(user, { name: 'CLI' }, req)).toEqual({ token: 'mcp_x' }); }); it('resource-token 503 when unavailable, else returns the token payload', () => { expect(thrown(() => new AuthController(asvc({ createResourceToken: vi.fn().mockReturnValue(null) } as Partial), rl()).resourceToken(user, {}))).toEqual({ status: 503, body: { error: 'Service unavailable' } }); expect(new AuthController(asvc({ createResourceToken: vi.fn().mockReturnValue({ token: 'rt' }) } as Partial), rl()).resourceToken(user, { purpose: 'download' })).toEqual({ token: 'rt' }); }); it('rate-limited account ops throw 429 once the bucket is exhausted', () => { const s = rl(); const now = Date.now(); // exhaust the shared 'login' bucket for this ip (max 5) for (let i = 0; i < 5; i++) s.check('login', '9.9.9.9', 5, 15 * 60 * 1000, now); const c = new AuthController(asvc({ changePassword: vi.fn() } as Partial), s); expect(thrown(() => c.changePassword(user, {}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); });