import { existsSync } from 'node:fs'; import { utimes, writeFile, mkdir } from 'node:fs/promises'; 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 { OrphanSweepService } from './orphan-sweep.service'; const HOUR = 60 * 60 * 1000; /** * Orphan-file sweep (issue #194): unclaimed attachments past the grace * period are reclaimed (row, file, quota), fresh ones are protected * (paste-then-insert), claimed ones are never touched — the page * attachments panel is a legitimate reference — and stray files without a * database row disappear once old enough. */ describe.skipIf(!hasTestDb)('orphan file sweep (e2e, issue #194)', () => { let app: INestApplication; let prisma: PrismaClient; const suffix = uniqueSuffix(); const password = 'orphan sweep pass 1'; const ids: Record = {}; const cookies: Record = {}; let pondId: string; let pageId: string; const api = () => request(app.getHttpServer()); const fileOnDisk = (fondId: string, fileId: string) => join(process.env.UPLOADS_DIR!, fondId, fileId); async function makeUser(handle: string, siteAdmin = false): Promise { const users = app.get(UsersService); const username = `os-${handle}-${suffix}`; const user = await users.createUser({ username, email: `${username}@example.org`, displayName: `Sweep ${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), ); } /** A real upload via the pond route (pageId stays null = unclaimed). */ async function uploadUnclaimed(name: string): Promise { const res = await api() .post(`/api/v1/ponds/${pondId}/files`) .set('Cookie', cookies.owner!) .attach('file', Buffer.from(`bytes of ${name}`), name) .expect(201); return res.body.id as string; } function backdate(attachmentId: string, ageMs: number): Promise { return prisma.attachment.update({ where: { id: attachmentId }, data: { createdAt: new Date(Date.now() - ageMs) }, }); } 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); const pond = await api() .post('/api/v1/ponds') .set('Cookie', cookies.owner!) .send({ name: `Sweep Pond ${suffix}` }) .expect(201); pondId = pond.body.id; const page = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', cookies.owner!) .send({ title: `Sweep Page ${suffix}` }) .expect(201); pageId = page.body.id; }); afterAll(async () => { const all = Object.values(ids); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: all } } }); await prisma.auditEntry.deleteMany({ where: { actorId: { in: all } } }); 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.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('reclaims unclaimed attachments past the grace period, protects fresh and claimed ones', async () => { const oldUnclaimed = await uploadUnclaimed('old-unclaimed.txt'); const freshUnclaimed = await uploadUnclaimed('fresh-unclaimed.txt'); const oldClaimed = await api() .post(`/api/v1/pages/${pageId}/files`) .set('Cookie', cookies.owner!) .attach('file', Buffer.from('panel asset'), 'panel-asset.txt') .expect(201); await backdate(oldUnclaimed, 25 * HOUR); await backdate(oldClaimed.body.id, 25 * HOUR); const usageBefore = await prisma.pondUsage.findUnique({ where: { pondId } }); const reclaimedBytes = ( await prisma.attachment.findUniqueOrThrow({ where: { id: oldUnclaimed }, }) ).sizeBytes; const result = await app.get(OrphanSweepService).sweep(); expect(result.reclaimed).toBeGreaterThanOrEqual(1); // The old unclaimed upload is gone: row, file, quota. expect(await prisma.attachment.findUnique({ where: { id: oldUnclaimed } })).toBeNull(); expect(existsSync(fileOnDisk(pondId, oldUnclaimed))).toBe(false); const usageAfter = await prisma.pondUsage.findUnique({ where: { pondId } }); expect(Number(usageBefore!.storageBytesUsed) - Number(usageAfter!.storageBytesUsed)).toBe( reclaimedBytes, ); // The fresh unclaimed upload survives (paste-then-insert grace). expect(await prisma.attachment.findUnique({ where: { id: freshUnclaimed } })).not.toBeNull(); expect(existsSync(fileOnDisk(pondId, freshUnclaimed))).toBe(true); // The claimed panel asset survives despite its age — never swept. expect( await prisma.attachment.findUnique({ where: { id: oldClaimed.body.id } }), ).not.toBeNull(); expect(existsSync(fileOnDisk(pondId, oldClaimed.body.id))).toBe(true); }); it('removes stray files without a database row once they are old enough', async () => { const dir = join(process.env.UPLOADS_DIR!, pondId); await mkdir(dir, { recursive: true }); const oldStray = join(dir, `stray-old-${suffix}`); const freshStray = join(dir, `stray-fresh-${suffix}`); await writeFile(oldStray, 'stray bytes'); await writeFile(freshStray, 'stray bytes'); const past = new Date(Date.now() - 25 * HOUR); await utimes(oldStray, past, past); const result = await app.get(OrphanSweepService).sweep(); expect(existsSync(oldStray)).toBe(false); expect(existsSync(freshStray)).toBe(true); expect(result.strays).toBeGreaterThanOrEqual(1); }); });