mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +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.
354 lines
15 KiB
TypeScript
354 lines
15 KiB
TypeScript
import type { AuditService } from '../../../../src/nest/audit/audit.service';
|
|
import { StorageAdminController } from '../../../../src/nest/storage/storage-admin.controller';
|
|
import type { StorageConfigDto, StorageTestRequestDto } from '../../../../src/nest/storage/storage-admin.dto';
|
|
import type { StorageAdminService } from '../../../../src/nest/storage/storage-admin.service';
|
|
import {
|
|
BackfillBusyError,
|
|
BackfillTargetError,
|
|
MigrationRequestError,
|
|
MigrationTargetError,
|
|
} from '../../../../src/nest/storage/storage-jobs.service';
|
|
import { StatsBusyError } from '../../../../src/nest/storage/storage-stats.service';
|
|
import { StorageModule } from '../../../../src/nest/storage/storage.module';
|
|
import { StorageBackendError, StorageConflictError } from '../../../../src/nest/storage/storage.types';
|
|
import type { User } from '../../../../src/types';
|
|
import { expectRegisteredController } from '../../../helpers/module-providers';
|
|
import { HttpException } from '@nestjs/common';
|
|
import type { StorageUsage } from '@trek/shared';
|
|
|
|
import type { Request } from 'express';
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
|
|
const user = { id: 1 } as User;
|
|
const req = { headers: {}, socket: { remoteAddress: '127.0.0.1' } } as unknown as Request;
|
|
const FRESH_STATE = {
|
|
backends: [],
|
|
categories: {},
|
|
health: { replicaFailures: [] },
|
|
seedFilePresent: false,
|
|
migrations: [],
|
|
};
|
|
|
|
function makeController(over: Partial<Record<keyof StorageAdminService, unknown>> = {}) {
|
|
const writeAudit = vi.fn();
|
|
const service = {
|
|
state: vi.fn(async () => FRESH_STATE),
|
|
applyConfig: vi.fn(),
|
|
...over,
|
|
} as unknown as StorageAdminService;
|
|
const controller = new StorageAdminController(service, { writeAudit } as unknown as AuditService);
|
|
return { controller, service, writeAudit };
|
|
}
|
|
|
|
const CONFIG = {
|
|
backends: [
|
|
{
|
|
name: 'off-box',
|
|
type: 's3' as const,
|
|
options: {
|
|
endpoint: 'http://127.0.0.1:9000',
|
|
bucket: 'trek',
|
|
accessKeyId: 'ak',
|
|
secretAccessKey: 'sk-plain',
|
|
region: 'us-east-1',
|
|
keyPrefix: '',
|
|
retries: 1,
|
|
timeoutMs: 30000,
|
|
},
|
|
},
|
|
],
|
|
categories: { backups: 'off-box' },
|
|
version: 0,
|
|
} as StorageConfigDto;
|
|
|
|
describe('StorageAdminController', () => {
|
|
it('STORCTL-001 GET returns the service state untouched', async () => {
|
|
const { controller, service } = makeController();
|
|
expect(await controller.get()).toBe(FRESH_STATE);
|
|
expect(service.state).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('STORCTL-002 PUT applies, audits with secrets redacted, and answers the fresh state', async () => {
|
|
const { controller, service, writeAudit } = makeController();
|
|
const result = await controller.update(user, CONFIG, req);
|
|
expect(service.applyConfig).toHaveBeenCalledWith(CONFIG);
|
|
expect(result).toBe(FRESH_STATE); // never echoes the request
|
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ userId: 1, action: 'admin.storage_update' }));
|
|
const details = (
|
|
writeAudit.mock.calls[0]![0] as { details: { backends: Array<{ options: Record<string, unknown> }> } }
|
|
).details;
|
|
expect(details.backends[0]!.options.secretAccessKey).toBe('***');
|
|
expect(details.backends[0]!.options.accessKeyId).toBe('ak'); // names/shape survive redaction
|
|
});
|
|
|
|
it('STORCTL-003 PUT maps pipeline refusals to a 400 with the message verbatim, no audit', async () => {
|
|
const { controller, writeAudit } = makeController({
|
|
applyConfig: vi.fn(() => {
|
|
throw new StorageBackendError("category 'backups' maps to unknown backend 'nope'");
|
|
}),
|
|
});
|
|
try {
|
|
await controller.update(user, CONFIG, req);
|
|
expect.unreachable('should have thrown');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
expect((err as HttpException).getStatus()).toBe(400);
|
|
expect((err as HttpException).getResponse()).toEqual({
|
|
error: "category 'backups' maps to unknown backend 'nope'",
|
|
});
|
|
}
|
|
expect(writeAudit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('STORCTL-023 PUT maps StorageConflictError to a 409, ahead of the blanket 400, no audit', async () => {
|
|
const { controller, writeAudit } = makeController({
|
|
applyConfig: vi.fn(() => {
|
|
// StorageConflictError IS an Error — this pins that the instanceof
|
|
// branch is checked BEFORE the generic catch-all 400 (audit #7).
|
|
throw new StorageConflictError(3, 1);
|
|
}),
|
|
});
|
|
try {
|
|
await controller.update(user, CONFIG, req);
|
|
expect.unreachable('should have thrown');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
expect((err as HttpException).getStatus()).toBe(409);
|
|
expect((err as HttpException).getResponse()).toEqual({
|
|
error:
|
|
'storage settings changed since this form was loaded (current version 3, submitted 1) — reload and reapply your edits',
|
|
});
|
|
}
|
|
expect(writeAudit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('STORCTL-004 the controller is registered in StorageModule', () => {
|
|
expectRegisteredController(StorageModule, StorageAdminController);
|
|
});
|
|
|
|
it('STORCTL-005 POST /test probes, audits names only, answers 200-shaped result', async () => {
|
|
const testResult = { ok: true, targets: [{ name: 'cand', ok: true }] };
|
|
const { controller, writeAudit } = makeController({ testBackend: vi.fn(async () => testResult) });
|
|
const result = await controller.test(user, { backend: CONFIG.backends[0]! } as StorageTestRequestDto, req);
|
|
expect(result).toBe(testResult);
|
|
expect(writeAudit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
action: 'admin.storage_test',
|
|
details: { backend: 'off-box', type: 's3', ok: true, targets: [{ name: 'cand', ok: true }] },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('STORCTL-006 POST /test maps thrown refusals to 400 verbatim', async () => {
|
|
const { controller } = makeController({
|
|
testBackend: vi.fn(async () => {
|
|
throw new StorageBackendError("mirror 'm' references unknown backend 'nope'");
|
|
}),
|
|
});
|
|
await expect(
|
|
controller.test(user, { backend: CONFIG.backends[0]! } as StorageTestRequestDto, req),
|
|
).rejects.toMatchObject({ status: 400 });
|
|
});
|
|
|
|
it('STORCTL-007 POST backfill starts the sync, audits the backend name, and answers { started: true }', async () => {
|
|
const { controller, service, writeAudit } = makeController({ startBackfill: vi.fn() });
|
|
const result = await controller.backfillStart(user, 'm', req);
|
|
expect(service.startBackfill).toHaveBeenCalledWith('m');
|
|
expect(result).toEqual({ started: true });
|
|
expect(writeAudit).toHaveBeenCalledWith(
|
|
expect.objectContaining({ userId: 1, action: 'admin.storage_backfill', details: { backend: 'm' } }),
|
|
);
|
|
});
|
|
|
|
it('STORCTL-008 POST backfill maps BackfillTargetError to 404, no audit', async () => {
|
|
const { controller, writeAudit } = makeController({
|
|
startBackfill: vi.fn(() => {
|
|
throw new BackfillTargetError("'ghost' is not a mirror routed by any category");
|
|
}),
|
|
});
|
|
try {
|
|
await controller.backfillStart(user, 'ghost', req);
|
|
expect.unreachable('should have thrown');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
expect((err as HttpException).getStatus()).toBe(404);
|
|
expect((err as HttpException).getResponse()).toEqual({ error: "'ghost' is not a mirror routed by any category" });
|
|
}
|
|
expect(writeAudit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('STORCTL-009 POST backfill maps BackfillBusyError to 409, no audit', async () => {
|
|
const { controller, writeAudit } = makeController({
|
|
startBackfill: vi.fn(() => {
|
|
throw new BackfillBusyError('a sync is already running — one backfill at a time');
|
|
}),
|
|
});
|
|
try {
|
|
await controller.backfillStart(user, 'm', req);
|
|
expect.unreachable('should have thrown');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
expect((err as HttpException).getStatus()).toBe(409);
|
|
}
|
|
expect(writeAudit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('STORCTL-010 POST backfill rethrows an unrecognized error untouched', async () => {
|
|
const boom = new Error('unexpected registry failure');
|
|
const { controller, writeAudit } = makeController({
|
|
startBackfill: vi.fn(() => {
|
|
throw boom;
|
|
}),
|
|
});
|
|
await expect(controller.backfillStart(user, 'm', req)).rejects.toThrow(boom);
|
|
expect(writeAudit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('STORCTL-011 DELETE backfill cancels, audits, and answers { cancelled: true } when a sync is active', async () => {
|
|
const { controller, service, writeAudit } = makeController({ cancelBackfill: vi.fn(() => true) });
|
|
const result = await controller.backfillCancel(user, 'm', req);
|
|
expect(service.cancelBackfill).toHaveBeenCalledWith('m');
|
|
expect(result).toEqual({ cancelled: true });
|
|
expect(writeAudit).toHaveBeenCalledWith(
|
|
expect.objectContaining({ userId: 1, action: 'admin.storage_backfill_cancel', details: { backend: 'm' } }),
|
|
);
|
|
});
|
|
|
|
it('STORCTL-012 DELETE backfill answers 404 with no audit when there is no active sync', async () => {
|
|
const { controller, writeAudit } = makeController({ cancelBackfill: vi.fn(() => false) });
|
|
try {
|
|
await controller.backfillCancel(user, 'ghost', req);
|
|
expect.unreachable('should have thrown');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
expect((err as HttpException).getStatus()).toBe(404);
|
|
expect((err as HttpException).getResponse()).toEqual({ error: "no active sync for 'ghost'" });
|
|
}
|
|
expect(writeAudit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('STORCTL-013 POST stats/refresh runs the scan, audits (no details payload), and answers the usage', async () => {
|
|
const usage = { computedAt: 1, categories: {}, legacyPhotos: { objects: 0, bytes: 0 } } as unknown as StorageUsage;
|
|
const { controller, service, writeAudit } = makeController({ refreshStats: vi.fn(async () => usage) });
|
|
const result = await controller.statsRefresh(user, req);
|
|
expect(service.refreshStats).toHaveBeenCalledTimes(1);
|
|
expect(result).toBe(usage);
|
|
expect(writeAudit).toHaveBeenCalledWith(
|
|
expect.objectContaining({ userId: 1, action: 'admin.storage_stats_refresh' }),
|
|
);
|
|
});
|
|
|
|
it('STORCTL-014 POST stats/refresh maps StatsBusyError to 409, no audit', async () => {
|
|
const { controller, writeAudit } = makeController({
|
|
refreshStats: vi.fn(async () => {
|
|
throw new StatsBusyError('a usage scan is already running');
|
|
}),
|
|
});
|
|
await expect(controller.statsRefresh(user, req)).rejects.toMatchObject({ status: 409 });
|
|
expect(writeAudit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('STORCTL-015 POST stats/refresh rethrows an unrecognized error untouched', async () => {
|
|
const boom = new Error('disk full');
|
|
const { controller, writeAudit } = makeController({
|
|
refreshStats: vi.fn(async () => {
|
|
throw boom;
|
|
}),
|
|
});
|
|
await expect(controller.statsRefresh(user, req)).rejects.toBe(boom);
|
|
expect(writeAudit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('STORCTL-020 POST migrations starts and audits', async () => {
|
|
const { controller, service, writeAudit } = makeController({ startMigration: vi.fn() });
|
|
const result = await controller.migrationStart(user, { category: 'files', to: 'dest' }, req);
|
|
expect(service.startMigration).toHaveBeenCalledWith('files', 'dest');
|
|
expect(result).toEqual({ started: true });
|
|
expect(writeAudit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId: 1,
|
|
action: 'admin.storage_migration',
|
|
details: { category: 'files', to: 'dest' },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('STORCTL-021 POST maps MigrationRequestError→400, MigrationTargetError→404, BackfillBusyError→409, others rethrow', async () => {
|
|
const { controller: reqController, writeAudit: reqAudit } = makeController({
|
|
startMigration: vi.fn(() => {
|
|
throw new MigrationRequestError("'files' is already on 'dest'");
|
|
}),
|
|
});
|
|
try {
|
|
await reqController.migrationStart(user, { category: 'files', to: 'dest' }, req);
|
|
expect.unreachable('should have thrown');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
expect((err as HttpException).getStatus()).toBe(400);
|
|
expect((err as HttpException).getResponse()).toEqual({ error: "'files' is already on 'dest'" });
|
|
}
|
|
expect(reqAudit).not.toHaveBeenCalled();
|
|
|
|
const { controller: targetController, writeAudit: targetAudit } = makeController({
|
|
startMigration: vi.fn(() => {
|
|
throw new MigrationTargetError("no backend named 'ghost'");
|
|
}),
|
|
});
|
|
try {
|
|
await targetController.migrationStart(user, { category: 'files', to: 'ghost' }, req);
|
|
expect.unreachable('should have thrown');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
expect((err as HttpException).getStatus()).toBe(404);
|
|
expect((err as HttpException).getResponse()).toEqual({ error: "no backend named 'ghost'" });
|
|
}
|
|
expect(targetAudit).not.toHaveBeenCalled();
|
|
|
|
const { controller: busyController, writeAudit: busyAudit } = makeController({
|
|
startMigration: vi.fn(() => {
|
|
throw new BackfillBusyError('a sync is already running — one storage job at a time');
|
|
}),
|
|
});
|
|
try {
|
|
await busyController.migrationStart(user, { category: 'files', to: 'dest' }, req);
|
|
expect.unreachable('should have thrown');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
expect((err as HttpException).getStatus()).toBe(409);
|
|
}
|
|
expect(busyAudit).not.toHaveBeenCalled();
|
|
|
|
const boom = new Error('unexpected registry failure');
|
|
const { controller: boomController, writeAudit: boomAudit } = makeController({
|
|
startMigration: vi.fn(() => {
|
|
throw boom;
|
|
}),
|
|
});
|
|
await expect(boomController.migrationStart(user, { category: 'files', to: 'dest' }, req)).rejects.toThrow(boom);
|
|
expect(boomAudit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('STORCTL-022 DELETE migrations/:category cancels + audits; 404 when none running', async () => {
|
|
const { controller, service, writeAudit } = makeController({ cancelMigration: vi.fn(() => true) });
|
|
const result = await controller.migrationCancel(user, 'files', req);
|
|
expect(service.cancelMigration).toHaveBeenCalledWith('files');
|
|
expect(result).toEqual({ cancelled: true });
|
|
expect(writeAudit).toHaveBeenCalledWith(
|
|
expect.objectContaining({ userId: 1, action: 'admin.storage_migration_cancel', details: { category: 'files' } }),
|
|
);
|
|
|
|
const { controller: noneController, writeAudit: noneAudit } = makeController({
|
|
cancelMigration: vi.fn(() => false),
|
|
});
|
|
try {
|
|
await noneController.migrationCancel(user, 'ghost', req);
|
|
expect.unreachable('should have thrown');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
expect((err as HttpException).getStatus()).toBe(404);
|
|
expect((err as HttpException).getResponse()).toEqual({ error: "no running migration for 'ghost'" });
|
|
}
|
|
expect(noneAudit).not.toHaveBeenCalled();
|
|
});
|
|
});
|