Files
multi-simadmin/apps/api/src/control-plane.test.ts
T
chick e4c4bce75b feat(secrets): add a 0600 file secret backend so production runs on Linux
The production composition hardwired the macOS Keychain (/usr/bin/security),
which made Linux deployment impossible. Now:

- SecretStore gains a stable provider identity persisted in
  secret_references.provider; services stop hardcoding 'macos-keychain'
- shared reference codec (secret-reference.ts) understands both
  keychain:// and secret-file:// references
- FileSecretStore: single 0600 JSON map under the data root, atomic
  temp+rename writes, serialized in-process, same validation envelope
- production composition picks the backend via
  MULTI_SIMADMIN_SECRET_BACKEND (default: Keychain on darwin, file store
  elsewhere) and readiness probes the matching backend
2026-09-07 01:32:07 +08:00

1430 lines
50 KiB
TypeScript

import Database from 'better-sqlite3';
import { afterEach, describe, expect, it } from 'vitest';
import { buildControlPlaneApp } from './control-plane.js';
import { ScheduledTaskRepository } from './application/automation/scheduled-task-repository.js';
import { migrateDatabase } from './infrastructure/database/migrations.js';
import { UpstreamError } from './infrastructure/transport/upstream-error.js';
import { SafeInstanceTransport } from './infrastructure/transport/safe-instance-transport.js';
import { createSafeControlPlaneUpstream } from './infrastructure/transport/safe-control-plane-upstream.js';
import type { SecretStore } from './infrastructure/secrets/secret-store.js';
const dbs: Database.Database[] = [];
afterEach(async () => {
for (const db of dbs.splice(0)) db.close();
});
class Store implements SecretStore {
readonly provider = 'macos-keychain';
private readonly values = new Map<string, string>();
async set(key: { instanceId: string; purpose: string; slot?: string }, value: string) {
const account = Buffer.from(
JSON.stringify(
key.slot === undefined
? [key.instanceId, key.purpose]
: [key.instanceId, key.purpose, key.slot],
),
'utf8',
).toString('base64url');
const reference = `keychain://multi-simadmin/${account}`;
this.values.set(reference, value);
return reference;
}
async get(reference: string) {
return this.values.get(reference);
}
async delete(reference: string) {
return this.values.delete(reference);
}
}
describe('buildControlPlaneApp', () => {
it('reconciles interrupted scheduled runs before starting the scheduler', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const repository = new ScheduledTaskRepository(db);
const task = {
name: 'Interrupted restart',
operationType: 'restart-service' as const,
cronExpression: '0 9 * * *',
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,
};
repository.create({
id: 'task-1',
task,
createdBy: 'operator',
now: '2026-07-30T00:00:00.000Z',
});
repository.claimOccurrence({
id: 'run-1',
scheduledTaskId: 'task-1',
scheduleVersion: 1,
dueAt: '2026-07-30T01:00:00.000Z',
claimedAt: '2026-07-30T01:00:00.000Z',
triggerSource: 'scheduled',
targetSnapshot: [],
taskSnapshot: task,
});
repository.startRun('run-1', '2026-07-30T01:00:01.000Z');
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async () => ({ status: 200, headers: {}, body: '' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
now: () => new Date('2026-07-30T02:00:00.000Z'),
});
expect(repository.getRun('run-1')).toMatchObject({
outcome: 'needs-attention',
finishedAt: '2026-07-30T02:00:00.000Z',
});
await app.close();
});
it('registers the durable event route fail-closed when no authentication dependency is supplied', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async () => ({ status: 200, headers: {}, body: '' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
const response = await app.inject({ method: 'GET', url: '/api/v1/events' });
expect(response.statusCode).toBe(401);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.json()).toMatchObject({ status: 401, code: 'UNAUTHORIZED' });
const jobs = await app.inject({ method: 'GET', url: '/api/v1/jobs' });
expect(jobs.statusCode).toBe(200);
expect(jobs.json()).toEqual({ items: [], page: { page: 1, pageSize: 25, total: 0 } });
const audit = await app.inject({ method: 'GET', url: '/api/v1/audit' });
expect(audit.statusCode).toBe(200);
expect(audit.json()).toEqual({ items: [], page: { page: 1, pageSize: 25, total: 0 } });
await app.close();
});
it('serves native system maintenance from the control plane composition', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async () => ({ status: 200, headers: {}, body: '' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
const response = await app.inject({ method: 'GET', url: '/api/v1/system/maintenance' });
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
runtime: { version: '0.1.0', platform: process.platform, arch: process.arch },
storage: { components: expect.any(Array) },
retention: { auditEvents: { enabled: true, days: 180, maximumCount: 50_000 } },
});
await app.close();
});
it('serves central notification configuration from the control plane composition', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async () => ({ status: 200, headers: {}, body: '' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/notifications/channels',
payload: {
name: 'Bark',
type: 'bark',
config: { server_url: 'https://api.day.app', device_key: 'opaque-device-key' },
},
});
expect(created.statusCode).toBe(201);
expect(created.body).not.toContain('opaque-device-key');
const rules = await app.inject('/api/v1/notifications/rules');
expect(rules.statusCode).toBe(200);
expect(rules.json()).toEqual({ items: [], page: { page: 1, pageSize: 50, total: 0 } });
await app.close();
});
it('passes an explicit event-stream authenticator to durable cursor replay', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async () => ({ status: 200, headers: {}, body: '' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
authenticateEventStream: (request) => request.headers.authorization === 'Bearer allowed',
});
const response = await app.inject({
method: 'GET',
url: '/api/v1/events',
headers: {
authorization: 'Bearer allowed',
'last-event-id': 'unknown-cursor',
},
});
expect(response.statusCode).toBe(409);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.json()).toMatchObject({
status: 409,
code: 'EVENT_POSITION_UNAVAILABLE',
});
await app.close();
});
it('assembles instance CRUD, connection checks, login and logout without listening or reading external config', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: createSafeControlPlaneUpstream(
new SafeInstanceTransport({
resolve: async () => [{ address: '192.168.1.10', family: 4 }],
request: async (request) => ({
status: request.url.endsWith('/api/health') ? 401 : 200,
headers: { 'set-cookie': 'simadmin_session=opaque' },
body: '',
}),
}),
),
now: () => new Date('2026-07-16T12:00:00.000Z'),
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: 'A', origin: 'http://192.168.1.10:8080' },
});
expect(created.statusCode).toBe(201);
const tested = await app.inject({
method: 'POST',
url: '/api/v1/instances/' + created.json().id + '/test-connection',
});
expect(tested.json()).toMatchObject({ authenticated: false });
const insecureLogin = await app.inject({
method: 'POST',
url: '/api/v1/instances/' + created.json().id + '/login',
payload: { password: 'never-leak-this' },
});
// Literal private HTTP origins are allowed for LAN instances; secrets still never leak.
expect(insecureLogin.statusCode).toBe(200);
expect(insecureLogin.json()).toMatchObject({
authenticated: true,
instanceId: created.json().id,
});
expect(insecureLogin.body).not.toContain('never-leak-this');
expect(insecureLogin.body).not.toContain('simadmin_session');
await app.close();
});
it('automatically logs in with saved credentials before aggregate messages and resource reads', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const calls: Array<{ url: string; cookie?: string }> = [];
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async (request) => {
calls.push({
url: request.url,
...(request.headers.cookie ? { cookie: request.headers.cookie } : {}),
});
if (request.url.endsWith('/api/auth/login'))
return {
status: 200,
headers: { 'set-cookie': 'simadmin_session=aggregate-session' },
body: '{"status":"success"}',
};
if (request.url.includes('/api/sms/list'))
return {
status: 200,
headers: {},
body: JSON.stringify({
status: 'success',
data: {
messages: [
{
id: 7,
direction: 'incoming',
phone_number: '10086',
content: 'hi',
timestamp: '2026-07-29 12:00:00',
status: 'received',
},
],
},
}),
};
if (request.url.endsWith('/api/health'))
return {
status: 200,
headers: {},
body: '{"status":"ok","version":"2.3.4","platform":"linux"}',
};
return { status: 200, headers: {}, body: '{"status":"success","data":{}}' };
},
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: {
name: 'Protected',
origin: 'http://192.168.1.20:3000',
password: { action: 'set', password: '[REDACTED]' },
},
});
const id = created.json().id as string;
const messages = await app.inject({
method: 'GET',
url: `/api/v1/instances/${id}/messages?limit=1&offset=0`,
});
expect(messages.statusCode).toBe(200);
expect(messages.json().messages).toEqual([
expect.objectContaining({ id: '7', phoneNumber: '10086', content: 'hi' }),
]);
const resources = await app.inject({ method: 'GET', url: `/api/v1/instances/${id}/resources` });
expect(resources.statusCode).toBe(200);
expect(resources.json()).toMatchObject({ version: '2.3.4', platform: 'linux' });
expect(calls.filter((call) => call.url.endsWith('/api/auth/login'))).toHaveLength(1);
for (const call of calls.filter((item) => !item.url.endsWith('/api/auth/login')))
expect(call.cookie).toBe('simadmin_session=aggregate-session');
await app.close();
});
it('keeps the hub namespace closed while serving a native fleet overview aggregate', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async () => ({ status: 200, headers: {}, body: '{"status":"success"}' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: 'Alpha', origin: 'http://192.168.1.10:8080' },
});
expect(created.statusCode).toBe(201);
const instance = created.json();
const hubResponse = await app.inject({ method: 'GET', url: '/api/v1/hub/overview' });
expect(hubResponse.statusCode).toBe(404);
const overview = await app.inject({ method: 'GET', url: '/api/v1/fleet/overview' });
expect(overview.statusCode).toBe(200);
expect(overview.json()).toEqual({
items: [
{
...instance,
connection: {
probed: false,
reachable: false,
authenticated: false,
checkedAt: null,
},
resources: {},
identity: { tracked: false, status: 'confirmed', reasons: [] },
},
],
});
// A live heartbeat snapshot turns the same row into a reported online device.
db.prepare(
`INSERT INTO status_snapshots (id,instance_id,category,state,payload_json,observed_at,expires_at,created_at)
VALUES ('snap-1',?, 'connection','fresh',?,?,?,?)`,
).run(
instance.id,
JSON.stringify({ reachable: true, authenticated: true }),
'2026-07-16T12:00:00.000Z',
'2099-01-01T00:00:00.000Z',
'2026-07-16T12:00:00.000Z',
);
const probed = await app.inject({ method: 'GET', url: '/api/v1/fleet/overview' });
expect(probed.json().items[0].connection).toEqual({
probed: true,
reachable: true,
authenticated: true,
checkedAt: '2026-07-16T12:00:00.000Z',
});
await app.close();
});
it('serves cross-node message management from the Fleet domain', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async (request) => {
if (request.url.endsWith('/api/auth/login'))
return {
status: 200,
headers: { 'set-cookie': 'simadmin_session=fleet-msg-session' },
body: '{"status":"success"}',
};
if (request.url.includes('/api/sms/list'))
return {
status: 200,
headers: {},
body: JSON.stringify({
status: 'success',
data: {
messages: [
{
id: 2,
direction: 'incoming',
phone_number: '10086',
content: 'Alpha balance updated',
timestamp: '2026-07-17T01:02:00.000Z',
status: 'received',
transport: 'gsm',
},
{
id: 1,
direction: 'outgoing',
phone_number: '13900139000',
content: 'Hello Fleet',
timestamp: '2026-07-17T00:59:00.000Z',
status: 'delivered',
transport: 'modem',
},
],
},
}),
};
if (request.url.endsWith('/api/sms/send'))
return { status: 200, headers: {}, body: '{"status":"success"}' };
if (request.url.endsWith('/api/sms/batch-delete'))
return {
status: 200,
headers: {},
body: JSON.stringify({
status: 'success',
data: { deleted: 2 },
}),
};
return { status: 200, headers: {}, body: '{"status":"success","data":{}}' };
},
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
now: () => new Date('2026-07-16T12:00:00.000Z'),
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: {
name: 'Message Node',
origin: 'http://192.168.1.40:8080',
password: { action: 'set', password: '[REDACTED]' },
},
});
expect(created.statusCode).toBe(201);
const instanceId = created.json().id as string;
const list = await app.inject({
method: 'GET',
url: '/api/v1/fleet/messages?limit=10&offset=0&search=Alpha',
});
expect(list.statusCode).toBe(200);
expect(list.json()).toEqual({
messages: [
expect.objectContaining({
instanceId,
instanceName: 'Message Node',
phoneNumber: '10086',
content: 'Alpha balance updated',
}),
],
devices: [{ id: instanceId, name: 'Message Node', availability: 'online' }],
total: 1,
});
const conversations = await app.inject({
method: 'GET',
url: '/api/v1/fleet/messages/conversations?limit=10&offset=0&direction=incoming',
});
expect(conversations.statusCode).toBe(200);
expect(conversations.json()).toEqual({
conversations: [
expect.objectContaining({
instanceId,
instanceName: 'Message Node',
phoneNumber: '10086',
messageCount: 1,
incomingCount: 1,
lastMessage: expect.objectContaining({ content: 'Alpha balance updated' }),
}),
],
total: 1,
// Direction counters describe the whole filtered archive, not just the returned page.
stats: { incoming: 1, outgoing: 1, total: 2 },
});
const badDirection = await app.inject({
method: 'GET',
url: '/api/v1/fleet/messages/conversations?direction=both',
});
expect(badDirection.statusCode).toBe(400);
// The plain message list has no direction concept and must keep rejecting the parameter.
expect(
(
await app.inject({
method: 'GET',
url: '/api/v1/fleet/messages?limit=10&offset=0&direction=incoming',
})
).statusCode,
).toBe(400);
const sent = await app.inject({
method: 'POST',
url: '/api/v1/fleet/messages/send',
payload: {
instanceId,
phoneNumber: '13900139000',
content: 'Verified via Fleet',
},
});
expect(sent.statusCode).toBe(200);
expect(sent.json()).toEqual({ sent: true, queued: false, instanceId });
const deleted = await app.inject({
method: 'POST',
url: '/api/v1/fleet/messages/delete',
payload: {
items: [
{ instanceId, id: 2 },
{ instanceId, id: 1 },
],
},
});
expect(deleted.statusCode).toBe(200);
expect(deleted.json()).toEqual({
requested: 2,
deleted: 2,
failed: 0,
failures: [],
});
const missing = await app.inject({
method: 'POST',
url: '/api/v1/fleet/messages/send',
payload: {
instanceId: 'missing-instance',
phoneNumber: '13900139000',
content: 'No target',
},
});
expect(missing.statusCode).toBe(404);
expect(missing.headers['content-type']).toContain('application/problem+json');
expect(missing.json()).toMatchObject({ status: 404, code: 'NOT_FOUND' });
await app.close();
});
it('queues fleet sends for offline nodes and drains them from the outbox routes', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const delivered: string[] = [];
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async (request) => {
if (request.url.endsWith('/api/auth/login'))
return {
status: 200,
headers: { 'set-cookie': 'simadmin_session=fleet-outbox-session' },
body: '{\"status\":\"success\"}',
};
if (request.url.endsWith('/api/sms/send')) {
delivered.push(request.body ?? '');
return { status: 200, headers: {}, body: '{\"status\":\"success\"}' };
}
return { status: 200, headers: {}, body: '{\"status\":\"success\",\"data\":{}}' };
},
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
now: () => new Date('2026-07-16T12:00:00.000Z'),
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: {
name: 'Sleeping Node',
origin: 'http://192.168.1.41:8080',
password: { action: 'set', password: '[REDACTED]' },
},
});
expect(created.statusCode).toBe(201);
const instanceId = created.json().id as string;
// The heartbeat journal says this node dropped off the LAN before the fixed clock time.
db.prepare(
`INSERT INTO status_snapshots (id,instance_id,category,state,payload_json,observed_at,expires_at,created_at)
VALUES ('snap-outbox',?, 'connection','fresh',?,?,?,?)`,
).run(
instanceId,
JSON.stringify({ reachable: true, authenticated: true }),
'2026-07-16T11:58:00.000Z',
'2026-07-16T11:59:00.000Z',
'2026-07-16T11:58:00.000Z',
);
const queued = await app.inject({
method: 'POST',
url: '/api/v1/fleet/messages/send',
payload: { instanceId, phoneNumber: '13900139000', content: 'Wait for the node' },
});
expect(queued.statusCode).toBe(200);
expect(queued.json()).toEqual({
sent: false,
queued: true,
instanceId,
queueId: expect.any(String),
});
expect(delivered).toHaveLength(0);
const queueId = queued.json().queueId as string;
const page = await app.inject({
method: 'GET',
url: '/api/v1/fleet/messages/outbox?status=open&limit=10&offset=0',
});
expect(page.statusCode).toBe(200);
expect(page.json()).toEqual({
items: [
expect.objectContaining({
id: queueId,
instanceId,
instanceName: 'Sleeping Node',
phoneNumber: '13900139000',
content: 'Wait for the node',
status: 'queued',
attempts: 0,
}),
],
total: 1,
summary: { queued: 1, sending: 0, failed: 0, sent: 0 },
});
// Still offline, so a manual drain defers the item instead of burning an attempt.
const deferred = await app.inject({
method: 'POST',
url: '/api/v1/fleet/messages/outbox/flush',
});
expect(deferred.statusCode).toBe(200);
expect(deferred.json()).toEqual({
attempted: 1,
delivered: 0,
deferred: 1,
failed: 0,
remaining: 1,
});
expect(delivered).toHaveLength(0);
const cancelled = await app.inject({
method: 'POST',
url: `/api/v1/fleet/messages/outbox/${queueId}/cancel`,
});
expect(cancelled.statusCode).toBe(200);
expect(cancelled.json()).toMatchObject({ id: queueId, status: 'cancelled' });
const retried = await app.inject({
method: 'POST',
url: `/api/v1/fleet/messages/outbox/${queueId}/retry`,
});
expect(retried.statusCode).toBe(200);
expect(retried.json()).toMatchObject({ id: queueId, status: 'queued', attempts: 1 });
// The node comes back: the snapshot is refreshed and the queue drains for real.
db.prepare(`UPDATE status_snapshots SET expires_at=? WHERE id='snap-outbox'`).run(
'2099-01-01T00:00:00.000Z',
);
const flushed = await app.inject({
method: 'POST',
url: '/api/v1/fleet/messages/outbox/flush',
});
expect(flushed.statusCode).toBe(200);
expect(flushed.json()).toEqual({
attempted: 1,
delivered: 1,
deferred: 0,
failed: 0,
remaining: 0,
});
expect(delivered).toHaveLength(1);
// A reachable node keeps the old fast path: no queue row at all.
const direct = await app.inject({
method: 'POST',
url: '/api/v1/fleet/messages/send',
payload: { instanceId, phoneNumber: '13900139000', content: 'Delivered now' },
});
expect(direct.json()).toEqual({ sent: true, queued: false, instanceId });
expect(delivered).toHaveLength(2);
const dropped = await app.inject({
method: 'DELETE',
url: `/api/v1/fleet/messages/outbox/${queueId}`,
});
expect(dropped.statusCode).toBe(200);
expect(dropped.json()).toEqual({ removed: true, queueId });
expect(
(await app.inject({ method: 'GET', url: '/api/v1/fleet/messages/outbox?status=all' })).json(),
).toMatchObject({ total: 0 });
const badId = await app.inject({
method: 'POST',
url: '/api/v1/fleet/messages/outbox/not%20an%20id/cancel',
});
expect(badId.statusCode).toBe(400);
const missing = await app.inject({
method: 'POST',
url: '/api/v1/fleet/messages/outbox/q-999/retry',
});
expect(missing.statusCode).toBe(404);
expect(missing.json()).toMatchObject({ status: 404, code: 'NOT_FOUND' });
await app.close();
});
it('aggregates notification channels, rules, logs and queue in the Fleet center', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async (request) => {
if (request.url.endsWith('/api/auth/login'))
return {
status: 200,
headers: { 'set-cookie': 'simadmin_session=fleet-notification-session' },
body: '{"status":"success"}',
};
if (request.url.endsWith('/api/notifications/config'))
return {
status: 200,
headers: {},
body: JSON.stringify({
status: 'success',
data: {
channels: [
{ type: 'webhook', enabled: true },
{ type: 'bark', enabled: false },
{ type: 'wecom_robot', enabled: true },
],
rules: [{ enabled: true }, { enabled: false }],
},
}),
};
if (request.url.includes('/api/notifications/logs'))
return {
status: 200,
headers: {},
body: JSON.stringify({
status: 'success',
data: {
total: 5,
logs: [
{
id: 'log-1',
event_type: 'balance',
status: 'success',
rule_name: 'Balance Alert',
channel_name: 'webhook',
created_at: '2026-07-17T01:02:00.000Z',
},
{
id: 'log-2',
event_type: 'low-signal',
status: 'failed',
rule_name: 'Signal Alert',
channel_name: 'bark',
created_at: '2026-07-17T00:59:00.000Z',
},
{
id: 'log-3',
event_type: 'quiet',
status: 'quiet_hours',
created_at: '2026-07-17T00:58:00.000Z',
},
{
id: 'log-4',
event_type: 'unknown',
status: 'unmatched',
created_at: '2026-07-17T00:57:00.000Z',
},
{
id: 'log-5',
event_type: 'no-channel',
status: 'no_available_channel',
created_at: '2026-07-17T00:56:00.000Z',
},
],
},
}),
};
if (
request.url.includes('/api/notifications/queue') &&
(request.method === 'POST' || request.method === 'DELETE')
)
return {
status: 200,
headers: {},
body: '{"status":"success"}',
};
if (request.url.includes('/api/notifications/queue'))
return {
status: 200,
headers: {},
body: JSON.stringify({
status: 'success',
data: {
total: 3,
items: [
{
id: 'q-1',
status: 'pending',
event_type: 'balance',
rule_name: 'Balance Alert',
channel_name: 'webhook',
created_at: '2026-07-17T01:03:00.000Z',
},
{
id: 'q-2',
status: 'scheduled',
event_type: 'report',
created_at: '2026-07-17T01:04:00.000Z',
},
{
id: 'q-3',
status: 'retrying',
event_type: 'restart',
created_at: '2026-07-17T01:05:00.000Z',
},
],
},
}),
};
return { status: 200, headers: {}, body: '{"status":"success","data":{}}' };
},
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
now: () => new Date('2026-07-16T12:00:00.000Z'),
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: {
name: 'Notification Node',
origin: 'http://192.168.1.50:8080',
password: { action: 'set', password: '[REDACTED]' },
},
});
expect(created.statusCode).toBe(201);
const instanceId = created.json().id as string;
const response = await app.inject({
method: 'GET',
url: '/api/v1/fleet/notifications',
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
observedAt: expect.any(String),
deviceCount: 1,
readyCount: 1,
unavailableCount: 0,
devices: [{ id: instanceId, name: 'Notification Node', state: 'ready' }],
config: {
channelCount: 3,
channelEnabled: 2,
ruleCount: 2,
ruleEnabled: 1,
channelTypes: [
{ type: 'webhook', total: 1, enabled: 1 },
{ type: 'bark', total: 1, enabled: 0 },
{ type: 'wecom_robot', total: 1, enabled: 1 },
],
},
logs: {
total: 5,
success: 1,
failed: 1,
quietHours: 1,
unmatched: 1,
noAvailableChannel: 1,
other: 0,
},
queue: {
total: 3,
pending: 1,
scheduled: 1,
retrying: 1,
sending: 0,
failed: 0,
},
});
expect(response.json().logs.recent[0]).toMatchObject({
id: 'log-1',
eventType: 'balance',
status: 'success',
ruleName: 'Balance Alert',
channelName: 'webhook',
});
expect(response.json().queue.recent[0]).toMatchObject({
id: 'q-1',
eventType: 'balance',
status: 'pending',
});
const requested = await app.inject({
method: 'POST',
url: '/api/v1/fleet/notifications/queue/retry-all',
});
expect(requested.statusCode).toBe(200);
expect(requested.json()).toEqual({
requested: 1,
succeeded: 1,
failed: 0,
skipped: 0,
failures: [],
});
const retried = await app.inject({
method: 'POST',
url: `/api/v1/fleet/notifications/queue/${instanceId}/items/q-1/retry`,
});
expect(retried.statusCode).toBe(200);
expect(retried.json()).toEqual({ retried: true });
const deleted = await app.inject({
method: 'DELETE',
url: `/api/v1/fleet/notifications/queue/${instanceId}/items/q-1`,
});
expect(deleted.statusCode).toBe(200);
expect(deleted.json()).toEqual({ deleted: true });
const missing = await app.inject({
method: 'POST',
url: '/api/v1/fleet/notifications/queue/missing-instance/items/q-1/retry',
});
expect(missing.statusCode).toBe(404);
expect(missing.json()).toMatchObject({ status: 404, code: 'NOT_FOUND' });
const invalidQueueId = await app.inject({
method: 'POST',
url: `/api/v1/fleet/notifications/queue/${instanceId}/items/bad%20id/retry`,
});
expect(invalidQueueId.statusCode).toBe(400);
expect(invalidQueueId.json()).toMatchObject({ status: 400, code: 'VALIDATION_FAILED' });
await app.close();
});
it('attaches an origin-bound session cookie when executing restart operations', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const calls: unknown[] = [];
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async (request) => {
calls.push({ kind: 'auth', request });
if (request.url.endsWith('/api/auth/login'))
return {
status: 200,
headers: { 'set-cookie': 'simadmin_session=opaque-token' },
body: '',
};
return { status: 200, headers: {}, body: '' };
},
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async (origin, cookie) => {
calls.push({ kind: 'restart', origin, cookie });
return { status: 200 };
},
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async (origin, delaySeconds, cookie) => {
calls.push({ kind: 'reboot', origin, delaySeconds, cookie });
return { status: 200 };
},
},
now: () => new Date('2026-07-16T12:00:00.000Z'),
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: {
name: 'Restart Target',
origin: 'http://192.168.1.10:8080',
password: { action: 'set', password: '[REDACTED]' },
},
});
expect(created.statusCode).toBe(201);
const instanceId = created.json().id as string;
const revision = created.json().revision as number;
const prepared = await app.inject({
method: 'POST',
url: '/api/v1/operations/prepare',
payload: {
operationId: 'postServiceRestart',
targets: [{ instanceId, revision }],
parameters: {
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
fields: [],
},
},
});
expect(prepared.statusCode).toBe(200);
const executed = await app.inject({
method: 'POST',
url: '/api/v1/operations/execute',
payload: {
preparationId: prepared.json().id,
confirmationToken: prepared.json().confirmationToken,
},
});
expect(executed.statusCode).toBe(202);
expect(executed.json()).toMatchObject({
operationId: 'postServiceRestart',
status: 'succeeded',
});
expect(calls).toContainEqual({
kind: 'restart',
origin: 'http://192.168.1.10:8080',
cookie: 'simadmin_session=opaque-token',
});
// Auth request body uses the redacted marker; the actual secret must never appear.
expect(JSON.stringify(calls)).toContain('"[REDACTED]"');
expect(JSON.stringify(calls)).not.toMatch(/never-leak|password":"[^[]/);
await app.close();
});
it('maps typed upstream failures to redacted gateway Problem Details', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => {
throw new UpstreamError('UPSTREAM_UNAVAILABLE', {
cause: new Error('ECONNREFUSED 192.168.1.99 cookie=simadmin_session=secret'),
});
},
request: async () => ({ status: 200, headers: {}, body: '' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: 'B', origin: 'http://192.168.1.99' },
});
const response = await app.inject({
method: 'POST',
url: '/api/v1/instances/' + created.json().id + '/test-connection',
});
expect(response.statusCode).toBe(502);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.json()).toMatchObject({
title: 'Bad Gateway',
status: 502,
code: 'UPSTREAM_UNAVAILABLE',
});
expect(response.body).not.toContain('ECONNREFUSED');
expect(response.body).not.toContain('192.168.1.99');
expect(response.body).not.toContain('simadmin_session');
await app.close();
});
it('keeps a failed SMS node visible instead of pretending it has no messages', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
let smsListCall = 0;
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async (request) => {
if (request.url.endsWith('/api/auth/login'))
return {
status: 200,
headers: { 'set-cookie': 'simadmin_session=fleet-availability-session' },
body: '{"status":"success"}',
};
if (request.url.includes('/api/sms/list') && ++smsListCall === 1)
return { status: 502, headers: {}, body: '{"status":"error"}' };
if (request.url.includes('/api/sms/list'))
return {
status: 200,
headers: {},
body: JSON.stringify({
status: 'success',
data: {
messages: [
{
id: 1,
direction: 'incoming',
phone_number: '10086',
content: 'Healthy node',
timestamp: '2026-07-17T01:00:00.000Z',
status: 'received',
},
],
},
}),
};
return { status: 200, headers: {}, body: '{"status":"success","data":{}}' };
},
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
for (const [index, name] of ['Healthy', 'Broken'].entries()) {
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: {
name,
origin: `http://192.168.1.${40 + index}:8080`,
password: { action: 'set', password: '[REDACTED]' },
},
});
expect(created.statusCode).toBe(201);
}
const response = await app.inject({ method: 'GET', url: '/api/v1/fleet/messages' });
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body.devices).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'Healthy', availability: 'online' }),
expect.objectContaining({ name: 'Broken', availability: 'unavailable' }),
]),
);
expect(body.messages).toEqual([expect.objectContaining({ content: 'Healthy node' })]);
expect(body.total).toBe(1);
await app.close();
});
it('keeps a node that refuses to release its hub binding and drops one that is unreachable', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const unbinds: string[] = [];
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async (url) => {
if (url.includes('192.168.1.21')) throw new Error('ECONNREFUSED');
return { status: 200, headers: {}, body: '' };
},
request: async ({ url, method }) => {
if (method === 'POST' && url.endsWith('/api/hub/unbind')) {
unbinds.push(url);
return { status: 409, headers: {}, body: '{"status":"error","message":"busy"}' };
}
return { status: 200, headers: {}, body: '{"status":"success"}' };
},
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
async function remove(instanceId: string): Promise<Record<string, unknown>> {
const prepared = await app.inject({
method: 'POST',
url: '/api/v1/operations/prepare',
payload: {
operationId: 'deleteInstance',
targets: [{ instanceId, revision: 1 }],
parameters: { parameterSchemaId: 'deleteInstance.parameters.v1', fields: [] },
},
});
expect(prepared.statusCode).toBe(200);
const executed = await app.inject({
method: 'DELETE',
url: `/api/v1/instances/${encodeURIComponent(instanceId)}`,
headers: {
'if-match': '"rev-1"',
'x-preparation-id': prepared.json().id as string,
'x-confirmation-token': prepared.json().confirmationToken as string,
},
});
expect(executed.statusCode).toBe(202);
return executed.json() as Record<string, unknown>;
}
const created: string[] = [];
for (const [name, host] of [
['Refusing', '192.168.1.20'],
['Unreachable', '192.168.1.21'],
]) {
const response = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name, origin: `http://${host}:8080` },
});
expect(response.statusCode).toBe(201);
created.push(response.json().id as string);
}
const [refused, gone] = created;
expect(await remove(refused!)).toMatchObject({
status: 'failed',
items: [{ state: 'failed', error: { code: 'UNBIND_FAILED' } }],
});
expect(await remove(gone!)).toMatchObject({ status: 'succeeded' });
const listed = await app.inject('/api/v1/instances');
expect((listed.json().items as Array<{ id: string }>).map((item) => item.id)).toEqual([
refused,
]);
expect(unbinds).toEqual([`http://192.168.1.20:8080/api/hub/unbind`]);
await app.close();
});
it('holds controls for two nodes that claim the same device identity', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const imei = '860000000000007';
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async ({ url, method }) => {
if (url.endsWith('/api/device'))
return {
status: 200,
headers: {},
body: JSON.stringify({ imei, model: 'RM500Q', manufacturer: 'Quectel' }),
};
return {
status: method === 'POST' ? 200 : 404,
headers: {},
body: '{"status":"success"}',
};
},
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
const created: string[] = [];
for (const host of ['192.168.1.30', '192.168.1.31']) {
const response = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: `节点 ${host}`, origin: `http://${host}:8080` },
});
created.push(response.json().id as string);
}
const [first, second] = created;
// Reading the overview is enough: the device reports its own hardware and the guard notices
// that two records now answer with one IMEI.
for (const id of created) {
const snapshot = await app.inject(`/api/v1/instances/${id}/modules/overview`);
expect(snapshot.statusCode).toBe(200);
}
const listed = await app.inject('/api/v1/fleet/identities');
expect(listed.json()).toMatchObject({
summary: { tracked: 2, pending: 2 },
items: [
{ instanceId: first, reasons: ['imei_claimed'] },
{ instanceId: second, reasons: ['imei_claimed'] },
],
});
const blocked = await app.inject({
method: 'POST',
url: `/api/v1/instances/${first}/device-actions/call.waiting`,
payload: { confirm: true, params: { value: 'enabled' } },
});
expect(blocked.statusCode).toBe(409);
expect(blocked.json()).toMatchObject({ code: 'IDENTITY_UNCONFIRMED' });
const overview = await app.inject('/api/v1/fleet/overview');
expect(overview.json().items[0].identity).toEqual({
tracked: true,
status: 'pending',
reasons: ['imei_claimed'],
});
// The operator vouches for one node; the other keeps waiting.
const confirmed = await app.inject({
method: 'POST',
url: `/api/v1/fleet/identities/${first}/confirm`,
});
expect(confirmed.json()).toMatchObject({
identity: { instanceId: first, status: 'confirmed', reasons: [] },
});
const allowed = await app.inject({
method: 'POST',
url: `/api/v1/instances/${first}/device-actions/call.waiting`,
payload: { confirm: true, params: { value: 'enabled' } },
});
expect(allowed.statusCode).toBe(200);
await app.close();
});
it('scrapes the fused fleet through the metrics endpoint', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
runtimeVersion: '9.9.9',
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async ({ method }) => ({
status: method === 'POST' ? 200 : 404,
headers: {},
body: '{"status":"success"}',
}),
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: '节点 A', origin: 'http://192.168.1.40:8080' },
});
expect(created.statusCode).toBe(201);
const response = await app.inject({ method: 'GET', url: '/api/v1/metrics' });
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('text/plain');
expect(response.body).toContain('multi_simadmin_build_info{version="9.9.9"} 1');
expect(response.body).toContain('multi_simadmin_nodes_total 1');
expect(response.body).toContain('multi_simadmin_notification_queue{status="pending"} 0');
await app.close();
});
});