dorfteich/apps/api/src/files/attachment-integrity.e2e.db.test.ts
Claude Fable 5 05a979bac3
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m25s
CI / Build container images (pull_request) Successful in 2m58s
CI / Auth e2e pack (pull_request) Successful in 8m35s
CI / Import/export fidelity gate (pull_request) Successful in 1m7s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Has been cancelled
CD / Build and push images (push) Has been cancelled
#222: read-access trail for classified pages
Instrument every full-content read channel for pages with
classification = vs_nfd (ADR 0023, variant A): SPA state fetch and read
rendering, public JSON content, no-JS shell, expanded embeds, public API
GET (incl. the MCP read_page path and write echoes), attachment download
under the #212 effective classification, all export shapes (markdown,
pond ZIP, account data export, queued docx/odt/pdf at enqueue), and
collab-token issuance as the api-side proxy for the WS join.

Events land in the new read_events table (no FKs — evidence survives
page purges and hard user deletions) with actor, session key
(session:/token:/job:/anon), page, pond, channel and the classification
at read time. Recording failures are NOT swallowed: a failed write
aborts the read (hard failure, the deliberate contrast to AuditService —
decision recorded in ADR 0023 and security.md §Logging, together with
the recorded residuals: content fragments and feeds).

One e2e test per channel proves both the event and its absence for
unclassified pages, plus the hard-failure semantics.

Refs #222.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 12:12:55 +02:00

153 lines
6.3 KiB
TypeScript

import { createHash } from 'node:crypto';
import { INestApplication, InternalServerErrorException } from '@nestjs/common';
import { PrismaClient, User } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service';
import { createTestApp } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
import { FileStorageService } from './file-storage.service';
import { FilesService } from './files.service';
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const pngBuffer = (payload: string): Buffer => Buffer.concat([PNG_SIGNATURE, Buffer.from(payload)]);
const sha256 = (buffer: Buffer): string => createHash('sha256').update(buffer).digest('hex');
/**
* Attachment integrity (issue #199): uploads store the SHA-256 of the
* written bytes, downloads verify it and fail closed (audited) on mismatch,
* and the nightly backfill hashes pre-#199 rows idempotently, reporting
* unreadable files instead of skipping them.
*/
describe.skipIf(!hasTestDb)('attachment integrity (e2e, issue #199)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let files: FilesService;
let storage: FileStorageService;
let user: User;
let pondId: string;
const suffix = uniqueSuffix();
async function uploadPng(payload: string): Promise<{ id: string; bytes: Buffer }> {
const bytes = pngBuffer(payload);
const view = await files.upload(user, pondId, {
buffer: bytes,
size: bytes.length,
originalname: `${payload}.png`,
});
return { id: view.id, bytes };
}
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
files = app.get(FilesService);
storage = app.get(FileStorageService);
const users = app.get(UsersService);
user = await users.createUser({
username: `ines-integrity-${suffix}`,
email: `ines-integrity-${suffix}@example.org`,
displayName: `Ines Integrity ${suffix}`,
password: 'jedes byte bleibt wie es war 1',
locale: 'en',
});
// Verification via the endpoint (not markEmailVerified) because only the
// endpoint creates the personal pond the uploads go into.
const token = await app.get(AuthTokensService).issue(user.id, 'EMAIL_VERIFICATION', 600);
await request(app.getHttpServer())
.post('/api/v1/auth/verify-email')
.send({ token })
.expect(204);
const pond = await prisma.pond.findFirstOrThrow({ where: { ownerId: user.id } });
pondId = pond.id;
});
afterAll(async () => {
await prisma.auditEntry.deleteMany({
where: { action: 'file.integrity_failed', details: { path: ['pondId'], equals: pondId } },
});
await prisma.attachment.deleteMany({ where: { pondId } });
const where = { pond: { owner: { username: { contains: suffix } } } };
await prisma.roleGrant.deleteMany({ where });
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('stores the hash of the written bytes at upload', async () => {
const { id, bytes } = await uploadPng('honest-upload');
const row = await prisma.attachment.findUniqueOrThrow({ where: { id } });
expect(row.sha256).toBe(sha256(bytes));
});
it('serves an intact file and fails closed, audited, on a tampered one', async () => {
const { id, bytes } = await uploadPng('will-be-tampered');
// Intact: the download succeeds and streams the exact bytes.
const intact = await files.download(null, id, { actorId: null, sessionKey: 'anon' });
const chunks: Buffer[] = [];
for await (const chunk of intact.stream) chunks.push(chunk as Buffer);
expect(Buffer.concat(chunks).equals(bytes)).toBe(true);
// Tampered on disk (row untouched): fail closed with the dedicated code.
await storage.save(pondId, id, pngBuffer('evil-replacement'));
const failure = await files
.download(null, id, { actorId: null, sessionKey: 'anon' })
.catch((error: unknown) => error);
expect(failure).toBeInstanceOf(InternalServerErrorException);
expect((failure as InternalServerErrorException).getResponse()).toMatchObject({
code: 'attachment_integrity_failure',
});
// The mismatch is on the audit trail with both hashes.
const audit = await prisma.auditEntry.findFirst({
where: { action: 'file.integrity_failed', targetId: id },
});
expect(audit).not.toBeNull();
expect(audit!.details).toMatchObject({
expected: sha256(bytes),
actual: sha256(pngBuffer('evil-replacement')),
});
});
it('backfills missing hashes idempotently and reports unreadable files', async () => {
const readable = await uploadPng('backfill-me');
const unreadable = await uploadPng('bytes-will-vanish');
await prisma.attachment.updateMany({
where: { id: { in: [readable.id, unreadable.id] } },
data: { sha256: null },
});
await storage.delete(pondId, unreadable.id);
// A null-hash row is served unverified (pre-#199 status quo).
const unverified = await files.download(null, readable.id, {
actorId: null,
sessionKey: 'anon',
});
expect(unverified.attachment.sha256).toBeNull();
const first = await files.backfillHashes();
expect(first.hashed).toBeGreaterThanOrEqual(1);
expect(first.unreadable).toBeGreaterThanOrEqual(1);
const rehashed = await prisma.attachment.findUniqueOrThrow({ where: { id: readable.id } });
expect(rehashed.sha256).toBe(sha256(readable.bytes));
// The unreadable row keeps its null hash — reported, retried next run,
// never silently marked done.
const vanished = await prisma.attachment.findUniqueOrThrow({ where: { id: unreadable.id } });
expect(vanished.sha256).toBeNull();
// Idempotent: a second run finds nothing new to hash here.
const second = await files.backfillHashes();
const third = await prisma.attachment.findUniqueOrThrow({ where: { id: readable.id } });
expect(third.sha256).toBe(sha256(readable.bytes));
expect(second.unreadable).toBeGreaterThanOrEqual(1);
});
});