All checks were successful
CD / Build and push images (push) Successful in 3m4s
CI / Lint, typecheck, test (push) Successful in 2m27s
CI / Auth e2e pack (push) Successful in 3m6s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s
Live editing now obeys the same rules as REST: the collab-token mode comes from the shared grant resolution, anonymous visitors can join public pages, and revoking write access flips a running session to read-only within seconds. - Anonymous public tokens: `GET /pages/:id/collab-token` is `@Public()` but still permission-guarded, so a logged-out visitor gets an `ro` token where a `public` grant makes the page readable (404 otherwise). The token's `userId` is nullable (shared schema + collab context) for anonymous subjects. - Prompt revocation: the pond-level NOTIFY (#39) now also fires on label tree/assignment changes (LabelsService move/remove/assign/unassign), and the collab server closes the *actual* WebSocket instead of only sending an application-level close message. Hocuspocus' `closeConnections` leaves the socket open so the client only re-checks on its ~30s message timeout; `closeDocumentConnections` drops the socket so the client reconnects and re-authenticates with a freshly-resolved token at once — the "within seconds" downgrade the milestone promises. - Tests: the #52 fixture matrix gains anonymous cases (public grant → `ro`, none → 404); a collab db test proves an editor downgraded to reader goes read-only on reconnect (its post-downgrade edits no longer reach a peer); a new browser `collab-permissions` pack covers the read-only participant and the live downgrade end to end (new plain `fixture-editor` account). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
408 lines
16 KiB
TypeScript
408 lines
16 KiB
TypeScript
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<string, string> = {};
|
|
const cookies: Record<string, string> = {};
|
|
|
|
let pondId: string;
|
|
let pondSlug: string;
|
|
let pageId: string;
|
|
|
|
const api = () => request(app.getHttpServer());
|
|
|
|
async function makeUser(handle: string): Promise<void> {
|
|
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>): CreateGrantInput {
|
|
return {
|
|
subjectType: 'user',
|
|
subjectId: undefined,
|
|
role: 'reader',
|
|
scopeType: 'pond',
|
|
scopeId: undefined,
|
|
effect: 'allow',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function createGrant(input: CreateGrantInput, as = 'owner'): Promise<string> {
|
|
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);
|
|
});
|
|
});
|