dorfteich/apps/api/src/auth/auth.e2e.db.test.ts
Claude Opus 5 45f1925917
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m28s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Auth e2e pack (pull_request) Failing after 4m1s
CI / Build container images (pull_request) Successful in 4m3s
#302: configurable pond start page, created with every new pond
Opening a pond landed on whatever sorted first in the sidebar — stable,
but a rule nobody could see, and one whose target moved as soon as
someone added a page ahead of it. New ponds landed on the empty-pond hint
instead of anything useful.

- `startPageId` joins the pond settings. No migration: `Pond.settings` is
  already jsonb. It stores an id, not a slug, so renaming or moving the
  page keeps it working.
- `PondHomePage` prefers it, but only when the page is in this user's
  page list. That list already holds just what they may see, so a start
  page hidden by a page-scoped grant — or trashed — falls back silently
  instead of landing them on a 404, and it costs no extra request.
- Both creation paths give the pond a start page, titled from the
  creator's stored locale. It happens after the creating transaction
  commits: the owner's grant is written inside it and permissions cache
  per pond, so creating the page any earlier would ask about rights the
  grant has not published yet. A failure is logged, not fatal — a pond
  without a start page still works.

`PagesModule` imported `PondsModule` without using it. Removing that
vestigial edge let PondsModule depend on PagesModule in the honest
direction instead of tying the two together with forwardRef.

Every pond created through the api now owns a page, which broke eight
suites whose teardown deleted ponds directly — `Page.pond` deliberately
has no cascade, because a real purge removes contents explicitly and
audits it. A shared `deletePondsWhere` helper deletes pages first. Two
tests that counted pages now account for the start page rather than
pretending the pond began empty.
2026-08-01 08:06:35 +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, deletePondsWhere, 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 deletePondsWhere(prisma, { 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);
});
});