All checks were successful
CD / Build and push images (push) Successful in 2m5s
CI / Lint, typecheck, test (push) Successful in 1m45s
CI / Auth e2e pack (push) Successful in 1m50s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 10s
Backend: a generic maintenance-job scheduler (SchedulerService, `jobs` table) that any later maintenance job registers with instead of growing its own timer loop. Due-ness and the run-mutex both live in the DB row (`lastRunAt` survives a restart; claiming a due job is one atomic `UPDATE ... WHERE status != 'RUNNING'`), and an injectable ClockService lets tests simulate retention elapsing without waiting or faking the global clock. Trash endpoints: GET /ponds/:id/trash (list), POST /pages/:id/restore, DELETE /pages/:id/purge (manual, bypasses retention) — all sharing the same purge logic as the scheduled daily job (default 30-day retention, new trash.retentionDays instance setting). Purging deletes a page's content cache, update log, and attachment files/quota; page_versions is a placeholder until M3 exists. Direct navigation to a trashed page now 404s with a distinguishable `page_trashed` code for editors (a plain 404 for everyone else) instead of the generic not-found. Attachment.pageId — added in #27 but never wired up — now gets set on every page state save to whichever page's document currently embeds the file, which is what lets purge find a page's files. Frontend: a per-pond trash view (restore/purge), a "move to trash" action with confirmation in the page menu, and a trash link in the sidebar for pond owners. Also fixes react-query retrying 4xx responses for several seconds by default, which was masking the trash-hint 404 in the UI (and would have affected any other not-found/permission error the same way). Closes #31
281 lines
11 KiB
TypeScript
281 lines
11 KiB
TypeScript
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<string> {
|
|
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' } });
|
|
}
|
|
});
|
|
});
|