dorfteich/apps/api/src/trash/pond-purge.e2e.db.test.ts
Claude Fable 5 402b22e05f
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m58s
CI / Build container images (pull_request) Successful in 2m47s
CI / Auth e2e pack (pull_request) Successful in 7m46s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 28s
CD / Smoke tests against Test (push) Successful in 1m20s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 5m4s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m38s
CI / Import/export fidelity gate (push) Successful in 56s
#193: pond purge — retention job and manual Site-Admin endpoint
Deletion now actually deletes: a trashed pond past the trash retention
(same clock as pages, extended trash-purge job) or purged manually via
DELETE /ponds/:id/purge (Site-Admin-only, like pond restore) is removed
with everything it holds. Files go first (idempotent rm, resumable on a
crash), then one transaction ordered around the FK actions: attachments
and labels (Restrict) precede the pond; the page delete cascades
versions, comments, content cache incl. the search vector, update log,
mentions, label assignments, favorites, outgoing links and open collab
sessions; the pond delete cascades grants, usage counters (that is the
quota correction), pond-plugin opt-ins and conversion jobs; polymorphic
watches and pond quota overrides are deleted explicitly. A purge racing
a restore or another purge is a no-op; both paths record a pond.purged
audit event.

Known residues by design, documented in operations.md: target_slug in
other ponds' page links (#235) and backups within their retention.

Refs #193

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 12:44:52 +02:00

270 lines
10 KiB
TypeScript

