dorfteich/apps/api/src/read-trail/read-trail.e2e.db.test.ts
Claude Fable 5 7ce6467385
Some checks failed
CI / Lint, typecheck, test (pull_request) Failing after 5s
CI / Build container images (pull_request) Has been skipped
CI / Auth e2e pack (pull_request) Has been skipped
CI / Import/export fidelity gate (pull_request) Has been skipped
#225: read-trail master switch and written purpose limitation
New instance switch readTrail.enabled, default OFF: read logging is
employee monitoring in a works council's eyes — an ordinary instance
must not surveil reads. Off means no event is written ANYWHERE (no row,
no stdout line, verified by test); the api announces the switch position
once per boot, so an eventless trail is never ambiguous — a gap reads
as "was off", never "was lost".

The written purpose limitation ships as section 7 of the VS-NfD
security documentation (#228): what is recorded (no content, no titles,
no IPs, no fingerprinting), why (evidence for reads of marked content
only — variant A is the technical anchor of the promise), who may read
it (Site Admin, API-only), for how long (readTrail.retentionDays,
audited pruning), and what it may NOT be used for (no performance or
behaviour monitoring). The hardening guide's reference configuration
turns the trail on (reference value true) and points to that text; the
existing trail suites now enable the switch explicitly.

Refs #225.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 12:33:04 +02:00

