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
336 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|