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