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.
142 lines
6.3 KiB
TypeScript
142 lines
6.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import type { Request, Response } from 'express';
|
|
|
|
import { OidcController } from '../../../src/nest/oidc/oidc.controller';
|
|
import type { OidcService } from '../../../src/nest/oidc/oidc.service';
|
|
|
|
function svc(o: Partial<OidcService> = {}): OidcService {
|
|
return {
|
|
oidcLoginEnabled: vi.fn().mockReturnValue(true),
|
|
getOidcConfig: vi.fn().mockReturnValue({ issuer: 'https://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null }),
|
|
getAppUrl: vi.fn().mockReturnValue('https://app'),
|
|
discover: vi.fn().mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui', issuer: 'https://idp' }),
|
|
createState: vi.fn().mockReturnValue({ state: 'st', codeChallenge: 'cc' }),
|
|
consumeState: vi.fn().mockReturnValue({ redirectUri: 'https://app/api/auth/oidc/callback', codeVerifier: 'cv', inviteToken: undefined }),
|
|
exchangeCodeForToken: vi.fn(),
|
|
verifyIdToken: vi.fn(),
|
|
getUserInfo: vi.fn(),
|
|
findOrCreateUser: vi.fn(),
|
|
touchLastLogin: vi.fn(),
|
|
generateToken: vi.fn().mockReturnValue('jwt'),
|
|
createAuthCode: vi.fn().mockReturnValue('ac'),
|
|
consumeAuthCode: vi.fn(),
|
|
frontendUrl: vi.fn((p: string) => 'https://app' + p),
|
|
setAuthCookie: vi.fn(),
|
|
...o,
|
|
} as unknown as OidcService;
|
|
}
|
|
|
|
function makeRes() {
|
|
const res = {
|
|
statusCode: 200,
|
|
redirectedTo: '' as string,
|
|
body: undefined as unknown,
|
|
status: vi.fn((c: number) => { res.statusCode = c; return res; }),
|
|
json: vi.fn((b: unknown) => { res.body = b; return res; }),
|
|
redirect: vi.fn((u: string) => { res.redirectedTo = u; }),
|
|
};
|
|
return res as unknown as Response & { statusCode: number; redirectedTo: string; body: unknown };
|
|
}
|
|
|
|
const req = { query: {}, headers: {} } as Request;
|
|
|
|
beforeEach(() => vi.clearAllMocks());
|
|
afterEach(() => { delete process.env.NODE_ENV; });
|
|
|
|
describe('OidcController /login', () => {
|
|
it('403 when SSO is disabled', async () => {
|
|
const res = makeRes();
|
|
await new OidcController(svc({ oidcLoginEnabled: vi.fn().mockReturnValue(false) })).login(req, res);
|
|
expect(res.statusCode).toBe(403);
|
|
expect(res.body).toEqual({ error: 'SSO login is disabled.' });
|
|
});
|
|
|
|
it('400 when not configured', async () => {
|
|
const res = makeRes();
|
|
await new OidcController(svc({ getOidcConfig: vi.fn().mockReturnValue(null) })).login(req, res);
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.body).toEqual({ error: 'OIDC not configured' });
|
|
});
|
|
|
|
it('redirects to the provider authorize endpoint with PKCE params', async () => {
|
|
const res = makeRes();
|
|
await new OidcController(svc()).login(req, res);
|
|
expect(res.redirect).toHaveBeenCalled();
|
|
expect(res.redirectedTo).toContain('https://idp/auth?');
|
|
expect(res.redirectedTo).toContain('code_challenge=cc');
|
|
expect(res.redirectedTo).toContain('code_challenge_method=S256');
|
|
});
|
|
});
|
|
|
|
describe('OidcController /callback', () => {
|
|
it('redirects with sso_disabled when SSO is off', async () => {
|
|
const res = makeRes();
|
|
await new OidcController(svc({ oidcLoginEnabled: vi.fn().mockReturnValue(false) })).callback('c', 's', undefined, res);
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=sso_disabled');
|
|
});
|
|
|
|
it('redirects with the provider error', async () => {
|
|
const res = makeRes();
|
|
await new OidcController(svc()).callback(undefined, undefined, 'access_denied', res);
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=access_denied');
|
|
});
|
|
|
|
it('redirects missing_params / invalid_state', async () => {
|
|
const r1 = makeRes();
|
|
await new OidcController(svc()).callback(undefined, 's', undefined, r1);
|
|
expect(r1.redirectedTo).toBe('https://app/login?oidc_error=missing_params');
|
|
const r2 = makeRes();
|
|
await new OidcController(svc({ consumeState: vi.fn().mockReturnValue(null) })).callback('c', 's', undefined, r2);
|
|
expect(r2.redirectedTo).toBe('https://app/login?oidc_error=invalid_state');
|
|
});
|
|
|
|
it('rejects a missing id_token, then completes with an auth code on success', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const noId = makeRes();
|
|
await new OidcController(svc({ exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at' }) })).callback('c', 's', undefined, noId);
|
|
expect(noId.redirectedTo).toBe('https://app/login?oidc_error=no_id_token');
|
|
|
|
const ok = makeRes();
|
|
const c = new OidcController(svc({
|
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
|
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 1 } }),
|
|
}));
|
|
await c.callback('c', 's', undefined, ok);
|
|
expect(ok.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
|
});
|
|
|
|
it('rejects a userinfo subject mismatch', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const res = makeRes();
|
|
const c = new OidcController(svc({
|
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'OTHER' }),
|
|
}));
|
|
await c.callback('c', 's', undefined, res);
|
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=subject_mismatch');
|
|
});
|
|
});
|
|
|
|
describe('OidcController /exchange', () => {
|
|
it('400 without a code, 400 on an invalid code, else sets the cookie + returns the token', () => {
|
|
const r1 = makeRes();
|
|
new OidcController(svc()).exchange(undefined, req, r1);
|
|
expect(r1.statusCode).toBe(400);
|
|
expect(r1.body).toEqual({ error: 'Code required' });
|
|
|
|
const r2 = makeRes();
|
|
new OidcController(svc({ consumeAuthCode: vi.fn().mockReturnValue({ error: 'invalid_code' }) })).exchange('x', req, r2);
|
|
expect(r2.statusCode).toBe(400);
|
|
expect(r2.body).toEqual({ error: 'invalid_code' });
|
|
|
|
const r3 = makeRes();
|
|
const setAuthCookie = vi.fn();
|
|
new OidcController(svc({ consumeAuthCode: vi.fn().mockReturnValue({ token: 'jwt' }), setAuthCookie })).exchange('x', req, r3);
|
|
expect(setAuthCookie).toHaveBeenCalledWith(r3, 'jwt', req);
|
|
expect(r3.body).toEqual({ token: 'jwt' });
|
|
});
|
|
});
|