feat(api): complete phase 2.4 control plane

This commit is contained in:
chick
2026-07-17 00:37:11 +08:00
parent c2702b5fc6
commit 7b91dbbad1
40 changed files with 4526 additions and 74 deletions
@@ -0,0 +1,383 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildApp } from '../../app.js';
import {
InstanceSessionStore,
UpstreamSessionClient,
} from '../../application/connections/upstream-session-client.js';
import { InstanceLoginService } from '../../application/connections/instance-login-service.js';
import { ConnectionProbe } from '../../application/connections/connection-probe.js';
import {
InstanceService,
InstanceServiceError,
type InstanceServiceErrorCode,
} from '../../application/instances/instance-service.js';
import {
DELETE_INSTANCE_PARAMETER_SCHEMA_ID,
DeleteInstanceOperation,
} from '../../application/operations/delete-instance-operation.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
import { registerInstanceRoutes } from './instance-routes.js';
class FakeStore implements SecretStore {
async set(key: { instanceId: string; purpose: string; slot?: string }) {
return `keychain://multi-simadmin/${Buffer.from(
JSON.stringify([key.instanceId, key.purpose, key.slot]),
'utf8',
).toString('base64url')}`;
}
async get() {
return undefined;
}
async delete() {
return false;
}
}
const dbs: Database.Database[] = [];
afterEach(() => {
for (const db of dbs.splice(0)) db.close();
});
const fixture = () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
let id = 0;
const instances = new InstanceService({
db,
store: new FakeStore(),
idFactory: () => `id-${++id}`,
now: () => new Date('2026-07-16T12:00:00.000Z'),
});
const connections = new ConnectionProbe({
db,
instances,
transport: { get: async () => ({ status: 401, headers: {}, body: '' }) },
now: () => new Date('2026-07-16T12:00:00.000Z'),
});
const sessionClient = new UpstreamSessionClient({
sessions: new InstanceSessionStore(),
request: async () => ({
status: 200,
headers: { 'set-cookie': 'simadmin_session=opaque' },
body: '',
}),
});
const login = new InstanceLoginService({
db,
client: sessionClient,
resolver: { resolve: async () => '[REDACTED]' },
now: () => new Date('2026-07-16T12:00:00.000Z'),
});
const deletion = new DeleteInstanceOperation({
db,
instances,
now: () => new Date('2026-07-16T12:00:00.000Z'),
idFactory: () => `operation-${++id}`,
tokenFactory: () => Buffer.alloc(32, 11).toString('base64url'),
});
const app = buildApp({
registerRoutes: (scope) =>
registerInstanceRoutes(scope, { instances, connections, login, deletion }),
});
return { app, instances };
};
describe('instance HTTP routes', () => {
it('creates, lists, gets, updates and deletes redacted instance resources', async () => {
const { app } = fixture();
const create = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: {
name: 'Alpha',
origin: 'http://192.168.1.10:8080',
tags: ['lab'],
password: { action: 'set', password: 'never-return-this' },
},
});
expect(create.statusCode).toBe(201);
expect(create.headers.etag).toBe('"rev-1"');
expect(JSON.stringify(create.json())).not.toContain('never-return-this');
expect(create.json()).toMatchObject({ id: 'id-1', revision: 1, credentialConfigured: true });
const list = await app.inject({ method: 'GET', url: '/api/v1/instances?page=1&pageSize=20' });
expect(list.statusCode).toBe(200);
expect(list.json()).toMatchObject({ items: [expect.objectContaining({ id: 'id-1' })] });
const get = await app.inject({ method: 'GET', url: '/api/v1/instances/id-1' });
expect(get.statusCode).toBe(200);
expect(get.headers.etag).toBe('"rev-1"');
const session = await app.inject({
method: 'POST',
url: '/api/v1/instances/id-1/test-connection',
});
expect(session.statusCode).toBe(200);
expect(session.json()).toEqual({
instanceId: 'id-1',
authenticated: false,
checkedAt: '2026-07-16T12:00:00.000Z',
});
const login = await app.inject({
method: 'POST',
url: '/api/v1/instances/id-1/login',
payload: { password: 'one-shot-secret' },
});
expect(login.statusCode).toBe(200);
expect(login.json()).toEqual({
instanceId: 'id-1',
authenticated: true,
checkedAt: '2026-07-16T12:00:00.000Z',
});
expect(login.body).not.toContain('one-shot-secret');
const logout = await app.inject({ method: 'POST', url: '/api/v1/instances/id-1/logout' });
expect(logout.statusCode).toBe(200);
expect(logout.json()).toMatchObject({ instanceId: 'id-1', authenticated: false });
const stale = await app.inject({
method: 'PATCH',
url: '/api/v1/instances/id-1',
headers: { 'if-match': '"rev-2"' },
payload: { name: 'Beta' },
});
expect(stale.statusCode).toBe(412);
expect(stale.json()).toMatchObject({ code: 'REVISION_CONFLICT' });
const update = await app.inject({
method: 'PATCH',
url: '/api/v1/instances/id-1',
headers: { 'if-match': '"rev-1"' },
payload: { name: 'Beta', password: { action: 'preserve' } },
});
expect(update.statusCode).toBe(200);
expect(update.headers.etag).toBe('"rev-2"');
expect(update.json()).toMatchObject({ name: 'Beta', revision: 2 });
const directRemove = await app.inject({
method: 'DELETE',
url: '/api/v1/instances/id-1',
headers: { 'if-match': '"rev-2"' },
});
expect(directRemove.statusCode).toBe(400);
expect(directRemove.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
const preparation = await app.inject({
method: 'POST',
url: '/api/v1/operations/prepare',
payload: {
operationId: 'deleteInstance',
targets: [{ instanceId: 'id-1', revision: 2 }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
},
});
expect(preparation.statusCode).toBe(200);
const prepared = preparation.json() as { id: string; confirmationToken: string };
const remove = await app.inject({
method: 'DELETE',
url: '/api/v1/instances/id-1',
headers: {
'if-match': '"rev-2"',
'x-preparation-id': prepared.id,
'x-confirmation-token': prepared.confirmationToken,
},
});
expect(remove.statusCode).toBe(202);
expect(remove.json()).toMatchObject({ operationId: 'deleteInstance', status: 'succeeded' });
const replay = await app.inject({
method: 'DELETE',
url: '/api/v1/instances/id-1',
headers: {
'if-match': '"rev-2"',
'x-preparation-id': prepared.id,
'x-confirmation-token': prepared.confirmationToken,
},
});
expect(replay.statusCode).toBe(400);
expect(replay.json()).toMatchObject({ code: 'CONFIRMATION_INVALID' });
expect((await app.inject({ method: 'GET', url: '/api/v1/instances/id-1' })).statusCode).toBe(
404,
);
await app.close();
});
it('rejects invalid input and maps domain errors to stable RFC problem details', async () => {
const { app } = fixture();
const invalid = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: '' },
});
expect(invalid.statusCode).toBe(400);
expect(invalid.headers['content-type']).toContain('application/problem+json');
expect(invalid.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
const missing = await app.inject({ method: 'GET', url: '/api/v1/instances/nope' });
expect(missing.statusCode).toBe(404);
expect(missing.json()).toMatchObject({ code: 'NOT_FOUND' });
const nullCreate = await app.inject({
method: 'POST',
url: '/api/v1/instances',
headers: { 'content-type': 'application/json' },
payload: 'null',
});
expect(nullCreate.statusCode).toBe(400);
expect(nullCreate.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
const arrayPatch = await app.inject({
method: 'PATCH',
url: '/api/v1/instances/nope',
headers: { 'if-match': '"rev-1"' },
payload: [],
});
expect(arrayPatch.statusCode).toBe(400);
expect(arrayPatch.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
const strictCreateCases = [
{ name: 'Alpha', origin: 'http://192.168.1.10', unexpected: true },
{ name: 'Alpha', origin: 'http://192.168.1.10', tags: 'lab' },
{ name: 'Alpha', origin: 'http://192.168.1.10', password: { action: 'set' } },
];
for (const payload of strictCreateCases) {
const response = await app.inject({ method: 'POST', url: '/api/v1/instances', payload });
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
}
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: 'Strict', origin: 'http://192.168.1.11' },
});
expect(created.statusCode).toBe(201);
const strictId = (created.json() as { id: string }).id;
for (const payload of [{}, { name: 123 }, { unexpected: true }]) {
const response = await app.inject({
method: 'PATCH',
url: `/api/v1/instances/${strictId}`,
headers: { 'if-match': '"rev-1"' },
payload,
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
}
const unchanged = await app.inject({ method: 'GET', url: `/api/v1/instances/${strictId}` });
expect(unchanged.json()).toMatchObject({ name: 'Strict', revision: 1 });
for (const payload of [
{ password: { not: 'a string' } },
{ password: '' },
{ unexpected: true },
]) {
const response = await app.inject({
method: 'POST',
url: `/api/v1/instances/${strictId}/login`,
payload,
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
}
const loginMissing = await app.inject({
method: 'POST',
url: '/api/v1/instances/nope/login',
payload: {},
});
expect(loginMissing.statusCode).toBe(404);
expect(loginMissing.json()).toMatchObject({ code: 'NOT_FOUND' });
await app.close();
});
it('requires an exact strong revision ETag in If-Match', async () => {
const { app } = fixture();
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: 'Strict ETag', origin: 'http://192.168.1.12' },
});
const id = (created.json() as { id: string }).id;
for (const value of ['1', 'rev-1', 'W/"rev-1"', '"1"', '"rev-01"', '"rev-1", "rev-2"']) {
const response = await app.inject({
method: 'PATCH',
url: `/api/v1/instances/${id}`,
headers: { 'if-match': value },
payload: { name: 'Rejected' },
});
expect(response.statusCode, value).toBe(400);
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
}
await app.close();
});
it('parses every frozen list filter and defaults pageSize to 25', async () => {
const { app, instances } = fixture();
const list = vi.spyOn(instances, 'list');
const response = await app.inject({
method: 'GET',
url: '/api/v1/instances?page=2&pageSize=30&sort=status&direction=desc&search=alpha&capabilityStatus=degraded&freshness=stale&tag=lab&credentialConfigured=false',
});
expect(response.statusCode).toBe(200);
expect(list).toHaveBeenCalledWith({
page: 2,
pageSize: 30,
sort: 'status',
direction: 'desc',
search: 'alpha',
capabilityStatus: 'degraded',
freshness: 'stale',
tag: 'lab',
credentialConfigured: false,
});
const defaults = await app.inject({ method: 'GET', url: '/api/v1/instances' });
expect(defaults.json()).toMatchObject({ page: { page: 1, pageSize: 25, total: 0 } });
await app.close();
});
it('rejects list query values outside the frozen OpenAPI constraints', async () => {
const { app } = fixture();
const invalidQueries = [
'page=0',
'page=1.5',
'pageSize=0',
'pageSize=101',
'pageSize=abc',
'sort=id',
'direction=sideways',
`search=${'x'.repeat(201)}`,
'capabilityStatus=online',
'freshness=old',
'credentialConfigured=1',
'credentialConfigured=TRUE',
];
for (const query of invalidQueries) {
const response = await app.inject({ method: 'GET', url: `/api/v1/instances?${query}` });
expect(response.statusCode, query).toBe(400);
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
}
await app.close();
});
it('exhaustively maps and redacts instance service errors', async () => {
const expected: Record<InstanceServiceErrorCode, number> = {
VALIDATION_FAILED: 400,
NOT_FOUND: 404,
REVISION_CONFLICT: 412,
HAS_JOB_HISTORY: 409,
DUPLICATE_ID: 409,
DUPLICATE_ORIGIN: 409,
SECRET_STORE_FAILED: 500,
DATABASE_FAILED: 500,
SECRET_CLEANUP_FAILED: 500,
COMPENSATION_FAILED: 500,
COMPENSATION_PERSISTENCE_FAILED: 500,
};
for (const [code, status] of Object.entries(expected) as [InstanceServiceErrorCode, number][]) {
const { app, instances } = fixture();
vi.spyOn(instances, 'list').mockRejectedValue(
new InstanceServiceError(code, 'sensitive internal failure detail'),
);
const response = await app.inject({ method: 'GET', url: '/api/v1/instances' });
expect(response.statusCode, code).toBe(status);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.json()).toMatchObject({ code, status });
expect(response.body).not.toContain('sensitive internal failure detail');
await app.close();
}
});
});