336 lines
12 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { PrismaService } from '../prisma/prisma.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/**
* The read-access trail (issue #222, ADR 0023): every read channel emits one
* `read_events` row for a `VS_NFD` page and none for an unclassified one —
* per channel, both directions. Plus the deliberate failure semantics: a
* failed trail write aborts the read (hard failure, the documented contrast
* to AuditService's swallow-and-log).
*/
describe.skipIf(!hasTestDb)('read-access trail (e2e, issue #222)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'lesetrail zeugen 123';
let ownerId: string;
let ownerCookie: string;
let pondId: string;
let pondSlug: string;
let classifiedId: string;
let classifiedSlug: string;
let openId: string;
let openSlug: string;
let hostSlug: string;
const api = () => request(app.getHttpServer());
const eventsFor = (pageId: string) =>
prisma.readEvent.findMany({ where: { pageId }, orderBy: { occurredAt: 'asc' } });
async function makePage(
slug: string,
title: string,
html: string,
classification: 'UNCLASSIFIED' | 'VS_NFD',
) {
return prisma.page.create({
data: {
pondId,
slug,
title,
classification,
createdBy: ownerId,
sortKey: 'a0',
ydocState: new Uint8Array(),
contentCache: { create: { plainText: title, markdown: `# ${title}`, html, outline: [] } },
},
});
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
const users = app.get(UsersService);
const owner = await users.createUser({
username: `trail-owner-${suffix}`,
email: `trail-owner-${suffix}@example.test`,
displayName: 'Trail Owner',
password,
locale: 'en',
});
await users.markEmailVerified(owner.id);
ownerId = owner.id;
// The trail ships OFF by default (#225) — these suites test the
// instrumented channels, so they run with the switch on.
await app.get(InstanceSettingsService).set('readTrail.enabled', true, ownerId);
ownerCookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: `trail-owner-${suffix}`, password })
.expect(200),
);
pondSlug = `trail-pond-${suffix}`;
const pond = await prisma.pond.create({
data: { slug: pondSlug, name: 'Trail Pond', type: 'SHARED', ownerId },
});
pondId = pond.id;
// Grants land as rows BEFORE any permission query touches this pond, so
// the per-pond cache first fills with them present.
await grantOwnerAdmin(prisma, pondId, ownerId);
await prisma.roleGrant.create({
data: {
pondId,
subjectType: 'PUBLIC',
subjectId: null,
role: 'READER',
scopeType: 'POND',
scopeId: null,
effect: 'ALLOW',
createdBy: ownerId,
},
});
classifiedSlug = `classified-${suffix}`;
const classified = await makePage(
classifiedSlug,
'Classified Note',
'<p>Restricted content.</p>',
'VS_NFD',
);
classifiedId = classified.id;
openSlug = `open-${suffix}`;
const open = await makePage(openSlug, 'Open Note', '<p>Open content.</p>', 'UNCLASSIFIED');
openId = open.id;
// An unclassified host page that transcludes the classified page — the
// expanded embed shows the target's full content (#222).
hostSlug = `host-${suffix}`;
await makePage(
hostSlug,
'Host Page',
`<p>Intro.</p><div class="dt-transclusion" data-transclusion="${classifiedSlug}">${classifiedSlug}</div>`,
'UNCLASSIFIED',
);
});
afterEach(async () => {
await prisma.readEvent.deleteMany({ where: { pondId } });
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({
where: { key: { in: ['api.enabled', 'readTrail.enabled'] } },
});
await prisma.readEvent.deleteMany({ where: { pondId } });
await prisma.conversionJob.deleteMany({ where: { ownerId } });
await prisma.apiToken.deleteMany({ where: { userId: ownerId } });
await prisma.attachment.deleteMany({ where: { pondId } });
await prisma.roleGrant.deleteMany({ where: { pondId } });
await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } });
await prisma.page.deleteMany({ where: { pondId } });
await prisma.pond.deleteMany({ where: { id: pondId } });
await prisma.user.deleteMany({ where: { id: ownerId } });
await prisma.$disconnect();
await app.close();
});
it('records the SPA state fetch (page_view) with actor and session key — and nothing for unclassified', async () => {
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
const events = await eventsFor(classifiedId);
expect(events).toHaveLength(1);
expect(events[0]!).toMatchObject({
channel: 'page_view',
actorId: ownerId,
pondId,
classification: 'vs_nfd',
});
expect(events[0]!.sessionKey).toMatch(/^session:/);
await api().get(`/api/v1/pages/${openId}`).set('Cookie', ownerCookie).expect(200);
expect(await eventsFor(openId)).toHaveLength(0);
});
it('records the authenticated read rendering (/read) as page_view', async () => {
await api()
.get(`/api/v1/read/${pondSlug}/${classifiedSlug}`)
.set('Cookie', ownerCookie)
.expect(200);
const events = await eventsFor(classifiedId);
expect(events).toHaveLength(1);
expect(events[0]!.channel).toBe('page_view');
await api().get(`/api/v1/read/${pondSlug}/${openSlug}`).set('Cookie', ownerCookie).expect(200);
expect(await eventsFor(openId)).toHaveLength(0);
});
it('records the anonymous public JSON read with the documented anon marker', async () => {
await api().get(`/api/v1/public/${pondSlug}/${classifiedSlug}/content`).expect(200);
const events = await eventsFor(classifiedId);
expect(events).toHaveLength(1);
expect(events[0]!).toMatchObject({ channel: 'page_view', actorId: null, sessionKey: 'anon' });
await api().get(`/api/v1/public/${pondSlug}/${openSlug}/content`).expect(200);
expect(await eventsFor(openId)).toHaveLength(0);
});
it('records the no-JS shell under its own channel', async () => {
await api().get(`/api/v1/public/${pondSlug}/${classifiedSlug}`).expect(200);
const events = await eventsFor(classifiedId);
expect(events).toHaveLength(1);
expect(events[0]!.channel).toBe('no_js_shell');
await api().get(`/api/v1/public/${pondSlug}/${openSlug}`).expect(200);
expect(await eventsFor(openId)).toHaveLength(0);
});
it('records an expanded embed of a classified page inside an unclassified host', async () => {
const res = await api()
.get(`/api/v1/read/${pondSlug}/${hostSlug}`)
.set('Cookie', ownerCookie)
.expect(200);
expect(res.body.html).toContain('Restricted content.');
const events = await eventsFor(classifiedId);
expect(events).toHaveLength(1);
expect(events[0]!).toMatchObject({ channel: 'page_view', details: { embedded: true } });
});
it('records collab-token issuance (collab_join) with the granted mode', async () => {
await api()
.get(`/api/v1/pages/${classifiedId}/collab-token`)
.set('Cookie', ownerCookie)
.expect(200);
const events = await eventsFor(classifiedId);
expect(events).toHaveLength(1);
expect(events[0]!).toMatchObject({ channel: 'collab_join', details: { mode: 'rw' } });
await api().get(`/api/v1/pages/${openId}/collab-token`).set('Cookie', ownerCookie).expect(200);
expect(await eventsFor(openId)).toHaveLength(0);
});
it('records the single-page markdown download and the queued document export', async () => {
await api()
.get(`/api/v1/pages/${classifiedId}/export/markdown`)
.set('Cookie', ownerCookie)
.expect(200);
let events = await eventsFor(classifiedId);
expect(events).toHaveLength(1);
expect(events[0]!.channel).toBe('export');
await prisma.readEvent.deleteMany({ where: { pondId } });
await api()
.post(`/api/v1/pages/${classifiedId}/export`)
.set('Cookie', ownerCookie)
.send({ format: 'docx' })
.expect(201);
events = await eventsFor(classifiedId);
expect(events).toHaveLength(1);
expect(events[0]!).toMatchObject({ channel: 'export', details: { format: 'docx' } });
await api()
.get(`/api/v1/pages/${openId}/export/markdown`)
.set('Cookie', ownerCookie)
.expect(200);
await api()
.post(`/api/v1/pages/${openId}/export`)
.set('Cookie', ownerCookie)
.send({ format: 'docx' })
.expect(201);
expect(await eventsFor(openId)).toHaveLength(0);
});
it('records one event per classified page in a pond ZIP export — none for the unclassified ones', async () => {
await api()
.get(`/api/v1/ponds/${pondId}/export/markdown`)
.set('Cookie', ownerCookie)
.expect(200);
const events = await eventsFor(classifiedId);
expect(events).toHaveLength(1);
expect(events[0]!).toMatchObject({ channel: 'export', details: { format: 'markdown_zip' } });
expect(await eventsFor(openId)).toHaveLength(0);
});
it('records a download of an attachment whose effective classification is vs_nfd', async () => {
const classifiedUpload = await api()
.post(`/api/v1/pages/${classifiedId}/files`)
.set('Cookie', ownerCookie)
.attach('file', Buffer.concat([PNG_SIGNATURE, Buffer.from('classified bytes')]), 'c.png')
.expect(201);
await prisma.readEvent.deleteMany({ where: { pondId } });
await api()
.get(`/api/v1/media/${classifiedUpload.body.id}`)
.set('Cookie', ownerCookie)
.expect(200);
const events = await prisma.readEvent.findMany({ where: { pondId, channel: 'attachment' } });
expect(events).toHaveLength(1);
expect(events[0]!).toMatchObject({
pageId: classifiedId,
details: { attachmentId: classifiedUpload.body.id },
});
});
it('records a public-api page read under the token session key', async () => {
await app.get(InstanceSettingsService).set('api.enabled', true, ownerId);
try {
await api()
.patch(`/api/v1/ponds/${pondId}`)
.set('Cookie', ownerCookie)
.send({ apiEnabled: true })
.expect(200);
const minted = await api()
.post('/api/v1/users/me/api-tokens')
.set('Cookie', ownerCookie)
.send({ name: `trail-${suffix}`, scope: 'read' })
.expect(201);
await api()
.get(`/api/public/v1/ponds/${pondSlug}/pages/${classifiedSlug}`)
.set('Authorization', `Bearer ${minted.body.token}`)
.expect(200);
const events = await eventsFor(classifiedId);
expect(events).toHaveLength(1);
expect(events[0]!.channel).toBe('public_api');
expect(events[0]!.sessionKey).toMatch(/^token:/);
await api()
.get(`/api/public/v1/ponds/${pondSlug}/pages/${openSlug}`)
.set('Authorization', `Bearer ${minted.body.token}`)
.expect(200);
expect(await eventsFor(openId)).toHaveLength(0);
} finally {
await prisma.instanceSetting.deleteMany({ where: { key: 'api.enabled' } });
}
});
it('fails the read hard when the trail cannot be written (ADR 0023 — no silent gap)', async () => {
const appPrisma = app.get(PrismaService);
const create = vi
.spyOn(appPrisma.readEvent, 'create')
.mockRejectedValueOnce(new Error('trail unavailable'));
try {
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(500);
// The unclassified read never touches the trail and stays unaffected.
await api().get(`/api/v1/pages/${openId}`).set('Cookie', ownerCookie).expect(200);
} finally {
create.mockRestore();
}
expect(await eventsFor(classifiedId)).toHaveLength(0);
});
});