Opening a pond landed on whatever sorted first in the sidebar — stable, but a rule nobody could see, and one whose target moved as soon as someone added a page ahead of it. New ponds landed on the empty-pond hint instead of anything useful. - `startPageId` joins the pond settings. No migration: `Pond.settings` is already jsonb. It stores an id, not a slug, so renaming or moving the page keeps it working. - `PondHomePage` prefers it, but only when the page is in this user's page list. That list already holds just what they may see, so a start page hidden by a page-scoped grant — or trashed — falls back silently instead of landing them on a 404, and it costs no extra request. - Both creation paths give the pond a start page, titled from the creator's stored locale. It happens after the creating transaction commits: the owner's grant is written inside it and permissions cache per pond, so creating the page any earlier would ask about rights the grant has not published yet. A failure is logged, not fatal — a pond without a start page still works. `PagesModule` imported `PondsModule` without using it. Removing that vestigial edge let PondsModule depend on PagesModule in the honest direction instead of tying the two together with forwardRef. Every pond created through the api now owns a page, which broke eight suites whose teardown deleted ponds directly — `Page.pond` deliberately has no cascade, because a real purge removes contents explicitly and audits it. A shared `deletePondsWhere` helper deletes pages first. Two tests that counted pages now account for the start page rather than pretending the pond began empty.
153 lines
6.3 KiB
TypeScript
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, deletePondsWhere, 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 deletePondsWhere(prisma, { 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);
|
|
});
|
|
});
|