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.
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, 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);
|
|
});
|
|
});
|