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
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
182 lines
7.3 KiB
TypeScript
182 lines
7.3 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 { ClockService } from '../common/clock.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-trail dedup window (issue #223, ADR 0023): one event per
|
|
* (session, page, channel) within an aligned `readTrail.dedupWindowMinutes`
|
|
* window — repeats and reconnect-style re-requests collapse, a new session
|
|
* or a new window does not, and the recorded row states the window length
|
|
* it represents.
|
|
*/
|
|
describe.skipIf(!hasTestDb)('read-trail dedup window (e2e, issue #223)', () => {
|
|
let app: INestApplication;
|
|
let prisma: PrismaClient;
|
|
const suffix = uniqueSuffix();
|
|
const password = 'dedup fenster zeugen 123';
|
|
|
|
let ownerId: string;
|
|
let ownerCookie: string;
|
|
let pondId: string;
|
|
let classifiedId: string;
|
|
|
|
const api = () => request(app.getHttpServer());
|
|
const login = async () =>
|
|
sessionCookieOf(
|
|
await api()
|
|
.post('/api/v1/auth/login')
|
|
.send({ usernameOrEmail: `dedup-owner-${suffix}`, password })
|
|
.expect(200),
|
|
);
|
|
const events = () =>
|
|
prisma.readEvent.findMany({ where: { pondId }, orderBy: { occurredAt: 'asc' } });
|
|
|
|
beforeAll(async () => {
|
|
prisma = createTestPrisma();
|
|
await prisma.rateLimit.deleteMany({});
|
|
app = await createTestApp();
|
|
const users = app.get(UsersService);
|
|
|
|
const owner = await users.createUser({
|
|
username: `dedup-owner-${suffix}`,
|
|
email: `dedup-owner-${suffix}@example.test`,
|
|
displayName: 'Dedup Owner',
|
|
password,
|
|
locale: 'en',
|
|
});
|
|
await users.markEmailVerified(owner.id);
|
|
ownerId = owner.id;
|
|
// The trail ships OFF by default (#225) — this suite needs it on.
|
|
await app.get(InstanceSettingsService).set('readTrail.enabled', true, ownerId);
|
|
ownerCookie = await login();
|
|
|
|
const pond = await prisma.pond.create({
|
|
data: { slug: `dedup-pond-${suffix}`, name: 'Dedup 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;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await prisma.readEvent.deleteMany({ where: { pondId } });
|
|
await prisma.rateLimit.deleteMany({});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await prisma.instanceSetting.deleteMany({ where: { key: 'readTrail.enabled' } });
|
|
await prisma.readEvent.deleteMany({ where: { pondId } });
|
|
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('collapses repeated reads of one page in one session+channel to a single event that names its window', async () => {
|
|
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
|
|
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
|
|
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
|
|
const rows = await events();
|
|
expect(rows).toHaveLength(1);
|
|
// The row itself states that it represents a window, not a request.
|
|
expect(rows[0]!.windowSeconds).toBe(5 * 60);
|
|
expect(rows[0]!.dedupKey).toBe(`${rows[0]!.sessionKey}:${classifiedId}:page_view`);
|
|
});
|
|
|
|
it('keeps channels apart: the same session reading and joining collab yields one event each', async () => {
|
|
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
|
|
await api()
|
|
.get(`/api/v1/pages/${classifiedId}/collab-token`)
|
|
.set('Cookie', ownerCookie)
|
|
.expect(200);
|
|
const rows = await events();
|
|
expect(rows.map((r) => r.channel).sort()).toEqual(['collab_join', 'page_view']);
|
|
});
|
|
|
|
it('a new session records again even for the same user — a reconnect within the session does not', async () => {
|
|
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
|
|
// Same session, later request ("reconnect"): deduped.
|
|
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
|
|
expect(await events()).toHaveLength(1);
|
|
|
|
const secondCookie = await login();
|
|
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', secondCookie).expect(200);
|
|
const rows = await events();
|
|
expect(rows).toHaveLength(2);
|
|
expect(new Set(rows.map((r) => r.sessionKey)).size).toBe(2);
|
|
expect(rows.every((r) => r.actorId === ownerId)).toBe(true);
|
|
});
|
|
|
|
it('bounds a live editing session: 30 collab-token renewals in one window are one event', async () => {
|
|
for (let i = 0; i < 30; i += 1) {
|
|
await api()
|
|
.get(`/api/v1/pages/${classifiedId}/collab-token`)
|
|
.set('Cookie', ownerCookie)
|
|
.expect(200);
|
|
}
|
|
const rows = await events();
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]!.channel).toBe('collab_join');
|
|
});
|
|
|
|
it('opens a new window when the clock moves past the bucket boundary', async () => {
|
|
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
|
|
const clock = app.get(ClockService);
|
|
const later = new Date(Date.now() + 10 * 60 * 1000);
|
|
const spy = vi.spyOn(clock, 'now').mockReturnValue(later);
|
|
try {
|
|
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
expect(await events()).toHaveLength(2);
|
|
});
|
|
|
|
it('dedups page-less attachment downloads on the placeholder page key', async () => {
|
|
// A pond-level upload has no page; its effective classification falls
|
|
// back to the pond maximum (#212) and the dedup key carries `-`.
|
|
const uploaded = await api()
|
|
.post(`/api/v1/ponds/${pondId}/files`)
|
|
.set('Cookie', ownerCookie)
|
|
.attach('file', Buffer.concat([PNG_SIGNATURE, Buffer.from('img')]), 'a.png')
|
|
.expect(201);
|
|
await prisma.readEvent.deleteMany({ where: { pondId } });
|
|
|
|
await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(200);
|
|
await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(200);
|
|
const rows = await events();
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]!.channel).toBe('attachment');
|
|
expect(rows[0]!.pageId).toBeNull();
|
|
expect(rows[0]!.dedupKey).toBe(`${rows[0]!.sessionKey}:-:attachment`);
|
|
});
|
|
});
|