Files
TREK/server/tests/e2e/storage-reclaim.e2e.test.ts
T
Maurice bf8e04741d test(storage): follow the release queue in the tests that pinned an immediate delete
Replacing a cover or an avatar and deleting a note file now hand the old
bytes to the release queue instead of deleting them in the request; the
integration tests check the queue row and that the bytes are still
there. Release mocks answer with a count, the reclaim e2e sends its
refused bodies one after another, and a stale-count reload that fails is
told to the admin instead of swallowed.
2026-11-01 18:46:58 +01:00

237 lines
10 KiB
TypeScript

/**
* Storage reclaim e2e: /api/admin/storage/reclaim on the whole app (buildApp
* against the worker's snapshot test db), so the real JwtAuthGuard,
* AdminGuard and ManagedGuard, the Zod pipe on both bodies and the response
* contracts the handlers declare all stand in front of it. Every owner
* domain registers its reference source here, so a pass looks at every
* reclaimable category; the checks below run in the background and only
* report. RCLE-001 to RCLE-008.
*/
import { buildApp } from '../../src/bootstrap';
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
import { StorageInstallService } from '../../src/nest/storage/storage-install.service';
import { StorageJobsService } from '../../src/nest/storage/storage-jobs.service';
import { STORAGE_RECLAIMABLE_CATEGORIES, storageOwnerMarkerKey } from '../../src/nest/storage/storage-reclaim.types';
import { StorageService } from '../../src/nest/storage/storage.service';
import { countRows, deleteRows, findRow } from '../helpers/factories/rows';
import { setAppSetting } from '../helpers/factories/settings';
import { makeAdmin, makeUser } from '../helpers/factories/users';
import { sessionCookie } from './harness';
import { MikroORM } from '@mikro-orm/core';
import type { INestApplication } from '@nestjs/common';
import { STORAGE_RECLAIM_CATEGORIES, type StorageReclaimReport } from '@trek/shared';
import type { Server } from 'http';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error',
logInfo: vi.fn(),
logDebug: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
}));
describe('Storage reclaim e2e (whole app: real auth + admin guard + managed guard)', () => {
let orm: MikroORM;
let app: INestApplication;
let server: Server;
let adminCookie: string;
let userCookie: string;
beforeAll(async () => {
app = await buildApp();
orm = app.get(MikroORM);
server = app.getHttpServer();
userCookie = sessionCookie((await makeUser(orm, { email: 'e2e-reclaim@example.test' })).user.id);
adminCookie = sessionCookie((await makeAdmin(orm, { email: 'e2e-reclaim-admin@example.test' })).user.id);
});
beforeEach(async () => {
await deleteRows(orm, AppSettings, { key: { $like: 'storage.reclaim%' } });
await deleteRows(orm, AuditLog);
});
afterAll(async () => {
// The uploads folder is the test run's shared one: take back the owner markers this app's checks wrote.
const installId = app.get(StorageInstallService).installId();
if (installId) {
const storage = app.get(StorageService);
await Promise.all(
STORAGE_RECLAIMABLE_CATEGORIES.map((category) =>
storage.delete(category, storageOwnerMarkerKey(installId)).catch(() => undefined),
),
);
}
await app?.close();
});
/** The admin state's reclaim part once a check started by hand has finished. */
async function settledState() {
await app.get(StorageJobsService).exclusiveIdle();
const res = await request(server).get('/api/admin/storage').set('Cookie', adminCookie);
expect(res.status).toBe(200);
return res.body.reclaim;
}
it('RCLE-001: 401 without a session on every route', async () => {
expect((await request(server).put('/api/admin/storage/reclaim').send({ deleteEnabled: true })).status).toBe(401);
expect((await request(server).post('/api/admin/storage/reclaim/run').send({})).status).toBe(401);
expect((await request(server).post('/api/admin/storage/reclaim/confirm').send({ category: 'covers' })).status).toBe(
401,
);
});
it('RCLE-002: 403 for a non-admin, and nothing is stored', async () => {
const res = await request(server)
.put('/api/admin/storage/reclaim')
.set('Cookie', userCookie)
.send({ deleteEnabled: true });
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'Admin access required' });
expect(await findRow(orm, AppSettings, { key: 'storage.reclaim_delete' })).toBeFalsy();
});
it('RCLE-003: PUT stores the switch for this install, answers the state, audits, and the admin state carries it', async () => {
const res = await request(server)
.put('/api/admin/storage/reclaim')
.set('Cookie', adminCookie)
.send({ deleteEnabled: true });
expect(res.status).toBe(200);
expect(res.body).toEqual({ deleteEnabled: true, running: false, lastRun: null, lastDeleteRun: null });
expect(await countRows(orm, AuditLog, { action: 'admin.storage_reclaim_update' })).toBe(1);
// Stored as the install id it was turned on for, so a database restored elsewhere reads as off. The id
// comes from the data dir's file, and the database carries a copy it is reconciled with at boot.
const installId = (await findRow(orm, AppSettings, { key: 'install_id' }))?.value;
expect(installId).toMatch(/^[0-9a-f-]{36}$/);
expect(installId).toBe(app.get(StorageInstallService).installId());
expect((await findRow(orm, AppSettings, { key: 'storage.reclaim_delete' }))?.value).toBe(installId);
expect((await settledState()).deleteEnabled).toBe(true);
const off = await request(server)
.put('/api/admin/storage/reclaim')
.set('Cookie', adminCookie)
.send({ deleteEnabled: false });
expect(off.body.deleteEnabled).toBe(false);
});
it('RCLE-004: PUT and confirm refuse a body outside the contract', async () => {
// One request after the other: a burst of parallel requests on one supertest server resets connections.
const statusesOf = (method: 'put' | 'post', url: string, bodies: object[]) =>
bodies.reduce<Promise<number[]>>(async (previous, body) => {
const done = await previous;
const res = await request(server)[method](url).set('Cookie', adminCookie).send(body);
return [...done, res.status];
}, Promise.resolve([]));
expect(
await statusesOf('put', '/api/admin/storage/reclaim', [
{},
{ deleteEnabled: 'yes' },
{ deleteEnabled: true, extra: 1 },
]),
).toEqual([400, 400, 400]);
expect(
await statusesOf('post', '/api/admin/storage/reclaim/confirm', [
{},
{ category: 'nowhere', unreferenced: 3 },
{ category: 'covers', count: 3 },
{ category: 'covers' },
{ category: 'covers', unreferenced: -1 },
]),
).toEqual([400, 400, 400, 400, 400]);
expect(await countRows(orm, AuditLog)).toBe(0);
});
it('RCLE-005: POST run answers 202 at once, the check asks every source, deletes nothing and lands on the state', async () => {
const res = await request(server).post('/api/admin/storage/reclaim/run').set('Cookie', adminCookie).send({});
expect(res.status).toBe(202);
expect(res.body).toEqual({ started: true });
const audit = await findRow(orm, AuditLog, { action: 'admin.storage_reclaim_run' });
expect(JSON.parse(String(audit?.details))).toEqual({ mode: 'report' });
const reclaim = await settledState();
expect(reclaim.running).toBe(false);
expect(reclaim.lastRun.mode).toBe('report');
expect(reclaim.lastDeleteRun).toBeNull();
for (const category of STORAGE_RECLAIMABLE_CATEGORIES) {
expect(reclaim.lastRun.categories[category].skipped).not.toBe('sources_missing');
expect(reclaim.lastRun.categories[category].deleted).toEqual({ objects: 0, bytes: 0 });
}
expect(reclaim.lastRun.categories.backups.skipped).toBe('not_reclaimed');
});
it('RCLE-006: 409 with a code while another storage job holds the slot, and while nothing waits for a confirmation', async () => {
const jobs = app.get(StorageJobsService);
expect(jobs.claimExclusive('a test job')).toBe(true);
try {
const res = await request(server).post('/api/admin/storage/reclaim/run').set('Cookie', adminCookie).send({});
expect(res.status).toBe(409);
expect(res.body).toEqual({
error: 'another storage job is running, one storage job at a time',
code: 'reclaim_busy',
});
expect(await countRows(orm, AuditLog)).toBe(0);
} finally {
jobs.releaseExclusive('a test job');
}
const confirm = await request(server)
.post('/api/admin/storage/reclaim/confirm')
.set('Cookie', adminCookie)
.send({ category: 'covers', unreferenced: 3 });
expect(confirm.status).toBe(409);
expect(confirm.body.code).toBe('reclaim_nothing_to_confirm');
});
it('RCLE-008: confirm takes the count shown, refuses a stale one with a code, and the prompt goes at once', async () => {
const none = { objects: 0, bytes: 0 };
const row = {
objects: 0,
unreferenced: none,
eligible: none,
deleted: none,
confirmationRequired: false,
skipped: null,
};
const report: StorageReclaimReport = {
startedAt: Date.now(),
finishedAt: Date.now(),
mode: 'report',
stopped: null,
categories: {
...(Object.fromEntries(STORAGE_RECLAIM_CATEGORIES.map((c) => [c, row])) as StorageReclaimReport['categories']),
covers: { ...row, objects: 1000, unreferenced: { objects: 300, bytes: 3000 }, confirmationRequired: true },
},
};
await setAppSetting(orm, 'storage.reclaim_report', JSON.stringify(report));
const confirm = (unreferenced: number) =>
request(server)
.post('/api/admin/storage/reclaim/confirm')
.set('Cookie', adminCookie)
.send({ category: 'covers', unreferenced });
const stale = await confirm(5000);
expect(stale.status).toBe(409);
expect(stale.body.code).toBe('reclaim_confirm_stale');
expect(await countRows(orm, AuditLog)).toBe(0);
const ok = await confirm(300);
expect(ok.status).toBe(200);
expect(ok.body.lastRun.categories.covers.confirmationRequired).toBe(false);
const audit = await findRow(orm, AuditLog, { action: 'admin.storage_reclaim_confirm' });
expect(JSON.parse(String(audit?.details))).toEqual({ category: 'covers', unreferenced: 300 });
expect((await settledState()).lastRun.categories.covers.confirmationRequired).toBe(false);
});
it('RCLE-007: managed mode refuses the surface with the standard body', async () => {
process.env.TREK_MANAGED = 'true';
try {
const res = await request(server).post('/api/admin/storage/reclaim/run').set('Cookie', adminCookie).send({});
expect(res.status).toBe(403);
expect(res.body).toEqual({
error: 'This is configured by the operator of this instance.',
code: 'MANAGED_FORBIDDEN',
});
} finally {
delete process.env.TREK_MANAGED;
}
});
});