feat: rebuild warm operations workbench
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -12,17 +13,83 @@ afterEach(async () => {
|
||||
for (const db of dbs.splice(0)) db.close();
|
||||
});
|
||||
class Store implements SecretStore {
|
||||
async set() {
|
||||
return '';
|
||||
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() {
|
||||
return '[REDACTED]';
|
||||
async get(reference: string) {
|
||||
return this.values.get(reference);
|
||||
}
|
||||
async delete() {
|
||||
return false;
|
||||
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 }),
|
||||
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');
|
||||
@@ -127,14 +194,178 @@ describe('buildControlPlaneApp', () => {
|
||||
url: '/api/v1/instances/' + created.json().id + '/login',
|
||||
payload: { password: 'never-leak-this' },
|
||||
});
|
||||
expect(insecureLogin.statusCode).toBe(400);
|
||||
// Literal private HTTP origins are allowed for LAN instances; secrets still never leak.
|
||||
expect(insecureLogin.statusCode).toBe(200);
|
||||
expect(insecureLogin.json()).toMatchObject({
|
||||
title: 'Bad Request',
|
||||
status: 400,
|
||||
code: 'UPSTREAM_INSECURE_AUTH',
|
||||
authenticated: true,
|
||||
instanceId: created.json().id,
|
||||
});
|
||||
expect(insecureLogin.body).not.toContain('never-leak-this');
|
||||
expect(insecureLogin.body).not.toContain('192.168.1.10');
|
||||
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 }),
|
||||
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('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 };
|
||||
},
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user