Files
multi-simadmin/apps/api/src/interface/http/system-routes.test.ts
T
chick f9186bd851 feat(api): absorb the Hub control plane into the local instance model
Add central notification channels, rules, queue and delivery logs, fleet
organization groups and tags, device discovery, the device action catalog,
instance module reads, the log centre, connection settings and system
maintenance as native /api/v1 routes backed by the existing secret store,
audit trail and pinned upstream transport.
2026-09-05 18:53:04 +08:00

317 lines
12 KiB
TypeScript

import Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import { rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SystemMaintenanceService } from '../../application/system/system-maintenance-service.js';
import { ComponentBackupService } from '../../application/system/component-backup-service.js';
import { ConnectionSettingsService } from '../../application/connections/connection-settings-service.js';
import type { FleetHeartbeatCoordinator } from '../../application/connections/fleet-heartbeat.js';
import { buildApp } from '../../app.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { registerSystemRoutes } from './system-routes.js';
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
function fixture(heartbeat?: FleetHeartbeatCoordinator) {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
migrateDatabase(db);
const maintenance = new SystemMaintenanceService(db, {
version: 'test',
backupDirectory: join(tmpdir(), `multi-simadmin-test-${randomUUID()}`),
});
const componentBackups = new ComponentBackupService(db, {
version: 'test',
backupDirectory: join(tmpdir(), `multi-simadmin-test-${randomUUID()}`),
});
const connectionSettings = new ConnectionSettingsService({ db });
const app = buildApp({
registerRoutes: (scope) =>
registerSystemRoutes(scope, {
maintenance,
componentBackups,
connectionSettings,
...(heartbeat ? { heartbeat } : {}),
}),
});
return { app, db, componentBackups, connectionSettings };
}
describe('system maintenance routes', () => {
it('serves the native system overview', async () => {
const { app } = fixture();
const response = await app.inject({ method: 'GET', url: '/api/v1/system/maintenance' });
expect(response.statusCode, response.body).toBe(200);
expect(response.json()).toMatchObject({
runtime: { version: 'test', platform: process.platform, arch: process.arch },
storage: { databaseBytes: expect.any(Number) },
retention: { auditEvents: { enabled: true, days: 180, maximumCount: 50_000 } },
});
await app.close();
});
it('updates retention, cleans selected components, optimizes, and creates backups', async () => {
const { app, db } = fixture();
db.prepare(
`INSERT INTO audit_events
(id,actor,operation_id,risk_level,request_id,parameters_summary_json,result_code,duration_ms,created_at)
VALUES ('old','test','test','R0','old','{}','success',0,'2020-01-01T00:00:00.000Z')`,
).run();
const retention = await app.inject({
method: 'PUT',
url: '/api/v1/system/maintenance/retention',
payload: { auditEvents: { enabled: true, days: 1, maximumCount: 100 } },
});
expect(retention.statusCode, retention.body).toBe(200);
const cleanup = await app.inject({
method: 'POST',
url: '/api/v1/system/maintenance/cleanup',
payload: { components: ['auditEvents'] },
});
expect(cleanup.statusCode).toBe(200);
expect(cleanup.json()).toEqual({ auditEvents: 1 });
const optimize = await app.inject({
method: 'POST',
url: '/api/v1/system/maintenance/optimize',
});
expect(optimize.statusCode).toBe(200);
const backup = await app.inject({
method: 'POST',
url: '/api/v1/system/maintenance/backups',
});
expect(backup.statusCode).toBe(201);
expect(backup.json()).toMatchObject({
filename: expect.any(String),
sha256: expect.any(String),
});
const list = await app.inject({ method: 'GET', url: '/api/v1/system/maintenance/backups' });
expect(list.statusCode).toBe(200);
expect(list.json().items).toHaveLength(1);
const filename = backup.json().filename as string;
const download = await app.inject({
method: 'GET',
url: `/api/v1/system/maintenance/backups/${encodeURIComponent(filename)}`,
});
expect(download.statusCode).toBe(200);
expect(download.headers['content-disposition']).toBe(`attachment; filename="${filename}"`);
expect(download.headers['content-type']).toBe('application/octet-stream');
expect(Number(download.headers['content-length'])).toBe(download.rawPayload.length);
const removed = await app.inject({
method: 'DELETE',
url: `/api/v1/system/maintenance/backups/${encodeURIComponent(filename)}`,
});
expect(removed.statusCode).toBe(200);
expect(removed.json()).toEqual({ filename });
expect(
(await app.inject({ method: 'GET', url: '/api/v1/system/maintenance/backups' })).json().items,
).toEqual([]);
expect(
(
await app.inject({
method: 'GET',
url: `/api/v1/system/maintenance/backups/${encodeURIComponent(filename)}`,
})
).statusCode,
).toBe(404);
await app.close();
});
it('rejects invalid maintenance actions with stable problems', async () => {
const { app } = fixture();
const traversal = await app.inject({
method: 'GET',
url: '/api/v1/system/maintenance/backups/multi-simadmin-%2e%2e%2fsecret.db',
});
expect(traversal.statusCode).toBe(400);
expect(traversal.json()).toMatchObject({ code: 'MAINTENANCE_VALIDATION_FAILED' });
const cleanup = await app.inject({
method: 'POST',
url: '/api/v1/system/maintenance/cleanup',
payload: { components: ['unknown'] },
});
expect(cleanup.statusCode).toBe(400);
expect(cleanup.json()).toMatchObject({ code: 'MAINTENANCE_VALIDATION_FAILED' });
const retention = await app.inject({
method: 'PUT',
url: '/api/v1/system/maintenance/retention',
payload: { auditEvents: { enabled: true, days: 0, maximumCount: 1 } },
});
expect(retention.statusCode).toBe(400);
expect(retention.json()).toMatchObject({ code: 'MAINTENANCE_VALIDATION_FAILED' });
await app.close();
});
it('creates, previews, restores, and deletes a component backup over HTTP', async () => {
const { app, db } = fixture();
db.prepare('INSERT INTO device_groups (id,name,created_at,updated_at) VALUES (?,?,?,?)').run(
'group-1',
'机房 A',
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z',
);
const catalog = await app.inject({
method: 'GET',
url: '/api/v1/system/component-backups/catalog',
});
expect(catalog.statusCode, catalog.body).toBe(200);
expect(catalog.json().items).toEqual(
expect.arrayContaining([expect.objectContaining({ key: 'devices', rows: 1 })]),
);
const created = await app.inject({
method: 'POST',
url: '/api/v1/system/component-backups',
payload: { components: ['devices'], note: '升级前' },
});
expect(created.statusCode, created.body).toBe(201);
const filename = created.json().filename as string;
const listed = await app.inject({ method: 'GET', url: '/api/v1/system/component-backups' });
expect(listed.json().items).toHaveLength(1);
const preview = await app.inject({
method: 'GET',
url: `/api/v1/system/component-backups/${encodeURIComponent(filename)}/preview`,
});
expect(preview.statusCode, preview.body).toBe(200);
expect(preview.json()).toMatchObject({ integrity: 'ok', note: '升级前' });
db.prepare('DELETE FROM device_groups').run();
const restored = await app.inject({
method: 'POST',
url: `/api/v1/system/component-backups/${encodeURIComponent(filename)}/restore`,
payload: { components: ['devices'] },
});
expect(restored.statusCode, restored.body).toBe(200);
expect(restored.json()).toMatchObject({ devices: 1 });
expect(
(db.prepare('SELECT COUNT(*) AS count FROM device_groups').get() as { count: number }).count,
).toBe(1);
const removed = await app.inject({
method: 'DELETE',
url: `/api/v1/system/component-backups/${encodeURIComponent(filename)}`,
});
expect(removed.statusCode).toBe(200);
expect(
(await app.inject({ method: 'GET', url: '/api/v1/system/component-backups' })).json(),
).toEqual({
items: [],
});
await app.close();
});
it('serves the automatic backup plan and rejects a bad component list', async () => {
const { app } = fixture();
const settings = await app.inject({
method: 'PUT',
url: '/api/v1/system/component-backups/auto/settings',
payload: {
enabled: true,
components: ['sms'],
timeOfDay: '04:15',
weekday: 1,
maximumCount: 5,
},
});
expect(settings.statusCode, settings.body).toBe(200);
expect(settings.json()).toMatchObject({ enabled: true, timeOfDay: '04:15', weekday: 1 });
const read = await app.inject({
method: 'GET',
url: '/api/v1/system/component-backups/auto/settings',
});
expect(read.json()).toMatchObject({ components: ['sms'], maximumCount: 5 });
const invalid = await app.inject({
method: 'POST',
url: '/api/v1/system/component-backups',
payload: { components: ['secrets'] },
});
expect(invalid.statusCode).toBe(400);
expect(invalid.json()).toMatchObject({ code: 'COMPONENT_BACKUP_VALIDATION_FAILED' });
const missing = await app.inject({
method: 'GET',
url: '/api/v1/system/component-backups/multi-simadmin-components-2026-01-01T00-00-00-000Z.json/preview',
});
expect(missing.statusCode).toBe(404);
expect(missing.json()).toMatchObject({ code: 'COMPONENT_BACKUP_NOT_FOUND' });
await app.close();
});
});
describe('connection settings routes', () => {
it('reads the defaults, persists an update, and refuses a beat without a coordinator', async () => {
const { app, connectionSettings } = fixture();
const read = await app.inject({ method: 'GET', url: '/api/v1/system/connection' });
expect(read.statusCode, read.body).toBe(200);
expect(read.json()).toEqual({ heartbeatSeconds: 30, offlineSeconds: 90 });
const updated = await app.inject({
method: 'PUT',
url: '/api/v1/system/connection',
payload: { heartbeatSeconds: 45, offlineSeconds: 120 },
});
expect(updated.statusCode, updated.body).toBe(200);
expect(updated.json()).toEqual({ heartbeatSeconds: 45, offlineSeconds: 120 });
expect(connectionSettings.get()).toEqual({ heartbeatSeconds: 45, offlineSeconds: 120 });
expect(connectionSettings.snapshotTtlMs).toBe(120_000);
const refresh = await app.inject({ method: 'POST', url: '/api/v1/system/connection/refresh' });
expect(refresh.statusCode).toBe(501);
expect(refresh.json()).toMatchObject({ code: 'HEARTBEAT_UNAVAILABLE' });
await app.close();
});
it('rejects out-of-range cadence with a stable problem', async () => {
const { app } = fixture();
const cases: readonly Record<string, number>[] = [
{ heartbeatSeconds: 4, offlineSeconds: 90 },
{ heartbeatSeconds: 301, offlineSeconds: 900 },
{ heartbeatSeconds: 30, offlineSeconds: 59 },
{ heartbeatSeconds: 30, offlineSeconds: 1801 },
];
for (const payload of cases) {
const response = await app.inject({
method: 'PUT',
url: '/api/v1/system/connection',
payload,
});
expect(response.statusCode, `${JSON.stringify(payload)} -> ${response.body}`).toBe(400);
expect(response.json()).toMatchObject({ code: 'MAINTENANCE_VALIDATION_FAILED' });
}
expect((await app.inject({ method: 'GET', url: '/api/v1/system/connection' })).json()).toEqual({
heartbeatSeconds: 30,
offlineSeconds: 90,
});
await app.close();
});
it('reports the beat a coordinator just ran', async () => {
const runOnce = vi.fn(async () => ({
probed: 4,
failed: 1,
startedAt: '2026-09-05T00:00:00.000Z',
finishedAt: '2026-09-05T00:00:01.000Z',
}));
const { app } = fixture({ runOnce } as unknown as FleetHeartbeatCoordinator);
const refresh = await app.inject({ method: 'POST', url: '/api/v1/system/connection/refresh' });
expect(refresh.statusCode, refresh.body).toBe(200);
expect(refresh.json()).toMatchObject({ probed: 4, failed: 1 });
expect(runOnce).toHaveBeenCalledTimes(1);
await app.close();
});
});