import { INestApplication } from '@nestjs/common'; import { CreateGrantInput } from '@dorfteich/shared'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { PondsService } from '../ponds/ponds.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; /** * The fixture matrix for the real permission model (issue #52 acceptance * criteria): reader / editor / pond admin / foreign user against one shared * pond, exercised through HTTP so the guard, the resolver, the 404/403 * policy, and the cache invalidation are all proven end to end. */ describe.skipIf(!hasTestDb)('permission enforcement (e2e, issue #52)', () => { let app: INestApplication; let prisma: PrismaClient; const suffix = uniqueSuffix(); const password = 'berechtigungen sind kein zufall 1'; const userIds: Record = {}; const cookies: Record = {}; let pondId: string; let pondSlug: string; let pageId: string; const api = () => request(app.getHttpServer()); async function makeUser(handle: string): Promise { const users = app.get(UsersService); const username = `perm-${handle}-${suffix}`; const user = await users.createUser({ username, email: `${username}@example.org`, displayName: `Perm ${handle}`, password, locale: 'en', }); userIds[handle] = user.id; await users.markEmailVerified(user.id); const res = await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: username, password }) .expect(200); cookies[handle] = sessionCookieOf(res); } function grantInput(overrides: Partial): CreateGrantInput { return { subjectType: 'user', subjectId: undefined, role: 'reader', scopeType: 'pond', scopeId: undefined, effect: 'allow', ...overrides, }; } async function createGrant(input: CreateGrantInput, as = 'owner'): Promise { const res = await api() .post(`/api/v1/ponds/${pondId}/grants`) .set('Cookie', cookies[as]!) .send(input) .expect(201); return (res.body as { id: string }).id; } beforeAll(async () => { prisma = createTestPrisma(); await prisma.rateLimit.deleteMany({}); app = await createTestApp(); for (const handle of ['owner', 'reader', 'editor', 'admin2', 'foreign']) { await makeUser(handle); } // markEmailVerified bypasses the verification flow, so the personal pond // (for the personal-pond grant rules below) and the shared-pond quota // headroom are created explicitly. await app .get(PondsService) .ensurePersonalPond(await prisma.user.findUniqueOrThrow({ where: { id: userIds.owner! } })); await prisma.quotaOverride.create({ data: { subjectType: 'USER', subjectId: userIds.owner!, quotaKey: 'additional_ponds', value: 10, }, }); const pond = await api() .post('/api/v1/ponds') .set('Cookie', cookies.owner!) .send({ name: `Perm Pond ${suffix}` }) .expect(201); pondId = (pond.body as { id: string }).id; pondSlug = (pond.body as { slug: string }).slug; const page = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', cookies.owner!) .send({ title: 'Guarded Page' }) .expect(201); pageId = (page.body as { id: string }).id; await createGrant(grantInput({ subjectId: userIds.reader!, role: 'reader' })); await createGrant(grantInput({ subjectId: userIds.editor!, role: 'editor' })); await createGrant(grantInput({ subjectId: userIds.admin2!, role: 'pond_admin' })); }); afterAll(async () => { await prisma.roleGrant.deleteMany({ where: { pondId } }); await prisma.pageLabel.deleteMany({ where: { page: { pondId } } }); await prisma.label.deleteMany({ where: { pondId } }); await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } }); await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } }); await prisma.page.deleteMany({ where: { pondId } }); await prisma.pond.deleteMany({ where: { id: pondId } }); // Personal ponds (and their grants) before their users. const ids = Object.values(userIds); await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } }); await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } }); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } }); await prisma.user.deleteMany({ where: { id: { in: ids } } }); await prisma.$disconnect(); await app.close(); }); it('owner (pond admin) sees and edits everything, including grants', async () => { await api().get(`/api/v1/ponds/${pondSlug}`).set('Cookie', cookies.owner!).expect(200); await api() .patch(`/api/v1/pages/${pageId}`) .set('Cookie', cookies.owner!) .send({ title: 'Renamed by owner' }) .expect(200); const grants = await api() .get(`/api/v1/ponds/${pondId}/grants`) .set('Cookie', cookies.owner!) .expect(200); // Owner admin grant + the three fixture grants. expect((grants.body as unknown[]).length).toBeGreaterThanOrEqual(4); }); it('reader reads but cannot write (403 on readable things)', async () => { await api().get(`/api/v1/ponds/${pondSlug}`).set('Cookie', cookies.reader!).expect(200); await api().get(`/api/v1/pages/${pageId}`).set('Cookie', cookies.reader!).expect(200); // Write on a readable page → 403 (README §Conventions). await api() .patch(`/api/v1/pages/${pageId}`) .set('Cookie', cookies.reader!) .send({ title: 'Nope' }) .expect(403); // Page creation needs a pond-wide editor. await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', cookies.reader!) .send({ title: 'Nope' }) .expect(403); // History is gated like editing (ADR 0013). await api().get(`/api/v1/pages/${pageId}/versions`).set('Cookie', cookies.reader!).expect(403); }); it('reader gets a read-only collab token, editor a rw one (issue #52)', async () => { const ro = await api() .get(`/api/v1/pages/${pageId}/collab-token`) .set('Cookie', cookies.reader!) .expect(200); expect((ro.body as { mode: string }).mode).toBe('ro'); const rw = await api() .get(`/api/v1/pages/${pageId}/collab-token`) .set('Cookie', cookies.editor!) .expect(200); expect((rw.body as { mode: string }).mode).toBe('rw'); }); it('an anonymous visitor gets a ro token only where a public grant exists (issue #53)', async () => { // No public grant yet → an anonymous request (no cookie) is a 404, hiding // the page's existence just like any unauthorized read. await api().get(`/api/v1/pages/${pageId}/collab-token`).expect(404); // A public reader grant opens the page to everyone, including logged-out // visitors, who then receive a read-only token with a null subject. const publicGrant = await createGrant(grantInput({ subjectType: 'public', role: 'reader' })); const ro = await api().get(`/api/v1/pages/${pageId}/collab-token`).expect(200); expect((ro.body as { mode: string }).mode).toBe('ro'); // Revoke it again so the rest of the matrix keeps its private baseline. await api() .delete(`/api/v1/ponds/${pondId}/grants/${publicGrant}`) .set('Cookie', cookies.owner!) .expect(204); await api().get(`/api/v1/pages/${pageId}/collab-token`).expect(404); }); it('editor edits pages but cannot manage members or labels', async () => { await api() .patch(`/api/v1/pages/${pageId}`) .set('Cookie', cookies.editor!) .send({ title: 'Renamed by editor' }) .expect(200); await api().get(`/api/v1/ponds/${pondId}/grants`).set('Cookie', cookies.editor!).expect(403); await api() .post(`/api/v1/ponds/${pondId}/grants`) .set('Cookie', cookies.editor!) .send(grantInput({ subjectId: userIds.editor!, role: 'pond_admin' })) .expect(403); await api() .post(`/api/v1/ponds/${pondId}/labels`) .set('Cookie', cookies.editor!) .send({ name: 'Nope' }) .expect(403); // Pond settings are Pond Admin work too. await api() .patch(`/api/v1/ponds/${pondId}`) .set('Cookie', cookies.editor!) .send({ name: 'Nope' }) .expect(403); }); it('a second pond admin manages grants and labels', async () => { const id = await createGrant( grantInput({ subjectType: 'authenticated', role: 'reader' }), 'admin2', ); await api() .delete(`/api/v1/ponds/${pondId}/grants/${id}`) .set('Cookie', cookies.admin2!) .expect(204); await api() .patch(`/api/v1/ponds/${pondId}`) .set('Cookie', cookies.admin2!) .send({ name: `Perm Pond ${suffix}` }) .expect(200); }); it('foreign users consistently see 404 — existence stays hidden', async () => { await api().get(`/api/v1/ponds/${pondSlug}`).set('Cookie', cookies.foreign!).expect(404); await api().get(`/api/v1/pages/${pageId}`).set('Cookie', cookies.foreign!).expect(404); await api() .patch(`/api/v1/pages/${pageId}`) .set('Cookie', cookies.foreign!) .send({ title: 'Nope' }) .expect(404); await api().get(`/api/v1/ponds/${pondId}/grants`).set('Cookie', cookies.foreign!).expect(404); await api().get(`/api/v1/ponds/${pondId}/trash`).set('Cookie', cookies.foreign!).expect(404); // Not in the pond list either. const list = await api().get('/api/v1/ponds').set('Cookie', cookies.foreign!).expect(200); expect((list.body as { id: string }[]).map((p) => p.id)).not.toContain(pondId); }); it('an authenticated-subject grant opens the pond to signed-in users', async () => { const id = await createGrant(grantInput({ subjectType: 'authenticated', role: 'reader' })); await api().get(`/api/v1/pages/${pageId}`).set('Cookie', cookies.foreign!).expect(200); await api() .delete(`/api/v1/ponds/${pondId}/grants/${id}`) .set('Cookie', cookies.owner!) .expect(204); await api().get(`/api/v1/pages/${pageId}`).set('Cookie', cookies.foreign!).expect(404); }); it('a label-scope deny beats the pond-scope allow (most specific wins)', async () => { const label = await api() .post(`/api/v1/ponds/${pondId}/labels`) .set('Cookie', cookies.owner!) .send({ name: `confidential-${suffix}` }) .expect(201); const labelId = (label.body as { id: string }).id; await api() .post(`/api/v1/pages/${pageId}/labels`) .set('Cookie', cookies.owner!) .send({ labelId }) .expect(201); const denyId = await createGrant( grantInput({ subjectId: userIds.reader!, role: 'reader', scopeType: 'label', scopeId: labelId, effect: 'deny', }), ); // The page reads as nonexistent for the denied reader; the pond stays visible. await api().get(`/api/v1/pages/${pageId}`).set('Cookie', cookies.reader!).expect(404); await api().get(`/api/v1/ponds/${pondSlug}`).set('Cookie', cookies.reader!).expect(200); // …and it disappears from the sidebar list. const pages = await api() .get(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', cookies.reader!) .expect(200); expect((pages.body as { id: string }[]).map((p) => p.id)).not.toContain(pageId); await api() .delete(`/api/v1/ponds/${pondId}/grants/${denyId}`) .set('Cookie', cookies.owner!) .expect(204); await api() .delete(`/api/v1/pages/${pageId}/labels/${labelId}`) .set('Cookie', cookies.owner!) .expect(204); await api().get(`/api/v1/pages/${pageId}`).set('Cookie', cookies.reader!).expect(200); }); it('revoking a grant denies the very next request (cache invalidation)', async () => { // Fill the cache with a granted read… await api().get(`/api/v1/pages/${pageId}`).set('Cookie', cookies.reader!).expect(200); // …revoke… const grants = await api() .get(`/api/v1/ponds/${pondId}/grants`) .set('Cookie', cookies.owner!) .expect(200); const readerGrant = (grants.body as { id: string; subjectId: string | null }[]).find( (g) => g.subjectId === userIds.reader, ); expect(readerGrant).toBeDefined(); await api() .delete(`/api/v1/ponds/${pondId}/grants/${readerGrant!.id}`) .set('Cookie', cookies.owner!) .expect(204); // …and the immediately following request is already denied. await api().get(`/api/v1/pages/${pageId}`).set('Cookie', cookies.reader!).expect(404); // Restore for any later cases. await createGrant(grantInput({ subjectId: userIds.reader!, role: 'reader' })); await api().get(`/api/v1/pages/${pageId}`).set('Cookie', cookies.reader!).expect(200); }); it('trash requires write capability, per page (ADR 0013)', async () => { const page = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', cookies.editor!) .send({ title: 'Trash me' }) .expect(201); const trashedId = (page.body as { id: string }).id; await api().delete(`/api/v1/pages/${trashedId}`).set('Cookie', cookies.editor!).expect(204); // Readers see an empty trash and cannot restore; editors can. const readerTrash = await api() .get(`/api/v1/ponds/${pondId}/trash`) .set('Cookie', cookies.reader!) .expect(200); expect(readerTrash.body).toEqual([]); await api() .post(`/api/v1/pages/${trashedId}/restore`) .set('Cookie', cookies.reader!) .expect(404); const editorTrash = await api() .get(`/api/v1/ponds/${pondId}/trash`) .set('Cookie', cookies.editor!) .expect(200); expect((editorTrash.body as { id: string }[]).map((p) => p.id)).toContain(trashedId); await api() .post(`/api/v1/pages/${trashedId}/restore`) .set('Cookie', cookies.editor!) .expect(201); await api().delete(`/api/v1/pages/${trashedId}`).set('Cookie', cookies.editor!).expect(204); await api() .delete(`/api/v1/pages/${trashedId}/purge`) .set('Cookie', cookies.editor!) .expect(204); }); it('grant validation: personal ponds refuse extra admins, last admin stays', async () => { const personal = await prisma.pond.findFirstOrThrow({ where: { ownerId: userIds.owner!, type: 'PERSONAL' }, }); await api() .post(`/api/v1/ponds/${personal.id}/grants`) .set('Cookie', cookies.owner!) .send(grantInput({ subjectId: userIds.editor!, role: 'pond_admin' })) .expect(400); // The personal pond's only admin grant (the owner's) cannot be deleted. const grants = await api() .get(`/api/v1/ponds/${personal.id}/grants`) .set('Cookie', cookies.owner!) .expect(200); const adminGrant = (grants.body as { id: string; role: string }[]).find( (g) => g.role === 'pond_admin', ); expect(adminGrant).toBeDefined(); await api() .delete(`/api/v1/ponds/${personal.id}/grants/${adminGrant!.id}`) .set('Cookie', cookies.owner!) .expect(409); }); it('rejects grants pointing at foreign or missing scopes and subjects', async () => { await api() .post(`/api/v1/ponds/${pondId}/grants`) .set('Cookie', cookies.owner!) .send(grantInput({ subjectId: userIds.reader!, scopeType: 'label', scopeId: 'missing' })) .expect(400); await api() .post(`/api/v1/ponds/${pondId}/grants`) .set('Cookie', cookies.owner!) .send(grantInput({ subjectId: 'no-such-user' })) .expect(400); }); });