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.
273 lines
11 KiB
TypeScript
273 lines
11 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();
|
|
// Two pages created here plus the pond's own start page (issue #302) —
|
|
// the audit records what was actually removed, so the count moves with
|
|
// the pond's real contents rather than with what the test typed out.
|
|
expect(audit!.details).toMatchObject({ trigger: 'manual', pages: 3, 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' });
|
|
});
|
|
});
|