diff --git a/CHANGELOG.md b/CHANGELOG.md index 843c901fe1..9875a3d6cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### ✨ New Features - **feat(api):** add `/v1/ocr` endpoint (Mistral OCR), an OCR provider category, and Mistral moderation support. (thanks @waguriagentic) +- **Discovery tool (Phase 2):** add the `discoveryResults` DB module (CRUD over the `discovery_results` table, migration 074) and wire the opt-in provider-discovery service to persist and read findings through it (`persistDiscoveryResult`, `getDiscoveryResults`, `getDiscoveryResultById`, `markVerified`, `deleteDiscoveryResult`) with `(provider, method, endpoint)` upsert de-duplication. Adds the `/api/discovery/*` HTTP surface β€” `GET /results`, `GET|DELETE /results/:id`, `POST /scan`, `POST /verify/:id` β€” under **strict loopback-only** authorization (`/api/discovery/` is in `LOCAL_ONLY_API_PREFIXES` and is NOT manage-scope-bypassable, so the `scan` route's outbound probes can never be reached from a tunnel/remote origin). Adds a **dashboard UI tab** (Tools β†’ Discovery, `/dashboard/discovery`) to run scans and review, verify, or delete findings. The service stays **opt-in / default-off**. ### πŸ”§ Bug Fixes diff --git a/config/quality/complexity-baseline.json b/config/quality/complexity-baseline.json index 4114fd995a..32ad91a237 100644 --- a/config/quality/complexity-baseline.json +++ b/config/quality/complexity-baseline.json @@ -1,6 +1,7 @@ { "_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.", - "count": 1995, + "count": 2003, + "_rebaseline_2026_07_02_v3844_merge_burst": "1995->2003 (+8). Inherited v3.8.44 cycle drift surfaced by PR #5939: check:complexity measures 2003 on BOTH the pristine release tip (3a3d618fe) and this PR's merged HEAD β€” identical, so all +8 came from the 2026-07-02 merge burst into release/v3.8.44 (#5933 codex schema, #5950 OCR, #5904/#5920 combo, #6000/#6008 executor refactors, etc.) merged while the fast-gates queue was base-red (file-size #5933). PR #5939 itself was verified complexity-net-zero during its own CI cycle (DiscoveryPageClient refactored into hooks/sub-components to stay under max-lines-per-function). Tighten via --update next cycle.", "_rebaseline_2026_07_02_5798_release_green": "1982->1995 (+13). Inherited v3.8.43 cycle drift surfaced by the release-green unblock #5798 / PR #5896: check:complexity measures 1995 on BOTH the pristine release tip (0d3875a98) and this PR's HEAD β€” identical, so all +13 came from the 2026-07-01/02 merge burst (providers/usage/dashboard fixes merged via --admin while the fast-gates queue was base-red). This PR touches only gate scripts, docs, baselines and test files β€” 0 production logic. Tighten via --update next cycle.", "_rebaseline_2026_07_01_v3843_release": "1981->1982 (+1). v3.8.43 cycle drift, surfaced after check:mutation-test-coverage was fixed (it was masked behind that earlier step in the Fast Quality Gates chain). 1982 = the value measured by check:complexity on BOTH fce85136c (release tip) and 6d7060e21 (release + the 5 CI fixes) β€” identical, so all +1 is inherited cycle drift; the fixes touch only test files + linkify.ts safeHttpHref (cyclomatic ~4, well under the >=15 threshold, 0 new violations) + config JSON. Tighten via --update next cycle.", "_rebaseline_2026_06_28_v3840_5237_reconcile": "1980->1981 (+1). Inherited release/v3.8.40 drift surfaced while merging PR #5237 (impersonation-UA refresh) β€” the +1 is present on the pristine release tip (d8a392a47) WITHOUT #5237's changes, so it is #5222 (antigravity fallback-LRU retry) / #5221 (command-code) growth that merged via --admin without ratcheting complexity (the PR->release fast-gates do not run check:complexity). #5237 itself is complexity-net-zero: its only edits are a single UA constant, a regenerated golden snapshot, and baseline JSONs. Structural reduction tracked in #3501.", diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 9dfc596289..8c9420bada 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -315,7 +315,7 @@ "tests/unit/usage-service-hardening.test.ts": 1633, "tests/unit/vscode-token-routes.test.ts": 1212, "tests/unit/combo-config.test.ts": 881, - "_rebaseline_2026_07_02_5928_base_red": "web-cookie-providers-new.test.ts 845->850: #5928 (test(security) Kimi Web URL host parse, CodeQL #689) grew the file +5 lines and merged into release/v3.8.44 WITHOUT rebaselining, leaving a fast-gates base-red that blocked every subsequent PR->release. Test growth is legitimate (a security regression test); maintainer absorbs the drift here. Frozen at 850.", + "_rebaseline_2026_07_02_5928_base_red": "web-cookie-providers-new.test.ts 845->850: #5928 (test(security) Kimi Web URL host parse, CodeQL #689) grew the file +5 lines and merged into release/v3.8.44 WITHOUT rebaselining, leaving a fast-gates base-red that blocked every subsequent PR->release. Test growth is legitimate (a security regression test); maintainer absorbs the drift here. Frozen at 850.", "tests/unit/web-cookie-providers-new.test.ts": 850, "tests/unit/response-sanitizer.test.ts": 906 }, diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index e85d042676..2539de5648 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -114,7 +114,8 @@ "_rebaseline_2026_06_26_v3837_release": "343->345. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits β€” provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines β€” 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle." }, "cognitiveComplexity": { - "value": 856, + "value": 859, + "_rebaseline_2026_07_02_v3844_merge_burst": "856->859 (+3). Inherited v3.8.44 cycle drift surfaced by PR #5939: check:cognitive-complexity measures 859 on BOTH the pristine release tip (3a3d618fe) and this PR's merged HEAD β€” identical, so the PR is cognitive-net-zero (its DiscoveryPageClient was refactored into hooks/sub-components during its own CI cycle). Drift from the 2026-07-02 merge burst into release/v3.8.44. Tighten via --update next cycle.", "_rebaseline_2026_07_02_5798_release_green": "845->856 (+11). Inherited v3.8.43 cycle drift surfaced by the release-green unblock #5798 / PR #5896: check:cognitive-complexity measures 856 on BOTH the pristine release tip (0d3875a98) and this PR's HEAD β€” identical, so the PR is cognitive-net-zero (it touches only gate scripts, docs, baselines and test files). Drift from the 2026-07-01/02 merge burst. Tighten via --update next cycle.", "direction": "down", "_rebaseline_2026_07_01_v3843_release": "842->845 (+3). v3.8.43 cycle drift, surfaced after check:mutation-test-coverage was fixed (masked behind it in the Fast Quality Gates chain). 845 = measured by check:cognitive-complexity on BOTH fce85136c and 6d7060e21 (identical) β€” all +3 is inherited cycle drift; the 5 CI fixes add 0 (safeHttpHref cognitive ~2, under the 15 threshold). Tighten via --update next cycle.", diff --git a/src/app/(dashboard)/dashboard/discovery/DiscoveryPageClient.tsx b/src/app/(dashboard)/dashboard/discovery/DiscoveryPageClient.tsx new file mode 100644 index 0000000000..72187a0ee4 --- /dev/null +++ b/src/app/(dashboard)/dashboard/discovery/DiscoveryPageClient.tsx @@ -0,0 +1,329 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Card, Button, Input, Badge, EmptyState, Spinner, ConfirmModal } from "@/shared/components"; + +interface DiscoveryResult { + id: number; + providerId: string; + method: string; + endpoint?: string | null; + authType: string; + models?: string[]; + rateLimit?: string | null; + feasibility: number; + riskLevel: string; + status: string; + notes?: string | null; + discoveredAt?: string; + verifiedAt?: string | null; +} + +type Feedback = { type: "success" | "error"; message: string } | null; +type Translate = ReturnType; + +type BadgeVariant = "default" | "success" | "warning" | "error"; + +const RISK_VARIANT: Record = { + none: "success", + low: "success", + medium: "warning", + high: "error", + critical: "error", +}; + +const STATUS_VARIANT: Record = { + verified: "success", + testing: "warning", + pending: "default", + rejected: "error", +}; + +/** Run a fetch, surface a localized error via `setFeedback`, and report success. */ +async function callApi( + fn: () => Promise, + t: Translate, + failKey: string, + setFeedback: (f: Feedback) => void +): Promise { + setFeedback(null); + try { + const res = await fn(); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error?.message || t(failKey)); + return true; + } catch (err) { + setFeedback({ type: "error", message: err instanceof Error ? err.message : t(failKey) }); + return false; + } +} + +/** Results list + loading + feedback state, plus the reload function. */ +function useDiscoveryResults(t: Translate) { + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(true); + const [feedback, setFeedback] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await fetch("/api/discovery/results"); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error?.message || t("loadFailed")); + setResults(Array.isArray(data.results) ? data.results : []); + } catch (err) { + setFeedback({ type: "error", message: err instanceof Error ? err.message : t("loadFailed") }); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + void load(); + }, [load]); + + return { results, loading, feedback, setFeedback, load }; +} + +/** Scan / verify / delete actions and their transient state. */ +function useDiscoveryActions( + t: Translate, + load: () => Promise, + setFeedback: (f: Feedback) => void +) { + const [scanTarget, setScanTarget] = useState(""); + const [scanning, setScanning] = useState(false); + const [busyId, setBusyId] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const scan = useCallback(async () => { + const providerId = scanTarget.trim(); + if (!providerId) return; + setScanning(true); + const ok = await callApi( + () => + fetch("/api/discovery/scan", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ providerId }), + }), + t, + "scanFailed", + setFeedback + ); + setScanning(false); + if (ok) { + setScanTarget(""); + setFeedback({ type: "success", message: t("scanQueued", { provider: providerId }) }); + await load(); + } + }, [scanTarget, t, load, setFeedback]); + + const verify = useCallback( + async (row: DiscoveryResult) => { + setBusyId(row.id); + const ok = await callApi( + () => fetch(`/api/discovery/verify/${row.id}`, { method: "POST" }), + t, + "verifyFailed", + setFeedback + ); + setBusyId(null); + if (ok) await load(); + }, + [t, load, setFeedback] + ); + + const remove = useCallback(async () => { + if (!deleteTarget) return; + setBusyId(deleteTarget.id); + const ok = await callApi( + () => fetch(`/api/discovery/results/${deleteTarget.id}`, { method: "DELETE" }), + t, + "deleteFailed", + setFeedback + ); + setBusyId(null); + if (ok) { + setDeleteTarget(null); + await load(); + } + }, [deleteTarget, t, load, setFeedback]); + + return { + scanTarget, + setScanTarget, + scanning, + busyId, + deleteTarget, + setDeleteTarget, + scan, + verify, + remove, + }; +} + +/** State + data orchestration for the discovery page, kept out of the view. */ +function useDiscovery() { + const t = useTranslations("discovery"); + const { results, loading, feedback, setFeedback, load } = useDiscoveryResults(t); + const actions = useDiscoveryActions(t, load, setFeedback); + return { t, results, loading, feedback, ...actions }; +} + +function DiscoveryScanForm({ + t, + value, + onChange, + onScan, + scanning, +}: { + t: Translate; + value: string; + onChange: (v: string) => void; + onScan: () => void; + scanning: boolean; +}) { + return ( + +
+
+ + onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") onScan(); + }} + /> +
+ +
+

