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
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
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 = 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);
|
|
});
|
|
});
|