import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { INestApplication } from '@nestjs/common'; import { editorSchema } from '@dorfteich/shared'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { prosemirrorJSONToYXmlFragment } from 'y-prosemirror'; import * as Y from 'yjs'; import { AuthTokensService } from '../auth/auth-tokens.service'; import { ClockService } from '../common/clock.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; import { SchedulerService } from '../scheduler/scheduler.service'; import { TrashService } from './trash.service'; const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const pngBuffer = (payload = 'trash test png'): Buffer => Buffer.concat([PNG_SIGNATURE, Buffer.from(payload)]); /** Encodes a one-paragraph doc embedding `fileId` as an image. */ function stateWithImage(fileId: string): string { const ydoc = new Y.Doc(); const fragment = ydoc.getXmlFragment('default'); const doc = editorSchema.node('doc', null, [ editorSchema.node('paragraph', null, [ editorSchema.node('image', { fileId, alt: 'trash test', width: null }), ]), ]); prosemirrorJSONToYXmlFragment(editorSchema, doc.toJSON(), fragment); const state = Buffer.from(Y.encodeStateAsUpdate(ydoc)).toString('base64'); ydoc.destroy(); return state; } describe.skipIf(!hasTestDb)('page trash (e2e, issue #31)', () => { let app: INestApplication; let prisma: PrismaClient; const suffix = uniqueSuffix(); const password = 'trash it like its hot 1'; const owner = { username: `tina-trash-${suffix}`, displayName: `Tina Trash ${suffix}` }; const outsider = { username: `otto-trash-${suffix}`, displayName: `Otto Outside ${suffix}` }; let ownerCookie: string; let outsiderCookie: string; let pondId: string; const api = () => request(app.getHttpServer()); async function loginOf(username: string): Promise { const res = await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: username, password }) .expect(200); return sessionCookieOf(res); } beforeAll(async () => { prisma = createTestPrisma(); await prisma.rateLimit.deleteMany({}); app = await createTestApp(); const users = app.get(UsersService); const tokens = app.get(AuthTokensService); const ownerUser = await users.createUser({ username: owner.username, email: `${owner.username}@example.org`, displayName: owner.displayName, password, locale: 'en', }); const verifyToken = await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600); await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204); ownerCookie = await loginOf(owner.username); const outsiderUser = await users.createUser({ username: outsider.username, email: `${outsider.username}@example.org`, displayName: outsider.displayName, password, locale: 'en', }); await users.markEmailVerified(outsiderUser.id); outsiderCookie = await loginOf(outsider.username); const ponds = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200); pondId = ponds.body.find((p: { type: string }) => p.type === 'personal').id; }); afterAll(async () => { await prisma.attachment.deleteMany({ where: { pondId } }); await prisma.page.deleteMany({ where: { pond: { owner: { username: { contains: suffix } } } }, }); const users = await prisma.user.findMany({ where: { username: { contains: suffix } }, select: { id: true }, }); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: users.map((u) => u.id) } } }); await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.$disconnect(); await app.close(); }); it('excludes a soft-deleted page from the sidebar and 404s the direct URL, with a trash hint for editors', async () => { const created = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title: `Trashed Sidebar ${suffix}` }) .expect(201); await api().delete(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(204); const list = await api() .get(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .expect(200); expect((list.body as { id: string }[]).some((p) => p.id === created.body.id)).toBe(false); const ownerView = await api() .get(`/api/v1/ponds/${pondId}/pages/${created.body.slug}`) .set('Cookie', ownerCookie) .expect(404); expect(ownerView.body.code).toBe('page_trashed'); expect(ownerView.body.details.pageId).toBe(created.body.id); const outsiderView = await api() .get(`/api/v1/ponds/${pondId}/pages/${created.body.slug}`) .set('Cookie', outsiderCookie) .expect(404); expect(outsiderView.body.code).not.toBe('page_trashed'); }); it('lists a pond trash, visible only to editors', async () => { const created = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title: `Trash List ${suffix}` }) .expect(201); await api().delete(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(204); const trash = await api() .get(`/api/v1/ponds/${pondId}/trash`) .set('Cookie', ownerCookie) .expect(200); expect((trash.body as { id: string }[]).some((p) => p.id === created.body.id)).toBe(true); await api().get(`/api/v1/ponds/${pondId}/trash`).set('Cookie', outsiderCookie).expect(404); }); it('restore brings content and files back intact', async () => { const created = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title: `Restore Me ${suffix}` }) .expect(201); const uploaded = await api() .post(`/api/v1/ponds/${pondId}/files`) .set('Cookie', ownerCookie) .attach('file', pngBuffer(), 'restore.png') .expect(201); await api() .put(`/api/v1/pages/${created.body.id}/state`) .set('Cookie', ownerCookie) .send({ state: stateWithImage(uploaded.body.id) }) .expect(200); // The state save links the embedded image to this page (issue #31). const linked = await prisma.attachment.findUniqueOrThrow({ where: { id: uploaded.body.id } }); expect(linked.pageId).toBe(created.body.id); await api().delete(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(204); await api() .post(`/api/v1/pages/${created.body.id}/restore`) .set('Cookie', ownerCookie) .expect(201); const restoredPage = await api() .get(`/api/v1/pages/${created.body.id}`) .set('Cookie', ownerCookie) .expect(200); expect(restoredPage.body.deletedAt).toBeNull(); expect(Buffer.from(restoredPage.body.state, 'base64').length).toBeGreaterThan(0); await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(200); }); it('purges a single page on demand, removing rows and files', async () => { const created = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title: `Purge Me ${suffix}` }) .expect(201); const uploaded = await api() .post(`/api/v1/ponds/${pondId}/files`) .set('Cookie', ownerCookie) .attach('file', pngBuffer(), 'purge.png') .expect(201); await api() .put(`/api/v1/pages/${created.body.id}/state`) .set('Cookie', ownerCookie) .send({ state: stateWithImage(uploaded.body.id) }) .expect(200); const filePath = join(process.env.UPLOADS_DIR!, pondId, uploaded.body.id); expect(existsSync(filePath)).toBe(true); const usageBefore = await prisma.pondUsage.findUniqueOrThrow({ where: { pondId } }); await api().delete(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(204); await api() .delete(`/api/v1/pages/${created.body.id}/purge`) .set('Cookie', ownerCookie) .expect(204); expect(await prisma.page.findUnique({ where: { id: created.body.id } })).toBeNull(); expect(await prisma.attachment.findUnique({ where: { id: uploaded.body.id } })).toBeNull(); expect(existsSync(filePath)).toBe(false); const usageAfter = await prisma.pondUsage.findUniqueOrThrow({ where: { pondId } }); expect(Number(usageAfter.storageBytesUsed)).toBeLessThan(Number(usageBefore.storageBytesUsed)); // Purging a page that no longer exists (or was never trashed) 404s. await api() .delete(`/api/v1/pages/${created.body.id}/purge`) .set('Cookie', ownerCookie) .expect(404); }); it('the scheduled job purges only pages past retention (time-travel via injected clock)', async () => { const clock = app.get(ClockService); const originalNow = clock.now.bind(clock); const scheduler = app.get(SchedulerService); const trash = app.get(TrashService); // Both deleted at real "now" — a few milliseconds apart at most. "old" // is backdated only 20 days (still within the 30-day default // retention under real wall-clock time), so the test can prove the // job's purge/no-purge split comes from the *injected* clock and not // from how much real time happened to pass while the test ran. const recent = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title: `Recent Trash ${suffix}` }) .expect(201); const old = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title: `Old Trash ${suffix}` }) .expect(201); await api().delete(`/api/v1/pages/${recent.body.id}`).set('Cookie', ownerCookie).expect(204); await api().delete(`/api/v1/pages/${old.body.id}`).set('Cookie', ownerCookie).expect(204); const twentyDaysAgo = new Date(originalNow().getTime() - 20 * 24 * 60 * 60 * 1000); await prisma.page.update({ where: { id: old.body.id }, data: { deletedAt: twentyDaysAgo } }); try { // Travel 15 days into the future: "old" is now 35 days past its // deletion (past the 30-day retention), "recent" only 15. const fifteenDaysLater = new Date(originalNow().getTime() + 15 * 24 * 60 * 60 * 1000); clock.now = () => fifteenDaysLater; await scheduler.runIfDue({ name: 'trash-purge-test-run', cadenceSeconds: 0, run: () => trash.purgeDuePages(), }); expect(await prisma.page.findUnique({ where: { id: old.body.id } })).toBeNull(); expect(await prisma.page.findUnique({ where: { id: recent.body.id } })).not.toBeNull(); } finally { clock.now = originalNow; await prisma.job.deleteMany({ where: { name: 'trash-purge-test-run' } }); } }); });