Nightly sweep with two directions: attachments still unclaimed (pageId null) after a 24 h grace period - claimed by no collab persist, page upload, or import - are reclaimed (row, file, quota released); files on the uploads volume without a database row (drift after a crashed upload) are removed once older than the grace period. The grace period protects the paste-then-insert window. Deliberate deviation from the issue's content-reference idea, documented in schema comment and operations.md: claimed attachments whose page content no longer embeds them are NOT auto-deleted. The page attachments panel lists claimed files as user-managed objects (inserting into the document is optional there), so 'not embedded' is not 'unused' - an auto-delete would destroy panel assets. Humans clean those up in the panel or the pond file manager, which flags orphans already. Attachment.deletedAt is removed by migration - deletion is hard everywhere (sweep, purge, manual), there is no soft-delete state; the never-true deletedAt:null filters in files/export queries went with it. Refs #194 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
180 lines
6.9 KiB
TypeScript
180 lines
6.9 KiB
TypeScript
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<string, string> = {};
|
|
const cookies: Record<string, string> = {};
|
|
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<void> {
|
|
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<string> {
|
|
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<unknown> {
|
|
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);
|
|
});
|
|
});
|