/** * Auth e2e — exercises the migrated /api/auth endpoints through the real * JwtAuthGuard/OptionalJwtGuard, the real cookie service AND the real * DI-native AuthService (DatabaseModule + AuthModule, which pulls in * Audit/Permissions/AtlasModule) against a temp SQLite db (full schema). * No service mock (the legacy path-mock died with the auth fold): login runs * real bcrypt against a factory-seeded hash, audit rows land in audit_log for * real, and the httpOnly trek_session cookie set/clear is asserted end to end. */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import { Test } from '@nestjs/testing'; import { sessionCookie } from './harness'; vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb } = await import('../helpers/db-mock'); const db = createSnapshotTestDb(); return { db, closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, canAccessTrip: () => undefined, isOwner: () => false, }; }); vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() })); // The audit domain is DI-native: writeAudit runs for real against the temp // db's audit_log table; only the file logger is silenced. vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); // Switchable so the passkey cases can reproduce the real APP_URL-unset // fallback (http://localhost:{PORT}) without disturbing the other cases. const { appUrlRef } = vi.hoisted(() => ({ appUrlRef: { value: 'https://x' } })); vi.mock('../../src/app-config', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getAppUrl: () => appUrlRef.value }; }); import { MailerService } from '../../src/nest/notifications/mailer/mailer.service'; import { db } from '../../src/db/database'; import { createUser } from '../helpers/factories'; import { encrypt_api_key } from '../../src/nest/common/crypto/apiKeyCrypto'; import { resetRateLimits } from '../helpers/test-db'; import { AuthModule } from '../../src/nest/auth/auth.module'; import { SessionsService } from '../../src/nest/sessions/sessions.service'; import { SessionRenewalInterceptor } from '../../src/nest/auth/session-renewal.interceptor'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; import { countRows, deleteRows, findRow, findRows, updateRows } from '../helpers/factories/rows'; import { readAppSetting, setAppSetting } from '../helpers/factories/settings'; import { AppSettings } from '../../src/db/entities/AppSettings.entity'; import { AuditLog } from '../../src/db/entities/AuditLog.entity'; import { Users } from '../../src/db/entities/Users.entity'; describe('Auth e2e (real auth guard + real service + real cookie service + temp SQLite)', () => { let server: Server; let app: Awaited>; let userId: number; let userEmail: string; let userPassword: string; async function build() { const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), AuthModule] }) // The mailer is a provider since the notifications fold; overriding it is // the DI-native replacement for the old services/notifications module mock. .overrideProvider(MailerService) .useValue({ sendPasswordResetEmail: vi.fn().mockResolvedValue({ delivered: 'email' }) }) .compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); // Mirror the production APP_PIPE (app.module.ts): DTO-typed bodies validate // by metatype, exactly as they do under buildApp(). nest.useGlobalPipes(new ZodValidationPipe()); // Mirror the production APP_INTERCEPTOR: sliding session renewal (#1927). // Inert for the harness's exp-less tokens and freshly issued logins. nest.useGlobalInterceptors(new SessionRenewalInterceptor(moduleRef.get(SessionsService))); await nest.init(); return nest; } let orm: TestOrm; const auditRows = (action: string) => countRows(orm, AuditLog, { action }); beforeAll(async () => { orm = await createTestOrm(db); const seeded = createUser(db as never, { username: 'auth-e2e', email: 'u@example.test' }); userId = seeded.user.id; userEmail = seeded.user.email; userPassword = seeded.password; app = await build(); server = app.getHttpServer(); }); afterAll(async () => { await app.close(); await orm.close(); }); it('GET /app-config is optional-auth (200 without a cookie, real toggles)', async () => { const res = await request(server).get('/api/auth/app-config'); expect(res.status).toBe(200); expect(res.body.password_login).toBe(true); expect(res.body.has_users).toBe(true); expect(typeof res.body.version).toBe('string'); expect(res.body.version.length).toBeGreaterThan(0); // Unauthenticated → no permissions block. expect(res.body.permissions).toBeUndefined(); }); it('GET /me requires a session (401 without a cookie)', async () => { expect((await request(server).get('/api/auth/me')).status).toBe(401); }); it('GET /me returns the real user row with a valid session', async () => { const res = await request(server).get('/api/auth/me').set('Cookie', sessionCookie(userId)); expect(res.status).toBe(200); expect(res.body.user.id).toBe(userId); expect(res.body.user.email).toBe(userEmail); expect(res.body.user).not.toHaveProperty('password_hash'); }); it('POST /login sets the httpOnly trek_session cookie and audits user.login', async () => { const before = await auditRows('user.login'); const res = await request(server).post('/api/auth/login').send({ email: userEmail, password: userPassword }); expect(res.status).toBe(200); expect(typeof res.body.token).toBe('string'); expect(res.body.user.id).toBe(userId); const setCookie = res.headers['set-cookie'] as unknown as string[]; expect(setCookie.some((c) => c.startsWith('trek_session=') && /HttpOnly/i.test(c))).toBe(true); expect(await auditRows('user.login')).toBe(before + 1); }, 10000); it('POST /login with a wrong password answers the generic 401 and audits user.login_failed', async () => { const before = await auditRows('user.login_failed'); const res = await request(server).post('/api/auth/login').send({ email: userEmail, password: 'wrong-password' }); expect(res.status).toBe(401); expect(res.body).toEqual({ error: 'Invalid email or password' }); expect(await auditRows('user.login_failed')).toBe(before + 1); }, 10000); it('POST /login with remember_me sets a persistent cookie (Max-Age present)', async () => { const res = await request(server).post('/api/auth/login').send({ email: userEmail, password: userPassword, remember_me: true }); expect(res.status).toBe(200); const setCookie = res.headers['set-cookie'] as unknown as string[]; const cookie = setCookie.find((c) => c.startsWith('trek_session='))!; expect(cookie).toMatch(/Max-Age=\d+/i); // 30d default — well above the 24h (86400s) non-remember window. const maxAge = Number(/Max-Age=(\d+)/i.exec(cookie)?.[1]); expect(maxAge).toBeGreaterThan(86_400); }, 10000); it('POST /login without remember_me sets a session cookie (no Max-Age)', async () => { const res = await request(server).post('/api/auth/login').send({ email: userEmail, password: userPassword }); expect(res.status).toBe(200); const setCookie = res.headers['set-cookie'] as unknown as string[]; const cookie = setCookie.find((c) => c.startsWith('trek_session='))!; expect(cookie).not.toMatch(/Max-Age/i); expect(cookie).not.toMatch(/Expires/i); }, 10000); it('renews the session cookie when the token is past half its lifetime (#1927)', async () => { // 70% of a 24h token consumed → the interceptor re-issues the cookie. const res = await request(server) .get('/api/auth/me') .set('Cookie', sessionCookie(userId, 0, { lifetime: 86400, consumed: 60000 })); expect(res.status).toBe(200); const setCookie = (res.headers['set-cookie'] ?? []) as unknown as string[]; const renewed = setCookie.find((c) => c.startsWith('trek_session='))!; expect(renewed).toBeTruthy(); expect(renewed).toMatch(/Max-Age=86400/i); // The renewed token is a fresh full-lifetime session for the same user. const jwt = require('jsonwebtoken'); const decoded = jwt.decode(/trek_session=([^;]+)/.exec(renewed)![1]) as { id: number; exp: number; iat: number }; expect(decoded.id).toBe(userId); expect(decoded.exp - decoded.iat).toBe(86400); }, 10000); it('renews a remembered session with the long window and a remember:false one as a session cookie (#1927)', async () => { const long = await request(server) .get('/api/auth/me') .set('Cookie', sessionCookie(userId, 0, { lifetime: 2592000, consumed: 1600000, remember: true })); expect(long.status).toBe(200); const longCookie = ((long.headers['set-cookie'] ?? []) as unknown as string[]).find((c) => c.startsWith('trek_session='))!; expect(longCookie).toMatch(/Max-Age=2592000/i); const sess = await request(server) .get('/api/auth/me') .set('Cookie', sessionCookie(userId, 0, { lifetime: 86400, consumed: 60000, remember: false })); expect(sess.status).toBe(200); const sessCookie = ((sess.headers['set-cookie'] ?? []) as unknown as string[]).find((c) => c.startsWith('trek_session='))!; expect(sessCookie).not.toMatch(/Max-Age/i); expect(sessCookie).not.toMatch(/Expires/i); }, 10000); it('does not renew a young session cookie (#1927)', async () => { const res = await request(server) .get('/api/auth/me') .set('Cookie', sessionCookie(userId, 0, { lifetime: 86400, consumed: 1000 })); expect(res.status).toBe(200); const setCookie = (res.headers['set-cookie'] ?? []) as unknown as string[]; expect(setCookie.some((c) => c.startsWith('trek_session='))).toBe(false); }, 10000); it('PUT /me/password preserves the remember choice on the re-issued cookie (#1927)', async () => { await resetRateLimits(app); // earlier login cases share the same per-ip 'login' bucket const seeded = createUser(db as never, { username: 'pw-remember', email: 'pw-remember@example.test' }); const login = await request(server) .post('/api/auth/login') .send({ email: seeded.user.email, password: seeded.password, remember_me: true }); expect(login.status).toBe(200); const loginCookie = ((login.headers['set-cookie'] as unknown as string[]).find((c) => c.startsWith('trek_session=')))!; const sessionValue = /trek_session=([^;]+)/.exec(loginCookie)![1]; const change = await request(server) .put('/api/auth/me/password') .set('Cookie', `trek_session=${sessionValue}`) .send({ current_password: seeded.password, new_password: 'New1234!x' }); expect(change.status).toBe(200); // The handler's re-issue is appended after any interceptor renewal — the // browser keeps the last trek_session cookie, so assert on that one. const setCookie = (change.headers['set-cookie'] ?? []) as unknown as string[]; const finalCookie = setCookie.filter((c) => c.startsWith('trek_session=')).pop()!; expect(finalCookie).toMatch(/Max-Age=2592000/i); const jwt = require('jsonwebtoken'); const decoded = jwt.decode(/trek_session=([^;]+)/.exec(finalCookie)![1]) as { remember?: boolean; exp: number; iat: number }; expect(decoded.remember).toBe(true); expect(decoded.exp - decoded.iat).toBe(2592000); }, 10000); it('POST /register creates the user, sets the cookie and audits user.register', async () => { const before = await auditRows('user.register'); const res = await request(server) .post('/api/auth/register') .send({ username: 'fresh-e2e', email: 'fresh-e2e@example.test', password: 'Str0ng!Pass1' }); expect(res.status).toBe(201); expect(typeof res.body.token).toBe('string'); expect(res.body.user.username).toBe('fresh-e2e'); const setCookie = res.headers['set-cookie'] as unknown as string[]; expect(setCookie.some((c) => c.startsWith('trek_session=') && /HttpOnly/i.test(c))).toBe(true); expect(await auditRows('user.register')).toBe(before + 1); const row = await findRow(orm, Users, { email: 'fresh-e2e@example.test' }); expect(row).not.toBeNull(); }); it('POST /login with a shapeless body answers the pipe 400 envelope', async () => { const res = await request(server).post('/api/auth/login').send({ email: 'u@example.test' }); expect(res.status).toBe(400); // The global ZodValidationPipe envelope: { error: 'field: message; ...' }. expect(res.body.error).toMatch(/password/i); }); it('PUT /me/api-keys stores the key instance-wide and audits it once (#1939)', async () => { const admin = createUser(db as never, { username: 'keys-admin', email: 'keys-admin@example.test', role: 'admin' }); const before = await auditRows('settings.api_keys_update'); const res = await request(server) .put('/api/auth/me/api-keys') .set('Cookie', sessionCookie(admin.user.id)) .send({ maps_api_key: 'e2e-google-key' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); // Masked in the response and never accompanied by the audit bookkeeping. expect(res.body.user.maps_api_key).not.toBe('e2e-google-key'); expect(res.body).not.toHaveProperty('changedKeys'); // What every member's search will now resolve to, encrypted at rest. const stored = await readAppSetting(orm, 'maps_api_key'); expect(stored).toMatch(/^enc:v1:/); // And the admin panel reads back what the search uses. const settings = await request(server).get('/api/auth/me/settings').set('Cookie', sessionCookie(admin.user.id)); expect(settings.body.settings.maps_api_key).toBe('e2e-google-key'); expect(await auditRows('settings.api_keys_update')).toBe(before + 1); const [row] = await findRows(orm, AuditLog, { action: 'settings.api_keys_update' }, { id: 'desc' }); expect(row.resource).toBe('api_keys'); expect(row.details).toContain('maps_api_key'); expect(row.details).not.toContain('e2e-google-key'); // Saving the same value again is not a change, so the log does not grow. await request(server) .put('/api/auth/me/api-keys') .set('Cookie', sessionCookie(admin.user.id)) .send({ maps_api_key: 'e2e-google-key' }); expect(await auditRows('settings.api_keys_update')).toBe(before + 1); }, 10000); it('GET /me/settings names the variable behind a key set in the environment, never its value (#1881)', async () => { const admin = createUser(db as never, { username: 'env-keys-admin', email: 'env-keys-admin@example.test', role: 'admin' }); const prev = process.env.PLACES_API_KEY; process.env.PLACES_API_KEY = 'e2e-google-from-env'; try { const res = await request(server).get('/api/auth/me/settings').set('Cookie', sessionCookie(admin.user.id)); expect(res.status).toBe(200); expect(res.body.settings.maps_api_key).toBeNull(); expect(res.body.settings.env_keys).toEqual({ maps_api_key: 'PLACES_API_KEY' }); expect(JSON.stringify(res.body)).not.toContain('e2e-google-from-env'); } finally { if (prev === undefined) delete process.env.PLACES_API_KEY; else process.env.PLACES_API_KEY = prev; } }); it('GET /app-config answers has_maps_key from the instance row, never from an admin column (#1939)', async () => { const member = createUser(db as never, { username: 'keys-member', email: 'keys-member@example.test' }); const admin = createUser(db as never, { username: 'keys-cfg-admin', email: 'keys-cfg-admin@example.test', role: 'admin' }); // Seeded here instead of riding on the save above, so running this case on // its own asserts the same thing. const setInstanceKey = async (value: string | null) => { if (value === null) { await deleteRows(orm, AppSettings, { key: 'maps_api_key' }); return; } await setAppSetting(orm, 'maps_api_key', encrypt_api_key(value)); }; const hasMapsKey = async (userId: number) => { const res = await request(server).get('/api/auth/app-config').set('Cookie', sessionCookie(userId)); expect(res.status).toBe(200); return res.body.has_maps_key; }; await setInstanceKey('instance-configured-key'); await updateRows(orm, Users, { id: admin.user.id }, { maps_api_key: null }); // Instance key, no column anywhere: the member searches with it, so the // client is told the feature is there. expect(await hasMapsKey(member.user.id)).toBe(true); // The reported half of #1939: the key exists only in one admin's column. // It is not the member's to spend, so they are told there is none instead // of being offered a Google search that answers 403. await setInstanceKey(null); await updateRows(orm, Users, { id: admin.user.id }, { maps_api_key: encrypt_api_key('admins-own-key') }); expect(await hasMapsKey(member.user.id)).toBe(false); // That same column still counts for the admin themselves. expect(await hasMapsKey(admin.user.id)).toBe(true); }); describe('passkey origin gate (#2147)', () => { beforeAll(async () => { await setAppSetting(orm, 'passkey_login', 'true'); // What getAppUrl() really yields with APP_URL unset — the phantom config. appUrlRef.value = 'http://localhost:3001'; }); afterAll(async () => { await deleteRows(orm, AppSettings, { key: 'passkey_login' }); appUrlRef.value = 'https://x'; }); it('register/options answers the actionable not-configured 400 for a remote origin', async () => { const res = await request(server) .post('/api/auth/passkey/register/options') .set('Cookie', sessionCookie(userId)) .set('Origin', 'https://trip.example.org') .send({ password: userPassword }); expect(res.status).toBe(400); expect(res.body).toEqual({ error: 'Passkey login is not configured for this server.' }); }); it('register/options still serves the localhost dev flow (no Origin header, localhost origin)', async () => { const bare = await request(server) .post('/api/auth/passkey/register/options') .set('Cookie', sessionCookie(userId)) .send({ password: userPassword }); expect(bare.status).toBe(200); expect(bare.body.rp).toEqual({ name: 'TREK', id: 'localhost' }); const local = await request(server) .post('/api/auth/passkey/register/options') .set('Cookie', sessionCookie(userId)) .set('Origin', 'http://localhost:5173') .send({ password: userPassword }); expect(local.status).toBe(200); expect(local.body.rp.id).toBe('localhost'); }); it('login/options gets the same gate on the same derivation chain', async () => { await resetRateLimits(app); // shares the per-ip 'login' bucket with the login cases above const res = await request(server) .post('/api/auth/passkey/login/options') .set('Origin', 'https://trip.example.org') .send({}); expect(res.status).toBe(400); expect(res.body).toEqual({ error: 'Passkey login is not configured for this server.' }); }); }); it('POST /logout clears the session cookie', async () => { const res = await request(server).post('/api/auth/logout'); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true }); const setCookie = res.headers['set-cookie'] as unknown as string[]; expect(setCookie.some((c) => c.startsWith('trek_session='))).toBe(true); }); });