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
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
270 lines
10 KiB
TypeScript
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' });
|
|
});
|
|
});
|