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',
'
Restricted content.
',
'VS_NFD',
);
classifiedId = classified.id;
openSlug = `open-${suffix}`;
const open = await makePage(openSlug, 'Open Note', 'Open content.
', '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',
`Intro.
${classifiedSlug}
`,
'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);
});
});