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 = {}; const cookies: Record = {}; 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 { 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: `

${needle}

`, outline: [], }, update: { plainText: `text with ${needle}`, markdown: needle, html: `

${needle}

` }, }); 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' }); }); });