mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +00:00
c460bb87a8
GET /api/mesh/aliases/:alias/diagnostic returned a cached state derived from the last latency/error maps. Those maps only mutated when someone called POST .../test or when a cross-node connect logged an event, so once an upstream stopped the diagnostic kept reporting "healthy" until the 60 s alias-cache refresh pruned the alias entirely. The GET now calls testUpstream synchronously after the alias-resolved, opted-in, tunnel-up short-circuits. Probe failures land in routeErrorMap via logActivity (cross-node already did this; same-node timeout/error paths now log probe.fail in the same shape), so the state computation flips to "unreachable" on the same request that exposed the stopped upstream. A new routeProbeAtMap stamps freshness and surfaces in the response as lastProbeAt; the route detail sheet renders "Last probe <age> · <ms>" using the existing formatTimeAgo helper.
154 lines
6.7 KiB
TypeScript
154 lines
6.7 KiB
TypeScript
/**
|
|
* Regression guard for the M-12 fix: `MeshService.getRouteDiagnostic` and
|
|
* `MeshService.getStatus` must report `pilotConnected: true` for local nodes
|
|
* instead of asking `PilotTunnelManager.hasActiveTunnel`, which always returns
|
|
* false for local nodes (they do not have pilot tunnels because they do not
|
|
* need them — local mesh traffic uses the same-node fast path). Without this
|
|
* conditional, the route detail sheet renders every local alias as a
|
|
* destructive `tunnel down` pill on a working route.
|
|
*/
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
|
|
|
let tmpDir: string;
|
|
let MeshService: typeof import('../services/MeshService').MeshService;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
let PilotTunnelManager: typeof import('../services/PilotTunnelManager').PilotTunnelManager;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ MeshService } = await import('../services/MeshService'));
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
({ PilotTunnelManager } = await import('../services/PilotTunnelManager'));
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.restoreAllMocks();
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
const svc = MeshService.getInstance() as unknown as {
|
|
aliasCache: Map<string, unknown>;
|
|
aliasByPort: Map<number, unknown>;
|
|
};
|
|
svc.aliasCache = new Map();
|
|
svc.aliasByPort = new Map();
|
|
});
|
|
|
|
describe('MeshService diagnostic — local-node pilot state (M-12)', () => {
|
|
it('reports pilotConnected: true and state: healthy for a local alias even though no pilot tunnel exists', async () => {
|
|
const svc = MeshService.getInstance();
|
|
const db = DatabaseService.getInstance();
|
|
const localNodeId = db.getNodes()[0].id;
|
|
db.insertMeshStack(localNodeId, 'audit-mesh-prod', 'tester');
|
|
|
|
// hasActiveTunnel always returns false for local nodes; the helper
|
|
// must short-circuit that, otherwise the diagnostic flips to
|
|
// 'tunnel down'.
|
|
vi.spyOn(PilotTunnelManager.getInstance(), 'hasActiveTunnel').mockReturnValue(false);
|
|
|
|
// F-11: getRouteDiagnostic now probes synchronously. Stub testUpstream
|
|
// so the unit test does not depend on a live TCP socket on port 9000.
|
|
// The stub writes routeLatencyMap in lockstep with the real probe
|
|
// success path so the state computation below resolves to 'healthy'.
|
|
vi.spyOn(svc, 'testUpstream').mockImplementation(async (alias: string) => {
|
|
(svc as unknown as { routeLatencyMap: Map<string, number> }).routeLatencyMap.set(alias, 5);
|
|
(svc as unknown as { routeProbeAtMap: Map<string, number> }).routeProbeAtMap.set(alias, Date.now());
|
|
return { ok: true, latencyMs: 5 };
|
|
});
|
|
|
|
(svc as unknown as { aliasCache: Map<string, unknown> }).aliasCache = new Map([
|
|
['echo.audit-mesh-prod.local.sencho', {
|
|
host: 'echo.audit-mesh-prod.local.sencho',
|
|
nodeId: localNodeId,
|
|
nodeName: 'local',
|
|
stackName: 'audit-mesh-prod',
|
|
serviceName: 'echo',
|
|
port: 9000,
|
|
}],
|
|
]);
|
|
|
|
const diag = await svc.getRouteDiagnostic('echo.audit-mesh-prod.local.sencho');
|
|
expect(diag.pilot.connected).toBe(true);
|
|
expect(diag.state).toBe('healthy');
|
|
|
|
db.deleteMeshStack(localNodeId, 'audit-mesh-prod');
|
|
});
|
|
|
|
it('reports pilotConnected: false when the alias points to a node that no longer exists', async () => {
|
|
const svc = MeshService.getInstance();
|
|
const db = DatabaseService.getInstance();
|
|
// Create a remote node, register an alias pointing at it, opt the
|
|
// stack in (so the alias is not 'not authorized'), then delete the
|
|
// node row. The diagnostic should fall back to false rather than
|
|
// throw or treat a missing node as reachable.
|
|
const remoteNodeId = db.addNode({
|
|
name: 'm12-orphan',
|
|
type: 'remote',
|
|
mode: 'pilot_agent',
|
|
compose_dir: '/tmp',
|
|
is_default: false,
|
|
api_url: '',
|
|
api_token: '',
|
|
});
|
|
db.insertMeshStack(remoteNodeId, 'audit-mesh-orphan', 'tester');
|
|
(svc as unknown as { aliasCache: Map<string, unknown> }).aliasCache = new Map([
|
|
['echo.audit-mesh-orphan.m12-orphan.sencho', {
|
|
host: 'echo.audit-mesh-orphan.m12-orphan.sencho',
|
|
nodeId: remoteNodeId,
|
|
nodeName: 'm12-orphan',
|
|
stackName: 'audit-mesh-orphan',
|
|
serviceName: 'echo',
|
|
port: 9002,
|
|
}],
|
|
]);
|
|
// Drop the node row but leave the alias cache + mesh_stacks intact
|
|
// (mirrors a race where listMeshStacks ran before the node delete
|
|
// cascade completed). The orphan path is already covered by the
|
|
// initial !node check; this asserts the fallback is false rather
|
|
// than the test-default true.
|
|
db.deleteMeshStack(remoteNodeId, 'audit-mesh-orphan');
|
|
db.deleteNode(remoteNodeId);
|
|
|
|
const diag = await svc.getRouteDiagnostic('echo.audit-mesh-orphan.m12-orphan.sencho');
|
|
expect(diag.pilot.connected).toBe(false);
|
|
});
|
|
|
|
it('still reports pilotConnected: false and state: tunnel down for a remote alias when the tunnel is actually down', async () => {
|
|
const svc = MeshService.getInstance();
|
|
const db = DatabaseService.getInstance();
|
|
const remoteNodeId = db.addNode({
|
|
name: 'm12-remote',
|
|
type: 'remote',
|
|
mode: 'pilot_agent',
|
|
compose_dir: '/tmp',
|
|
is_default: false,
|
|
api_url: '',
|
|
api_token: '',
|
|
});
|
|
db.insertMeshStack(remoteNodeId, 'audit-mesh-pilot', 'tester');
|
|
|
|
vi.spyOn(PilotTunnelManager.getInstance(), 'hasActiveTunnel').mockReturnValue(false);
|
|
|
|
(svc as unknown as { aliasCache: Map<string, unknown> }).aliasCache = new Map([
|
|
['echo.audit-mesh-pilot.m12-remote.sencho', {
|
|
host: 'echo.audit-mesh-pilot.m12-remote.sencho',
|
|
nodeId: remoteNodeId,
|
|
nodeName: 'm12-remote',
|
|
stackName: 'audit-mesh-pilot',
|
|
serviceName: 'echo',
|
|
port: 9001,
|
|
}],
|
|
]);
|
|
|
|
const diag = await svc.getRouteDiagnostic('echo.audit-mesh-pilot.m12-remote.sencho');
|
|
expect(diag.pilot.connected).toBe(false);
|
|
expect(diag.state).toBe('tunnel down');
|
|
|
|
db.deleteMeshStack(remoteNodeId, 'audit-mesh-pilot');
|
|
db.deleteNode(remoteNodeId);
|
|
});
|
|
});
|