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
132 lines
4.8 KiB
TypeScript
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);
|
|
});
|
|
});
|