mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 18:32:52 +00:00
5e6e96e405
* feat(mesh): add developer-mode diagnostics to the mesh data plane Add a developer-mode-gated [Mesh:diag] diagnostic log to MeshService, following the existing diagnostic-logging pattern: gated on the shared developer_mode setting (off in production by default) and every value run through sanitizeForLog. The diagnostics cover the forwarder dispatch decision (which self-node resolution won, and whether the route is same-node or cross-node), the entry of the opt-in, opt-out, enable, and disable operations, and completion timing for opt-in, node disable, and the alias-cache refresh. This lets an operator trace a slow or stuck mesh operation from the logs without attaching a debugger. The calls sit only at per-operation, per-accept, and per-refresh cadences, never inside the per-frame byte-relay loops. Add tests covering the developer_mode on/off gating and a guard that a node's api token never reaches a diagnostic log or the activity buffer. * refactor(mesh): redact secret-shaped values in developer-mode diagnostics Run each [Mesh:diag] detail through redactSensitiveText before sanitizeForLog so a future caller cannot leak a Bearer token, JWT, or credentialed URL through a diagnostic line, regardless of which call site emits it. No current call site passes a secret; this is defense in depth. Use a JWT-shaped canary in the no-leak test so it guards the real credential class.
107 lines
4.6 KiB
TypeScript
107 lines
4.6 KiB
TypeScript
/**
|
|
* Developer-mode diagnostics for the mesh data plane.
|
|
*
|
|
* The `[Mesh:diag]` logs are gated on the shared `developer_mode` setting, so
|
|
* they must be silent in production (the default) and appear only when an
|
|
* operator turns developer mode on. They must also never carry a node's
|
|
* api_token: the cross-fleet inspect path runs that token in a Bearer header,
|
|
* and a diagnostic or error log that echoed it would leak a long-lived
|
|
* credential into the log surface.
|
|
*/
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
|
|
|
let tmpDir: string;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
let MeshService: typeof import('../services/MeshService').MeshService;
|
|
|
|
function captureConsole() {
|
|
const lines: string[] = [];
|
|
const record = (...args: unknown[]): void => {
|
|
lines.push(args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '));
|
|
};
|
|
const spies = [
|
|
vi.spyOn(console, 'debug').mockImplementation(record),
|
|
vi.spyOn(console, 'log').mockImplementation(record),
|
|
vi.spyOn(console, 'warn').mockImplementation(record),
|
|
vi.spyOn(console, 'error').mockImplementation(record),
|
|
];
|
|
return { lines, restore: (): void => spies.forEach((s) => s.mockRestore()) };
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
({ MeshService } = await import('../services/MeshService'));
|
|
});
|
|
|
|
beforeEach(() => {
|
|
DatabaseService.getInstance().getDb().prepare('DELETE FROM mesh_stacks').run();
|
|
DatabaseService.getInstance().updateGlobalSetting('developer_mode', '0');
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
DatabaseService.getInstance().updateGlobalSetting('developer_mode', '0');
|
|
});
|
|
|
|
afterAll(() => cleanupTestDb(tmpDir));
|
|
|
|
describe('mesh developer-mode diagnostics', () => {
|
|
it('emits no [Mesh:diag] logs when developer_mode is off', async () => {
|
|
const cap = captureConsole();
|
|
await MeshService.getInstance().refreshAliasCache();
|
|
cap.restore();
|
|
expect(cap.lines.some((l) => l.includes('[Mesh:diag]'))).toBe(false);
|
|
});
|
|
|
|
it('emits the [Mesh:diag] refresh log when developer_mode is on', async () => {
|
|
DatabaseService.getInstance().updateGlobalSetting('developer_mode', '1');
|
|
const cap = captureConsole();
|
|
await MeshService.getInstance().refreshAliasCache();
|
|
cap.restore();
|
|
expect(cap.lines.some((l) => l.includes('[Mesh:diag] alias cache refreshed'))).toBe(true);
|
|
});
|
|
|
|
it('emits a [Mesh:diag] line from a second instrumented method (enable-for-node)', async () => {
|
|
const db = DatabaseService.getInstance();
|
|
const localNodeId = db.getDefaultNode()?.id ?? 1;
|
|
db.updateGlobalSetting('developer_mode', '1');
|
|
const cap = captureConsole();
|
|
// enableForNode on the local node is a pure DB write plus the diag and
|
|
// activity log; the proxy-dial branch only runs for remote proxy nodes.
|
|
await MeshService.getInstance().enableForNode(localNodeId);
|
|
cap.restore();
|
|
expect(cap.lines.some((l) => l.includes('[Mesh:diag] enable-for-node'))).toBe(true);
|
|
});
|
|
|
|
it('never leaks a remote node api token through diagnostics or logs', async () => {
|
|
// A JWT-shaped credential canary, so the assertion guards the actual
|
|
// class of secret (a Bearer/JWT node token) rather than an opaque string.
|
|
const SECRET = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtZXNoLWNhbmFyeSJ9.bWVzaC1jYW5hcnlfc2ln';
|
|
const db = DatabaseService.getInstance();
|
|
const remoteId = db.addNode({
|
|
name: 'obs-remote',
|
|
type: 'remote',
|
|
compose_dir: '',
|
|
is_default: false,
|
|
// Port 1 refuses immediately, so the inspect fetch fails fast without
|
|
// a real peer; the token still travels in the Bearer header.
|
|
api_url: 'http://127.0.0.1:1',
|
|
api_token: SECRET,
|
|
});
|
|
db.insertMeshStack(remoteId, 'obs-stack', 'tester');
|
|
db.updateGlobalSetting('developer_mode', '1');
|
|
|
|
const activity: unknown[] = [];
|
|
const unsub = MeshService.getInstance().subscribeActivity((e) => activity.push(e));
|
|
const cap = captureConsole();
|
|
await MeshService.getInstance().refreshAliasCache();
|
|
cap.restore();
|
|
unsub();
|
|
|
|
const haystack = `${cap.lines.join('\n')}\n${JSON.stringify(activity)}`;
|
|
expect(haystack).not.toContain(SECRET);
|
|
});
|
|
});
|