dorfteich/apps/api/src/pages/collab-token.e2e.db.test.ts
Claude Fable 5 3d1f4fda53
All checks were successful
CI / Build container images (pull_request) Successful in 3m51s
CI / Auth e2e pack (pull_request) Successful in 7m49s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CI / Lint, typecheck, test (pull_request) Successful in 4m43s
CD / Build and push images (push) Successful in 20s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m54s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m39s
CI / Import/export fidelity gate (push) Successful in 59s
#188: purpose-bound token keys via HKDF, jose replaces the homegrown JWT
COLLAB_TOKEN_SECRET becomes a root key: every purpose derives its own
HKDF-SHA-256 subkey (deriveTokenKey), and no code path signs with the
root key directly. Collaboration tokens are signed and verified by jose
with HS256 as an explicit allowlist; the sign/verify API turns async at
its three call sites. Unsubscribe tokens move from a purpose-prefix
string to the structural subkey, with a documented dual-verify window
(legacy derivation accepted until 2026-11-01, covering the 90-day TTL
of links in already-sent mail).

The cross-runtime property that justified the homegrown implementation
is now proven by a test: the built CJS and ESM dist artefacts round-trip
tokens in both directions in child processes (jose v6 reaches CJS via
Node's require(esm), pinned Node 22 images). Negative tests cover
cross-purpose subkeys, root-key-signed tokens, alg:none and RS256.

Refs #188 (ADR 0020)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 06:41:11 +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 = await 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);
});
});