import { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; describe.skipIf(!hasTestDb)('auth flows (e2e)', () => { let app: INestApplication; let prisma: PrismaClient; const suffix = uniqueSuffix(); const account = { username: `erik-${suffix}`, email: `erik-${suffix}@example.org`, displayName: 'Erik End-to-End', password: 'ein wirklich gutes passwort', locale: 'de' as const, }; const api = () => request(app.getHttpServer()); /** Latest mail for an address, from the outbox (worker is off in tests). */ async function latestMailLink(to: string): Promise { const mail = await prisma.mailOutbox.findFirst({ where: { toAddress: to }, orderBy: { createdAt: 'desc' }, }); const link = mail?.textBody.match(/https?:\/\/\S+token=(\S+)/)?.[0]; if (!link) throw new Error(`no mail with token link for ${to}`); return link; } function tokenFromLink(link: string): string { return new URL(link).searchParams.get('token')!; } beforeAll(async () => { prisma = createTestPrisma(); // Rate-limit counters survive across local runs on the shared test // db — a clean slate keeps the suite deterministic. await prisma.rateLimit.deleteMany({}); app = await createTestApp(); }); afterAll(async () => { // Verified users own a personal pond (#21) — remove it before them. await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } }); await prisma.$disconnect(); await app.close(); }); // ---------------------------------------------------------------- #13 it('signs up and enqueues a verification mail with the app link', async () => { await api().post('/api/v1/auth/signup').send(account).expect(201); const link = await latestMailLink(account.email); expect(link).toContain('/verify-email?token='); }); it('rejects a duplicate username with a field-level conflict', async () => { const res = await api() .post('/api/v1/auth/signup') .send({ ...account, email: `other-${suffix}@example.org` }) .expect(409); expect(res.body.details).toHaveProperty('username'); }); it('blocks login before the e-mail is verified', async () => { await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: account.username, password: account.password }) .expect(403); }); it('verifies the e-mail exactly once', async () => { const token = tokenFromLink(await latestMailLink(account.email)); await api().post('/api/v1/auth/verify-email').send({ token }).expect(204); await api().post('/api/v1/auth/verify-email').send({ token }).expect(400); const user = await prisma.user.findUnique({ where: { email: account.email } }); expect(user?.status).toBe('ACTIVE'); }); // ---------------------------------------------------------------- #14 let cookie: string; it('logs in with username or e-mail and sets the session cookie', async () => { const res = await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: account.email, password: account.password }) .expect(200); expect(res.body.username).toBe(account.username); cookie = sessionCookieOf(res); const cookieHeader = (res.headers['set-cookie'] as unknown as string[])[0]!; expect(cookieHeader).toContain('HttpOnly'); expect(cookieHeader).toContain('SameSite=Lax'); }); it('serves /auth/me with a valid session and 401 without', async () => { const me = await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(200); expect(me.body.email).toBe(account.email); await api().get('/api/v1/auth/me').expect(401); }); it('answers wrong password and unknown user with the same generic 401', async () => { const wrong = await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: account.username, password: 'falsch falsch falsch' }) .expect(401); const unknown = await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: `ghost-${suffix}`, password: 'egal egal egal' }) .expect(401); expect(wrong.body.message).toBe(unknown.body.message); }); it('applies per-account backoff after repeated failures', async () => { // 1 failure from the previous test + 4 more = 5 within the window; // the next attempt is blocked even with correct credentials. for (let i = 0; i < 4; i += 1) { await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: account.username, password: 'immer noch falsch' }) .expect(401); } await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: account.username, password: account.password }) .expect(401); await prisma.rateLimit.deleteMany({ where: { key: { startsWith: 'login-account' } } }); }); it('rejects mutating requests from a foreign origin (CSRF)', async () => { await api() .post('/api/v1/auth/logout') .set('Cookie', cookie) .set('Origin', 'https://evil.example') .expect(403); }); it('logs out and invalidates the session immediately', async () => { await api().post('/api/v1/auth/logout').set('Cookie', cookie).expect(204); await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(401); }); // ---------------------------------------------------------------- #15 it('handles forgot-password without account enumeration', async () => { await api().post('/api/v1/auth/forgot-password').send({ email: account.email }).expect(204); await api() .post('/api/v1/auth/forgot-password') .send({ email: `niemand-${suffix}@example.org` }) .expect(204); }); it('resets the password, kills old sessions, and accepts the new password', async () => { // The suite itself has spent the per-IP login budget by now. await prisma.rateLimit.deleteMany({ where: { key: { startsWith: 'login:' } } }); const login = await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: account.username, password: account.password }) .expect(200); const oldCookie = sessionCookieOf(login); const token = tokenFromLink(await latestMailLink(account.email)); const newPassword = 'ein noch besseres passwort'; await api() .post('/api/v1/auth/reset-password') .send({ token, password: newPassword }) .expect(204); await api().get('/api/v1/auth/me').set('Cookie', oldCookie).expect(401); await api() .post('/api/v1/auth/reset-password') .send({ token, password: newPassword }) .expect(400); await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: account.username, password: newPassword }) .expect(200); }); });