mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
394 lines
18 KiB
TypeScript
394 lines
18 KiB
TypeScript
/**
|
|
* Storage admin e2e — exercises the migrated /api/admin/storage surface
|
|
* (StorageAdminController) through the real JwtAuthGuard + AdminGuard +
|
|
* ManagedGuard against a migrated temp SQLite db. DI-native: no service mock, so the
|
|
* registry's real boot/seed/reload pipeline and StorageAdminService run for
|
|
* real. Covers auth (401), the admin gate (403), managed-mode refusal (403,
|
|
* the first e2e to assert it — ManagedGuard is otherwise only wired in
|
|
* AppModule), the GET/PUT/test happy paths, the 400 envelope for both a
|
|
* semantic registry refusal and a Zod pipe rejection, and secret masking/
|
|
* encryption/redaction (including without an explicit ENCRYPTION_KEY).
|
|
*/
|
|
import { db } from '../../src/db/database';
|
|
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
|
|
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
|
|
import { ManagedGuard } from '../../src/nest/common/managed.guard';
|
|
// apiKeyCrypto reads ENCRYPTION_KEY for the cipher from src/config, which the
|
|
// global test setup fixes (tests/helpers/test-config.ts); the raw process.env
|
|
// var is managed separately in beforeAll/afterAll for the no-explicit-key case
|
|
// (STORE2E-007).
|
|
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
|
import { StorageModule } from '../../src/nest/storage/storage.module';
|
|
import { countRows, deleteRows, findRow } from '../helpers/factories/rows';
|
|
import { makeAdmin, makeUser } from '../helpers/factories/users';
|
|
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
|
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
|
import { sessionCookie } from './harness';
|
|
import { APP_GUARD } from '@nestjs/core';
|
|
import { Test } from '@nestjs/testing';
|
|
|
|
import cookieParser from 'cookie-parser';
|
|
import type { Server } from 'http';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import request from 'supertest';
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
|
|
vi.mock('../../src/db/database', async () => {
|
|
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
|
return buildDbMock(createSnapshotTestDb());
|
|
});
|
|
// The audit domain is DI-native: writeAudit runs for real against the temp db's
|
|
// audit_log table; only the file logger is silenced.
|
|
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
|
|
LOG_LEVEL: 'error',
|
|
logInfo: vi.fn(),
|
|
logDebug: vi.fn(),
|
|
logError: vi.fn(),
|
|
logWarn: vi.fn(),
|
|
}));
|
|
|
|
let orm: TestOrm;
|
|
|
|
describe('Storage admin e2e (real auth + admin guard + managed guard + migrated temp SQLite)', () => {
|
|
let server: Server;
|
|
let app: Awaited<ReturnType<typeof build>>;
|
|
let adminCookie: string;
|
|
let userCookie: string;
|
|
|
|
async function build() {
|
|
const moduleRef = await Test.createTestingModule({
|
|
imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), StorageModule],
|
|
providers: [{ provide: APP_GUARD, useClass: ManagedGuard }],
|
|
}).compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalPipes(new ZodValidationPipe());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
process.env.ENCRYPTION_KEY = 'e2e-storage-key';
|
|
orm = await createTestOrm(db);
|
|
// Pinned ids: sessionCookie(1) and (2) sign for exactly these users.
|
|
await makeUser(orm, { id: 1, email: 'e2e@example.test' });
|
|
await makeAdmin(orm, { id: 2, email: 'e2e-storage-admin@example.test' });
|
|
userCookie = sessionCookie(1);
|
|
adminCookie = sessionCookie(2);
|
|
app = await build();
|
|
server = app.getHttpServer();
|
|
});
|
|
|
|
// Clears the persisted rows only — NOT the registry's in-memory last-good
|
|
// snapshot (StorageRegistryService keeps state across requests within one
|
|
// `app`, and this suite builds `app` once in beforeAll). A test added after
|
|
// STORE2E-004 that expects to see fresh defaults must call GET (which
|
|
// renders the registry's live snapshot) rather than assume the deleted rows
|
|
// reset it — the registry only re-reads app_settings on `reload()`/init.
|
|
beforeEach(async () => {
|
|
await deleteRows(orm, AppSettings, { key: { $like: 'storage.%' } });
|
|
await deleteRows(orm, AuditLog);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
await orm.close();
|
|
delete process.env.ENCRYPTION_KEY;
|
|
});
|
|
|
|
it('STORE2E-001 401 without a session', async () => {
|
|
expect((await request(server).get('/api/admin/storage')).status).toBe(401);
|
|
// PUT stays behind the same guard chain — pins that the guards are declared
|
|
// class-level (JwtAuthGuard + AdminGuard on the controller), not re-added
|
|
// per handler where a future handler could forget them.
|
|
expect((await request(server).put('/api/admin/storage').send({ backends: [], categories: {} })).status).toBe(401);
|
|
expect(
|
|
(
|
|
await request(server)
|
|
.post('/api/admin/storage/test')
|
|
.send({ backend: { name: 'x', type: 'local', options: { root: '/tmp' } } })
|
|
).status,
|
|
).toBe(401);
|
|
});
|
|
|
|
it('STORE2E-002 403 for a non-admin', async () => {
|
|
const res = await request(server).get('/api/admin/storage').set('Cookie', userCookie);
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toEqual({ error: 'Admin access required' });
|
|
});
|
|
|
|
it('STORE2E-003 GET renders the effective defaults world', async () => {
|
|
const res = await request(server).get('/api/admin/storage').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
const names = (res.body.backends as Array<{ name: string; source: string }>).map((b) => [b.name, b.source]);
|
|
expect(names).toEqual(
|
|
expect.arrayContaining([
|
|
['uploads-local', 'built-in'],
|
|
['backups-local', 'built-in'],
|
|
]),
|
|
);
|
|
expect(Object.keys(res.body.categories)).toHaveLength(8);
|
|
expect(res.body.seedFilePresent).toBe(false);
|
|
expect(res.body.health).toEqual({ replicaFailures: [] });
|
|
});
|
|
|
|
it('STORE2E-004 PUT persists, answers the fresh world, audits with redacted secrets', async () => {
|
|
const nasRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-e2e-nas-'));
|
|
const body = {
|
|
backends: [
|
|
{ name: 'nas-backups', type: 'local', options: { root: nasRoot } },
|
|
{
|
|
name: 'off-box',
|
|
type: 's3',
|
|
options: { endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak', secretAccessKey: 'sk-e2e' },
|
|
},
|
|
],
|
|
categories: { backups: 'nas-backups' },
|
|
version: 0,
|
|
};
|
|
const res = await request(server).put('/api/admin/storage').set('Cookie', adminCookie).send(body);
|
|
expect(res.status).toBe(200);
|
|
const offBox = (
|
|
res.body.backends as Array<{ name: string; source: string; options: Record<string, unknown> }>
|
|
).find((b) => b.name === 'off-box')!;
|
|
expect(offBox.source).toBe('settings');
|
|
expect(offBox.options.secretAccessKey).toBe('••••••••'); // masked, never echoed
|
|
expect(res.body.categories.backups).toEqual({ backend: 'nas-backups', source: 'settings' });
|
|
|
|
const row = await findRow(orm, AppSettings, { key: 'storage.backends' });
|
|
expect(row?.value).toContain('enc:v1:');
|
|
expect(row?.value).not.toContain('sk-e2e');
|
|
|
|
const audit = await findRow(orm, AuditLog, { action: 'admin.storage_update' });
|
|
expect(audit?.details).toContain('***');
|
|
expect(audit?.details).not.toContain('sk-e2e');
|
|
});
|
|
|
|
it('STORE2E-005 PUT with a semantic violation → 400 with the registry message verbatim', async () => {
|
|
const res = await request(server)
|
|
.put('/api/admin/storage')
|
|
.set('Cookie', adminCookie)
|
|
.send({ backends: [], categories: { backups: 'nope' }, version: 0 });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body).toEqual({ error: "category 'backups' maps to unknown backend 'nope'" });
|
|
});
|
|
|
|
it('STORE2E-006 PUT with an unknown top-level key → 400 from the Zod pipe (reserved readOnly door)', async () => {
|
|
const res = await request(server)
|
|
.put('/api/admin/storage')
|
|
.set('Cookie', adminCookie)
|
|
.send({ backends: [], categories: {}, version: 0, readOnly: true });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toBeDefined();
|
|
});
|
|
|
|
it('STORE2E-007 PUT with a plaintext secret and no explicit ENCRYPTION_KEY saves, encrypted at rest', async () => {
|
|
delete process.env.ENCRYPTION_KEY;
|
|
try {
|
|
const res = await request(server)
|
|
.put('/api/admin/storage')
|
|
.set('Cookie', adminCookie)
|
|
.send({
|
|
backends: [
|
|
{
|
|
name: 'off-box',
|
|
type: 's3',
|
|
options: { endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak', secretAccessKey: 'sk' },
|
|
},
|
|
],
|
|
categories: {},
|
|
version: 0,
|
|
});
|
|
expect(res.status).toBe(200);
|
|
// The secret never comes back and never persists in the clear.
|
|
const offBox = (res.body.backends as Array<{ name: string; options: Record<string, string> }>).find(
|
|
(b) => b.name === 'off-box',
|
|
)!;
|
|
expect(offBox.options.secretAccessKey).not.toBe('sk');
|
|
const row = await findRow(orm, AppSettings, { key: 'storage.backends' });
|
|
expect(row?.value).not.toContain('"sk"');
|
|
expect(row?.value).toContain('enc:v1:');
|
|
} finally {
|
|
process.env.ENCRYPTION_KEY = 'e2e-storage-key';
|
|
}
|
|
});
|
|
|
|
it('STORE2E-008 managed mode refuses the whole surface with the standard body', async () => {
|
|
process.env.TREK_MANAGED = 'true';
|
|
try {
|
|
const res = await request(server).get('/api/admin/storage').set('Cookie', adminCookie);
|
|
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;
|
|
}
|
|
});
|
|
|
|
it('STORE2E-009 POST /test probes a local candidate and answers 200 with per-target results', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-e2e-probe-'));
|
|
const res = await request(server)
|
|
.post('/api/admin/storage/test')
|
|
.set('Cookie', adminCookie)
|
|
.send({ backend: { name: 'cand', type: 'local', options: { root } } });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ ok: true, targets: [{ name: 'cand', ok: true }] });
|
|
const audit = await findRow(orm, AuditLog, { action: 'admin.storage_test' });
|
|
expect(JSON.parse(String(audit?.details))).toMatchObject({ backend: 'cand', type: 'local', ok: true });
|
|
});
|
|
|
|
it('STORE2E-010 migration moves a category end to end', async () => {
|
|
const destRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-e2e-migration-dest-'));
|
|
const put = await request(server)
|
|
.put('/api/admin/storage')
|
|
.set('Cookie', adminCookie)
|
|
.send({ backends: [{ name: 'dest', type: 'local', options: { root: destRoot } }], categories: {}, version: 0 });
|
|
expect(put.status).toBe(200);
|
|
|
|
// 'journey' is empty (no objects planted), so the migration is valid and instant.
|
|
const start = await request(server)
|
|
.post('/api/admin/storage/migrations')
|
|
.set('Cookie', adminCookie)
|
|
.send({ category: 'journey', to: 'dest' });
|
|
expect(start.status).toBe(200);
|
|
expect(start.body).toEqual({ started: true });
|
|
|
|
let status: { status: string } | undefined;
|
|
let stateBody: { categories: Record<string, { backend: string; source: string }> } | undefined;
|
|
for (let i = 0; i < 50; i++) {
|
|
const state = await request(server).get('/api/admin/storage').set('Cookie', adminCookie);
|
|
stateBody = state.body as typeof stateBody;
|
|
status = (state.body.migrations as Array<{ category: string; status: string }>).find(
|
|
(m) => m.category === 'journey',
|
|
);
|
|
if (status && status.status !== 'running') break;
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
expect(status).toMatchObject({ status: 'done' });
|
|
expect(stateBody!.categories.journey).toEqual({ backend: 'dest', source: 'settings' });
|
|
const audit = await findRow(orm, AuditLog, { action: 'admin.storage_migration' });
|
|
expect(JSON.parse(String(audit?.details))).toEqual({ category: 'journey', to: 'dest' });
|
|
});
|
|
|
|
it('STORE2E-011 backfill guards: 401 anon, 404 non-mirror, 409 while running is covered by unit — here the 404', async () => {
|
|
expect((await request(server).post('/api/admin/storage/backends/x/backfill')).status).toBe(401);
|
|
const res = await request(server)
|
|
.post('/api/admin/storage/backends/uploads-local/backfill')
|
|
.set('Cookie', adminCookie);
|
|
expect(res.status).toBe(404);
|
|
expect(res.body.error).toContain('not a mirror');
|
|
});
|
|
|
|
it('STORE2E-012 backfill happy path copies pre-existing objects and surfaces in state until done', async () => {
|
|
// Route backups through a mirror with a local replica, then plant a
|
|
// pre-mirror object directly on the primary.
|
|
const nasRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-e2e-nas-'));
|
|
// Override backups-local to a tmp root: WITHOUT this the built-in default
|
|
// is the repo's real server/data/backups directory — never write there.
|
|
const backupsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-e2e-backups-'));
|
|
const put = await request(server)
|
|
.put('/api/admin/storage')
|
|
.set('Cookie', adminCookie)
|
|
.send({
|
|
backends: [
|
|
{ name: 'backups-local', type: 'local', options: { root: backupsRoot } },
|
|
{ name: 'nas', type: 'local', options: { root: nasRoot } },
|
|
{ name: 'm', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas'] } },
|
|
],
|
|
categories: { backups: 'm' },
|
|
version: 0,
|
|
});
|
|
expect(put.status).toBe(200);
|
|
fs.writeFileSync(path.join(backupsRoot, 'pre-mirror.zip'), 'oldbytes');
|
|
|
|
const start = await request(server).post('/api/admin/storage/backends/m/backfill').set('Cookie', adminCookie);
|
|
expect(start.status).toBe(200);
|
|
expect(start.body).toEqual({ started: true });
|
|
|
|
// Poll state until the job finishes (tiny local copy — a few ticks).
|
|
let status: { status: string } | undefined;
|
|
for (let i = 0; i < 50; i++) {
|
|
const state = await request(server).get('/api/admin/storage').set('Cookie', adminCookie);
|
|
status = (state.body.backfills as Array<{ backend: string; status: string }>).find((b) => b.backend === 'm');
|
|
if (status && status.status !== 'running') break;
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
expect(status).toMatchObject({ status: 'done' });
|
|
expect(fs.existsSync(path.join(nasRoot, 'pre-mirror.zip'))).toBe(true);
|
|
const audit = await findRow(orm, AuditLog, { action: 'admin.storage_backfill' });
|
|
expect(JSON.parse(String(audit?.details))).toMatchObject({ backend: 'm' });
|
|
});
|
|
|
|
it('STORE2E-013 cancel 404s with no active run; stats refresh returns real numbers and audits', async () => {
|
|
expect(
|
|
(await request(server).delete('/api/admin/storage/backends/m/backfill').set('Cookie', adminCookie)).status,
|
|
).toBe(404);
|
|
const res = await request(server).post('/api/admin/storage/stats/refresh').set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.computedAt).toBeGreaterThan(0);
|
|
expect(res.body.categories.backups.objects).toBeGreaterThanOrEqual(1); // pre-mirror.zip at least
|
|
expect(await countRows(orm, AuditLog, { action: 'admin.storage_stats_refresh' })).toBe(1);
|
|
});
|
|
|
|
it('STORE2E-014 regression (audit #7): a migration flip while the admin form is open makes the stale save 409, and the flip survives', async () => {
|
|
const destRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-e2e-migration-dest2-'));
|
|
const put = await request(server)
|
|
.put('/api/admin/storage')
|
|
.set('Cookie', adminCookie)
|
|
.send({ backends: [{ name: 'dest', type: 'local', options: { root: destRoot } }], categories: {}, version: 0 });
|
|
expect(put.status).toBe(200);
|
|
|
|
// The admin opens the form: their draft is built at THIS version, before
|
|
// the migration below flips anything.
|
|
const loaded = await request(server).get('/api/admin/storage').set('Cookie', adminCookie);
|
|
const staleVersion = loaded.body.version as number;
|
|
expect(staleVersion).toBe(1);
|
|
expect(loaded.body.categories.journey).toEqual({ backend: 'uploads-local', source: 'default' });
|
|
|
|
// Meanwhile: a category migration flips 'journey' to 'dest' (empty
|
|
// category, so it completes instantly) — the same write path
|
|
// StorageRegistryService.assignCategory uses, bumping the version.
|
|
const start = await request(server)
|
|
.post('/api/admin/storage/migrations')
|
|
.set('Cookie', adminCookie)
|
|
.send({ category: 'journey', to: 'dest' });
|
|
expect(start.status).toBe(200);
|
|
let status: { status: string } | undefined;
|
|
for (let i = 0; i < 50; i++) {
|
|
const state = await request(server).get('/api/admin/storage').set('Cookie', adminCookie);
|
|
status = (state.body.migrations as Array<{ category: string; status: string }>).find(
|
|
(m) => m.category === 'journey',
|
|
);
|
|
if (status && status.status !== 'running') break;
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
expect(status).toMatchObject({ status: 'done' });
|
|
|
|
// The admin's still-open form — unaware of the flip, and carrying no
|
|
// opinion of its own on 'journey' — submits its (now stale) save.
|
|
const staleSave = await request(server)
|
|
.put('/api/admin/storage')
|
|
.set('Cookie', adminCookie)
|
|
.send({
|
|
backends: [{ name: 'dest', type: 'local', options: { root: destRoot } }],
|
|
categories: {},
|
|
version: staleVersion,
|
|
});
|
|
expect(staleSave.status).toBe(409);
|
|
expect(staleSave.body.error).toContain('storage settings changed since this form was loaded');
|
|
|
|
// The flip survives — the stale save never silently reverted 'journey'
|
|
// back to its old default.
|
|
const after = await request(server).get('/api/admin/storage').set('Cookie', adminCookie);
|
|
expect(after.body.categories.journey).toEqual({ backend: 'dest', source: 'settings' });
|
|
expect(after.body.version).toBeGreaterThan(staleVersion);
|
|
});
|
|
});
|