dorfteich/apps/api/src/auth/auth.e2e.db.test.ts
Claude Fable 5 f0850eecd3
All checks were successful
CD / Build and push images (push) Successful in 1m46s
CI / Lint, typecheck, test (push) Successful in 1m19s
CI / Auth e2e pack (push) Successful in 1m42s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m5s
CD / Promote to Int (push) Successful in 10s
Ponds: data model, CRUD API, personal pond on verification (#21)
- Pond model with pond-level trash columns (ADR 0013) and settings jsonb
  holding only deviations from the defaults (sidebar sort, font slots per
  ADR 0016); migration 20260705090100_ponds
- shared: pond schemas/views and slugify (German transliteration,
  URL-safe, length-capped); deterministic -2/-3 suffixes for collisions
- InterimAccessService: single place answering pond access questions
  until the real role model lands in M5
- POST/GET /ponds, GET /ponds/:slug, PATCH/DELETE /ponds/:id, Site-Admin
  trash + restore; personal pond auto-created on e-mail verification and
  for active seed fixtures; personal ponds cannot be trashed
- e2e pack covering verify-flow pond creation, slug suffixes, rename,
  foreign-pond 404s, trash/restore; slugify unit tests

Closes #21

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpQz6ypHJsLfMf4S6fyQEB
2026-07-05 11:08:16 +02:00

184 lines
7.0 KiB
TypeScript

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<string> {
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);
});
});