181 lines
9.3 KiB
TypeScript
181 lines
9.3 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import { ManagedStorage } from "../../apps/api/src/managed-storage.js";
|
|
import { RegistrationService } from "../../apps/api/src/registration.js";
|
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
|
import { StickerReleaseService } from "../../apps/api/src/sticker-releases.js";
|
|
import { ProjectPurgeCleanup } from "../../apps/worker/src/project-purge-cleanup.js";
|
|
|
|
const roots: string[] = [];
|
|
const closeables: Array<{ close(): void }> = [];
|
|
|
|
function fixture() {
|
|
const dataRoot = mkdtempSync(join(tmpdir(), "dada-wp5-06-cleanup-"));
|
|
roots.push(dataRoot);
|
|
mkdirSync(join(dataRoot, "db"), { recursive: true });
|
|
const databasePath = join(dataRoot, "db", "dada.sqlite3");
|
|
const registration = new RegistrationService({
|
|
challengePepper: Buffer.alloc(32, 0x61),
|
|
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
|
|
databasePath,
|
|
invitePepper: Buffer.alloc(32, 0x62),
|
|
resend: new MockResendAdapter(),
|
|
sessionPepper: Buffer.alloc(32, 0x63),
|
|
});
|
|
const storage = new ManagedStorage({ dataRoot, databasePath });
|
|
const stickers = new StickerReleaseService({ databasePath, storage });
|
|
closeables.push(stickers, storage, registration);
|
|
return { dataRoot, databasePath, database: registration.database, storage };
|
|
}
|
|
|
|
function seedAdmin(database: RegistrationService["database"]) {
|
|
const adminId = randomUUID();
|
|
database.prepare(`
|
|
INSERT INTO users (
|
|
user_id, normalized_email, role, status, counts_toward_stage_limit,
|
|
registration_id, created_at
|
|
) VALUES (?, ?, 'super_admin', 'active', 0, ?, ?)
|
|
`).run(adminId, `${adminId}@example.invalid`, randomUUID(), Date.now());
|
|
database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(adminId);
|
|
return adminId;
|
|
}
|
|
|
|
async function seedHistoricalPair(test: ReturnType<typeof fixture>) {
|
|
const original = await test.storage.commitBufferFixture("sticker_original", "STK2401.png", Buffer.from("original-history"));
|
|
const thumbnail = await test.storage.commitBufferFixture("sticker_thumbnail", "STK2401-thumbnail.png", Buffer.from("thumbnail-history"));
|
|
const insert = test.database.prepare(`
|
|
INSERT INTO sticker_managed_file_history (
|
|
managed_file_id, stable_id, resource_version, file_kind, created_at
|
|
) VALUES (?, 'STK2401', 'asset-20260701.1', ?, ?)
|
|
`);
|
|
insert.run(original.file_id, "original", Date.now());
|
|
insert.run(thumbnail.file_id, "thumbnail", Date.now());
|
|
return { original, thumbnail };
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const value of closeables.splice(0).reverse()) value.close();
|
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
|
});
|
|
|
|
describe("TDD-WP5-CLN-001 sticker history cleanup", () => {
|
|
it("denies the whole batch when a reference appears after the candidate snapshot", async () => {
|
|
const test = fixture();
|
|
const adminId = seedAdmin(test.database);
|
|
const files = await seedHistoricalPair(test);
|
|
const candidates = test.storage.listAssetCleanupCandidates();
|
|
expect(candidates.items).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ file_id: files.original.file_id, file_kind: "original", reference_count: 0, stable_id: "STK2401" }),
|
|
expect.objectContaining({ file_id: files.thumbnail.file_id, file_kind: "thumbnail", reference_count: 0, stable_id: "STK2401" }),
|
|
]));
|
|
const intent = test.storage.createAssetCleanupIntent({
|
|
actorId: adminId,
|
|
fileIds: [files.original.file_id, files.thumbnail.file_id],
|
|
idempotencyKey: `cleanup-${randomUUID()}-${randomUUID()}`,
|
|
snapshotVersion: candidates.candidate_snapshot_version,
|
|
});
|
|
|
|
test.storage.addAssetReference(files.original.file_id, "release");
|
|
expect(() => test.storage.confirmAssetCleanupIntent({
|
|
actorId: adminId,
|
|
confirmationToken: intent.confirmation_token,
|
|
requestId: intent.request_id,
|
|
})).toThrow("ASSET_HISTORY_REFERENCE_CONFLICT");
|
|
|
|
expect(test.database.prepare("SELECT status FROM asset_cleanup_requests WHERE request_id = ?").get(intent.request_id)).toEqual({ status: "denied" });
|
|
expect(test.database.prepare("SELECT COUNT(*) AS count FROM file_cleanup_queue WHERE status = 'pending'").get()).toEqual({ count: 0 });
|
|
expect(test.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs WHERE target_ref = ? AND result = 'failed'").get(intent.request_id)).toEqual({ count: 1 });
|
|
});
|
|
|
|
it("requires the same admin, queues without reducing capacity, then remeasures after physical deletion", async () => {
|
|
const test = fixture();
|
|
const adminId = seedAdmin(test.database);
|
|
const otherAdminId = seedAdmin(test.database);
|
|
const files = await seedHistoricalPair(test);
|
|
const bytesBefore = test.storage.getState().managed_content_bytes;
|
|
const candidates = test.storage.listAssetCleanupCandidates();
|
|
const intent = test.storage.createAssetCleanupIntent({
|
|
actorId: adminId,
|
|
fileIds: [files.original.file_id, files.thumbnail.file_id],
|
|
idempotencyKey: `cleanup-${randomUUID()}-${randomUUID()}`,
|
|
snapshotVersion: candidates.candidate_snapshot_version,
|
|
});
|
|
|
|
expect(() => test.storage.confirmAssetCleanupIntent({
|
|
actorId: otherAdminId,
|
|
confirmationToken: intent.confirmation_token,
|
|
requestId: intent.request_id,
|
|
})).toThrow("ASSET_CLEANUP_CANDIDATE_STALE");
|
|
const queued = test.storage.confirmAssetCleanupIntent({
|
|
actorId: adminId,
|
|
confirmationToken: intent.confirmation_token,
|
|
requestId: intent.request_id,
|
|
});
|
|
expect(queued).toMatchObject({ file_count: 2, status: "queued" });
|
|
expect(test.storage.getState().managed_content_bytes).toBe(bytesBefore);
|
|
expect(existsSync(join(test.dataRoot, files.original.relative_path))).toBe(true);
|
|
expect(test.database.prepare("SELECT COUNT(*) AS count FROM file_cleanup_queue WHERE status = 'pending'").get()).toEqual({ count: 2 });
|
|
|
|
const worker = new ProjectPurgeCleanup({ dataRoot: test.dataRoot, databasePath: test.databasePath });
|
|
const result = worker.processFileCleanup();
|
|
worker.close();
|
|
expect(result).toEqual({ completed: 2, failed: 0 });
|
|
expect(existsSync(join(test.dataRoot, files.original.relative_path))).toBe(false);
|
|
expect(existsSync(join(test.dataRoot, files.thumbnail.relative_path))).toBe(false);
|
|
expect(test.storage.getState()).toMatchObject({ managed_content_bytes: 0, storage_status: "active" });
|
|
expect(test.database.prepare("SELECT status FROM asset_cleanup_requests WHERE request_id = ?").get(intent.request_id)).toEqual({ status: "completed" });
|
|
expect(test.database.prepare("SELECT operation_type, result FROM admin_operation_logs WHERE target_ref = ? ORDER BY occurred_at").all(intent.request_id)).toEqual([
|
|
{ operation_type: "asset_cleanup_requested", result: "succeeded" },
|
|
{ operation_type: "asset_cleanup_validated", result: "succeeded" },
|
|
{ operation_type: "asset_cleanup_scheduled", result: "succeeded" },
|
|
{ operation_type: "asset_cleanup_physical_completed", result: "succeeded" },
|
|
]);
|
|
});
|
|
|
|
it("records a redacted physical failure in the same transaction and leaves the request retryable", async () => {
|
|
const test = fixture();
|
|
const adminId = seedAdmin(test.database);
|
|
const files = await seedHistoricalPair(test);
|
|
const candidates = test.storage.listAssetCleanupCandidates();
|
|
const intent = test.storage.createAssetCleanupIntent({
|
|
actorId: adminId,
|
|
fileIds: [files.original.file_id, files.thumbnail.file_id],
|
|
idempotencyKey: `cleanup-${randomUUID()}-${randomUUID()}`,
|
|
snapshotVersion: candidates.candidate_snapshot_version,
|
|
});
|
|
test.storage.confirmAssetCleanupIntent({
|
|
actorId: adminId,
|
|
confirmationToken: intent.confirmation_token,
|
|
requestId: intent.request_id,
|
|
});
|
|
test.database.prepare(`
|
|
UPDATE file_cleanup_queue SET relative_path = '../outside-fixture'
|
|
WHERE managed_file_id = ?
|
|
`).run(files.original.file_id);
|
|
|
|
const worker = new ProjectPurgeCleanup({ dataRoot: test.dataRoot, databasePath: test.databasePath });
|
|
const result = worker.processFileCleanup();
|
|
worker.close();
|
|
expect(result).toEqual({ completed: 1, failed: 1 });
|
|
expect(test.database.prepare("SELECT status FROM asset_cleanup_requests WHERE request_id = ?").get(intent.request_id)).toEqual({ status: "queued" });
|
|
expect(test.database.prepare("SELECT status, last_error FROM file_cleanup_queue WHERE managed_file_id = ?").get(files.original.file_id))
|
|
.toEqual({ last_error: "physical_file_cleanup_failed", status: "failed" });
|
|
const failure = test.database.prepare(`
|
|
SELECT operation_type, result, before_summary, after_summary
|
|
FROM admin_operation_logs WHERE target_ref = ? AND operation_type = 'asset_cleanup_physical_failed'
|
|
`).get(intent.request_id) as { after_summary: string; before_summary: null; operation_type: string; result: string };
|
|
expect(failure).toEqual({
|
|
after_summary: JSON.stringify({ failed_count: 1, status: "retry_pending" }),
|
|
before_summary: null,
|
|
operation_type: "asset_cleanup_physical_failed",
|
|
result: "failed",
|
|
});
|
|
expect(JSON.stringify(failure)).not.toMatch(/outside-fixture|relative_path|absolute_path|image|prompt|secret/i);
|
|
});
|
|
});
|