mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 05:02:15 +03:00
* feat(dashboard): add RADAR_ENABLED flag (default off) * feat(db): radar feed cache + settings with encrypted supporter key * feat(radar): signed feed sync with pinned key and version floor - feedSchema.ts: Zod v4 schema mirroring the server feed format (discriminated union on budget.kind, enum constraints, etc.) - pinnedKeys.ts: Ed25519 SPKI-DER pinned key + env override for forks - verify.ts: signature verification over exact wire bytes, never throws - sync.ts: full download/verify/validate/cache pipeline with injectable deps, feature-flag gate, opt-in gate, version floor (numeric compare), and sanitized error reasons (no stack traces) - 40 tests covering: contract hash, key handling, sig verification, schema validation, version compare, all sync paths (disabled, opt_out, invalid_signature, invalid_schema, stale, updated, error), auth header injection, and cache-untouched assertions for every failure mode * feat(radar): read-time overlay merge rules over the free catalog Pure function applyFeed() merges the cached Radar feed over the static baseline catalog at read time, honoring 4 rules: 1. Feed never overwrites a local override field. 2. enabled:false disables the entry with disabledBy:"radar" provenance. 3. User-added entry NOT in the feed survives untouched. 4. User deletion tombstone prevents feed resurrection. getRadarCatalog() accessor in index.ts: flag off / no cache / corrupt payload all fall back to baseline. Valid cache applies the overlay and returns feed metadata (version, tier, fetchedAt). TDD: 19 tests (4 rules + dedup + origin + accessor flag/cache/corrupt/ valid/bad-feed + baselineToMergedEntries converter). * feat(dashboard): radar catalog and guided setup screens - API routes: GET /api/radar/catalog, POST /api/radar/sync, POST /api/radar/settings - All gated on RADAR_ENABLED flag (404 when off) - Error responses via buildErrorBody(), never raw stack/message - Settings never echoes clear supporter key (masked omr_****<last4>) - Sync delegates to syncRadar() server-side, never proxies feed URL - Dashboard pages: - /dashboard/radar: 4 states (flag off, opt-in pending, empty, populated) - /dashboard/radar/setup?provider=X: guided setup with steps, key URL, test connection - Uses existing Card component and next-intl patterns - Sidebar: radar entry in costs group with icon - i18n: pt-BR and en keys for radarPage and radarSetupPage namespaces - Tests: - radar-api-routes.test.ts: 11 tests (flag-off 404, flag-on shape, error sanitization) - radar-page-state.test.ts: 5 tests (pure state logic) - All 90 radar tests pass (including prior 74) * docs(radar): module doc and flag-off inertia test Add docs/frameworks/RADAR.md covering the flag gate, the separate data-sync opt-in and privacy promise, the Ed25519 signature/pinned-key security model, tiers, the read-time overlay merge rules, and the self-hosting env vars — plus index entries in CLAUDE.md/AGENTS.md/docs/README.md/REPOSITORY_MAP.md. Document RADAR_FEED_URL and RADAR_FEED_PUBKEY in .env.example and docs/reference/ENVIRONMENT.md to satisfy check:env-doc-sync, which was failing on this branch since the sync.ts commit added the reads. Add tests/unit/radar-inertia.test.ts as the single canonical place asserting the "RADAR_ENABLED off => zero behavioral delta" claim end to end: the three /api/radar/* routes 404, the flag resolves to the definition default with no override, getRadarCatalog() returns exactly the baseline without touching the cache, and computeFreeModelTotals() keeps its pinned values with the Radar module imported alongside it. * fix(db): renumber radar migration to 135 after collision with 134 The base branch introduced 134_proxy_logs_egress_ip while this branch carried 134_radar_cache_settings; the migration runner rejects duplicate numeric prefixes. This migration has never been applied to a real database (the PR is unmerged), so no retroactive isSchemaAlreadyApplied guard is needed. * i18n(radar): translate radar catalog and setup strings to all locales The UI-coverage ratchet measures (present - placeholder) / total_en, so the __MISSING__ sentinels that i18n:sync-ui writes do not count as covered — only real translations restore the metric. Scoped to this PR's namespaces (radarPage, radarSetupPage, sidebar.radar*) instead of a bulk sync, which would have pulled ~978 unrelated pending keys into this diff. Placeholders and code identifiers verified preserved across all 1682 strings. * fix(radar): trust the served-tier header instead of the signed body field The signed feed body always carries tier:"live" by design (one signed artifact per version — rewriting the field server-side per request would break the exact-bytes Ed25519 signature). The server now returns the tier ACTUALLY served via the x-omniroute-feed-tier response header, so free users on a delayed community snapshot no longer see "Ao vivo (tempo real)" in the UI. sync.ts now reads and validates that header (falling back to the body's tier only when the header is absent or holds an unrecognized value) and stores the served tier in the cache; index.ts already surfaces cache.tier to the UI unchanged. * test(combo): shorten an assert message that exceeded the line limit The assertion added by #9507 was 104 chars, so prettier reformatted it into five lines on the next commit that touched the file, pushing it past its frozen size (3449) and failing check:file-size. The message is shortened (the issue reference stays in the comment directly above); the assertion itself is unchanged, and the file is back to 3448 lines and prettier-clean. * i18n(radar): use the canonical zh-TW glossary terms The machine translation produced retired renderings the glossary gate blocks: 供應商 for provider (canonical 提供者) and 文檔 for documentation (canonical 文件). Fixed across the 11 affected radar strings; tests/unit/i18n-glossary-consistency-check.test.ts is back to 17/17. * fix(radar): point the default feed URL at the domain that exists radar.omniroute.dev was a placeholder for a domain that was never registered, so an out-of-the-box sync would fail DNS resolution for every user. The live feed is served from radar.omniroute.online (the subdomain the design always specified), now behind Cloudflare TLS. Forks still override it via RADAR_FEED_URL. --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
290 lines
12 KiB
TypeScript
290 lines
12 KiB
TypeScript
/**
|
|
* Integration tests: AgentBridge REST routes — happy paths + LOCAL_ONLY + Zod 400
|
|
*
|
|
* Covers:
|
|
* - GET /api/tools/agent-bridge/state
|
|
* - POST /api/tools/agent-bridge/server (invalid body → 400)
|
|
* - GET /api/tools/agent-bridge/agents
|
|
* - GET /api/tools/agent-bridge/agents/[id]
|
|
* - PATCH /api/tools/agent-bridge/agents/[id] (setup_completed)
|
|
* - GET /api/tools/agent-bridge/agents/[id]/detect
|
|
* - GET /api/tools/agent-bridge/upstream-ca
|
|
* - POST /api/tools/agent-bridge/upstream-ca (path validation)
|
|
*
|
|
* LOCAL_ONLY enforcement: request with non-loopback Host header → 403
|
|
* (tested via routeGuard helper)
|
|
*/
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ab-routes-"));
|
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
|
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
|
|
|
// Import db core first to allow reset
|
|
const core = await import("../../src/lib/db/core.ts");
|
|
|
|
// Import routes under test
|
|
const stateRoute = await import("../../src/app/api/tools/agent-bridge/state/route.ts");
|
|
const serverRoute = await import("../../src/app/api/tools/agent-bridge/server/route.ts");
|
|
const agentsRoute = await import("../../src/app/api/tools/agent-bridge/agents/route.ts");
|
|
const agentIdRoute = await import("../../src/app/api/tools/agent-bridge/agents/[id]/route.ts");
|
|
const detectRoute =
|
|
await import("../../src/app/api/tools/agent-bridge/agents/[id]/detect/route.ts");
|
|
const upstreamCaRoute = await import("../../src/app/api/tools/agent-bridge/upstream-ca/route.ts");
|
|
const routeGuard = await import("../../src/server/authz/routeGuard.ts");
|
|
|
|
function resetDb() {
|
|
core.resetDbInstance();
|
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
}
|
|
|
|
test.beforeEach(() => {
|
|
resetDb();
|
|
});
|
|
|
|
test.after(() => {
|
|
try {
|
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
});
|
|
|
|
// ── routeGuard classification ──────────────────────────────────────────────
|
|
|
|
test("routeGuard: /api/tools/agent-bridge/ is LOCAL_ONLY", () => {
|
|
assert.equal(routeGuard.isLocalOnlyPath("/api/tools/agent-bridge/"), true);
|
|
assert.equal(routeGuard.isLocalOnlyPath("/api/tools/agent-bridge/state"), true);
|
|
assert.equal(routeGuard.isLocalOnlyPath("/api/tools/agent-bridge/agents"), true);
|
|
});
|
|
|
|
test("routeGuard: /api/tools/agent-bridge/ is SPAWN_CAPABLE", () => {
|
|
const { SPAWN_CAPABLE_PREFIXES } = routeGuard;
|
|
const found = (SPAWN_CAPABLE_PREFIXES as ReadonlyArray<string>).some(
|
|
(p) => p === "/api/tools/agent-bridge/"
|
|
);
|
|
assert.equal(found, true, "Expected /api/tools/agent-bridge/ in SPAWN_CAPABLE_PREFIXES");
|
|
});
|
|
|
|
// ── GET /state ─────────────────────────────────────────────────────────────
|
|
|
|
test("GET /state: returns both legacy (server/agents) and new (serverState/agentStates) keys (#8656)", async () => {
|
|
const res = await stateRoute.GET();
|
|
assert.equal(res.status, 200);
|
|
const body = (await res.json()) as Record<string, unknown>;
|
|
|
|
// Legacy keys (integration test + settings/mitm depend on these)
|
|
assert.ok("server" in body, "body.server missing — breaks backward compat");
|
|
assert.ok("agents" in body, "body.agents missing");
|
|
assert.ok(Array.isArray(body.agents), "agents should be array");
|
|
|
|
// New keys (#8656 fix — what UI actually reads)
|
|
assert.ok("serverState" in body, "body.serverState missing");
|
|
assert.ok("agentStates" in body, "body.agentStates missing");
|
|
assert.ok(Array.isArray(body.agentStates), "agentStates should be array");
|
|
assert.ok("bypassPatterns" in body, "body.bypassPatterns missing");
|
|
assert.ok(Array.isArray(body.bypassPatterns), "bypassPatterns should be array");
|
|
assert.ok("mappings" in body, "body.mappings missing");
|
|
assert.equal(typeof body.mappings, "object", "mappings should be object");
|
|
});
|
|
|
|
test("GET /state: error responses do not leak stack traces", async () => {
|
|
// Routine GET — should always succeed in test env; just verify if it errors it's clean
|
|
const res = await stateRoute.GET();
|
|
const text = await res.text();
|
|
assert.ok(!text.includes("at /"), "stack trace leaked in GET /state response");
|
|
});
|
|
|
|
// ── POST /server (Zod validation) ─────────────────────────────────────────
|
|
|
|
test("POST /server: invalid body returns 400", async () => {
|
|
const res = await serverRoute.POST(
|
|
new Request("http://localhost/api/tools/agent-bridge/server", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ action: "invalid-action" }),
|
|
})
|
|
);
|
|
assert.equal(res.status, 400);
|
|
const body = (await res.json()) as Record<string, unknown>;
|
|
assert.ok("error" in body);
|
|
const errMsg = (body.error as Record<string, unknown>)?.message as string;
|
|
assert.ok(typeof errMsg === "string");
|
|
assert.ok(!errMsg.includes("at /"), "stack trace leaked in 400 error message");
|
|
});
|
|
|
|
test("POST /server: missing body returns 400", async () => {
|
|
const res = await serverRoute.POST(
|
|
new Request("http://localhost/api/tools/agent-bridge/server", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: "not-json",
|
|
})
|
|
);
|
|
assert.equal(res.status, 400);
|
|
});
|
|
|
|
// ── GET /agents ────────────────────────────────────────────────────────────
|
|
|
|
test("GET /agents: returns agents array with expected shape", async () => {
|
|
const res = await agentsRoute.GET();
|
|
assert.equal(res.status, 200);
|
|
const body = (await res.json()) as { agents: unknown[] };
|
|
assert.ok(Array.isArray(body.agents));
|
|
assert.ok(body.agents.length >= 9, `Expected ≥9 agents, got ${body.agents.length}`);
|
|
const first = body.agents[0] as Record<string, unknown>;
|
|
assert.ok("id" in first);
|
|
assert.ok("name" in first);
|
|
assert.ok("hosts" in first);
|
|
assert.ok("viability" in first);
|
|
assert.ok("state" in first);
|
|
});
|
|
|
|
// ── GET /agents/[id] ───────────────────────────────────────────────────────
|
|
|
|
test("GET /agents/[id]: returns 404 for unknown id", async () => {
|
|
const res = await agentIdRoute.GET(new Request("http://localhost/"), {
|
|
params: { id: "nonexistent-agent" },
|
|
});
|
|
assert.equal(res.status, 404);
|
|
const body = (await res.json()) as Record<string, unknown>;
|
|
const errMsg = (body.error as Record<string, unknown>)?.message as string;
|
|
assert.ok(!errMsg.includes("at /"), "stack trace leaked in 404 message");
|
|
});
|
|
|
|
test("GET /agents/[id]: returns agent detail for 'copilot'", async () => {
|
|
const res = await agentIdRoute.GET(new Request("http://localhost/"), {
|
|
params: { id: "copilot" },
|
|
});
|
|
// resolveTarget searches by hostname, not agent id directly; 'copilot' may return 404
|
|
// if resolveTarget doesn't match by id. In current implementation resolveTarget checks hosts.
|
|
// Acceptable: either 200 with agent or 404 — but NOT a 500.
|
|
assert.ok(res.status === 200 || res.status === 404, `Unexpected status: ${res.status}`);
|
|
if (res.status === 200) {
|
|
const body = (await res.json()) as Record<string, unknown>;
|
|
assert.ok("detection" in body);
|
|
}
|
|
});
|
|
|
|
// ── PATCH /agents/[id] ────────────────────────────────────────────────────
|
|
|
|
test("PATCH /agents/[id]: invalid body returns 400", async () => {
|
|
const res = await agentIdRoute.PATCH(
|
|
new Request("http://localhost/", {
|
|
method: "PATCH",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ setup_completed: "not-a-boolean" }),
|
|
}),
|
|
{ params: { id: "antigravity" } }
|
|
);
|
|
assert.equal(res.status, 400);
|
|
const body = (await res.json()) as Record<string, unknown>;
|
|
const errMsg = (body.error as Record<string, unknown>)?.message as string;
|
|
assert.ok(!errMsg.includes("at /"), "stack trace in 400 error");
|
|
});
|
|
|
|
test("PATCH /agents/[id]: valid body persists setup_completed", async () => {
|
|
const res = await agentIdRoute.PATCH(
|
|
new Request("http://localhost/", {
|
|
method: "PATCH",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ setup_completed: true }),
|
|
}),
|
|
{ params: { id: "antigravity" } }
|
|
);
|
|
assert.equal(res.status, 200);
|
|
const body = (await res.json()) as Record<string, unknown>;
|
|
assert.equal((body as Record<string, unknown>).ok, true);
|
|
});
|
|
|
|
// ── GET /agents/[id]/detect ────────────────────────────────────────────────
|
|
|
|
test("GET /detect: returns installed:false for unknown id", async () => {
|
|
const res = await detectRoute.GET(new Request("http://localhost/"), {
|
|
params: { id: "unknown-agent-xyz" },
|
|
});
|
|
assert.equal(res.status, 404);
|
|
});
|
|
|
|
test("GET /detect: returns detection result for valid id", async () => {
|
|
const res = await detectRoute.GET(new Request("http://localhost/"), {
|
|
params: { id: "copilot" },
|
|
});
|
|
assert.equal(res.status, 200);
|
|
const body = (await res.json()) as Record<string, unknown>;
|
|
assert.ok("installed" in body);
|
|
assert.ok(typeof body.installed === "boolean");
|
|
});
|
|
|
|
test("GET /detect: error response does not leak stack trace", async () => {
|
|
const res = await detectRoute.GET(new Request("http://localhost/"), {
|
|
params: { id: "unknown-id-test" },
|
|
});
|
|
const text = await res.text();
|
|
assert.ok(!text.includes("at /"), "stack trace leaked in detect response");
|
|
});
|
|
|
|
// ── GET + POST /upstream-ca ────────────────────────────────────────────────
|
|
|
|
test("GET /upstream-ca: returns null when not configured", async () => {
|
|
delete process.env.AGENTBRIDGE_UPSTREAM_CA_CERT;
|
|
const res = await upstreamCaRoute.GET();
|
|
assert.equal(res.status, 200);
|
|
const body = (await res.json()) as { path: string | null };
|
|
// path is either null or whatever AGENTBRIDGE_UPSTREAM_CA_CERT is set to
|
|
assert.ok(body.path === null || typeof body.path === "string");
|
|
});
|
|
|
|
test("POST /upstream-ca: non-existent file returns 400", async () => {
|
|
const res = await upstreamCaRoute.POST(
|
|
new Request("http://localhost/", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ path: "/nonexistent/path/ca.pem" }),
|
|
})
|
|
);
|
|
assert.equal(res.status, 400);
|
|
const body = (await res.json()) as Record<string, unknown>;
|
|
const errMsg = (body.error as Record<string, unknown>)?.message as string;
|
|
assert.ok(!errMsg.includes("at /"), "stack trace leaked in 400 body");
|
|
});
|
|
|
|
test("POST /upstream-ca: valid file persists path", async () => {
|
|
// Create a temp PEM file
|
|
const tmpFile = path.join(TEST_DATA_DIR, "test-ca.pem");
|
|
fs.writeFileSync(tmpFile, "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n");
|
|
|
|
const res = await upstreamCaRoute.POST(
|
|
new Request("http://localhost/", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ path: tmpFile }),
|
|
})
|
|
);
|
|
assert.equal(res.status, 200);
|
|
const body = (await res.json()) as Record<string, unknown>;
|
|
assert.equal(body.ok, true);
|
|
assert.equal(body.path, tmpFile);
|
|
|
|
// Verify GET returns the stored path
|
|
const getRes = await upstreamCaRoute.GET();
|
|
const getBody = (await getRes.json()) as { path: string | null };
|
|
assert.equal(getBody.path, tmpFile);
|
|
});
|
|
|
|
test("POST /upstream-ca: invalid JSON returns 400", async () => {
|
|
const res = await upstreamCaRoute.POST(
|
|
new Request("http://localhost/", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: "not-json",
|
|
})
|
|
);
|
|
assert.equal(res.status, 400);
|
|
});
|