171 lines
5.9 KiB
TypeScript
171 lines
5.9 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
import { buildControlPlaneApp } from './control-plane.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 {
|
|
async set() {
|
|
return '';
|
|
}
|
|
async get() {
|
|
return '[REDACTED]';
|
|
}
|
|
async delete() {
|
|
return false;
|
|
}
|
|
}
|
|
describe('buildControlPlaneApp', () => {
|
|
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 }),
|
|
},
|
|
});
|
|
|
|
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' });
|
|
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 }),
|
|
},
|
|
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' },
|
|
});
|
|
expect(insecureLogin.statusCode).toBe(400);
|
|
expect(insecureLogin.json()).toMatchObject({
|
|
title: 'Bad Request',
|
|
status: 400,
|
|
code: 'UPSTREAM_INSECURE_AUTH',
|
|
});
|
|
expect(insecureLogin.body).not.toContain('never-leak-this');
|
|
expect(insecureLogin.body).not.toContain('192.168.1.10');
|
|
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 }),
|
|
},
|
|
});
|
|
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();
|
|
});
|
|
});
|