mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
352 lines
15 KiB
TypeScript
352 lines
15 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { HttpException } from '@nestjs/common';
|
|
import type { Request } from 'express';
|
|
import type { StorageUsage } from '@trek/shared';
|
|
import type { User } from '../../../../src/types';
|
|
import { StorageAdminController } from '../../../../src/nest/storage/storage-admin.controller';
|
|
import type { StorageAdminService } from '../../../../src/nest/storage/storage-admin.service';
|
|
import type { AuditService } from '../../../../src/nest/audit/audit.service';
|
|
import type { StorageConfigDto, StorageTestRequestDto } from '../../../../src/nest/storage/storage-admin.dto';
|
|
import { StorageModule } from '../../../../src/nest/storage/storage.module';
|
|
import { StorageBackendError, StorageConflictError } from '../../../../src/nest/storage/storage.types';
|
|
import {
|
|
BackfillBusyError,
|
|
BackfillTargetError,
|
|
MigrationRequestError,
|
|
MigrationTargetError,
|
|
} from '../../../../src/nest/storage/storage-jobs.service';
|
|
import { StatsBusyError } from '../../../../src/nest/storage/storage-stats.service';
|
|
import { expectRegisteredController } from '../../../helpers/module-providers';
|
|
|
|
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();
|
|
});
|
|
});
|