dorfteich/apps/api/src/read-trail/read-trail-switch.e2e.db.test.ts
Claude Fable 5 4af5e6e81f
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m2s
CI / Build container images (pull_request) Successful in 4m1s
CI / Auth e2e pack (pull_request) Successful in 8m26s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 18s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m23s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m6s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m25s
CI / Import/export fidelity gate (push) Successful in 1m0s
#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:35:18 +02:00

133 lines
5.0 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
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';
import { ReadTrailService } from './read-trail.service';
/**
* The read-trail master switch (issue #225, ADR 0023): OFF is the default
* and means no event is written ANYWHERE — no row, no stdout line; ON
* restores the full #222 semantics. The switch position is announced so a
* silent trail is never ambiguous.
*/
describe.skipIf(!hasTestDb)('read-trail switch (e2e, issue #225)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'schalter zeugen 123';
let ownerId: string;
let ownerCookie: string;
let pondId: string;
let classifiedId: string;
const api = () => request(app.getHttpServer());
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
// No leftover switch row: this suite tests the DEFAULT (off).
await prisma.instanceSetting.deleteMany({ where: { key: 'readTrail.enabled' } });
app = await createTestApp();
const users = app.get(UsersService);
const owner = await users.createUser({
username: `switch-owner-${suffix}`,
email: `switch-owner-${suffix}@example.test`,
displayName: 'Switch Owner',
password,
locale: 'en',
});
await users.markEmailVerified(owner.id);
ownerId = owner.id;
ownerCookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: `switch-owner-${suffix}`, password })
.expect(200),
);
const pond = await prisma.pond.create({
data: { slug: `switch-pond-${suffix}`, name: 'Switch Pond', type: 'SHARED', ownerId },
});
pondId = pond.id;
await grantOwnerAdmin(prisma, pondId, ownerId);
const page = await prisma.page.create({
data: {
pondId,
slug: `classified-${suffix}`,
title: 'Classified',
classification: 'VS_NFD',
createdBy: ownerId,
sortKey: 'a0',
ydocState: new Uint8Array(),
contentCache: {
create: { plainText: 'x', markdown: 'x', html: '<p>x</p>', outline: [] },
},
},
});
classifiedId = page.id;
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({ where: { key: 'readTrail.enabled' } });
await prisma.readEvent.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('is OFF by default: a classified read writes nothing — no row, no stdout line', async () => {
const trail = app.get(ReadTrailService);
const logger = (trail as unknown as { logger: PinoLogger }).logger;
const infoSpy = vi.spyOn(logger, 'info');
try {
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
} finally {
infoSpy.mockRestore();
}
expect(await prisma.readEvent.count({ where: { pondId } })).toBe(0);
expect(infoSpy).not.toHaveBeenCalled();
});
it('records again the moment the switch turns on', async () => {
await app.get(InstanceSettingsService).set('readTrail.enabled', true, ownerId);
try {
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
expect(await prisma.readEvent.count({ where: { pondId } })).toBe(1);
} finally {
await app.get(InstanceSettingsService).set('readTrail.enabled', false, ownerId);
}
});
it('announces the switch position so silence is never ambiguous', async () => {
const trail = app.get(ReadTrailService);
const logger = (trail as unknown as { logger: PinoLogger }).logger;
const warnSpy = vi.spyOn(logger, 'warn');
const infoSpy = vi.spyOn(logger, 'info');
try {
await trail.announceState(); // switch is off after the previous test
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('NOT evidenced'));
await app.get(InstanceSettingsService).set('readTrail.enabled', true, ownerId);
await trail.announceState();
expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('enabled'));
} finally {
warnSpy.mockRestore();
infoSpy.mockRestore();
await app.get(InstanceSettingsService).set('readTrail.enabled', false, ownerId);
}
});
});