import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
import { TrashService } from './trash.service';
/**
* Pond purge end to end (issue #193): deletion must actually delete —
* after the purge NOTHING referencing the pond survives (rows, files on
* disk, search index), the operation is Site-Admin-only, idempotent, and
* both the manual and the retention path leave an audit event.
*/
describe.skipIf(!hasTestDb)('pond purge (e2e, issue #193)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'pond purge pass 1';
const ids: Record<string, string> = {};
const cookies: Record<string, string> = {};
let pondId: string;
let pageAId: string;
let fileId: string;
const needle = `zzpurgeneedle${suffix.replaceAll('-', '')}`;
const api = () => request(app.getHttpServer());
async function makeUser(handle: string, siteAdmin = false): Promise<void> {
const users = app.get(UsersService);
const username = `pp-${handle}-${suffix}`;
const user = await users.createUser({
username,
email: `${username}@example.org`,
displayName: `Purge ${handle}`,
password,
locale: 'en',
});
ids[handle] = user.id;
await users.markEmailVerified(user.id);
if (siteAdmin) {
await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } });
}
cookies[handle] = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
await makeUser('owner');
await makeUser('admin', true);
await api()
.put(`/api/v1/admin/quotas/user/${ids.owner!}/additional_ponds`)
.set('Cookie', cookies.admin!)
.send({ value: 5 })
.expect(200);
// The pond and two pages through the real API (grants, usage, slugs).
const pond = await api()
.post('/api/v1/ponds')
.set('Cookie', cookies.owner!)
.send({ name: `Purge Pond ${suffix}` })
.expect(201);
pondId = pond.body.id;
const pageA = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', cookies.owner!)
.send({ title: `Purge Page A ${suffix}` })
.expect(201);
pageAId = pageA.body.id;
const pageB = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', cookies.owner!)
.send({ title: `Purge Page B ${suffix}` })
.expect(201);
// A real uploaded file (disk + quota usage), attached to page A.
const upload = await api()
.post(`/api/v1/pages/${pageAId}/files`)
.set('Cookie', cookies.owner!)
.attach('file', Buffer.from('purge me'), 'purge.txt')
.expect(201);
fileId = upload.body.id;
// Every remaining representation, seeded directly: content cache with a
// searchable vector, update log, version, comment, label + assignment,
// favorite, watches (page + pond), a page link, a pond quota override.
// Page creation already seeded a cache row — fill it with content.
await prisma.pageContentCache.upsert({
where: { pageId: pageAId },
create: {
pageId: pageAId,
plainText: `text with ${needle}`,
markdown: needle,
html: `<p>${needle}</p>`,
outline: [],
},
update: { plainText: `text with ${needle}`, markdown: needle, html: `<p>${needle}</p>` },
});
await prisma.$executeRawUnsafe(
`UPDATE page_content_cache SET search_vector = to_tsvector('simple', plain_text) WHERE page_id = $1`,
pageAId,
);
await prisma.pageUpdate.create({
data: { pageId: pageAId, seq: 1, update: new Uint8Array([1, 2, 3]) },
});
await prisma.pageVersion.create({
data: {
pageId: pageAId,
ydocSnapshot: new Uint8Array(),
trigger: 'MANUAL',
createdBy: ids.owner!,
},
});
await prisma.comment.create({
data: { pageId: pageAId, authorId: ids.owner!, body: 'purge comment', anchor: null },
});
const label = await prisma.label.create({
data: { pondId, name: `purge-label-${suffix}`, color: '#00aa00' },
});
await prisma.pageLabel.create({ data: { pageId: pageAId, labelId: label.id } });
await prisma.pageFavorite.create({ data: { pageId: pageAId, userId: ids.owner! } });
// The API page creation auto-watched page A already (autoWatchOwnPages).
await prisma.watch.createMany({
data: [
{ userId: ids.owner!, targetType: 'PAGE', targetId: pageAId },
{ userId: ids.owner!, targetType: 'POND', targetId: pondId },
],
skipDuplicates: true,
});
await prisma.pageLink.create({
data: { fromPageId: pageAId, toPageId: pageB.body.id, targetSlug: pageB.body.slug },
});
await prisma.quotaOverride.create({
data: {
subjectType: 'POND',
subjectId: pondId,
quotaKey: 'storage_bytes',
value: 123456789n,
},
});
});
afterAll(async () => {
const all = Object.values(ids);
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: [...all, pondId] } } });
await prisma.auditEntry.deleteMany({
where: { OR: [{ actorId: { in: all } }, { targetId: pondId }] },
});
const ponds = await prisma.pond.findMany({
where: { ownerId: { in: all } },
select: { id: true },
});
const pondIds = ponds.map((p) => p.id);
await prisma.attachment.deleteMany({ where: { pondId: { in: pondIds } } });
await prisma.page.deleteMany({ where: { pondId: { in: pondIds } } });
await prisma.label.deleteMany({ where: { pondId: { in: pondIds } } });
await prisma.pond.deleteMany({ where: { id: { in: pondIds } } });
await prisma.watch.deleteMany({ where: { userId: { in: all } } });
await prisma.session.deleteMany({ where: { userId: { in: all } } });
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
await prisma.user.deleteMany({ where: { id: { in: all } } });
await prisma.$disconnect();
await app.close();
});
it('purges a trashed pond without leaving anything behind', async () => {
// Pre-flight: the content is findable and the file exists on disk.
const hitsBefore = await api()
.get(`/api/v1/search?q=${needle}`)
.set('Cookie', cookies.owner!)
.expect(200);
expect(hitsBefore.body.length).toBeGreaterThan(0);
const filePath = join(process.env.UPLOADS_DIR!, pondId, fileId);
expect(existsSync(filePath)).toBe(true);
// Only a TRASHED pond can be purged, and only by a Site Admin.
await api().delete(`/api/v1/ponds/${pondId}/purge`).set('Cookie', cookies.admin!).expect(404);
await api().delete(`/api/v1/ponds/${pondId}`).set('Cookie', cookies.owner!).expect(204);
await api().delete(`/api/v1/ponds/${pondId}/purge`).set('Cookie', cookies.owner!).expect(403);
await api().delete(`/api/v1/ponds/${pondId}/purge`).set('Cookie', cookies.admin!).expect(204);
// Nothing referencing the pond survives.
expect(await prisma.pond.findUnique({ where: { id: pondId } })).toBeNull();
expect(await prisma.page.count({ where: { pondId } })).toBe(0);
expect(await prisma.attachment.count({ where: { pondId } })).toBe(0);
expect(await prisma.label.count({ where: { pondId } })).toBe(0);
expect(await prisma.roleGrant.count({ where: { pondId } })).toBe(0);
expect(await prisma.pondUsage.count({ where: { pondId } })).toBe(0);
expect(await prisma.pageContentCache.count({ where: { pageId: pageAId } })).toBe(0);
expect(await prisma.pageUpdate.count({ where: { pageId: pageAId } })).toBe(0);
expect(await prisma.pageVersion.count({ where: { pageId: pageAId } })).toBe(0);
expect(await prisma.comment.count({ where: { pageId: pageAId } })).toBe(0);
expect(await prisma.pageLabel.count({ where: { pageId: pageAId } })).toBe(0);
expect(await prisma.pageFavorite.count({ where: { pageId: pageAId } })).toBe(0);
expect(await prisma.pageLink.count({ where: { fromPageId: pageAId } })).toBe(0);
expect(
await prisma.watch.count({
where: { targetId: { in: [pondId, pageAId] } },
}),
).toBe(0);
expect(
await prisma.quotaOverride.count({ where: { subjectType: 'POND', subjectId: pondId } }),
).toBe(0);
expect(existsSync(filePath)).toBe(false);
// The follow-up search finds nothing of the purged pond.
const hitsAfter = await api()
.get(`/api/v1/search?q=${needle}`)
.set('Cookie', cookies.owner!)
.expect(200);
expect(hitsAfter.body).toEqual([]);
// Idempotent: a second purge is a clean 404, not an error.
await api().delete(`/api/v1/ponds/${pondId}/purge`).set('Cookie', cookies.admin!).expect(404);
// The manual purge left an audit event.
const audit = await prisma.auditEntry.findFirst({
where: { action: 'pond.purged', targetId: pondId },
});
expect(audit).not.toBeNull();
expect(audit!.details).toMatchObject({ trigger: 'manual', pages: 2, attachments: 1 });
});
it('purges due ponds on the retention path with an audit event', async () => {
const trashedLongAgo = await prisma.pond.create({
data: {
slug: `pp-ret-${suffix}`,
name: 'Retention Pond',
type: 'SHARED',
ownerId: ids.owner!,
deletedAt: new Date('2020-01-01T00:00:00Z'),
deletedBy: ids.owner!,
},
});
await prisma.page.create({
data: {
pondId: trashedLongAgo.id,
slug: `pp-ret-page-${suffix}`,
title: 'Retention Page',
createdBy: ids.owner!,
sortKey: 'a0',
ydocState: new Uint8Array(),
},
});
await app.get(TrashService).purgeDuePonds();
expect(await prisma.pond.findUnique({ where: { id: trashedLongAgo.id } })).toBeNull();
expect(await prisma.page.count({ where: { pondId: trashedLongAgo.id } })).toBe(0);
const audit = await prisma.auditEntry.findFirst({
where: { action: 'pond.purged', targetId: trashedLongAgo.id },
});
expect(audit).not.toBeNull();
expect(audit!.details).toMatchObject({ trigger: 'retention' });
});
});