This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* console-protocol.ts is a type-only module describing the Console WS wire
|
||||
* protocol. These tests act as a wire-format regression guard:
|
||||
* - compile-time: object literals must `satisfies` each message type, so a
|
||||
* field rename (e.g. acting_user_id → actingUserId) breaks this file
|
||||
* - runtime: each message survives a JSON round-trip losslessly (the actual
|
||||
* transport is JSON text frames, see src/bridge/console-ws-api.ts)
|
||||
* - malformed input: shows how consumers must defend against junk frames
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type {
|
||||
AttachMessage,
|
||||
ReplayBeginMessage,
|
||||
ReplayEndMessage,
|
||||
ResizeMessage,
|
||||
NoticeMessage,
|
||||
CloseMessage,
|
||||
ServerTextMessage,
|
||||
ClientTextMessage,
|
||||
AnyTextMessage,
|
||||
SessionCloseReason,
|
||||
NoticeSeverity,
|
||||
} from './console-protocol.js';
|
||||
|
||||
/** Minimal runtime validator mirroring what a WS consumer must do. */
|
||||
function parseTextFrame(raw: string): AnyTextMessage | null {
|
||||
let obj: unknown;
|
||||
try {
|
||||
obj = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof obj !== 'object' || obj === null) return null;
|
||||
const type = (obj as Record<string, unknown>)['type'];
|
||||
if (typeof type !== 'string') return null;
|
||||
const known = ['attach', 'replay_begin', 'replay_end', 'resize', 'notice', 'close'];
|
||||
if (!known.includes(type)) return null;
|
||||
return obj as AnyTextMessage;
|
||||
}
|
||||
|
||||
function roundTrip<T extends AnyTextMessage>(msg: T): T {
|
||||
return JSON.parse(JSON.stringify(msg)) as T;
|
||||
}
|
||||
|
||||
describe('ssh/console-protocol wire shapes', () => {
|
||||
it('attach message round-trips with snake_case wire fields intact', () => {
|
||||
const msg = {
|
||||
type: 'attach',
|
||||
acting_user_id: 'user-42',
|
||||
can_write: true,
|
||||
connection_id: 'conn-7',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
} satisfies AttachMessage;
|
||||
const back = roundTrip(msg);
|
||||
expect(back).toEqual(msg);
|
||||
// wire field names are part of the protocol contract
|
||||
expect(Object.keys(back).sort()).toEqual([
|
||||
'acting_user_id',
|
||||
'can_write',
|
||||
'cols',
|
||||
'connection_id',
|
||||
'rows',
|
||||
'type',
|
||||
]);
|
||||
});
|
||||
|
||||
it('replay_begin / replay_end round-trip', () => {
|
||||
const begin = { type: 'replay_begin', bytes: 8192 } satisfies ReplayBeginMessage;
|
||||
const end = { type: 'replay_end' } satisfies ReplayEndMessage;
|
||||
expect(roundTrip(begin)).toEqual(begin);
|
||||
expect(roundTrip(end)).toEqual(end);
|
||||
});
|
||||
|
||||
it('resize message (client → server) round-trips', () => {
|
||||
const msg = { type: 'resize', cols: 80, rows: 24 } satisfies ResizeMessage;
|
||||
expect(roundTrip(msg)).toEqual(msg);
|
||||
});
|
||||
|
||||
it('notice message round-trips for every severity', () => {
|
||||
const severities = ['info', 'warn', 'error'] satisfies NoticeSeverity[];
|
||||
for (const severity of severities) {
|
||||
const msg = { type: 'notice', severity, msg: 'something happened' } satisfies NoticeMessage;
|
||||
expect(roundTrip(msg)).toEqual(msg);
|
||||
}
|
||||
});
|
||||
|
||||
it('close message round-trips for every documented close reason', () => {
|
||||
const reasons = [
|
||||
'idle_timeout',
|
||||
'duration_cap',
|
||||
'host_disconnect',
|
||||
'maintenance',
|
||||
'admin_kill',
|
||||
'connection_change',
|
||||
'session_cap_evict',
|
||||
'worker_shutdown',
|
||||
'access_revoked',
|
||||
] satisfies SessionCloseReason[];
|
||||
expect(new Set(reasons).size).toBe(reasons.length);
|
||||
for (const reason of reasons) {
|
||||
const msg = { type: 'close', reason } satisfies CloseMessage;
|
||||
expect(roundTrip(msg)).toEqual(msg);
|
||||
}
|
||||
});
|
||||
|
||||
it('discriminated union narrows on type', () => {
|
||||
const msgs: ServerTextMessage[] = [
|
||||
{ type: 'attach', acting_user_id: 'u', can_write: false, connection_id: 'c', cols: 1, rows: 1 },
|
||||
{ type: 'replay_begin', bytes: 0 },
|
||||
{ type: 'replay_end' },
|
||||
{ type: 'notice', severity: 'warn', msg: 'read-only' },
|
||||
{ type: 'close', reason: 'admin_kill' },
|
||||
];
|
||||
const seen: string[] = [];
|
||||
for (const m of msgs) {
|
||||
switch (m.type) {
|
||||
case 'attach':
|
||||
seen.push(`attach:${m.connection_id}`);
|
||||
break;
|
||||
case 'replay_begin':
|
||||
seen.push(`replay_begin:${m.bytes}`);
|
||||
break;
|
||||
case 'replay_end':
|
||||
seen.push('replay_end');
|
||||
break;
|
||||
case 'notice':
|
||||
seen.push(`notice:${m.severity}`);
|
||||
break;
|
||||
case 'close':
|
||||
seen.push(`close:${m.reason}`);
|
||||
break;
|
||||
default: {
|
||||
// exhaustiveness guard — adding a new ServerTextMessage variant
|
||||
// without handling it here is a compile error
|
||||
const _never: never = m;
|
||||
void _never;
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(seen).toEqual([
|
||||
'attach:c',
|
||||
'replay_begin:0',
|
||||
'replay_end',
|
||||
'notice:warn',
|
||||
'close:admin_kill',
|
||||
]);
|
||||
|
||||
const client: ClientTextMessage = { type: 'resize', cols: 100, rows: 30 };
|
||||
expect(client.type).toBe('resize');
|
||||
});
|
||||
|
||||
it('malformed frames are rejected by a defensive parser', () => {
|
||||
expect(parseTextFrame('not json at all')).toBeNull();
|
||||
expect(parseTextFrame('')).toBeNull();
|
||||
expect(parseTextFrame('null')).toBeNull();
|
||||
expect(parseTextFrame('42')).toBeNull();
|
||||
expect(parseTextFrame('"resize"')).toBeNull();
|
||||
expect(parseTextFrame('[]')).toBeNull();
|
||||
expect(parseTextFrame('{}')).toBeNull(); // missing type
|
||||
expect(parseTextFrame('{"type":123}')).toBeNull(); // non-string type
|
||||
expect(parseTextFrame('{"type":"selfdestruct"}')).toBeNull(); // unknown type
|
||||
// valid frame passes
|
||||
const ok = parseTextFrame('{"type":"resize","cols":80,"rows":24}');
|
||||
expect(ok).toEqual({ type: 'resize', cols: 80, rows: 24 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Startup recovery for stale (pending) SSH audit rows.
|
||||
* Complements the smoke tests in audit-repo.test.ts with detail-merge,
|
||||
* idempotency, and ordering coverage.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from '../db/migrate.js';
|
||||
import { createAuditRepo } from './audit-repo.js';
|
||||
import { reconcileStaleSshAudit } from './recovery.js';
|
||||
|
||||
const validKey = 'a'.repeat(64);
|
||||
|
||||
function bootstrapDb(): Database.Database {
|
||||
process.env.MCP_ENCRYPTION_KEY = validKey;
|
||||
const db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`);
|
||||
db.exec(`CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT);`);
|
||||
db.exec(`CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`);
|
||||
runMigrations(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
describe('ssh/recovery reconcileStaleSshAudit', () => {
|
||||
let db: Database.Database;
|
||||
beforeEach(() => {
|
||||
db = bootstrapDb();
|
||||
});
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
delete process.env.MCP_ENCRYPTION_KEY;
|
||||
});
|
||||
|
||||
it('returns zero counts on an empty audit log', () => {
|
||||
const result = reconcileStaleSshAudit(db);
|
||||
expect(result).toEqual({ reconciledCount: 0, ids: [] });
|
||||
});
|
||||
|
||||
it('marks every pending row aborted with the stale_reason detail', () => {
|
||||
const repo = createAuditRepo(db);
|
||||
const id1 = repo.begin({ action: 'ssh.exec', connectionId: 'c1' });
|
||||
const id2 = repo.begin({ action: 'ssh.upload', connectionId: 'c2' });
|
||||
|
||||
const result = reconcileStaleSshAudit(db);
|
||||
expect(result.reconciledCount).toBe(2);
|
||||
expect(result.ids.sort()).toEqual([id1, id2].sort());
|
||||
|
||||
for (const id of [id1, id2]) {
|
||||
const row = repo.getById(id);
|
||||
expect(row?.outcome).toBe('aborted');
|
||||
expect(row?.completedAt).not.toBeNull();
|
||||
expect(row?.detail).toMatchObject({
|
||||
stale_reason: 'orchestrator_restart',
|
||||
});
|
||||
expect(String(row?.detail?.['detail'])).toMatch(/outcome is unknown/);
|
||||
}
|
||||
});
|
||||
|
||||
it('merges stale_reason into existing detail without dropping prior keys', () => {
|
||||
const repo = createAuditRepo(db);
|
||||
const id = repo.begin({
|
||||
action: 'ssh.exec',
|
||||
connectionId: 'c1',
|
||||
detail: { command: 'systemctl restart app', host: 'web-1' },
|
||||
});
|
||||
|
||||
reconcileStaleSshAudit(db);
|
||||
|
||||
const row = repo.getById(id);
|
||||
expect(row?.detail).toMatchObject({
|
||||
command: 'systemctl restart app',
|
||||
host: 'web-1',
|
||||
stale_reason: 'orchestrator_restart',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not touch rows that already reached a terminal outcome', () => {
|
||||
const repo = createAuditRepo(db);
|
||||
const doneId = repo.begin({ action: 'ssh.exec', connectionId: 'c1' });
|
||||
repo.complete(doneId, 'success', { exit_code: 0 });
|
||||
const failedId = repo.beginAndComplete({ action: 'ssh.exec', connectionId: 'c1' }, 'failed');
|
||||
const pendingId = repo.begin({ action: 'ssh.exec', connectionId: 'c1' });
|
||||
|
||||
const result = reconcileStaleSshAudit(db);
|
||||
expect(result.reconciledCount).toBe(1);
|
||||
expect(result.ids).toEqual([pendingId]);
|
||||
|
||||
expect(repo.getById(doneId)?.outcome).toBe('success');
|
||||
expect(repo.getById(doneId)?.detail).toEqual({ exit_code: 0 });
|
||||
expect(repo.getById(failedId)?.outcome).toBe('failed');
|
||||
});
|
||||
|
||||
it('is idempotent — a second run finds nothing to reconcile', () => {
|
||||
const repo = createAuditRepo(db);
|
||||
repo.begin({ action: 'ssh.exec', connectionId: 'c1' });
|
||||
|
||||
expect(reconcileStaleSshAudit(db).reconciledCount).toBe(1);
|
||||
expect(reconcileStaleSshAudit(db)).toEqual({ reconciledCount: 0, ids: [] });
|
||||
});
|
||||
|
||||
it('returns ids in started_at ascending order (oldest first)', () => {
|
||||
const repo = createAuditRepo(db);
|
||||
const newer = repo.begin({
|
||||
action: 'ssh.exec',
|
||||
connectionId: 'c1',
|
||||
startedAt: '2026-06-10T12:00:00.000Z',
|
||||
});
|
||||
const oldest = repo.begin({
|
||||
action: 'ssh.exec',
|
||||
connectionId: 'c1',
|
||||
startedAt: '2026-06-01T00:00:00.000Z',
|
||||
});
|
||||
const middle = repo.begin({
|
||||
action: 'ssh.exec',
|
||||
connectionId: 'c1',
|
||||
startedAt: '2026-06-05T08:30:00.000Z',
|
||||
});
|
||||
|
||||
const result = reconcileStaleSshAudit(db);
|
||||
expect(result.ids).toEqual([oldest, middle, newer]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user