Files
multi-simadmin/apps/api/src/application/system/system-maintenance-service.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

244 lines
8.6 KiB
TypeScript

import Database from 'better-sqlite3';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { afterEach, describe, expect, it } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { SystemMaintenanceService } from './system-maintenance-service.js';
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
function database(): Database.Database {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
migrateDatabase(db);
return db;
}
function insertAudit(db: Database.Database, id: string, createdAt: string): void {
db.prepare(
`INSERT INTO audit_events
(id,actor,operation_id,risk_level,request_id,parameters_summary_json,result_code,duration_ms,created_at)
VALUES (?,?,?,?,?,?,?,?,?)`,
).run(id, 'operator', 'probe', 'R0', `${id}-request`, '{}', 'success', 1, createdAt);
}
function insertJob(db: Database.Database, id: string, createdAt: string): void {
db.prepare(
`INSERT INTO jobs
(id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?)`,
).run(
id,
'probe',
'R0',
'succeeded',
'operator',
`${id}-request`,
'0'.repeat(64),
createdAt,
createdAt,
);
}
function insertEvent(db: Database.Database, publicId: string, envelope: unknown): void {
db.prepare('INSERT INTO event_journal (public_id,envelope_json) VALUES (?,?)').run(
publicId,
JSON.stringify(envelope),
);
}
describe('SystemMaintenanceService', () => {
it('reports runtime storage, component counts, and Hub-compatible default retention', async () => {
const db = database();
insertAudit(db, 'audit-1', '2026-01-01T00:00:00.000Z');
insertJob(db, 'job-1', '2026-01-01T00:00:00.000Z');
insertEvent(db, 'event-1', { kind: 'test' });
const service = new SystemMaintenanceService(db, { version: '1.9.9' });
const overview = await service.overview();
expect(overview.runtime).toMatchObject({
version: '1.9.9',
platform: process.platform,
arch: process.arch,
});
expect(overview.runtime.uptimeSeconds).toBeGreaterThanOrEqual(0);
expect(overview.storage.databaseBytes).toBeGreaterThan(0);
expect(
Object.fromEntries(overview.storage.components.map((item) => [item.key, item.count])),
).toEqual({
instances: 0,
statusSnapshots: 0,
jobs: 1,
auditEvents: 1,
scheduledRuns: 0,
notificationQueue: 0,
smsMessages: 0,
eventJournal: 1,
connectionLogs: 0,
});
expect(overview.retention.auditEvents).toEqual({
enabled: true,
days: 180,
maximumCount: 50_000,
});
expect(overview.retention.jobs).toEqual({
enabled: true,
days: 90,
maximumCount: 5_000,
});
});
it('persists validated retention policies and cleans selected components', async () => {
const db = database();
const task = {
name: 'Night restart',
operationType: 'restart-service' as const,
cronExpression: '0 2 * * *',
timezone: 'Asia/Shanghai' as const,
targetSelector: { mode: 'fixed' as const, instanceIds: ['instance-a'] },
misfirePolicy: 'skip' as const,
overlapPolicy: 'skip' as const,
retryPolicy: { maxRetries: 0, intervalSeconds: 60 },
enabled: false,
};
db.prepare(
`INSERT INTO scheduled_tasks
(id,name,operation_type,enabled,version,cron_expression,timezone,target_selector_json,
misfire_policy,overlap_policy,retry_policy_json,created_by,updated_by,created_at,updated_at)
VALUES ('task-1',?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
).run(
task.name,
task.operationType,
0,
1,
task.cronExpression,
task.timezone,
JSON.stringify(task.targetSelector),
task.misfirePolicy,
task.overlapPolicy,
JSON.stringify(task.retryPolicy),
'operator',
'operator',
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z',
);
db.prepare(
`INSERT INTO scheduled_runs
(id,scheduled_task_id,schedule_version,task_snapshot_json,due_at,claimed_at,
target_snapshot_json,job_ids_json,trigger_source,attempt)
VALUES ('run-old','task-1',1,?,'2026-01-01T02:00:00.000Z','2026-01-01T02:00:00.000Z','[]','[]','scheduled',1)`,
).run(JSON.stringify(task));
insertAudit(db, 'audit-old', '2026-01-01T00:00:00.000Z');
insertAudit(db, 'audit-new', '2026-09-01T00:00:00.000Z');
insertJob(db, 'job-old', '2026-01-01T00:00:00.000Z');
insertEvent(db, 'event-old', { kind: 'old' });
const service = new SystemMaintenanceService(db, {
version: '1.9.9',
now: () => new Date('2026-09-03T00:00:00.000Z'),
});
await service.updateRetention({
auditEvents: { enabled: true, days: 30, maximumCount: 1 },
jobs: { enabled: true, days: 1, maximumCount: 100 },
eventJournal: { enabled: true, days: 1, maximumCount: 100 },
});
const result = await service.cleanup(['auditEvents', 'jobs', 'eventJournal', 'scheduledRuns']);
expect(result).toEqual({
auditEvents: 1,
jobs: 1,
eventJournal: 1,
scheduledRuns: 1,
});
const counts = (table: string) =>
(db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count;
expect(counts('audit_events')).toBe(1);
expect(counts('jobs')).toBe(0);
expect(counts('event_journal')).toBe(0);
expect(counts('scheduled_runs')).toBe(0);
expect((await service.overview()).retention.auditEvents).toEqual({
enabled: true,
days: 30,
maximumCount: 1,
});
});
it('rejects unknown components and invalid retention values without touching settings', async () => {
const db = database();
const service = new SystemMaintenanceService(db, { version: '1.9.9' });
await expect(service.cleanup(['unknown' as never])).rejects.toThrow('Unknown data component');
await expect(
service.updateRetention({ auditEvents: { enabled: true, days: 0, maximumCount: 1 } }),
).rejects.toThrow('Retention days must be between');
expect(await service.getRetention()).toEqual(await service.defaultRetention());
});
it('optimizes SQLite and creates validated, listed backups with retention pruning', async () => {
const db = database();
const backupRoot = await mkdtemp(join(tmpdir(), 'multi-simadmin-backup-'));
roots.push(backupRoot);
const service = new SystemMaintenanceService(db, {
version: '1.9.9',
backupDirectory: backupRoot,
maximumBackups: 2,
});
expect(await service.optimize()).toEqual({
checkpointed: true,
vacuumed: true,
analyzed: true,
});
const first = await service.createBackup();
const second = await service.createBackup();
const third = await service.createBackup();
const backups = await service.listBackups();
expect(backups).toHaveLength(2);
expect(backups.map((item) => item.filename)).toEqual([third.filename, second.filename]);
expect(first.sha256).toMatch(/^[a-f0-9]{64}$/u);
// Retention is enforced on disk, not just in the listing.
await expect(readFile(first.path)).rejects.toThrow();
expect(await readFile(second.path)).toBeTruthy();
expect(backups[0]).toEqual(
expect.objectContaining({
filename: third.filename,
sha256: third.sha256,
sizeBytes: expect.any(Number),
createdAt: expect.any(String),
}),
);
});
it('serves and deletes single backups and refuses names outside the directory', async () => {
const db = database();
const backupRoot = await mkdtemp(join(tmpdir(), 'multi-simadmin-backup-'));
roots.push(backupRoot);
const service = new SystemMaintenanceService(db, {
version: '1.9.9',
backupDirectory: backupRoot,
});
const backup = await service.createBackup();
expect(await service.backupFile(backup.filename)).toEqual({
filename: backup.filename,
path: backup.path,
sizeBytes: backup.sizeBytes,
createdAt: expect.any(String),
});
expect(await service.backupFile('multi-simadmin-1970-01-01T00-00-00-000.db')).toBeUndefined();
await expect(service.backupFile('multi-simadmin-../secret.db')).rejects.toThrow();
await expect(service.backupFile('multi-simadmin-notes.txt')).rejects.toThrow();
await expect(service.deleteBackup('multi-simadmin-%00.db')).rejects.toThrow();
expect(await service.deleteBackup(backup.filename)).toEqual({ filename: backup.filename });
expect(await service.listBackups()).toEqual([]);
});
});