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