import 'reflect-metadata'; import { INestApplication } from '@nestjs/common'; import { PATH_METADATA } from '@nestjs/common/constants'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createTestApp } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; import { LOCAL_CREDENTIAL_KEY } from './auth.guard'; import { AuthController } from './auth.controller'; import { OidcController } from './oidc.controller'; import { SessionsService } from './sessions.service'; /** * The hard local-auth switch (issue #216, ADR 0021): AUTH_LOCAL_ENABLED=false * closes EVERY local credential flow with 404 — enumerated, not assumed — * while sessions themselves, logout, and token issuance for * externally-authenticated users keep working (the stated decision: PATs * and feed tokens authorize API access under their own switches, they are * not interactive sign-in). A fence asserts every auth route is either * marked as a local flow or on the reviewed allowlist. */ describe.skipIf(!hasTestDb)('local-auth switch (e2e, issue #216)', () => { let app: INestApplication; let prisma: PrismaClient; const suffix = uniqueSuffix(); /** Every local credential surface — the enumeration the issue demands. */ const LOCAL_ROUTES: { method: 'post'; path: string; body: Record }[] = [ { method: 'post', path: '/api/v1/auth/login', body: { usernameOrEmail: 'x', password: 'y' } }, { method: 'post', path: '/api/v1/auth/signup', body: { username: `switch-${suffix}`, email: `switch-${suffix}@example.test`, displayName: 'x', password: 'ein langes passwort 123', locale: 'en', }, }, { method: 'post', path: '/api/v1/auth/verify-email', body: { token: 'x' } }, { method: 'post', path: '/api/v1/auth/resend-verification', body: { email: 'x@example.test' }, }, { method: 'post', path: '/api/v1/auth/forgot-password', body: { email: 'x@example.test' } }, { method: 'post', path: '/api/v1/auth/reset-password', body: { token: 'x', password: 'ein langes passwort 123' }, }, { method: 'post', path: '/api/v1/users/me/change-password', body: { currentPassword: 'x', newPassword: 'ein langes passwort 123' }, }, ]; const api = () => request(app.getHttpServer()); beforeAll(async () => { prisma = createTestPrisma(); await prisma.rateLimit.deleteMany({}); process.env.AUTH_LOCAL_ENABLED = 'false'; app = await createTestApp(); }); afterAll(async () => { delete process.env.AUTH_LOCAL_ENABLED; await prisma.apiToken.deleteMany({ where: { user: { username: { contains: suffix } } } }); await prisma.feedToken.deleteMany({ where: { user: { username: { contains: suffix } } } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.$disconnect(); await app.close(); }); it('answers 404 on every enumerated local credential route', async () => { for (const route of LOCAL_ROUTES) { const res = await api()[route.method](route.path).send(route.body); expect(`${route.path}: ${res.status}`).toBe(`${route.path}: 404`); } }); it('reports local:false so the login screen hides the form', async () => { const res = await api().get('/api/v1/auth/methods').expect(200); expect(res.body.local).toBe(false); }); it('keeps sessions, logout, and PAT/feed-token issuance working for externally-authenticated users', async () => { // An externally-authenticated user is simulated by creating the session // through the session service — exactly what the OIDC/proxy paths do. const users = app.get(UsersService); const user = await users.createUser({ username: `ext-${suffix}`, email: `ext-${suffix}@example.test`, displayName: 'External', password: 'nie benutzt weil lokal aus', locale: 'en', }); await users.markEmailVerified(user.id); const token = await app.get(SessionsService).create(user.id, undefined); const cookie = `dt_session=${token}`; const me = await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(200); expect(me.body.id).toBe(user.id); // Stated decision (#216): token issuance is API authorization, not // interactive sign-in — it stays available under its own switches. await api() .post('/api/v1/users/me/api-tokens') .set('Cookie', cookie) .send({ name: `switch-${suffix}`, scope: 'read' }) .expect(201); await api() .post('/api/v1/users/me/feed-tokens') .set('Cookie', cookie) .send({ name: `switch-${suffix}` }) .expect(201); await api().post('/api/v1/auth/logout').set('Cookie', cookie).expect(204); await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(401); }); it('fence: every auth route is either a marked local flow or on the reviewed allowlist', () => { // Routes that must stay reachable with local auth off — reviewed here. const allowlist = new Set([ 'registration', // signup-mode discovery; harmless metadata 'methods', // the login screen's discovery endpoint 'logout', // ending a session is not a credential flow 'me', // session introspection 'login', // OidcController: IdP redirect 'link', // OidcController: explicit identity linking 'callback', // OidcController: IdP return leg ]); for (const controller of [AuthController, OidcController]) { for (const name of Object.getOwnPropertyNames(controller.prototype)) { if (name === 'constructor') continue; const handler = controller.prototype[name as keyof typeof controller.prototype] as ( ...args: unknown[] ) => unknown; const path = Reflect.getMetadata(PATH_METADATA, handler) as string | undefined; if (path === undefined) continue; // not a route const marked = Reflect.getMetadata(LOCAL_CREDENTIAL_KEY, handler) === true; expect( marked || allowlist.has(path), `${controller.name}.${name} (path "${path}") is neither @LocalCredentialFlow nor allowlisted`, ).toBe(true); } } }); });