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
- 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
184 lines
7.0 KiB
TypeScript
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);
|
|
});
|
|
});
|