import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; import { HttpException } from '@nestjs/common'; import type { Request, Response } from 'express'; vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') })); vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); vi.mock('../../../src/nest/common/demo', () => ({ isDemoEmail: vi.fn(() => false) })); import { AuthPublicController } from '../../../src/nest/auth/auth-public.controller'; import { AuthController } from '../../../src/nest/auth/auth.controller'; import type { TokenService } from '../../../src/nest/tokens/token.service'; import type { UserProfileService } from '../../../src/nest/auth/user-profile.service'; import { RateLimitService } from '../../../src/nest/common/rate-limit.service'; import type { AuthService } from '../../../src/nest/auth/auth.service'; import type { AuditService } from '../../../src/nest/audit/audit.service'; import { isDemoEmail } from '../../../src/nest/common/demo'; import type { User } from '../../../src/types'; import { anyBody } from '../../helpers/dto'; import type { ForgotPasswordDto } from '../../../src/nest/auth/auth.dto'; import type { SessionsService } from '../../../src/nest/sessions/sessions.service'; import { JWT_SECRET } from '../../../src/config'; import jwt from 'jsonwebtoken'; 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(); } // AuditService is constructor-injected since the auditLog DI migration; the // wrappers keep the historical construction sites positional. const writeAudit = vi.fn(); const audit = { writeAudit } as unknown as AuditService; // Logout ends the session through SessionsService, the controller's fourth argument. const endSession = vi.fn().mockResolvedValue(true); const sessionsStub = { endSession } as unknown as SessionsService; const apc = (a: AuthService, limiter: RateLimitService) => new AuthPublicController(a, limiter, audit, sessionsStub); // Tokens moved to TokenService; the controller takes it second. Stubbed via a // third, optional argument so every non-token call site stays as it was. const storageStub = { put: vi.fn().mockResolvedValue(undefined) } as unknown as import('../../../src/nest/storage/storage.service').StorageService; const ac = (a: AuthService, limiter: RateLimitService, t: Partial = {}, pr: Partial = {}) => new AuthController(a, pr as UserProfileService, t as TokenService, limiter, audit, new RuntimeEnvService(), storageStub); 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', async () => { const s = rl(); expect(await s.check('login', 'ip', 2, 1000, 0)).toBe(true); expect(await s.check('login', 'ip', 2, 1000, 10)).toBe(true); expect(await s.check('login', 'ip', 2, 1000, 20)).toBe(false); // 3rd within window expect(await s.check('mfa', 'ip', 2, 1000, 20)).toBe(true); // different bucket expect(await s.check('login', 'ip', 2, 1000, 2000)).toBe(true); // window elapsed -> reset }); it('reset clears a single named bucket, and reset() clears all of them', async () => { const s = rl(); await s.check('login', 'ip', 1, 1000, 0); // login bucket now at its cap await s.check('mfa', 'ip', 1, 1000, 0); // mfa bucket now at its cap expect(await s.check('login', 'ip', 1, 1000, 0)).toBe(false); await s.reset('login'); // only the login bucket expect(await s.check('login', 'ip', 1, 1000, 0)).toBe(true); expect(await s.check('mfa', 'ip', 1, 1000, 0)).toBe(false); // mfa untouched await s.reset(); // everything expect(await s.check('mfa', 'ip', 1, 1000, 0)).toBe(true); }); }); describe('AuthPublicController', () => { it('demo-login maps error, else sets the cookie + returns token/user', async () => { expect(await thrownAsync(() => apc(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 demoLogin = vi.fn().mockReturnValue({ token: 'tk', user }); const c = apc(asvc({ demoLogin, setAuthCookie } as Partial), rl()); expect(await c.demoLogin({ ...req, headers: { 'user-agent': 'Visitor' } } as Request, res)).toEqual({ token: 'tk', user }); expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', expect.objectContaining({ ip: '9.9.9.9' })); // The session records the device like every other sign-in does. expect(demoLogin).toHaveBeenCalledWith({ userAgent: 'Visitor' }); }); it('register audits + sets cookie; maps error', async () => { expect(await thrownAsync(() => apc(asvc({ registerUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial), rl()).register(anyBody(), req, res))).toEqual({ status: 409, body: { error: 'Email taken' } }); const setAuthCookie = vi.fn(); const c = apc(asvc({ registerUser: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1, auditDetails: {} }), setAuthCookie } as Partial), rl()); expect(await 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', async () => { const s = rl(); await s.check('login', '9.9.9.9', 10, 15 * 60 * 1000, Date.now()); // not exhausted yet const c = apc(asvc({ validateInviteToken: vi.fn().mockReturnValue({ valid: true, max_uses: 1, used_count: 0, expires_at: null }) } as Partial), s); expect(await 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 = apc(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt' }) } as Partial), rl()); expect(await mfa.login(anyBody(), req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' }); const ok = apc(asvc({ loginUser: vi.fn().mockReturnValue({ token: 'tk', user, remember: true }), setAuthCookie } as Partial), rl()); expect(await ok.login(anyBody(), req, res)).toEqual({ token: 'tk', user }); // The "remember me" flag from the service rides through to the cookie service. expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req, true); const bad = apc(asvc({ loginUser: vi.fn().mockReturnValue({ error: 'Bad creds', status: 401, auditAction: 'user.login_fail' }) } as Partial), rl()); expect(await thrownAsync(() => bad.login(anyBody(), 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 = apc(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', async () => { expect(await thrownAsync(() => apc(asvc({ resetPassword: vi.fn().mockReturnValue({ error: 'Invalid token', status: 400 }) } as Partial), rl()).resetPassword(anyBody(), req))).toEqual({ status: 400, body: { error: 'Invalid token' } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_reset_fail' })); expect(await apc(asvc({ resetPassword: vi.fn().mockReturnValue({ mfa_required: true }) } as Partial), rl()).resetPassword(anyBody(), req)).toEqual({ mfa_required: true }); expect(await apc(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial), rl()).resetPassword(anyBody(), req)).toEqual({ success: true }); }); it('app-config forwards the optional user (present and absent)', async () => { const getAppConfig = vi.fn().mockReturnValue({ version: '3' }); const c = apc(asvc({ getAppConfig } as Partial), rl()); expect(await c.appConfig({ user } as unknown as Request)).toEqual({ version: '3' }); expect(getAppConfig).toHaveBeenLastCalledWith(user); expect(await c.appConfig({} as Request)).toEqual({ version: '3' }); expect(getAppConfig).toHaveBeenLastCalledWith(undefined); }); it('invite maps a service error', async () => { const c = apc(asvc({ validateInviteToken: vi.fn().mockReturnValue({ error: 'Expired', status: 410 }) } as Partial), rl()); expect(await thrownAsync(() => c.invite('tok', req))).toEqual({ status: 410, body: { error: 'Expired' } }); }); it('login takes the mfa-required branch and never sets a cookie', async () => { const setAuthCookie = vi.fn(); const c = apc(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt', auditAction: 'user.login_mfa' }), setAuthCookie } as Partial), rl()); expect(await c.login(anyBody(), req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' }); expect(setAuthCookie).not.toHaveBeenCalled(); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login_mfa' })); }, 10000); it('forgot-password: non-issued reason and a delivery failure both still return ok', async () => { // Non-issued (unknown email / throttled): audits the reason, no email sent. const sendNever = vi.fn(); const skip = apc(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'not_found', userId: null }), sendPasswordResetEmail: sendNever } as Partial), rl()); expect(await skip.forgotPassword({ email: 'x@y.z' }, req)).toEqual({ ok: true }); expect(sendNever).not.toHaveBeenCalled(); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_reset_request', details: { reason: 'not_found' } })); // Issued but the mailer throws: swallowed, audited as failed, still ok. const boom = vi.fn().mockRejectedValue(new Error('smtp')); const fail = apc(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'issued', tokenForDelivery: 'rt', userEmail: 'a@b.c', userId: 1 }), sendPasswordResetEmail: boom } as Partial), rl()); expect(await fail.forgotPassword({ email: 'a@b.c' }, req)).toEqual({ ok: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ details: { delivered: 'failed' } })); }, 10000); it('forgot-password ignores a non-string email body', async () => { const requestPasswordReset = vi.fn().mockReturnValue({ reason: 'not_found', userId: null }); const c = apc(asvc({ requestPasswordReset } as Partial), rl()); expect(await c.forgotPassword(anyBody({ email: 42 as unknown as string }), req)).toEqual({ ok: true }); expect(requestPasswordReset).toHaveBeenCalledWith('', expect.any(String)); }, 10000); it('reset-password 429 once the dedicated reset bucket is exhausted', async () => { const s = rl(); const now = Date.now(); for (let i = 0; i < 5; i++) await s.check('reset', '9.9.9.9', 5, 15 * 60 * 1000, now); const c = apc(asvc({ resetPassword: vi.fn() } as Partial), s); expect(await thrownAsync(() => c.resetPassword(anyBody(), req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); it('mfa/verify-login maps a service error', async () => { const c = apc(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ error: 'Bad code', status: 401 }) } as Partial), rl()); expect(await thrownAsync(() => c.verifyMfaLogin(anyBody(), req, res))).toEqual({ status: 401, body: { error: 'Bad code' } }); }); it('demo-login + register + invite throw 429 when the login bucket is exhausted', async () => { const s = rl(); const now = Date.now(); for (let i = 0; i < 10; i++) await s.check('login', '9.9.9.9', 10, 15 * 60 * 1000, now); const c = apc(asvc({ registerUser: vi.fn(), validateInviteToken: vi.fn() } as Partial), s); expect(await thrownAsync(() => c.register(anyBody(), req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); expect(await thrownAsync(() => c.invite('t', req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); it('mfa/verify-login sets cookie + audits; logout clears cookie', async () => { const setAuthCookie = vi.fn(); const c = apc(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1 }), setAuthCookie } as Partial), rl()); expect(await c.verifyMfaLogin(anyBody(), req, res)).toEqual({ token: 'tk', user }); expect(setAuthCookie).toHaveBeenCalled(); const clearAuthCookie = vi.fn(); expect(await apc(asvc({ clearAuthCookie } as Partial), rl()).logout(req, res)).toEqual({ success: true }); expect(clearAuthCookie).toHaveBeenCalledWith(res, req); // No token: nothing to end, the cookie still goes. expect(endSession).toHaveBeenCalledWith(null); }); it('logout ends the session a well-signed token names, expired or not', async () => { const expired = jwt.sign({ id: 1, pv: 0, exp: Math.floor(Date.now() / 1000) - 60 }, JWT_SECRET, { algorithm: 'HS256', jwtid: 'sid-1' }); const withCookie = { ip: '9.9.9.9', headers: {}, cookies: { trek_session: expired } } as unknown as Request; const clearAuthCookie = vi.fn(); expect(await apc(asvc({ clearAuthCookie } as Partial), rl()).logout(withCookie, res)).toEqual({ success: true }); expect(endSession).toHaveBeenCalledWith(expect.objectContaining({ id: 1, jti: 'sid-1' })); expect(clearAuthCookie).toHaveBeenCalledWith(res, withCookie); }); it('logout with a forged token only clears the cookie', async () => { const forged = jwt.sign({ id: 1, pv: 0 }, 'not-the-secret', { algorithm: 'HS256', jwtid: 'sid-1' }); const withHeader = { ip: '9.9.9.9', headers: { authorization: `Bearer ${forged}` } } as unknown as Request; const clearAuthCookie = vi.fn(); expect(await apc(asvc({ clearAuthCookie } as Partial), rl()).logout(withHeader, res)).toEqual({ success: true }); expect(endSession).toHaveBeenCalledWith(null); expect(clearAuthCookie).toHaveBeenCalled(); }); it('login, register and MFA login hand the device to the service', async () => { const withAgent = { ip: '9.9.9.9', headers: { 'user-agent': 'Firefox/130' } } as unknown as Request; const loginUser = vi.fn().mockReturnValue({ token: 'tk', user }); const registerUser = vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1 }); const verifyMfaLogin = vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1 }); const c = apc(asvc({ loginUser, registerUser, verifyMfaLogin } as Partial), rl()); await c.login(anyBody(), withAgent, res); await c.register(anyBody(), withAgent, res); await c.verifyMfaLogin(anyBody(), withAgent, res); expect(loginUser).toHaveBeenCalledWith(anyBody(), { userAgent: 'Firefox/130' }); expect(registerUser).toHaveBeenCalledWith(anyBody(), { userAgent: 'Firefox/130' }); expect(verifyMfaLogin).toHaveBeenCalledWith(anyBody(), { userAgent: 'Firefox/130' }); }, 10000); }); describe('AuthController (authenticated)', () => { it('GET /me 404 when missing, else returns the loaded user', async () => { expect(await thrownAsync(() => ac(asvc({ getCurrentUser: vi.fn().mockReturnValue(undefined) } as Partial), rl()).me(user))).toEqual({ status: 404, body: { error: 'User not found' } }); expect(await ac(asvc({ getCurrentUser: vi.fn().mockReturnValue({ id: 1 }) } as Partial), rl()).me(user)).toEqual({ user: { id: 1 } }); }); it('change-password maps error, else audits', async () => { expect(await thrownAsync(() => ac(asvc({ changePassword: vi.fn().mockReturnValue({ error: 'Wrong', status: 400 }) } as Partial), rl()).changePassword(user, anyBody(), req, res))).toEqual({ status: 400, body: { error: 'Wrong' } }); expect(await ac(asvc({ changePassword: vi.fn().mockReturnValue({}) } as Partial), rl()).changePassword(user, anyBody(), req, res)).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(() => ac(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(() => ac(asvc(), rl()).avatar(user, undefined))).toEqual({ status: 400, body: { error: 'No image uploaded' } }); const saveAvatar = vi.fn().mockResolvedValue({ avatar: '/a.jpg' }); expect(await ac(asvc({}), rl(), {}, { saveAvatar }).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 = ac(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 = ac(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', async () => { const enable = ac(asvc({ enableMfa: vi.fn().mockReturnValue({ mfa_enabled: true, backup_codes: ['a', 'b'] }) } as Partial), rl()); expect(await 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 = ac(asvc({}), rl(), { createMcpToken: vi.fn().mockReturnValue({ token: 'mcp_x' }) }); expect(await tok.createMcpToken(user, { name: 'CLI' }, req)).toEqual({ token: 'mcp_x' }); }); it('resource-token 503 when unavailable, else returns the token payload', async () => { expect(await thrownAsync(() => ac(asvc({}), rl(), { createResourceToken: vi.fn().mockReturnValue(null) }).resourceToken(user, {}))).toEqual({ status: 503, body: { error: 'Service unavailable' } }); expect(await ac(asvc({}), rl(), { createResourceToken: vi.fn().mockReturnValue({ token: 'rt' }) }).resourceToken(user, { purpose: 'download' })).toEqual({ token: 'rt' }); }); it('ws/resource tokens throttle on their own buckets, so a mint loop cannot drain the store or block login', async () => { const s = rl(); const now = Date.now(); for (let i = 0; i < 120; i++) await s.check('ws_token', String(user.id), 120, 15 * 60 * 1000, now); expect(await thrownAsync(() => ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user))) .toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); // The login bucket is untouched: an exhausted socket loop must not lock the account out. expect(await s.check('login', '9.9.9.9', 5, 15 * 60 * 1000, now)).toBe(true); const s2 = rl(); for (let i = 0; i < 120; i++) await s2.check('resource_token', String(user.id), 120, 15 * 60 * 1000, now); expect(await thrownAsync(() => ac(asvc({}), s2, { createResourceToken: vi.fn().mockReturnValue({ token: 'rt' }) }).resourceToken(user, {}))) .toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); it('the ws/resource ceilings are per account, not per address: one heavy user cannot 429 the office', async () => { const s = rl(); const now = Date.now(); const other = { ...user, id: 2 } as User; // Both users sit behind the same NAT address, and the first one burns its // whole ceiling. for (let i = 0; i < 120; i++) { await ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user); } expect(await thrownAsync(() => ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user))) .toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); expect(await ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws2' }) }).wsToken(other)).toEqual({ token: 'ws2' }); expect(await ac(asvc({}), s, { createResourceToken: vi.fn().mockReturnValue({ token: 'rt2' }) }).resourceToken(other, {})).toEqual({ token: 'rt2' }); }); it('rate-limited account ops throw 429 once the bucket is exhausted', async () => { const s = rl(); const now = Date.now(); // exhaust the shared 'login' bucket for this ip (max 5) for (let i = 0; i < 5; i++) await s.check('login', '9.9.9.9', 5, 15 * 60 * 1000, now); const c = ac(asvc({ changePassword: vi.fn() } as Partial), s); expect(await thrownAsync(() => c.changePassword(user, anyBody(), req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); it('change-password refreshes this device cookie when the service returns a token', async () => { const setAuthCookie = vi.fn(); const c = ac(asvc({ changePassword: vi.fn().mockReturnValue({ token: 'tk2' }), setAuthCookie } as Partial), rl()); expect(await c.changePassword(user, anyBody(), req, res)).toEqual({ success: true }); expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk2', req, undefined); }); it('change-password carries the session cookie remember claim into the service and the re-issued cookie (#1927)', async () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const jwt = require('jsonwebtoken'); const remembered = jwt.sign({ id: 1, pv: 0, remember: true }, 'any-secret'); const reqCookie = { ip: '9.9.9.9', headers: {}, cookies: { trek_session: remembered } } as unknown as Request; const setAuthCookie = vi.fn(); const changePassword = vi.fn().mockReturnValue({ token: 'tk3' }); const c = ac(asvc({ changePassword, setAuthCookie } as Partial), rl()); expect(await c.changePassword(user, anyBody(), reqCookie, res)).toEqual({ success: true }); expect(changePassword).toHaveBeenCalledWith(1, 'u@example.test', anyBody(), true, { userAgent: null }, true); expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk3', reqCookie, true); }); it('change-password starts no new session for a Bearer caller, who has no cookie to receive it', async () => { const setAuthCookie = vi.fn(); const changePassword = vi.fn().mockReturnValue({ success: true }); const bearer = { ip: '9.9.9.9', headers: { authorization: 'Bearer abc' } } as unknown as Request; const c = ac(asvc({ changePassword, setAuthCookie } as Partial), rl()); expect(await c.changePassword(user, anyBody(), bearer, res)).toEqual({ success: true }); expect(changePassword).toHaveBeenCalledWith(1, 'u@example.test', anyBody(), undefined, { userAgent: null }, false); expect(setAuthCookie).not.toHaveBeenCalled(); }); it('delete-account maps error, else audits and succeeds', async () => { expect(await thrownAsync(() => ac(asvc({ deleteAccount: vi.fn().mockReturnValue({ error: 'Last admin', status: 403 }) } as Partial), rl()).deleteAccount(user, req))).toEqual({ status: 403, body: { error: 'Last admin' } }); expect(await ac(asvc({ deleteAccount: vi.fn().mockReturnValue({}) } as Partial), rl()).deleteAccount(user, req)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.account_delete' })); }); it('maps-key + api-keys pass straight through to the service', async () => { const updateMapsKey = vi.fn().mockReturnValue({ success: true }); expect(await ac(asvc({}), rl(), {}, { updateMapsKey }).mapsKey(user, { maps_api_key: 'k' }, req)).toEqual({ success: true }); expect(updateMapsKey).toHaveBeenCalledWith(1, 'k'); const updateApiKeys = vi.fn().mockReturnValue({ ok: 1 }); expect(await ac(asvc({}), rl(), {}, { updateApiKeys }).apiKeys(user, anyBody({ a: 1 } as never), req)).toEqual({ ok: 1 }); // No changedKeys from the service (and none in the body): nothing to audit. expect(writeAudit).not.toHaveBeenCalled(); }); it('api-keys audits one settings.api_keys_update with the changed names only (#1939)', async () => { const updateApiKeys = vi.fn().mockReturnValue({ success: true, user: { id: 1 }, changedKeys: ['maps_api_key'] }); const c = ac(asvc({}), rl(), {}, { updateApiKeys }); // changedKeys is stripped off the response — the client body is unchanged. expect(await c.apiKeys(user, anyBody({ maps_api_key: 'AIza-super-secret' } as never), req)).toEqual({ success: true, user: { id: 1 } }); expect(writeAudit).toHaveBeenCalledTimes(1); expect(writeAudit).toHaveBeenCalledWith({ userId: 1, action: 'settings.api_keys_update', resource: 'api_keys', ip: '1.2.3.4', details: { changed: ['maps_api_key'] }, }); // Never the value, not even truncated: details is rendered raw in the panel. expect(JSON.stringify(writeAudit.mock.calls)).not.toContain('AIza-super-secret'); }); it('maps-key and settings audit the same action; an unchanged save writes nothing', async () => { const mapsC = ac(asvc({}), rl(), {}, { updateMapsKey: vi.fn().mockReturnValue({ success: true, changedKeys: ['maps_api_key'] }) }); await mapsC.mapsKey(user, { maps_api_key: 'k' }, req); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.api_keys_update', details: { changed: ['maps_api_key'] } })); writeAudit.mockClear(); const setC = ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 }, changedKeys: ['unsplash_api_key'] }) }); expect(await setC.updateSettings(user, {}, req)).toEqual({ success: true, user: { id: 1 } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.api_keys_update', details: { changed: ['unsplash_api_key'] } })); // The test button in the panel saves before every click — an identical value // must not cost a log line. writeAudit.mockClear(); await ac(asvc({}), rl(), {}, { updateApiKeys: vi.fn().mockReturnValue({ success: true, changedKeys: [] }) }) .apiKeys(user, anyBody({ maps_api_key: 'same' } as never), req); expect(writeAudit).not.toHaveBeenCalled(); }); it('update-settings + get-settings map errors, else return their payloads', async () => { expect(await thrownAsync(() => ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ error: 'Bad', status: 400 }) }).updateSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'Bad' } }); expect(await ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 } }) }).updateSettings(user, {}, req)).toEqual({ success: true, user: { id: 1 } }); expect(await thrownAsync(() => ac(asvc({}), rl(), {}, { getSettings: vi.fn().mockReturnValue({ error: 'Nope', status: 404 }) }).getSettings(user))).toEqual({ status: 404, body: { error: 'Nope' } }); expect(await ac(asvc({}), rl(), {}, { getSettings: vi.fn().mockReturnValue({ settings: { theme: 'dark' } }) }).getSettings(user)).toEqual({ settings: { theme: 'dark' } }); }); // travel-stats left with getTravelStats; it is covered by // atlas.controller.test.ts (TravelStatsController). it('delete-avatar + users delegate to the service', async () => { const deleteAvatar = vi.fn().mockResolvedValue({ removed: true }); expect(await ac(asvc({}), rl(), {}, { deleteAvatar }).deleteAvatar(user)).toEqual({ removed: true }); const listUsers = vi.fn().mockReturnValue([{ id: 1 }]); expect(await ac(asvc({}), rl(), {}, { listUsers }).users(user)).toEqual({ users: [{ id: 1 }] }); expect(listUsers).toHaveBeenCalledWith(1); }); it('validate-keys maps error, else returns the maps/weather payload', async () => { expect(await thrownAsync(() => ac(asvc({}), rl(), {}, { validateKeys: vi.fn().mockResolvedValue({ error: 'fail', status: 502 }) }).validateKeys(user))).toEqual({ status: 502, body: { error: 'fail' } }); const ok = ac(asvc({}), rl(), {}, { validateKeys: vi.fn().mockResolvedValue({ maps: true, weather: false, maps_details: { ok: 1 } }) }); expect(await ok.validateKeys(user)).toEqual({ maps: true, weather: false, maps_details: { ok: 1 } }); }); it('app-settings get maps error, else returns data; put maps error, else audits', async () => { expect(await thrownAsync(() => ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ error: 'denied', status: 403 }) } as Partial), rl()).getAppSettings(user))).toEqual({ status: 403, body: { error: 'denied' } }); expect(await ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ data: { x: 1 } }) } as Partial), rl()).getAppSettings(user)).toEqual({ x: 1 }); expect(await thrownAsync(() => ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ error: 'bad', status: 400 }) } as Partial), rl()).updateAppSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'bad' } }); expect(await ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ auditSummary: 's', auditDebugDetails: 'd' }) } as Partial), rl()).updateAppSettings(user, {}, req)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.app_update' })); }); it('mfa/setup maps a service error before ever awaiting the QR promise', async () => { const c = ac(asvc({ setupMfa: vi.fn().mockReturnValue({ error: 'already on', status: 409 }) } as Partial), rl()); expect(await thrownAsync(() => c.mfaSetup(user))).toEqual({ status: 409, body: { error: 'already on' } }); }); it('mfa/enable + mfa/disable map errors', async () => { expect(await thrownAsync(() => ac(asvc({ enableMfa: vi.fn().mockReturnValue({ error: 'Invalid code', status: 400 }) } as Partial), rl()).mfaEnable(user, { code: 'x' }, req))).toEqual({ status: 400, body: { error: 'Invalid code' } }); expect(await thrownAsync(() => ac(asvc({ disableMfa: vi.fn().mockReturnValue({ error: 'Wrong', status: 401 }) } as Partial), rl()).mfaDisable(user, anyBody(), req))).toEqual({ status: 401, body: { error: 'Wrong' } }); const disableMfa = vi.fn().mockReturnValue({ mfa_enabled: false }); const ok = ac(asvc({ disableMfa } as Partial), rl()); expect(await ok.mfaDisable(user, anyBody(), req)).toEqual({ success: true, mfa_enabled: false }); // No session id on this request's token, so no session is kept back. expect(disableMfa).toHaveBeenCalledWith(1, 'u@example.test', anyBody(), undefined); const tracked = jwt.sign({ id: 1, pv: 0 }, JWT_SECRET, { algorithm: 'HS256', jwtid: 'sid-here' }); const reqTracked = { ip: '9.9.9.9', headers: {}, cookies: { trek_session: tracked } } as unknown as Request; await ok.mfaDisable(user, anyBody(), reqTracked); expect(disableMfa).toHaveBeenLastCalledWith(1, 'u@example.test', anyBody(), 'sid-here'); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_disable' })); }); it('mcp-tokens list + create error + delete error/success', async () => { expect(await ac(asvc({}), rl(), { listMcpTokens: vi.fn().mockReturnValue([{ id: 't' }]) }).listMcpTokens(user)).toEqual({ tokens: [{ id: 't' }] }); expect(await thrownAsync(() => ac(asvc({}), rl(), { createMcpToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) }).createMcpToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } }); expect(await thrownAsync(() => ac(asvc({}), rl(), { deleteMcpToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) }).deleteMcpToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } }); expect(await ac(asvc({}), rl(), { deleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken(user, 'tid')).toEqual({ success: true }); }); // Same four paths as the MCP block above, against the other token kind. They are // separate routes on purpose — an API key and an MCP token open different doors — // so nothing here is implied by the MCP tests passing. it('api-tokens list + create success/error + delete error/success', async () => { expect(await ac(asvc({}), rl(), { listApiTokens: vi.fn().mockReturnValue([{ id: 'a' }]) }).listApiTokens(user)).toEqual({ tokens: [{ id: 'a' }] }); expect(await ac(asvc({}), rl(), { createApiToken: vi.fn().mockReturnValue({ token: 'trek_x' }) }).createApiToken(user, { name: 'Homepage' }, req)).toEqual({ token: 'trek_x' }); expect(await thrownAsync(() => ac(asvc({}), rl(), { createApiToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) }).createApiToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } }); expect(await thrownAsync(() => ac(asvc({}), rl(), { deleteApiToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) }).deleteApiToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } }); expect(await ac(asvc({}), rl(), { deleteApiToken: vi.fn().mockReturnValue({}) }).deleteApiToken(user, 'tid')).toEqual({ success: true }); }); // The create route shares the 'login' limiter bucket with the MCP one, at 5/window. // Unlike MCP tokens it is NOT refused on a managed instance, so the limiter is the // only thing standing between a scripted caller and an unbounded key list. it('api-tokens create is rate limited after 5 in the window', async () => { const limiter = rl(); const createApiToken = vi.fn().mockReturnValue({ token: 'trek_x' }); const ctl = ac(asvc({}), limiter, { createApiToken }); for (let i = 0; i < 5; i++) expect(await ctl.createApiToken(user, { name: `k${i}` }, req)).toEqual({ token: 'trek_x' }); expect((await thrownAsync(() => ctl.createApiToken(user, { name: 'k5' }, req))).status).toBe(429); expect(createApiToken).toHaveBeenCalledTimes(5); }); it('ws-token maps error, else returns the token', async () => { expect(await thrownAsync(() => ac(asvc({}), rl(), { createWsToken: vi.fn().mockReturnValue({ error: 'down', status: 503 }) }).wsToken(user))).toEqual({ status: 503, body: { error: 'down' } }); expect(await ac(asvc({}), rl(), { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user)).toEqual({ token: 'ws' }); }); it('avatar saves when not in demo mode (env present but email is not a demo email)', async () => { process.env.DEMO_MODE = 'true'; vi.mocked(isDemoEmail).mockReturnValue(false); const saveAvatar = vi.fn().mockResolvedValue({ avatar: '/b.png' }); expect(await ac(asvc({}), rl(), {}, { saveAvatar }).avatar(user, { filename: 'b.png' } as Express.Multer.File)).toEqual({ avatar: '/b.png' }); }); });