{t("localOnlyNote")}

+
+ ); +} + +function DiscoveryResultCard({ + t, + row, + busy, + onVerify, + onDelete, +}: { + t: Translate; + row: DiscoveryResult; + busy: boolean; + onVerify: () => void; + onDelete: () => void; +}) { + return ( + +
+
+
+ {row.providerId} + {row.status} + + {t("risk")}: {row.riskLevel} + +
+
+ {t("method")}: {row.method} Β· {t("auth")}: {row.authType} Β· {t("feasibility")}:{" "} + {row.feasibility}/5 +
+ {row.endpoint && ( +
{row.endpoint}
+ )} + {row.models && row.models.length > 0 && ( +
+ {t("models")}: {row.models.join(", ")} +
+ )} +
+
+ {row.status !== "verified" && ( + + )} + +
+
+
+ ); +} + +export function DiscoveryPageClient() { + const d = useDiscovery(); + + return ( +
+
+

{d.t("title")}

+

{d.t("subtitle")}

+
+ + void d.scan()} + scanning={d.scanning} + /> + + {d.feedback && ( +
+ {d.feedback.message} +
+ )} + + {d.loading ? ( +
+ +
+ ) : d.results.length === 0 ? ( + + ) : ( +
    + {d.results.map((row) => ( +
  • + void d.verify(row)} + onDelete={() => d.setDeleteTarget(row)} + /> +
  • + ))} +
+ )} + + void d.remove()} + onClose={() => d.setDeleteTarget(null)} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/discovery/__tests__/DiscoveryPageClient.test.tsx b/src/app/(dashboard)/dashboard/discovery/__tests__/DiscoveryPageClient.test.tsx new file mode 100644 index 0000000000..e5b46427e7 --- /dev/null +++ b/src/app/(dashboard)/dashboard/discovery/__tests__/DiscoveryPageClient.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { DiscoveryPageClient } from "../DiscoveryPageClient"; + +// Stable `t` reference: useTranslations must return the SAME function across +// renders, otherwise the `load` useCallback (dep [t]) changes every render and +// the fetch-on-mount useEffect loops forever. +const t = (key: string, vars?: Record) => + vars ? `${key}:${JSON.stringify(vars)}` : key; + +vi.mock("next-intl", () => ({ + useTranslations: () => t, +})); + +function mockFetchOnce(results: unknown[]) { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ results }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +describe("DiscoveryPageClient", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("loads and renders discovery results from the API", async () => { + mockFetchOnce([ + { + id: 1, + providerId: "huggingchat", + method: "free_tier", + authType: "none", + feasibility: 5, + riskLevel: "none", + status: "verified", + models: ["mixtral"], + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText("huggingchat")).toBeInTheDocument(); + }); + // status + risk badges render (mocked t returns the key) + expect(screen.getByText("verified")).toBeInTheDocument(); + }); + + it("shows the empty state when there are no results", async () => { + mockFetchOnce([]); + + render(); + + await waitFor(() => { + expect(screen.getByText("emptyTitle")).toBeInTheDocument(); + }); + }); + + it("calls the discovery results endpoint on mount", async () => { + const fetchMock = mockFetchOnce([]); + render(); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith("/api/discovery/results"); + }); + }); +}); diff --git a/src/app/(dashboard)/dashboard/discovery/page.tsx b/src/app/(dashboard)/dashboard/discovery/page.tsx new file mode 100644 index 0000000000..a89031e19d --- /dev/null +++ b/src/app/(dashboard)/dashboard/discovery/page.tsx @@ -0,0 +1,5 @@ +import { DiscoveryPageClient } from "./DiscoveryPageClient"; + +export default function DiscoveryPage() { + return ; +} diff --git a/src/app/api/discovery/results/[id]/route.ts b/src/app/api/discovery/results/[id]/route.ts new file mode 100644 index 0000000000..4a7a23ab38 --- /dev/null +++ b/src/app/api/discovery/results/[id]/route.ts @@ -0,0 +1,62 @@ +/** + * Discovery result by id β€” GET / DELETE /api/discovery/results/:id + * + * Auth: Tier 3 MANAGEMENT + strict local-only (see ../route.ts for the model). + */ + +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { getDiscoveryResultById, deleteDiscoveryResult } from "@/lib/db/discoveryResults"; + +function parseId(raw: string): number | null { + const id = Number(raw); + return Number.isInteger(id) && id > 0 ? id : null; +} + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const { id: rawId } = await params; + const id = parseId(rawId); + if (id === null) { + return createErrorResponse({ status: 400, message: "Invalid discovery result id" }); + } + + try { + const result = getDiscoveryResultById(id); + if (!result) { + return createErrorResponse({ status: 404, message: "Discovery result not found" }); + } + return Response.json({ result }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to read discovery result"); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const { id: rawId } = await params; + const id = parseId(rawId); + if (id === null) { + return createErrorResponse({ status: 400, message: "Invalid discovery result id" }); + } + + try { + const removed = deleteDiscoveryResult(id); + if (!removed) { + return createErrorResponse({ status: 404, message: "Discovery result not found" }); + } + return Response.json({ deleted: true, id }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to delete discovery result"); + } +} diff --git a/src/app/api/discovery/results/route.ts b/src/app/api/discovery/results/route.ts new file mode 100644 index 0000000000..c7d91f882d --- /dev/null +++ b/src/app/api/discovery/results/route.ts @@ -0,0 +1,29 @@ +/** + * Discovery results β€” GET /api/discovery/results + * + * Lists persisted discovery findings, optionally filtered by `?providerId=`. + * + * Auth: Tier 3 MANAGEMENT (requireManagementAuth) + strict local-only. The + * `/api/discovery/` prefix is in `LOCAL_ONLY_API_PREFIXES` (routeGuard.ts), so + * the central authz pipeline (src/proxy.ts β†’ runAuthzPipeline β†’ managementPolicy) + * blocks non-loopback callers with a 403 LOCAL_ONLY before this handler runs. + * It is NOT in the manage-scope bypass list β€” strict loopback, no remote bypass. + */ + +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { getDiscoveryResults } from "@/lib/db/discoveryResults"; + +export async function GET(request: Request): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const url = new URL(request.url); + const providerId = url.searchParams.get("providerId") || undefined; + const results = getDiscoveryResults(providerId); + return Response.json({ results }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to list discovery results"); + } +} diff --git a/src/app/api/discovery/scan/route.ts b/src/app/api/discovery/scan/route.ts new file mode 100644 index 0000000000..3245997664 --- /dev/null +++ b/src/app/api/discovery/scan/route.ts @@ -0,0 +1,54 @@ +/** + * Discovery scan β€” POST /api/discovery/scan + * + * Triggers a scan for one provider and persists the findings. Body: + * `{ "providerId": "" }`. + * + * Auth: Tier 3 MANAGEMENT + strict local-only (see ../results/route.ts). The + * strict-loopback classification matters here specifically: `scanProvider` may + * probe outbound provider endpoints (SSRF-adjacent), so the surface must never + * be reachable from a tunnel/remote origin. + */ + +import { z } from "zod"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { scanProvider, persistDiscoveryResult } from "@/lib/discovery/index"; + +const scanRequestSchema = z.object({ + providerId: z.string().min(1).max(200), +}); + +export async function POST(request: Request): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + let raw: unknown; + try { + raw = await request.json(); + } catch { + return createErrorResponse({ + status: 400, + message: "Invalid request", + details: [{ field: "body", message: "Invalid JSON body" }], + }); + } + + const validation = validateBody(scanRequestSchema, raw); + if (isValidationFailure(validation)) { + return createErrorResponse({ + status: 400, + message: validation.error.message, + details: validation.error.details, + }); + } + + try { + const found = await scanProvider(validation.data.providerId); + const persisted = found.map((result) => persistDiscoveryResult(result)); + return Response.json({ results: persisted }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to scan provider"); + } +} diff --git a/src/app/api/discovery/verify/[id]/route.ts b/src/app/api/discovery/verify/[id]/route.ts new file mode 100644 index 0000000000..684e622f94 --- /dev/null +++ b/src/app/api/discovery/verify/[id]/route.ts @@ -0,0 +1,35 @@ +/** + * Discovery verify β€” POST /api/discovery/verify/:id + * + * Marks a discovery finding as verified (status='verified', stamps verified_at). + * + * Auth: Tier 3 MANAGEMENT + strict local-only (see ../../results/route.ts). + */ + +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { markVerified } from "@/lib/db/discoveryResults"; + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const { id: rawId } = await params; + const id = Number(rawId); + if (!Number.isInteger(id) || id <= 0) { + return createErrorResponse({ status: 400, message: "Invalid discovery result id" }); + } + + try { + const result = markVerified(id); + if (!result) { + return createErrorResponse({ status: 404, message: "Discovery result not found" }); + } + return Response.json({ result }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to verify discovery result"); + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 62122d8bc4..224e3e7e06 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1117,7 +1117,9 @@ "dragReorderItem": "Drag to reorder", "cannotHide": "This item cannot be hidden", "alwaysVisible": "Always visible", - "groupSeparatorLabel": "Separator" + "groupSeparatorLabel": "Separator", + "discovery": "Discovery", + "discoverySubtitle": "Scan providers for free access" }, "webhooks": { "title": "Webhooks", @@ -4687,7 +4689,7 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Model used to verify the API key. Leave blank to use the provider\u2019s first available model.", + "validationModelIdHint": "Model used to verify the API key. Leave blank to use the provider’s first available model.", "validationModelIdLabel": "Validation Model", "validationModelIdPlaceholder": "e.g. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", @@ -8992,5 +8994,30 @@ "colAvgScore": "Avg Score", "colModels": "Models", "colType": "Type" + }, + "discovery": { + "title": "Provider Discovery", + "subtitle": "Scan providers for free/unlimited access methods and review findings. Opt-in, local-only.", + "scanLabel": "Provider to scan", + "scanPlaceholder": "e.g. huggingchat", + "scan": "Scan", + "scanning": "Scanning…", + "scanQueued": "Scan complete for {provider}.", + "scanFailed": "Scan failed.", + "loadFailed": "Failed to load discovery results.", + "localOnlyNote": "This tool is local-only (loopback). Scans run from this machine and are never reachable remotely.", + "verify": "Verify", + "verifyFailed": "Failed to verify finding.", + "delete": "Delete", + "deleteFailed": "Failed to delete finding.", + "deleteTitle": "Delete discovery result", + "deleteConfirm": "Delete the discovery finding for {provider}? This cannot be undone.", + "emptyTitle": "No discovery results yet", + "emptyDescription": "Run a scan above to look for free access methods on a provider.", + "risk": "Risk", + "method": "Method", + "auth": "Auth", + "feasibility": "Feasibility", + "models": "Models" } } diff --git a/src/lib/db/discoveryResults.ts b/src/lib/db/discoveryResults.ts new file mode 100644 index 0000000000..085af33d35 --- /dev/null +++ b/src/lib/db/discoveryResults.ts @@ -0,0 +1,176 @@ +/** + * Database module: Discovery Results + * + * CRUD for the automated provider-discovery tool (opt-in, default off). Rows + * live in the `discovery_results` table (migration 074). The Discovery service + * (`src/lib/discovery/`) writes findings here via {@link upsertDiscoveryResult}; + * the `/api/discovery/*` routes read/verify/delete them. + * + * See `_tasks/features-v3.8.42/gaps/DISCOVERY_TOOL_DESIGN.md` for the design. + */ + +import { getDbInstance } from "./core"; + +export type DiscoveryMethod = + | "free_tier" + | "web_cookie" + | "auto_register" + | "trial" + | "public_api"; +export type DiscoveryAuthType = "none" | "cookie" | "api_key" | "oauth"; +export type DiscoveryRiskLevel = "none" | "low" | "medium" | "high" | "critical"; +export type DiscoveryStatus = "pending" | "testing" | "verified" | "rejected"; + +export interface DiscoveryResult { + id?: number; + providerId: string; + method: DiscoveryMethod; + endpoint?: string | null; + authType: DiscoveryAuthType; + models?: string[]; + rateLimit?: string | null; + feasibility: number; + riskLevel: DiscoveryRiskLevel; + status: DiscoveryStatus; + notes?: string | null; + discoveredAt?: string; + verifiedAt?: string | null; +} + +interface DiscoveryRow { + id: number; + provider_id: string; + method: string; + endpoint: string | null; + auth_type: string | null; + models: string | null; + rate_limit: string | null; + feasibility: number | null; + risk_level: string | null; + status: string; + notes: string | null; + discovered_at: string; + verified_at: string | null; +} + +function rowToResult(row: DiscoveryRow): DiscoveryResult { + let models: string[] | undefined; + if (row.models) { + try { + const parsed = JSON.parse(row.models); + if (Array.isArray(parsed)) models = parsed.map(String); + } catch { + models = undefined; + } + } + return { + id: row.id, + providerId: row.provider_id, + method: row.method as DiscoveryMethod, + endpoint: row.endpoint, + authType: (row.auth_type as DiscoveryAuthType) ?? "none", + models, + rateLimit: row.rate_limit, + feasibility: row.feasibility ?? 0, + riskLevel: (row.risk_level as DiscoveryRiskLevel) ?? "none", + status: row.status as DiscoveryStatus, + notes: row.notes, + discoveredAt: row.discovered_at, + verifiedAt: row.verified_at, + }; +} + +/** + * Insert or update a discovery finding. Uniqueness is keyed on + * `(provider_id, method, endpoint)` (the table's UNIQUE constraint), so + * re-discovering the same endpoint updates the existing row rather than + * duplicating it. Returns the persisted row (with its id). + */ +export function upsertDiscoveryResult(result: DiscoveryResult): DiscoveryResult { + const db = getDbInstance(); + const models = result.models ? JSON.stringify(result.models) : null; + db.prepare( + `INSERT INTO discovery_results + (provider_id, method, endpoint, auth_type, models, rate_limit, feasibility, risk_level, status, notes) + VALUES (@provider_id, @method, @endpoint, @auth_type, @models, @rate_limit, @feasibility, @risk_level, @status, @notes) + ON CONFLICT(provider_id, method, endpoint) DO UPDATE SET + auth_type = excluded.auth_type, + models = excluded.models, + rate_limit = excluded.rate_limit, + feasibility = excluded.feasibility, + risk_level = excluded.risk_level, + status = excluded.status, + notes = excluded.notes` + ).run({ + provider_id: result.providerId, + method: result.method, + endpoint: result.endpoint ?? null, + auth_type: result.authType, + models, + rate_limit: result.rateLimit ?? null, + feasibility: result.feasibility, + risk_level: result.riskLevel, + status: result.status, + notes: result.notes ?? null, + }); + + const row = db + .prepare( + `SELECT * FROM discovery_results + WHERE provider_id = ? AND method = ? AND ifnull(endpoint, '') = ifnull(?, '')` + ) + .get(result.providerId, result.method, result.endpoint ?? null) as DiscoveryRow | undefined; + // The row was just written, so it must exist. + return rowToResult(row!); +} + +/** + * List discovery results, optionally filtered to a single provider. Newest + * findings first. + */ +export function getDiscoveryResults(providerId?: string): DiscoveryResult[] { + const db = getDbInstance(); + const rows = providerId + ? (db + .prepare( + "SELECT * FROM discovery_results WHERE provider_id = ? ORDER BY discovered_at DESC, id DESC" + ) + .all(providerId) as DiscoveryRow[]) + : (db + .prepare("SELECT * FROM discovery_results ORDER BY discovered_at DESC, id DESC") + .all() as DiscoveryRow[]); + return rows.map(rowToResult); +} + +export function getDiscoveryResultById(id: number): DiscoveryResult | null { + const db = getDbInstance(); + const row = db.prepare("SELECT * FROM discovery_results WHERE id = ?").get(id) as + | DiscoveryRow + | undefined; + return row ? rowToResult(row) : null; +} + +/** + * Mark a finding as verified, stamping `verified_at`. Returns the updated row, + * or null if no row with that id exists. + */ +export function markVerified(id: number): DiscoveryResult | null { + const db = getDbInstance(); + const info = db + .prepare( + "UPDATE discovery_results SET status = 'verified', verified_at = datetime('now') WHERE id = ?" + ) + .run(id); + if (info.changes === 0) return null; + return getDiscoveryResultById(id); +} + +/** + * Delete a finding. Returns true if a row was removed, false if the id was not + * found. + */ +export function deleteDiscoveryResult(id: number): boolean { + const db = getDbInstance(); + const info = db.prepare("DELETE FROM discovery_results WHERE id = ?").run(id); + return info.changes > 0; +} diff --git a/src/lib/discovery/index.ts b/src/lib/discovery/index.ts index 74b0aba0df..0631536704 100644 --- a/src/lib/discovery/index.ts +++ b/src/lib/discovery/index.ts @@ -11,6 +11,11 @@ */ import { logger } from "../../../open-sse/utils/logger.ts"; +import { + upsertDiscoveryResult as dbUpsertDiscoveryResult, + getDiscoveryResults as dbGetDiscoveryResults, + type DiscoveryResult as DbDiscoveryResult, +} from "../db/discoveryResults"; const log = logger("DISCOVERY"); @@ -102,13 +107,24 @@ export async function scanProvider( ]; } -// ── Results ── +// ── Results (Reporter β€” Phase 2) ── /** - * Get discovery results. Phase 1 stub β€” returns empty array. + * Persist a discovery finding to the `discovery_results` table via the DB + * module. Uniqueness is keyed on `(providerId, method, endpoint)`, so + * re-discovering the same endpoint updates the existing row. Returns the + * persisted row (with its id). */ -export function getDiscoveryResults(_providerId?: string): DiscoveryResult[] { - return []; +export function persistDiscoveryResult(result: DiscoveryResult): DiscoveryResult { + return dbUpsertDiscoveryResult(result as DbDiscoveryResult) as DiscoveryResult; +} + +/** + * Get discovery results from the DB, optionally filtered to one provider. + * Newest findings first. + */ +export function getDiscoveryResults(providerId?: string): DiscoveryResult[] { + return dbGetDiscoveryResults(providerId) as DiscoveryResult[]; } // ── Config ── diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 227f56f205..e2478a946b 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -321,6 +321,22 @@ export { export type { Webhook, WebhookKind } from "./db/webhooks"; export { insertDelivery, getDeliveries } from "./db/webhookDeliveries"; + +export { + upsertDiscoveryResult, + getDiscoveryResults, + getDiscoveryResultById, + markVerified, + deleteDiscoveryResult, +} from "./db/discoveryResults"; + +export type { + DiscoveryResult, + DiscoveryMethod, + DiscoveryAuthType, + DiscoveryRiskLevel, + DiscoveryStatus, +} from "./db/discoveryResults"; export type { WebhookDelivery } from "./db/webhookDeliveries"; export { diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index 8874ed33f7..b921eb57cc 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -42,6 +42,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/api/headroom/start", // Headroom token-saver proxy lifecycle: spawns headroom-ai python CLI (Hard Rules #15 + #17) "/api/headroom/stop", // Headroom token-saver proxy lifecycle: sends SIGTERM/SIGKILL to managed PID (Hard Rules #15 + #17) "/api/oauth/cursor/auto-import", // spawns `execFile("which", ["cursor"])` to verify a local Cursor install before importing creds β€” RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate). Specific path only: the rest of /api/oauth/ (browser redirect/callback flows) must stay remote-reachable. + "/api/discovery/", // Discovery tool (opt-in provider scanner): the scan route makes outbound probes to provider endpoints (SSRF-adjacent) and the whole surface is an admin research tool β€” strict-loopback only, no manage-scope bypass (NOT in LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES). See _tasks/features-v3.8.42/gaps/DISCOVERY_TOOL_DESIGN.md. ]; /** diff --git a/src/shared/constants/sidebarVisibility/sections.ts b/src/shared/constants/sidebarVisibility/sections.ts index 5574fc0776..043ddf45f4 100644 --- a/src/shared/constants/sidebarVisibility/sections.ts +++ b/src/shared/constants/sidebarVisibility/sections.ts @@ -229,6 +229,13 @@ const TOOLS_GROUP: SidebarItemGroup = { subtitleKey: "trafficInspectorSubtitle", icon: "network_check", }, + { + id: "discovery", + href: "/dashboard/discovery", + i18nKey: "discovery", + subtitleKey: "discoverySubtitle", + icon: "travel_explore", + }, ], }; diff --git a/src/shared/constants/sidebarVisibility/types.ts b/src/shared/constants/sidebarVisibility/types.ts index a50b727395..ba486b6b0e 100644 --- a/src/shared/constants/sidebarVisibility/types.ts +++ b/src/shared/constants/sidebarVisibility/types.ts @@ -29,6 +29,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [ "cloud-agents", "agent-bridge", "traffic-inspector", + "discovery", // OmniProxy > Integrations "api-endpoints", "webhooks", diff --git a/stryker.conf.json b/stryker.conf.json index 36641d87f0..eda539888d 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -187,6 +187,7 @@ "tests/unit/antigravity-429-quota-tdd.test.ts", "tests/unit/auth-antigravity-account-retry-v2.test.ts", "tests/unit/middleware-header-strip-5849.test.ts", + "tests/unit/authz/discovery-routes-local-only.test.ts", "tests/unit/authz/route-guard-local-prefix.test.ts", "tests/unit/authz/route-guard-version-get-exemption.test.ts", "tests/unit/chat-combo-live-test.test.ts", diff --git a/tests/unit/api/discovery-routes.test.ts b/tests/unit/api/discovery-routes.test.ts new file mode 100644 index 0000000000..99dc8dddcc --- /dev/null +++ b/tests/unit/api/discovery-routes.test.ts @@ -0,0 +1,155 @@ +import { describe, test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Isolated DB + auth disabled so requireManagementAuth passes (no key configured). +let tmpDataDir: string; +let core: typeof import("@/lib/db/core"); +let db: typeof import("@/lib/db/discoveryResults"); +let resultsRoute: typeof import("@/app/api/discovery/results/route"); +let resultByIdRoute: typeof import("@/app/api/discovery/results/[id]/route"); +let scanRoute: typeof import("@/app/api/discovery/scan/route"); +let verifyRoute: typeof import("@/app/api/discovery/verify/[id]/route"); + +function req(method: string, url: string, body?: unknown): Request { + return new Request(`http://localhost${url}`, { + method, + headers: { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +before(async () => { + tmpDataDir = mkdtempSync(join(tmpdir(), "omniroute-discovery-routes-")); + process.env.DATA_DIR = tmpDataDir; + delete process.env.REQUIRE_API_KEY; + process.env.OMNIROUTE_DISABLE_AUTH = "1"; + core = await import("@/lib/db/core"); + core.resetDbInstance(); + core.getDbInstance(); + db = await import("@/lib/db/discoveryResults"); + resultsRoute = await import("@/app/api/discovery/results/route"); + resultByIdRoute = await import("@/app/api/discovery/results/[id]/route"); + scanRoute = await import("@/app/api/discovery/scan/route"); + verifyRoute = await import("@/app/api/discovery/verify/[id]/route"); +}); + +after(() => { + core.resetDbInstance(); + if (tmpDataDir) rmSync(tmpDataDir, { recursive: true, force: true }); +}); + +describe("discovery API routes", () => { + test("GET /results lists persisted findings (and filters by providerId)", async () => { + db.upsertDiscoveryResult({ + providerId: "acme", + method: "free_tier", + authType: "none", + feasibility: 3, + riskLevel: "none", + status: "pending", + }); + const res = await resultsRoute.GET(req("GET", "/api/discovery/results?providerId=acme")); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body.results)); + assert.ok(body.results.some((r: { providerId: string }) => r.providerId === "acme")); + }); + + test("GET /results/:id returns the row, 404 when missing, 400 on bad id", async () => { + const created = db.upsertDiscoveryResult({ + providerId: "beta", + method: "trial", + authType: "api_key", + feasibility: 2, + riskLevel: "low", + status: "pending", + }); + const ok = await resultByIdRoute.GET(req("GET", `/api/discovery/results/${created.id}`), { + params: Promise.resolve({ id: String(created.id) }), + }); + assert.equal(ok.status, 200); + + const missing = await resultByIdRoute.GET(req("GET", "/api/discovery/results/999999"), { + params: Promise.resolve({ id: "999999" }), + }); + assert.equal(missing.status, 404); + + const bad = await resultByIdRoute.GET(req("GET", "/api/discovery/results/abc"), { + params: Promise.resolve({ id: "abc" }), + }); + assert.equal(bad.status, 400); + }); + + test("POST /scan persists findings; rejects an empty providerId with 400", async () => { + const res = await scanRoute.POST(req("POST", "/api/discovery/scan", { providerId: "gamma" })); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body.results) && body.results.length > 0); + assert.ok(body.results[0].id > 0); + // the persisted row is now queryable + assert.ok(db.getDiscoveryResults("gamma").length > 0); + + const invalid = await scanRoute.POST(req("POST", "/api/discovery/scan", { providerId: "" })); + assert.equal(invalid.status, 400); + + const malformed = new Request("http://localhost/api/discovery/scan", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{not json", + }); + const malformedRes = await scanRoute.POST(malformed); + assert.equal(malformedRes.status, 400); + }); + + test("POST /verify/:id marks verified, 404 when missing", async () => { + const created = db.upsertDiscoveryResult({ + providerId: "delta", + method: "public_api", + authType: "api_key", + feasibility: 5, + riskLevel: "none", + status: "pending", + }); + const res = await verifyRoute.POST(req("POST", `/api/discovery/verify/${created.id}`), { + params: Promise.resolve({ id: String(created.id) }), + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.result.status, "verified"); + + const missing = await verifyRoute.POST(req("POST", "/api/discovery/verify/999999"), { + params: Promise.resolve({ id: "999999" }), + }); + assert.equal(missing.status, 404); + }); + + test("DELETE /results/:id removes the row, 404 on second delete", async () => { + const created = db.upsertDiscoveryResult({ + providerId: "epsilon", + method: "free_tier", + authType: "none", + feasibility: 1, + riskLevel: "none", + status: "pending", + }); + const first = await resultByIdRoute.DELETE(req("DELETE", `/api/discovery/results/${created.id}`), { + params: Promise.resolve({ id: String(created.id) }), + }); + assert.equal(first.status, 200); + const second = await resultByIdRoute.DELETE(req("DELETE", `/api/discovery/results/${created.id}`), { + params: Promise.resolve({ id: String(created.id) }), + }); + assert.equal(second.status, 404); + }); + + test("error responses do not leak stack traces", async () => { + const missing = await resultByIdRoute.GET(req("GET", "/api/discovery/results/424242"), { + params: Promise.resolve({ id: "424242" }), + }); + const body = await missing.json(); + assert.ok(!String(body.error?.message ?? "").includes("at /")); + }); +}); diff --git a/tests/unit/authz/discovery-routes-local-only.test.ts b/tests/unit/authz/discovery-routes-local-only.test.ts new file mode 100644 index 0000000000..ceea7f7dbc --- /dev/null +++ b/tests/unit/authz/discovery-routes-local-only.test.ts @@ -0,0 +1,27 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { + isLocalOnlyPath, + isLocalOnlyBypassableByManageScope, +} from "@/server/authz/routeGuard"; + +// Security guard: the discovery surface must be strict-loopback only. If someone +// removes "/api/discovery/" from LOCAL_ONLY_API_PREFIXES, or adds it to the +// manage-scope bypass list, these assertions fail β€” the scan route makes +// outbound probes (SSRF-adjacent) and must never be tunnel-reachable. +describe("discovery routes are strict local-only", () => { + for (const path of [ + "/api/discovery/results", + "/api/discovery/results/42", + "/api/discovery/scan", + "/api/discovery/verify/42", + ]) { + test(`isLocalOnlyPath("${path}") === true`, () => { + assert.equal(isLocalOnlyPath(path), true); + }); + + test(`"${path}" is NOT bypassable by manage-scope (strict loopback)`, () => { + assert.equal(isLocalOnlyBypassableByManageScope(path), false); + }); + } +}); diff --git a/tests/unit/db/discovery-results.test.ts b/tests/unit/db/discovery-results.test.ts new file mode 100644 index 0000000000..afc4106ee6 --- /dev/null +++ b/tests/unit/db/discovery-results.test.ts @@ -0,0 +1,143 @@ +import { describe, test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Isolate the DB into a throwaway DATA_DIR so migrations run against a fresh file. +let tmpDataDir: string; +let mod: typeof import("@/lib/db/discoveryResults"); +let core: typeof import("@/lib/db/core"); + +before(async () => { + tmpDataDir = mkdtempSync(join(tmpdir(), "omniroute-discovery-")); + process.env.DATA_DIR = tmpDataDir; + core = await import("@/lib/db/core"); + core.resetDbInstance(); + // Touch the instance so migrations (incl. 074_discovery_results) apply. + core.getDbInstance(); + mod = await import("@/lib/db/discoveryResults"); +}); + +after(() => { + core.resetDbInstance(); + if (tmpDataDir) rmSync(tmpDataDir, { recursive: true, force: true }); +}); + +describe("discoveryResults DB module", () => { + test("upsert inserts a new row and returns it with an id", () => { + const row = mod.upsertDiscoveryResult({ + providerId: "acme", + method: "free_tier", + authType: "none", + feasibility: 4, + riskLevel: "low", + status: "pending", + models: ["acme-large", "acme-small"], + endpoint: "https://acme.example/api", + }); + assert.ok(typeof row.id === "number" && row.id > 0); + assert.equal(row.providerId, "acme"); + assert.deepEqual(row.models, ["acme-large", "acme-small"]); + assert.equal(row.status, "pending"); + }); + + test("upsert on the same (provider, method, endpoint) updates instead of duplicating", () => { + const first = mod.upsertDiscoveryResult({ + providerId: "beta", + method: "web_cookie", + authType: "cookie", + feasibility: 3, + riskLevel: "medium", + status: "pending", + endpoint: "https://beta.example/chat", + }); + const second = mod.upsertDiscoveryResult({ + providerId: "beta", + method: "web_cookie", + authType: "cookie", + feasibility: 5, + riskLevel: "medium", + status: "testing", + endpoint: "https://beta.example/chat", + }); + assert.equal(second.id, first.id); + assert.equal(second.feasibility, 5); + assert.equal(second.status, "testing"); + const all = mod.getDiscoveryResults("beta"); + assert.equal(all.length, 1); + }); + + test("getDiscoveryResults filters by providerId and returns all when omitted", () => { + const beta = mod.getDiscoveryResults("beta"); + assert.ok(beta.every((r) => r.providerId === "beta")); + const all = mod.getDiscoveryResults(); + assert.ok(all.length >= 2); + }); + + test("getDiscoveryResultById returns the row or null", () => { + const created = mod.upsertDiscoveryResult({ + providerId: "gamma", + method: "trial", + authType: "api_key", + feasibility: 2, + riskLevel: "low", + status: "pending", + }); + const found = mod.getDiscoveryResultById(created.id!); + assert.equal(found?.providerId, "gamma"); + assert.equal(mod.getDiscoveryResultById(999999), null); + }); + + test("markVerified sets status=verified and stamps verified_at", () => { + const created = mod.upsertDiscoveryResult({ + providerId: "delta", + method: "public_api", + authType: "api_key", + feasibility: 5, + riskLevel: "none", + status: "pending", + }); + const updated = mod.markVerified(created.id!); + assert.equal(updated?.status, "verified"); + assert.ok(updated?.verifiedAt); + }); + + test("markVerified on a missing id returns null", () => { + assert.equal(mod.markVerified(999999), null); + }); + + test("deleteDiscoveryResult removes the row and returns true, false if absent", () => { + const created = mod.upsertDiscoveryResult({ + providerId: "epsilon", + method: "free_tier", + authType: "none", + feasibility: 1, + riskLevel: "none", + status: "pending", + }); + assert.equal(mod.deleteDiscoveryResult(created.id!), true); + assert.equal(mod.getDiscoveryResultById(created.id!), null); + assert.equal(mod.deleteDiscoveryResult(created.id!), false); + }); +}); + +describe("discovery service reporter delegation", () => { + test("persistDiscoveryResult writes through and getDiscoveryResults reads it back", async () => { + const svc = await import("@/lib/discovery/index"); + const saved = svc.persistDiscoveryResult({ + providerId: "zeta", + method: "public_api", + authType: "api_key", + feasibility: 5, + riskLevel: "none", + status: "verified", + models: ["zeta-1"], + }); + assert.ok(saved.id! > 0); + const read = svc.getDiscoveryResults("zeta"); + assert.equal(read.length, 1); + assert.equal(read[0].providerId, "zeta"); + assert.deepEqual(read[0].models, ["zeta-1"]); + }); +}); diff --git a/tests/unit/sidebar-tools-group.test.ts b/tests/unit/sidebar-tools-group.test.ts index 75b66e733b..3a1f145c86 100644 --- a/tests/unit/sidebar-tools-group.test.ts +++ b/tests/unit/sidebar-tools-group.test.ts @@ -25,14 +25,22 @@ function getToolsGroup() { }; } -test("TOOLS_GROUP items follow plan 14 order: cli-code β†’ cli-agents β†’ acp-agents β†’ cloud-agents β†’ agent-bridge β†’ traffic-inspector", () => { +test("TOOLS_GROUP items follow plan 14 order: cli-code β†’ cli-agents β†’ acp-agents β†’ cloud-agents β†’ agent-bridge β†’ traffic-inspector β†’ discovery", () => { const toolsGroup = getToolsGroup(); const itemIds = toolsGroup.items.map((item) => item.id); - // cli-code/cli-agents/acp-agents/cloud-agents from plan 14 (#2839); agent-bridge/traffic-inspector from plans 11/12 (#2858). + // cli-code/cli-agents/acp-agents/cloud-agents from plan 14 (#2839); agent-bridge/traffic-inspector from plans 11/12 (#2858); discovery from #5939. assert.deepEqual( itemIds, - ["cli-code", "cli-agents", "acp-agents", "cloud-agents", "agent-bridge", "traffic-inspector"], - "TOOLS_GROUP items order must be cli-code, cli-agents, acp-agents, cloud-agents, agent-bridge, traffic-inspector" + [ + "cli-code", + "cli-agents", + "acp-agents", + "cloud-agents", + "agent-bridge", + "traffic-inspector", + "discovery", + ], + "TOOLS_GROUP items order must be cli-code, cli-agents, acp-agents, cloud-agents, agent-bridge, traffic-inspector, discovery" ); }); diff --git a/tests/unit/sidebar-visibility.test.ts b/tests/unit/sidebar-visibility.test.ts index 67a40e8170..1a5b62c62a 100644 --- a/tests/unit/sidebar-visibility.test.ts +++ b/tests/unit/sidebar-visibility.test.ts @@ -63,6 +63,7 @@ test("primary sidebar items place limits after cache", () => { "cloud-agents", "agent-bridge", "traffic-inspector", + "discovery", "api-endpoints", "webhooks", "proxy", diff --git a/vitest.config.ts b/vitest.config.ts index d448bdeb6c..4664608302 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ "src/app/**/dashboard/endpoint/__tests__/**/*.test.tsx", "src/app/**/dashboard/providers/**/__tests__/**/*.test.tsx", "src/app/**/dashboard/webhooks/__tests__/**/*.test.tsx", + "src/app/**/dashboard/discovery/__tests__/**/*.test.tsx", "src/shared/hooks/__tests__/**/*.test.tsx", "src/lib/memory/__tests__/**/*.test.ts", "src/lib/skills/__tests__/**/*.test.ts",