From d39e5fb48f3c47827dc5fd8f1bfd9a8d3e2be968 Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:53:31 -0400 Subject: [PATCH] Move DB health to management API (#1757) * Move DB health to management API * Address DB health review feedback --- .../mcp-server/__tests__/dbHealthTool.test.ts | 36 +++++----- open-sse/mcp-server/schemas/tools.ts | 2 +- open-sse/mcp-server/tools/advancedTools.ts | 7 +- src/app/(dashboard)/dashboard/health/page.tsx | 4 +- src/app/api/{v1 => }/db/health/route.ts | 0 tests/e2e/resilience-plan-alignment.spec.ts | 2 +- tests/unit/authz/classify.test.ts | 1 + tests/unit/authz/pipeline.test.ts | 14 ++++ tests/unit/db-health-route.test.ts | 65 ++++++++++--------- 9 files changed, 76 insertions(+), 55 deletions(-) rename src/app/api/{v1 => }/db/health/route.ts (100%) diff --git a/open-sse/mcp-server/__tests__/dbHealthTool.test.ts b/open-sse/mcp-server/__tests__/dbHealthTool.test.ts index 9aa7565ca4..850074a7c5 100644 --- a/open-sse/mcp-server/__tests__/dbHealthTool.test.ts +++ b/open-sse/mcp-server/__tests__/dbHealthTool.test.ts @@ -7,15 +7,30 @@ import { MCP_TOOL_MAP, dbHealthCheckInput } from "../schemas/tools.ts"; const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); +const mockRunManagedDbHealthCheck = vi.hoisted(() => vi.fn()); + vi.mock("../audit.ts", () => ({ logToolCall: vi.fn().mockResolvedValue(undefined), })); +vi.mock("../../../src/lib/db/core.ts", () => ({ + runManagedDbHealthCheck: mockRunManagedDbHealthCheck, +})); + describe("omniroute_db_health_check MCP tool", () => { let client: Client; beforeEach(async () => { mockFetch.mockReset(); + mockRunManagedDbHealthCheck.mockReset(); + mockRunManagedDbHealthCheck.mockReturnValue({ + isHealthy: false, + issues: [{ type: "broken_reference", table: "combos", description: "broken", count: 1 }], + repairedCount: 1, + backupCreated: true, + autoRepair: true, + checkedAt: new Date().toISOString(), + }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const server = createMcpServer(); await server.connect(serverTransport); @@ -38,32 +53,19 @@ describe("omniroute_db_health_check MCP tool", () => { expect(dbHealthCheckInput.safeParse({ autoRepair: "yes" }).success).toBe(false); }); - it("dispatches to /api/v1/db/health using POST when autoRepair=true", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - isHealthy: false, - issues: [{ type: "broken_reference", table: "combos", description: "broken", count: 1 }], - repairedCount: 1, - backupCreated: true, - autoRepair: true, - checkedAt: new Date().toISOString(), - }), - }); - + it("runs the database repair flow directly when autoRepair=true", async () => { const result = await client.callTool({ name: "omniroute_db_health_check", arguments: { autoRepair: true }, }); expect(result.isError).toBeFalsy(); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/db/health"), - expect.objectContaining({ method: "POST" }) - ); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockRunManagedDbHealthCheck).toHaveBeenCalledWith({ autoRepair: true }); const content = result.content[0] as { type: string; text: string }; const payload = JSON.parse(content.text); + expect(payload.autoRepair).toBe(true); expect(payload.repairedCount).toBe(1); expect(payload.backupCreated).toBe(true); }); diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index 85492cde97..047d877f79 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -882,7 +882,7 @@ export const dbHealthCheckTool: McpToolDefinition< scopes: ["read:health", "write:resilience"], auditLevel: "full", phase: 2, - sourceEndpoints: ["/api/v1/db/health"], + sourceEndpoints: ["/api/db/health"], }; // --- Tool 19: omniroute_sync_pricing --- diff --git a/open-sse/mcp-server/tools/advancedTools.ts b/open-sse/mcp-server/tools/advancedTools.ts index e9918f491b..9769244f88 100644 --- a/open-sse/mcp-server/tools/advancedTools.ts +++ b/open-sse/mcp-server/tools/advancedTools.ts @@ -893,11 +893,8 @@ export async function handleDbHealthCheck(args: { autoRepair?: boolean }) { const autoRepair = args.autoRepair === true; try { - const result = toRecord( - await apiFetch("/api/v1/db/health", { - method: autoRepair ? "POST" : "GET", - }) - ); + const { runManagedDbHealthCheck } = await import("../../../src/lib/db/core.ts"); + const result = runManagedDbHealthCheck({ autoRepair }); await logToolCall( "omniroute_db_health_check", diff --git a/src/app/(dashboard)/dashboard/health/page.tsx b/src/app/(dashboard)/dashboard/health/page.tsx index 5dad4cd35f..d4af48798e 100644 --- a/src/app/(dashboard)/dashboard/health/page.tsx +++ b/src/app/(dashboard)/dashboard/health/page.tsx @@ -81,7 +81,7 @@ export default function HealthPage() { const fetchDbHealth = useCallback(async () => { try { - const res = await fetch("/api/v1/db/health"); + const res = await fetch("/api/db/health"); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); setDbHealth(json); @@ -138,7 +138,7 @@ export default function HealthPage() { const handleRepairDb = async () => { setRepairingDb(true); try { - const res = await fetch("/api/v1/db/health", { method: "POST" }); + const res = await fetch("/api/db/health", { method: "POST" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); setDbHealth(json); diff --git a/src/app/api/v1/db/health/route.ts b/src/app/api/db/health/route.ts similarity index 100% rename from src/app/api/v1/db/health/route.ts rename to src/app/api/db/health/route.ts diff --git a/tests/e2e/resilience-plan-alignment.spec.ts b/tests/e2e/resilience-plan-alignment.spec.ts index 374830841b..18a0097c4f 100644 --- a/tests/e2e/resilience-plan-alignment.spec.ts +++ b/tests/e2e/resilience-plan-alignment.spec.ts @@ -167,7 +167,7 @@ async function mockHealthPageApis(page: Page) { }); }); - await page.route("**/api/v1/db/health", async (route) => { + await page.route("**/api/db/health", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", diff --git a/tests/unit/authz/classify.test.ts b/tests/unit/authz/classify.test.ts index 3811697050..7f6021992e 100644 --- a/tests/unit/authz/classify.test.ts +++ b/tests/unit/authz/classify.test.ts @@ -153,6 +153,7 @@ const cases: Case[] = [ expectedClass: "MANAGEMENT", }, { name: "/api/keys MANAGEMENT", path: "/api/keys", expectedClass: "MANAGEMENT" }, + { name: "/api/db/health MANAGEMENT", path: "/api/db/health", expectedClass: "MANAGEMENT" }, { name: "/api/settings MANAGEMENT", path: "/api/settings", expectedClass: "MANAGEMENT" }, { name: "/api/audit MANAGEMENT", path: "/api/audit", expectedClass: "MANAGEMENT" }, diff --git a/tests/unit/authz/pipeline.test.ts b/tests/unit/authz/pipeline.test.ts index 2b6e81c8c3..a2ee225445 100644 --- a/tests/unit/authz/pipeline.test.ts +++ b/tests/unit/authz/pipeline.test.ts @@ -174,6 +174,20 @@ test("runAuthzPipeline allows dashboard sessions to read model catalog aliases", assert.equal(response.headers.get("x-omniroute-route-class"), "CLIENT_API"); }); +test("runAuthzPipeline allows dashboard sessions to reach DB health management API", async () => { + await forceAuthRequired(); + + const response = await pipeline.runAuthzPipeline( + request("http://localhost/api/db/health", { + headers: { cookie: await dashboardCookie() }, + }), + { enforce: true } + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT"); +}); + test("runAuthzPipeline refreshes dashboard JWTs near expiry", async () => { await forceAuthRequired(); const secret = new TextEncoder().encode(process.env.JWT_SECRET); diff --git a/tests/unit/db-health-route.test.ts b/tests/unit/db-health-route.test.ts index fa8c136e20..8b36871b88 100644 --- a/tests/unit/db-health-route.test.ts +++ b/tests/unit/db-health-route.test.ts @@ -3,29 +3,44 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { SignJWT } from "jose"; const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-health-route-")); process.env.DATA_DIR = TEST_DATA_DIR; process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "task-303-api-key-secret"; const core = await import("../../src/lib/db/core.ts"); -const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); -const routeModule = await import("../../src/app/api/v1/db/health/route.ts"); +const routeModule = await import("../../src/app/api/db/health/route.ts"); + +const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET; +const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD; +const TEST_JWT_SECRET = "db-health-route-jwt-secret"; +const TEST_INITIAL_PASSWORD = "db-health-route-password"; async function resetStorage() { core.resetDbInstance(); - apiKeysDb.resetApiKeyState(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + process.env.JWT_SECRET = TEST_JWT_SECRET; + process.env.INITIAL_PASSWORD = TEST_INITIAL_PASSWORD; } -function makeRequest(method, token) { - return new Request("http://localhost/api/v1/db/health", { +function makeRequest(method, cookie) { + return new Request("http://localhost/api/db/health", { method, - headers: token ? { Authorization: `Bearer ${token}` } : {}, + headers: cookie ? { cookie } : {}, }); } +async function dashboardCookie() { + const secret = new TextEncoder().encode(TEST_JWT_SECRET); + const token = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1h") + .sign(secret); + return `auth_token=${token}`; +} + function insertBrokenRows(db) { db.prepare( `INSERT INTO quota_snapshots @@ -43,35 +58,27 @@ test.beforeEach(async () => { test.after(async () => { core.resetDbInstance(); - apiKeysDb.resetApiKeyState(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_JWT_SECRET === undefined) delete process.env.JWT_SECRET; + else process.env.JWT_SECRET = ORIGINAL_JWT_SECRET; + if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD; + else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD; }); -test("GET /api/v1/db/health requires authentication", async () => { - const previousInitialPassword = process.env.INITIAL_PASSWORD; - process.env.INITIAL_PASSWORD = "route-health-auth"; +test("GET /api/db/health requires authentication", async () => { + const response = await routeModule.GET(makeRequest("GET")); + const body = (await response.json()) as any; - try { - const response = await routeModule.GET(makeRequest("GET")); - const body = (await response.json()) as any; - - assert.equal(response.status, 401); - assert.equal(body.error.message, "Authentication required"); - } finally { - if (previousInitialPassword === undefined) { - delete process.env.INITIAL_PASSWORD; - } else { - process.env.INITIAL_PASSWORD = previousInitialPassword; - } - } + assert.equal(response.status, 401); + assert.equal(body.error.message, "Authentication required"); }); -test("GET /api/v1/db/health diagnoses without mutating database rows", async () => { - const authKey = await apiKeysDb.createApiKey("Health Route", "machine-route-health"); +test("GET /api/db/health diagnoses without mutating database rows", async () => { + const cookie = await dashboardCookie(); const db = core.getDbInstance(); insertBrokenRows(db); - const response = await routeModule.GET(makeRequest("GET", authKey.key)); + const response = await routeModule.GET(makeRequest("GET", cookie)); const body = (await response.json()) as any; assert.equal(response.status, 200); @@ -81,12 +88,12 @@ test("GET /api/v1/db/health diagnoses without mutating database rows", async () assert.equal((db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get() as any).count, 1); }); -test("POST /api/v1/db/health repairs broken rows for authenticated callers", async () => { - const authKey = await apiKeysDb.createApiKey("Health Route", "machine-route-health"); +test("POST /api/db/health repairs broken rows for authenticated callers", async () => { + const cookie = await dashboardCookie(); const db = core.getDbInstance(); insertBrokenRows(db); - const response = await routeModule.POST(makeRequest("POST", authKey.key)); + const response = await routeModule.POST(makeRequest("POST", cookie)); const body = (await response.json()) as any; assert.equal(response.status, 200);