dorfteich/apps/api/src/pages/collab-token.e2e.db.test.ts
Claude Opus 4.8 9d288b2ad0
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
Wire real permissions into collab tokens and revocation (#53)
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
2026-07-09 18:48:42 +02:00

132 lines
4.8 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import { collabTokenClaimsSchema } from '@dorfteich/shared';
import { verifyCollabToken } from '@dorfteich/shared/token-crypto';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service';
import { AppConfig } from '../config/app-config.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
describe.skipIf(!hasTestDb)('collab token (e2e, issue #34)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let secret: string;
const suffix = uniqueSuffix();
const password = 'tokens fuer teiche 1';
const owner = { username: `tom-token-${suffix}`, displayName: `Tom Token ${suffix}` };
const outsider = { username: `oona-out-${suffix}`, displayName: `Oona Out ${suffix}` };
let ownerCookie: string;
let outsiderCookie: string;
let pageId: string;
const api = () => request(app.getHttpServer());
async function loginOf(username: string): Promise<string> {
const res = await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200);
return sessionCookieOf(res);
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
secret = app.get(AppConfig).env.COLLAB_TOKEN_SECRET;
const users = app.get(UsersService);
const tokens = app.get(AuthTokensService);
const ownerUser = await users.createUser({
username: owner.username,
email: `${owner.username}@example.org`,
displayName: owner.displayName,
password,
locale: 'en',
});
const verifyToken = await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600);
await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204);
ownerCookie = await loginOf(owner.username);
const outsiderUser = await users.createUser({
username: outsider.username,
email: `${outsider.username}@example.org`,
displayName: outsider.displayName,
password,
locale: 'en',
});
await users.markEmailVerified(outsiderUser.id);
outsiderCookie = await loginOf(outsider.username);
const ponds = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200);
const pondId = ponds.body.find((p: { type: string }) => p.type === 'personal').id;
const page = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: 'Collaborative page' })
.expect(201);
pageId = page.body.id;
});
afterAll(async () => {
const users = await prisma.user.findMany({
where: { username: { contains: suffix } },
select: { id: true },
});
await prisma.page.deleteMany({
where: { pond: { owner: { username: { contains: suffix } } } },
});
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: users.map((u) => u.id) } } });
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('hides a private page from an anonymous visitor (404, not 401 — issue #53)', async () => {
// The endpoint is public so anonymous visitors can reach public pages, but
// without a `public` grant this page is unreadable and its existence stays
// hidden — the same 404 an authenticated non-member gets.
await api().get(`/api/v1/pages/${pageId}/collab-token`).expect(404);
});
it('issues a valid rw token to a member and encodes the right claims', async () => {
const res = await api()
.get(`/api/v1/pages/${pageId}/collab-token`)
.set('Cookie', ownerCookie)
.expect(200);
expect(res.body.mode).toBe('rw');
expect(res.body.expiresInSeconds).toBe(60);
const verified = verifyCollabToken(res.body.token, secret);
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(collabTokenClaimsSchema.parse(verified.claims)).toEqual({
userId: expect.any(String),
pageId,
mode: 'rw',
});
}
});
it('hides the page from a non-member (404, not 403)', async () => {
await api()
.get(`/api/v1/pages/${pageId}/collab-token`)
.set('Cookie', outsiderCookie)
.expect(404);
});
it('404s for an unknown page id', async () => {
await api()
.get('/api/v1/pages/00000000-0000-4000-8000-0000000000ff/collab-token')
.set('Cookie', ownerCookie)
.expect(404);
});
});