mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 13:51:45 +00:00
fc7d8b5d12
Brownfield strangler migration of the backend onto NestJS modules (auth, trips, days, places, assignments, packing, todo, budget, reservations, collab, files, photos, journey, share, settings, backup, oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories, tags, notifications, system-notices) served through a per-prefix dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT httpOnly cookie auth, with behavioural parity for every route. Client: React 19 upgrade, "page = wiring container + data hook" pattern across all pages, per-domain Zustand stores bound to @trek/shared contracts, and decomposition of the large components (DayPlanSidebar, PackingListPanel, CollabNotes, FileManager, MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal, BudgetPanel, PlaceFormModal, ...) into focused render units backed by in-file hooks. Apply the shared global request pipeline (helmet/CSP, CORS, HSTS, forced HTTPS, the global MFA policy and request logging) to the NestJS instance as well, so a migrated route is protected identically to the legacy fallback rather than bypassing it.
170 lines
12 KiB
TypeScript
170 lines
12 KiB
TypeScript
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> = {}): 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<unknown>): 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), rl()).resetPassword({}, req)).toEqual({ mfa_required: true });
|
|
expect(new AuthPublicController(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial<AuthService>), 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<AuthService>), rl());
|
|
expect(c.verifyMfaLogin({}, req, res)).toEqual({ token: 'tk', user });
|
|
expect(setAuthCookie).toHaveBeenCalled();
|
|
const clearAuthCookie = vi.fn();
|
|
expect(new AuthPublicController(asvc({ clearAuthCookie } as Partial<AuthService>), 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<AuthService>), rl()).me(user))).toEqual({ status: 404, body: { error: 'User not found' } });
|
|
expect(new AuthController(asvc({ getCurrentUser: vi.fn().mockReturnValue({ id: 1 }) } as Partial<AuthService>), 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<AuthService>), rl()).changePassword(user, {}, req))).toEqual({ status: 400, body: { error: 'Wrong' } });
|
|
expect(new AuthController(asvc({ changePassword: vi.fn().mockReturnValue({}) } as Partial<AuthService>), 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<AuthService>), 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('<svg>') }) } as Partial<AuthService>), rl());
|
|
expect(await ok.mfaSetup(user)).toEqual({ secret: 's', otpauth_url: 'o', qr_svg: '<svg>' });
|
|
const fail = new AuthController(asvc({ setupMfa: vi.fn().mockReturnValue({ secret: 's', otpauth_url: 'o', qrPromise: Promise.reject(new Error('x')) }) } as Partial<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), rl()).resourceToken(user, {}))).toEqual({ status: 503, body: { error: 'Service unavailable' } });
|
|
expect(new AuthController(asvc({ createResourceToken: vi.fn().mockReturnValue({ token: 'rt' }) } as Partial<AuthService>), 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<AuthService>), s);
|
|
expect(thrown(() => c.changePassword(user, {}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
|
});
|
|
});
|