Files
TREK/server/tests/unit/nest/storage/storage-admin.controller.test.ts
T

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();
});
});