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[] = [ { 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(); }); });