chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AUTO_COMBO_TEMPLATES } from "@/domain/assessment/types";
|
||||
|
||||
// Minimal i18n stub — return interpolated value so {count} works.
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
|
||||
if (values && typeof values.count !== "undefined") {
|
||||
return `${values.count} ${key}`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}));
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => {
|
||||
container.remove();
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("AutoComboCatalog", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) {
|
||||
cleanupCallbacks.pop()?.();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("renders the header with translated title and template-count badge", async () => {
|
||||
const { default: AutoComboCatalog } =
|
||||
await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<AutoComboCatalog />);
|
||||
});
|
||||
expect(container.textContent).toContain("autoCatalogTitle");
|
||||
expect(container.textContent).toContain(
|
||||
`${AUTO_COMBO_TEMPLATES.length} autoCatalogTemplateCount`
|
||||
);
|
||||
});
|
||||
|
||||
it("stays collapsed by default — no template rows in the DOM", async () => {
|
||||
const { default: AutoComboCatalog } =
|
||||
await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<AutoComboCatalog />);
|
||||
});
|
||||
const first = AUTO_COMBO_TEMPLATES[0];
|
||||
expect(container.textContent ?? "").not.toContain(first.name);
|
||||
});
|
||||
|
||||
it("expands when toggled and lists every template name", async () => {
|
||||
const { default: AutoComboCatalog } =
|
||||
await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<AutoComboCatalog />);
|
||||
});
|
||||
const toggle = container.querySelector("button");
|
||||
expect(toggle).toBeTruthy();
|
||||
await act(async () => {
|
||||
toggle?.click();
|
||||
});
|
||||
for (const tpl of AUTO_COMBO_TEMPLATES) {
|
||||
expect(container.textContent ?? "").toContain(tpl.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("flips the toggle aria-label between expand and collapse", async () => {
|
||||
const { default: AutoComboCatalog } =
|
||||
await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<AutoComboCatalog />);
|
||||
});
|
||||
const toggle = container.querySelector("button");
|
||||
expect(toggle?.getAttribute("aria-label")).toBe("autoCatalogExpand");
|
||||
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
|
||||
await act(async () => {
|
||||
toggle?.click();
|
||||
});
|
||||
expect(toggle?.getAttribute("aria-label")).toBe("autoCatalogCollapse");
|
||||
expect(toggle?.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders the strategy badge for each template when expanded", async () => {
|
||||
const { default: AutoComboCatalog } =
|
||||
await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<AutoComboCatalog />);
|
||||
});
|
||||
await act(async () => {
|
||||
(container.querySelector("button") as HTMLButtonElement | null)?.click();
|
||||
});
|
||||
const strategies = new Set(AUTO_COMBO_TEMPLATES.map((t) => t.strategy));
|
||||
for (const s of strategies) {
|
||||
expect(container.textContent ?? "").toContain(s);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Minimal next-intl stub — returns the translation key for inspection.
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// Minimal next/link stub — renders as a plain anchor element.
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<a href={href} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => {
|
||||
container.remove();
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("SkillsConceptCard", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) {
|
||||
cleanupCallbacks.pop()?.();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders agent variant with correct i18n keys", async () => {
|
||||
const { SkillsConceptCard } = await import(
|
||||
"../../src/shared/components/SkillsConceptCard.tsx"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<SkillsConceptCard variant="agent" />);
|
||||
});
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
// The i18n mock returns the key itself, so these keys should appear.
|
||||
expect(text).toContain("conceptCard.agent.title");
|
||||
expect(text).toContain("conceptCard.agent.crossLinkLabel");
|
||||
});
|
||||
|
||||
it("renders omni variant with correct i18n keys", async () => {
|
||||
const { SkillsConceptCard } = await import(
|
||||
"../../src/shared/components/SkillsConceptCard.tsx"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<SkillsConceptCard variant="omni" />);
|
||||
});
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("conceptCard.omni.title");
|
||||
expect(text).toContain("conceptCard.omni.crossLinkLabel");
|
||||
});
|
||||
|
||||
it("agent variant renders 5 comparison rows (whatIs, direction, executor, storage, tagline)", async () => {
|
||||
const { SkillsConceptCard } = await import(
|
||||
"../../src/shared/components/SkillsConceptCard.tsx"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<SkillsConceptCard variant="agent" />);
|
||||
});
|
||||
|
||||
const rows = container.querySelectorAll("[data-testid^='comparison-row-']");
|
||||
expect(rows.length).toBe(5);
|
||||
|
||||
const rowIds = Array.from(rows).map((r) => r.getAttribute("data-testid"));
|
||||
expect(rowIds).toContain("comparison-row-whatIs");
|
||||
expect(rowIds).toContain("comparison-row-direction");
|
||||
expect(rowIds).toContain("comparison-row-executor");
|
||||
expect(rowIds).toContain("comparison-row-storage");
|
||||
expect(rowIds).toContain("comparison-row-tagline");
|
||||
});
|
||||
|
||||
it("omni variant renders 5 comparison rows", async () => {
|
||||
const { SkillsConceptCard } = await import(
|
||||
"../../src/shared/components/SkillsConceptCard.tsx"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<SkillsConceptCard variant="omni" />);
|
||||
});
|
||||
|
||||
const rows = container.querySelectorAll("[data-testid^='comparison-row-']");
|
||||
expect(rows.length).toBe(5);
|
||||
});
|
||||
|
||||
it("renders data-testid for variant", async () => {
|
||||
const { SkillsConceptCard } = await import(
|
||||
"../../src/shared/components/SkillsConceptCard.tsx"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<SkillsConceptCard variant="agent" />);
|
||||
});
|
||||
|
||||
const card = container.querySelector("[data-testid='skills-concept-card-agent']");
|
||||
expect(card).not.toBeNull();
|
||||
});
|
||||
|
||||
it("agent variant cross-link points to /dashboard/omni-skills", async () => {
|
||||
const { SkillsConceptCard } = await import(
|
||||
"../../src/shared/components/SkillsConceptCard.tsx"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<SkillsConceptCard variant="agent" />);
|
||||
});
|
||||
|
||||
const link = container.querySelector("a");
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.getAttribute("href")).toBe("/dashboard/omni-skills");
|
||||
});
|
||||
|
||||
it("omni variant cross-link points to /dashboard/agent-skills", async () => {
|
||||
const { SkillsConceptCard } = await import(
|
||||
"../../src/shared/components/SkillsConceptCard.tsx"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<SkillsConceptCard variant="omni" />);
|
||||
});
|
||||
|
||||
const link = container.querySelector("a");
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.getAttribute("href")).toBe("/dashboard/agent-skills");
|
||||
});
|
||||
|
||||
it("accepts optional className prop", async () => {
|
||||
const { SkillsConceptCard } = await import(
|
||||
"../../src/shared/components/SkillsConceptCard.tsx"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<SkillsConceptCard variant="agent" className="my-custom-class" />);
|
||||
});
|
||||
|
||||
const el = container.firstElementChild as HTMLElement | null;
|
||||
expect(el?.className).toContain("my-custom-class");
|
||||
});
|
||||
|
||||
it("renders without crashing when className is omitted", async () => {
|
||||
const { SkillsConceptCard } = await import(
|
||||
"../../src/shared/components/SkillsConceptCard.tsx"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<SkillsConceptCard variant="agent" />);
|
||||
});
|
||||
|
||||
expect(container.children.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* #5757 guard fixture — NOT a test file (no `.test.` in the name, so the runner
|
||||
* skips it). Exercised by `tests/unit/tsx-runtime-transform-5757.test.ts`.
|
||||
*
|
||||
* It concentrates the modern JS/TS syntax that the published CLI's runtime
|
||||
* `tsx/esm` loader (`bin/omniroute.mjs` → `await import("tsx/esm")`) must
|
||||
* transform through esbuild at startup: object/array destructuring + rest,
|
||||
* object/array spread, class + private fields, optional chaining, nullish
|
||||
* coalescing, logical assignment, async/await and top-level await.
|
||||
*
|
||||
* If a future esbuild (pulled transitively via `tsx`) cannot transform this on a
|
||||
* supported Node runtime, running this file fails — which is the whole point.
|
||||
*/
|
||||
class Box {
|
||||
value = 41; // public class field
|
||||
#secret = 1; // private field
|
||||
|
||||
bump(): number {
|
||||
this.value += this.#secret;
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { a, b, ...rest } = { a: 1, b: 2, c: 3, d: 4 }; // object destructuring + rest
|
||||
const [first, ...tail] = [10, 20, 30]; // array destructuring + rest
|
||||
const merged = { ...rest, first }; // object spread
|
||||
const arr = [...tail, first]; // array spread
|
||||
const bumped = new Box().bump(); // class + private field
|
||||
const maybe: { x?: { y?: number } } = {};
|
||||
const opt = maybe?.x?.y ?? 99; // optional chaining + nullish coalescing
|
||||
let acc = 0;
|
||||
acc ||= bumped; // logical assignment
|
||||
const total = await Promise.resolve(a + b + first + opt + acc); // async/await
|
||||
return { a, b, rest, first, tail, merged, arr, bumped, opt, total };
|
||||
}
|
||||
|
||||
const result = await main(); // top-level await
|
||||
console.log("TSX_TRANSFORM_OK " + JSON.stringify(result));
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Test harness for MitmHandlerBase subclasses.
|
||||
*
|
||||
* Mocks `globalThis.fetch` so handlers exercise their full intercept() path
|
||||
* (router round-trip + SSE pipe) without touching the network. Returns the
|
||||
* captured payload, response chunks written to the fake ServerResponse, and
|
||||
* the final status code.
|
||||
*/
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
import type { MitmHandlerBase } from "../../src/mitm/handlers/base.ts";
|
||||
|
||||
export interface HarnessResult {
|
||||
fetchCalled: boolean;
|
||||
fetchUrl: string | null;
|
||||
fetchHeaders: Record<string, string>;
|
||||
fetchBody: string;
|
||||
status: number;
|
||||
responseChunks: string[];
|
||||
}
|
||||
|
||||
function fakeReq(
|
||||
headers: Record<string, string> = {},
|
||||
url = "/v1/chat/completions"
|
||||
): IncomingMessage {
|
||||
return {
|
||||
method: "POST",
|
||||
url,
|
||||
headers: {
|
||||
host: "api.example.com",
|
||||
"user-agent": "ut",
|
||||
...headers,
|
||||
},
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
function fakeRes(): { res: ServerResponse; out: HarnessResult } {
|
||||
const out: HarnessResult = {
|
||||
fetchCalled: false,
|
||||
fetchUrl: null,
|
||||
fetchHeaders: {},
|
||||
fetchBody: "",
|
||||
status: 0,
|
||||
responseChunks: [],
|
||||
};
|
||||
let headersSent = false;
|
||||
const res = {
|
||||
get headersSent() {
|
||||
return headersSent;
|
||||
},
|
||||
writeHead(s: number) {
|
||||
out.status = s;
|
||||
headersSent = true;
|
||||
},
|
||||
write(c: Buffer | string) {
|
||||
out.responseChunks.push(typeof c === "string" ? c : c.toString());
|
||||
return true;
|
||||
},
|
||||
end(c?: Buffer | string) {
|
||||
if (c) out.responseChunks.push(typeof c === "string" ? c : c.toString());
|
||||
},
|
||||
} as unknown as ServerResponse;
|
||||
return { res, out };
|
||||
}
|
||||
|
||||
export async function runHandler(
|
||||
handler: MitmHandlerBase,
|
||||
body: unknown,
|
||||
mappedModel: string,
|
||||
opts: {
|
||||
upstreamStatus?: number;
|
||||
upstreamBody?: string;
|
||||
headers?: Record<string, string>;
|
||||
url?: string;
|
||||
} = {}
|
||||
): Promise<HarnessResult> {
|
||||
const { res, out } = fakeRes();
|
||||
const req = fakeReq(opts.headers, opts.url);
|
||||
const buf = Buffer.from(typeof body === "string" ? body : JSON.stringify(body));
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: string, init: RequestInit) => {
|
||||
out.fetchCalled = true;
|
||||
out.fetchUrl = String(url);
|
||||
out.fetchHeaders = (init?.headers ?? {}) as Record<string, string>;
|
||||
out.fetchBody = typeof init?.body === "string" ? init.body : "";
|
||||
const upstreamBody = opts.upstreamBody ?? "data: hello\n\n";
|
||||
const status = opts.upstreamStatus ?? 200;
|
||||
const stream = Readable.toWeb(
|
||||
Readable.from(Buffer.from(upstreamBody))
|
||||
) as unknown as ReadableStream<Uint8Array>;
|
||||
return new Response(stream, { status });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
try {
|
||||
await handler.intercept(req, res, buf, mappedModel);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Shared settings test fixture.
|
||||
*
|
||||
* Backs onto a real isolated SQLite DB + the production
|
||||
* `updateSettings → applyRuntimeSettings` pipeline so callers exercise the
|
||||
* actual hot-reload path. Tests that need to mock `getAuthzBypassSnapshot`
|
||||
* directly defeat the integration value of AC-7 — use this helper instead.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* import { setupSettingsFixture, mockSettings, resetSettingsMock } from "../_mocks/settings";
|
||||
* const fixture = setupSettingsFixture("authz-bypass");
|
||||
* test.beforeEach(() => fixture.resetStorage());
|
||||
* await mockSettings({ localOnlyManageScopeBypassEnabled: false });
|
||||
* ```
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
type SettingsPatch = Record<string, unknown>;
|
||||
|
||||
let activeFixture: SettingsFixture | null = null;
|
||||
|
||||
export interface SettingsFixture {
|
||||
testDataDir: string;
|
||||
resetStorage(): Promise<void>;
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate an isolated DATA_DIR + reset DB state per test. Must run BEFORE
|
||||
* any DB modules are imported by the test file.
|
||||
*/
|
||||
export function setupSettingsFixture(slug: string): SettingsFixture {
|
||||
const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), `omr-settings-mock-${slug}-`));
|
||||
process.env.DATA_DIR = testDataDir;
|
||||
if (!process.env.API_KEY_SECRET) {
|
||||
process.env.API_KEY_SECRET = `test-settings-mock-secret-${Date.now()}`;
|
||||
}
|
||||
|
||||
const fixture: SettingsFixture = {
|
||||
testDataDir,
|
||||
async resetStorage() {
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const runtime = await import("../../../src/lib/config/runtimeSettings.ts");
|
||||
core.resetDbInstance();
|
||||
runtime.resetRuntimeSettingsStateForTests();
|
||||
fs.rmSync(testDataDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(testDataDir, { recursive: true });
|
||||
},
|
||||
cleanup() {
|
||||
fs.rmSync(testDataDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
activeFixture = fixture;
|
||||
return fixture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a partial Settings patch through the production
|
||||
* `updateSettings → applyRuntimeSettings` pipeline. Hot-reload side effects
|
||||
* (route guard snapshot, etc.) fire exactly as they do in `PATCH /api/settings`.
|
||||
*/
|
||||
export async function mockSettings(partial: SettingsPatch): Promise<Record<string, unknown>> {
|
||||
const settingsDb = await import("../../../src/lib/db/settings.ts");
|
||||
return settingsDb.updateSettings(partial);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the in-process runtime snapshot to the cold-boot default. Called by
|
||||
* test `beforeEach` hooks that need a clean slate without nuking the whole
|
||||
* fixture directory.
|
||||
*/
|
||||
export async function resetSettingsMock(): Promise<void> {
|
||||
const runtime = await import("../../../src/lib/config/runtimeSettings.ts");
|
||||
runtime.resetRuntimeSettingsStateForTests();
|
||||
if (activeFixture) {
|
||||
await activeFixture.resetStorage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
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";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-a2a-enabled-route-"));
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
const ORIGINAL_OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY;
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
delete process.env.OMNIROUTE_API_KEY;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const statusRoute = await import("../../src/app/api/a2a/status/route.ts");
|
||||
const a2aRoute = await import("../../src/app/a2a/route.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function makeJsonRpcRequest(body: unknown): NextRequest {
|
||||
return new Request("http://localhost/a2a", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}) as unknown as NextRequest;
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
delete process.env.OMNIROUTE_API_KEY;
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
|
||||
if (ORIGINAL_DATA_DIR === undefined) {
|
||||
delete process.env.DATA_DIR;
|
||||
} else {
|
||||
process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
}
|
||||
|
||||
if (ORIGINAL_OMNIROUTE_API_KEY === undefined) {
|
||||
delete process.env.OMNIROUTE_API_KEY;
|
||||
} else {
|
||||
process.env.OMNIROUTE_API_KEY = ORIGINAL_OMNIROUTE_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
test("A2A status reports disabled and offline when the endpoint is off", async () => {
|
||||
const response = await statusRoute.GET();
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.status, "disabled");
|
||||
assert.equal(body.enabled, false);
|
||||
assert.equal(body.online, false);
|
||||
assert.equal(body.agent, null);
|
||||
});
|
||||
|
||||
test("A2A JSON-RPC rejects requests while the endpoint is disabled", async () => {
|
||||
const response = await a2aRoute.POST(
|
||||
makeJsonRpcRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: "disabled-check",
|
||||
method: "message/send",
|
||||
params: { message: { role: "user", content: "hello" } },
|
||||
})
|
||||
);
|
||||
const body = (await response.json()) as {
|
||||
id?: string | number | null;
|
||||
error?: { code?: number; message?: string };
|
||||
};
|
||||
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal(body.id, "disabled-check");
|
||||
assert.equal(body.error?.code, -32000);
|
||||
assert.match(body.error?.message || "", /disabled/i);
|
||||
});
|
||||
|
||||
test("A2A JSON-RPC checks auth before returning disabled state", async () => {
|
||||
process.env.OMNIROUTE_API_KEY = "test-secret";
|
||||
|
||||
const response = await a2aRoute.POST(
|
||||
makeJsonRpcRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: "unauthorized-disabled-check",
|
||||
method: "message/send",
|
||||
params: { message: { role: "user", content: "hello" } },
|
||||
})
|
||||
);
|
||||
const body = (await response.json()) as {
|
||||
error?: { code?: number; message?: string };
|
||||
};
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(body.error?.code, -32600);
|
||||
assert.match(body.error?.message || "", /Unauthorized/i);
|
||||
});
|
||||
|
||||
test("A2A status reports online only after enabling the endpoint", async () => {
|
||||
await settingsDb.updateSettings({ a2aEnabled: true });
|
||||
|
||||
const response = await statusRoute.GET();
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.status, "ok");
|
||||
assert.equal(body.enabled, true);
|
||||
assert.equal(body.online, true);
|
||||
assert.equal(typeof body.tasks, "object");
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { logRoutingDecision } from "../../src/lib/a2a/routingLogger.ts";
|
||||
|
||||
test("logRoutingDecision records a routing decision with generated metadata", () => {
|
||||
const decision = logRoutingDecision({
|
||||
taskType: "chat",
|
||||
comboId: "combo-1",
|
||||
providerSelected: "openai",
|
||||
modelUsed: "gpt-4o-mini",
|
||||
score: 0.91,
|
||||
factors: [
|
||||
{
|
||||
name: "health",
|
||||
value: 1,
|
||||
weight: 0.5,
|
||||
contribution: 0.5,
|
||||
},
|
||||
],
|
||||
fallbacksTriggered: [],
|
||||
success: true,
|
||||
latencyMs: 123,
|
||||
cost: 0.001,
|
||||
});
|
||||
|
||||
assert.equal(typeof decision.requestId, "string");
|
||||
assert.match(decision.requestId, /^[0-9a-f-]{36}$/);
|
||||
assert.match(decision.timestamp, /^\d{4}-\d{2}-\d{2}T/);
|
||||
assert.equal(decision.providerSelected, "openai");
|
||||
assert.equal(decision.factors[0].name, "health");
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { inferRequiredScope } from "../../src/server/authz/accessScopes.ts";
|
||||
|
||||
test("read methods default to read", () => {
|
||||
assert.equal(inferRequiredScope("GET", "/api/v1/models"), "read");
|
||||
assert.equal(inferRequiredScope("HEAD", "/api/health"), "read");
|
||||
assert.equal(inferRequiredScope("OPTIONS", "/api/anything"), "read");
|
||||
});
|
||||
|
||||
test("mutating methods default to write", () => {
|
||||
assert.equal(inferRequiredScope("POST", "/api/keys"), "write");
|
||||
assert.equal(inferRequiredScope("PUT", "/api/config"), "write");
|
||||
assert.equal(inferRequiredScope("PATCH", "/api/combo/x"), "write");
|
||||
assert.equal(inferRequiredScope("DELETE", "/api/keys/abc"), "write");
|
||||
});
|
||||
|
||||
test("admin-prefix routes require admin for ANY method", () => {
|
||||
assert.equal(inferRequiredScope("GET", "/api/cli/tokens"), "admin");
|
||||
assert.equal(inferRequiredScope("POST", "/api/cli/tokens"), "admin");
|
||||
assert.equal(inferRequiredScope("DELETE", "/api/cli/tokens/tok_1"), "admin");
|
||||
assert.equal(inferRequiredScope("GET", "/api/oauth/start"), "admin");
|
||||
assert.equal(inferRequiredScope("POST", "/api/auth/login"), "admin");
|
||||
assert.equal(inferRequiredScope("POST", "/api/policy"), "admin");
|
||||
assert.equal(inferRequiredScope("POST", "/api/services/foo/start"), "admin");
|
||||
});
|
||||
|
||||
test("admin-mutation prefixes: GET stays read, mutations become admin", () => {
|
||||
// providers: status is read, but creating/rotating is admin
|
||||
assert.equal(inferRequiredScope("GET", "/api/providers/status"), "read");
|
||||
assert.equal(inferRequiredScope("GET", "/api/providers"), "read");
|
||||
assert.equal(inferRequiredScope("POST", "/api/providers"), "admin");
|
||||
assert.equal(inferRequiredScope("DELETE", "/api/providers/openai"), "admin");
|
||||
// cli-tools/apply writes to the host fs
|
||||
assert.equal(inferRequiredScope("POST", "/api/cli-tools/apply"), "admin");
|
||||
});
|
||||
|
||||
test("a brand-new mutating route is write by default (not admin)", () => {
|
||||
assert.equal(inferRequiredScope("POST", "/api/some-future-route"), "write");
|
||||
assert.equal(inferRequiredScope("GET", "/api/some-future-route"), "read");
|
||||
});
|
||||
|
||||
test("prefix matching does not over-match unrelated paths", () => {
|
||||
// "/api/authz-inventory" must NOT be caught by the "/api/auth" admin prefix
|
||||
assert.equal(inferRequiredScope("GET", "/api/authz-inventory"), "read");
|
||||
// "/api/services" itself and its children are admin, but a lookalike is not
|
||||
assert.equal(inferRequiredScope("GET", "/api/services-catalog"), "read");
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
ACCESS_SCOPES,
|
||||
isAccessScope,
|
||||
scopeSatisfies,
|
||||
normalizeScope,
|
||||
} from "../../src/lib/accessTokens/scopes.ts";
|
||||
|
||||
test("ACCESS_SCOPES are exactly read/write/admin", () => {
|
||||
assert.deepEqual([...ACCESS_SCOPES], ["read", "write", "admin"]);
|
||||
});
|
||||
|
||||
test("isAccessScope accepts valid scopes and rejects anything else", () => {
|
||||
assert.equal(isAccessScope("read"), true);
|
||||
assert.equal(isAccessScope("write"), true);
|
||||
assert.equal(isAccessScope("admin"), true);
|
||||
assert.equal(isAccessScope("superuser"), false);
|
||||
assert.equal(isAccessScope(""), false);
|
||||
assert.equal(isAccessScope(null), false);
|
||||
assert.equal(isAccessScope(undefined), false);
|
||||
assert.equal(isAccessScope(3), false);
|
||||
});
|
||||
|
||||
test("scopeSatisfies enforces the admin ⊃ write ⊃ read hierarchy", () => {
|
||||
// admin satisfies everything
|
||||
assert.equal(scopeSatisfies("admin", "read"), true);
|
||||
assert.equal(scopeSatisfies("admin", "write"), true);
|
||||
assert.equal(scopeSatisfies("admin", "admin"), true);
|
||||
// write satisfies read+write, not admin
|
||||
assert.equal(scopeSatisfies("write", "read"), true);
|
||||
assert.equal(scopeSatisfies("write", "write"), true);
|
||||
assert.equal(scopeSatisfies("write", "admin"), false);
|
||||
// read satisfies only read
|
||||
assert.equal(scopeSatisfies("read", "read"), true);
|
||||
assert.equal(scopeSatisfies("read", "write"), false);
|
||||
assert.equal(scopeSatisfies("read", "admin"), false);
|
||||
});
|
||||
|
||||
test("scopeSatisfies returns false for invalid `have` scopes (fail closed)", () => {
|
||||
assert.equal(scopeSatisfies("bogus", "read"), false);
|
||||
assert.equal(scopeSatisfies(null, "read"), false);
|
||||
assert.equal(scopeSatisfies(undefined, "read"), false);
|
||||
assert.equal(scopeSatisfies("", "read"), false);
|
||||
});
|
||||
|
||||
test("normalizeScope falls back to read by default for invalid input", () => {
|
||||
assert.equal(normalizeScope("write"), "write");
|
||||
assert.equal(normalizeScope("admin"), "admin");
|
||||
assert.equal(normalizeScope("bogus"), "read");
|
||||
assert.equal(normalizeScope(undefined), "read");
|
||||
assert.equal(normalizeScope(null), "read");
|
||||
});
|
||||
|
||||
test("normalizeScope honors a custom fallback", () => {
|
||||
assert.equal(normalizeScope("bogus", "write"), "write");
|
||||
assert.equal(normalizeScope(undefined, "admin"), "admin");
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
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";
|
||||
|
||||
// DB-backed access-token store. Uses an isolated DATA_DIR + closes the handle in
|
||||
// test.after (CLAUDE.md "Database Handles in Tests" — otherwise Node's runner hangs).
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-access-tokens-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const at = await import("../../src/lib/db/accessTokens.ts");
|
||||
|
||||
test.after(() => {
|
||||
try {
|
||||
core.resetDbInstance();
|
||||
} catch {}
|
||||
try {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
test("createAccessToken returns a secret prefixed oma_live_ and a masked record", () => {
|
||||
const { record, secret } = at.createAccessToken({ name: "laptop", scope: "write" });
|
||||
assert.match(secret, /^oma_live_/);
|
||||
assert.equal(record.name, "laptop");
|
||||
assert.equal(record.scope, "write");
|
||||
assert.ok(record.id.startsWith("tok_"));
|
||||
assert.ok(secret.startsWith(record.tokenPrefix), "prefix must be a prefix of the secret");
|
||||
assert.equal(record.revokedAt, null);
|
||||
});
|
||||
|
||||
test("createAccessToken defaults to the safest scope (read) for invalid input", () => {
|
||||
const { record } = at.createAccessToken({ name: "x", scope: "bogus" });
|
||||
assert.equal(record.scope, "read");
|
||||
});
|
||||
|
||||
test("createAccessToken rejects an empty name", () => {
|
||||
assert.throws(() => at.createAccessToken({ name: " ", scope: "read" }), /name is required/);
|
||||
});
|
||||
|
||||
test("verifyAccessToken returns identity+scope for a valid secret, null for wrong", () => {
|
||||
const { secret } = at.createAccessToken({ name: "verify-me", scope: "admin" });
|
||||
const v = at.verifyAccessToken(secret);
|
||||
assert.ok(v);
|
||||
assert.equal(v?.scope, "admin");
|
||||
assert.equal(v?.name, "verify-me");
|
||||
assert.equal(at.verifyAccessToken("oma_live_wrong"), null);
|
||||
assert.equal(at.verifyAccessToken(""), null);
|
||||
assert.equal(at.verifyAccessToken(null), null);
|
||||
});
|
||||
|
||||
test("only the hash is stored — the plaintext secret never lands in the DB", () => {
|
||||
const { secret, record } = at.createAccessToken({ name: "secrecy", scope: "read" });
|
||||
const db = core.getDbInstance();
|
||||
const row = db
|
||||
.prepare("SELECT token_hash, token_prefix FROM cli_access_tokens WHERE id = ?")
|
||||
.get(record.id) as { token_hash: string; token_prefix: string };
|
||||
assert.notEqual(row.token_hash, secret, "must store hash, not plaintext");
|
||||
assert.equal(row.token_hash, at.hashAccessToken(secret));
|
||||
assert.equal(row.token_hash.length, 64, "sha-256 hex");
|
||||
});
|
||||
|
||||
test("verifyAccessToken stamps last_used_at", () => {
|
||||
const { secret, record } = at.createAccessToken({ name: "touch", scope: "read" });
|
||||
assert.equal(at.getAccessToken(record.id)?.lastUsedAt, null);
|
||||
at.verifyAccessToken(secret);
|
||||
assert.notEqual(at.getAccessToken(record.id)?.lastUsedAt, null);
|
||||
});
|
||||
|
||||
test("revoked tokens fail verification", () => {
|
||||
const { secret, record } = at.createAccessToken({ name: "to-revoke", scope: "write" });
|
||||
assert.ok(at.verifyAccessToken(secret));
|
||||
assert.equal(at.revokeAccessToken(record.id), true);
|
||||
assert.equal(at.verifyAccessToken(secret), null);
|
||||
// idempotent: revoking again is a no-op
|
||||
assert.equal(at.revokeAccessToken(record.id), false);
|
||||
});
|
||||
|
||||
test("revokeAccessToken works by display prefix too", () => {
|
||||
const { secret, record } = at.createAccessToken({ name: "by-prefix", scope: "read" });
|
||||
assert.equal(at.revokeAccessToken(record.tokenPrefix), true);
|
||||
assert.equal(at.verifyAccessToken(secret), null);
|
||||
});
|
||||
|
||||
test("expired tokens fail verification", () => {
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
const { secret } = at.createAccessToken({ name: "expired", scope: "admin", expiresAt: past });
|
||||
assert.equal(at.verifyAccessToken(secret), null);
|
||||
});
|
||||
|
||||
test("listAccessTokens returns masked records (no secret/hash field)", () => {
|
||||
at.createAccessToken({ name: "listed", scope: "read" });
|
||||
const list = at.listAccessTokens();
|
||||
assert.ok(list.length >= 1);
|
||||
for (const rec of list) {
|
||||
assert.ok("tokenPrefix" in rec);
|
||||
assert.ok(!("secret" in rec));
|
||||
assert.ok(!("tokenHash" in rec));
|
||||
assert.ok(!("token_hash" in rec));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { after, beforeEach, describe, it } 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-account-cap-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const { updateProviderConnectionSchema } = await import("../../src/shared/validation/schemas.ts");
|
||||
|
||||
type Connection = Awaited<ReturnType<typeof providersDb.createProviderConnection>>;
|
||||
type StoredConnection = Awaited<ReturnType<typeof providersDb.getProviderConnectionById>>;
|
||||
|
||||
function assertConnection(connection: Connection): asserts connection is NonNullable<Connection> {
|
||||
assert.ok(connection);
|
||||
}
|
||||
|
||||
function assertStoredConnection(
|
||||
connection: StoredConnection
|
||||
): asserts connection is NonNullable<StoredConnection> {
|
||||
assert.ok(connection);
|
||||
}
|
||||
|
||||
function getConnectionId(connection: NonNullable<Connection>): string {
|
||||
assert.equal(typeof connection.id, "string");
|
||||
return connection.id as string;
|
||||
}
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function createConnection(maxConcurrent: number | null): Promise<Connection> {
|
||||
return providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: `openai-${String(maxConcurrent)}-${Math.random().toString(16).slice(2, 8)}`,
|
||||
apiKey: "sk-test",
|
||||
maxConcurrent,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("maxConcurrent DB round-trip", () => {
|
||||
it("stores and retrieves maxConcurrent=3", async () => {
|
||||
const created = await createConnection(3);
|
||||
assertConnection(created);
|
||||
const connectionId = getConnectionId(created);
|
||||
|
||||
const stored = await providersDb.getProviderConnectionById(connectionId);
|
||||
assertStoredConnection(stored);
|
||||
assert.equal(stored.maxConcurrent, 3);
|
||||
});
|
||||
|
||||
it("stores and retrieves maxConcurrent=null", async () => {
|
||||
const created = await createConnection(null);
|
||||
assertConnection(created);
|
||||
const connectionId = getConnectionId(created);
|
||||
|
||||
const stored = await providersDb.getProviderConnectionById(connectionId);
|
||||
assertStoredConnection(stored);
|
||||
assert.equal(stored.maxConcurrent, null);
|
||||
});
|
||||
|
||||
it("stores and retrieves maxConcurrent=0", async () => {
|
||||
const created = await createConnection(3);
|
||||
assertConnection(created);
|
||||
const connectionId = getConnectionId(created);
|
||||
|
||||
const updated = await providersDb.updateProviderConnection(connectionId, { maxConcurrent: 0 });
|
||||
assertConnection(updated);
|
||||
assert.equal(updated.maxConcurrent, 0);
|
||||
|
||||
const stored = await providersDb.getProviderConnectionById(connectionId);
|
||||
assertStoredConnection(stored);
|
||||
assert.equal(stored.maxConcurrent, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maxConcurrent validation", () => {
|
||||
it("rejects negative values", () => {
|
||||
const result = updateProviderConnectionSchema.safeParse({ maxConcurrent: -1 });
|
||||
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
it("accepts positive integers", () => {
|
||||
const result = updateProviderConnectionSchema.safeParse({ maxConcurrent: 3 });
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.data.maxConcurrent, 3);
|
||||
});
|
||||
|
||||
it("accepts null/undefined as unlimited", () => {
|
||||
const nullResult = updateProviderConnectionSchema.safeParse({ maxConcurrent: null });
|
||||
const undefinedResult = updateProviderConnectionSchema.safeParse({ name: "unchanged" });
|
||||
|
||||
assert.equal(nullResult.success, true);
|
||||
assert.equal(nullResult.data.maxConcurrent, null);
|
||||
assert.equal(undefinedResult.success, true);
|
||||
assert.equal(undefinedResult.data.maxConcurrent, undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Issue #2321 — Anthropic OAuth (Claude Pro/Team) 429 responses with phrases
|
||||
* like "Usage Limit Reached" must be classified as QUOTA_EXHAUSTED with a
|
||||
* long cooldown, not as a generic RATE_LIMIT_EXCEEDED with a ~5s base
|
||||
* cooldown. Without this fix all Pro accounts on the same subscription
|
||||
* cascade into a tight retry loop until the 5h quota window resets.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { classifyErrorText, parseRetryFromErrorText, checkFallbackError, getProviderProfile } =
|
||||
await import("../../open-sse/services/accountFallback.ts");
|
||||
const { RateLimitReason } = await import("../../open-sse/config/constants.ts");
|
||||
|
||||
test("#2321 classifyErrorText flags 'Usage Limit Reached' as QUOTA_EXHAUSTED", () => {
|
||||
const out = classifyErrorText("Usage Limit Reached. Please wait until 10:00 AM");
|
||||
assert.equal(out, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
});
|
||||
|
||||
test("#2321 classifyErrorText flags 'Claude Pro usage limit reached' as QUOTA_EXHAUSTED", () => {
|
||||
const out = classifyErrorText("Claude Pro usage limit reached.");
|
||||
assert.equal(out, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
});
|
||||
|
||||
test("#2321 classifyErrorText flags possessive 'you've reached your usage limit'", () => {
|
||||
const out = classifyErrorText("Sorry — you've reached your usage limit for this model.");
|
||||
assert.equal(out, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
});
|
||||
|
||||
test("#2321 classifyErrorText still returns RATE_LIMIT_EXCEEDED for generic rate_limit", () => {
|
||||
// Regression guard: short-term rate limits (per-minute TPM) must remain
|
||||
// transient so we don't lock accounts for an hour after a normal burst.
|
||||
const out = classifyErrorText("rate_limit_exceeded: too many requests");
|
||||
assert.equal(out, RateLimitReason.RATE_LIMIT_EXCEEDED);
|
||||
});
|
||||
|
||||
test("#2321 parseRetryFromErrorText extracts an ISO timestamp", () => {
|
||||
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
const ms = parseRetryFromErrorText(`Usage Limit Reached. Try again at ${future}`);
|
||||
assert.ok(ms !== null, "expected non-null wait time");
|
||||
assert.ok(ms! > 30 * 60 * 1000 && ms! <= 60 * 60 * 1000, `expected ~1h wait, got ${ms}ms`);
|
||||
});
|
||||
|
||||
test("#2321 parseRetryFromErrorText ignores past ISO timestamps", () => {
|
||||
const past = "2020-01-01T00:00:00Z";
|
||||
const ms = parseRetryFromErrorText(`Try again at ${past}`);
|
||||
assert.equal(ms, null);
|
||||
});
|
||||
|
||||
test("#2321 parseRetryFromErrorText still handles the 'reset after Xh' format (backward compat)", () => {
|
||||
const ms = parseRetryFromErrorText("Your quota will reset after 1h30m");
|
||||
assert.equal(ms, (60 + 30) * 60 * 1000);
|
||||
});
|
||||
|
||||
test("#2321 checkFallbackError returns ~1h cooldown for OAuth 429 + Usage Limit Reached", () => {
|
||||
const out = checkFallbackError(
|
||||
429,
|
||||
"Usage Limit Reached. Please wait until 5h.",
|
||||
0,
|
||||
null,
|
||||
"claude" // OAuth provider
|
||||
);
|
||||
assert.equal(out.shouldFallback, true);
|
||||
assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
// Either the 1h default OR an upstream retry hint — both are far above
|
||||
// the ~5s base cooldown that caused the cascade.
|
||||
assert.ok(
|
||||
out.cooldownMs >= 5 * 60 * 1000,
|
||||
`expected long cooldown (>=5min), got ${out.cooldownMs}ms`
|
||||
);
|
||||
});
|
||||
|
||||
test("#2321 checkFallbackError ignores ISO timestamp when upstream retry hints are disabled", () => {
|
||||
const future = new Date(Date.now() + 45 * 60 * 1000).toISOString();
|
||||
const out = checkFallbackError(
|
||||
429,
|
||||
`Claude Pro usage limit reached. Try again at ${future}`,
|
||||
0,
|
||||
null,
|
||||
"claude"
|
||||
);
|
||||
assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
assert.equal(out.usedUpstreamRetryHint, false);
|
||||
assert.equal(out.cooldownMs, 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
test("#2321 checkFallbackError honors ISO timestamp when upstream retry hints are enabled", () => {
|
||||
const futureMs = 45 * 60 * 1000;
|
||||
const future = new Date(Date.now() + futureMs).toISOString();
|
||||
const profile = { ...getProviderProfile("claude"), useUpstreamRetryHints: true };
|
||||
const out = checkFallbackError(
|
||||
429,
|
||||
`Claude Pro usage limit reached. Try again at ${future}`,
|
||||
0,
|
||||
null,
|
||||
"claude",
|
||||
null,
|
||||
profile
|
||||
);
|
||||
assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
// Within ~30s of the requested wait time.
|
||||
assert.ok(
|
||||
Math.abs(out.cooldownMs - futureMs) < 30_000,
|
||||
`expected ~${futureMs}ms cooldown, got ${out.cooldownMs}ms`
|
||||
);
|
||||
});
|
||||
|
||||
test("#2321 generic 429 without quota keyword still gets the short cooldown path", () => {
|
||||
// Regression guard: plain rate-limit must NOT get the 1h cooldown.
|
||||
const out = checkFallbackError(429, "rate_limit_exceeded", 0, null, "claude");
|
||||
assert.equal(out.shouldFallback, true);
|
||||
// Generic 429 keeps the short retryable path (< 5 min).
|
||||
assert.ok(
|
||||
out.cooldownMs < 5 * 60 * 1000,
|
||||
`expected short cooldown (<5min), got ${out.cooldownMs}ms`
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { parseRetryFromErrorText } = await import("../../open-sse/services/accountFallback.ts");
|
||||
|
||||
test("parseRetryFromErrorText reads nested ISO retryAfter from a 429 JSON body", () => {
|
||||
const futureIso = new Date(Date.now() + 120_000).toISOString();
|
||||
const waitMs = parseRetryFromErrorText(JSON.stringify({ error: { retryAfter: futureIso } }));
|
||||
assert.ok(waitMs !== null, "expected a parsed wait time, got null");
|
||||
assert.ok(Math.abs((waitMs as number) - 120_000) <= 2_000, `expected ~120000ms, got ${waitMs}`);
|
||||
});
|
||||
|
||||
test("parseRetryFromErrorText reads top-level retryAfter when nested field is absent", () => {
|
||||
const futureIso = new Date(Date.now() + 60_000).toISOString();
|
||||
const waitMs = parseRetryFromErrorText(JSON.stringify({ retryAfter: futureIso }));
|
||||
assert.ok(waitMs !== null, "expected a parsed wait time, got null");
|
||||
assert.ok(Math.abs((waitMs as number) - 60_000) <= 2_000, `expected ~60000ms, got ${waitMs}`);
|
||||
});
|
||||
|
||||
test("parseRetryFromErrorText reads millisecond retry hints from 429 JSON bodies", () => {
|
||||
assert.equal(parseRetryFromErrorText(JSON.stringify({ retry_after_ms: 45_000 })), 45_000);
|
||||
assert.equal(
|
||||
parseRetryFromErrorText(JSON.stringify({ error: { retry_after_ms: 12_000 } })),
|
||||
12_000
|
||||
);
|
||||
assert.equal(parseRetryFromErrorText(JSON.stringify({ retryAfterMs: 8_000 })), 8_000);
|
||||
});
|
||||
|
||||
test("parseRetryFromErrorText ignores past ISO retryAfter values in JSON bodies", () => {
|
||||
const pastIso = new Date(Date.now() - 60_000).toISOString();
|
||||
assert.equal(parseRetryFromErrorText(JSON.stringify({ error: { retryAfter: pastIso } })), null);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Issue #2929 — Fire Pass (fpk_*) API keys incorrectly marked as unavailable
|
||||
* when the models endpoint returns 403.
|
||||
*
|
||||
* Fireworks Fire Pass keys return `403 "Fire Pass API keys are not authorized
|
||||
* for this route."` on /models while still serving chat. That route-restriction
|
||||
* 403 used to fall into the api-key-403 branch (→ AUTH_ERROR retryable fallback)
|
||||
* or the generic "all other errors" default (→ transient cooldown), either of
|
||||
* which cools down / marks the connection unavailable.
|
||||
*
|
||||
* A route-restriction 403 must be treated as benign for connection health:
|
||||
* shouldFallback=false, no cooldown.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { checkFallbackError } = await import("../../open-sse/services/accountFallback.ts");
|
||||
|
||||
test("#2929 route-restriction 403 does NOT cool down the connection", () => {
|
||||
const result = checkFallbackError(
|
||||
403,
|
||||
"Fire Pass API keys are not authorized for this route.",
|
||||
0,
|
||||
null,
|
||||
"fireworks"
|
||||
);
|
||||
assert.equal(result.shouldFallback, false, "route-restriction 403 must not trigger fallback/cooldown");
|
||||
assert.equal(result.cooldownMs, 0, "route-restriction 403 must not impose a cooldown");
|
||||
});
|
||||
|
||||
test("#2929 a genuine api-key 403 still triggers fallback (no over-broadening)", () => {
|
||||
// A 403 whose body does NOT indicate a route restriction must keep the
|
||||
// existing behavior (auth-error / transient fallback), so real bad keys still
|
||||
// get cooled down.
|
||||
const result = checkFallbackError(403, "invalid api key", 0, null, "fireworks");
|
||||
assert.equal(
|
||||
result.shouldFallback,
|
||||
true,
|
||||
"a non-route-restriction 403 must still be treated as fallback-worthy"
|
||||
);
|
||||
});
|
||||
|
||||
test("#2929 case-insensitive match on the route-restriction phrase", () => {
|
||||
const result = checkFallbackError(
|
||||
403,
|
||||
"ERROR: Not Authorized For This Route",
|
||||
0,
|
||||
null,
|
||||
"fireworks"
|
||||
);
|
||||
assert.equal(result.shouldFallback, false);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { selectAccountP2C, selectAccount } =
|
||||
await import("../../open-sse/services/accountSelector.ts");
|
||||
|
||||
// ─── selectAccountP2C ───────────────────────────────────────────────────────
|
||||
|
||||
test("selectAccountP2C: returns null for empty array", () => {
|
||||
assert.equal(selectAccountP2C([]), null);
|
||||
assert.equal(selectAccountP2C(null), null);
|
||||
});
|
||||
|
||||
test("selectAccountP2C: returns single account", () => {
|
||||
const acct = { id: "a1" };
|
||||
assert.equal(selectAccountP2C([acct]), acct);
|
||||
});
|
||||
|
||||
test("selectAccountP2C: returns one of the candidates", () => {
|
||||
const accounts = [{ id: "a1" }, { id: "a2" }, { id: "a3" }];
|
||||
const selected = selectAccountP2C(accounts);
|
||||
assert.ok(accounts.includes(selected));
|
||||
});
|
||||
|
||||
test("selectAccountP2C: prefers healthier account over many runs", () => {
|
||||
// Account with error should be selected less often
|
||||
const healthy = { id: "healthy" };
|
||||
const degraded = { id: "degraded", error: true, rateLimited: true, backoffLevel: 3 };
|
||||
const accounts = [healthy, degraded];
|
||||
|
||||
let healthyCount = 0;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const selected = selectAccountP2C(accounts);
|
||||
if (selected.id === "healthy") healthyCount++;
|
||||
}
|
||||
// Should strongly prefer healthy account
|
||||
assert.ok(healthyCount > 60, `Expected healthy to win >60/100 times, got ${healthyCount}`);
|
||||
});
|
||||
|
||||
// ─── selectAccount strategies ───────────────────────────────────────────────
|
||||
|
||||
test("selectAccount: fill-first returns first", () => {
|
||||
const accounts = [{ id: "first" }, { id: "second" }];
|
||||
const { account } = selectAccount(accounts, "fill-first");
|
||||
assert.equal(account.id, "first");
|
||||
});
|
||||
|
||||
test("selectAccount: round-robin cycles", () => {
|
||||
const accounts = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
||||
let state = {};
|
||||
const results = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const { account, state: newState } = selectAccount(accounts, "round-robin", state);
|
||||
results.push(account.id);
|
||||
state = newState;
|
||||
}
|
||||
assert.deepEqual(results, ["a", "b", "c", "a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("selectAccount: random returns valid account", () => {
|
||||
const accounts = [{ id: "x" }, { id: "y" }];
|
||||
const { account } = selectAccount(accounts, "random");
|
||||
assert.ok(accounts.includes(account));
|
||||
});
|
||||
|
||||
test("selectAccount: p2c returns valid account", () => {
|
||||
const accounts = [{ id: "p1" }, { id: "p2" }, { id: "p3" }];
|
||||
const { account } = selectAccount(accounts, "p2c");
|
||||
assert.ok(accounts.includes(account));
|
||||
});
|
||||
|
||||
test("selectAccount: empty accounts returns null", () => {
|
||||
const { account } = selectAccount([], "fill-first");
|
||||
assert.equal(account, null);
|
||||
});
|
||||
|
||||
// ─── Round-robin fallback scenario (Issue #340) ─────────────────────────────
|
||||
|
||||
test("selectAccount: round-robin with excludeConnectionId skips excluded", () => {
|
||||
const accounts = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
||||
|
||||
// Simulate: first request picks 'a', then it fails
|
||||
// Second request should exclude 'a' and pick 'b'
|
||||
let state = {};
|
||||
|
||||
// First request - no exclusion
|
||||
const { account: acc1 } = selectAccount(accounts, "round-robin", state);
|
||||
state = { lastIndex: 0 }; // Simulate 'a' was picked
|
||||
|
||||
// 'a' fails, exclude it
|
||||
const { account: acc2 } = selectAccount(
|
||||
accounts.filter((a) => a.id !== "a"),
|
||||
"round-robin",
|
||||
state
|
||||
);
|
||||
|
||||
// Should pick 'b' or 'c', not 'a'
|
||||
assert.notEqual(acc2.id, "a", "Should not pick excluded account");
|
||||
assert.ok(["b", "c"].includes(acc2.id), "Should pick from remaining accounts");
|
||||
});
|
||||
|
||||
test("selectAccount: round-robin respects state across calls", () => {
|
||||
const accounts = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
||||
let state = { lastIndex: -1 };
|
||||
|
||||
// First call should pick index 0
|
||||
const { account: acc1, state: state1 } = selectAccount(accounts, "round-robin", state);
|
||||
assert.equal(acc1.id, "a");
|
||||
assert.equal(state1.lastIndex, 0);
|
||||
|
||||
// Second call should pick index 1
|
||||
const { account: acc2, state: state2 } = selectAccount(accounts, "round-robin", state1);
|
||||
assert.equal(acc2.id, "b");
|
||||
assert.equal(state2.lastIndex, 1);
|
||||
|
||||
// Third call should pick index 2
|
||||
const { account: acc3, state: state3 } = selectAccount(accounts, "round-robin", state2);
|
||||
assert.equal(acc3.id, "c");
|
||||
assert.equal(state3.lastIndex, 2);
|
||||
|
||||
// Fourth call should wrap to index 0
|
||||
const { account: acc4 } = selectAccount(accounts, "round-robin", state3);
|
||||
assert.equal(acc4.id, "a");
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
acquire,
|
||||
buildAccountSemaphoreKey,
|
||||
getStats,
|
||||
markBlocked,
|
||||
reset,
|
||||
resetAll,
|
||||
} from "../../open-sse/services/accountSemaphore";
|
||||
|
||||
afterEach(() => {
|
||||
resetAll();
|
||||
});
|
||||
|
||||
describe("accountSemaphore", async () => {
|
||||
it("queues requests beyond the account cap and drains on release", async () => {
|
||||
const key = buildAccountSemaphoreKey({
|
||||
provider: "alibaba",
|
||||
accountKey: "acct-1",
|
||||
});
|
||||
|
||||
const releaseA = await acquire(key, { maxConcurrency: 2, timeoutMs: 200 });
|
||||
const releaseB = await acquire(key, { maxConcurrency: 2, timeoutMs: 200 });
|
||||
const queued = acquire(key, { maxConcurrency: 2, timeoutMs: 200 });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
assert.deepEqual(getStats()[key], {
|
||||
running: 2,
|
||||
queued: 1,
|
||||
maxConcurrency: 2,
|
||||
blockedUntil: null,
|
||||
});
|
||||
|
||||
releaseA();
|
||||
const releaseC = await queued;
|
||||
|
||||
assert.deepEqual(getStats()[key], {
|
||||
running: 2,
|
||||
queued: 0,
|
||||
maxConcurrency: 2,
|
||||
blockedUntil: null,
|
||||
});
|
||||
|
||||
releaseA();
|
||||
releaseB();
|
||||
releaseC();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(getStats()[key], undefined);
|
||||
});
|
||||
|
||||
it("returns a no-op release when concurrency is bypassed", async () => {
|
||||
const key = buildAccountSemaphoreKey({
|
||||
provider: "alibaba",
|
||||
accountKey: "acct-bypass",
|
||||
});
|
||||
|
||||
const release = await acquire(key, { maxConcurrency: 0, timeoutMs: 50 });
|
||||
|
||||
assert.deepEqual(getStats(), {});
|
||||
|
||||
release();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(getStats()[key], undefined);
|
||||
});
|
||||
|
||||
it("uses SEMAPHORE_TIMEOUT for timed out queued requests", async () => {
|
||||
const key = buildAccountSemaphoreKey({
|
||||
provider: "alibaba",
|
||||
accountKey: "acct-timeout",
|
||||
});
|
||||
|
||||
const releaseA = await acquire(key, { maxConcurrency: 1, timeoutMs: 200 });
|
||||
const queued = acquire(key, { maxConcurrency: 1, timeoutMs: 200 });
|
||||
const keepAlive = setTimeout(() => {}, 250);
|
||||
|
||||
try {
|
||||
await queued;
|
||||
assert.fail("Expected timeout error");
|
||||
} catch (err: unknown) {
|
||||
assert.ok(err instanceof Error);
|
||||
const error = err as Error & { code?: string };
|
||||
assert.equal(error.code, "SEMAPHORE_TIMEOUT");
|
||||
} finally {
|
||||
clearTimeout(keepAlive);
|
||||
}
|
||||
|
||||
releaseA();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(getStats()[key], undefined);
|
||||
});
|
||||
|
||||
it("keeps release idempotent for finally blocks", async () => {
|
||||
const key = buildAccountSemaphoreKey({
|
||||
provider: "alibaba",
|
||||
accountKey: "acct-idempotent",
|
||||
});
|
||||
|
||||
const releaseA = await acquire(key, { maxConcurrency: 1, timeoutMs: 200 });
|
||||
|
||||
// Simulate a finally block calling release twice
|
||||
releaseA();
|
||||
releaseA();
|
||||
releaseA();
|
||||
|
||||
// The second acquire should succeed immediately (slot was released)
|
||||
const releaseB = await acquire(key, { maxConcurrency: 1, timeoutMs: 200 });
|
||||
|
||||
assert.deepEqual(getStats()[key], {
|
||||
running: 1,
|
||||
queued: 0,
|
||||
maxConcurrency: 1,
|
||||
blockedUntil: null,
|
||||
});
|
||||
|
||||
releaseB();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(getStats()[key], undefined);
|
||||
});
|
||||
|
||||
it("supports temporary blocking and explicit reset hooks", async () => {
|
||||
const key = buildAccountSemaphoreKey({
|
||||
provider: "alibaba",
|
||||
accountKey: "acct-blocked",
|
||||
});
|
||||
|
||||
await acquire(key, { maxConcurrency: 1, timeoutMs: 200 });
|
||||
|
||||
assert.deepEqual(getStats()[key], {
|
||||
running: 1,
|
||||
queued: 0,
|
||||
maxConcurrency: 1,
|
||||
blockedUntil: null,
|
||||
});
|
||||
|
||||
markBlocked(key, 50);
|
||||
|
||||
// Should block even though slot is available
|
||||
const acquired = acquire(key, { maxConcurrency: 1, timeoutMs: 100 });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
|
||||
// Should still be queued because the gate is blocked
|
||||
const stats = getStats()[key];
|
||||
assert.equal(stats.running, 1);
|
||||
assert.equal(stats.queued, 1);
|
||||
assert.equal(stats.maxConcurrency, 1);
|
||||
assert.ok(stats.blockedUntil !== null, "blockedUntil should be set");
|
||||
|
||||
reset(key);
|
||||
|
||||
await assert.rejects(async () => {
|
||||
await acquired;
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves existing maxConcurrency when markBlocked is applied", async () => {
|
||||
const key = buildAccountSemaphoreKey({
|
||||
provider: "alibaba",
|
||||
accountKey: "acct-preserve",
|
||||
});
|
||||
|
||||
await acquire(key, { maxConcurrency: 2, timeoutMs: 200 });
|
||||
markBlocked(key, 50);
|
||||
|
||||
const stats = getStats()[key];
|
||||
assert.equal(stats.running, 1);
|
||||
assert.equal(stats.queued, 0);
|
||||
assert.equal(stats.maxConcurrency, 2);
|
||||
assert.ok(stats.blockedUntil !== null, "blockedUntil should be set");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// #4976 — A 400 response whose body carries rate-limit semantics (e.g. MiMoCode's
|
||||
// "Detected high-frequency non-compliant requests from you.") was misclassified as a
|
||||
// non-fallbackable generic 400, so MiMo-auto combo never failed over and the raw
|
||||
// failure surfaced to Cline as `[502]: fetch failed`. checkFallbackError must now
|
||||
// detect rate-limit text on a 400 and treat it as fallback-worthy
|
||||
// (RATE_LIMIT_EXCEEDED, connection-cooldown scope) WITHOUT regressing the #2101
|
||||
// malformed-400 infinite-loop guard.
|
||||
|
||||
const { checkFallbackError } = await import("../../open-sse/services/accountFallback.ts");
|
||||
const { RateLimitReason } = await import("../../open-sse/config/constants.ts");
|
||||
|
||||
test("#4976 400 with rate-limit text (MiMoCode) → fallback with RATE_LIMIT_EXCEEDED", () => {
|
||||
const res = checkFallbackError(
|
||||
400,
|
||||
"Detected high-frequency non-compliant requests from you.",
|
||||
0,
|
||||
null,
|
||||
"mimocode"
|
||||
);
|
||||
assert.equal(res.shouldFallback, true);
|
||||
assert.equal(res.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
|
||||
});
|
||||
|
||||
test("#4976 400 with Chinese rate-limit text → fallback with RATE_LIMIT_EXCEEDED", () => {
|
||||
const res = checkFallbackError(400, "检测到您的请求频率过高,请稍后再试", 0, null, "mimocode");
|
||||
assert.equal(res.shouldFallback, true);
|
||||
assert.equal(res.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
|
||||
});
|
||||
|
||||
test("#4976 regression: a generic non-rate-limit 400 still does NOT fall over (#2101 guard)", () => {
|
||||
const res = checkFallbackError(400, "Invalid JSON: unexpected token at position 12");
|
||||
assert.equal(res.shouldFallback, false);
|
||||
});
|
||||
|
||||
test("#4976 regression: a malformed 400 stays MODEL_CAPACITY, not reclassified as rate-limit", () => {
|
||||
// Malformed-request detection must win over the new rate-limit text check so the
|
||||
// #2101 infinite-loop guard (zero-cooldown MODEL_CAPACITY) is preserved.
|
||||
const res = checkFallbackError(400, "messages must alternate between user and assistant");
|
||||
assert.equal(res.shouldFallback, true);
|
||||
assert.equal(res.reason, RateLimitReason.MODEL_CAPACITY);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
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";
|
||||
import { SignJWT } from "jose";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-acp-agents-route-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "acp-agents-route-api-key-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const localDb = await import("../../src/lib/localDb.ts");
|
||||
const routeModule = await import("../../src/app/api/acp/agents/route.ts");
|
||||
|
||||
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
|
||||
const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
delete process.env.JWT_SECRET;
|
||||
}
|
||||
|
||||
async function createSessionToken() {
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
return new SignJWT({ authenticated: true })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("1h")
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
function makeRequest(method: string, body?: unknown, token?: string) {
|
||||
return new Request("http://localhost/api/acp/agents", {
|
||||
method,
|
||||
headers: {
|
||||
...(body ? { "content-type": "application/json" } : {}),
|
||||
...(token ? { cookie: `auth_token=${token}` } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
|
||||
if (ORIGINAL_INITIAL_PASSWORD === undefined) {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
} else {
|
||||
process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
|
||||
}
|
||||
|
||||
if (ORIGINAL_JWT_SECRET === undefined) {
|
||||
delete process.env.JWT_SECRET;
|
||||
} else {
|
||||
process.env.JWT_SECRET = ORIGINAL_JWT_SECRET;
|
||||
}
|
||||
});
|
||||
|
||||
test("GET /api/acp/agents requires authentication when login is enabled", async () => {
|
||||
process.env.INITIAL_PASSWORD = "route-auth-required";
|
||||
|
||||
const response = await routeModule.GET(makeRequest("GET"));
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.equal(body.error, "Unauthorized");
|
||||
});
|
||||
|
||||
test("POST /api/acp/agents rejects unsafe version commands for authenticated sessions", async () => {
|
||||
process.env.JWT_SECRET = "acp-agents-jwt-secret";
|
||||
await localDb.updateSettings({ requireLogin: true, password: "hashed-password" });
|
||||
const token = await createSessionToken();
|
||||
|
||||
const response = await routeModule.POST(
|
||||
makeRequest(
|
||||
"POST",
|
||||
{
|
||||
id: "custom-agent",
|
||||
name: "Custom Agent",
|
||||
binary: "/usr/local/bin/custom-agent",
|
||||
versionCommand: "/usr/local/bin/custom-agent --version; touch /tmp/pwned",
|
||||
providerAlias: "custom-agent",
|
||||
spawnArgs: [],
|
||||
protocol: "stdio",
|
||||
},
|
||||
token
|
||||
)
|
||||
);
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.match(body.error, /Invalid versionCommand/i);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { resolveVersionProbe, shouldUseShellForVersionProbe } =
|
||||
await import("../../src/lib/acp/registry.ts");
|
||||
|
||||
test("resolveVersionProbe parses quoted binary paths without shell semantics", () => {
|
||||
const probe = resolveVersionProbe(
|
||||
"/tmp/My Custom Agent",
|
||||
'"/tmp/My Custom Agent" --version',
|
||||
true
|
||||
);
|
||||
|
||||
assert.deepEqual(probe, {
|
||||
command: "/tmp/My Custom Agent",
|
||||
args: ["--version"],
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveVersionProbe rejects custom version commands that switch binaries", () => {
|
||||
const probe = resolveVersionProbe("/tmp/custom-agent", 'bash -lc "id"', true);
|
||||
assert.equal(probe, null);
|
||||
});
|
||||
|
||||
test("resolveVersionProbe rejects shell metacharacters in version commands", () => {
|
||||
const probe = resolveVersionProbe(
|
||||
"/tmp/custom-agent",
|
||||
"/tmp/custom-agent --version; touch /tmp/pwned",
|
||||
true
|
||||
);
|
||||
assert.equal(probe, null);
|
||||
});
|
||||
|
||||
test("shouldUseShellForVersionProbe preserves Windows npm wrapper detection", () => {
|
||||
assert.equal(shouldUseShellForVersionProbe("codex", "win32"), true);
|
||||
assert.equal(
|
||||
shouldUseShellForVersionProbe("C:\\Users\\dev\\AppData\\Roaming\\npm\\qwen.cmd", "win32"),
|
||||
true
|
||||
);
|
||||
assert.equal(shouldUseShellForVersionProbe("C:\\Tools\\claude.exe", "win32"), false);
|
||||
assert.equal(shouldUseShellForVersionProbe("codex", "linux"), false);
|
||||
});
|
||||
@@ -0,0 +1,700 @@
|
||||
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-active-stream-lifecycle-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const usageHistory = await import("../../src/lib/usage/usageHistory.ts");
|
||||
const callLogs = await import("../../src/lib/usage/callLogs.ts");
|
||||
|
||||
// Captured stream chunks carry a per-chunk arrival-time prefix ("[HH:MM:SS.mmm] ")
|
||||
// added by the request logger for streaming-latency observability (#5834). Strip it
|
||||
// before comparing the raw chunk payload.
|
||||
const stripChunkTs = (chunk: string): string => chunk.replace(/^\[\d{2}:\d{2}:\d{2}\.\d{3}\] /, "");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── Helper: Simulates /api/logs/[id] API route logic ──────────────────────
|
||||
//
|
||||
// The production route uses 3-tier lookup:
|
||||
// 1. DB (getCallLogById)
|
||||
// 2. completedDetails in-memory cache (getCompletedDetails)
|
||||
// 3. pendingById in-memory active requests (getPendingById)
|
||||
//
|
||||
// Each tier constructs the API response shape including pipelinePayloads.
|
||||
|
||||
function buildApiResponseFromPending(id: string): Record<string, unknown> | null {
|
||||
const active = usageHistory.getPendingById().get(id);
|
||||
if (!active) return null;
|
||||
|
||||
const pipelinePayloads: Record<string, unknown> = {
|
||||
clientRequest: active.clientRequest ?? null,
|
||||
providerRequest: active.providerRequest ?? null,
|
||||
providerResponse: active.providerResponse ?? null,
|
||||
clientResponse: active.clientResponse ?? null,
|
||||
streamChunks: active.streamChunks ?? null,
|
||||
};
|
||||
|
||||
return {
|
||||
id: active.id,
|
||||
timestamp: new Date(active.startedAt).toISOString(),
|
||||
method: "",
|
||||
path: active.clientEndpoint || "",
|
||||
status: 0,
|
||||
model: active.model,
|
||||
provider: active.provider,
|
||||
connectionId: active.connectionId,
|
||||
duration: Date.now() - active.startedAt,
|
||||
detailState: "in-flight",
|
||||
active: true,
|
||||
pipelinePayloads,
|
||||
hasPipelineDetails: true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildApiResponseFromCompleted(id: string): Record<string, unknown> | null {
|
||||
const completed = usageHistory.getCompletedDetails();
|
||||
const inMem = completed.get(id);
|
||||
if (!inMem) return null;
|
||||
|
||||
const pipelinePayloads: Record<string, unknown> = {
|
||||
clientRequest: inMem.clientRequest ?? null,
|
||||
providerRequest: inMem.providerRequest ?? null,
|
||||
providerResponse: inMem.providerResponse ?? null,
|
||||
clientResponse: inMem.clientResponse ?? null,
|
||||
streamChunks: inMem.streamChunks ?? null,
|
||||
};
|
||||
|
||||
return {
|
||||
id: inMem.id,
|
||||
timestamp: new Date(inMem.startedAt).toISOString(),
|
||||
path: inMem.clientEndpoint || "",
|
||||
status: 0,
|
||||
model: inMem.model,
|
||||
provider: inMem.provider,
|
||||
connectionId: inMem.connectionId,
|
||||
duration: Date.now() - inMem.startedAt,
|
||||
detailState: "in-memory",
|
||||
active: false,
|
||||
pipelinePayloads,
|
||||
hasPipelineDetails: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Helper: Simulates frontend streamChunksText IIFE ──────────────────────
|
||||
function computeStreamChunksText(
|
||||
debugEnabled: boolean,
|
||||
pipelinePayloads: Record<string, unknown> | null | undefined
|
||||
): string | null {
|
||||
if (!debugEnabled || !pipelinePayloads?.streamChunks) return null;
|
||||
let chunks: unknown = pipelinePayloads.streamChunks;
|
||||
|
||||
if (typeof chunks === "string") {
|
||||
try {
|
||||
chunks = JSON.parse(chunks);
|
||||
} catch {
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks && typeof chunks === "object") {
|
||||
try {
|
||||
return Object.entries(chunks as Record<string, unknown>)
|
||||
.map(([stage, arr]) => {
|
||||
const joined = Array.isArray(arr) ? (arr as string[]).join("") : String(arr);
|
||||
return `--- ${stage} ---\n${joined}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
} catch {
|
||||
return JSON.stringify(chunks, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Helper: Simulates frontend openDetail state merge ─────────────────────
|
||||
function mergeDetailData(
|
||||
prev: Record<string, unknown> | null,
|
||||
data: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const dataHasPipeline =
|
||||
data?.pipelinePayloads && Object.keys(data.pipelinePayloads || {}).length > 0;
|
||||
return {
|
||||
...prev,
|
||||
...data,
|
||||
pipelinePayloads: dataHasPipeline ? data.pipelinePayloads : prev?.pipelinePayloads,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Test: Full lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
test("streamChunks survive the full lifecycle: in-flight → completed → persisted", async () => {
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
const model = "gpt-4";
|
||||
const provider = "openai";
|
||||
const connectionId = "conn-lifecycle-1";
|
||||
|
||||
// ── Phase 1: Track a pending request ──
|
||||
const requestId = usageHistory.trackPendingRequest(model, provider, connectionId, true, {
|
||||
clientRequest: { messages: [{ role: "user", content: "hello" }] },
|
||||
providerRequest: { model: "gpt-4" },
|
||||
clientEndpoint: "/v1/chat/completions",
|
||||
});
|
||||
|
||||
assert.ok(requestId, "trackPendingRequest should return a request ID");
|
||||
assert.ok(
|
||||
usageHistory.getPendingById().has(requestId),
|
||||
"request ID should be in pendingById immediately"
|
||||
);
|
||||
|
||||
// ── Phase 2: Simulate streaming — chunks arrive gradually ──
|
||||
// Round 1: provider chunks
|
||||
usageHistory.updatePendingRequestStreamChunks(model, provider, connectionId, {
|
||||
provider: ['data: {"type":"message_start"}\n\n'],
|
||||
openai: [],
|
||||
client: [],
|
||||
});
|
||||
|
||||
// Verify via pendingById (what the API reads)
|
||||
const apiResponse1 = buildApiResponseFromPending(requestId);
|
||||
assert.ok(apiResponse1, "API should find in-flight request");
|
||||
assert.ok(apiResponse1!.pipelinePayloads, "should have pipelinePayloads");
|
||||
assert.ok(
|
||||
(apiResponse1!.pipelinePayloads as Record<string, unknown>).streamChunks,
|
||||
"streamChunks should be present in API response"
|
||||
);
|
||||
|
||||
// Simulate frontend streamChunksText
|
||||
const text1 = computeStreamChunksText(
|
||||
true,
|
||||
apiResponse1!.pipelinePayloads as Record<string, unknown>
|
||||
);
|
||||
assert.ok(text1, "streamChunksText should be non-null with debugEnabled");
|
||||
assert.ok(text1!.includes("message_start"), "streamChunksText should contain the chunk content");
|
||||
|
||||
// Verify frontend state merge
|
||||
const initialDetail = mergeDetailData(null, apiResponse1 as unknown as Record<string, unknown>);
|
||||
assert.ok(
|
||||
(initialDetail.pipelinePayloads as Record<string, unknown>).streamChunks,
|
||||
"streamChunks should survive the openDetail state merge"
|
||||
);
|
||||
const text1after = computeStreamChunksText(
|
||||
true,
|
||||
initialDetail.pipelinePayloads as Record<string, unknown>
|
||||
);
|
||||
assert.ok(text1after, "streamChunksText should work after state merge");
|
||||
assert.ok(text1after!.includes("message_start"), "content preserved after state merge");
|
||||
|
||||
// Round 2: openai chunks arrive (simulating proxy relay)
|
||||
usageHistory.updatePendingRequestStreamChunks(model, provider, connectionId, {
|
||||
provider: ['data: {"type":"message_start"}\n\n'],
|
||||
openai: ['data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n'],
|
||||
client: [],
|
||||
});
|
||||
|
||||
// Round 3: client (converted) chunk arrives
|
||||
usageHistory.updatePendingRequestStreamChunks(model, provider, connectionId, {
|
||||
provider: ['data: {"type":"message_start"}\n\n'],
|
||||
openai: ['data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n'],
|
||||
client: ['data: {"content":"Hello"}\n\n'],
|
||||
});
|
||||
|
||||
// Verify API sees the latest (shows all stages now)
|
||||
const apiResponse2 = buildApiResponseFromPending(requestId);
|
||||
const streamChunks2 = (apiResponse2!.pipelinePayloads as Record<string, unknown>)
|
||||
.streamChunks as Record<string, string[]>;
|
||||
assert.equal(streamChunks2.provider.length, 1, "provider chunks: 1");
|
||||
assert.equal(streamChunks2.openai.length, 1, "openai chunks: 1");
|
||||
assert.equal(streamChunks2.client.length, 1, "client chunks: 1");
|
||||
|
||||
// Verify frontend text includes all 3 stages
|
||||
const text2 = computeStreamChunksText(
|
||||
true,
|
||||
apiResponse2!.pipelinePayloads as Record<string, unknown>
|
||||
);
|
||||
assert.ok(text2!.includes("--- provider ---"), "should include provider stage");
|
||||
assert.ok(text2!.includes("--- openai ---"), "should include openai stage");
|
||||
assert.ok(text2!.includes("--- client ---"), "should include client stage");
|
||||
|
||||
// Verify debugEnabled=false hides the stream
|
||||
const textHidden = computeStreamChunksText(
|
||||
false,
|
||||
apiResponse2!.pipelinePayloads as Record<string, unknown>
|
||||
);
|
||||
assert.equal(textHidden, null, "streamChunksText should be null when debugEnabled=false");
|
||||
|
||||
// Verify null pipelinePayloads hides the stream
|
||||
const textNoPayload = computeStreamChunksText(true, null);
|
||||
assert.equal(textNoPayload, null, "streamChunksText should be null without pipelinePayloads");
|
||||
|
||||
const textNoChunks = computeStreamChunksText(true, {});
|
||||
assert.equal(textNoChunks, null, "streamChunksText should be null without streamChunks key");
|
||||
|
||||
// ── Phase 3: Simulate request completion ──
|
||||
usageHistory.finalizeMostRecentPendingRequest(model, provider, connectionId, {
|
||||
status: 200,
|
||||
model,
|
||||
provider,
|
||||
clientResponse: { choices: [{ message: { content: "Hello" } }] },
|
||||
});
|
||||
|
||||
// The request should be in completedDetails now
|
||||
assert.ok(
|
||||
!usageHistory.getPendingById().has(requestId),
|
||||
"request should be removed from pendingById after finalization"
|
||||
);
|
||||
|
||||
const completedResponse = buildApiResponseFromCompleted(requestId);
|
||||
assert.ok(completedResponse, "API should find request in completedDetails");
|
||||
assert.ok(
|
||||
(completedResponse!.pipelinePayloads as Record<string, unknown>).streamChunks,
|
||||
"streamChunks should be in completedDetails"
|
||||
);
|
||||
|
||||
// Verify frontend can render from completed response
|
||||
const completedText = computeStreamChunksText(
|
||||
true,
|
||||
completedResponse!.pipelinePayloads as Record<string, unknown>
|
||||
);
|
||||
assert.ok(completedText, "streamChunksText should work from completed data");
|
||||
assert.ok(completedText!.includes("Hello"), "content preserved after completion");
|
||||
|
||||
// ── Phase 4: Persist to DB (simulates saveCallLog) ──
|
||||
await callLogs.saveCallLog({
|
||||
id: requestId,
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
status: 200,
|
||||
model,
|
||||
requestedModel: model,
|
||||
provider,
|
||||
connectionId,
|
||||
duration: 5000,
|
||||
tokens: { in: 10, out: 20 },
|
||||
requestBody: { messages: [{ role: "user", content: "hello" }] },
|
||||
responseBody: { choices: [{ message: { content: "Hello" } }] },
|
||||
error: null,
|
||||
sourceFormat: "openai",
|
||||
targetFormat: "openai",
|
||||
comboName: null,
|
||||
comboStepId: null,
|
||||
comboExecutionKey: null,
|
||||
tokensCompressed: null,
|
||||
cacheSource: "upstream",
|
||||
apiKeyId: null,
|
||||
apiKeyName: null,
|
||||
noLog: false,
|
||||
pipelinePayloads: completedResponse!.pipelinePayloads as Record<string, unknown>,
|
||||
});
|
||||
|
||||
// Verify DB has the entry
|
||||
const dbEntry = await callLogs.getCallLogById(requestId);
|
||||
assert.ok(dbEntry, "should find persisted call log by the same ID");
|
||||
assert.ok(dbEntry.pipelinePayloads, "persisted entry should have pipelinePayloads");
|
||||
|
||||
// The pipelinePayloads in the DB may have been compacted/truncated.
|
||||
// streamChunks may or may not be there depending on captureStreamChunks,
|
||||
// but the ID must match so the API can find it.
|
||||
assert.equal(
|
||||
(dbEntry as Record<string, unknown>).id,
|
||||
requestId,
|
||||
"DB entry ID should match the original request ID"
|
||||
);
|
||||
});
|
||||
|
||||
test("streamChunksText renders progressive updates correctly", () => {
|
||||
// Simulate the scenario where streamChunks grows between polls
|
||||
|
||||
// Poll 1: first chunk arrives
|
||||
const payload1 = {
|
||||
streamChunks: { provider: ['data: {"content":"A"}\n\n'], openai: [], client: [] },
|
||||
};
|
||||
const text1 = computeStreamChunksText(true, payload1);
|
||||
assert.ok(text1, "poll 1 should produce text");
|
||||
assert.ok(text1!.includes("A"), "poll 1 should show chunk A");
|
||||
|
||||
// Poll 2: second chunk appended
|
||||
const payload2 = {
|
||||
streamChunks: {
|
||||
provider: ['data: {"content":"A"}\n\n', 'data: {"content":"B"}\n\n'],
|
||||
openai: [],
|
||||
client: [],
|
||||
},
|
||||
};
|
||||
const text2 = computeStreamChunksText(true, payload2);
|
||||
assert.ok(text2!.includes("A"), "poll 2 should still show chunk A");
|
||||
assert.ok(text2!.includes("B"), "poll 2 should show newly arrived chunk B");
|
||||
|
||||
// The joined text should be the concatenation
|
||||
assert.ok(
|
||||
text2!.includes('data: {"content":"A"}\n\ndata: {"content":"B"}'),
|
||||
"poll 2 joined text should contain both chunks"
|
||||
);
|
||||
});
|
||||
|
||||
test("pooling effect state merge preserves streamChunks across updates", () => {
|
||||
// Simulate the polling useEffect state merge:
|
||||
// setDetailData(prev => ({...prev, ...data, pipelinePayloads: data?.pipelinePayloads || prev?.pipelinePayloads}))
|
||||
|
||||
let detailData: Record<string, unknown> | null = null;
|
||||
|
||||
// openDetail initial fetch
|
||||
const initialFetch = {
|
||||
pipelinePayloads: {
|
||||
streamChunks: { provider: ['data: {"a":1}\n\n'] },
|
||||
providerRequest: { model: "gpt-4" },
|
||||
},
|
||||
active: true,
|
||||
detailState: "in-flight",
|
||||
};
|
||||
detailData = mergeDetailData(null, initialFetch);
|
||||
assert.ok(
|
||||
(detailData!.pipelinePayloads as Record<string, unknown>).streamChunks,
|
||||
"initial merge should preserve streamChunks"
|
||||
);
|
||||
|
||||
// Poll response adds more chunks
|
||||
const pollResponse = {
|
||||
pipelinePayloads: {
|
||||
streamChunks: {
|
||||
provider: ['data: {"a":1}\n\n', 'data: {"b":2}\n\n'],
|
||||
},
|
||||
providerRequest: { model: "gpt-4" },
|
||||
},
|
||||
active: true,
|
||||
detailState: "in-flight",
|
||||
};
|
||||
detailData = mergeDetailData(detailData, pollResponse);
|
||||
|
||||
const mergedChunks = (detailData!.pipelinePayloads as Record<string, unknown>)
|
||||
.streamChunks as Record<string, string[]>;
|
||||
assert.equal(mergedChunks.provider.length, 2, "merged should have 2 chunks");
|
||||
assert.equal(mergedChunks.provider[1], 'data: {"b":2}\n\n', "merged should include new chunk");
|
||||
|
||||
// Simulate: polling response loses pipelinePayloads (null)
|
||||
// This should NOT overwrite the existing streamChunks
|
||||
const nullPayloadResponse = {
|
||||
pipelinePayloads: null,
|
||||
active: true,
|
||||
detailState: "in-flight",
|
||||
};
|
||||
const afterNullPayload = mergeDetailData(detailData, nullPayloadResponse);
|
||||
assert.ok(
|
||||
(afterNullPayload.pipelinePayloads as Record<string, unknown>).streamChunks,
|
||||
"streamChunks should survive null pipelinePayloads update"
|
||||
);
|
||||
|
||||
// Simulate: polling response has pipelinePayloads but NO streamChunks
|
||||
// This SHOULD overwrite (|| semantics) — but the production condition is: when
|
||||
// data?.pipelinePayloads is truthy, it replaces prev. If captureStreamChunks was false,
|
||||
// the data won't have streamChunks. This is expected behavior — the frontend doesn't
|
||||
// try to retain old streamChunks when new data explicitly lacks them.
|
||||
const noChunksPayloadResponse = {
|
||||
pipelinePayloads: {
|
||||
providerResponse: { status: 200 },
|
||||
// no streamChunks key
|
||||
},
|
||||
active: false,
|
||||
detailState: "ready",
|
||||
};
|
||||
const afterNoChunks = mergeDetailData(detailData, noChunksPayloadResponse);
|
||||
const payloadAfter = afterNoChunks.pipelinePayloads as Record<string, unknown>;
|
||||
assert.equal(
|
||||
payloadAfter.streamChunks,
|
||||
undefined,
|
||||
"streamChunks is lost when new pipelinePayloads lacks it and is truthy"
|
||||
);
|
||||
// This demonstrates the || semantics: data.pipelinePayloads is truthy ({providerResponse: {...}})
|
||||
// so it replaces prev.pipelinePayloads even though it lacks streamChunks.
|
||||
// The Event Stream section will not render after this update.
|
||||
// However, in practice this only happens after request completion when the polling
|
||||
// transitions to the persisted artifact which may have truncated streamChunks.
|
||||
// By that point the Event Stream section is no longer needed (streaming is done).
|
||||
});
|
||||
|
||||
test("pendingById references are live: push mutates the shared arrays visible to API", () => {
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
const model = "gpt-4";
|
||||
const provider = "openai";
|
||||
const connectionId = "conn-ref-1";
|
||||
|
||||
const requestId = usageHistory.trackPendingRequest(model, provider, connectionId, true);
|
||||
|
||||
// Simulate push() — store initial empty arrays
|
||||
usageHistory.updatePendingRequestStreamChunks(model, provider, connectionId, {
|
||||
provider: [],
|
||||
openai: [],
|
||||
client: [],
|
||||
});
|
||||
|
||||
// Get the pending detail reference
|
||||
const detailFromPending = usageHistory.getPendingById().get(requestId);
|
||||
assert.ok(detailFromPending, "detail should be in pendingById");
|
||||
assert.ok(detailFromPending!.streamChunks, "streamChunks should be set to wrapper object");
|
||||
|
||||
// Simulate appendBoundedChunk — pushes to the shared array
|
||||
detailFromPending!.streamChunks!.provider.push('data: {"chunk":"live"}');
|
||||
|
||||
// Re-read from pendingById — should see the mutation
|
||||
const detailReRead = usageHistory.getPendingById().get(requestId);
|
||||
assert.equal(
|
||||
detailReRead!.streamChunks!.provider.length,
|
||||
1,
|
||||
"mutation should be visible through pendingById"
|
||||
);
|
||||
assert.equal(
|
||||
detailReRead!.streamChunks!.provider[0],
|
||||
'data: {"chunk":"live"}',
|
||||
"mutated content should be visible through pendingById"
|
||||
);
|
||||
|
||||
// Verify via simulated API response
|
||||
const apiResp = buildApiResponseFromPending(requestId);
|
||||
const apiChunks = (apiResp!.pipelinePayloads as Record<string, unknown>).streamChunks as Record<
|
||||
string,
|
||||
string[]
|
||||
>;
|
||||
assert.equal(apiChunks.provider.length, 1, "API should see the live mutation");
|
||||
});
|
||||
|
||||
test("no connectionId in request logger does not break anything", async () => {
|
||||
const { createRequestLogger } = await import("../../open-sse/utils/requestLogger.ts");
|
||||
|
||||
usageHistory.clearPendingRequests();
|
||||
usageHistory.trackPendingRequest("gpt-4", "openai", "conn-noop-1", true);
|
||||
|
||||
// Logger without connectionId/model — push() bails, no crash
|
||||
const logger = await createRequestLogger("openai", "openai", "gpt-4", {
|
||||
enabled: true,
|
||||
captureStreamChunks: true,
|
||||
});
|
||||
|
||||
logger.appendProviderChunk('data: {"a":1}');
|
||||
logger.appendOpenAIChunk('data: {"b":2}');
|
||||
logger.appendConvertedChunk('data: {"c":3}');
|
||||
|
||||
// The pending detail should NOT have streamChunks (no connectionId match)
|
||||
const pending = usageHistory.getPendingRequests();
|
||||
const detail = pending.details["conn-noop-1"]?.["gpt-4 (openai)"]?.[0];
|
||||
assert.equal(detail.streamChunks, undefined, "streamChunks should not be set");
|
||||
|
||||
// Simulated API should return null for streamChunks
|
||||
const apiResp = buildApiResponseFromPending(
|
||||
// We need the actual ID. trackPendingRequest returns it now.
|
||||
(() => {
|
||||
usageHistory.clearPendingRequests();
|
||||
const id = usageHistory.trackPendingRequest("gpt-4", "openai", "conn-noop-2", true);
|
||||
return id!;
|
||||
})()
|
||||
);
|
||||
// This request had no streaming, so streamChunks is null
|
||||
if (apiResp) {
|
||||
const noChunks = computeStreamChunksText(
|
||||
true,
|
||||
apiResp.pipelinePayloads as Record<string, unknown>
|
||||
);
|
||||
assert.equal(noChunks, null, "no streamChunks means no event stream");
|
||||
}
|
||||
});
|
||||
|
||||
test("createRequestLogger and trackPendingRequest with matching model propagate streamChunks", async () => {
|
||||
// The fix: chatCore.ts now passes `model` (not `effectiveModel`) to
|
||||
// createRequestLogger, so the modelKey matches what trackPendingRequest uses.
|
||||
|
||||
const { createRequestLogger } = await import("../../open-sse/utils/requestLogger.ts");
|
||||
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
const model = "gpt-4";
|
||||
const provider = "openai";
|
||||
const connectionId = "conn-match-1";
|
||||
|
||||
const requestId = usageHistory.trackPendingRequest(model, provider, connectionId, true, {
|
||||
clientRequest: { messages: [] },
|
||||
});
|
||||
|
||||
// Use matching model (the fix)
|
||||
const logger = await createRequestLogger("openai", "openai", model, {
|
||||
enabled: true,
|
||||
captureStreamChunks: true,
|
||||
model,
|
||||
provider,
|
||||
connectionId,
|
||||
});
|
||||
|
||||
logger.appendProviderChunk('data: {"chunk":"hello"}');
|
||||
logger.appendOpenAIChunk('data: {"choices":[{"delta":{"content":"hi"}}]}');
|
||||
logger.appendConvertedChunk('data: {"content":"world"}');
|
||||
|
||||
const detail = usageHistory.getPendingById().get(requestId);
|
||||
assert.ok(detail, "pending detail should exist");
|
||||
assert.ok(detail!.streamChunks, "streamChunks should be set");
|
||||
assert.equal(detail!.streamChunks!.provider.length, 1);
|
||||
assert.equal(detail!.streamChunks!.openai.length, 1);
|
||||
assert.equal(detail!.streamChunks!.client.length, 1);
|
||||
|
||||
const apiResp = buildApiResponseFromPending(requestId!);
|
||||
assert.ok(apiResp);
|
||||
const apiChunks = (apiResp!.pipelinePayloads as Record<string, unknown>).streamChunks as Record<
|
||||
string,
|
||||
string[]
|
||||
>;
|
||||
assert.equal(stripChunkTs(apiChunks?.provider[0]), 'data: {"chunk":"hello"}');
|
||||
});
|
||||
|
||||
test("finalizePendingRequestById completes the exact stream when same model requests overlap", () => {
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
const model = "gpt-4";
|
||||
const provider = "openai";
|
||||
const connectionId = "conn-overlap-1";
|
||||
|
||||
const firstId = usageHistory.trackPendingRequest(model, provider, connectionId, true, {
|
||||
clientRequest: { messages: [{ role: "user", content: "first" }] },
|
||||
});
|
||||
const secondId = usageHistory.trackPendingRequest(model, provider, connectionId, true, {
|
||||
clientRequest: { messages: [{ role: "user", content: "second" }] },
|
||||
});
|
||||
|
||||
const completed = usageHistory.finalizePendingRequestById(firstId, {
|
||||
providerResponse: { id: "first-response" },
|
||||
clientResponse: { id: "first-response" },
|
||||
});
|
||||
|
||||
assert.equal(completed, true);
|
||||
assert.ok(usageHistory.getCompletedDetails().has(firstId));
|
||||
assert.equal(usageHistory.getCompletedDetails().has(secondId), false);
|
||||
assert.equal(usageHistory.getPendingById().has(firstId), false);
|
||||
assert.equal(usageHistory.getPendingById().has(secondId), true);
|
||||
|
||||
const pending = usageHistory.getPendingRequests();
|
||||
const details = pending.details[connectionId]?.[`${model} (${provider})`] ?? [];
|
||||
assert.equal(details.length, 1);
|
||||
assert.equal(details[0].id, secondId);
|
||||
assert.equal(pending.byModel[`${model} (${provider})`], 1);
|
||||
assert.equal(pending.byAccount[connectionId]?.[`${model} (${provider})`], 1);
|
||||
});
|
||||
|
||||
test("completedDetails cache evicts oldest entries when bounded", () => {
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
const model = "gpt-4";
|
||||
const provider = "openai";
|
||||
const connectionId = "conn-completed-bound";
|
||||
const ids: string[] = [];
|
||||
|
||||
for (let i = 0; i < 260; i++) {
|
||||
const id = usageHistory.trackPendingRequest(model, provider, connectionId, true);
|
||||
ids.push(id!);
|
||||
const completed = usageHistory.finalizePendingRequestById(id, {
|
||||
clientResponse: { choices: [{ message: { content: `done ${i}` } }] },
|
||||
});
|
||||
assert.equal(completed, true);
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
usageHistory.getCompletedDetails().size <= 256,
|
||||
"completedDetails should remain bounded"
|
||||
);
|
||||
assert.equal(usageHistory.getCompletedDetails().has(ids[0]), false);
|
||||
assert.equal(usageHistory.getCompletedDetails().has(ids[ids.length - 1]), true);
|
||||
});
|
||||
|
||||
test("streamChunks in completedDetails survives beyond the logs polling window", async () => {
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
const model = "gpt-4";
|
||||
const provider = "openai";
|
||||
const connectionId = "conn-ttl-1";
|
||||
|
||||
const requestId = usageHistory.trackPendingRequest(model, provider, connectionId, true, {
|
||||
clientRequest: { messages: [] },
|
||||
});
|
||||
|
||||
// Simulate streaming then completion
|
||||
usageHistory.updatePendingRequestStreamChunks(model, provider, connectionId, {
|
||||
provider: ['data: {"chunk":"final"}\n\n'],
|
||||
openai: [],
|
||||
client: [],
|
||||
});
|
||||
|
||||
usageHistory.finalizeMostRecentPendingRequest(model, provider, connectionId, {
|
||||
status: 200,
|
||||
model,
|
||||
provider,
|
||||
});
|
||||
|
||||
// Should be in completedDetails
|
||||
assert.ok(
|
||||
usageHistory.getCompletedDetails().has(requestId),
|
||||
"should be in completedDetails after finalization"
|
||||
);
|
||||
|
||||
const completedResp = buildApiResponseFromCompleted(requestId);
|
||||
assert.ok(completedResp, "API response should be available from completedDetails");
|
||||
assert.ok(
|
||||
(completedResp!.pipelinePayloads as Record<string, unknown>).streamChunks,
|
||||
"streamChunks should be in completedDetails"
|
||||
);
|
||||
|
||||
// Save to DB as the normal durable path, but keep the completedDetails cache
|
||||
// long enough that a slow Logs-page poll does not see the row disappear
|
||||
// between pending removal and DB/detail refresh.
|
||||
await callLogs.saveCallLog({
|
||||
id: requestId,
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
status: 200,
|
||||
model,
|
||||
requestedModel: model,
|
||||
provider,
|
||||
connectionId,
|
||||
duration: 3000,
|
||||
tokens: { in: 5, out: 10 },
|
||||
requestBody: {},
|
||||
responseBody: {},
|
||||
error: null,
|
||||
sourceFormat: "openai",
|
||||
targetFormat: "openai",
|
||||
comboName: null,
|
||||
comboStepId: null,
|
||||
comboExecutionKey: null,
|
||||
tokensCompressed: null,
|
||||
cacheSource: "upstream",
|
||||
apiKeyId: null,
|
||||
apiKeyName: null,
|
||||
noLog: false,
|
||||
pipelinePayloads: completedResp!.pipelinePayloads as Record<string, unknown>,
|
||||
});
|
||||
|
||||
// Verify DB has it
|
||||
const dbEntry1 = await callLogs.getCallLogById(requestId);
|
||||
assert.ok(dbEntry1, "DB should have the entry after saveCallLog");
|
||||
|
||||
// This used to expire after 5 seconds. Keep it visible beyond that window so
|
||||
// a slow client poll can still resolve the completed row/details.
|
||||
await new Promise((r) => setTimeout(r, 5100));
|
||||
|
||||
assert.ok(
|
||||
usageHistory.getCompletedDetails().has(requestId),
|
||||
"completedDetails should still be available after a 5-second polling gap"
|
||||
);
|
||||
|
||||
const dbEntry2 = await callLogs.getCallLogById(requestId);
|
||||
assert.ok(dbEntry2, "DB should still have the entry after the polling gap");
|
||||
assert.equal(
|
||||
(dbEntry2 as Record<string, unknown>).id,
|
||||
requestId,
|
||||
"DB entry ID should match the original request ID"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
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";
|
||||
import { makeManagementSessionRequest } from "../helpers/managementSession.ts";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-admin-audit-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.APP_LOG_TO_FILE = "false";
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-audit-events";
|
||||
process.env.INITIAL_PASSWORD = "admin-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const compliance = await import("../../src/lib/compliance/index.ts");
|
||||
const loginRoute = await import("../../src/app/api/auth/login/route.ts");
|
||||
const logoutRoute = await import("../../src/app/api/auth/logout/route.ts");
|
||||
const providersRoute = await import("../../src/app/api/providers/route.ts");
|
||||
const providerByIdRoute = await import("../../src/app/api/providers/[id]/route.ts");
|
||||
const originalGetLoginCookieStore = loginRoute.authRouteInternals.getCookieStore;
|
||||
const originalGetLogoutCookieStore = logoutRoute.logoutRouteInternals.getCookieStore;
|
||||
|
||||
function resetDb() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetDb();
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
loginRoute.authRouteInternals.getCookieStore = originalGetLoginCookieStore;
|
||||
logoutRoute.logoutRouteInternals.getCookieStore = originalGetLogoutCookieStore;
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("auth login/logout routes emit structured audit events with ip and request id", async () => {
|
||||
const setCalls = [];
|
||||
const deleteCalls = [];
|
||||
|
||||
loginRoute.authRouteInternals.getCookieStore = async () => ({
|
||||
set: (...args) => setCalls.push(args),
|
||||
});
|
||||
logoutRoute.logoutRouteInternals.getCookieStore = async () => ({
|
||||
delete: (...args) => deleteCalls.push(args),
|
||||
});
|
||||
|
||||
const loginResponse = await loginRoute.POST(
|
||||
new Request("http://localhost/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-forwarded-for": "198.51.100.10",
|
||||
"x-request-id": "req-auth-login",
|
||||
},
|
||||
body: JSON.stringify({ password: "admin-secret" }),
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(loginResponse.status, 200);
|
||||
assert.deepEqual(await loginResponse.json(), { success: true });
|
||||
assert.equal(setCalls.length, 1);
|
||||
|
||||
const logoutResponse = await logoutRoute.POST(
|
||||
new Request("http://localhost/api/auth/logout", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-forwarded-for": "198.51.100.10",
|
||||
"x-request-id": "req-auth-logout",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(logoutResponse.status, 200);
|
||||
assert.deepEqual(await logoutResponse.json(), { success: true });
|
||||
assert.deepEqual(deleteCalls, [["auth_token"]]);
|
||||
|
||||
const loginEvent = compliance.getAuditLog({ action: "auth.login.success" })[0];
|
||||
assert.equal(loginEvent.actor, "admin");
|
||||
assert.equal(loginEvent.resourceType, "auth_session");
|
||||
assert.equal(loginEvent.status, "success");
|
||||
assert.equal(loginEvent.ip, "198.51.100.10");
|
||||
assert.equal(loginEvent.requestId, "req-auth-login");
|
||||
|
||||
const logoutEvent = compliance.getAuditLog({ action: "auth.logout.success" })[0];
|
||||
assert.equal(logoutEvent.actor, "admin");
|
||||
assert.equal(logoutEvent.resourceType, "auth_session");
|
||||
assert.equal(logoutEvent.status, "success");
|
||||
assert.equal(logoutEvent.requestId, "req-auth-logout");
|
||||
});
|
||||
|
||||
test("auth login route records failed password attempts", async () => {
|
||||
loginRoute.authRouteInternals.getCookieStore = async () => ({
|
||||
set() {},
|
||||
});
|
||||
|
||||
const response = await loginRoute.POST(
|
||||
new Request("http://localhost/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-forwarded-for": "198.51.100.22",
|
||||
"x-request-id": "req-auth-failed",
|
||||
},
|
||||
body: JSON.stringify({ password: "wrong-password" }),
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.deepEqual(await response.json(), { error: "Invalid password" });
|
||||
|
||||
const event = compliance.getAuditLog({ action: "auth.login.failed" })[0];
|
||||
assert.equal(event.actor, "anonymous");
|
||||
assert.equal(event.status, "failed");
|
||||
assert.equal(event.requestId, "req-auth-failed");
|
||||
assert.deepEqual(event.metadata, { reason: "invalid_password", lockedOut: false });
|
||||
});
|
||||
|
||||
test("provider create/update/delete routes emit sanitized credential audit events", async () => {
|
||||
const createResponse = await providersRoute.POST(
|
||||
await makeManagementSessionRequest("http://localhost/api/providers", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-forwarded-for": "203.0.113.10",
|
||||
"x-request-id": "req-provider-create",
|
||||
},
|
||||
body: {
|
||||
provider: "openai",
|
||||
apiKey: "sk-secret-provider-key",
|
||||
name: "Primary OpenAI",
|
||||
defaultModel: "gpt-4o-mini",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(createResponse.status, 201);
|
||||
const createBody = (await createResponse.json()) as any;
|
||||
const connectionId = createBody.connection.id;
|
||||
assert.equal(typeof connectionId, "string");
|
||||
|
||||
const updateResponse = await providerByIdRoute.PUT(
|
||||
await makeManagementSessionRequest(`http://localhost/api/providers/${connectionId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"x-forwarded-for": "203.0.113.10",
|
||||
"x-request-id": "req-provider-update",
|
||||
},
|
||||
body: {
|
||||
name: "Primary OpenAI Updated",
|
||||
defaultModel: "gpt-4.1-mini",
|
||||
isActive: false,
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: connectionId }) }
|
||||
);
|
||||
|
||||
assert.equal(updateResponse.status, 200);
|
||||
|
||||
const deleteResponse = await providerByIdRoute.DELETE(
|
||||
await makeManagementSessionRequest(`http://localhost/api/providers/${connectionId}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"x-forwarded-for": "203.0.113.10",
|
||||
"x-request-id": "req-provider-delete",
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: connectionId }) }
|
||||
);
|
||||
|
||||
assert.equal(deleteResponse.status, 200);
|
||||
|
||||
const createdEvent = compliance.getAuditLog({ action: "provider.credentials.created" })[0];
|
||||
assert.equal(createdEvent.status, "success");
|
||||
assert.equal(createdEvent.resourceType, "provider_credentials");
|
||||
assert.equal(createdEvent.requestId, "req-provider-create");
|
||||
assert.equal(createdEvent.target, "openai:Primary OpenAI");
|
||||
assert.equal("apiKey" in (createdEvent.metadata as any).connection, false);
|
||||
|
||||
const updatedEvent = compliance.getAuditLog({ action: "provider.credentials.updated" })[0];
|
||||
assert.equal(updatedEvent.requestId, "req-provider-update");
|
||||
assert.deepEqual((updatedEvent as any).metadata.changedFields.sort(), [
|
||||
"defaultModel",
|
||||
"isActive",
|
||||
"name",
|
||||
]);
|
||||
assert.equal((updatedEvent as any).metadata.before.name, "Primary OpenAI");
|
||||
(assert as any).equal((updatedEvent.metadata as any).after.name, "Primary OpenAI Updated");
|
||||
(assert as any).equal("apiKey" in (updatedEvent.metadata as any).before, false);
|
||||
(assert as any).equal("apiKey" in (updatedEvent.metadata as any).after, false);
|
||||
|
||||
const revokedEvent = compliance.getAuditLog({ action: "provider.credentials.revoked" })[0];
|
||||
assert.equal(revokedEvent.requestId, "req-provider-delete");
|
||||
assert.equal(revokedEvent.target, "openai:Primary OpenAI Updated");
|
||||
assert.equal(revokedEvent.status, "success");
|
||||
assert.equal("apiKey" in (revokedEvent.metadata as any).connection, false);
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// ═════════════════════════════════════════════════════
|
||||
// FASE-07/08/09: UX, LLM Advanced, E2E Hardening Tests
|
||||
// ═════════════════════════════════════════════════════
|
||||
|
||||
// ─── Policy Engine Tests ──────────────────────────
|
||||
|
||||
import { PolicyEngine } from "../../src/domain/policyEngine.ts";
|
||||
|
||||
test("PolicyEngine: evaluates empty policy list as allowed", () => {
|
||||
const engine = new PolicyEngine();
|
||||
const result = engine.evaluate({ model: "gpt-4" });
|
||||
assert.equal(result.allowed, true);
|
||||
assert.equal(result.appliedPolicies.length, 0);
|
||||
});
|
||||
|
||||
test("PolicyEngine: matches model glob patterns", () => {
|
||||
const engine = new PolicyEngine();
|
||||
engine.loadPolicies([
|
||||
{
|
||||
id: "1",
|
||||
name: "prefer-openai-for-gpt",
|
||||
type: "routing",
|
||||
enabled: true,
|
||||
priority: 1,
|
||||
conditions: { model_pattern: "gpt-*" },
|
||||
actions: { prefer_provider: ["openai"] },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = engine.evaluate({ model: "gpt-4o" });
|
||||
assert.equal(result.allowed, true);
|
||||
assert.deepEqual(result.preferredProviders, ["openai"]);
|
||||
assert.deepEqual(result.appliedPolicies, ["prefer-openai-for-gpt"]);
|
||||
});
|
||||
|
||||
test("PolicyEngine: does not match non-matching models", () => {
|
||||
const engine = new PolicyEngine();
|
||||
engine.loadPolicies([
|
||||
{
|
||||
id: "1",
|
||||
name: "gpt-only",
|
||||
type: "routing",
|
||||
enabled: true,
|
||||
priority: 1,
|
||||
conditions: { model_pattern: "gpt-*" },
|
||||
actions: { prefer_provider: ["openai"] },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = engine.evaluate({ model: "claude-3.5-sonnet" });
|
||||
assert.equal(result.preferredProviders.length, 0);
|
||||
});
|
||||
|
||||
test("PolicyEngine: blocks models via access policy", () => {
|
||||
const engine = new PolicyEngine();
|
||||
engine.loadPolicies([
|
||||
{
|
||||
id: "2",
|
||||
name: "block-expensive",
|
||||
type: "access",
|
||||
enabled: true,
|
||||
priority: 1,
|
||||
conditions: {},
|
||||
actions: { block_model: ["gpt-4*", "claude-3-opus*"] },
|
||||
},
|
||||
]);
|
||||
|
||||
const blocked = engine.evaluate({ model: "gpt-4o" });
|
||||
assert.equal(blocked.allowed, false);
|
||||
assert.ok(blocked.reason.includes("blocked"));
|
||||
|
||||
const allowed = engine.evaluate({ model: "gpt-3.5-turbo" });
|
||||
assert.equal(allowed.allowed, true);
|
||||
});
|
||||
|
||||
test("PolicyEngine: applies max_tokens from budget policy", () => {
|
||||
const engine = new PolicyEngine();
|
||||
engine.loadPolicies([
|
||||
{
|
||||
id: "3",
|
||||
name: "limit-tokens",
|
||||
type: "budget",
|
||||
enabled: true,
|
||||
priority: 1,
|
||||
conditions: {},
|
||||
actions: { max_tokens: 4096 },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = engine.evaluate({ model: "gpt-4" });
|
||||
assert.equal(result.maxTokens, 4096);
|
||||
});
|
||||
|
||||
test("PolicyEngine: skips disabled policies", () => {
|
||||
const engine = new PolicyEngine();
|
||||
engine.loadPolicies([
|
||||
{
|
||||
id: "4",
|
||||
name: "disabled-policy",
|
||||
type: "routing",
|
||||
enabled: false,
|
||||
priority: 1,
|
||||
conditions: {},
|
||||
actions: { prefer_provider: ["should-not-appear"] },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = engine.evaluate({ model: "gpt-4" });
|
||||
assert.equal(result.preferredProviders.length, 0);
|
||||
});
|
||||
|
||||
test("PolicyEngine: respects priority order", () => {
|
||||
const engine = new PolicyEngine();
|
||||
engine.loadPolicies([
|
||||
{
|
||||
id: "b",
|
||||
name: "second",
|
||||
type: "routing",
|
||||
enabled: true,
|
||||
priority: 10,
|
||||
conditions: {},
|
||||
actions: { prefer_provider: ["provider-b"] },
|
||||
},
|
||||
{
|
||||
id: "a",
|
||||
name: "first",
|
||||
type: "routing",
|
||||
enabled: true,
|
||||
priority: 1,
|
||||
conditions: {},
|
||||
actions: { prefer_provider: ["provider-a"] },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = engine.evaluate({ model: "any" });
|
||||
assert.deepEqual(result.preferredProviders, ["provider-a", "provider-b"]);
|
||||
assert.deepEqual(result.appliedPolicies, ["first", "second"]);
|
||||
});
|
||||
|
||||
test("PolicyEngine: addPolicy and removePolicy work", () => {
|
||||
const engine = new PolicyEngine();
|
||||
engine.addPolicy({
|
||||
id: "x",
|
||||
name: "temp",
|
||||
type: "routing",
|
||||
enabled: true,
|
||||
priority: 1,
|
||||
conditions: {},
|
||||
actions: { prefer_provider: ["x"] },
|
||||
});
|
||||
|
||||
assert.equal(engine.getPolicies().length, 1);
|
||||
engine.removePolicy("x");
|
||||
assert.equal(engine.getPolicies().length, 0);
|
||||
});
|
||||
|
||||
// ─── LRU Cache Tests ──────────────────────────
|
||||
|
||||
import { LRUCache } from "../../src/lib/cacheLayer.ts";
|
||||
|
||||
test("LRUCache: set and get work", () => {
|
||||
const cache = new LRUCache({ maxSize: 5 });
|
||||
cache.set("k1", "v1");
|
||||
assert.equal(cache.get("k1"), "v1");
|
||||
});
|
||||
|
||||
test("LRUCache: returns undefined for missing keys", () => {
|
||||
const cache = new LRUCache({ maxSize: 5 });
|
||||
assert.equal(cache.get("missing"), undefined);
|
||||
});
|
||||
|
||||
test("LRUCache: evicts oldest entry when full", () => {
|
||||
const cache = new LRUCache({ maxSize: 3 });
|
||||
cache.set("a", 1);
|
||||
cache.set("b", 2);
|
||||
cache.set("c", 3);
|
||||
cache.set("d", 4); // Should evict "a"
|
||||
|
||||
assert.equal(cache.get("a"), undefined);
|
||||
assert.equal(cache.get("d"), 4);
|
||||
});
|
||||
|
||||
test("LRUCache: TTL expiration works", async () => {
|
||||
const cache = new LRUCache({ maxSize: 5, defaultTTL: 50 });
|
||||
cache.set("temp", "value");
|
||||
assert.equal(cache.get("temp"), "value");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
assert.equal(cache.get("temp"), undefined);
|
||||
});
|
||||
|
||||
test("LRUCache: stats track hits and misses", () => {
|
||||
const cache = new LRUCache({ maxSize: 5 });
|
||||
cache.set("k", "v");
|
||||
cache.get("k"); // hit
|
||||
cache.get("missing"); // miss
|
||||
|
||||
const stats = cache.getStats();
|
||||
assert.equal(stats.hits, 1);
|
||||
assert.equal(stats.misses, 1);
|
||||
assert.equal(stats.hitRate, 50);
|
||||
});
|
||||
|
||||
test("LRUCache: generateKey produces consistent hashes", () => {
|
||||
const key1 = LRUCache.generateKey({ model: "gpt-4", prompt: "hello" });
|
||||
const key2 = LRUCache.generateKey({ prompt: "hello", model: "gpt-4" }); // different order
|
||||
assert.equal(key1, key2);
|
||||
});
|
||||
|
||||
test("LRUCache: delete removes entry", () => {
|
||||
const cache = new LRUCache({ maxSize: 5 });
|
||||
cache.set("k", "v");
|
||||
assert.equal(cache.delete("k"), true);
|
||||
assert.equal(cache.has("k"), false);
|
||||
});
|
||||
|
||||
test("LRUCache: clear empties cache", () => {
|
||||
const cache = new LRUCache({ maxSize: 5 });
|
||||
cache.set("a", 1);
|
||||
cache.set("b", 2);
|
||||
cache.clear();
|
||||
assert.equal(cache.getStats().size, 0);
|
||||
});
|
||||
|
||||
// ─── Stream State Machine Tests ──────────────────
|
||||
|
||||
import {
|
||||
StreamTracker,
|
||||
STREAM_STATES,
|
||||
createStreamTracker,
|
||||
getActiveStreams,
|
||||
archiveStream,
|
||||
} from "../../src/sse/services/streamState.ts";
|
||||
import * as streamStateModule from "../../src/sse/services/streamState.ts";
|
||||
|
||||
test("stream state public surface excludes removed completed-history accessor", () => {
|
||||
assert.equal(Object.hasOwn(streamStateModule, "getRecentCompletedStreams"), false);
|
||||
assert.equal(typeof streamStateModule.getActiveStreams, "function");
|
||||
assert.equal(typeof streamStateModule.archiveStream, "function");
|
||||
});
|
||||
|
||||
test("StreamTracker: starts in INITIALIZED state", () => {
|
||||
const tracker = new StreamTracker("req-1");
|
||||
assert.equal(tracker.state, STREAM_STATES.INITIALIZED);
|
||||
});
|
||||
|
||||
test("StreamTracker: valid transitions succeed", () => {
|
||||
const tracker = new StreamTracker("req-2");
|
||||
assert.equal(tracker.transition(STREAM_STATES.CONNECTING), true);
|
||||
assert.equal(tracker.transition(STREAM_STATES.STREAMING), true);
|
||||
assert.equal(tracker.transition(STREAM_STATES.COMPLETED), true);
|
||||
assert.equal(tracker.isTerminal(), true);
|
||||
});
|
||||
|
||||
test("StreamTracker: invalid transitions are rejected", () => {
|
||||
const tracker = new StreamTracker("req-3");
|
||||
// Can't go directly to STREAMING from INITIALIZED
|
||||
assert.equal(tracker.transition(STREAM_STATES.STREAMING), false);
|
||||
assert.equal(tracker.state, STREAM_STATES.INITIALIZED);
|
||||
});
|
||||
|
||||
test("StreamTracker: records TTFB on first STREAMING transition", () => {
|
||||
const tracker = new StreamTracker("req-4");
|
||||
tracker.transition(STREAM_STATES.CONNECTING);
|
||||
tracker.transition(STREAM_STATES.STREAMING);
|
||||
assert.ok(tracker.firstChunkAt !== null);
|
||||
});
|
||||
|
||||
test("StreamTracker: fail() transitions to FAILED", () => {
|
||||
const tracker = new StreamTracker("req-5");
|
||||
tracker.transition(STREAM_STATES.CONNECTING);
|
||||
tracker.fail(new Error("connection timeout"));
|
||||
assert.equal(tracker.state, STREAM_STATES.FAILED);
|
||||
assert.equal(tracker.error, "connection timeout");
|
||||
});
|
||||
|
||||
test("StreamTracker: getSummary returns telemetry", () => {
|
||||
const tracker = new StreamTracker("req-6", { model: "gpt-4", provider: "openai" });
|
||||
tracker.transition(STREAM_STATES.CONNECTING);
|
||||
tracker.transition(STREAM_STATES.STREAMING);
|
||||
tracker.recordChunk(500);
|
||||
tracker.recordChunk(300);
|
||||
tracker.transition(STREAM_STATES.COMPLETED);
|
||||
|
||||
const summary = tracker.getSummary();
|
||||
assert.equal(summary.requestId, "req-6");
|
||||
assert.equal(summary.model, "gpt-4");
|
||||
assert.equal(summary.chunkCount, 2);
|
||||
assert.equal(summary.totalBytes, 800);
|
||||
assert.ok(summary.duration >= 0);
|
||||
assert.ok(summary.ttfb !== null);
|
||||
});
|
||||
|
||||
test("StreamTracker: registry tracks active streams", () => {
|
||||
const tracker = createStreamTracker("reg-1", { model: "test" });
|
||||
const active = getActiveStreams();
|
||||
assert.ok(active.some((s) => s.requestId === "reg-1"));
|
||||
|
||||
// Archive it
|
||||
tracker.transition(STREAM_STATES.CONNECTING);
|
||||
tracker.transition(STREAM_STATES.STREAMING);
|
||||
tracker.transition(STREAM_STATES.COMPLETED);
|
||||
archiveStream("reg-1");
|
||||
|
||||
const afterArchive = getActiveStreams();
|
||||
assert.ok(!afterArchive.some((s) => s.requestId === "reg-1"));
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
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";
|
||||
|
||||
// Isolate DB state
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-adversarial-pii-"));
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
|
||||
import { createPiiSseTransform } from "../../src/lib/streamingPiiTransform";
|
||||
import { sanitizePIIResponse, sanitizePII } from "../../src/lib/piiSanitizer";
|
||||
import { resolveFeatureFlag } from "../../src/shared/utils/featureFlags";
|
||||
|
||||
// Mock feature flag to return what we want
|
||||
const mockFlags: Record<string, string> = {
|
||||
PII_RESPONSE_SANITIZATION: "true",
|
||||
PII_RESPONSE_SANITIZATION_MODE: "redact",
|
||||
};
|
||||
|
||||
test("Adversarial Tests", async (t) => {
|
||||
// Setup overrides for tests
|
||||
const originalEnv = process.env;
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
PII_RESPONSE_SANITIZATION: "true",
|
||||
PII_RESPONSE_SANITIZATION_MODE: "redact",
|
||||
PII_TEST_BYPASS_MIN_WINDOW: "true"
|
||||
};
|
||||
|
||||
// Mock resolveFeatureFlag using module caching trick if needed, but the tests already mock it via DB or we can just mock process.env if the system falls back to env.
|
||||
// Wait, our code in piiSanitizer uses resolveFeatureFlag which goes to the DB.
|
||||
// Instead of mocking DB, we can just let it run. The tests setup a clean DB if we use the test runner.
|
||||
|
||||
await t.test("surrogate pairs (emojis) are not split by window buffer", async () => {
|
||||
const transform = createPiiSseTransform({ windowSize: 3 });
|
||||
const writer = transform.writable.getWriter();
|
||||
const chunks: string[] = [];
|
||||
const reader = transform.readable.getReader();
|
||||
|
||||
// Start reading
|
||||
const readLoop = async () => {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(new TextDecoder().decode(value));
|
||||
}
|
||||
};
|
||||
const readPromise = readLoop();
|
||||
|
||||
// Emojis are 2 UTF-16 code units (surrogate pairs)
|
||||
const emojiStr = "Hi 👋"; // "Hi \ud83d\udc4b" (length 4)
|
||||
// Send a chunk that will cause slice(0, 1) or slice(0, 2)
|
||||
// If windowSize is 3, emitLength = 4 - 3 = 1 ("H").
|
||||
// Then send another emoji.
|
||||
|
||||
// We will send a large string of emojis one by one.
|
||||
const encoder = new TextEncoder();
|
||||
const payload1 = JSON.stringify({ choices: [{ delta: { content: "Hi 👋 " } }] });
|
||||
await writer.write(encoder.encode(`data: ${payload1}\n`));
|
||||
|
||||
const payload2 = JSON.stringify({ choices: [{ delta: { content: "🌍 " } }] });
|
||||
await writer.write(encoder.encode(`data: ${payload2}\n`));
|
||||
|
||||
await writer.write(encoder.encode("data: [DONE]\n"));
|
||||
await writer.close();
|
||||
await readPromise;
|
||||
|
||||
const fullOutput = chunks.join("");
|
||||
// We expect the chunks to be valid JSON (not broken surrogate pairs)
|
||||
assert.ok(fullOutput.includes('"content":"Hi "'));
|
||||
assert.ok(fullOutput.includes('"content":"👋 "'));
|
||||
assert.ok(fullOutput.includes('"content":"🌍 "'));
|
||||
});
|
||||
|
||||
await t.test("block mode actually throws", async () => {
|
||||
// Save the env values set by the outer test so we can restore them after.
|
||||
const savedMode = process.env.PII_RESPONSE_SANITIZATION_MODE;
|
||||
const savedEnabled = process.env.PII_RESPONSE_SANITIZATION;
|
||||
process.env.PII_RESPONSE_SANITIZATION_MODE = "block";
|
||||
process.env.PII_RESPONSE_SANITIZATION = "true";
|
||||
// Depending on DB state, we might need to actually insert into DB, but let's test sanitizePII directly if we can manipulate the mode.
|
||||
// If it doesn't throw here, we know it's because DB overrides it. We'll skip if DB overrides.
|
||||
try {
|
||||
const result = sanitizePII("My ssn is 123-45-6789");
|
||||
if (result.redacted) {
|
||||
// Mode is redact
|
||||
}
|
||||
} catch (err: any) {
|
||||
assert.match(err.message, /Blocked response/);
|
||||
} finally {
|
||||
// Restore previous values instead of deleting — outer test relies on these being set.
|
||||
if (savedMode !== undefined) {
|
||||
process.env.PII_RESPONSE_SANITIZATION_MODE = savedMode;
|
||||
} else {
|
||||
delete process.env.PII_RESPONSE_SANITIZATION_MODE;
|
||||
}
|
||||
if (savedEnabled !== undefined) {
|
||||
process.env.PII_RESPONSE_SANITIZATION = savedEnabled;
|
||||
} else {
|
||||
delete process.env.PII_RESPONSE_SANITIZATION;
|
||||
}
|
||||
}
|
||||
});
|
||||
await t.test("premature redaction is prevented for variable-length PII in streaming", async () => {
|
||||
const transform = createPiiSseTransform({ windowSize: 40 });
|
||||
const writer = transform.writable.getWriter();
|
||||
const chunks: string[] = [];
|
||||
const reader = transform.readable.getReader();
|
||||
|
||||
const readPromise = (async () => {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(new TextDecoder().decode(value));
|
||||
}
|
||||
})();
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
// Simulate API key being sent in two chunks
|
||||
// Prefix "sk_" + 20 chars matches the regex. Total = 23 chars.
|
||||
const chunk1 = "My key is sk_12345678901234567890"; // ends precisely on the partial key
|
||||
const payload1 = JSON.stringify({ choices: [{ delta: { content: chunk1 } }] });
|
||||
await writer.write(encoder.encode(`data: ${payload1}\n`));
|
||||
|
||||
// Because it touches the end of the streaming buffer, it should NOT be redacted yet!
|
||||
// Wait for the rest
|
||||
const chunk2 = "12345";
|
||||
const payload2 = JSON.stringify({ choices: [{ delta: { content: chunk2 } }] });
|
||||
await writer.write(encoder.encode(`data: ${payload2}\n`));
|
||||
|
||||
await writer.write(encoder.encode("data: [DONE]\n"));
|
||||
await writer.close();
|
||||
await readPromise;
|
||||
|
||||
const fullOutput = chunks.join("");
|
||||
// The regex /(?:sk|pk|api|key|token)[_-][a-zA-Z0-9]{20,}/gi matches sk_ with underscore.
|
||||
// The sanitizer MUST redact this key — if it passes through, that is a security regression.
|
||||
assert.ok(fullOutput.includes("[API_KEY_REDACTED]"), "sk_ API key must be redacted");
|
||||
// The raw key digits must NOT appear in the output
|
||||
assert.ok(!fullOutput.includes("12345678901234567890"), "raw API key digits must not leak in output");
|
||||
});
|
||||
|
||||
await t.test("malformed JSON fails safely without crash loop", async () => {
|
||||
const transform = createPiiSseTransform({ windowSize: 10 });
|
||||
const writer = transform.writable.getWriter();
|
||||
const chunks: string[] = [];
|
||||
const reader = transform.readable.getReader();
|
||||
|
||||
const readPromise = (async () => {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(new TextDecoder().decode(value));
|
||||
}
|
||||
})();
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
// Valid JSON
|
||||
await writer.write(encoder.encode(`data: {"choices":[{"delta":{"content":"Hello"}}]}\n`));
|
||||
// Malformed JSON (should be dropped, not treated as raw text)
|
||||
await writer.write(encoder.encode(`data: {"choices":[{"delta":{"content":"BAD_SYNTAX\n`));
|
||||
await writer.write(encoder.encode("data: [DONE]\n"));
|
||||
await writer.close();
|
||||
await readPromise;
|
||||
|
||||
const fullOutput = chunks.join("");
|
||||
assert.ok(fullOutput.includes("Hello"));
|
||||
assert.ok(!fullOutput.includes("BAD_SYNTAX")); // Raw JSON syntax from the malformed chunk shouldn't leak
|
||||
});
|
||||
|
||||
await t.test("VULN-001: control chunk type metadata is not corrupted", async () => {
|
||||
const transform = createPiiSseTransform();
|
||||
const writer = transform.writable.getWriter();
|
||||
const chunks: string[] = [];
|
||||
const reader = transform.readable.getReader();
|
||||
|
||||
const readPromise = (async () => {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(new TextDecoder().decode(value));
|
||||
}
|
||||
})();
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
// Feed content that gets buffered
|
||||
await writer.write(encoder.encode(`data: {"choices":[{"delta":{"content":"some buffer text"}}]}\n`));
|
||||
// Send a message_stop control chunk (will trigger generic fallback)
|
||||
await writer.write(encoder.encode(`data: {"type":"message_stop"}\n`));
|
||||
await writer.write(encoder.encode("data: [DONE]\n"));
|
||||
await writer.close();
|
||||
await readPromise;
|
||||
|
||||
const fullOutput = chunks.join("");
|
||||
// Ensure the message_stop chunk was not corrupted to "message_stop some buffer text"
|
||||
assert.ok(fullOutput.includes('"type":"message_stop"'));
|
||||
});
|
||||
|
||||
await t.test("VULN-002: small windowSize is clamped to 200 in production (without bypass env)", async () => {
|
||||
// Unset test bypass env variable temporarily
|
||||
const originalBypass = process.env.PII_TEST_BYPASS_MIN_WINDOW;
|
||||
delete process.env.PII_TEST_BYPASS_MIN_WINDOW;
|
||||
try {
|
||||
const transform = createPiiSseTransform({ windowSize: 10 });
|
||||
// W should be 200.
|
||||
// If we stream "hello world", length is 11, since W is 200, emitLength should be 0.
|
||||
// So nothing should be emitted before stop signal or close.
|
||||
const writer = transform.writable.getWriter();
|
||||
const reader = transform.readable.getReader();
|
||||
|
||||
const chunks: string[] = [];
|
||||
const readPromise = (async () => {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(new TextDecoder().decode(value));
|
||||
}
|
||||
})();
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
await writer.write(encoder.encode(`data: {"choices":[{"delta":{"content":"hello world"}}]}\n`));
|
||||
await writer.close();
|
||||
await readPromise;
|
||||
|
||||
const chunkText = chunks[0];
|
||||
// Since it's < 200 and windowSize is clamped to 200, the output chunk received before close is metadata-only or empty content
|
||||
assert.ok(chunkText.includes('"content":""') || !chunkText.includes("hello world"));
|
||||
} finally {
|
||||
process.env.PII_TEST_BYPASS_MIN_WINDOW = originalBypass;
|
||||
}
|
||||
});
|
||||
|
||||
await t.test("VULN-003: ZWJ emojis and Brahmic script ligatures do not decompose", async () => {
|
||||
const familyEmoji = "👨👩👧👦"; // Uses ZWJs
|
||||
const sinhalaText = "ශ්රී"; // Sri Lanka in Sinhala, uses ZWJ/ZWNJ ligatures
|
||||
|
||||
const result1 = sanitizePII(familyEmoji);
|
||||
assert.strictEqual(result1.text, familyEmoji, "Family emoji ZWJ should not be stripped");
|
||||
|
||||
const result2 = sanitizePII(sinhalaText);
|
||||
assert.strictEqual(result2.text, sinhalaText, "Sinhala ligatures should not be stripped");
|
||||
});
|
||||
|
||||
await t.test("VULN-004: circular references in deep sanitization do not fail open", async () => {
|
||||
const obj: any = { content: "My ssn is 123-45-6789" };
|
||||
obj.selfRef = obj; // Create circular reference
|
||||
|
||||
const sanitized = sanitizePIIResponse(obj);
|
||||
// The circular reference MUST be replaced with the exact sentinel string.
|
||||
assert.strictEqual(sanitized.selfRef, "[CIRCULAR_REFERENCE_REDACTED]", "circular selfRef must use exact uppercase sentinel");
|
||||
// The SSN in the content field MUST be redacted — raw SSN passthrough is a security failure.
|
||||
assert.ok(
|
||||
typeof sanitized.content === "string" && sanitized.content.includes("[SSN_REDACTED]"),
|
||||
"SSN must be redacted to [SSN_REDACTED]"
|
||||
);
|
||||
assert.ok(
|
||||
typeof sanitized.content === "string" && !sanitized.content.includes("123-45-6789"),
|
||||
"raw SSN must not appear in sanitized output"
|
||||
);
|
||||
});
|
||||
|
||||
await t.test("VULN-001 (Finding 1): top-level metadata like system_fingerprint is not corrupted/injected", async () => {
|
||||
const transform = createPiiSseTransform();
|
||||
const writer = transform.writable.getWriter();
|
||||
const chunks: string[] = [];
|
||||
const reader = transform.readable.getReader();
|
||||
|
||||
const readPromise = (async () => {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(new TextDecoder().decode(value));
|
||||
}
|
||||
})();
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
// Send a standard OpenAI chunk containing content delta and system_fingerprint
|
||||
await writer.write(encoder.encode(`data: {"choices":[{"delta":{"content":"Hello"}}],"system_fingerprint":"fp_123"}\n`));
|
||||
await writer.write(encoder.encode("data: [DONE]\n"));
|
||||
await writer.close();
|
||||
await readPromise;
|
||||
|
||||
const fullOutput = chunks.join("");
|
||||
// Ensure system_fingerprint value is preserved and not cleared/corrupted
|
||||
assert.ok(fullOutput.includes('"system_fingerprint":"fp_123"'));
|
||||
// Ensure it was not appended/injected into delta content
|
||||
assert.ok(!fullOutput.includes('"content":"Hello fp_123"'));
|
||||
});
|
||||
|
||||
await t.test("Finding 2: Claude stream stop signals do not truncate buffered tail content", async () => {
|
||||
// In Claude format, the stream ends with content_block_stop/message_stop which doesn't contain delta.
|
||||
// The transform should synthesize a content_block_delta first containing the buffered text, then pass stop chunks.
|
||||
const transform = createPiiSseTransform();
|
||||
const writer = transform.writable.getWriter();
|
||||
const chunks: string[] = [];
|
||||
const reader = transform.readable.getReader();
|
||||
|
||||
const readPromise = (async () => {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(new TextDecoder().decode(value));
|
||||
}
|
||||
})();
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
// Send content_block_delta (text gets buffered under W=200)
|
||||
await writer.write(encoder.encode(`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"The quick brown fox jumps over the lazy dog."}}\n`));
|
||||
// Send stop events
|
||||
await writer.write(encoder.encode(`data: {"type":"content_block_stop","index":0}\n`));
|
||||
await writer.write(encoder.encode(`data: {"type":"message_stop"}\n`));
|
||||
await writer.close();
|
||||
await readPromise;
|
||||
|
||||
const fullOutput = chunks.join("");
|
||||
// Ensure the buffered tail is flushed in a synthesized content_block_delta
|
||||
assert.ok(fullOutput.includes('"type":"content_block_delta"'));
|
||||
assert.ok(fullOutput.includes("The quick brown fox"));
|
||||
// Ensure final metadata chunks are also passed through uncorrupted
|
||||
assert.ok(fullOutput.includes('"type":"content_block_stop"'));
|
||||
assert.ok(fullOutput.includes('"type":"message_stop"'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// #4546 — In containers/headless, the system trust store can't be written
|
||||
// (no sudo / read-only store / no interactive auth), so the cert install
|
||||
// throws and used to abort the whole Agent Bridge start. These tests pin the
|
||||
// graceful-fallback contract: a structured result that distinguishes a
|
||||
// user-canceled auth from an environment failure, plus a platform-specific
|
||||
// manual-install guide so the operator can trust the MITM root CA themselves.
|
||||
|
||||
const { classifyCertInstallError, buildCertManualGuide, installCertResult } = await import(
|
||||
"../../src/mitm/cert/install.ts"
|
||||
);
|
||||
|
||||
const DOWNLOAD_URL = "/api/tools/agent-bridge/cert/download";
|
||||
|
||||
test("classifyCertInstallError → 'canceled' only when the message says canceled", () => {
|
||||
assert.equal(classifyCertInstallError("User canceled authorization"), "canceled");
|
||||
assert.equal(classifyCertInstallError("Operation was canceled by the user"), "canceled");
|
||||
});
|
||||
|
||||
test("classifyCertInstallError → 'environment' for trust-store / sudo failures", () => {
|
||||
assert.equal(classifyCertInstallError("Certificate install failed"), "environment");
|
||||
assert.equal(classifyCertInstallError("sudo: no tty present and no askpass program specified"), "environment");
|
||||
assert.equal(classifyCertInstallError("Certificate file not found: /x/server.crt"), "environment");
|
||||
});
|
||||
|
||||
test("buildCertManualGuide(linux) → update-ca-certificates steps + download url + cert path", () => {
|
||||
const guide = buildCertManualGuide("/data/mitm/server.crt", "linux");
|
||||
assert.equal(guide.platform, "linux");
|
||||
assert.equal(guide.certPath, "/data/mitm/server.crt");
|
||||
assert.equal(guide.downloadUrl, DOWNLOAD_URL);
|
||||
assert.ok(Array.isArray(guide.steps) && guide.steps.length > 0);
|
||||
const joined = guide.steps.join("\n");
|
||||
assert.ok(joined.includes("update-ca-"), "should mention the distro CA refresh command");
|
||||
assert.ok(joined.includes("/data/mitm/server.crt"), "should reference the cert path");
|
||||
});
|
||||
|
||||
test("buildCertManualGuide(darwin) → security add-trusted-cert", () => {
|
||||
const guide = buildCertManualGuide("/d/server.crt", "darwin");
|
||||
assert.equal(guide.platform, "darwin");
|
||||
assert.ok(guide.steps.join("\n").includes("add-trusted-cert"));
|
||||
});
|
||||
|
||||
test("buildCertManualGuide(win32) → certutil -addstore Root", () => {
|
||||
const guide = buildCertManualGuide("C:/d/server.crt", "win32");
|
||||
assert.equal(guide.platform, "win32");
|
||||
assert.ok(guide.steps.join("\n").toLowerCase().includes("certutil"));
|
||||
});
|
||||
|
||||
test("installCertResult → environment skip (not a throw) when install is impossible", async () => {
|
||||
// A non-existent cert path makes installCert() throw before any privileged
|
||||
// command runs — deterministic, no sudo. The wrapper must convert that into a
|
||||
// structured skippable result with a manual guide, never a thrown error.
|
||||
const result = await installCertResult("", "/nonexistent/omniroute-4546-server.crt");
|
||||
assert.equal(result.installed, false);
|
||||
assert.equal(result.skipped, true);
|
||||
assert.equal(result.reason, "environment");
|
||||
assert.ok(result.manualGuide, "environment skip must carry a manual guide");
|
||||
assert.equal(result.manualGuide?.downloadUrl, DOWNLOAD_URL);
|
||||
// The message must be a safe string (no stack trace leaked).
|
||||
assert.equal(typeof result.message, "string");
|
||||
assert.ok(!String(result.message).includes("\n at "), "must not leak a stack trace");
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// POST /api/tools/agent-bridge/cert previously read request.json() and accessed
|
||||
// raw.sudoPassword without any schema validation (failing the t06 route
|
||||
// validation gate). It now validates the body with CertTrustBodySchema via
|
||||
// safeParse. These tests pin that schema's contract so the route keeps both its
|
||||
// validation gate compliance and its lenient fallback behavior.
|
||||
|
||||
const { CertTrustBodySchema } = await import(
|
||||
"../../src/app/api/tools/agent-bridge/cert/route.ts"
|
||||
);
|
||||
|
||||
test("accepts a body with a string sudoPassword", () => {
|
||||
const parsed = CertTrustBodySchema.safeParse({ sudoPassword: "hunter2" });
|
||||
assert.equal(parsed.success, true);
|
||||
assert.equal(parsed.success && parsed.data.sudoPassword, "hunter2");
|
||||
});
|
||||
|
||||
test("accepts an empty body (sudoPassword is optional, falls back to cached)", () => {
|
||||
const parsed = CertTrustBodySchema.safeParse({});
|
||||
assert.equal(parsed.success, true);
|
||||
assert.equal(parsed.success && parsed.data.sudoPassword, undefined);
|
||||
});
|
||||
|
||||
test("rejects a non-string sudoPassword instead of trusting raw input", () => {
|
||||
const parsed = CertTrustBodySchema.safeParse({ sudoPassword: 12345 });
|
||||
assert.equal(parsed.success, false);
|
||||
});
|
||||
|
||||
test("ignores unrelated extra keys without throwing", () => {
|
||||
const parsed = CertTrustBodySchema.safeParse({ sudoPassword: "x", extra: true });
|
||||
assert.equal(parsed.success, true);
|
||||
assert.equal(parsed.success && parsed.data.sudoPassword, "x");
|
||||
// Zod strips unknown keys by default
|
||||
assert.equal(parsed.success && "extra" in parsed.data, false);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Gap 4: portable JSON import/export of AgentBridge config (bypass patterns +
|
||||
* custom hosts + per-agent model mappings) so users can replicate a setup
|
||||
* across machines. Schema validation is pure; export/import roundtrip uses the
|
||||
* DATA_DIR-tmp + resetDbInstance pattern (CLAUDE.md PII learning #3).
|
||||
*/
|
||||
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-agentbridge-config-")
|
||||
);
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const portability = await import("../../src/lib/inspector/configPortability.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
break;
|
||||
} catch (error: unknown) {
|
||||
const code = (error as { code?: string } | null)?.code;
|
||||
if ((code === "EBUSY" || code === "EPERM") && attempt < 9) {
|
||||
await new Promise((r) => setTimeout(r, 50 * (attempt + 1)));
|
||||
} else throw error;
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("AgentBridgeConfigSchema accepts a well-formed config", () => {
|
||||
const parsed = portability.AgentBridgeConfigSchema.safeParse({
|
||||
version: 1,
|
||||
bypassPatterns: ["*.bank.test"],
|
||||
customHosts: [{ host: "api.internal.test", kind: "custom", label: "Internal" }],
|
||||
agentMappings: { cursor: [{ source: "gpt-4o", target: "claude-sonnet-4-5" }] },
|
||||
});
|
||||
assert.equal(parsed.success, true);
|
||||
});
|
||||
|
||||
test("AgentBridgeConfigSchema rejects a wrong version", () => {
|
||||
const parsed = portability.AgentBridgeConfigSchema.safeParse({
|
||||
version: 2,
|
||||
bypassPatterns: [],
|
||||
customHosts: [],
|
||||
agentMappings: {},
|
||||
});
|
||||
assert.equal(parsed.success, false);
|
||||
});
|
||||
|
||||
test("AgentBridgeConfigSchema rejects a non-string bypass pattern", () => {
|
||||
const parsed = portability.AgentBridgeConfigSchema.safeParse({
|
||||
version: 1,
|
||||
bypassPatterns: [123],
|
||||
customHosts: [],
|
||||
agentMappings: {},
|
||||
});
|
||||
assert.equal(parsed.success, false);
|
||||
});
|
||||
|
||||
test("import then export roundtrips bypass + custom hosts + mappings", () => {
|
||||
const config = {
|
||||
version: 1 as const,
|
||||
bypassPatterns: ["*.bank.test", "literal.example.com"],
|
||||
customHosts: [
|
||||
{ host: "api.internal.test", kind: "custom" as const, label: "Internal LLM" },
|
||||
],
|
||||
agentMappings: {
|
||||
cursor: [{ source: "gpt-4o", target: "claude-sonnet-4-5" }],
|
||||
},
|
||||
};
|
||||
portability.importConfig(config);
|
||||
const exported = portability.exportConfig();
|
||||
|
||||
assert.deepEqual(
|
||||
[...exported.bypassPatterns].sort(),
|
||||
[...config.bypassPatterns].sort(),
|
||||
"bypass patterns must roundtrip"
|
||||
);
|
||||
assert.ok(
|
||||
exported.customHosts.some((h) => h.host === "api.internal.test"),
|
||||
"custom host must roundtrip"
|
||||
);
|
||||
assert.deepEqual(
|
||||
exported.agentMappings.cursor,
|
||||
config.agentMappings.cursor,
|
||||
"agent mappings must roundtrip"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Client-side fetch helpers that the AgentBridge maintenance card uses to drive
|
||||
* the already-shipped backend routes (#4084 repair + DELETE cert, #4093
|
||||
* diagnose, #4094 config import/export). Pure integration logic — no DOM — so
|
||||
* it is unit-testable by stubbing global.fetch: each helper must parse the
|
||||
* success body and surface the sanitized server error message on !res.ok.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const {
|
||||
runDiagnose,
|
||||
removeCaCert,
|
||||
repairMitmState,
|
||||
fetchAgentBridgeConfig,
|
||||
importAgentBridgeConfig,
|
||||
} = await import("../../src/lib/inspector/agentBridgeMaintenanceApi.ts");
|
||||
|
||||
type FetchCall = { url: string; init?: RequestInit };
|
||||
|
||||
function stubFetch(handler: (call: FetchCall) => { ok: boolean; status?: number; body: unknown }) {
|
||||
const calls: FetchCall[] = [];
|
||||
const original = global.fetch;
|
||||
global.fetch = (async (url: string, init?: RequestInit) => {
|
||||
const call = { url: String(url), init };
|
||||
calls.push(call);
|
||||
const { ok, status = ok ? 200 : 500, body } = handler(call);
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as unknown as Response;
|
||||
}) as typeof fetch;
|
||||
return {
|
||||
calls,
|
||||
restore() {
|
||||
global.fetch = original;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("runDiagnose returns the report and hits GET /diagnose", async () => {
|
||||
const report = { healthy: false, checks: [{ name: "cert-trusted", ok: false, hint: "trust it" }], port: 443 };
|
||||
const f = stubFetch(() => ({ ok: true, body: report }));
|
||||
try {
|
||||
const result = await runDiagnose();
|
||||
assert.equal(result.healthy, false);
|
||||
assert.equal(result.port, 443);
|
||||
assert.equal(result.checks[0].name, "cert-trusted");
|
||||
assert.equal(f.calls[0].url, "/api/tools/agent-bridge/diagnose");
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("removeCaCert DELETEs /cert and returns trusted flag", async () => {
|
||||
const f = stubFetch(() => ({ ok: true, body: { ok: true, trusted: false } }));
|
||||
try {
|
||||
const result = await removeCaCert();
|
||||
assert.equal(result.trusted, false);
|
||||
assert.equal(f.calls[0].url, "/api/tools/agent-bridge/cert");
|
||||
assert.equal(f.calls[0].init?.method, "DELETE");
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("repairMitmState POSTs /repair and returns the repaired list", async () => {
|
||||
const f = stubFetch(() => ({ ok: true, body: { ok: true, repaired: ["dns", "system-proxy"] } }));
|
||||
try {
|
||||
const result = await repairMitmState();
|
||||
assert.deepEqual(result.repaired, ["dns", "system-proxy"]);
|
||||
assert.equal(f.calls[0].url, "/api/tools/agent-bridge/repair");
|
||||
assert.equal(f.calls[0].init?.method, "POST");
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("fetchAgentBridgeConfig GETs /config and returns the portable blob", async () => {
|
||||
const cfg = { version: 1, bypassPatterns: ["*.corp"], customHosts: [], agentMappings: {} };
|
||||
const f = stubFetch(() => ({ ok: true, body: cfg }));
|
||||
try {
|
||||
const result = await fetchAgentBridgeConfig();
|
||||
assert.equal(result.version, 1);
|
||||
assert.deepEqual(result.bypassPatterns, ["*.corp"]);
|
||||
assert.equal(f.calls[0].url, "/api/tools/agent-bridge/config");
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("importAgentBridgeConfig POSTs the config and returns the counts", async () => {
|
||||
const cfg = { version: 1 as const, bypassPatterns: ["*.corp"], customHosts: [], agentMappings: {} };
|
||||
const f = stubFetch(() => ({ ok: true, body: { ok: true, bypassPatterns: 1, customHosts: 0, agents: 0 } }));
|
||||
try {
|
||||
const result = await importAgentBridgeConfig(cfg);
|
||||
assert.equal(result.bypassPatterns, 1);
|
||||
assert.equal(f.calls[0].url, "/api/tools/agent-bridge/config");
|
||||
assert.equal(f.calls[0].init?.method, "POST");
|
||||
assert.equal(JSON.parse(String(f.calls[0].init?.body)).version, 1);
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("each helper surfaces the sanitized server error message on !res.ok", async () => {
|
||||
const f = stubFetch(() => ({ ok: false, status: 400, body: { error: { message: "Invalid AgentBridge config" } } }));
|
||||
try {
|
||||
await assert.rejects(() => importAgentBridgeConfig({ version: 1, bypassPatterns: [], customHosts: [], agentMappings: {} }), /Invalid AgentBridge config/);
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("helpers fall back to HTTP status when the error body has no message", async () => {
|
||||
const f = stubFetch(() => ({ ok: false, status: 503, body: null }));
|
||||
try {
|
||||
await assert.rejects(() => repairMitmState(), /HTTP 503/);
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* POST /api/tools/agent-bridge/repair validates its body with RepairBodySchema
|
||||
* via safeParse (route validation gate t06) and falls back to the cached sudo
|
||||
* password when none is supplied. These tests pin that schema contract. (Gap 7.)
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { RepairBodySchema } = await import(
|
||||
"../../src/app/api/tools/agent-bridge/repair/route.ts"
|
||||
);
|
||||
|
||||
test("accepts a body with a string sudoPassword", () => {
|
||||
const parsed = RepairBodySchema.safeParse({ sudoPassword: "hunter2" });
|
||||
assert.equal(parsed.success, true);
|
||||
assert.equal(parsed.success && parsed.data.sudoPassword, "hunter2");
|
||||
});
|
||||
|
||||
test("accepts an empty body (sudoPassword optional, falls back to cached)", () => {
|
||||
const parsed = RepairBodySchema.safeParse({});
|
||||
assert.equal(parsed.success, true);
|
||||
assert.equal(parsed.success && parsed.data.sudoPassword, undefined);
|
||||
});
|
||||
|
||||
test("rejects a non-string sudoPassword instead of trusting raw input", () => {
|
||||
const parsed = RepairBodySchema.safeParse({ sudoPassword: 12345 });
|
||||
assert.equal(parsed.success, false);
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Unit tests: agent-bridge/server route — dynamic import behavior
|
||||
*
|
||||
* Verifies that start/stop/restart actions resolve MITM manager functions
|
||||
* from @/mitm/manager.runtime (bypassing the Turbopack alias to stub.ts)
|
||||
* and that restart re-caches the password after stopMitm clears it.
|
||||
*/
|
||||
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-dynimp-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const serverRoute = await import("../../src/app/api/tools/agent-bridge/server/route.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 */
|
||||
}
|
||||
});
|
||||
|
||||
function makeRequest(action: string, body: Record<string, unknown> = {}) {
|
||||
return new Request("http://localhost/api/tools/agent-bridge/server", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ action, ...body }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── start action ────────────────────────────────────────────────────────────
|
||||
|
||||
test("start: dynamically imports startMitm (not stub)", async () => {
|
||||
// startMitm will fail because there is no real MITM server to spawn,
|
||||
// but it should throw a runtime error from the real module, NOT the stub error.
|
||||
const res = await serverRoute.POST(makeRequest("start", { sudoPassword: "test" }));
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
|
||||
// The real startMitm will fail (no MITM binary), but the error should NOT
|
||||
// contain the stub error message.
|
||||
const errMsg =
|
||||
typeof body.error === "object" && body.error !== null
|
||||
? ((body.error as Record<string, unknown>).message as string)
|
||||
: "";
|
||||
assert.ok(
|
||||
!errMsg.includes("MITM manager stub reached at runtime"),
|
||||
`Expected real MITM error, got stub error: ${errMsg}`
|
||||
);
|
||||
});
|
||||
|
||||
// ── stop action ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("stop: dynamically imports stopMitm (not stub)", async () => {
|
||||
const res = await serverRoute.POST(makeRequest("stop", { sudoPassword: "test" }));
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
|
||||
// stopMitm on a non-running server should succeed (no-op cleanup) or fail
|
||||
// with a real error — never the stub error.
|
||||
if (res.status !== 200) {
|
||||
const errMsg =
|
||||
typeof body.error === "object" && body.error !== null
|
||||
? ((body.error as Record<string, unknown>).message as string)
|
||||
: "";
|
||||
assert.ok(
|
||||
!errMsg.includes("MITM manager stub reached at runtime"),
|
||||
`Expected real error, got stub error: ${errMsg}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ── restart action ──────────────────────────────────────────────────────────
|
||||
|
||||
test("restart: dynamically imports getMitmStatus (not stub)", async () => {
|
||||
// The real getMitmStatus returns running:false since no server is started.
|
||||
// The stub also returns running:false, but we verify the path works end-to-end.
|
||||
const res = await serverRoute.POST(makeRequest("restart", { sudoPassword: "test" }));
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
|
||||
// Should not contain the stub error
|
||||
const errMsg =
|
||||
typeof body.error === "object" && body.error !== null
|
||||
? ((body.error as Record<string, unknown>).message as string)
|
||||
: "";
|
||||
assert.ok(
|
||||
!errMsg.includes("MITM manager stub reached at runtime"),
|
||||
`Expected real error, got stub error: ${errMsg}`
|
||||
);
|
||||
});
|
||||
|
||||
test("restart: re-caches password after stopMitm clears it", async () => {
|
||||
// This test verifies the fix for the cached-password loss bug.
|
||||
// We can't easily observe the internal cache, but we can verify
|
||||
// the restart action completes without a "password required" error
|
||||
// when sudoPassword is provided.
|
||||
const res = await serverRoute.POST(makeRequest("restart", { sudoPassword: "my-secret-pwd" }));
|
||||
// Should not fail with an auth-related error — the password should survive the stop phase
|
||||
assert.ok(res.status === 200 || res.status === 500, `Unexpected status: ${res.status}`);
|
||||
if (res.status === 500) {
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
const errMsg =
|
||||
typeof body.error === "object" && body.error !== null
|
||||
? ((body.error as Record<string, unknown>).message as string)
|
||||
: "";
|
||||
// Should NOT be a stub error or password-missing error
|
||||
assert.ok(
|
||||
!errMsg.includes("MITM manager stub reached at runtime"),
|
||||
`Got stub error instead of runtime error: ${errMsg}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ── existing validation tests (unchanged behavior) ─────────────────────────
|
||||
|
||||
test("invalid action returns 400", async () => {
|
||||
const res = await serverRoute.POST(makeRequest("bogus"));
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test("malformed JSON 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);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { normalizeAgentBridgeState, DEFAULT_AGENT_BRIDGE_STATE } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/tools/agent-bridge/normalizeState.ts"
|
||||
);
|
||||
|
||||
// #3318: the /api/tools/agent-bridge/state route returns `{ server, agents }`,
|
||||
// but the page/components read `{ serverState, agentStates, bypassPatterns,
|
||||
// mappings }`. The page replaced its well-shaped default with the raw response,
|
||||
// so `serverState` became undefined → `serverState.running` crashed the page
|
||||
// with the full "Internal Server Error" boundary. The normalizer must always
|
||||
// return a well-shaped object (never an undefined serverState), mapping the
|
||||
// known server fields through.
|
||||
|
||||
test("the raw /state route shape lacks the keys the page reads (documents the bug)", () => {
|
||||
const routeShape = {
|
||||
server: { running: true, pid: 123, dnsConfigured: true, certExists: true },
|
||||
agents: [{ id: "claude-code", name: "Claude Code", hosts: [], viability: "ok" }],
|
||||
};
|
||||
// This is exactly what the old code assigned straight into initialData.
|
||||
assert.equal(routeShape.serverState, undefined);
|
||||
});
|
||||
|
||||
test("normalizeAgentBridgeState maps the route shape and never leaves serverState undefined (#3318)", () => {
|
||||
const routeShape = {
|
||||
server: { running: true, pid: 123, dnsConfigured: true, certExists: true },
|
||||
agents: [{ id: "claude-code", name: "Claude Code", hosts: [], viability: "ok" }],
|
||||
};
|
||||
const result = normalizeAgentBridgeState(routeShape);
|
||||
|
||||
assert.ok(result.serverState, "serverState must be defined");
|
||||
assert.equal(result.serverState.running, true, "server.running maps through");
|
||||
assert.equal(result.serverState.certTrusted, true, "server.certExists -> certTrusted");
|
||||
assert.ok(Array.isArray(result.agentStates), "agentStates is always an array");
|
||||
assert.ok(Array.isArray(result.bypassPatterns), "bypassPatterns is always an array");
|
||||
assert.equal(typeof result.mappings, "object", "mappings is always an object");
|
||||
});
|
||||
|
||||
test("normalizeAgentBridgeState falls back to safe defaults for empty/garbage input", () => {
|
||||
for (const bad of [null, undefined, {}, 42, "x", []]) {
|
||||
const result = normalizeAgentBridgeState(bad);
|
||||
assert.ok(result.serverState, `serverState defined for ${JSON.stringify(bad)}`);
|
||||
assert.equal(result.serverState.running, false);
|
||||
assert.ok(Array.isArray(result.agentStates));
|
||||
assert.ok(Array.isArray(result.bypassPatterns));
|
||||
assert.equal(typeof result.mappings, "object");
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizeAgentBridgeState maps orphanedStateDetected + dnsConfigured from getMitmStatus (Gap 7 repair banner)", () => {
|
||||
// getMitmStatus() returns these two flags; the maintenance card needs them in
|
||||
// serverState to decide whether to surface the "orphaned state — repair" banner.
|
||||
const routeShape = {
|
||||
server: { running: false, dnsConfigured: true, certExists: true, orphanedStateDetected: true },
|
||||
agents: [],
|
||||
};
|
||||
const result = normalizeAgentBridgeState(routeShape);
|
||||
assert.equal(result.serverState.orphanedStateDetected, true, "orphanedStateDetected maps through");
|
||||
assert.equal(result.serverState.dnsConfigured, true, "dnsConfigured maps through");
|
||||
});
|
||||
|
||||
test("normalizeAgentBridgeState defaults orphanedStateDetected + dnsConfigured to false", () => {
|
||||
for (const bad of [null, undefined, {}, { server: {} }]) {
|
||||
const result = normalizeAgentBridgeState(bad);
|
||||
assert.equal(result.serverState.orphanedStateDetected, false);
|
||||
assert.equal(result.serverState.dnsConfigured, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizeAgentBridgeState passes a correctly-shaped payload through intact", () => {
|
||||
const correct = {
|
||||
...DEFAULT_AGENT_BRIDGE_STATE,
|
||||
serverState: { ...DEFAULT_AGENT_BRIDGE_STATE.serverState, running: true, port: 8443 },
|
||||
agentStates: [
|
||||
{
|
||||
agent_id: "claude-code",
|
||||
dns_enabled: true,
|
||||
cert_trusted: true,
|
||||
setup_completed: true,
|
||||
last_started_at: null,
|
||||
last_error: null,
|
||||
},
|
||||
],
|
||||
bypassPatterns: ["*.internal"],
|
||||
mappings: { "claude-code": [] },
|
||||
};
|
||||
const result = normalizeAgentBridgeState(correct);
|
||||
assert.equal(result.serverState.running, true);
|
||||
assert.equal(result.serverState.port, 8443);
|
||||
assert.equal(result.agentStates.length, 1);
|
||||
assert.equal(result.agentStates[0].agent_id, "claude-code");
|
||||
assert.deepEqual(result.bypassPatterns, ["*.internal"]);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { ALL_TARGETS } from "../../src/mitm/targets/index.ts";
|
||||
|
||||
// Regression guard for the agent-bridge "erro ao carregar" bug.
|
||||
//
|
||||
// `agent-bridge/page.tsx` is a Server Component that passes `targets` to the
|
||||
// `AgentBridgePageClient` Client Component. Each MitmTarget carries a
|
||||
// `handler: () => Promise<...>` function. Next.js forbids passing functions
|
||||
// across the Server/Client boundary, raising at runtime:
|
||||
// "Functions cannot be passed directly to Client Components ..."
|
||||
// which broke SSR for the whole page. The fix sanitizes the array via
|
||||
// ALL_TARGETS.map(({ handler, ...rest }) => rest)
|
||||
// (a MitmTargetView). These tests pin both halves of that contract.
|
||||
|
||||
test("agent-bridge: sanitized targets (no handler) are fully serializable for Client Components", () => {
|
||||
const views = ALL_TARGETS.map(({ handler, ...rest }) => rest);
|
||||
assert.ok(views.length > 0, "expected at least one MITM target");
|
||||
for (const v of views) {
|
||||
const id = (v as { id?: string }).id ?? "<unknown>";
|
||||
assert.equal("handler" in v, false, `${id}: handler must be stripped before crossing to a Client Component`);
|
||||
for (const [key, value] of Object.entries(v)) {
|
||||
assert.notEqual(typeof value, "function", `${id}.${key} must not be a function (non-serializable)`);
|
||||
}
|
||||
assert.doesNotThrow(() => JSON.parse(JSON.stringify(v)), `${id} must be JSON-serializable`);
|
||||
}
|
||||
});
|
||||
|
||||
test("agent-bridge: raw ALL_TARGETS still carry a handler function (so the sanitization is required)", () => {
|
||||
for (const t of ALL_TARGETS) {
|
||||
assert.equal(typeof t.handler, "function", `${t.id} should expose a lazy handler function`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Unit tests for GET /.well-known/agent.json (Agent Card endpoint).
|
||||
*
|
||||
* Verifies:
|
||||
* - Response includes 6 skills after the list-capabilities addition
|
||||
* - list-capabilities entry has the required id, tags, and examples
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { GET } = await import("../../src/app/.well-known/agent.json/route.js");
|
||||
|
||||
interface AgentSkillEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
examples: string[];
|
||||
}
|
||||
|
||||
interface AgentCard {
|
||||
name: string;
|
||||
version: string;
|
||||
skills: AgentSkillEntry[];
|
||||
}
|
||||
|
||||
test("GET /.well-known/agent.json returns 6 skills", async () => {
|
||||
const response = await GET();
|
||||
assert.equal(response.status, 200, "Expected HTTP 200");
|
||||
|
||||
const body = (await response.json()) as AgentCard;
|
||||
assert.ok(Array.isArray(body.skills), "skills is an array");
|
||||
assert.equal(body.skills.length, 6, "Expected exactly 6 skills");
|
||||
});
|
||||
|
||||
test("Agent Card includes list-capabilities skill entry", async () => {
|
||||
const response = await GET();
|
||||
const body = (await response.json()) as AgentCard;
|
||||
|
||||
const skill = body.skills.find((s) => s.id === "list-capabilities");
|
||||
assert.ok(skill, "list-capabilities skill must be present in Agent Card");
|
||||
});
|
||||
|
||||
test("list-capabilities entry has required tags [discovery, capabilities]", async () => {
|
||||
const response = await GET();
|
||||
const body = (await response.json()) as AgentCard;
|
||||
|
||||
const skill = body.skills.find((s) => s.id === "list-capabilities");
|
||||
assert.ok(skill, "list-capabilities skill must be present");
|
||||
assert.ok(Array.isArray(skill.tags), "tags is an array");
|
||||
assert.ok(skill.tags.includes("discovery"), "tags includes 'discovery'");
|
||||
assert.ok(skill.tags.includes("capabilities"), "tags includes 'capabilities'");
|
||||
});
|
||||
|
||||
test("list-capabilities entry has at least one example question", async () => {
|
||||
const response = await GET();
|
||||
const body = (await response.json()) as AgentCard;
|
||||
|
||||
const skill = body.skills.find((s) => s.id === "list-capabilities");
|
||||
assert.ok(skill, "list-capabilities skill must be present");
|
||||
assert.ok(Array.isArray(skill.examples), "examples is an array");
|
||||
assert.ok(skill.examples.length > 0, "examples has at least one entry");
|
||||
});
|
||||
|
||||
test("Agent Card includes all 5 original skills", async () => {
|
||||
const response = await GET();
|
||||
const body = (await response.json()) as AgentCard;
|
||||
|
||||
const originalIds = [
|
||||
"smart-routing",
|
||||
"quota-management",
|
||||
"provider-discovery",
|
||||
"cost-analysis",
|
||||
"health-report",
|
||||
];
|
||||
|
||||
for (const id of originalIds) {
|
||||
assert.ok(
|
||||
body.skills.some((s) => s.id === id),
|
||||
`Original skill '${id}' must be present in Agent Card`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DEFAULT_AGENT_GOAL_READINESS_MAX_TIMEOUT_MS,
|
||||
isAgentGoalRequestBody,
|
||||
resolveAgentGoalPolicy,
|
||||
} from "../../open-sse/utils/agentGoalPolicy.ts";
|
||||
|
||||
test("detects Claude /goal slash command in nested message content", () => {
|
||||
assert.equal(
|
||||
isAgentGoalRequestBody({
|
||||
model: "claude-sonnet-4-5",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "please continue" },
|
||||
{ type: "text", text: "/goal finish the migration without stopping" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("does not detect ordinary goal text or longer slash commands", () => {
|
||||
assert.equal(isAgentGoalRequestBody({ messages: [{ content: "goal: keep going" }] }), false);
|
||||
assert.equal(isAgentGoalRequestBody({ messages: [{ content: "/goals list" }] }), false);
|
||||
});
|
||||
|
||||
test("header can force goal policy and env can tune or disable recovery", () => {
|
||||
const forced = resolveAgentGoalPolicy(
|
||||
{ messages: [{ content: "normal prompt" }] },
|
||||
{ "x-omniroute-agent-goal": "true" },
|
||||
{
|
||||
OMNIROUTE_AGENT_GOAL_READINESS_MAX_TIMEOUT_MS: "900000",
|
||||
OMNIROUTE_AGENT_GOAL_STREAM_RECOVERY: "false",
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(forced.detected, true);
|
||||
assert.equal(forced.readinessMaxTimeoutMs, 900_000);
|
||||
assert.equal(forced.streamRecoveryEnabled, false);
|
||||
});
|
||||
|
||||
test("goal policy defaults to 10 minute readiness cap with recovery enabled", () => {
|
||||
const policy = resolveAgentGoalPolicy({ messages: [{ content: "/goal ship it" }] }, null, {});
|
||||
|
||||
assert.equal(policy.detected, true);
|
||||
assert.equal(policy.readinessMaxTimeoutMs, DEFAULT_AGENT_GOAL_READINESS_MAX_TIMEOUT_MS);
|
||||
assert.equal(policy.streamRecoveryEnabled, true);
|
||||
});
|
||||
|
||||
test("OMNIROUTE_AGENT_GOAL_POLICY_ENABLED defaults to true — heuristic stays active", () => {
|
||||
const policy = resolveAgentGoalPolicy({ messages: [{ content: "/goal ship it" }] }, null, {});
|
||||
|
||||
assert.equal(policy.detected, true);
|
||||
assert.equal(policy.streamRecoveryEnabled, true);
|
||||
});
|
||||
|
||||
test("OMNIROUTE_AGENT_GOAL_POLICY_ENABLED=false disables the heuristic entirely (no-op)", () => {
|
||||
const policy = resolveAgentGoalPolicy(
|
||||
{ messages: [{ content: "/goal ship it" }] },
|
||||
{ "x-omniroute-agent-goal": "true" },
|
||||
{
|
||||
OMNIROUTE_AGENT_GOAL_POLICY_ENABLED: "false",
|
||||
OMNIROUTE_AGENT_GOAL_READINESS_MAX_TIMEOUT_MS: "900000",
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(policy.detected, false, "detection must be a no-op even when header forces it");
|
||||
assert.equal(
|
||||
policy.readinessMaxTimeoutMs,
|
||||
DEFAULT_AGENT_GOAL_READINESS_MAX_TIMEOUT_MS,
|
||||
"readiness timeout must never be elevated when the policy is disabled"
|
||||
);
|
||||
assert.equal(policy.streamRecoveryEnabled, false);
|
||||
});
|
||||
@@ -0,0 +1,645 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Root } from "react-dom/client";
|
||||
import type { AgentSkill, SkillCoverage } from "../../src/lib/agentSkills/types";
|
||||
|
||||
// ── i18n stub ────────────────────────────────────────────────────────────────
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// ── next/link stub ───────────────────────────────────────────────────────────
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<a href={href} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── next/dynamic stub — renders placeholder immediately ───────────────────────
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: (loader: () => Promise<{ default: React.ComponentType<{ children: string }> }>, _opts?: unknown) => {
|
||||
// Return a synchronous stub that renders children as plain text.
|
||||
return function DynamicStub({ children }: { children: string }) {
|
||||
return <div data-testid="react-markdown">{children}</div>;
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Fixture helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function makeSkill(overrides: Partial<AgentSkill> = {}): AgentSkill {
|
||||
return {
|
||||
id: "omni-providers",
|
||||
name: "Providers",
|
||||
description: "Manage provider connections and API keys.",
|
||||
category: "api",
|
||||
area: "providers",
|
||||
icon: "hub",
|
||||
endpoints: ["POST /api/providers", "GET /api/providers"],
|
||||
rawUrl: "https://raw.githubusercontent.com/diegosouzapw/OmniRoute/refs/heads/main/skills/omni-providers/SKILL.md",
|
||||
githubUrl: "https://github.com/diegosouzapw/OmniRoute/blob/main/skills/omni-providers/SKILL.md",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function make42Skills(): AgentSkill[] {
|
||||
const skills: AgentSkill[] = [];
|
||||
for (let i = 0; i < 22; i++) {
|
||||
skills.push(
|
||||
makeSkill({
|
||||
id: `omni-skill-${i}`,
|
||||
name: `API Skill ${i}`,
|
||||
category: "api",
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < 20; i++) {
|
||||
skills.push(
|
||||
makeSkill({
|
||||
id: `cli-skill-${i}`,
|
||||
name: `CLI Skill ${i}`,
|
||||
category: "cli",
|
||||
endpoints: undefined,
|
||||
cliCommands: [`skill${i} run`, `skill${i} status`],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return skills;
|
||||
}
|
||||
|
||||
const FULL_COVERAGE: SkillCoverage = {
|
||||
api: { have: 22, total: 22 },
|
||||
cli: { have: 20, total: 20 },
|
||||
totalSkills: 42,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const PARTIAL_COVERAGE: SkillCoverage = {
|
||||
api: { have: 10, total: 22 },
|
||||
cli: { have: 8, total: 20 },
|
||||
totalSkills: 18,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// ── Fetch mock factory ───────────────────────────────────────────────────────
|
||||
|
||||
function mockFetch(skills: AgentSkill[], coverage: SkillCoverage, rawMarkdown = "# Test Skill\nContent here.") {
|
||||
return vi.fn(async (url: string | Request) => {
|
||||
const urlStr = typeof url === "string" ? url : url.toString();
|
||||
if (urlStr === "/api/agent-skills") {
|
||||
return new Response(JSON.stringify({ skills, coverage }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (/\/api\/agent-skills\/.+\/raw/.test(urlStr)) {
|
||||
return new Response(rawMarkdown, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/markdown; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Test setup ───────────────────────────────────────────────────────────────
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
let root: Root | null = null;
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
// Mock clipboard
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
configurable: true,
|
||||
});
|
||||
// Mock window.location.origin
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { origin: "http://localhost:20128" },
|
||||
configurable: true,
|
||||
});
|
||||
// Mock window.confirm
|
||||
vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
root = null;
|
||||
}
|
||||
while (cleanupCallbacks.length > 0) {
|
||||
cleanupCallbacks.pop()?.();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AgentSkillsPageClient", () => {
|
||||
it("renders 42 skill cards after fetch resolves", async () => {
|
||||
const skills = make42Skills();
|
||||
vi.stubGlobal("fetch", mockFetch(skills, FULL_COVERAGE));
|
||||
|
||||
const { AgentSkillsPageClient } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
|
||||
);
|
||||
const container = makeContainer();
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
const cards = container.querySelectorAll("[data-testid^='skill-card-']");
|
||||
expect(cards.length).toBe(42);
|
||||
});
|
||||
|
||||
it("renders SkillsConceptCard variant=agent at the top", async () => {
|
||||
vi.stubGlobal("fetch", mockFetch(make42Skills(), FULL_COVERAGE));
|
||||
|
||||
const { AgentSkillsPageClient } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
|
||||
);
|
||||
const container = makeContainer();
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
// SkillsConceptCard uses i18n key conceptCard.agent.title
|
||||
expect(container.textContent).toContain("conceptCard.agent.title");
|
||||
});
|
||||
|
||||
it("filter API shows only api-category cards", async () => {
|
||||
const skills = make42Skills();
|
||||
vi.stubGlobal("fetch", mockFetch(skills, FULL_COVERAGE));
|
||||
|
||||
const { AgentSkillsPageClient } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
|
||||
);
|
||||
const container = makeContainer();
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
const filterApiBtn = container.querySelector("[data-testid='filter-api']") as HTMLButtonElement | null;
|
||||
expect(filterApiBtn).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
filterApiBtn?.click();
|
||||
});
|
||||
|
||||
const cards = container.querySelectorAll("[data-testid^='skill-card-']");
|
||||
expect(cards.length).toBe(22);
|
||||
});
|
||||
|
||||
it("filter CLI shows only cli-category cards", async () => {
|
||||
const skills = make42Skills();
|
||||
vi.stubGlobal("fetch", mockFetch(skills, FULL_COVERAGE));
|
||||
|
||||
const { AgentSkillsPageClient } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
|
||||
);
|
||||
const container = makeContainer();
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
const filterCliBtn = container.querySelector("[data-testid='filter-cli']") as HTMLButtonElement | null;
|
||||
await act(async () => {
|
||||
filterCliBtn?.click();
|
||||
});
|
||||
|
||||
const cards = container.querySelectorAll("[data-testid^='skill-card-']");
|
||||
expect(cards.length).toBe(20);
|
||||
});
|
||||
|
||||
it("clicking a card triggers preview fetch after 200ms debounce", async () => {
|
||||
vi.useFakeTimers();
|
||||
const skills = make42Skills();
|
||||
const fetchMock = mockFetch(skills, FULL_COVERAGE, "# omni-skill-0 doc");
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { AgentSkillsPageClient } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
|
||||
);
|
||||
const container = makeContainer();
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
const firstCard = container.querySelector("[data-testid='skill-card-omni-skill-0']") as HTMLElement | null;
|
||||
expect(firstCard).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
firstCard?.click();
|
||||
});
|
||||
|
||||
// Before debounce fires — raw fetch should NOT have been made yet
|
||||
const rawFetchCallsBefore = (fetchMock as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
([url]: [string]) => typeof url === "string" && url.includes("/raw"),
|
||||
);
|
||||
expect(rawFetchCallsBefore.length).toBe(0);
|
||||
|
||||
// Advance timers past the 200ms debounce
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
// Now the raw fetch should have been triggered
|
||||
const rawFetchCallsAfter = (fetchMock as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
([url]: [string]) => typeof url === "string" && url.includes("/raw"),
|
||||
);
|
||||
expect(rawFetchCallsAfter.length).toBeGreaterThan(0);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("preview pane shows empty state when no card is selected", async () => {
|
||||
vi.stubGlobal("fetch", mockFetch(make42Skills(), FULL_COVERAGE));
|
||||
|
||||
const { AgentSkillsPageClient } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
|
||||
);
|
||||
const container = makeContainer();
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
const emptyState = container.querySelector("[data-testid='skill-preview-empty']");
|
||||
expect(emptyState).not.toBeNull();
|
||||
});
|
||||
|
||||
it("CoverageBar is rendered with 100% = green bars when coverage is full", async () => {
|
||||
vi.stubGlobal("fetch", mockFetch(make42Skills(), FULL_COVERAGE));
|
||||
|
||||
const { AgentSkillsPageClient } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
|
||||
);
|
||||
const container = makeContainer();
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
const coverageBar = container.querySelector("[data-testid='coverage-bar']");
|
||||
expect(coverageBar).not.toBeNull();
|
||||
|
||||
const progressBars = container.querySelectorAll("[role='progressbar']");
|
||||
expect(progressBars.length).toBe(2);
|
||||
|
||||
// API bar — 22/22 = 100%, should have emerald color class
|
||||
const apiBar = progressBars[0] as HTMLElement;
|
||||
expect(apiBar.className).toContain("bg-emerald-500");
|
||||
|
||||
// CLI bar — 20/20 = 100%, should have emerald color class
|
||||
const cliBar = progressBars[1] as HTMLElement;
|
||||
expect(cliBar.className).toContain("bg-emerald-500");
|
||||
});
|
||||
|
||||
it("generate button is hidden when coverage is 100%", async () => {
|
||||
vi.stubGlobal("fetch", mockFetch(make42Skills(), FULL_COVERAGE));
|
||||
|
||||
const { AgentSkillsPageClient } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
|
||||
);
|
||||
const container = makeContainer();
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
const generateBtn = container.querySelector("[data-testid='generate-button']");
|
||||
expect(generateBtn).toBeNull();
|
||||
});
|
||||
|
||||
it("generate button is visible when coverage is partial", async () => {
|
||||
vi.stubGlobal("fetch", mockFetch(make42Skills(), PARTIAL_COVERAGE));
|
||||
|
||||
const { AgentSkillsPageClient } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
|
||||
);
|
||||
const container = makeContainer();
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
const generateBtn = container.querySelector("[data-testid='generate-button']");
|
||||
expect(generateBtn).not.toBeNull();
|
||||
});
|
||||
|
||||
it("search filters cards by name", async () => {
|
||||
const skills = make42Skills();
|
||||
vi.stubGlobal("fetch", mockFetch(skills, FULL_COVERAGE));
|
||||
|
||||
const { AgentSkillsPageClient } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
|
||||
);
|
||||
const container = makeContainer();
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
const searchInput = container.querySelector("[data-testid='search-input']") as HTMLInputElement | null;
|
||||
expect(searchInput).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
searchInput.value = "API Skill 0";
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
// React uses onChange
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(searchInput, "API Skill 0");
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
// After search, cards with "API Skill 0" in name should be visible
|
||||
// (at minimum the one exact match)
|
||||
const cards = container.querySelectorAll("[data-testid^='skill-card-']");
|
||||
expect(cards.length).toBeLessThanOrEqual(42);
|
||||
});
|
||||
|
||||
it("MCP and A2A links bar is present", async () => {
|
||||
vi.stubGlobal("fetch", mockFetch(make42Skills(), FULL_COVERAGE));
|
||||
|
||||
const { AgentSkillsPageClient } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
|
||||
);
|
||||
const container = makeContainer();
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
const linksBar = container.querySelector("[data-testid='mcp-a2a-links-bar']");
|
||||
expect(linksBar).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── CoverageBar isolated tests ───────────────────────────────────────────────
|
||||
|
||||
describe("CoverageBar", () => {
|
||||
it("renders two progressbars with correct aria attributes", async () => {
|
||||
const { CoverageBar } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const localRoot = createRoot(container);
|
||||
await act(async () => {
|
||||
localRoot.render(<CoverageBar coverage={FULL_COVERAGE} />);
|
||||
});
|
||||
|
||||
const bars = container.querySelectorAll("[role='progressbar']");
|
||||
expect(bars.length).toBe(2);
|
||||
|
||||
const apiBar = bars[0] as HTMLElement;
|
||||
expect(apiBar.getAttribute("aria-valuenow")).toBe("22");
|
||||
expect(apiBar.getAttribute("aria-valuemax")).toBe("22");
|
||||
|
||||
const cliBar = bars[1] as HTMLElement;
|
||||
expect(cliBar.getAttribute("aria-valuenow")).toBe("20");
|
||||
expect(cliBar.getAttribute("aria-valuemax")).toBe("20");
|
||||
|
||||
await act(async () => localRoot.unmount());
|
||||
});
|
||||
|
||||
it("applies red color class when coverage is below 75%", async () => {
|
||||
const { CoverageBar } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar"
|
||||
);
|
||||
const lowCoverage: SkillCoverage = {
|
||||
api: { have: 5, total: 22 },
|
||||
cli: { have: 0, total: 20 },
|
||||
totalSkills: 5,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const container = makeContainer();
|
||||
const localRoot = createRoot(container);
|
||||
await act(async () => {
|
||||
localRoot.render(<CoverageBar coverage={lowCoverage} />);
|
||||
});
|
||||
|
||||
const bars = container.querySelectorAll("[role='progressbar']");
|
||||
const apiBar = bars[0] as HTMLElement;
|
||||
expect(apiBar.className).toContain("bg-red-500");
|
||||
|
||||
await act(async () => localRoot.unmount());
|
||||
});
|
||||
|
||||
it("applies amber color class when coverage is between 75% and 100%", async () => {
|
||||
const { CoverageBar } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar"
|
||||
);
|
||||
const partialCoverage: SkillCoverage = {
|
||||
api: { have: 18, total: 22 }, // ~81.8% = amber
|
||||
cli: { have: 15, total: 20 }, // 75% = amber
|
||||
totalSkills: 33,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const container = makeContainer();
|
||||
const localRoot = createRoot(container);
|
||||
await act(async () => {
|
||||
localRoot.render(<CoverageBar coverage={partialCoverage} />);
|
||||
});
|
||||
|
||||
const bars = container.querySelectorAll("[role='progressbar']");
|
||||
const apiBar = bars[0] as HTMLElement;
|
||||
expect(apiBar.className).toContain("bg-amber-400");
|
||||
|
||||
await act(async () => localRoot.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
// ── SkillCard isolated tests ─────────────────────────────────────────────────
|
||||
|
||||
describe("SkillCard", () => {
|
||||
it("renders skill name and description", async () => {
|
||||
const { SkillCard } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/components/SkillCard"
|
||||
);
|
||||
const skill = makeSkill({ name: "Providers", description: "Manage connections" });
|
||||
const container = makeContainer();
|
||||
const localRoot = createRoot(container);
|
||||
await act(async () => {
|
||||
localRoot.render(<SkillCard skill={skill} selected={false} onClick={() => {}} />);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Providers");
|
||||
expect(container.textContent).toContain("Manage connections");
|
||||
|
||||
await act(async () => localRoot.unmount());
|
||||
});
|
||||
|
||||
it("has role=button and aria-pressed=false when not selected", async () => {
|
||||
const { SkillCard } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/components/SkillCard"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const localRoot = createRoot(container);
|
||||
await act(async () => {
|
||||
localRoot.render(<SkillCard skill={makeSkill()} selected={false} onClick={() => {}} />);
|
||||
});
|
||||
|
||||
const btn = container.querySelector("[role='button']") as HTMLElement | null;
|
||||
expect(btn).not.toBeNull();
|
||||
expect(btn?.getAttribute("aria-pressed")).toBe("false");
|
||||
|
||||
await act(async () => localRoot.unmount());
|
||||
});
|
||||
|
||||
it("has aria-pressed=true when selected", async () => {
|
||||
const { SkillCard } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/components/SkillCard"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const localRoot = createRoot(container);
|
||||
await act(async () => {
|
||||
localRoot.render(<SkillCard skill={makeSkill()} selected={true} onClick={() => {}} />);
|
||||
});
|
||||
|
||||
const btn = container.querySelector("[role='button']") as HTMLElement | null;
|
||||
expect(btn?.getAttribute("aria-pressed")).toBe("true");
|
||||
|
||||
await act(async () => localRoot.unmount());
|
||||
});
|
||||
|
||||
it("calls onClick when clicked", async () => {
|
||||
const { SkillCard } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/components/SkillCard"
|
||||
);
|
||||
const handleClick = vi.fn();
|
||||
const container = makeContainer();
|
||||
const localRoot = createRoot(container);
|
||||
await act(async () => {
|
||||
localRoot.render(<SkillCard skill={makeSkill()} selected={false} onClick={handleClick} />);
|
||||
});
|
||||
|
||||
const btn = container.querySelector("[role='button']") as HTMLElement | null;
|
||||
await act(async () => btn?.click());
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => localRoot.unmount());
|
||||
});
|
||||
|
||||
it("shows first 2 endpoints as chips for API skill", async () => {
|
||||
const { SkillCard } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/components/SkillCard"
|
||||
);
|
||||
const skill = makeSkill({
|
||||
endpoints: ["POST /api/providers", "GET /api/providers", "DELETE /api/providers/:id"],
|
||||
});
|
||||
const container = makeContainer();
|
||||
const localRoot = createRoot(container);
|
||||
await act(async () => {
|
||||
localRoot.render(<SkillCard skill={skill} selected={false} onClick={() => {}} />);
|
||||
});
|
||||
|
||||
const chips = container.querySelectorAll("code");
|
||||
expect(chips.length).toBe(2);
|
||||
expect(chips[0].textContent).toBe("POST /api/providers");
|
||||
expect(chips[1].textContent).toBe("GET /api/providers");
|
||||
|
||||
await act(async () => localRoot.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
// ── SkillPreviewPane isolated tests ──────────────────────────────────────────
|
||||
|
||||
describe("SkillPreviewPane", () => {
|
||||
it("renders empty state when skillId is null", async () => {
|
||||
const { SkillPreviewPane } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const localRoot = createRoot(container);
|
||||
await act(async () => {
|
||||
localRoot.render(
|
||||
<SkillPreviewPane skillId={null} markdown={null} loading={false} />,
|
||||
);
|
||||
});
|
||||
|
||||
const empty = container.querySelector("[data-testid='skill-preview-empty']");
|
||||
expect(empty).not.toBeNull();
|
||||
expect(container.textContent).toContain("previewEmpty");
|
||||
|
||||
await act(async () => localRoot.unmount());
|
||||
});
|
||||
|
||||
it("renders markdown when skillId and markdown are provided", async () => {
|
||||
const { SkillPreviewPane } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const localRoot = createRoot(container);
|
||||
await act(async () => {
|
||||
localRoot.render(
|
||||
<SkillPreviewPane
|
||||
skillId="omni-providers"
|
||||
markdown="# Providers\nContent here."
|
||||
loading={false}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const preview = container.querySelector("[data-testid='skill-preview-pane']");
|
||||
expect(preview).not.toBeNull();
|
||||
expect(container.textContent).toContain("Providers");
|
||||
|
||||
await act(async () => localRoot.unmount());
|
||||
});
|
||||
|
||||
it("shows error state when skillId provided but markdown is empty string", async () => {
|
||||
const { SkillPreviewPane } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const localRoot = createRoot(container);
|
||||
await act(async () => {
|
||||
localRoot.render(
|
||||
<SkillPreviewPane skillId="omni-providers" markdown="" loading={false} />,
|
||||
);
|
||||
});
|
||||
|
||||
// markdown is "" (falsy) — should show error state
|
||||
const errorEl = container.querySelector("[data-testid='skill-preview-error']");
|
||||
expect(errorEl).not.toBeNull();
|
||||
|
||||
await act(async () => localRoot.unmount());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Dynamic imports for ESM + tsx compatibility
|
||||
const { agentSkillTools, AgentSkillsListSchema, AgentSkillsGetSchema, AgentSkillsCoverageSchema } =
|
||||
await import("../../open-sse/mcp-server/tools/agentSkillTools.ts");
|
||||
|
||||
const { MCP_TOOL_MAP } = await import("../../open-sse/mcp-server/schemas/tools.ts");
|
||||
|
||||
// ─── Schema registration in MCP_TOOL_MAP ──────────────────────────────────
|
||||
|
||||
test("omniroute_agent_skills_list is registered in MCP_TOOL_MAP", () => {
|
||||
const tool = MCP_TOOL_MAP["omniroute_agent_skills_list"];
|
||||
assert.ok(tool, "Tool should exist in MCP_TOOL_MAP");
|
||||
assert.equal(tool.name, "omniroute_agent_skills_list");
|
||||
assert.deepEqual(tool.scopes, ["read:catalog"]);
|
||||
});
|
||||
|
||||
test("omniroute_agent_skills_get is registered in MCP_TOOL_MAP", () => {
|
||||
const tool = MCP_TOOL_MAP["omniroute_agent_skills_get"];
|
||||
assert.ok(tool, "Tool should exist in MCP_TOOL_MAP");
|
||||
assert.equal(tool.name, "omniroute_agent_skills_get");
|
||||
assert.deepEqual(tool.scopes, ["read:catalog"]);
|
||||
});
|
||||
|
||||
test("omniroute_agent_skills_coverage is registered in MCP_TOOL_MAP", () => {
|
||||
const tool = MCP_TOOL_MAP["omniroute_agent_skills_coverage"];
|
||||
assert.ok(tool, "Tool should exist in MCP_TOOL_MAP");
|
||||
assert.equal(tool.name, "omniroute_agent_skills_coverage");
|
||||
assert.deepEqual(tool.scopes, ["read:catalog"]);
|
||||
});
|
||||
|
||||
// ─── agentSkillTools object shape ────────────────────────────────────────
|
||||
|
||||
test("agentSkillTools exports exactly 3 tools", () => {
|
||||
const keys = Object.keys(agentSkillTools);
|
||||
assert.deepEqual(keys.sort(), [
|
||||
"omniroute_agent_skills_coverage",
|
||||
"omniroute_agent_skills_get",
|
||||
"omniroute_agent_skills_list",
|
||||
]);
|
||||
});
|
||||
|
||||
test("each agentSkillTool has name, description, inputSchema, and handler", () => {
|
||||
for (const toolDef of Object.values(agentSkillTools)) {
|
||||
assert.ok(
|
||||
typeof toolDef.name === "string" && toolDef.name.length > 0,
|
||||
`${toolDef.name}: name missing`
|
||||
);
|
||||
assert.ok(
|
||||
typeof toolDef.description === "string" && toolDef.description.length > 0,
|
||||
`${toolDef.name}: description missing`
|
||||
);
|
||||
assert.ok(toolDef.inputSchema != null, `${toolDef.name}: inputSchema missing`);
|
||||
assert.ok(typeof toolDef.handler === "function", `${toolDef.name}: handler missing`);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── omniroute_agent_skills_list ────────────────────────────────────────────
|
||||
|
||||
test("omniroute_agent_skills_list with no filters returns all 44 skills", async () => {
|
||||
const result = await agentSkillTools.omniroute_agent_skills_list.handler({});
|
||||
assert.equal(result.count, 44, `Expected 44 but got ${result.count}`);
|
||||
assert.ok(Array.isArray(result.skills));
|
||||
assert.equal(result.skills.length, 44);
|
||||
});
|
||||
|
||||
test("omniroute_agent_skills_list({category:'api'}) returns exactly 23 entries", async () => {
|
||||
const result = await agentSkillTools.omniroute_agent_skills_list.handler({ category: "api" });
|
||||
assert.equal(result.count, 23, `Expected 23 api skills but got ${result.count}`);
|
||||
assert.ok(result.skills.every((s: { category: string }) => s.category === "api"));
|
||||
});
|
||||
|
||||
test("omniroute_agent_skills_list({category:'cli'}) returns exactly 20 entries", async () => {
|
||||
const result = await agentSkillTools.omniroute_agent_skills_list.handler({ category: "cli" });
|
||||
assert.equal(result.count, 20, `Expected 20 cli skills but got ${result.count}`);
|
||||
assert.ok(result.skills.every((s: { category: string }) => s.category === "cli"));
|
||||
});
|
||||
|
||||
test("omniroute_agent_skills_list result includes coverage shape", async () => {
|
||||
const result = await agentSkillTools.omniroute_agent_skills_list.handler({});
|
||||
assert.ok(result.coverage != null, "coverage should be present");
|
||||
assert.ok(typeof result.coverage.api === "object");
|
||||
assert.ok(typeof result.coverage.cli === "object");
|
||||
assert.equal(result.coverage.api.total, 23);
|
||||
assert.equal(result.coverage.cli.total, 20);
|
||||
assert.ok(typeof result.coverage.totalSkills === "number");
|
||||
assert.ok(typeof result.coverage.generatedAt === "string");
|
||||
});
|
||||
|
||||
test("omniroute_agent_skills_list skill entries have required fields", async () => {
|
||||
const result = await agentSkillTools.omniroute_agent_skills_list.handler({});
|
||||
const first = result.skills[0];
|
||||
assert.ok(typeof first.id === "string" && first.id.length > 0);
|
||||
assert.ok(typeof first.name === "string" && first.name.length > 0);
|
||||
assert.ok(typeof first.description === "string");
|
||||
assert.ok(first.category === "api" || first.category === "cli");
|
||||
assert.ok(typeof first.area === "string");
|
||||
assert.ok(typeof first.rawUrl === "string");
|
||||
assert.ok(typeof first.githubUrl === "string");
|
||||
});
|
||||
|
||||
test("AgentSkillsListSchema parses valid category filter", () => {
|
||||
const parsed = AgentSkillsListSchema.parse({ category: "api" });
|
||||
assert.equal(parsed.category, "api");
|
||||
});
|
||||
|
||||
test("AgentSkillsListSchema rejects invalid category", () => {
|
||||
assert.throws(() => AgentSkillsListSchema.parse({ category: "unknown" }));
|
||||
});
|
||||
|
||||
// ─── omniroute_agent_skills_get ─────────────────────────────────────────────
|
||||
|
||||
// NOTE: omniroute_agent_skills_get calls fetchSkillMarkdown which tries:
|
||||
// 1. local filesystem skills/{id}/SKILL.md
|
||||
// 2. GitHub raw URL
|
||||
// In unit test environment, SKILL.md files are not yet generated and GitHub
|
||||
// raw URLs are 404 on feature branches. We test the shape contract by
|
||||
// verifying: (a) metadata fields are correct, (b) a real GitHub-hosted skill
|
||||
// (main branch) resolves, or (c) a skill not found throws correctly.
|
||||
// The deep integration (fetch round-trip) is covered by e2e/ecosystem tests.
|
||||
|
||||
test("omniroute_agent_skills_get({id:'omni-providers'}) returns correct skill metadata before markdown fetch", async () => {
|
||||
const { getSkillById } = await import("../../src/lib/agentSkills/catalog.ts");
|
||||
const skill = getSkillById("omni-providers");
|
||||
assert.ok(skill != null, "omni-providers should exist in catalog");
|
||||
assert.equal(skill!.id, "omni-providers");
|
||||
assert.equal(skill!.category, "api");
|
||||
assert.ok(typeof skill!.name === "string" && skill!.name.length > 0);
|
||||
assert.ok(typeof skill!.rawUrl === "string");
|
||||
assert.ok(typeof skill!.githubUrl === "string");
|
||||
});
|
||||
|
||||
test("omniroute_agent_skills_get({id:'cli-serve'}) resolves correct cli skill metadata", async () => {
|
||||
const { getSkillById } = await import("../../src/lib/agentSkills/catalog.ts");
|
||||
const skill = getSkillById("cli-serve");
|
||||
assert.ok(skill != null, "cli-serve should exist in catalog");
|
||||
assert.equal(skill!.id, "cli-serve");
|
||||
assert.equal(skill!.category, "cli");
|
||||
assert.ok(typeof skill!.name === "string" && skill!.name.length > 0);
|
||||
});
|
||||
|
||||
test("omniroute_agent_skills_get with invalid id throws Error", async () => {
|
||||
await assert.rejects(
|
||||
() => agentSkillTools.omniroute_agent_skills_get.handler({ id: "non-existent-skill-xyz" }),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof Error);
|
||||
assert.ok(err.message.includes("non-existent-skill-xyz"));
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("AgentSkillsGetSchema requires id field", () => {
|
||||
assert.throws(() => AgentSkillsGetSchema.parse({}));
|
||||
});
|
||||
|
||||
test("AgentSkillsGetSchema parses valid id", () => {
|
||||
const parsed = AgentSkillsGetSchema.parse({ id: "omni-providers" });
|
||||
assert.equal(parsed.id, "omni-providers");
|
||||
});
|
||||
|
||||
// ─── omniroute_agent_skills_coverage ────────────────────────────────────────
|
||||
|
||||
test("omniroute_agent_skills_coverage({}) returns coverage shape", async () => {
|
||||
const result = await agentSkillTools.omniroute_agent_skills_coverage.handler({});
|
||||
assert.ok(result != null);
|
||||
assert.ok(typeof result.api === "object");
|
||||
assert.ok(typeof result.cli === "object");
|
||||
assert.equal(result.api.total, 23);
|
||||
assert.equal(result.cli.total, 20);
|
||||
assert.ok(typeof result.api.have === "number");
|
||||
assert.ok(typeof result.cli.have === "number");
|
||||
assert.ok(result.api.have >= 0 && result.api.have <= 23);
|
||||
assert.ok(result.cli.have >= 0 && result.cli.have <= 20);
|
||||
assert.ok(typeof result.totalSkills === "number");
|
||||
assert.equal(result.totalSkills, result.api.have + result.cli.have + (result.config?.have ?? 0));
|
||||
assert.ok(typeof result.generatedAt === "string");
|
||||
// Validate ISO datetime format
|
||||
assert.ok(!isNaN(Date.parse(result.generatedAt)), "generatedAt should be valid ISO datetime");
|
||||
});
|
||||
|
||||
test("AgentSkillsCoverageSchema parses empty input", () => {
|
||||
const parsed = AgentSkillsCoverageSchema.parse({});
|
||||
assert.deepEqual(parsed, {});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Dynamic imports to pick up ESM modules with tsx
|
||||
const {
|
||||
getCatalog,
|
||||
getSkillById,
|
||||
filterCatalog,
|
||||
computeCoverage,
|
||||
refreshCatalog,
|
||||
API_SKILL_IDS,
|
||||
CLI_SKILL_IDS,
|
||||
} = await import("../../src/lib/agentSkills/catalog.ts");
|
||||
const agentSkillsConstants = await import("../../src/shared/constants/agentSkills.ts");
|
||||
|
||||
// ─── Counts ───────────────────────────────────────────────────────────────────
|
||||
|
||||
test("getCatalog() returns exactly 44 entries", () => {
|
||||
refreshCatalog();
|
||||
const catalog = getCatalog();
|
||||
assert.equal(catalog.length, 44, `Expected 44 but got ${catalog.length}`);
|
||||
});
|
||||
|
||||
test("API_SKILL_IDS has exactly 23 entries", () => {
|
||||
assert.equal(API_SKILL_IDS.length, 23);
|
||||
});
|
||||
|
||||
test("CLI_SKILL_IDS has exactly 20 entries", () => {
|
||||
assert.equal(CLI_SKILL_IDS.length, 20);
|
||||
});
|
||||
|
||||
test("getCatalog() contains exactly 22 api skills", () => {
|
||||
const apiSkills = getCatalog().filter((s) => s.category === "api");
|
||||
assert.equal(apiSkills.length, 23);
|
||||
});
|
||||
|
||||
test("getCatalog() contains exactly 20 cli skills", () => {
|
||||
const cliSkills = getCatalog().filter((s) => s.category === "cli");
|
||||
assert.equal(cliSkills.length, 20);
|
||||
});
|
||||
|
||||
// ─── ID format ────────────────────────────────────────────────────────────────
|
||||
|
||||
test("all skill IDs match regex ^[a-z][a-z0-9-]*$", () => {
|
||||
const ID_REGEX = /^[a-z][a-z0-9-]*$/;
|
||||
for (const skill of getCatalog()) {
|
||||
assert.match(skill.id, ID_REGEX, `Skill ID "${skill.id}" does not match expected format`);
|
||||
}
|
||||
});
|
||||
|
||||
test("all skill IDs are unique (no duplicates)", () => {
|
||||
const ids = getCatalog().map((s) => s.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
assert.equal(
|
||||
uniqueIds.size,
|
||||
ids.length,
|
||||
`Duplicate IDs found: ${ids.filter((id, i) => ids.indexOf(id) !== i).join(", ")}`
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Required fields ──────────────────────────────────────────────────────────
|
||||
|
||||
test("all skills have non-empty name and description", () => {
|
||||
for (const skill of getCatalog()) {
|
||||
assert.ok(skill.name.length > 0, `Skill ${skill.id} has empty name`);
|
||||
assert.ok(skill.description.length > 0, `Skill ${skill.id} has empty description`);
|
||||
}
|
||||
});
|
||||
|
||||
test("all skills have rawUrl and githubUrl as valid GitHub URLs", () => {
|
||||
for (const skill of getCatalog()) {
|
||||
assert.ok(
|
||||
skill.rawUrl.startsWith("https://raw.githubusercontent.com/"),
|
||||
`Skill ${skill.id}: rawUrl "${skill.rawUrl}" is not a GitHub raw URL`
|
||||
);
|
||||
assert.ok(
|
||||
skill.githubUrl.startsWith("https://github.com/"),
|
||||
`Skill ${skill.id}: githubUrl "${skill.githubUrl}" is not a GitHub blob URL`
|
||||
);
|
||||
assert.ok(
|
||||
skill.rawUrl.endsWith("/SKILL.md"),
|
||||
`Skill ${skill.id}: rawUrl does not end with /SKILL.md`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("agent skills constants expose URL builders without the unused repository URL", () => {
|
||||
assert.equal(typeof agentSkillsConstants.getAgentSkillRawUrl, "function");
|
||||
assert.equal(typeof agentSkillsConstants.getAgentSkillBlobUrl, "function");
|
||||
assert.equal("AGENT_SKILLS_REPO_URL" in agentSkillsConstants, false);
|
||||
});
|
||||
|
||||
test("api skills have area matching API_SKILL_IDS derived IDs", () => {
|
||||
const catalog = getCatalog();
|
||||
for (const id of API_SKILL_IDS) {
|
||||
const skill = catalog.find((s) => s.id === id);
|
||||
assert.ok(skill, `API skill ID "${id}" not found in catalog`);
|
||||
assert.equal(
|
||||
skill!.category,
|
||||
"api",
|
||||
`Skill "${id}" expected category api, got ${skill!.category}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("cli skills have area matching CLI_SKILL_IDS derived IDs", () => {
|
||||
const catalog = getCatalog();
|
||||
for (const id of CLI_SKILL_IDS) {
|
||||
const skill = catalog.find((s) => s.id === id);
|
||||
assert.ok(skill, `CLI skill ID "${id}" not found in catalog`);
|
||||
assert.equal(
|
||||
skill!.category,
|
||||
"cli",
|
||||
`Skill "${id}" expected category cli, got ${skill!.category}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── getSkillById ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("getSkillById('omni-providers') returns the omni-providers entry", () => {
|
||||
const skill = getSkillById("omni-providers");
|
||||
assert.ok(skill, "Expected skill to be found");
|
||||
assert.equal(skill!.id, "omni-providers");
|
||||
assert.equal(skill!.category, "api");
|
||||
assert.equal(skill!.area, "providers");
|
||||
});
|
||||
|
||||
test("getSkillById('cli-serve') returns the cli-serve entry", () => {
|
||||
const skill = getSkillById("cli-serve");
|
||||
assert.ok(skill);
|
||||
assert.equal(skill!.id, "cli-serve");
|
||||
assert.equal(skill!.category, "cli");
|
||||
assert.equal(skill!.isEntry, true);
|
||||
});
|
||||
|
||||
test("getSkillById('omni-auth') returns entry with isEntry=true", () => {
|
||||
const skill = getSkillById("omni-auth");
|
||||
assert.ok(skill);
|
||||
assert.equal(skill!.isEntry, true);
|
||||
});
|
||||
|
||||
test("getSkillById('does-not-exist') returns null", () => {
|
||||
const skill = getSkillById("does-not-exist");
|
||||
assert.equal(skill, null);
|
||||
});
|
||||
|
||||
test("getSkillById('') returns null", () => {
|
||||
const skill = getSkillById("");
|
||||
assert.equal(skill, null);
|
||||
});
|
||||
|
||||
// ─── filterCatalog ────────────────────────────────────────────────────────────
|
||||
|
||||
test("filterCatalog({ category: 'api' }) returns 23 api skills", () => {
|
||||
const skills = filterCatalog({ category: "api" });
|
||||
assert.equal(skills.length, 23);
|
||||
for (const s of skills) {
|
||||
assert.equal(s.category, "api");
|
||||
}
|
||||
});
|
||||
|
||||
test("filterCatalog({ category: 'cli' }) returns 20 cli skills", () => {
|
||||
const skills = filterCatalog({ category: "cli" });
|
||||
assert.equal(skills.length, 20);
|
||||
for (const s of skills) {
|
||||
assert.equal(s.category, "cli");
|
||||
}
|
||||
});
|
||||
|
||||
test("filterCatalog({ area: 'providers' }) returns exactly omni-providers", () => {
|
||||
const skills = filterCatalog({ area: "providers" });
|
||||
assert.equal(skills.length, 1);
|
||||
assert.equal(skills[0].id, "omni-providers");
|
||||
});
|
||||
|
||||
test("filterCatalog({ category: 'api', area: 'mcp' }) returns omni-mcp", () => {
|
||||
const skills = filterCatalog({ category: "api", area: "mcp" });
|
||||
assert.equal(skills.length, 1);
|
||||
assert.equal(skills[0].id, "omni-mcp");
|
||||
});
|
||||
|
||||
test("filterCatalog({ area: 'nonexistent' }) returns empty array", () => {
|
||||
const skills = filterCatalog({ area: "nonexistent" });
|
||||
assert.equal(skills.length, 0);
|
||||
});
|
||||
|
||||
test("filterCatalog({}) returns full catalog (44 entries)", () => {
|
||||
const skills = filterCatalog({});
|
||||
assert.equal(skills.length, 44);
|
||||
});
|
||||
|
||||
// ─── refreshCatalog ───────────────────────────────────────────────────────────
|
||||
|
||||
test("refreshCatalog() causes getCatalog() to re-derive (returns fresh array)", () => {
|
||||
const first = getCatalog();
|
||||
refreshCatalog();
|
||||
const second = getCatalog();
|
||||
// Different array reference after refresh
|
||||
assert.notEqual(first, second);
|
||||
// But same content
|
||||
assert.equal(first.length, second.length);
|
||||
assert.equal(first[0].id, second[0].id);
|
||||
});
|
||||
|
||||
// ─── computeCoverage ─────────────────────────────────────────────────────────
|
||||
|
||||
test("computeCoverage() returns valid SkillCoverage shape", () => {
|
||||
const cov = computeCoverage();
|
||||
|
||||
assert.ok(typeof cov.api === "object");
|
||||
assert.equal(cov.api.total, 23);
|
||||
assert.ok(typeof cov.api.have === "number");
|
||||
assert.ok(cov.api.have >= 0 && cov.api.have <= 23);
|
||||
|
||||
assert.ok(typeof cov.cli === "object");
|
||||
assert.equal(cov.cli.total, 20);
|
||||
assert.ok(typeof cov.cli.have === "number");
|
||||
assert.ok(cov.cli.have >= 0 && cov.cli.have <= 20);
|
||||
|
||||
assert.equal(cov.totalSkills, cov.api.have + cov.cli.have + (cov.config?.have ?? 0));
|
||||
|
||||
// generatedAt must be a valid ISO datetime string
|
||||
assert.ok(
|
||||
!isNaN(Date.parse(cov.generatedAt)),
|
||||
`generatedAt "${cov.generatedAt}" is not a valid ISO date`
|
||||
);
|
||||
});
|
||||
|
||||
test("computeCoverage() api.have + cli.have = totalSkills", () => {
|
||||
const cov = computeCoverage();
|
||||
assert.equal(cov.totalSkills, cov.api.have + cov.cli.have + (cov.config?.have ?? 0));
|
||||
});
|
||||
|
||||
// ─── Cache behaviour ─────────────────────────────────────────────────────────
|
||||
|
||||
test("getCatalog() returns the same array reference on repeated calls (cached)", () => {
|
||||
refreshCatalog();
|
||||
const first = getCatalog();
|
||||
const second = getCatalog();
|
||||
assert.strictEqual(first, second, "Expected same cached array reference");
|
||||
});
|
||||
|
||||
// ─── Canonical IDs check ─────────────────────────────────────────────────────
|
||||
|
||||
test("API_SKILL_IDS first entry is omni-auth", () => {
|
||||
assert.equal(API_SKILL_IDS[0], "omni-auth");
|
||||
});
|
||||
|
||||
test("API_SKILL_IDS last entry is omni-github-skills", () => {
|
||||
assert.equal(API_SKILL_IDS[API_SKILL_IDS.length - 1], "omni-github-skills");
|
||||
});
|
||||
|
||||
test("CLI_SKILL_IDS first entry is cli-serve", () => {
|
||||
assert.equal(CLI_SKILL_IDS[0], "cli-serve");
|
||||
});
|
||||
|
||||
test("CLI_SKILL_IDS last entry is cli-setup", () => {
|
||||
assert.equal(CLI_SKILL_IDS[CLI_SKILL_IDS.length - 1], "cli-setup");
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
// Dynamic import to pick up ESM module
|
||||
const { parseCliRegistry, getCommandsForFamily } = await import(
|
||||
"../../src/lib/agentSkills/cliRegistryParser.ts"
|
||||
);
|
||||
|
||||
// ─── Fixture helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates a temporary directory mirroring bin/cli/commands/,
|
||||
* writes fixture .mjs files, changes CWD, returns cleanup fn.
|
||||
*/
|
||||
function withFixtureCli(
|
||||
files: Record<string, string>,
|
||||
): { cleanup: () => void } {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-cli-test-"));
|
||||
const commandsDir = path.join(tmpDir, "bin", "cli", "commands");
|
||||
fs.mkdirSync(commandsDir, { recursive: true });
|
||||
|
||||
for (const [filename, content] of Object.entries(files)) {
|
||||
fs.writeFileSync(path.join(commandsDir, filename), content, "utf-8");
|
||||
}
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
return {
|
||||
cleanup() {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Fixture content ──────────────────────────────────────────────────────────
|
||||
|
||||
const FIXTURE_PROVIDERS_MJS = `
|
||||
export function registerProviders(program) {
|
||||
const providers = program.command('providers').description('Manage provider connections');
|
||||
|
||||
providers
|
||||
.command('list')
|
||||
.description('List configured provider connections')
|
||||
.option('--json', 'Print machine-readable JSON')
|
||||
.action(async (opts) => {});
|
||||
|
||||
providers
|
||||
.command('available')
|
||||
.description('Show available providers in the catalog')
|
||||
.option('--search <query>', 'Filter by id or name')
|
||||
.option('--category <category>', 'Filter by category')
|
||||
.action(async (opts) => {});
|
||||
|
||||
providers
|
||||
.command('test <idOrName>')
|
||||
.description('Test a configured provider connection')
|
||||
.action(async (idOrName, opts) => {});
|
||||
|
||||
providers
|
||||
.command('test-all')
|
||||
.description('Test all active provider connections')
|
||||
.action(async (opts) => {});
|
||||
|
||||
providers
|
||||
.command('validate')
|
||||
.description('Validate local provider configuration')
|
||||
.action(async (opts) => {});
|
||||
|
||||
providers
|
||||
.command('rotate <idOrName>')
|
||||
.description('Rotate API key for a provider connection')
|
||||
.option('--new-key <key>', 'New API key value')
|
||||
.option('--dry-run', 'Preview without writing')
|
||||
.action(async (idOrName, opts) => {});
|
||||
|
||||
providers
|
||||
.command('status')
|
||||
.description('Show provider connection status and expiry')
|
||||
.option('--json', 'JSON output')
|
||||
.action(async (opts) => {});
|
||||
}
|
||||
`;
|
||||
|
||||
const FIXTURE_HEALTH_MJS = `
|
||||
export function registerHealth(program) {
|
||||
const health = program
|
||||
.command('health')
|
||||
.description('Check server health status')
|
||||
.option('-v, --verbose', 'Show extended info')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (opts) => {});
|
||||
|
||||
health
|
||||
.command('components')
|
||||
.description('List health components and status')
|
||||
.action(async (opts) => {});
|
||||
|
||||
health
|
||||
.command('watch')
|
||||
.description('Live dashboard — refresh every N seconds')
|
||||
.option('--interval <s>', 'Refresh interval in seconds')
|
||||
.action(async (opts) => {});
|
||||
}
|
||||
`;
|
||||
|
||||
const FIXTURE_KEYS_MJS = `
|
||||
export function registerKeys(program) {
|
||||
const keys = program.command('keys').description('Manage OmniRoute API keys');
|
||||
|
||||
keys
|
||||
.command('list')
|
||||
.description('List all API keys')
|
||||
.option('--json', 'JSON output')
|
||||
.action(async (opts) => {});
|
||||
|
||||
keys
|
||||
.command('create')
|
||||
.description('Create a new API key')
|
||||
.option('--name <name>', 'Key name')
|
||||
.action(async (opts) => {});
|
||||
|
||||
keys
|
||||
.command('revoke <id>')
|
||||
.description('Revoke an API key')
|
||||
.action(async (id, opts) => {});
|
||||
}
|
||||
`;
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("parseCliRegistry() returns commands Map and families Map", () => {
|
||||
const { cleanup } = withFixtureCli({
|
||||
"providers.mjs": FIXTURE_PROVIDERS_MJS,
|
||||
"health.mjs": FIXTURE_HEALTH_MJS,
|
||||
});
|
||||
try {
|
||||
const result = parseCliRegistry();
|
||||
assert.ok(result.commands instanceof Map, "commands should be a Map");
|
||||
assert.ok(result.families instanceof Map, "families should be a Map");
|
||||
assert.ok(result.commands.size > 0, "commands should not be empty");
|
||||
assert.ok(result.families.size > 0, "families should not be empty");
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("parseCliRegistry() recognises providers family with ≥5 subcommands", () => {
|
||||
const { cleanup } = withFixtureCli({
|
||||
"providers.mjs": FIXTURE_PROVIDERS_MJS,
|
||||
});
|
||||
try {
|
||||
const { families } = parseCliRegistry();
|
||||
const providerCmds = families.get("cli-providers");
|
||||
assert.ok(providerCmds, "Expected 'cli-providers' family to exist");
|
||||
assert.ok(
|
||||
providerCmds!.length >= 5,
|
||||
`Expected ≥5 provider commands, got ${providerCmds!.length}: ${providerCmds!.map((c) => c.name).join(", ")}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("parseCliRegistry() recognises health family commands", () => {
|
||||
const { cleanup } = withFixtureCli({
|
||||
"health.mjs": FIXTURE_HEALTH_MJS,
|
||||
});
|
||||
try {
|
||||
const { families } = parseCliRegistry();
|
||||
const healthCmds = families.get("cli-health");
|
||||
assert.ok(healthCmds, "Expected 'cli-health' family to exist");
|
||||
assert.ok(
|
||||
healthCmds!.length >= 2,
|
||||
`Expected ≥2 health commands, got ${healthCmds!.length}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("parseCliRegistry() extracts description for each command", () => {
|
||||
const { cleanup } = withFixtureCli({
|
||||
"providers.mjs": FIXTURE_PROVIDERS_MJS,
|
||||
"keys.mjs": FIXTURE_KEYS_MJS,
|
||||
});
|
||||
try {
|
||||
const { commands } = parseCliRegistry();
|
||||
// Top-level providers command should have description
|
||||
const providers = [...commands.values()].find((c) => c.name === "providers");
|
||||
assert.ok(providers, "Expected providers command");
|
||||
assert.ok(
|
||||
providers!.description.length > 0,
|
||||
`Expected non-empty description for providers, got: "${providers!.description}"`,
|
||||
);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("parseCliRegistry() marks subcommands with isSubcommand=true (after first)", () => {
|
||||
const { cleanup } = withFixtureCli({
|
||||
"providers.mjs": FIXTURE_PROVIDERS_MJS,
|
||||
});
|
||||
try {
|
||||
const { families } = parseCliRegistry();
|
||||
const providerCmds = families.get("cli-providers")!;
|
||||
// After the first (top-level) entry, rest should be subcommands
|
||||
const subCmds = providerCmds.filter((c) => c.isSubcommand);
|
||||
assert.ok(
|
||||
subCmds.length >= 4,
|
||||
`Expected ≥4 subcommands (list, available, test, etc.), got ${subCmds.length}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("parseCliRegistry() extracts flags from .option() calls", () => {
|
||||
const { cleanup } = withFixtureCli({
|
||||
"providers.mjs": FIXTURE_PROVIDERS_MJS,
|
||||
});
|
||||
try {
|
||||
const { commands } = parseCliRegistry();
|
||||
// Find a command that has options
|
||||
const rotate = [...commands.values()].find((c) => c.name.includes("rotate"));
|
||||
// Flags might be present if parsing found them
|
||||
if (rotate) {
|
||||
// If rotate exists and has flags, verify format
|
||||
for (const flag of rotate.flags) {
|
||||
assert.ok(
|
||||
typeof flag === "string" && flag.length > 0,
|
||||
`Invalid flag: "${flag}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("parseCliRegistry() skips unrecognised .mjs files", () => {
|
||||
const { cleanup } = withFixtureCli({
|
||||
"unknown-custom.mjs": `export function register(p) {}`,
|
||||
"providers.mjs": FIXTURE_PROVIDERS_MJS,
|
||||
});
|
||||
try {
|
||||
const { families } = parseCliRegistry();
|
||||
// No family should be mapped from unknown-custom
|
||||
const hasUnknown = [...families.keys()].some((k) =>
|
||||
String(k).includes("unknown-custom"),
|
||||
);
|
||||
assert.equal(hasUnknown, false, "unknown-custom.mjs should not create a family");
|
||||
// providers.mjs should still be parsed
|
||||
assert.ok(families.has("cli-providers"), "Expected cli-providers family from providers.mjs");
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("parseCliRegistry() throws if commands directory is missing", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-cli-missing-"));
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
try {
|
||||
assert.throws(
|
||||
() => parseCliRegistry(),
|
||||
/cliRegistryParser: could not read/,
|
||||
"Expected error when commands dir is missing",
|
||||
);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Integration test: real providers.mjs (always runs — it's in the repo) ───
|
||||
|
||||
test("parseCliRegistry() with real providers.mjs: providers family has ≥5 commands", () => {
|
||||
// This test uses the actual project files (not a fixture).
|
||||
// We rely on the CWD being the worktree root during `npm run test:unit`.
|
||||
const result = parseCliRegistry();
|
||||
const providerCmds = result.families.get("cli-providers");
|
||||
assert.ok(providerCmds, "Expected cli-providers family from real providers.mjs");
|
||||
assert.ok(
|
||||
providerCmds!.length >= 5,
|
||||
`Expected ≥5 real provider commands, got ${providerCmds!.length}: ${providerCmds!.map((c) => c.name).join(", ")}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("getCommandsForFamily('cli-providers') with real files: returns ≥5 strings", () => {
|
||||
const commands = getCommandsForFamily("cli-providers");
|
||||
assert.ok(
|
||||
commands.length >= 5,
|
||||
`Expected ≥5 cli-providers commands, got ${commands.length}`,
|
||||
);
|
||||
for (const cmd of commands) {
|
||||
assert.ok(typeof cmd === "string" && cmd.length > 0, `Invalid command name: "${cmd}"`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,561 @@
|
||||
/**
|
||||
* agentSkills-generator.test.ts
|
||||
*
|
||||
* Tests for src/lib/agentSkills/generator.ts
|
||||
*
|
||||
* All tests use a temporary directory so they never touch the real `skills/` folder.
|
||||
* Uses Node.js native test runner.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
// ── Dynamic imports (tsx/esm resolves TS imports) ────────────────────────────
|
||||
|
||||
const { generateAgentSkills, buildSkillMarkdown, __testing } = await import(
|
||||
"../../src/lib/agentSkills/generator.ts"
|
||||
);
|
||||
|
||||
const { getCatalog, refreshCatalog } = await import(
|
||||
"../../src/lib/agentSkills/catalog.ts"
|
||||
);
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Creates an isolated tmp directory and returns its path. */
|
||||
function mkTmpDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "agent-skills-gen-test-"));
|
||||
}
|
||||
|
||||
/** Cleanup a tmp directory. */
|
||||
function rmTmpDir(dir: string): void {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a minimal sources object for tests that don't need real parsers.
|
||||
*/
|
||||
function emptySources() {
|
||||
return {
|
||||
openapi: { paths: new Map(), areas: new Map() },
|
||||
cliRegistry: { commands: new Map(), families: new Map() },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Dry-run: no writes ────────────────────────────────────────────────────────
|
||||
|
||||
test("dry-run (default) returns report without writing any files", async () => {
|
||||
const tmpDir = mkTmpDir();
|
||||
try {
|
||||
refreshCatalog();
|
||||
const report = await generateAgentSkills({
|
||||
dryRun: true,
|
||||
prune: false,
|
||||
outputDir: tmpDir,
|
||||
});
|
||||
|
||||
// All 44 skills should appear as generated (would-write) since dir is empty
|
||||
assert.equal(
|
||||
report.generated.length + report.unchanged.length,
|
||||
44,
|
||||
`Expected 44 total (generated+unchanged), got generated=${report.generated.length} unchanged=${report.unchanged.length}`,
|
||||
);
|
||||
assert.equal(report.errors.length, 0, `Unexpected errors: ${JSON.stringify(report.errors)}`);
|
||||
|
||||
// No files written
|
||||
const entries = fs.readdirSync(tmpDir);
|
||||
assert.equal(
|
||||
entries.length,
|
||||
0,
|
||||
`Dry-run wrote ${entries.length} entries to ${tmpDir}: ${entries.join(", ")}`,
|
||||
);
|
||||
} finally {
|
||||
rmTmpDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("dry-run generates report with 44 total (generated+unchanged)", async () => {
|
||||
const tmpDir = mkTmpDir();
|
||||
try {
|
||||
refreshCatalog();
|
||||
const report = await generateAgentSkills({
|
||||
dryRun: true,
|
||||
prune: false,
|
||||
outputDir: tmpDir,
|
||||
});
|
||||
const total = report.generated.length + report.unchanged.length;
|
||||
assert.equal(total, 44);
|
||||
} finally {
|
||||
rmTmpDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Apply: writes SKILL.md ─────────────────────────────────────────────────────
|
||||
|
||||
test("apply mode writes SKILL.md with valid frontmatter for omni-providers", async () => {
|
||||
const tmpDir = mkTmpDir();
|
||||
try {
|
||||
refreshCatalog();
|
||||
const report = await generateAgentSkills({
|
||||
dryRun: false,
|
||||
prune: false,
|
||||
outputDir: tmpDir,
|
||||
onlyIds: ["omni-providers"],
|
||||
});
|
||||
|
||||
assert.equal(report.errors.length, 0, `Errors: ${JSON.stringify(report.errors)}`);
|
||||
assert.equal(report.generated.length, 1);
|
||||
assert.equal(report.generated[0], "omni-providers");
|
||||
|
||||
const skillFile = path.join(tmpDir, "omni-providers", "SKILL.md");
|
||||
assert.ok(fs.existsSync(skillFile), `SKILL.md not found at ${skillFile}`);
|
||||
|
||||
const content = fs.readFileSync(skillFile, "utf-8");
|
||||
|
||||
// Frontmatter present
|
||||
assert.ok(content.startsWith("---\n"), "Missing frontmatter start");
|
||||
assert.ok(content.includes("name: omni-providers"), "Missing name in frontmatter");
|
||||
assert.ok(content.includes("---\n"), "Missing frontmatter end");
|
||||
|
||||
// Generated comment present
|
||||
assert.ok(
|
||||
content.includes("<!-- generated by src/lib/agentSkills/generator.ts"),
|
||||
"Missing generated comment",
|
||||
);
|
||||
} finally {
|
||||
rmTmpDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("apply mode writes all 44 SKILL.md files when no onlyIds filter", async () => {
|
||||
const tmpDir = mkTmpDir();
|
||||
try {
|
||||
refreshCatalog();
|
||||
const report = await generateAgentSkills({
|
||||
dryRun: false,
|
||||
prune: false,
|
||||
outputDir: tmpDir,
|
||||
});
|
||||
|
||||
assert.equal(report.errors.length, 0, `Errors: ${JSON.stringify(report.errors)}`);
|
||||
assert.equal(report.generated.length, 44);
|
||||
|
||||
// Verify all dirs exist
|
||||
const catalog = getCatalog();
|
||||
for (const skill of catalog) {
|
||||
const skillFile = path.join(tmpDir, skill.id, "SKILL.md");
|
||||
assert.ok(fs.existsSync(skillFile), `Missing SKILL.md for ${skill.id}`);
|
||||
}
|
||||
} finally {
|
||||
rmTmpDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("apply mode writes SKILL.md for a CLI skill with correct structure", async () => {
|
||||
const tmpDir = mkTmpDir();
|
||||
try {
|
||||
refreshCatalog();
|
||||
const report = await generateAgentSkills({
|
||||
dryRun: false,
|
||||
prune: false,
|
||||
outputDir: tmpDir,
|
||||
onlyIds: ["cli-serve"],
|
||||
});
|
||||
|
||||
assert.equal(report.errors.length, 0);
|
||||
assert.equal(report.generated.length, 1);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, "cli-serve", "SKILL.md"), "utf-8");
|
||||
assert.ok(content.includes("name: cli-serve"), "Missing name in frontmatter");
|
||||
assert.ok(content.includes("## Overview"), "Missing Overview section");
|
||||
assert.ok(content.includes("## Quick install"), "Missing Quick install section");
|
||||
assert.ok(content.includes("## Subcommands"), "Missing Subcommands section");
|
||||
} finally {
|
||||
rmTmpDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("apply mode writes SKILL.md for an API skill with correct sections", async () => {
|
||||
const tmpDir = mkTmpDir();
|
||||
try {
|
||||
refreshCatalog();
|
||||
const report = await generateAgentSkills({
|
||||
dryRun: false,
|
||||
prune: false,
|
||||
outputDir: tmpDir,
|
||||
onlyIds: ["omni-auth"],
|
||||
});
|
||||
|
||||
assert.equal(report.errors.length, 0);
|
||||
const content = fs.readFileSync(path.join(tmpDir, "omni-auth", "SKILL.md"), "utf-8");
|
||||
assert.ok(content.includes("## Overview"), "Missing Overview section");
|
||||
assert.ok(content.includes("## Authentication"), "Missing Authentication section");
|
||||
assert.ok(content.includes("## Endpoints"), "Missing endpoints section");
|
||||
assert.ok(content.includes("## Payloads"), "Missing payloads section");
|
||||
} finally {
|
||||
rmTmpDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Idempotency ───────────────────────────────────────────────────────────────
|
||||
|
||||
test("idempotency: second apply run reports 0 generated, all unchanged", async () => {
|
||||
const tmpDir = mkTmpDir();
|
||||
try {
|
||||
refreshCatalog();
|
||||
|
||||
// First run: apply
|
||||
const report1 = await generateAgentSkills({
|
||||
dryRun: false,
|
||||
prune: false,
|
||||
outputDir: tmpDir,
|
||||
onlyIds: ["omni-providers", "cli-serve"],
|
||||
});
|
||||
assert.equal(report1.generated.length, 2, "First run should generate 2 skills");
|
||||
|
||||
// Second run: same options
|
||||
const report2 = await generateAgentSkills({
|
||||
dryRun: false,
|
||||
prune: false,
|
||||
outputDir: tmpDir,
|
||||
onlyIds: ["omni-providers", "cli-serve"],
|
||||
});
|
||||
assert.equal(report2.generated.length, 0, "Second run should generate 0 (already up-to-date)");
|
||||
assert.equal(report2.unchanged.length, 2, "Second run should report 2 unchanged");
|
||||
} finally {
|
||||
rmTmpDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Prune: detect orphans ─────────────────────────────────────────────────────
|
||||
|
||||
test("prune dry-run detects orphan dirs without deleting them", async () => {
|
||||
const tmpDir = mkTmpDir();
|
||||
try {
|
||||
refreshCatalog();
|
||||
|
||||
// Create an orphan directory (not in catalog)
|
||||
const orphanDir = path.join(tmpDir, "old-orphan-skill");
|
||||
fs.mkdirSync(orphanDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(orphanDir, "SKILL.md"), "# Old\n");
|
||||
|
||||
const report = await generateAgentSkills({
|
||||
dryRun: true,
|
||||
prune: true,
|
||||
outputDir: tmpDir,
|
||||
});
|
||||
|
||||
assert.ok(report.orphansDetected.includes("old-orphan-skill"), "Orphan not detected");
|
||||
assert.equal(report.pruned.length, 0, "Nothing should be pruned in dry-run");
|
||||
|
||||
// Orphan dir still exists
|
||||
assert.ok(fs.existsSync(orphanDir), "Orphan dir was deleted in dry-run (should not happen)");
|
||||
} finally {
|
||||
rmTmpDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("prune apply mode deletes orphan dirs", async () => {
|
||||
const tmpDir = mkTmpDir();
|
||||
try {
|
||||
refreshCatalog();
|
||||
|
||||
// Create an orphan directory
|
||||
const orphanDir = path.join(tmpDir, "old-orphan-to-delete");
|
||||
fs.mkdirSync(orphanDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(orphanDir, "SKILL.md"), "# Old\n");
|
||||
|
||||
const report = await generateAgentSkills({
|
||||
dryRun: false,
|
||||
prune: true,
|
||||
outputDir: tmpDir,
|
||||
});
|
||||
|
||||
assert.ok(report.orphansDetected.includes("old-orphan-to-delete"), "Orphan not detected");
|
||||
assert.ok(report.pruned.includes("old-orphan-to-delete"), "Orphan not pruned");
|
||||
|
||||
// Orphan dir should be gone
|
||||
assert.ok(
|
||||
!fs.existsSync(orphanDir),
|
||||
"Orphan dir was NOT deleted in apply+prune mode",
|
||||
);
|
||||
} finally {
|
||||
rmTmpDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("prune does not delete catalog skill dirs", async () => {
|
||||
const tmpDir = mkTmpDir();
|
||||
try {
|
||||
refreshCatalog();
|
||||
|
||||
// Pre-create a valid skill dir
|
||||
const validDir = path.join(tmpDir, "omni-providers");
|
||||
fs.mkdirSync(validDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(validDir, "SKILL.md"), "# Existing\n");
|
||||
|
||||
// Add an orphan
|
||||
const orphanDir = path.join(tmpDir, "orphan-xyz");
|
||||
fs.mkdirSync(orphanDir, { recursive: true });
|
||||
|
||||
const report = await generateAgentSkills({
|
||||
dryRun: false,
|
||||
prune: true,
|
||||
outputDir: tmpDir,
|
||||
onlyIds: [],
|
||||
});
|
||||
|
||||
// omni-providers should not be in orphansDetected
|
||||
assert.ok(
|
||||
!report.orphansDetected.includes("omni-providers"),
|
||||
"Valid skill mistakenly flagged as orphan",
|
||||
);
|
||||
assert.ok(report.orphansDetected.includes("orphan-xyz"), "Orphan not detected");
|
||||
} finally {
|
||||
rmTmpDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Marker preservation ───────────────────────────────────────────────────────
|
||||
|
||||
test("marker preservation: custom block survives regeneration", async () => {
|
||||
const tmpDir = mkTmpDir();
|
||||
try {
|
||||
refreshCatalog();
|
||||
|
||||
// First apply to create the file
|
||||
await generateAgentSkills({
|
||||
dryRun: false,
|
||||
prune: false,
|
||||
outputDir: tmpDir,
|
||||
onlyIds: ["omni-providers"],
|
||||
});
|
||||
|
||||
const skillFile = path.join(tmpDir, "omni-providers", "SKILL.md");
|
||||
const originalContent = fs.readFileSync(skillFile, "utf-8");
|
||||
|
||||
// Inject a custom block
|
||||
const customBlock = "<!-- skill:custom-start -->\nMy custom content here.\n<!-- skill:custom-end -->";
|
||||
const contentWithCustom = originalContent + "\n" + customBlock + "\n";
|
||||
fs.writeFileSync(skillFile, contentWithCustom, "utf-8");
|
||||
|
||||
// Re-run generator — file is now different (custom block changed content)
|
||||
const report2 = await generateAgentSkills({
|
||||
dryRun: false,
|
||||
prune: false,
|
||||
outputDir: tmpDir,
|
||||
onlyIds: ["omni-providers"],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
report2.errors.length,
|
||||
0,
|
||||
`Errors: ${JSON.stringify(report2.errors)}`,
|
||||
);
|
||||
|
||||
const newContent = fs.readFileSync(skillFile, "utf-8");
|
||||
|
||||
// Custom block should still be present
|
||||
assert.ok(
|
||||
newContent.includes("My custom content here."),
|
||||
"Custom content was lost during regeneration",
|
||||
);
|
||||
assert.ok(
|
||||
newContent.includes("<!-- skill:custom-start -->"),
|
||||
"Custom start marker missing",
|
||||
);
|
||||
assert.ok(
|
||||
newContent.includes("<!-- skill:custom-end -->"),
|
||||
"Custom end marker missing",
|
||||
);
|
||||
} finally {
|
||||
rmTmpDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── buildSkillMarkdown ─────────────────────────────────────────────────────────
|
||||
|
||||
test("buildSkillMarkdown returns valid frontmatter + body for omni-providers", () => {
|
||||
refreshCatalog();
|
||||
const sources = emptySources();
|
||||
const result = buildSkillMarkdown("omni-providers", sources);
|
||||
|
||||
assert.ok(typeof result.frontmatter === "object", "frontmatter must be an object");
|
||||
assert.equal(result.frontmatter.name, "omni-providers");
|
||||
assert.ok(result.frontmatter.description.length > 0, "description must be non-empty");
|
||||
assert.ok(typeof result.body === "string" && result.body.length > 0, "body must be a non-empty string");
|
||||
});
|
||||
|
||||
test("buildSkillMarkdown body has no erroneously escaped characters", () => {
|
||||
refreshCatalog();
|
||||
const sources = emptySources();
|
||||
|
||||
// Test several skills
|
||||
const testIds = ["omni-providers", "cli-serve", "omni-auth", "cli-health"];
|
||||
for (const id of testIds) {
|
||||
const result = buildSkillMarkdown(id, sources);
|
||||
|
||||
// Check for common escape errors: \\n in rendered text, &, <, >
|
||||
assert.ok(
|
||||
!result.body.includes("\\\\n"),
|
||||
`Skill ${id}: body contains \\\\n (double-escaped newline)`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.body.includes("&"),
|
||||
`Skill ${id}: body contains HTML entity &`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.body.includes("<"),
|
||||
`Skill ${id}: body contains HTML entity <`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.body.includes(">"),
|
||||
`Skill ${id}: body contains HTML entity >`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildSkillMarkdown throws for unknown skillId", () => {
|
||||
refreshCatalog();
|
||||
const sources = emptySources();
|
||||
|
||||
assert.throws(
|
||||
() => buildSkillMarkdown("non-existent-skill", sources),
|
||||
/non-existent-skill/,
|
||||
"Should throw with skill ID in message",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSkillMarkdown API skill body contains expected sections", () => {
|
||||
refreshCatalog();
|
||||
const sources = emptySources();
|
||||
const result = buildSkillMarkdown("omni-cache", sources);
|
||||
|
||||
assert.ok(result.body.includes("## Overview"), "Missing Overview section");
|
||||
assert.ok(result.body.includes("## Authentication"), "Missing Authentication section");
|
||||
assert.ok(result.body.includes("## Endpoints"), "Missing Endpoints section");
|
||||
assert.ok(result.body.includes("## Payloads"), "Missing Payloads section");
|
||||
});
|
||||
|
||||
test("buildSkillMarkdown CLI skill body contains expected sections", () => {
|
||||
refreshCatalog();
|
||||
const sources = emptySources();
|
||||
const result = buildSkillMarkdown("cli-health", sources);
|
||||
|
||||
assert.ok(result.body.includes("## Overview"), "Missing Overview section");
|
||||
assert.ok(result.body.includes("## Quick install"), "Missing Quick install section");
|
||||
assert.ok(result.body.includes("## Subcommands"), "Missing Subcommands section");
|
||||
});
|
||||
|
||||
test("buildSkillMarkdown description is at most 2000 chars", () => {
|
||||
refreshCatalog();
|
||||
const catalog = getCatalog();
|
||||
const sources = emptySources();
|
||||
|
||||
for (const skill of catalog) {
|
||||
const result = buildSkillMarkdown(skill.id, sources);
|
||||
assert.ok(
|
||||
result.frontmatter.description.length <= 2000,
|
||||
`Skill ${skill.id}: description too long (${result.frontmatter.description.length} > 2000)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ── onlyIds filter ────────────────────────────────────────────────────────────
|
||||
|
||||
test("onlyIds filter limits generation to specified IDs", async () => {
|
||||
const tmpDir = mkTmpDir();
|
||||
try {
|
||||
refreshCatalog();
|
||||
const report = await generateAgentSkills({
|
||||
dryRun: false,
|
||||
prune: false,
|
||||
outputDir: tmpDir,
|
||||
onlyIds: ["omni-providers", "cli-serve"],
|
||||
});
|
||||
|
||||
assert.equal(report.generated.length, 2);
|
||||
assert.ok(report.generated.includes("omni-providers"));
|
||||
assert.ok(report.generated.includes("cli-serve"));
|
||||
|
||||
// Only 2 dirs created
|
||||
const entries = fs.readdirSync(tmpDir);
|
||||
assert.equal(entries.length, 2, `Expected 2 dirs, got: ${entries.join(", ")}`);
|
||||
} finally {
|
||||
rmTmpDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Generated comment ─────────────────────────────────────────────────────────
|
||||
|
||||
test("generated SKILL.md contains the mandatory generated comment", async () => {
|
||||
const tmpDir = mkTmpDir();
|
||||
try {
|
||||
refreshCatalog();
|
||||
await generateAgentSkills({
|
||||
dryRun: false,
|
||||
prune: false,
|
||||
outputDir: tmpDir,
|
||||
onlyIds: ["omni-providers"],
|
||||
});
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, "omni-providers", "SKILL.md"), "utf-8");
|
||||
assert.ok(
|
||||
content.includes("<!-- generated by src/lib/agentSkills/generator.ts; manual edits will be overwritten -->"),
|
||||
"Missing mandatory generated comment",
|
||||
);
|
||||
} finally {
|
||||
rmTmpDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Report shape ──────────────────────────────────────────────────────────────
|
||||
|
||||
test("report has all required fields with correct types", async () => {
|
||||
const tmpDir = mkTmpDir();
|
||||
try {
|
||||
refreshCatalog();
|
||||
const report = await generateAgentSkills({
|
||||
dryRun: true,
|
||||
prune: false,
|
||||
outputDir: tmpDir,
|
||||
});
|
||||
|
||||
assert.ok(Array.isArray(report.generated), "generated must be an array");
|
||||
assert.ok(Array.isArray(report.unchanged), "unchanged must be an array");
|
||||
assert.ok(Array.isArray(report.pruned), "pruned must be an array");
|
||||
assert.ok(Array.isArray(report.orphansDetected), "orphansDetected must be an array");
|
||||
assert.ok(Array.isArray(report.errors), "errors must be an array");
|
||||
} finally {
|
||||
rmTmpDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── serializeFrontmatter escaping (js/incomplete-sanitization regression) ──────
|
||||
|
||||
test("serializeFrontmatter escapes backslashes before quotes → valid round-trippable YAML", async () => {
|
||||
const { parse } = await import("yaml");
|
||||
const fm = {
|
||||
name: 'name with "quote"',
|
||||
description: 'line1\nline2: path C:\\Users\\x with "q" and trailing \\',
|
||||
};
|
||||
const block = __testing.serializeFrontmatter(fm);
|
||||
// Strip the --- fences and parse the inner YAML; broken escaping (e.g. an
|
||||
// unescaped trailing backslash that escapes the closing quote) would throw or
|
||||
// corrupt the value here.
|
||||
const inner = block.replace(/^---\n/, "").replace(/---\n?$/, "");
|
||||
const parsed = parse(inner) as { name: string; description: string };
|
||||
assert.equal(parsed.name, fm.name, "name must round-trip through YAML unchanged");
|
||||
assert.equal(
|
||||
parsed.description,
|
||||
fm.description,
|
||||
"description with backslashes + quotes + newline must round-trip exactly"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
// Dynamic import to pick up ESM module
|
||||
const { parseOpenapi, getEndpointsForArea } =
|
||||
await import("../../src/lib/agentSkills/openapiParser.ts");
|
||||
|
||||
// ─── Fixture helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates a temporary directory with a minimal openapi.yaml fixture,
|
||||
* changes CWD to it, and returns a cleanup function.
|
||||
*/
|
||||
function withFixtureOpenapi(yamlContent: string): { cleanup: () => void } {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-openapi-test-"));
|
||||
// openapiParser reads docs/openapi.yaml (consolidated location since #4781,
|
||||
// previously docs/reference/openapi.yaml) — the fixture must mirror that path.
|
||||
const docsDir = path.join(tmpDir, "docs");
|
||||
fs.mkdirSync(docsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(docsDir, "openapi.yaml"), yamlContent, "utf-8");
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
return {
|
||||
cleanup() {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Fixture YAML ─────────────────────────────────────────────────────────────
|
||||
|
||||
const FIXTURE_YAML = `
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute Test
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/api/providers:
|
||||
get:
|
||||
tags: [Providers]
|
||||
summary: List provider connections
|
||||
description: Returns all configured provider connections.
|
||||
post:
|
||||
tags: [Providers]
|
||||
summary: Add provider connection
|
||||
/api/providers/{id}:
|
||||
get:
|
||||
tags: [Providers]
|
||||
summary: Get provider by id
|
||||
patch:
|
||||
tags: [Providers]
|
||||
summary: Update provider connection
|
||||
delete:
|
||||
tags: [Providers]
|
||||
summary: Remove provider connection
|
||||
/api/providers/{id}/test:
|
||||
post:
|
||||
tags: [Providers]
|
||||
summary: Test provider connection
|
||||
/api/keys:
|
||||
get:
|
||||
tags: [APIKeys]
|
||||
summary: List API keys
|
||||
post:
|
||||
tags: [APIKeys]
|
||||
summary: Create API key
|
||||
/api/keys/{id}:
|
||||
delete:
|
||||
tags: [APIKeys]
|
||||
summary: Revoke API key
|
||||
/api/usage/analytics:
|
||||
get:
|
||||
tags: [Usage]
|
||||
summary: Get usage analytics
|
||||
/api/v1/chat/completions:
|
||||
post:
|
||||
tags: [Chat]
|
||||
summary: Create chat completion
|
||||
/api/settings:
|
||||
get:
|
||||
tags: [Settings]
|
||||
summary: Get settings
|
||||
put:
|
||||
tags: [Settings]
|
||||
summary: Update settings
|
||||
`;
|
||||
|
||||
// ─── Tests using fixture ──────────────────────────────────────────────────────
|
||||
|
||||
test("parseOpenapi() returns paths Map with all operations from fixture", () => {
|
||||
const { cleanup } = withFixtureOpenapi(FIXTURE_YAML);
|
||||
try {
|
||||
const { paths } = parseOpenapi();
|
||||
|
||||
assert.ok(paths instanceof Map, "paths should be a Map");
|
||||
assert.ok(paths.size > 0, "paths should not be empty");
|
||||
|
||||
// Spot-check a few keys
|
||||
assert.ok(paths.has("GET /api/providers"), "Expected GET /api/providers");
|
||||
assert.ok(paths.has("POST /api/providers"), "Expected POST /api/providers");
|
||||
assert.ok(paths.has("GET /api/providers/{id}"), "Expected GET /api/providers/{id}");
|
||||
assert.ok(paths.has("DELETE /api/providers/{id}"), "Expected DELETE /api/providers/{id}");
|
||||
assert.ok(paths.has("POST /api/v1/chat/completions"), "Expected POST /api/v1/chat/completions");
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("parseOpenapi() groups /api/providers/* under 'providers' area", () => {
|
||||
const { cleanup } = withFixtureOpenapi(FIXTURE_YAML);
|
||||
try {
|
||||
const { areas } = parseOpenapi();
|
||||
|
||||
const providerOps = areas.get("providers");
|
||||
assert.ok(providerOps, "Expected 'providers' area to exist");
|
||||
assert.ok(
|
||||
providerOps!.length >= 5,
|
||||
`Expected at least 5 provider endpoints, got ${providerOps!.length}`
|
||||
);
|
||||
|
||||
const paths = providerOps!.map((op) => op.path);
|
||||
assert.ok(paths.includes("/api/providers"), "Expected /api/providers");
|
||||
assert.ok(paths.includes("/api/providers/{id}"), "Expected /api/providers/{id}");
|
||||
assert.ok(paths.includes("/api/providers/{id}/test"), "Expected /api/providers/{id}/test");
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("parseOpenapi() groups /api/keys/* under 'api-keys' area", () => {
|
||||
const { cleanup } = withFixtureOpenapi(FIXTURE_YAML);
|
||||
try {
|
||||
const { areas } = parseOpenapi();
|
||||
const keyOps = areas.get("api-keys");
|
||||
assert.ok(keyOps, "Expected 'api-keys' area to exist");
|
||||
assert.ok(keyOps!.length >= 2, `Expected at least 2 key endpoints, got ${keyOps!.length}`);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("parseOpenapi() groups /api/v1/* under 'inference' area", () => {
|
||||
const { cleanup } = withFixtureOpenapi(FIXTURE_YAML);
|
||||
try {
|
||||
const { areas } = parseOpenapi();
|
||||
const inferenceOps = areas.get("inference");
|
||||
assert.ok(inferenceOps, "Expected 'inference' area to exist");
|
||||
assert.ok(
|
||||
inferenceOps!.length >= 1,
|
||||
`Expected at least 1 inference endpoint, got ${inferenceOps!.length}`
|
||||
);
|
||||
assert.ok(
|
||||
inferenceOps!.some((op) => op.path === "/api/v1/chat/completions"),
|
||||
"Expected /api/v1/chat/completions in inference area"
|
||||
);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("parseOpenapi() OpenapiPath entries have required fields", () => {
|
||||
const { cleanup } = withFixtureOpenapi(FIXTURE_YAML);
|
||||
try {
|
||||
const { paths } = parseOpenapi();
|
||||
for (const [key, op] of paths) {
|
||||
assert.ok(typeof op.method === "string" && op.method.length > 0, `${key}: method missing`);
|
||||
assert.ok(typeof op.path === "string" && op.path.length > 0, `${key}: path missing`);
|
||||
assert.ok(typeof op.summary === "string", `${key}: summary not a string`);
|
||||
assert.ok(Array.isArray(op.tags), `${key}: tags not an array`);
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("parseOpenapi() throws if openapi.yaml is missing", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-openapi-missing-"));
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
try {
|
||||
assert.throws(
|
||||
() => parseOpenapi(),
|
||||
/openapiParser: could not read/,
|
||||
"Expected error when openapi.yaml is missing"
|
||||
);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("parseOpenapi() returns empty areas Map for YAML with no paths", () => {
|
||||
const emptyPathsYaml = `
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Empty
|
||||
version: 1.0.0
|
||||
paths: {}
|
||||
`;
|
||||
const { cleanup } = withFixtureOpenapi(emptyPathsYaml);
|
||||
try {
|
||||
const { paths, areas } = parseOpenapi();
|
||||
assert.equal(paths.size, 0);
|
||||
assert.equal(areas.size, 0);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Integration test: real openapi.yaml (gated) ─────────────────────────────
|
||||
|
||||
const SKIP_REAL = process.env.SKIP_REAL_OPENAPI === "1";
|
||||
|
||||
test(
|
||||
"parseOpenapi() with real openapi.yaml: providers area has ≥5 endpoints",
|
||||
{ skip: SKIP_REAL ? "SKIP_REAL_OPENAPI=1" : false },
|
||||
() => {
|
||||
// This test runs from the project root (the worktree).
|
||||
// It will fail if openapi.yaml doesn't exist — that's intentional.
|
||||
const { areas } = parseOpenapi();
|
||||
const providerOps = areas.get("providers");
|
||||
assert.ok(providerOps, "Expected 'providers' area in real OpenAPI spec");
|
||||
assert.ok(
|
||||
providerOps!.length >= 5,
|
||||
`Expected ≥5 provider endpoints in real spec, got ${providerOps!.length}`
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"getEndpointsForArea('providers') with real openapi.yaml: returns ≥5 strings",
|
||||
{ skip: SKIP_REAL ? "SKIP_REAL_OPENAPI=1" : false },
|
||||
() => {
|
||||
const endpoints = getEndpointsForArea("providers");
|
||||
assert.ok(
|
||||
endpoints.length >= 5,
|
||||
`Expected ≥5 provider endpoint strings, got ${endpoints.length}: ${endpoints.join(", ")}`
|
||||
);
|
||||
// Each entry should match "METHOD /path"
|
||||
for (const ep of endpoints) {
|
||||
assert.match(ep, /^[A-Z]+ \//, `Endpoint "${ep}" does not match METHOD /path format`);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* Unit tests for /api/agent-skills/* REST routes.
|
||||
*
|
||||
* Uses Node.js native test runner + real catalog (pure, stateless).
|
||||
* Auth tested via requireManagementAuth with live DB in temp directory.
|
||||
*
|
||||
* Coverage goals:
|
||||
* - GET /api/agent-skills — happy path (43 skills), filters, invalid category
|
||||
* - GET /api/agent-skills/[id] — found, 404 not found
|
||||
* - GET /api/agent-skills/[id]/raw — found, 404 not found, 502 on GitHub failure
|
||||
* - GET /api/agent-skills/coverage — happy path
|
||||
* - POST /api/agent-skills/generate — 401 no auth, 400 bad body, 503 no generator, 200 with mock
|
||||
*
|
||||
* Hard Rule #12: every error response goes through buildErrorBody/errorResponse — verified
|
||||
* explicitly by asserting no stack trace in messages.
|
||||
*/
|
||||
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";
|
||||
|
||||
// ── DB / auth setup ───────────────────────────────────────────────────────────
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-agentskills-routes-"));
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
const ORIGINAL_API_KEY_SECRET = process.env.API_KEY_SECRET;
|
||||
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "agentskills-routes-test-secret";
|
||||
|
||||
// Import DB first (order matters — sets DATA_DIR before localDb loads)
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
|
||||
// Import routes AFTER env vars are set
|
||||
const listRoute = await import("../../src/app/api/agent-skills/route.ts");
|
||||
const idRoute = await import("../../src/app/api/agent-skills/[id]/route.ts");
|
||||
const rawRoute = await import("../../src/app/api/agent-skills/[id]/raw/route.ts");
|
||||
const coverageRoute = await import("../../src/app/api/agent-skills/coverage/route.ts");
|
||||
const generateRoute = await import("../../src/app/api/agent-skills/generate/route.ts");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
}
|
||||
|
||||
function makeRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
body?: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
): Request {
|
||||
return new Request(url, {
|
||||
method,
|
||||
headers: {
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
|
||||
if (ORIGINAL_DATA_DIR === undefined) {
|
||||
delete process.env.DATA_DIR;
|
||||
} else {
|
||||
process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
}
|
||||
|
||||
if (ORIGINAL_API_KEY_SECRET === undefined) {
|
||||
delete process.env.API_KEY_SECRET;
|
||||
} else {
|
||||
process.env.API_KEY_SECRET = ORIGINAL_API_KEY_SECRET;
|
||||
}
|
||||
|
||||
if (ORIGINAL_INITIAL_PASSWORD === undefined) {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
} else {
|
||||
process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
|
||||
}
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/agent-skills
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test("GET /api/agent-skills — returns 44 skills with count and coverage", async () => {
|
||||
const req = makeRequest("GET", "http://localhost/api/agent-skills");
|
||||
const res = await listRoute.GET(req);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as { skills: unknown[]; count: number; coverage: unknown };
|
||||
assert.equal(body.count, 44, `Expected 44 skills but got ${body.count}`);
|
||||
assert.equal(Array.isArray(body.skills), true);
|
||||
assert.equal(body.skills.length, 44);
|
||||
assert.ok(body.coverage !== undefined, "coverage should be present");
|
||||
});
|
||||
|
||||
test("GET /api/agent-skills?category=api — returns 23 api skills", async () => {
|
||||
const req = makeRequest("GET", "http://localhost/api/agent-skills?category=api");
|
||||
const res = await listRoute.GET(req);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as { skills: Array<{ category: string }>; count: number };
|
||||
assert.equal(body.count, 23);
|
||||
assert.ok(body.skills.every((s) => s.category === "api"), "All skills should be api category");
|
||||
});
|
||||
|
||||
test("GET /api/agent-skills?category=cli — returns 20 cli skills", async () => {
|
||||
const req = makeRequest("GET", "http://localhost/api/agent-skills?category=cli");
|
||||
const res = await listRoute.GET(req);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as { skills: Array<{ category: string }>; count: number };
|
||||
assert.equal(body.count, 20);
|
||||
assert.ok(body.skills.every((s) => s.category === "cli"), "All skills should be cli category");
|
||||
});
|
||||
|
||||
test("GET /api/agent-skills?area=providers — returns only providers area skills", async () => {
|
||||
const req = makeRequest("GET", "http://localhost/api/agent-skills?area=providers");
|
||||
const res = await listRoute.GET(req);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as { skills: Array<{ area: string }>; count: number };
|
||||
assert.ok(body.count >= 1, "Should find at least one providers skill");
|
||||
assert.ok(body.skills.every((s) => s.area === "providers"));
|
||||
});
|
||||
|
||||
test("GET /api/agent-skills?category=invalid — returns 400 with sanitized error", async () => {
|
||||
const req = makeRequest("GET", "http://localhost/api/agent-skills?category=invalid");
|
||||
const res = await listRoute.GET(req);
|
||||
|
||||
assert.equal(res.status, 400);
|
||||
const body = (await res.json()) as { error: { message: string } };
|
||||
assert.ok(body.error, "error field should be present");
|
||||
assert.ok(typeof body.error.message === "string", "error.message should be a string");
|
||||
// Hard Rule #12: no stack trace exposure
|
||||
assert.ok(
|
||||
!body.error.message.match(/\bat \/|\bat file:\/\//),
|
||||
`Error message must not contain stack trace: "${body.error.message}"`,
|
||||
);
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/agent-skills/[id]
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test("GET /api/agent-skills/[id] — returns skill for valid id", async () => {
|
||||
const req = makeRequest("GET", "http://localhost/api/agent-skills/omni-providers");
|
||||
const res = await idRoute.GET(req, { params: Promise.resolve({ id: "omni-providers" }) });
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as { id: string; category: string };
|
||||
assert.equal(body.id, "omni-providers");
|
||||
assert.equal(body.category, "api");
|
||||
});
|
||||
|
||||
test("GET /api/agent-skills/[id] — returns skill for cli skill id", async () => {
|
||||
const req = makeRequest("GET", "http://localhost/api/agent-skills/cli-serve");
|
||||
const res = await idRoute.GET(req, { params: Promise.resolve({ id: "cli-serve" }) });
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as { id: string; category: string };
|
||||
assert.equal(body.id, "cli-serve");
|
||||
assert.equal(body.category, "cli");
|
||||
});
|
||||
|
||||
test("GET /api/agent-skills/[id] — returns 404 with sanitized error for unknown id", async () => {
|
||||
const req = makeRequest("GET", "http://localhost/api/agent-skills/does-not-exist");
|
||||
const res = await idRoute.GET(req, { params: Promise.resolve({ id: "does-not-exist" }) });
|
||||
|
||||
assert.equal(res.status, 404);
|
||||
const body = (await res.json()) as { error: { message: string } };
|
||||
assert.ok(body.error);
|
||||
// Hard Rule #12: no stack trace exposure
|
||||
assert.ok(
|
||||
!body.error.message.match(/\bat \/|\bat file:\/\//),
|
||||
`Error message must not contain stack trace: "${body.error.message}"`,
|
||||
);
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/agent-skills/[id]/raw
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test("GET /api/agent-skills/[id]/raw — returns 404 with sanitized error for unknown id", async () => {
|
||||
const req = makeRequest("GET", "http://localhost/api/agent-skills/does-not-exist/raw");
|
||||
const res = await rawRoute.GET(req, { params: Promise.resolve({ id: "does-not-exist" }) });
|
||||
|
||||
assert.equal(res.status, 404);
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
assert.ok(contentType.includes("application/json"), "404 should be JSON, not markdown");
|
||||
|
||||
const body = (await res.json()) as { error: { message: string } };
|
||||
assert.ok(body.error);
|
||||
// Hard Rule #12: no stack trace exposure
|
||||
assert.ok(
|
||||
!body.error.message.match(/\bat \/|\bat file:\/\//),
|
||||
`Error message must not contain stack trace: "${body.error.message}"`,
|
||||
);
|
||||
});
|
||||
|
||||
test("GET /api/agent-skills/[id]/raw — returns markdown or 502 for valid id (no local file)", async () => {
|
||||
// With no local skills/ dir, the route will attempt GitHub fetch.
|
||||
// In test environment with no network or GitHub response, we expect either:
|
||||
// - 200 with text/markdown if GitHub fetch succeeds (unlikely in CI)
|
||||
// - 502 if GitHub fetch fails
|
||||
// We test the 502 branch explicitly here by using a skill where the rawUrl won't work.
|
||||
|
||||
const req = makeRequest("GET", "http://localhost/api/agent-skills/omni-providers/raw");
|
||||
const res = await rawRoute.GET(req, { params: Promise.resolve({ id: "omni-providers" }) });
|
||||
|
||||
// Either 200 (network available) or 502 (no network) is acceptable
|
||||
assert.ok(
|
||||
res.status === 200 || res.status === 502 || res.status === 500,
|
||||
`Expected 200, 502, or 500 but got ${res.status}`,
|
||||
);
|
||||
|
||||
if (res.status === 200) {
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
assert.ok(
|
||||
contentType.includes("text/markdown"),
|
||||
`Expected text/markdown content-type, got: ${contentType}`,
|
||||
);
|
||||
const cacheControl = res.headers.get("cache-control") ?? "";
|
||||
assert.ok(cacheControl.includes("max-age=3600"), "Cache-Control should include max-age=3600");
|
||||
} else {
|
||||
// 502 or 500 — error body must not leak stack trace
|
||||
const body = (await res.json()) as { error: { message: string } };
|
||||
assert.ok(body.error);
|
||||
assert.ok(
|
||||
!body.error.message.match(/\bat \/|\bat file:\/\//),
|
||||
`Error message must not contain stack trace: "${body.error.message}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/agent-skills/coverage
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test("GET /api/agent-skills/coverage — returns valid SkillCoverage shape", async () => {
|
||||
const res = await coverageRoute.GET();
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as {
|
||||
api: { have: number; total: number };
|
||||
cli: { have: number; total: number };
|
||||
totalSkills: number;
|
||||
generatedAt: string;
|
||||
};
|
||||
|
||||
assert.equal(body.api.total, 23, "api.total must be 23");
|
||||
assert.equal(body.cli.total, 20, "cli.total must be 20");
|
||||
assert.ok(typeof body.totalSkills === "number", "totalSkills must be a number");
|
||||
assert.ok(typeof body.generatedAt === "string", "generatedAt must be a string");
|
||||
// generatedAt must be a valid ISO datetime
|
||||
assert.ok(!isNaN(Date.parse(body.generatedAt)), "generatedAt must be a valid ISO datetime");
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// POST /api/agent-skills/generate — auth guard
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test("POST /api/agent-skills/generate — 401 when auth is required and no token provided", async () => {
|
||||
// Enable auth by setting INITIAL_PASSWORD (triggers requireManagementAuth)
|
||||
process.env.INITIAL_PASSWORD = "test-password-requires-login";
|
||||
|
||||
const req = makeRequest("POST", "http://localhost/api/agent-skills/generate", {
|
||||
dryRun: true,
|
||||
});
|
||||
const res = await generateRoute.POST(req);
|
||||
|
||||
// requireManagementAuth returns 401 or 403 when auth is required and no token
|
||||
assert.ok(
|
||||
res.status === 401 || res.status === 403,
|
||||
`Expected 401 or 403 without auth, got ${res.status}`,
|
||||
);
|
||||
|
||||
const body = (await res.json()) as { error: { message: string } | string };
|
||||
// Hard Rule #12: no stack trace exposure
|
||||
const errorMsg =
|
||||
typeof body.error === "string" ? body.error : (body.error as { message: string }).message;
|
||||
assert.ok(
|
||||
!errorMsg.match(/\bat \/|\bat file:\/\//),
|
||||
`Error message must not contain stack trace: "${errorMsg}"`,
|
||||
);
|
||||
});
|
||||
|
||||
test("POST /api/agent-skills/generate — 400 when body is invalid (non-boolean dryRun)", async () => {
|
||||
// Disable auth for this test by not setting INITIAL_PASSWORD
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
|
||||
const req = makeRequest("POST", "http://localhost/api/agent-skills/generate", {
|
||||
dryRun: "yes", // invalid — must be boolean
|
||||
});
|
||||
const res = await generateRoute.POST(req);
|
||||
|
||||
// Should be 400 (body validation fails) or 503 (generator not yet available)
|
||||
// Since F3 is not merged, generator.ts doesn't exist → dynamic import fails → 503 if auth passes
|
||||
// But with invalid body, 400 should come first
|
||||
assert.ok(
|
||||
res.status === 400 || res.status === 503,
|
||||
`Expected 400 (bad body) or 503 (generator unavailable), got ${res.status}`,
|
||||
);
|
||||
|
||||
const body = (await res.json()) as { error: { message: string } };
|
||||
assert.ok(body.error);
|
||||
// Hard Rule #12: no stack trace exposure
|
||||
assert.ok(
|
||||
!body.error.message.match(/\bat \/|\bat file:\/\//),
|
||||
`Error message must not contain stack trace: "${body.error.message}"`,
|
||||
);
|
||||
});
|
||||
|
||||
test("POST /api/agent-skills/generate — 503 when generator module unavailable (F3 not merged)", async () => {
|
||||
// Disable auth for this test
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
|
||||
const req = makeRequest("POST", "http://localhost/api/agent-skills/generate", {
|
||||
dryRun: true,
|
||||
prune: false,
|
||||
});
|
||||
const res = await generateRoute.POST(req);
|
||||
|
||||
// If generator.ts doesn't exist (F3 not merged), expect 503.
|
||||
// If it does exist (F3 already merged), it may return 200 with a report.
|
||||
// Both are valid depending on merge state.
|
||||
assert.ok(
|
||||
res.status === 200 || res.status === 503,
|
||||
`Expected 200 (generator available) or 503 (generator unavailable), got ${res.status}`,
|
||||
);
|
||||
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
if (res.status === 503) {
|
||||
const err = body.error as { message: string } | undefined;
|
||||
assert.ok(err?.message, "503 should include error.message");
|
||||
// Hard Rule #12: no stack trace exposure
|
||||
assert.ok(
|
||||
!err.message.match(/\bat \/|\bat file:\/\//),
|
||||
`503 error message must not contain stack trace: "${err.message}"`,
|
||||
);
|
||||
} else {
|
||||
// 200: body should look like a GeneratorReport
|
||||
assert.ok("generated" in body || "report" in body || typeof body === "object");
|
||||
}
|
||||
});
|
||||
|
||||
test("POST /api/agent-skills/generate — 400 when request body is not JSON", async () => {
|
||||
// Disable auth for this test
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
|
||||
const req = new Request("http://localhost/api/agent-skills/generate", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "not valid json {{{",
|
||||
});
|
||||
const res = await generateRoute.POST(req);
|
||||
|
||||
assert.equal(res.status, 400, `Expected 400 for invalid JSON body, got ${res.status}`);
|
||||
const body = (await res.json()) as { error: { message: string } };
|
||||
assert.ok(body.error);
|
||||
// Hard Rule #12: no stack trace exposure
|
||||
assert.ok(
|
||||
!body.error.message.match(/\bat \/|\bat file:\/\//),
|
||||
`Error message must not contain stack trace: "${body.error.message}"`,
|
||||
);
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Hard Rule #12 sanity — all error bodies sanitized
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test("Hard Rule #12: all error responses contain sanitized messages (no 'at /' patterns)", async () => {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
|
||||
// Collect error responses from various bad inputs
|
||||
const errorResponses: Response[] = [
|
||||
// Invalid category query
|
||||
await listRoute.GET(
|
||||
makeRequest("GET", "http://localhost/api/agent-skills?category=bad-val"),
|
||||
),
|
||||
// Unknown skill id
|
||||
await idRoute.GET(makeRequest("GET", "http://localhost/api/agent-skills/unknown-id"), {
|
||||
params: Promise.resolve({ id: "unknown-id" }),
|
||||
}),
|
||||
// Unknown raw skill id
|
||||
await rawRoute.GET(
|
||||
makeRequest("GET", "http://localhost/api/agent-skills/unknown-id/raw"),
|
||||
{ params: Promise.resolve({ id: "unknown-id" }) },
|
||||
),
|
||||
// Invalid generate body (non-boolean)
|
||||
await generateRoute.POST(
|
||||
makeRequest("POST", "http://localhost/api/agent-skills/generate", {
|
||||
dryRun: 42,
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
for (const res of errorResponses) {
|
||||
assert.ok(res.status >= 400, `Expected an error status, got ${res.status}`);
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
assert.ok(
|
||||
contentType.includes("application/json") || contentType.includes("json"),
|
||||
`Error response must be JSON, got content-type: ${contentType}`,
|
||||
);
|
||||
|
||||
const body = (await res.json()) as { error?: { message?: string } };
|
||||
const message = body?.error?.message ?? "";
|
||||
assert.ok(
|
||||
!message.match(/\bat \/|\bat file:\/\//),
|
||||
`Stack trace detected in error response (status ${res.status}): "${message}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { AgentSkillSchema, SkillCoverageSchema, ListQuerySchema, GenerateBodySchema } =
|
||||
await import("../../src/lib/agentSkills/schemas.ts");
|
||||
|
||||
test("agent skills schemas module exposes runtime validators", async () => {
|
||||
const schemas = await import("../../src/lib/agentSkills/schemas.ts");
|
||||
|
||||
for (const key of [
|
||||
"AgentSkillSchema",
|
||||
"GenerateBodySchema",
|
||||
"ListQuerySchema",
|
||||
"SkillCategorySchema",
|
||||
"SkillCoverageSchema",
|
||||
]) {
|
||||
assert.equal(typeof schemas[key].safeParse, "function", `${key} should stay exported`);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── AgentSkillSchema ─────────────────────────────────────────────────────────
|
||||
|
||||
test("AgentSkillSchema — valid api skill parses successfully", () => {
|
||||
const input = {
|
||||
id: "omni-providers",
|
||||
name: "Providers",
|
||||
description: "Manage LLM providers",
|
||||
category: "api" as const,
|
||||
area: "providers",
|
||||
endpoints: ["GET /api/providers", "POST /api/providers"],
|
||||
rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/omni-providers/SKILL.md",
|
||||
githubUrl: "https://github.com/owner/repo/blob/main/skills/omni-providers/SKILL.md",
|
||||
};
|
||||
const result = AgentSkillSchema.safeParse(input);
|
||||
assert.equal(result.success, true);
|
||||
if (result.success) {
|
||||
assert.equal(result.data.id, "omni-providers");
|
||||
assert.equal(result.data.category, "api");
|
||||
}
|
||||
});
|
||||
|
||||
test("AgentSkillSchema — valid cli skill parses successfully", () => {
|
||||
const input = {
|
||||
id: "cli-serve",
|
||||
name: "Serve",
|
||||
description: "Start the OmniRoute server",
|
||||
category: "cli" as const,
|
||||
area: "cli-serve",
|
||||
cliCommands: ["serve", "serve --port 8080"],
|
||||
rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/cli-serve/SKILL.md",
|
||||
githubUrl: "https://github.com/owner/repo/blob/main/skills/cli-serve/SKILL.md",
|
||||
};
|
||||
const result = AgentSkillSchema.safeParse(input);
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
|
||||
test("AgentSkillSchema — invalid id (uppercase) fails", () => {
|
||||
const input = {
|
||||
id: "Omni-Providers",
|
||||
name: "Providers",
|
||||
description: "Manage LLM providers",
|
||||
category: "api",
|
||||
area: "providers",
|
||||
rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/omni-providers/SKILL.md",
|
||||
githubUrl: "https://github.com/owner/repo/blob/main/skills/omni-providers/SKILL.md",
|
||||
};
|
||||
const result = AgentSkillSchema.safeParse(input);
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
test("AgentSkillSchema — invalid category fails", () => {
|
||||
const input = {
|
||||
id: "omni-providers",
|
||||
name: "Providers",
|
||||
description: "Manage LLM providers",
|
||||
category: "unknown",
|
||||
area: "providers",
|
||||
rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/omni-providers/SKILL.md",
|
||||
githubUrl: "https://github.com/owner/repo/blob/main/skills/omni-providers/SKILL.md",
|
||||
};
|
||||
const result = AgentSkillSchema.safeParse(input);
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
test("AgentSkillSchema — non-url rawUrl fails", () => {
|
||||
const input = {
|
||||
id: "omni-providers",
|
||||
name: "Providers",
|
||||
description: "Manage LLM providers",
|
||||
category: "api",
|
||||
area: "providers",
|
||||
rawUrl: "not-a-url",
|
||||
githubUrl: "https://github.com/owner/repo/blob/main/skills/omni-providers/SKILL.md",
|
||||
};
|
||||
const result = AgentSkillSchema.safeParse(input);
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
test("AgentSkillSchema — optional fields absent parses successfully", () => {
|
||||
const input = {
|
||||
id: "omni-providers",
|
||||
name: "Providers",
|
||||
description: "Manage LLM providers",
|
||||
category: "api",
|
||||
area: "providers",
|
||||
rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/omni-providers/SKILL.md",
|
||||
githubUrl: "https://github.com/owner/repo/blob/main/skills/omni-providers/SKILL.md",
|
||||
};
|
||||
const result = AgentSkillSchema.safeParse(input);
|
||||
assert.equal(result.success, true);
|
||||
if (result.success) {
|
||||
assert.equal(result.data.endpoints, undefined);
|
||||
assert.equal(result.data.cliCommands, undefined);
|
||||
assert.equal(result.data.icon, undefined);
|
||||
assert.equal(result.data.isEntry, undefined);
|
||||
assert.equal(result.data.isNew, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("AgentSkillSchema — .parse throws on invalid input", () => {
|
||||
assert.throws(() => {
|
||||
AgentSkillSchema.parse({
|
||||
id: "bad id",
|
||||
name: "",
|
||||
description: "",
|
||||
category: "api",
|
||||
area: "x",
|
||||
rawUrl: "x",
|
||||
githubUrl: "x",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── SkillCoverageSchema ──────────────────────────────────────────────────────
|
||||
|
||||
test("SkillCoverageSchema — valid coverage parses successfully", () => {
|
||||
const input = {
|
||||
api: { have: 23, total: 23 },
|
||||
cli: { have: 20, total: 20 },
|
||||
totalSkills: 42,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
const result = SkillCoverageSchema.safeParse(input);
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
|
||||
test("SkillCoverageSchema — wrong total literal (api.total=21) fails", () => {
|
||||
const input = {
|
||||
api: { have: 21, total: 21 },
|
||||
cli: { have: 20, total: 20 },
|
||||
totalSkills: 41,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
const result = SkillCoverageSchema.safeParse(input);
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
test("SkillCoverageSchema — wrong total literal (cli.total=19) fails", () => {
|
||||
const input = {
|
||||
api: { have: 23, total: 23 },
|
||||
cli: { have: 19, total: 19 },
|
||||
totalSkills: 41,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
const result = SkillCoverageSchema.safeParse(input);
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
test("SkillCoverageSchema — invalid datetime fails", () => {
|
||||
const input = {
|
||||
api: { have: 23, total: 23 },
|
||||
cli: { have: 20, total: 20 },
|
||||
totalSkills: 42,
|
||||
generatedAt: "not-a-date",
|
||||
};
|
||||
const result = SkillCoverageSchema.safeParse(input);
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
test("SkillCoverageSchema — negative have value fails", () => {
|
||||
const input = {
|
||||
api: { have: -1, total: 23 },
|
||||
cli: { have: 20, total: 20 },
|
||||
totalSkills: 42,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
const result = SkillCoverageSchema.safeParse(input);
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
// ─── ListQuerySchema ──────────────────────────────────────────────────────────
|
||||
|
||||
test("ListQuerySchema — empty object parses successfully", () => {
|
||||
const result = ListQuerySchema.safeParse({});
|
||||
assert.equal(result.success, true);
|
||||
if (result.success) {
|
||||
assert.equal(result.data.category, undefined);
|
||||
assert.equal(result.data.area, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("ListQuerySchema — valid category parses successfully", () => {
|
||||
const result = ListQuerySchema.safeParse({ category: "api" });
|
||||
assert.equal(result.success, true);
|
||||
if (result.success) {
|
||||
assert.equal(result.data.category, "api");
|
||||
}
|
||||
});
|
||||
|
||||
test("ListQuerySchema — invalid category fails", () => {
|
||||
const result = ListQuerySchema.safeParse({ category: "invalid" });
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
test("ListQuerySchema — area filter parses successfully", () => {
|
||||
const result = ListQuerySchema.safeParse({ category: "cli", area: "cli-serve" });
|
||||
assert.equal(result.success, true);
|
||||
if (result.success) {
|
||||
assert.equal(result.data.area, "cli-serve");
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GenerateBodySchema ───────────────────────────────────────────────────────
|
||||
|
||||
test("GenerateBodySchema — empty object applies defaults", () => {
|
||||
const result = GenerateBodySchema.safeParse({});
|
||||
assert.equal(result.success, true);
|
||||
if (result.success) {
|
||||
assert.equal(result.data.dryRun, true);
|
||||
assert.equal(result.data.prune, false);
|
||||
assert.equal(result.data.onlyIds, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("GenerateBodySchema — explicit dryRun=false parses", () => {
|
||||
const result = GenerateBodySchema.safeParse({ dryRun: false, prune: true });
|
||||
assert.equal(result.success, true);
|
||||
if (result.success) {
|
||||
assert.equal(result.data.dryRun, false);
|
||||
assert.equal(result.data.prune, true);
|
||||
}
|
||||
});
|
||||
|
||||
test("GenerateBodySchema — onlyIds array parses", () => {
|
||||
const result = GenerateBodySchema.safeParse({ onlyIds: ["omni-providers", "cli-serve"] });
|
||||
assert.equal(result.success, true);
|
||||
if (result.success) {
|
||||
assert.deepEqual(result.data.onlyIds, ["omni-providers", "cli-serve"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("GenerateBodySchema — non-boolean dryRun fails", () => {
|
||||
const result = GenerateBodySchema.safeParse({ dryRun: "yes" });
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
isClaudeCodeCompatible,
|
||||
buildProviderUrl,
|
||||
buildProviderHeaders,
|
||||
} from "../../open-sse/services/provider.ts";
|
||||
import { isClaudeCodeCompatibleProvider } from "../../open-sse/services/claudeCodeCompatible.ts";
|
||||
import {
|
||||
CC_WIRE_IMAGE_BUILTINS,
|
||||
usesCcWireImage,
|
||||
} from "../../open-sse/services/ccWireImageBuiltins.ts";
|
||||
import { CLAUDE_CODE_COMPATIBLE_USER_AGENT } from "../../open-sse/services/claudeCodeCompatible.ts";
|
||||
import { CLAUDE_CLI_USER_AGENT } from "../../open-sse/config/anthropicHeaders.ts";
|
||||
import { applyFingerprint } from "../../open-sse/config/cliFingerprints.ts";
|
||||
|
||||
// Regression guard for #6056 — the built-in `agentrouter` provider must route
|
||||
// through the DYNAMIC Claude-Code wire image (fingerprint headers + `?beta=true`
|
||||
// chat path) while KEEPING its own registry baseUrl + x-api-key auth.
|
||||
|
||||
test("agentrouter is registered in the CC-wire-image built-in allow-set", () => {
|
||||
assert.ok(CC_WIRE_IMAGE_BUILTINS.has("agentrouter"));
|
||||
assert.equal(usesCcWireImage("agentrouter"), true);
|
||||
assert.equal(usesCcWireImage("claude"), false);
|
||||
assert.equal(usesCcWireImage(null), false);
|
||||
});
|
||||
|
||||
test("(a) both CC predicates return true for agentrouter", () => {
|
||||
assert.equal(isClaudeCodeCompatible("agentrouter"), true);
|
||||
assert.equal(isClaudeCodeCompatibleProvider("agentrouter"), true);
|
||||
});
|
||||
|
||||
test("(a) predicates are unaffected for non-allow-set providers", () => {
|
||||
// Official Claude OAuth provider must NOT be treated as CC-compatible.
|
||||
assert.equal(isClaudeCodeCompatible("claude"), false);
|
||||
assert.equal(isClaudeCodeCompatibleProvider("claude"), false);
|
||||
// Genuine CC-family providers still match via the prefix.
|
||||
assert.equal(isClaudeCodeCompatible("anthropic-compatible-cc-foo"), true);
|
||||
assert.equal(isClaudeCodeCompatibleProvider("anthropic-compatible-cc-foo"), true);
|
||||
});
|
||||
|
||||
test("(b) agentrouter outbound headers carry the dynamic CC wire image", () => {
|
||||
const headers = buildProviderHeaders("agentrouter", { apiKey: "sk-agentrouter" }, true);
|
||||
|
||||
// CC wire image markers (not the static getClaudeCliHeaders() shape).
|
||||
assert.equal(headers["User-Agent"], CLAUDE_CODE_COMPATIBLE_USER_AGENT);
|
||||
assert.notEqual(headers["User-Agent"], CLAUDE_CLI_USER_AGENT);
|
||||
assert.equal(headers["x-app"], "cli");
|
||||
assert.equal(headers["anthropic-dangerous-direct-browser-access"], "true");
|
||||
assert.ok(headers["anthropic-beta"], "expected the CC anthropic-beta header");
|
||||
assert.ok(headers["X-Stainless-Package-Version"], "expected CC X-Stainless anchors");
|
||||
});
|
||||
|
||||
test("(b) applyFingerprint selects the claude-code-compatible fingerprint for agentrouter", () => {
|
||||
const { headers } = applyFingerprint(
|
||||
"agentrouter",
|
||||
buildProviderHeaders("agentrouter", { apiKey: "sk-agentrouter" }, true),
|
||||
{ model: "claude-opus-4-6", messages: [] }
|
||||
);
|
||||
// Fingerprint reordering keeps the CC wire image + the preserved x-api-key auth.
|
||||
assert.equal(headers["x-api-key"], "sk-agentrouter");
|
||||
assert.equal(headers["User-Agent"], CLAUDE_CODE_COMPATIBLE_USER_AGENT);
|
||||
});
|
||||
|
||||
test("(c) CRUX: agentrouter keeps its OWN x-api-key auth (NOT CC Bearer)", () => {
|
||||
const headers = buildProviderHeaders("agentrouter", { apiKey: "sk-agentrouter" }, true);
|
||||
assert.equal(headers["x-api-key"], "sk-agentrouter");
|
||||
assert.equal(headers["Authorization"], undefined);
|
||||
});
|
||||
|
||||
test("(c) CRUX: agentrouter keeps its OWN registry baseUrl + ?beta=true", () => {
|
||||
const url = buildProviderUrl("agentrouter", "claude-opus-4-6", true);
|
||||
assert.equal(url, "https://agentrouter.org/v1/messages?beta=true");
|
||||
// NOT the CC-family anthropic default baseUrl.
|
||||
assert.ok(!url.includes("api.anthropic.com"));
|
||||
});
|
||||
|
||||
test("(c) real CC-family provider still uses the CC default baseUrl + Bearer auth", () => {
|
||||
// The wire-image guard must NOT leak into genuine anthropic-compatible-cc-* providers.
|
||||
const headers = buildProviderHeaders(
|
||||
"anthropic-compatible-cc-foo",
|
||||
{ apiKey: "sk-foo" },
|
||||
true
|
||||
);
|
||||
assert.equal(headers["Authorization"], "Bearer sk-foo");
|
||||
assert.equal(headers["x-api-key"], undefined);
|
||||
|
||||
const url = buildProviderUrl("anthropic-compatible-cc-foo", "claude-sonnet-4-6", true);
|
||||
assert.ok(url.includes("api.anthropic.com"));
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
parseAndValidateAgyToken,
|
||||
AgyAuthFileError,
|
||||
} from "../../src/lib/oauth/utils/agyAuthImport.ts";
|
||||
|
||||
// Fixture token values are deliberately generic (not `ya29.`/`1//` shaped) so secret
|
||||
// scanners don't flag them — the parser only cares that they are non-empty strings.
|
||||
const ACCESS = "agy-access-token-fixture";
|
||||
const REFRESH = "agy-refresh-token-fixture";
|
||||
|
||||
test("parses the nested agy token-file shape (token.* with ISO expiry, no id_token)", () => {
|
||||
const parsed = parseAndValidateAgyToken({
|
||||
token: {
|
||||
access_token: ACCESS,
|
||||
token_type: "Bearer",
|
||||
refresh_token: REFRESH,
|
||||
expiry: "2026-05-29T06:16:24.338-03:00",
|
||||
},
|
||||
auth_method: "consumer",
|
||||
});
|
||||
assert.equal(parsed.accessToken, ACCESS);
|
||||
assert.equal(parsed.refreshToken, REFRESH);
|
||||
assert.equal(parsed.tokenType, "Bearer");
|
||||
assert.equal(parsed.authMethod, "consumer");
|
||||
assert.equal(parsed.expiresAt, new Date("2026-05-29T06:16:24.338-03:00").toISOString());
|
||||
});
|
||||
|
||||
test("accepts a flat fallback shape (access_token/refresh_token at top level)", () => {
|
||||
const parsed = parseAndValidateAgyToken({
|
||||
access_token: ACCESS,
|
||||
refresh_token: REFRESH,
|
||||
});
|
||||
assert.equal(parsed.accessToken, ACCESS);
|
||||
assert.equal(parsed.refreshToken, REFRESH);
|
||||
assert.equal(parsed.tokenType, "Bearer"); // default
|
||||
assert.equal(parsed.expiresAt, null);
|
||||
});
|
||||
|
||||
test("supports unix-ms expiry_date as an alternative to ISO expiry", () => {
|
||||
const ms = 1780038984000;
|
||||
const parsed = parseAndValidateAgyToken({
|
||||
token: { access_token: ACCESS, refresh_token: REFRESH, expiry_date: ms },
|
||||
});
|
||||
assert.equal(parsed.expiresAt, new Date(ms).toISOString());
|
||||
});
|
||||
|
||||
test("rejects a token file missing access_token", () => {
|
||||
assert.throws(
|
||||
() => parseAndValidateAgyToken({ token: { refresh_token: REFRESH } }),
|
||||
(err) =>
|
||||
err instanceof AgyAuthFileError &&
|
||||
err.code === "missing_access_token" &&
|
||||
err.status === 400
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a token file missing refresh_token", () => {
|
||||
assert.throws(
|
||||
() => parseAndValidateAgyToken({ token: { access_token: ACCESS } }),
|
||||
(err) => err instanceof AgyAuthFileError && err.code === "missing_refresh_token"
|
||||
);
|
||||
});
|
||||
|
||||
test("invalid/garbage expiry becomes null rather than throwing", () => {
|
||||
const parsed = parseAndValidateAgyToken({
|
||||
token: { access_token: ACCESS, refresh_token: REFRESH, expiry: "not-a-date" },
|
||||
});
|
||||
assert.equal(parsed.expiresAt, null);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* #3696 — antigravity/agy gemini-3.1-pro-high / gemini-3.1-pro-low were being collapsed
|
||||
* to the bare upstream id `gemini-3.1-pro`, losing the tier distinction.
|
||||
*
|
||||
* Wire evidence (captured by maintainer via `agy --model gemini-3.1-pro-high --log-file`):
|
||||
* - `gemini-3.1-pro-high` sent literally to `/v1internal:streamGenerateContent` → 200 OK
|
||||
* - `gemini-3.1-pro-low` sent literally → 200 OK
|
||||
*
|
||||
* CONCLUSION: the upstream ACCEPTS the suffixed ids directly. The old assumption in #3229
|
||||
* ("upstream rejects the suffix for gemini-3.x") was refuted by this wire capture.
|
||||
* The collapse aliases must be removed so the tier-specific ids reach the upstream.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
ANTIGRAVITY_PUBLIC_MODELS,
|
||||
resolveAntigravityModelId,
|
||||
} from "../../open-sse/config/antigravityModelAliases.ts";
|
||||
|
||||
test("(#3696) resolveAntigravityModelId passes gemini-3.1-pro-high through unchanged", () => {
|
||||
assert.equal(resolveAntigravityModelId("gemini-3.1-pro-high"), "gemini-3.1-pro-high");
|
||||
});
|
||||
|
||||
test("(#3696) resolveAntigravityModelId passes gemini-3.1-pro-low through unchanged", () => {
|
||||
assert.equal(resolveAntigravityModelId("gemini-3.1-pro-low"), "gemini-3.1-pro-low");
|
||||
});
|
||||
|
||||
test("(#3696) no two ANTIGRAVITY_PUBLIC_MODELS entries resolve to the same upstream id", () => {
|
||||
const seen = new Map<string, string>();
|
||||
const collisions: string[] = [];
|
||||
for (const model of ANTIGRAVITY_PUBLIC_MODELS) {
|
||||
const upstream = resolveAntigravityModelId(model.id);
|
||||
if (seen.has(upstream)) {
|
||||
collisions.push(`${model.id} and ${seen.get(upstream)} both resolve to "${upstream}"`);
|
||||
} else {
|
||||
seen.set(upstream, model.id);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(collisions, [], `upstream-id collisions: ${collisions.join("; ")}`);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* #3229 — agy `gemini-3.1-pro-high`/`-low` returned [400] but were MASKED as an empty
|
||||
* `chat.completion` envelope.
|
||||
*
|
||||
* Two parts:
|
||||
* (a) ORIGINAL FIX (#3229): aliased -high/-low to plain `gemini-3.1-pro` (upstream
|
||||
* was believed to reject the suffix). SUPERSEDED BY #3696: wire capture via
|
||||
* `agy --model gemini-3.1-pro-high --log-file` confirmed upstream returns 200 OK
|
||||
* with the suffixed id; the collapse aliases are now removed so the tier-specific
|
||||
* id passes through verbatim.
|
||||
* (b) the non-stream branch fed the 4xx response into the SSE collector, producing a
|
||||
* synthetic `{"object":"chat.completion","content":""}` instead of a real error →
|
||||
* build a proper sanitized error body for non-ok upstream responses. (Still valid.)
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveAntigravityModelId } from "../../open-sse/config/antigravityModelAliases.ts";
|
||||
import { buildAntigravityUpstreamError } from "../../open-sse/executors/antigravityUpstreamError.ts";
|
||||
|
||||
test("(a) agy gemini-3.1-pro -high/-low budget suffixes pass through to upstream unchanged (#3696)", () => {
|
||||
// #3696: wire capture confirmed upstream accepts the suffixed ids verbatim.
|
||||
// The old #3229 collapse aliases ("gemini-3.1-pro-high" → "gemini-3.1-pro") were removed.
|
||||
assert.equal(resolveAntigravityModelId("gemini-3.1-pro-high"), "gemini-3.1-pro-high");
|
||||
assert.equal(resolveAntigravityModelId("gemini-3.1-pro-low"), "gemini-3.1-pro-low");
|
||||
// plain id stays plain
|
||||
assert.equal(resolveAntigravityModelId("gemini-3.1-pro"), "gemini-3.1-pro");
|
||||
});
|
||||
|
||||
test("(b) a non-ok upstream response becomes a real error body, not an empty chat.completion", () => {
|
||||
const body = buildAntigravityUpstreamError(
|
||||
400,
|
||||
"Bad Request",
|
||||
JSON.stringify({ error: { code: 400, message: "Model not found: gemini-3.1-pro-high" } })
|
||||
);
|
||||
assert.notEqual((body as { object?: string }).object, "chat.completion");
|
||||
assert.ok(body.error, "must carry an error object");
|
||||
assert.equal(typeof body.error.message, "string");
|
||||
// sanitized: no raw stack traces leaked (hard rule #12)
|
||||
assert.ok(!body.error.message.includes("at /"));
|
||||
|
||||
// non-JSON upstream body still yields a valid error envelope
|
||||
const body2 = buildAntigravityUpstreamError(503, "Service Unavailable", "<html>oops</html>");
|
||||
assert.ok(body2.error);
|
||||
assert.notEqual((body2 as { object?: string }).object, "chat.completion");
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* #3786 — Antigravity (`agy` / `antigravity`) `gemini-3.1-pro-high` returns HTTP 400
|
||||
* ("Antigravity upstream error (400)") on recent upstream versions; `gemini-3.1-pro-low`
|
||||
* still works. OmniRoute sends the requested id VERBATIM (per #3696 wire capture). The
|
||||
* upstream changed the accepted model-id format for the Pro-high tier and the two
|
||||
* actively-maintained competitor proxies DISAGREE on the live id:
|
||||
* - AntigravityManager → `gemini-3.1-pro-high`
|
||||
* - CLIProxyAPI → `gemini-pro-agent` (display: "Gemini 3.1 Pro (High)")
|
||||
* - older form → `gemini-3-pro-high`
|
||||
*
|
||||
* Because the live id cannot be known from static analysis, we mirror AntigravityManager's
|
||||
* ROBUST approach: a per-request FALLBACK CHAIN that retries alternative upstream ids on a
|
||||
* 400, until one succeeds (2xx) or the chain is exhausted (then the original 400 surfaces,
|
||||
* sanitized — hard rule #12).
|
||||
*
|
||||
* The fallback chain lives at EXECUTOR REQUEST-TIME (retry on 400). It is NOT a change to
|
||||
* the static `resolveAntigravityModelId` map, so the #3696 invariant test stays green.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
ANTIGRAVITY_PRO_FALLBACK_CHAINS,
|
||||
getAntigravityModelFallbacks,
|
||||
} from "../../open-sse/config/antigravityModelAliases.ts";
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts";
|
||||
import { seedAntigravityVersionCache } from "../../open-sse/services/antigravityVersion.ts";
|
||||
|
||||
type ChatCompletionPayload = {
|
||||
object?: string;
|
||||
choices: Array<{ message: { content: string }; finish_reason: string }>;
|
||||
};
|
||||
|
||||
type ErrorPayload = {
|
||||
error: { code?: string; message: string };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helper: getAntigravityModelFallbacks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("(#3786) getAntigravityModelFallbacks returns the ordered pro-high chain", () => {
|
||||
assert.deepEqual(getAntigravityModelFallbacks("gemini-3.1-pro-high"), [
|
||||
"gemini-3.1-pro-high",
|
||||
"gemini-pro-agent",
|
||||
"gemini-3-pro-high",
|
||||
]);
|
||||
});
|
||||
|
||||
test("(#3786) getAntigravityModelFallbacks returns the ordered pro-low chain", () => {
|
||||
assert.deepEqual(getAntigravityModelFallbacks("gemini-3.1-pro-low"), [
|
||||
"gemini-3.1-pro-low",
|
||||
"gemini-3-pro-low",
|
||||
]);
|
||||
});
|
||||
|
||||
test("(#3786) getAntigravityModelFallbacks returns [] for unrelated models", () => {
|
||||
assert.deepEqual(getAntigravityModelFallbacks("gemini-2.5-flash"), []);
|
||||
assert.deepEqual(getAntigravityModelFallbacks("claude-sonnet-4-6"), []);
|
||||
assert.deepEqual(getAntigravityModelFallbacks("gemini-3-pro-preview"), []);
|
||||
assert.deepEqual(getAntigravityModelFallbacks(""), []);
|
||||
});
|
||||
|
||||
test("(#3786) every chain starts with its own key (each candidate listed once)", () => {
|
||||
for (const [key, chain] of Object.entries(ANTIGRAVITY_PRO_FALLBACK_CHAINS)) {
|
||||
assert.equal(chain[0], key, `chain for ${key} must start with itself`);
|
||||
assert.equal(new Set(chain).size, chain.length, `chain for ${key} must have no duplicates`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Behavioral: executor retries the next candidate on a 400
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeSuccessSSE(): Response {
|
||||
return new Response(
|
||||
'data: {"response":{"candidates":[{"content":{"parts":[{"text":"OK"}]},"finishReason":"STOP"}]}}\n\n',
|
||||
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
|
||||
);
|
||||
}
|
||||
|
||||
function make400(modelId: string): Response {
|
||||
return new Response(
|
||||
JSON.stringify({ error: { code: 400, message: `Model not found: ${modelId}` } }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
/** Extract the upstream model id from the serialized request envelope. */
|
||||
function envelopeModel(init: RequestInit | undefined): string {
|
||||
try {
|
||||
return JSON.parse(String(init?.body)).model as string;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
test("(#3786) execute retries pro-high with the next candidate when the first id 400s", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
seedAntigravityVersionCache("2026.04.17-test");
|
||||
const modelsTried: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
|
||||
const m = envelopeModel(init);
|
||||
modelsTried.push(m);
|
||||
// First candidate (gemini-3.1-pro-high) → 400, second (gemini-pro-agent) → 200
|
||||
if (m === "gemini-3.1-pro-high") return make400(m);
|
||||
return makeSuccessSSE();
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "antigravity/gemini-3.1-pro-high",
|
||||
body: { request: { contents: [] } },
|
||||
stream: false,
|
||||
credentials: { accessToken: "token", projectId: "project-1" },
|
||||
log: { debug() {}, warn() {}, info() {} },
|
||||
});
|
||||
const payload = (await result.response.json()) as ChatCompletionPayload;
|
||||
|
||||
assert.equal(result.response.status, 200, "second candidate should succeed");
|
||||
assert.equal(payload.choices[0].message.content, "OK");
|
||||
// Exactly two upstream calls: the 400 then the 200 on the next id.
|
||||
assert.deepEqual(modelsTried, ["gemini-3.1-pro-high", "gemini-pro-agent"]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("(#3786) execute exhausts the chain on all-400 and surfaces a sanitized 400 (each candidate tried once)", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
seedAntigravityVersionCache("2026.04.17-test");
|
||||
const modelsTried: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
|
||||
const m = envelopeModel(init);
|
||||
modelsTried.push(m);
|
||||
return make400(m); // every candidate fails with 400
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "antigravity/gemini-3.1-pro-high",
|
||||
body: { request: { contents: [] } },
|
||||
stream: false,
|
||||
credentials: { accessToken: "token", projectId: "project-1" },
|
||||
log: { debug() {}, warn() {}, info() {} },
|
||||
});
|
||||
const payload = (await result.response.json()) as ErrorPayload;
|
||||
|
||||
// Surfaces a real, sanitized 400 — not a masked empty chat.completion.
|
||||
assert.equal(result.response.status, 400);
|
||||
assert.ok(payload.error, "must carry an error object");
|
||||
assert.equal(typeof payload.error.message, "string");
|
||||
assert.ok(!payload.error.message.includes("at /"), "no raw stack trace (hard rule #12)");
|
||||
|
||||
// Each candidate tried EXACTLY once (bounded — no infinite loop).
|
||||
assert.deepEqual(modelsTried, ["gemini-3.1-pro-high", "gemini-pro-agent", "gemini-3-pro-high"]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("(#3786) happy path: first id 200 makes exactly ONE upstream call (zero extra)", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
seedAntigravityVersionCache("2026.04.17-test");
|
||||
const modelsTried: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
|
||||
modelsTried.push(envelopeModel(init));
|
||||
return makeSuccessSSE();
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "antigravity/gemini-3.1-pro-high",
|
||||
body: { request: { contents: [] } },
|
||||
stream: false,
|
||||
credentials: { accessToken: "token", projectId: "project-1" },
|
||||
log: { debug() {}, warn() {}, info() {} },
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 200);
|
||||
assert.deepEqual(modelsTried, ["gemini-3.1-pro-high"], "exactly one call on the happy path");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("(#3786) a non-pro model that 400s does NOT trigger the fallback chain", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
seedAntigravityVersionCache("2026.04.17-test");
|
||||
const modelsTried: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
|
||||
modelsTried.push(envelopeModel(init));
|
||||
return make400(envelopeModel(init));
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "antigravity/gemini-2.5-flash",
|
||||
body: { request: { contents: [] } },
|
||||
stream: false,
|
||||
credentials: { accessToken: "token", projectId: "project-1" },
|
||||
log: { debug() {}, warn() {}, info() {} },
|
||||
});
|
||||
|
||||
// flash 400 surfaces directly — only the requested id is tried, no chain.
|
||||
assert.equal(result.response.status, 400);
|
||||
assert.deepEqual(modelsTried, ["gemini-2.5-flash"]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const modalPath =
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx";
|
||||
const source = readFileSync(modalPath, "utf8");
|
||||
|
||||
describe("agy Project ID UI support", () => {
|
||||
it("declares a single Antigravity-family provider gate", () => {
|
||||
assert.ok(
|
||||
source.includes(
|
||||
'const isAntigravityFamily = provider === "antigravity" || provider === "agy";'
|
||||
),
|
||||
"isAntigravityFamily must include antigravity and agy without a separate Google Project ID gate"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not keep the old supportsGoogleProjectId alias", () => {
|
||||
assert.equal(source.includes("supportsGoogleProjectId"), false);
|
||||
});
|
||||
|
||||
it("uses antigravityProjectIdLabel for Antigravity-family providers", () => {
|
||||
assert.ok(
|
||||
source.includes('label={t("antigravityProjectIdLabel")}'),
|
||||
"projectId label must use Antigravity-family copy"
|
||||
);
|
||||
});
|
||||
|
||||
it("uses isAntigravityFamily for antigravityClientProfile UI", () => {
|
||||
assert.ok(
|
||||
source.includes("{isAntigravityFamily && (\n <div") &&
|
||||
source.includes('label={t("antigravityClientProfileLabel")}'),
|
||||
"client profile Select must render for isAntigravityFamily"
|
||||
);
|
||||
});
|
||||
|
||||
it("uses isAntigravityFamily for client profile save", () => {
|
||||
assert.ok(
|
||||
source.includes("if (isAntigravityFamily) {"),
|
||||
"client profile save must use isAntigravityFamily"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { AI_PROVIDERS, USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.ts";
|
||||
import { REGISTRY } from "../../open-sse/config/providerRegistry.ts";
|
||||
import { PROVIDERS as LEGACY_PROVIDERS } from "../../open-sse/config/constants.ts";
|
||||
import {
|
||||
PROVIDERS as OAUTH_PROVIDER_IDS,
|
||||
AGY_CONFIG,
|
||||
} from "../../src/lib/oauth/constants/oauth.ts";
|
||||
import { supportsTokenRefresh, REFRESH_LEAD_MS } from "../../open-sse/services/tokenRefresh.ts";
|
||||
import {
|
||||
AGY_PUBLIC_MODELS,
|
||||
isUserCallableAgyModelId,
|
||||
getClientVisibleAgyModelName,
|
||||
} from "../../open-sse/config/agyModels.ts";
|
||||
|
||||
test("agy is registered as an OAuth provider in the UI catalog", () => {
|
||||
const agy = AI_PROVIDERS.agy;
|
||||
assert.ok(agy, "AI_PROVIDERS.agy must exist");
|
||||
assert.equal(agy.id, "agy");
|
||||
assert.equal(agy.name, "Antigravity CLI");
|
||||
assert.equal(agy.riskNoticeVariant, "oauth");
|
||||
assert.equal(agy.subscriptionRisk, true);
|
||||
});
|
||||
|
||||
test("agy supports the usage/quota API", () => {
|
||||
assert.ok(USAGE_SUPPORTED_PROVIDERS.includes("agy"));
|
||||
});
|
||||
|
||||
test("agy registry entry reuses the antigravity backend (no duplicate executor/format)", () => {
|
||||
const agy = REGISTRY.agy;
|
||||
assert.ok(agy, "REGISTRY.agy must exist");
|
||||
assert.equal(agy.format, "antigravity");
|
||||
assert.equal(agy.executor, "antigravity");
|
||||
assert.equal(agy.authType, "oauth");
|
||||
assert.equal(agy.authHeader, "bearer");
|
||||
assert.equal(agy.passthroughModels, true);
|
||||
});
|
||||
|
||||
test("agy reuses the identical antigravity Google OAuth credentials (no new embedded secret)", () => {
|
||||
// The agy client_id was verified byte-for-byte identical to antigravity's.
|
||||
assert.equal(LEGACY_PROVIDERS.agy.clientId, LEGACY_PROVIDERS.antigravity.clientId);
|
||||
assert.equal(LEGACY_PROVIDERS.agy.clientSecret, LEGACY_PROVIDERS.antigravity.clientSecret);
|
||||
assert.equal(AGY_CONFIG.clientId, LEGACY_PROVIDERS.antigravity.clientId);
|
||||
assert.equal(OAUTH_PROVIDER_IDS.AGY, "agy");
|
||||
});
|
||||
|
||||
test("agy ships its own catalog including the Claude models antigravity omits", () => {
|
||||
const ids = REGISTRY.agy.models.map((m) => m.id);
|
||||
assert.ok(ids.includes("claude-opus-4-6-thinking"), "must expose Claude Opus 4.6 Thinking");
|
||||
assert.ok(ids.includes("claude-sonnet-4-6"), "must expose Claude Sonnet 4.6");
|
||||
assert.ok(ids.includes("gemini-3.5-flash-low"), "must expose clean Flash Low tier");
|
||||
assert.ok(ids.includes("gemini-3.5-flash-medium"), "must expose clean Flash Medium tier");
|
||||
assert.ok(ids.includes("gemini-3.5-flash-high"), "must expose clean Flash High tier");
|
||||
assert.ok(!ids.includes("gemini-3-flash-agent"));
|
||||
assert.ok(!ids.includes("gemini-3-flash"));
|
||||
assert.ok(!ids.includes("gemini-3.5-flash-extra-low"));
|
||||
// Tab-completion models are not chat-callable and must be excluded.
|
||||
assert.ok(!ids.includes("tab_flash_lite_preview"));
|
||||
assert.ok(!ids.includes("tab_jump_flash_lite_preview"));
|
||||
assert.equal(ids.length, AGY_PUBLIC_MODELS.length);
|
||||
});
|
||||
|
||||
test("agy model helpers resolve catalog ids and display names", () => {
|
||||
assert.equal(isUserCallableAgyModelId("claude-opus-4-6-thinking"), true);
|
||||
assert.equal(isUserCallableAgyModelId("tab_flash_lite_preview"), false);
|
||||
assert.equal(isUserCallableAgyModelId(""), false);
|
||||
assert.equal(
|
||||
getClientVisibleAgyModelName("claude-opus-4-6-thinking"),
|
||||
"Claude Opus 4.6 (Thinking)"
|
||||
);
|
||||
assert.equal(getClientVisibleAgyModelName("unknown-model", "Fallback"), "Fallback");
|
||||
});
|
||||
|
||||
test("agy token refresh is wired on the Google (non-rotating) refresh path", () => {
|
||||
assert.equal(supportsTokenRefresh("agy"), true);
|
||||
// Same 15-minute proactive lead as antigravity (Google refresh tokens are permanent).
|
||||
assert.equal(REFRESH_LEAD_MS.agy, REFRESH_LEAD_MS.antigravity);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const usageModule = await import("../../open-sse/services/usage.ts");
|
||||
const providerLimitUtils =
|
||||
await import("../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx");
|
||||
|
||||
test("agy is registered for the background usage fetcher", () => {
|
||||
assert.ok(
|
||||
usageModule.USAGE_FETCHER_PROVIDERS.includes("agy"),
|
||||
"agy should be fetched by the generic quota refresher"
|
||||
);
|
||||
});
|
||||
|
||||
test("getUsageForProvider routes agy through the Antigravity usage implementation", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
models: {
|
||||
"gemini-3-flash-agent": {
|
||||
quotaInfo: {
|
||||
remainingFraction: 0.75,
|
||||
resetTime: "2026-06-06T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await usageModule.getUsageForProvider(
|
||||
{
|
||||
id: "agy-test-conn",
|
||||
provider: "agy",
|
||||
accessToken: "fake-token",
|
||||
providerSpecificData: {},
|
||||
},
|
||||
{ forceRefresh: true }
|
||||
);
|
||||
|
||||
assert.ok(result && typeof result === "object");
|
||||
assert.notEqual(
|
||||
(result as { message?: string }).message,
|
||||
"Usage API not implemented for agy",
|
||||
"agy must not fall through to the unsupported-provider branch"
|
||||
);
|
||||
assert.ok("quotas" in result, "agy should return quota data when upstream responds");
|
||||
|
||||
const quota = (result as { quotas: Record<string, any> }).quotas["gemini-3.5-flash-high"];
|
||||
assert.ok(quota, "should expose the clean agy per-model quota");
|
||||
assert.equal(quota.remainingPercentage, 75);
|
||||
assert.equal(
|
||||
(result as { quotas: Record<string, any> }).quotas["gemini-3-flash-agent"],
|
||||
undefined,
|
||||
"agy quota should not expose retired upstream IDs"
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("parseQuotaData treats agy quota payloads like Antigravity", () => {
|
||||
const parsed = providerLimitUtils.parseQuotaData("agy", {
|
||||
quotas: {
|
||||
credits: { remaining: 42 },
|
||||
"gemini-3.5-flash-high": {
|
||||
used: 250,
|
||||
total: 1000,
|
||||
remainingPercentage: 75,
|
||||
},
|
||||
models: { used: 0, total: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
parsed.length,
|
||||
2,
|
||||
"credits and model quota should be rendered, models summary skipped"
|
||||
);
|
||||
const credits = parsed.find((quota: any) => quota.name === "credits");
|
||||
assert.ok(credits, "credits quota should be rendered");
|
||||
assert.equal(credits.isCredits, true);
|
||||
|
||||
const modelQuota = parsed.find((quota: any) => quota.name === "gemini-3.5-flash-high");
|
||||
assert.ok(modelQuota, "model quota should be rendered");
|
||||
assert.equal(modelQuota.remainingPercentage, 75);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Regression for #5899 (PR #5920): the OpenAI-compatible models-discovery URL
|
||||
* builder must strip a trailing `/v1` UNCONDITIONALLY before appending
|
||||
* `/v1/models`. A gateway baseUrl like ".../v1/chat/completions" was reduced to
|
||||
* ".../v1" (the old `else if` skipped the /v1 strip once `/chat/completions`
|
||||
* matched) and then produced ".../v1/v1/models" — a 308 redirect that blocked
|
||||
* model discovery. The fix converts the `/v1` strip to an independent `if`
|
||||
* (guarding against a literal "scheme://v1" authority) in BOTH the general
|
||||
* discovery path and the `provider === "openai"` custom-base-URL path.
|
||||
*/
|
||||
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-5899-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const modelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("#5899 openai gateway baseUrl ending in /v1/chat/completions never probes /v1/v1/models", async () => {
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "airforce-gateway",
|
||||
apiKey: "sk-airforce",
|
||||
providerSpecificData: { baseUrl: "https://api.airforce/v1/chat/completions" },
|
||||
});
|
||||
|
||||
const requestedUrls: string[] = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
const u = String(url);
|
||||
requestedUrls.push(u);
|
||||
// The correctly-stripped candidate must be the one that serves models.
|
||||
if (u === "https://api.airforce/v1/models") {
|
||||
return Response.json({ object: "list", data: [{ id: "gpt-4o" }, { id: "gpt-5" }] });
|
||||
}
|
||||
// The double-prefixed URL upstream answered with a 308 redirect (#5899).
|
||||
if (u === "https://api.airforce/v1/v1/models") {
|
||||
return new Response(null, { status: 308, headers: { location: u } });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
|
||||
try {
|
||||
await modelsRoute.GET(
|
||||
new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`),
|
||||
{ params: { id: connection.id } }
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
requestedUrls.includes("https://api.airforce/v1/models"),
|
||||
`expected a request to the correctly-stripped /v1/models URL; got: ${JSON.stringify(requestedUrls)}`
|
||||
);
|
||||
assert.ok(
|
||||
!requestedUrls.includes("https://api.airforce/v1/v1/models"),
|
||||
`must never probe the double-prefixed /v1/v1/models URL; got: ${JSON.stringify(requestedUrls)}`
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Tests for Anthropic billing header fingerprint stability (#1638).
|
||||
*
|
||||
* Validates that the billing header fingerprint is stable across different
|
||||
* messages within the same day, preventing prompt-cache prefix invalidation.
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
// Replicate the stabilized fingerprint logic from base.ts
|
||||
function computeStableFingerprint(ccVersion: string): string {
|
||||
const dayStamp = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
return createHash("sha256").update(`${dayStamp}${ccVersion}`).digest("hex").slice(0, 3);
|
||||
}
|
||||
|
||||
// The old implementation for comparison
|
||||
function computeOldFingerprint(firstUserMessageText: string, version: string): string {
|
||||
const FINGERPRINT_SALT = "59cf53e54c78";
|
||||
const indices = [4, 7, 20];
|
||||
const chars = indices.map((i) => firstUserMessageText[i] || "0").join("");
|
||||
const input = `${FINGERPRINT_SALT}${chars}${version}`;
|
||||
return createHash("sha256").update(input).digest("hex").slice(0, 3);
|
||||
}
|
||||
|
||||
describe("Anthropic billing header fingerprint (#1638)", () => {
|
||||
const ccVersion = "2.1.137";
|
||||
|
||||
it("should produce the same fingerprint for different messages (stable)", () => {
|
||||
const fp1 = computeStableFingerprint(ccVersion);
|
||||
const fp2 = computeStableFingerprint(ccVersion);
|
||||
assert.equal(fp1, fp2, "Same-day fingerprints should be identical");
|
||||
});
|
||||
|
||||
it("should produce a 3-character hex fingerprint", () => {
|
||||
const fp = computeStableFingerprint(ccVersion);
|
||||
assert.equal(fp.length, 3, "Fingerprint should be 3 chars");
|
||||
assert.ok(/^[a-f0-9]{3}$/.test(fp), `Fingerprint '${fp}' should be lowercase hex`);
|
||||
});
|
||||
|
||||
it("old implementation produces DIFFERENT fingerprints for different messages", () => {
|
||||
const msg1 = "Hello, how can I help you with your code?";
|
||||
const msg2 = "Please fix the bug in my application";
|
||||
const fp1 = computeOldFingerprint(msg1, ccVersion);
|
||||
const fp2 = computeOldFingerprint(msg2, ccVersion);
|
||||
assert.notEqual(fp1, fp2, "Old method should differ per message — this was the bug");
|
||||
});
|
||||
|
||||
it("new implementation produces SAME fingerprint regardless of message content", () => {
|
||||
// The new implementation doesn't use message content at all
|
||||
const fp1 = computeStableFingerprint(ccVersion);
|
||||
const fp2 = computeStableFingerprint(ccVersion);
|
||||
const fp3 = computeStableFingerprint(ccVersion);
|
||||
assert.equal(fp1, fp2);
|
||||
assert.equal(fp2, fp3);
|
||||
});
|
||||
|
||||
it("billing header line should be deterministic within the same day", () => {
|
||||
const fp = computeStableFingerprint(ccVersion);
|
||||
const billingLine1 = `x-anthropic-billing-header: cc_version=${ccVersion}.${fp}; cc_entrypoint=cli; cch=00000;`;
|
||||
const billingLine2 = `x-anthropic-billing-header: cc_version=${ccVersion}.${fp}; cc_entrypoint=cli; cch=00000;`;
|
||||
assert.equal(billingLine1, billingLine2, "Billing lines should be byte-identical");
|
||||
});
|
||||
|
||||
it("should produce different fingerprints for different days", () => {
|
||||
// Simulate different days by computing manually
|
||||
const day1 = "2026-04-27";
|
||||
const day2 = "2026-04-28";
|
||||
const fp1 = createHash("sha256").update(`${day1}${ccVersion}`).digest("hex").slice(0, 3);
|
||||
const fp2 = createHash("sha256").update(`${day2}${ccVersion}`).digest("hex").slice(0, 3);
|
||||
// They should be different (extremely high probability with SHA-256)
|
||||
assert.notEqual(fp1, fp2, "Different days should produce different fingerprints");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// Regression test for the anthropic-compatible connection validator.
|
||||
//
|
||||
// Upstream fix: GET /models is not part of the Anthropic API spec; many
|
||||
// compatible proxies either 404, 401, or 403 on /models even with a valid
|
||||
// API key. The connection test must therefore not reject the credentials
|
||||
// solely on a 401/403 from /models — it must fall back to POST /v1/messages
|
||||
// (the canonical Anthropic auth probe) and treat any non-401/403 messages
|
||||
// response as proof that the key was accepted.
|
||||
//
|
||||
// Ported from decolua/9router 584cf66a (Co-author: Rehan Choirul).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test(
|
||||
"anthropic-compatible validation falls back to /messages when /models returns 403",
|
||||
async () => {
|
||||
const calls: { url: string; method: string }[] = [];
|
||||
globalThis.fetch = async (url: any, init: any = {}) => {
|
||||
const u = String(url);
|
||||
const method = String(init?.method || "GET").toUpperCase();
|
||||
calls.push({ url: u, method });
|
||||
if (u.endsWith("/models")) {
|
||||
return new Response(JSON.stringify({ error: "forbidden on models" }), { status: 403 });
|
||||
}
|
||||
// /messages: upstream accepts the key but rejects the toy payload with 400.
|
||||
return new Response(JSON.stringify({ error: "bad request" }), { status: 400 });
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "anthropic-compatible-403-on-models",
|
||||
apiKey: "sk-test",
|
||||
providerSpecificData: { baseUrl: "https://proxy.example.com/v1/messages" },
|
||||
});
|
||||
|
||||
// BEFORE the fix this returned { valid: false, error: "Invalid API key" }
|
||||
// because validateAnthropicCompatibleProvider short-circuited on the 403
|
||||
// from GET /models without ever probing POST /v1/messages.
|
||||
assert.equal(result.valid, true, "403 on /models alone must NOT mark the key invalid");
|
||||
assert.equal(result.error, null);
|
||||
|
||||
// The validator must actually exercise the messages endpoint.
|
||||
const messagesCall = calls.find(
|
||||
(call) => call.url.endsWith("/messages") && call.method === "POST"
|
||||
);
|
||||
assert.ok(messagesCall, "expected a POST /messages probe after /models 403");
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"anthropic-compatible validation still rejects when /messages itself returns 401",
|
||||
async () => {
|
||||
// Symmetry guard: the fix must NOT make every 403/401 pass. Only the
|
||||
// messages probe is authoritative — if it also rejects auth, the key is bad.
|
||||
globalThis.fetch = async (url: any) => {
|
||||
const u = String(url);
|
||||
if (u.endsWith("/models")) {
|
||||
return new Response(JSON.stringify({ error: "no models endpoint" }), { status: 403 });
|
||||
}
|
||||
return new Response(JSON.stringify({ error: "invalid_api_key" }), { status: 401 });
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "anthropic-compatible-truly-bad-key",
|
||||
apiKey: "sk-bad",
|
||||
providerSpecificData: { baseUrl: "https://proxy.example.com/v1/messages" },
|
||||
});
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(result.error, "Invalid API key");
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
// Regression test for #1475 — duplicate case-variant Anthropic headers.
|
||||
//
|
||||
// Node/undici's fetch lowercases and MERGES same-name headers, so an outbound
|
||||
// set carrying both "anthropic-version" and "Anthropic-Version" collapses into
|
||||
// a single "anthropic-version: 2023-06-01, 2023-06-01" value, which the
|
||||
// Anthropic API rejects. normalizeAnthropicHeaderVariants() reconciles the two
|
||||
// case variants down to one canonical lowercase header with a single value
|
||||
// (and a deduped, joined comma-list for anthropic-beta) before the request is
|
||||
// dispatched in BaseExecutor.buildHeaders / DefaultExecutor.buildHeaders.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { normalizeAnthropicHeaderVariants } from "../../open-sse/config/anthropicHeaders.ts";
|
||||
import { DefaultExecutor } from "../../open-sse/executors/default.ts";
|
||||
|
||||
test("normalizeAnthropicHeaderVariants collapses case-variant anthropic-version into one value", () => {
|
||||
const headers: Record<string, string> = {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"Anthropic-Version": "2023-06-01",
|
||||
};
|
||||
|
||||
normalizeAnthropicHeaderVariants(headers);
|
||||
|
||||
// The mixed-case variant must be gone, and the lowercase one must carry a
|
||||
// single value (no "v, v" duplication that Anthropic rejects).
|
||||
assert.equal(headers["Anthropic-Version"], undefined);
|
||||
assert.equal(headers["anthropic-version"], "2023-06-01");
|
||||
assert.ok(!headers["anthropic-version"].includes(","));
|
||||
});
|
||||
|
||||
test("normalizeAnthropicHeaderVariants dedupes and joins anthropic-beta variants", () => {
|
||||
const headers: Record<string, string> = {
|
||||
"anthropic-beta": "oauth-2025-04-20, prompt-caching",
|
||||
"Anthropic-Beta": "prompt-caching, fine-grained",
|
||||
};
|
||||
|
||||
normalizeAnthropicHeaderVariants(headers);
|
||||
|
||||
assert.equal(headers["Anthropic-Beta"], undefined);
|
||||
assert.equal(headers["anthropic-beta"], "oauth-2025-04-20,prompt-caching,fine-grained");
|
||||
});
|
||||
|
||||
test("normalizeAnthropicHeaderVariants leaves a single lowercase header untouched", () => {
|
||||
const headers: Record<string, string> = { "anthropic-version": "2023-06-01" };
|
||||
normalizeAnthropicHeaderVariants(headers);
|
||||
assert.equal(headers["anthropic-version"], "2023-06-01");
|
||||
assert.equal(headers["Anthropic-Version"], undefined);
|
||||
});
|
||||
|
||||
test("normalizeAnthropicHeaderVariants is a no-op when no anthropic headers are present", () => {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
normalizeAnthropicHeaderVariants(headers);
|
||||
assert.deepEqual(headers, { "Content-Type": "application/json" });
|
||||
});
|
||||
|
||||
test("DefaultExecutor.buildHeaders does not emit duplicate anthropic-version variants for an anthropic-compatible provider", () => {
|
||||
const executor = new DefaultExecutor("anthropic-compatible-test");
|
||||
const headers = executor.buildHeaders({ apiKey: "sk-test" }, true) as Record<string, string>;
|
||||
|
||||
// Whatever case the upstream registry/auth path used, only the canonical
|
||||
// lowercase header survives, with exactly one value.
|
||||
assert.equal(headers["Anthropic-Version"], undefined);
|
||||
if (headers["anthropic-version"] !== undefined) {
|
||||
assert.ok(!headers["anthropic-version"].includes(","));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { isOfficialAnthropicBaseUrl } from "../../open-sse/utils/anthropicHost.ts";
|
||||
|
||||
// CodeQL #674 (js/incomplete-url-substring-sanitization): the official-Anthropic check
|
||||
// must use exact hostname equality, not a substring `.includes("api.anthropic.com")`, so a
|
||||
// look-alike upstream cannot impersonate the official endpoint and suppress the Bearer
|
||||
// fallback intended for third-party gateways.
|
||||
|
||||
test("official endpoints are recognized (empty / scheme / scheme-less / path)", () => {
|
||||
assert.equal(isOfficialAnthropicBaseUrl(""), true, "empty baseUrl = default official");
|
||||
assert.equal(isOfficialAnthropicBaseUrl("https://api.anthropic.com"), true);
|
||||
assert.equal(isOfficialAnthropicBaseUrl("https://api.anthropic.com/v1"), true);
|
||||
assert.equal(isOfficialAnthropicBaseUrl("https://api.anthropic.com/"), true);
|
||||
// scheme-less host (operator may omit the protocol) is parsed with an assumed https://
|
||||
assert.equal(isOfficialAnthropicBaseUrl("api.anthropic.com"), true);
|
||||
assert.equal(isOfficialAnthropicBaseUrl("api.anthropic.com/v1"), true);
|
||||
});
|
||||
|
||||
test("look-alike / third-party hosts are NOT treated as official", () => {
|
||||
// The exact strings the old substring check would have wrongly accepted:
|
||||
assert.equal(isOfficialAnthropicBaseUrl("https://api.anthropic.com.evil.test"), false);
|
||||
assert.equal(isOfficialAnthropicBaseUrl("https://api.anthropic.com.evil.test/v1"), false);
|
||||
assert.equal(isOfficialAnthropicBaseUrl("https://evil.test/?x=api.anthropic.com"), false);
|
||||
assert.equal(isOfficialAnthropicBaseUrl("https://evil.test/api.anthropic.com"), false);
|
||||
assert.equal(isOfficialAnthropicBaseUrl("https://my-gateway.test/anthropic"), false);
|
||||
// a genuinely different host
|
||||
assert.equal(isOfficialAnthropicBaseUrl("https://openrouter.ai/api"), false);
|
||||
});
|
||||
|
||||
test("unparseable baseUrl falls back to third-party (Bearer emitted)", () => {
|
||||
assert.equal(isOfficialAnthropicBaseUrl("http://"), false);
|
||||
assert.equal(isOfficialAnthropicBaseUrl(":::"), false);
|
||||
});
|
||||
|
||||
test("source no longer uses substring .includes for the official-host check", () => {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const src = fs.readFileSync(
|
||||
path.join(here, "../../open-sse/executors/default.ts"),
|
||||
"utf8"
|
||||
);
|
||||
assert.equal(
|
||||
src.includes('.includes("api.anthropic.com")'),
|
||||
false,
|
||||
"default.ts must not substring-match the official Anthropic host (CodeQL #674)"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
// Regression test for the Anthropic-compatible stream "thinking block never closes" bug.
|
||||
//
|
||||
// When clients consume the OpenAI-compatible stream that OmniRoute synthesises from
|
||||
// Claude-native SSE, they need an explicit signal that the thinking/reasoning section
|
||||
// has ended; otherwise the UI stays stuck on the "thinking" indicator even after the
|
||||
// upstream stream has cleanly completed.
|
||||
//
|
||||
// Inspired by upstream decolua/9router PR #454.
|
||||
//
|
||||
// Before the fix, the `content_block_stop` event for a thinking block emitted NO
|
||||
// terminating chunk at all (a previous drift had emitted `reasoning_content: ""`,
|
||||
// which is semantically a no-op and does not signal "thinking complete" to clients
|
||||
// such as Claude Code).
|
||||
//
|
||||
// PR #4633 added an immediate `content: "</think>"` chunk on close.
|
||||
//
|
||||
// PR #5123 refined this to DEFERRED emission: instead of emitting at content_block_stop
|
||||
// (which caused the marker to leak before tool_calls in tool-use streams), the marker is
|
||||
// now queued and flushed either:
|
||||
// • at the first text_delta that follows (preserving #4633 for Claude Code / Cursor), or
|
||||
// • at message_delta finish when there are no tool_calls (pure thinking-only responses).
|
||||
// This test covers the message_delta flush path (thinking block with no subsequent text).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { claudeToOpenAIResponse } = await import(
|
||||
"../../open-sse/translator/response/claude-to-openai.ts"
|
||||
);
|
||||
|
||||
function newState() {
|
||||
return {
|
||||
toolCalls: new Map(),
|
||||
toolNameMap: new Map(),
|
||||
messageId: "msg_test",
|
||||
model: "claude-3-7-sonnet",
|
||||
toolCallIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
test("claudeToOpenAIResponse emits </think> close marker on message finish (deferred from content_block_stop)", () => {
|
||||
const state = newState();
|
||||
|
||||
// Open thinking block.
|
||||
claudeToOpenAIResponse(
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "thinking", thinking: "" },
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
// Stream reasoning delta.
|
||||
claudeToOpenAIResponse(
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "thinking_delta", thinking: "Plan first." },
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
// Close the thinking block — marker is now DEFERRED (not emitted here).
|
||||
const closeChunks = claudeToOpenAIResponse(
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
state
|
||||
);
|
||||
// content_block_stop for a thinking block no longer emits </think> immediately
|
||||
// (the marker is deferred to prevent leaking before tool_calls — see #5123).
|
||||
const immediateClose = Array.isArray(closeChunks) ? closeChunks : [];
|
||||
const hasImmediateMarker = immediateClose.some(
|
||||
(chunk) => chunk?.choices?.[0]?.delta?.content === "</think>"
|
||||
);
|
||||
assert.equal(
|
||||
hasImmediateMarker,
|
||||
false,
|
||||
"content_block_stop must NOT emit </think> immediately (deferred — see #5123)"
|
||||
);
|
||||
|
||||
// After close, the thinking-block flag is cleared and the pending marker is queued.
|
||||
assert.equal(state.inThinkingBlock, false);
|
||||
assert.equal(state.pendingThinkClose, true, "pendingThinkClose must be set after thinking block stop");
|
||||
|
||||
// message_delta with stop_reason=end_turn and no tool_calls → marker must be flushed here.
|
||||
const finishChunks = claudeToOpenAIResponse(
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 5 } },
|
||||
state
|
||||
);
|
||||
|
||||
const arr = Array.isArray(finishChunks) ? finishChunks : [];
|
||||
const hasCloseMarker = arr.some(
|
||||
(chunk) => chunk?.choices?.[0]?.delta?.content === "</think>"
|
||||
);
|
||||
assert.ok(
|
||||
hasCloseMarker,
|
||||
`expected a chunk with delta.content === "</think>" in message_delta result; got ${JSON.stringify(arr)}`
|
||||
);
|
||||
|
||||
// pendingThinkClose must be cleared after flush.
|
||||
assert.equal(state.pendingThinkClose, false, "pendingThinkClose must be cleared after flush");
|
||||
});
|
||||
|
||||
test("claudeToOpenAIResponse does not emit </think> on stop of non-thinking blocks", () => {
|
||||
const state = newState();
|
||||
|
||||
// Open + immediately close a text block — must NOT inject </think>.
|
||||
claudeToOpenAIResponse(
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: "" },
|
||||
},
|
||||
state
|
||||
);
|
||||
const closeChunks = claudeToOpenAIResponse(
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
state
|
||||
);
|
||||
|
||||
const arr = Array.isArray(closeChunks) ? closeChunks : [];
|
||||
const hasCloseMarker = arr.some(
|
||||
(chunk) => chunk?.choices?.[0]?.delta?.content === "</think>"
|
||||
);
|
||||
assert.equal(
|
||||
hasCloseMarker,
|
||||
false,
|
||||
"text-block close must not emit </think> sentinel"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* TDD regression tests for #3707:
|
||||
* 1. `decide429("quota_exhausted")` → `full_quota_exhausted` verdict (engine contract)
|
||||
* 2. `markConnectionQuotaExhausted` persists the 24h cooldown in the DB so that
|
||||
* cross-request and post-restart routing skips exhausted connections.
|
||||
*
|
||||
* Bug: before the fix the executor never called `setConnectionRateLimitUntil`,
|
||||
* so `isConnectionRateLimited` always returned false for AG connections that
|
||||
* had their daily quota exhausted — learned state was lost on restart.
|
||||
*/
|
||||
|
||||
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-ag-quota-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
|
||||
import {
|
||||
classify429,
|
||||
decide429,
|
||||
FULL_QUOTA_COOLDOWN_MS,
|
||||
} from "../../open-sse/services/antigravity429Engine.ts";
|
||||
import { markConnectionQuotaExhausted } from "../../open-sse/executors/antigravity.ts";
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── Engine contract (regression guard) ───────────────────────────────────────
|
||||
|
||||
test("decide429: quota_exhausted category → full_quota_exhausted kind with 24h cooldown", () => {
|
||||
const decision = decide429("quota_exhausted", null);
|
||||
assert.equal(decision.kind, "full_quota_exhausted");
|
||||
assert.equal(decision.retryAfterMs, FULL_QUOTA_COOLDOWN_MS);
|
||||
assert.equal(FULL_QUOTA_COOLDOWN_MS, 24 * 60 * 60 * 1000, "cooldown must be 24h");
|
||||
});
|
||||
|
||||
test("decide429: quota_exhausted with explicit retryAfterMs preserves the provided value", () => {
|
||||
const twoDaysMs = 2 * 24 * 60 * 60 * 1000;
|
||||
const decision = decide429("quota_exhausted", twoDaysMs);
|
||||
assert.equal(decision.kind, "full_quota_exhausted");
|
||||
assert.equal(decision.retryAfterMs, twoDaysMs);
|
||||
});
|
||||
|
||||
test("classify429: AG 'Individual quota reached' message → quota_exhausted", () => {
|
||||
const msg =
|
||||
"Individual quota reached. Contact your administrator to enable overages. Resets in 14h22m.";
|
||||
assert.equal(classify429(msg), "quota_exhausted");
|
||||
});
|
||||
|
||||
test("classify429: AG G1 Credits Exhausted message → quota_exhausted", () => {
|
||||
assert.equal(classify429("insufficient_g1_credits_balance"), "quota_exhausted");
|
||||
});
|
||||
|
||||
test("classify429: standard Gemini rate limit 'resource has been exhausted' -> rate_limited or unknown, not quota_exhausted", () => {
|
||||
const msg =
|
||||
"RESOURCE_EXHAUSTED: Resource has been exhausted (e.g. queries per minute limit was reached).";
|
||||
const result = classify429(msg);
|
||||
assert.notEqual(
|
||||
result,
|
||||
"quota_exhausted",
|
||||
"RESOURCE_EXHAUSTED rate limit should not be classified as quota_exhausted"
|
||||
);
|
||||
});
|
||||
|
||||
// ── DB persistence (the missing wire — Bug #2) ───────────────────────────────
|
||||
|
||||
test("markConnectionQuotaExhausted persists 24h cooldown; isConnectionRateLimited returns true", async () => {
|
||||
const conn = await providersDb.createProviderConnection({
|
||||
provider: "antigravity",
|
||||
authType: "oauth",
|
||||
name: "AG Test Quota",
|
||||
});
|
||||
const connId = (conn as any).id;
|
||||
|
||||
assert.equal(
|
||||
providersDb.isConnectionRateLimited(connId),
|
||||
false,
|
||||
"should start as not rate-limited"
|
||||
);
|
||||
|
||||
markConnectionQuotaExhausted(connId, FULL_QUOTA_COOLDOWN_MS);
|
||||
|
||||
assert.equal(
|
||||
providersDb.isConnectionRateLimited(connId),
|
||||
true,
|
||||
"should be rate-limited after marking quota exhausted"
|
||||
);
|
||||
});
|
||||
|
||||
test("markConnectionQuotaExhausted: expired cooldown does not block the connection", async () => {
|
||||
const conn = await providersDb.createProviderConnection({
|
||||
provider: "antigravity",
|
||||
authType: "oauth",
|
||||
name: "AG Test Expired",
|
||||
});
|
||||
const connId = (conn as any).id;
|
||||
|
||||
// Set cooldown in the past — simulates expired cooldown
|
||||
providersDb.setConnectionRateLimitUntil(connId, Date.now() - 1);
|
||||
assert.equal(
|
||||
providersDb.isConnectionRateLimited(connId),
|
||||
false,
|
||||
"expired cooldown should not block"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { classify429 as classify429AG } from "../../open-sse/services/antigravity429Engine.ts";
|
||||
import { classify429 as classify429Shared } from "../../src/shared/utils/classify429.ts";
|
||||
import {
|
||||
parseRetryFromErrorText,
|
||||
checkFallbackError,
|
||||
} from "../../open-sse/services/accountFallback.ts";
|
||||
|
||||
test("TDD S1: classify429 (Antigravity engine) detects INSUFFICIENT_G1_CREDITS_BALANCE", () => {
|
||||
const msg = JSON.stringify({
|
||||
error: {
|
||||
code: 429,
|
||||
message: "Resource has been exhausted (e.g. check quota).",
|
||||
status: "RESOURCE_EXHAUSTED",
|
||||
details: [
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
||||
reason: "INSUFFICIENT_G1_CREDITS_BALANCE",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const category = classify429AG(msg);
|
||||
assert.equal(category, "quota_exhausted");
|
||||
});
|
||||
|
||||
test("TDD S1: classify429 (Shared utility) detects INSUFFICIENT_G1_CREDITS_BALANCE", () => {
|
||||
const body = {
|
||||
error: {
|
||||
code: 429,
|
||||
message: "Resource has been exhausted (e.g. check quota).",
|
||||
status: "RESOURCE_EXHAUSTED",
|
||||
details: [
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
||||
reason: "INSUFFICIENT_G1_CREDITS_BALANCE",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const kind = classify429Shared({ status: 429, body });
|
||||
assert.equal(kind, "quota_exhausted");
|
||||
});
|
||||
|
||||
test("TDD S2: Regression: standard Gemini rate limit 'queries per minute limit was reached' -> rate_limit (shared) and rate_limited (AG)", () => {
|
||||
const msg =
|
||||
"RESOURCE_EXHAUSTED: Resource has been exhausted (e.g. queries per minute limit was reached).";
|
||||
assert.notEqual(classify429AG(msg), "quota_exhausted");
|
||||
assert.equal(classify429Shared({ status: 429, body: msg }), "rate_limit");
|
||||
});
|
||||
|
||||
test("TDD S3: parseRetryFromErrorText parses resets in 5h and resets in 164h27m24s", () => {
|
||||
// Antigravity returns: "Individual quota reached. Contact your administrator to enable overages. Resets in 5h."
|
||||
const msg5h =
|
||||
"Individual quota reached. Contact your administrator to enable overages. Resets in 5h.";
|
||||
const msgWeekly =
|
||||
"Individual quota reached. Contact your administrator to enable overages. Resets in 164h27m24s.";
|
||||
|
||||
const val5h = parseRetryFromErrorText(msg5h);
|
||||
assert.equal(val5h, 5 * 3600 * 1000);
|
||||
|
||||
const valWeekly = parseRetryFromErrorText(msgWeekly);
|
||||
assert.equal(valWeekly, 164 * 3600 * 1000 + 27 * 60 * 1000 + 24 * 1000);
|
||||
});
|
||||
|
||||
test("TDD S3: checkFallbackError extracts retry hint for oauth providers even if useUpstreamRetryHints is false", () => {
|
||||
const errorText =
|
||||
"Individual quota reached. Contact your administrator to enable overages. Resets in 5h.";
|
||||
const res = checkFallbackError(
|
||||
429,
|
||||
errorText,
|
||||
0,
|
||||
"gemini-3.5-flash",
|
||||
"antigravity", // which uses oauth provider profile (useUpstreamRetryHints: false)
|
||||
null
|
||||
);
|
||||
|
||||
assert.equal(res.shouldFallback, true);
|
||||
assert.equal(res.usedUpstreamRetryHint, false);
|
||||
// Connection cooldown should be the default/scaled backoff cooldown (e.g. ~5000 ms) because useUpstreamRetryHints is false
|
||||
assert.notEqual(res.cooldownMs, 5 * 3600 * 1000);
|
||||
// But quotaResetHintMs MUST be the precise parsed reset time (5h = 18,000,000 ms)
|
||||
assert.equal(res.quotaResetHintMs, 5 * 3600 * 1000);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
AntigravityExecutor,
|
||||
__test_stripTrailingAntigravityAssistantTurn,
|
||||
} from "../../open-sse/executors/antigravity.ts";
|
||||
|
||||
/**
|
||||
* Ports decolua/9router#2321 (anki1kr): Vertex AI (used by Antigravity for
|
||||
* Claude-branded models) rejects a conversation ending on an assistant turn —
|
||||
* "This model does not support assistant message prefill" — so the request must
|
||||
* always end on a user turn.
|
||||
*
|
||||
* Upstream's diff patched `openaiToClaudeRequestForAntigravity` in
|
||||
* `open-sse/translator/request/openai-to-claude.ts`, which has ZERO callers in
|
||||
* OmniRoute (dead code). The live Antigravity Claude dispatch path converts to
|
||||
* Gemini `contents` (`role: "user"/"model"`) in `AntigravityExecutor.transformRequest`
|
||||
* via `sanitizeAntigravityGeminiRequest` — this test drives THAT function end-to-end.
|
||||
*/
|
||||
|
||||
async function transform(model: string, contents: Array<Record<string, unknown>>) {
|
||||
const executor = new AntigravityExecutor();
|
||||
const body = {
|
||||
request: {
|
||||
contents,
|
||||
generationConfig: {},
|
||||
},
|
||||
};
|
||||
const result = await executor.transformRequest(model, body, true, {
|
||||
projectId: "project-1",
|
||||
});
|
||||
if (result instanceof Response) throw new Error("Unexpected Response from transformRequest");
|
||||
return (result as Record<string, unknown>).request as Record<string, unknown>;
|
||||
}
|
||||
|
||||
test("(a) strips a single trailing assistant (model) turn for Claude models", async () => {
|
||||
const request = await transform("antigravity/claude-opus-4-8", [
|
||||
{ role: "user", parts: [{ text: "Hello" }] },
|
||||
{ role: "model", parts: [{ text: "Hi there" }] }, // prefill to strip
|
||||
]);
|
||||
const contents = request.contents as Array<{ role: string }>;
|
||||
assert.equal(contents.length, 1);
|
||||
assert.equal(contents.at(-1)?.role, "user");
|
||||
});
|
||||
|
||||
test("(b) does NOT strip a trailing model turn for non-Claude (native Gemini) models", async () => {
|
||||
const request = await transform("antigravity/gemini-3.1-pro", [
|
||||
{ role: "user", parts: [{ text: "Hello" }] },
|
||||
{ role: "model", parts: [{ text: "Hi there" }] },
|
||||
]);
|
||||
const contents = request.contents as Array<{ role: string }>;
|
||||
assert.equal(contents.length, 2);
|
||||
assert.equal(contents.at(-1)?.role, "model", "native Gemini requests via Antigravity are untouched");
|
||||
});
|
||||
|
||||
test("(c) a Claude conversation already ending on user is unchanged", async () => {
|
||||
const request = await transform("antigravity/claude-opus-4-8", [
|
||||
{ role: "user", parts: [{ text: "Hello" }] },
|
||||
{ role: "model", parts: [{ text: "Hi" }] },
|
||||
{ role: "user", parts: [{ text: "What is 2+2?" }] },
|
||||
]);
|
||||
const contents = request.contents as Array<{ role: string }>;
|
||||
assert.equal(contents.length, 3);
|
||||
assert.equal(contents.at(-1)?.role, "user");
|
||||
});
|
||||
|
||||
test("(d) multiple trailing model turns are all stripped", () => {
|
||||
// Under normal executor flow, adjacent same-role turns are merged before this
|
||||
// helper runs — this directly exercises the helper's robustness for an input
|
||||
// that (defensively) still carries multiple consecutive trailing "model" turns.
|
||||
const request = __test_stripTrailingAntigravityAssistantTurn({
|
||||
contents: [
|
||||
{ role: "user", parts: [{ text: "Hello" }] },
|
||||
{ role: "model", parts: [{ text: "A" }] },
|
||||
{ role: "model", parts: [{ text: "B" }] },
|
||||
],
|
||||
});
|
||||
const contents = request.contents as Array<{ role: string }>;
|
||||
assert.equal(contents.length, 1);
|
||||
assert.equal(contents.at(-1)?.role, "user");
|
||||
});
|
||||
|
||||
test("(e) never strips contents down to empty", () => {
|
||||
const request = __test_stripTrailingAntigravityAssistantTurn({
|
||||
contents: [{ role: "model", parts: [{ text: "solo prefill" }] }],
|
||||
});
|
||||
const contents = request.contents as Array<{ role: string }>;
|
||||
// A lone trailing "model" turn is preserved rather than emptying `contents`
|
||||
// (an empty contents array is itself an invalid upstream request).
|
||||
assert.equal(contents.length, 1);
|
||||
assert.equal(contents[0].role, "model");
|
||||
});
|
||||
|
||||
test("empty/missing contents does not throw", () => {
|
||||
const request = __test_stripTrailingAntigravityAssistantTurn({ contents: [] });
|
||||
assert.deepEqual(request.contents, []);
|
||||
|
||||
const request2 = __test_stripTrailingAntigravityAssistantTurn({});
|
||||
assert.equal(request2.contents, undefined);
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import { normalizeAntigravityClientProfile } from "../../src/shared/constants/antigravityClientProfile.ts";
|
||||
import {
|
||||
applyAntigravityClientProfileHeaders,
|
||||
getAntigravityBootstrapHeaders,
|
||||
antigravityHarnessUserAgent,
|
||||
getAntigravityClientProfile,
|
||||
} from "../../open-sse/services/antigravityClientProfile.ts";
|
||||
import { antigravityUserAgent } from "../../open-sse/services/antigravityHeaders.ts";
|
||||
import { deriveAntigravityMachineId } from "../../open-sse/services/antigravityIdentity.ts";
|
||||
import {
|
||||
seedAntigravityVersionCache,
|
||||
clearAntigravityVersionCache,
|
||||
} from "../../open-sse/services/antigravityVersion.ts";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { machineIdSync } = require("node-machine-id") as {
|
||||
machineIdSync: (original?: boolean) => string;
|
||||
};
|
||||
|
||||
test.afterEach(() => {
|
||||
clearAntigravityVersionCache();
|
||||
});
|
||||
|
||||
test("normalizeAntigravityClientProfile maps cli/sdk aliases to harness", () => {
|
||||
assert.equal(normalizeAntigravityClientProfile("cli"), "harness");
|
||||
assert.equal(normalizeAntigravityClientProfile("SDK"), "harness");
|
||||
assert.equal(normalizeAntigravityClientProfile("ide"), "ide");
|
||||
assert.equal(normalizeAntigravityClientProfile(undefined), "ide");
|
||||
});
|
||||
|
||||
test("getAntigravityClientProfile reads providerSpecificData.clientProfile per connection", () => {
|
||||
assert.equal(
|
||||
getAntigravityClientProfile({
|
||||
providerSpecificData: { clientProfile: "harness" },
|
||||
}),
|
||||
"harness"
|
||||
);
|
||||
assert.equal(getAntigravityClientProfile({ providerSpecificData: {} }), "ide");
|
||||
});
|
||||
|
||||
test("applyAntigravityClientProfileHeaders uses IDE fingerprint by default", () => {
|
||||
seedAntigravityVersionCache("4.2.0");
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: "Bearer token",
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
applyAntigravityClientProfileHeaders(
|
||||
headers,
|
||||
{ connectionId: "conn-1", providerSpecificData: {} },
|
||||
{ project: "project-1" }
|
||||
);
|
||||
|
||||
assert.match(headers["User-Agent"], /^Antigravity\/4\.2\.0 /);
|
||||
assert.equal(headers["x-client-name"], "antigravity");
|
||||
assert.equal(headers["x-client-version"], "4.2.0");
|
||||
assert.equal(typeof headers["x-machine-id"], "string");
|
||||
assert.equal(typeof headers["x-vscode-sessionid"], "string");
|
||||
assert.equal(headers["x-goog-user-project"], "project-1");
|
||||
assert.equal(headers["X-Goog-Api-Client"], undefined);
|
||||
});
|
||||
|
||||
test("antigravityUserAgent matches Antigravity Manager platform fingerprints", () => {
|
||||
assert.equal(
|
||||
antigravityUserAgent("4.2.0", "darwin"),
|
||||
"Antigravity/4.2.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/142.0.7444.175 Electron/39.2.3"
|
||||
);
|
||||
assert.equal(
|
||||
antigravityUserAgent("4.2.0", "win32"),
|
||||
"Antigravity/4.2.0 (Windows NT 10.0; Win64; x64) Chrome/142.0.7444.175 Electron/39.2.3"
|
||||
);
|
||||
assert.equal(
|
||||
antigravityUserAgent("4.2.0", "linux"),
|
||||
"Antigravity/4.2.0 (X11; Linux x86_64) Chrome/142.0.7444.175 Electron/39.2.3"
|
||||
);
|
||||
});
|
||||
|
||||
test("deriveAntigravityMachineId uses the raw system machine id like Antigravity Manager", () => {
|
||||
let systemMachineId: string | null = null;
|
||||
try {
|
||||
systemMachineId = machineIdSync(true);
|
||||
} catch {
|
||||
systemMachineId = null;
|
||||
}
|
||||
if (!systemMachineId) return;
|
||||
|
||||
assert.equal(deriveAntigravityMachineId(), systemMachineId);
|
||||
});
|
||||
|
||||
test("antigravityHarnessUserAgent uses Go harness platform and arch names", () => {
|
||||
assert.equal(
|
||||
antigravityHarnessUserAgent("2.1.1", "darwin", "arm64"),
|
||||
"antigravity/2.1.1 darwin/arm64"
|
||||
);
|
||||
assert.equal(
|
||||
antigravityHarnessUserAgent("2.1.1", "win32", "x64"),
|
||||
"antigravity/2.1.1 windows/amd64"
|
||||
);
|
||||
assert.equal(
|
||||
antigravityHarnessUserAgent("2.1.1", "linux", "x64"),
|
||||
"antigravity/2.1.1 linux/amd64"
|
||||
);
|
||||
});
|
||||
|
||||
test("getAntigravityBootstrapHeaders uses SDK loadCodeAssist harness fingerprint", () => {
|
||||
seedAntigravityVersionCache("2.1.1");
|
||||
|
||||
const headers = getAntigravityBootstrapHeaders("harness", "token");
|
||||
|
||||
assert.equal(headers.Authorization, "Bearer token");
|
||||
assert.equal(
|
||||
headers["User-Agent"],
|
||||
`${antigravityHarnessUserAgent("2.1.1")} google-api-nodejs-client/10.3.0`
|
||||
);
|
||||
assert.equal(headers["X-Goog-Api-Client"], "gl-node/22.21.1");
|
||||
assert.equal(headers["Client-Metadata"], undefined);
|
||||
});
|
||||
|
||||
test("applyAntigravityClientProfileHeaders uses harness fingerprint when configured", () => {
|
||||
seedAntigravityVersionCache("4.2.0");
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: "Bearer token",
|
||||
"Content-Type": "application/json",
|
||||
"x-client-name": "antigravity",
|
||||
"x-vscode-sessionid": "old-session",
|
||||
};
|
||||
|
||||
applyAntigravityClientProfileHeaders(
|
||||
headers,
|
||||
{ connectionId: "conn-2", providerSpecificData: { clientProfile: "harness" } },
|
||||
{ project: "project-2" }
|
||||
);
|
||||
|
||||
assert.equal(headers["User-Agent"], antigravityHarnessUserAgent("4.2.0"));
|
||||
assert.equal(headers["X-Goog-Api-Client"], undefined);
|
||||
assert.equal(headers["x-client-name"], undefined);
|
||||
assert.equal(headers["x-vscode-sessionid"], undefined);
|
||||
assert.equal(headers["x-goog-user-project"], "project-2");
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Tests for antigravity.ts — AI Credits overages fallback.
|
||||
*
|
||||
* Verifies:
|
||||
* 1. Credit balance cache read/write (getAntigravityRemainingCredits / updateAntigravityRemainingCredits)
|
||||
* 2. SSE remainingCredits extraction logic from collectStreamToResponse
|
||||
* 3. accountId consistency: executor and fetcher must use the same key (email || sub)
|
||||
* 4. Balance updates are correctly reflected in subsequent reads
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// ── Import credit cache helpers ───────────────────────────────────────────────
|
||||
import {
|
||||
getAntigravityRemainingCredits,
|
||||
updateAntigravityRemainingCredits,
|
||||
} from "../../open-sse/executors/antigravity.ts";
|
||||
|
||||
// ── Credit balance cache tests ────────────────────────────────────────────────
|
||||
|
||||
describe("getAntigravityRemainingCredits / updateAntigravityRemainingCredits", () => {
|
||||
it("returns null for an account with no cached balance", () => {
|
||||
const accountId = `test-unknown-${Date.now()}`;
|
||||
assert.equal(
|
||||
getAntigravityRemainingCredits(accountId),
|
||||
null,
|
||||
"should return null before any update"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the balance after updateAntigravityRemainingCredits", () => {
|
||||
const accountId = `test-write-${Date.now()}`;
|
||||
updateAntigravityRemainingCredits(accountId, 42);
|
||||
assert.equal(getAntigravityRemainingCredits(accountId), 42, "stored balance should be 42");
|
||||
});
|
||||
|
||||
it("overwrites a previous balance with a new value", () => {
|
||||
const accountId = `test-overwrite-${Date.now()}`;
|
||||
updateAntigravityRemainingCredits(accountId, 100);
|
||||
updateAntigravityRemainingCredits(accountId, 55);
|
||||
assert.equal(getAntigravityRemainingCredits(accountId), 55, "should reflect the latest update");
|
||||
});
|
||||
|
||||
it("stores balance=0 correctly (not treated as null/falsy)", () => {
|
||||
const accountId = `test-zero-${Date.now()}`;
|
||||
updateAntigravityRemainingCredits(accountId, 0);
|
||||
assert.equal(
|
||||
getAntigravityRemainingCredits(accountId),
|
||||
0,
|
||||
"balance 0 must be stored and returned as 0"
|
||||
);
|
||||
});
|
||||
|
||||
it("different accountIds do not interfere with each other", () => {
|
||||
const idA = `test-a-${Date.now()}`;
|
||||
const idB = `test-b-${Date.now()}`;
|
||||
updateAntigravityRemainingCredits(idA, 200);
|
||||
updateAntigravityRemainingCredits(idB, 999);
|
||||
assert.equal(getAntigravityRemainingCredits(idA), 200);
|
||||
assert.equal(getAntigravityRemainingCredits(idB), 999);
|
||||
});
|
||||
});
|
||||
|
||||
// ── accountId key consistency ─────────────────────────────────────────────────
|
||||
|
||||
describe("accountId key consistency: executor vs fetcher derivation", () => {
|
||||
it("both executor and fetcher use email → sub → 'unknown' order", () => {
|
||||
// Enforces the contract between executor (write) and fetcher (read) for creditBalanceCache.
|
||||
const credentials = { email: "user@example.com", sub: "abc123" };
|
||||
const providerSpecificData = { email: "user@example.com", sub: "abc123" };
|
||||
|
||||
// executor derivation
|
||||
const executorAccountId = credentials.email || credentials.sub || "unknown";
|
||||
// fetcher derivation
|
||||
const fetcherAccountId = providerSpecificData.email || providerSpecificData.sub || "unknown";
|
||||
|
||||
assert.equal(
|
||||
executorAccountId,
|
||||
fetcherAccountId,
|
||||
"accountId must match between executor and fetcher"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to sub when email is absent — both paths agree", () => {
|
||||
const credentials = { sub: "sub-only-123" };
|
||||
const providerSpecificData = { sub: "sub-only-123" };
|
||||
|
||||
const executorAccountId = credentials.email || credentials.sub || "unknown";
|
||||
const fetcherAccountId = providerSpecificData.email || providerSpecificData.sub || "unknown";
|
||||
|
||||
assert.equal(executorAccountId, "sub-only-123");
|
||||
assert.equal(fetcherAccountId, "sub-only-123");
|
||||
assert.equal(executorAccountId, fetcherAccountId);
|
||||
});
|
||||
|
||||
it("both paths return 'unknown' when email and sub are absent", () => {
|
||||
const credentials = {};
|
||||
const providerSpecificData = {};
|
||||
|
||||
const executorAccountId = credentials.email || credentials.sub || "unknown";
|
||||
const fetcherAccountId = providerSpecificData.email || providerSpecificData.sub || "unknown";
|
||||
|
||||
assert.equal(executorAccountId, "unknown");
|
||||
assert.equal(fetcherAccountId, "unknown");
|
||||
});
|
||||
});
|
||||
|
||||
// ── SSE remainingCredits extraction logic ─────────────────────────────────────
|
||||
|
||||
describe("SSE remainingCredits extraction logic", () => {
|
||||
it("parses GOOGLE_ONE_AI credit amount from remainingCredits array", () => {
|
||||
const remainingCredits = [
|
||||
{ creditType: "GOOGLE_ONE_AI", creditAmount: "123" },
|
||||
{ creditType: "SOME_OTHER", creditAmount: "999" },
|
||||
];
|
||||
|
||||
const googleCredit = remainingCredits.find((c) => c.creditType === "GOOGLE_ONE_AI");
|
||||
assert.ok(googleCredit, "GOOGLE_ONE_AI entry must be found");
|
||||
|
||||
const balance = parseInt(googleCredit.creditAmount, 10);
|
||||
assert.equal(balance, 123, "credit balance must be parsed correctly");
|
||||
});
|
||||
|
||||
it("handles missing GOOGLE_ONE_AI gracefully — no crash", () => {
|
||||
const remainingCredits = [{ creditType: "SOME_OTHER", creditAmount: "999" }];
|
||||
|
||||
const googleCredit = remainingCredits.find((c) => c.creditType === "GOOGLE_ONE_AI");
|
||||
assert.equal(googleCredit, undefined, "should not find GOOGLE_ONE_AI if not present");
|
||||
});
|
||||
|
||||
it("handles malformed creditAmount gracefully — NaN is not stored", () => {
|
||||
const remainingCredits = [{ creditType: "GOOGLE_ONE_AI", creditAmount: "not-a-number" }];
|
||||
|
||||
const googleCredit = remainingCredits.find((c) => c.creditType === "GOOGLE_ONE_AI");
|
||||
const balance = parseInt(googleCredit.creditAmount, 10);
|
||||
assert.ok(isNaN(balance), "NaN guard prevents invalid balance storage");
|
||||
});
|
||||
|
||||
it("balance update is correctly reflected in the cache after a successful parse", () => {
|
||||
const accountId = `test-sse-${Date.now()}`;
|
||||
const remainingCredits = [{ creditType: "GOOGLE_ONE_AI", creditAmount: "77" }];
|
||||
|
||||
const googleCredit = remainingCredits.find((c) => c.creditType === "GOOGLE_ONE_AI");
|
||||
if (googleCredit) {
|
||||
const balance = parseInt(googleCredit.creditAmount, 10);
|
||||
if (!isNaN(balance)) {
|
||||
updateAntigravityRemainingCredits(accountId, balance);
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(getAntigravityRemainingCredits(accountId), 77);
|
||||
});
|
||||
|
||||
it("skips cache update when creditAmount is NaN — balance remains null", () => {
|
||||
const accountId = `test-nan-guard-${Date.now()}`;
|
||||
const remainingCredits = [{ creditType: "GOOGLE_ONE_AI", creditAmount: "bad" }];
|
||||
|
||||
const googleCredit = remainingCredits.find((c) => c.creditType === "GOOGLE_ONE_AI");
|
||||
if (googleCredit) {
|
||||
const balance = parseInt(googleCredit.creditAmount, 10);
|
||||
if (!isNaN(balance)) {
|
||||
updateAntigravityRemainingCredits(accountId, balance);
|
||||
}
|
||||
// NaN — no update should happen
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
getAntigravityRemainingCredits(accountId),
|
||||
null,
|
||||
"balance should remain null when parsing yields NaN"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Tests: antigravity loadCodeAssist bootstrap before :models discovery.
|
||||
*
|
||||
* The Google Cloud Code Assist /v1internal:models endpoint requires a prior
|
||||
* /v1internal:loadCodeAssist call to assign a project context to the OAuth
|
||||
* token. Without this bootstrap, :models returns 404 for all three base URLs.
|
||||
*
|
||||
* These tests verify:
|
||||
* 1. ensureAntigravityProjectAssigned calls loadCodeAssist before returning.
|
||||
* 2. The call is memoized — repeated calls for the same token do not re-hit
|
||||
* the network.
|
||||
* 3. Non-fatal: if loadCodeAssist fails, the function resolves without throwing.
|
||||
* 4. The loadCodeAssist request uses the correct headers (Authorization, User-Agent).
|
||||
* 5. Ordering guarantee — in a full discovery flow, loadCodeAssist is called
|
||||
* BEFORE any :models request.
|
||||
*/
|
||||
|
||||
import { test, describe, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
ensureAntigravityProjectAssigned,
|
||||
clearAntigravityProjectCache,
|
||||
getAntigravityProjectFromCache,
|
||||
getAntigravityLoadCodeAssistUrls,
|
||||
} from "../../open-sse/services/antigravityProjectBootstrap.ts";
|
||||
|
||||
// Reset the module-level memoization cache between tests.
|
||||
beforeEach(() => {
|
||||
clearAntigravityProjectCache();
|
||||
});
|
||||
|
||||
describe("ensureAntigravityProjectAssigned", () => {
|
||||
test("calls loadCodeAssist and caches the returned project id", async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const mockFetch = async (url: string, _init?: RequestInit): Promise<Response> => {
|
||||
calls.push(url);
|
||||
if (url.endsWith(":loadCodeAssist")) {
|
||||
return new Response(JSON.stringify({ cloudaicompanionProject: "proj-from-bootstrap" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response("Not Found", { status: 404 });
|
||||
};
|
||||
|
||||
const projectId = await ensureAntigravityProjectAssigned("fake-token-1", mockFetch);
|
||||
|
||||
const loadCalls = calls.filter((u) => u.endsWith(":loadCodeAssist"));
|
||||
assert.ok(loadCalls.length >= 1, ":loadCodeAssist must be called at least once");
|
||||
assert.equal(projectId, "proj-from-bootstrap", "project id must be returned");
|
||||
assert.equal(
|
||||
getAntigravityProjectFromCache("fake-token-1"),
|
||||
"proj-from-bootstrap",
|
||||
"project id must be memoized after first call"
|
||||
);
|
||||
});
|
||||
|
||||
test("subsequent calls for the same token skip the network", async () => {
|
||||
let networkCalls = 0;
|
||||
|
||||
const mockFetch = async (url: string, _init?: RequestInit): Promise<Response> => {
|
||||
networkCalls += 1;
|
||||
return new Response(JSON.stringify({ cloudaicompanionProject: "proj-cached" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
await ensureAntigravityProjectAssigned("fake-token-2", mockFetch);
|
||||
await ensureAntigravityProjectAssigned("fake-token-2", mockFetch);
|
||||
await ensureAntigravityProjectAssigned("fake-token-2", mockFetch);
|
||||
|
||||
assert.equal(networkCalls, 1, "network must be called exactly once for the same token");
|
||||
});
|
||||
|
||||
test("different tokens each trigger their own loadCodeAssist call", async () => {
|
||||
const calledFor: string[] = [];
|
||||
|
||||
const mockFetch = async (url: string, init?: RequestInit): Promise<Response> => {
|
||||
const auth = new Headers(init?.headers).get("Authorization") ?? "";
|
||||
calledFor.push(auth);
|
||||
return new Response(JSON.stringify({ cloudaicompanionProject: "proj-x" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
await ensureAntigravityProjectAssigned("token-A", mockFetch);
|
||||
await ensureAntigravityProjectAssigned("token-B", mockFetch);
|
||||
|
||||
assert.equal(calledFor.length, 2, "each unique token should trigger one network call");
|
||||
});
|
||||
|
||||
test("does not throw when loadCodeAssist returns non-200", async () => {
|
||||
const mockFetch = async (_url: string, _init?: RequestInit): Promise<Response> => {
|
||||
return new Response("Service Unavailable", { status: 503 });
|
||||
};
|
||||
|
||||
// Must resolve without throwing even if all endpoints fail.
|
||||
await assert.doesNotReject(ensureAntigravityProjectAssigned("fail-token", mockFetch));
|
||||
});
|
||||
|
||||
test("does not throw when fetch rejects (network error)", async () => {
|
||||
const mockFetch = async (_url: string, _init?: RequestInit): Promise<Response> => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
};
|
||||
|
||||
await assert.doesNotReject(ensureAntigravityProjectAssigned("throw-token", mockFetch));
|
||||
});
|
||||
|
||||
test("sets Authorization header with Bearer token", async () => {
|
||||
let capturedAuth: string | null = null;
|
||||
|
||||
const mockFetch = async (_url: string, init?: RequestInit): Promise<Response> => {
|
||||
capturedAuth = new Headers(init?.headers).get("Authorization") ?? null;
|
||||
return new Response(JSON.stringify({ cloudaicompanionProject: "proj-auth-check" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
await ensureAntigravityProjectAssigned("my-secret-token", mockFetch);
|
||||
|
||||
assert.equal(capturedAuth, "Bearer my-secret-token", "Authorization header must be set");
|
||||
});
|
||||
|
||||
test("uses CLI/SDK harness headers when requested", async () => {
|
||||
let capturedHeaders: Headers | null = null;
|
||||
|
||||
const mockFetch = async (_url: string, init?: RequestInit): Promise<Response> => {
|
||||
capturedHeaders = new Headers(init?.headers);
|
||||
return new Response(JSON.stringify({ cloudaicompanionProject: "proj-harness" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
await ensureAntigravityProjectAssigned("harness-token", mockFetch, "harness");
|
||||
|
||||
assert.match(
|
||||
capturedHeaders?.get("User-Agent") || "",
|
||||
/^antigravity\/4\.2\.0 [^ ]+\/[^ ]+ google-api-nodejs-client\/10\.3\.0$/
|
||||
);
|
||||
assert.equal(capturedHeaders?.get("X-Goog-Api-Client"), "gl-node/22.21.1");
|
||||
assert.equal(capturedHeaders?.get("Client-Metadata"), null);
|
||||
});
|
||||
|
||||
test("falls through to next URL when first loadCodeAssist returns 404", async () => {
|
||||
const hitUrls: string[] = [];
|
||||
|
||||
const mockFetch = async (url: string, _init?: RequestInit): Promise<Response> => {
|
||||
hitUrls.push(url);
|
||||
// Exact hostname match (not substring .includes) so the check can't be fooled by a
|
||||
// look-alike host like daily-cloudcode-pa.googleapis.com.evil.com (CodeQL
|
||||
// js/incomplete-url-substring-sanitization).
|
||||
if (new URL(url).hostname === "daily-cloudcode-pa.googleapis.com") {
|
||||
// First URL fails
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
// Second URL succeeds
|
||||
return new Response(JSON.stringify({ cloudaicompanionProject: "proj-fallback" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
const projectId = await ensureAntigravityProjectAssigned("fallback-token", mockFetch);
|
||||
|
||||
assert.ok(hitUrls.length >= 2, "should try at least two URLs on the first failure");
|
||||
assert.equal(projectId, "proj-fallback", "should return the project from the successful URL");
|
||||
assert.equal(
|
||||
getAntigravityProjectFromCache("fallback-token"),
|
||||
"proj-fallback",
|
||||
"should cache the project from the successful URL"
|
||||
);
|
||||
});
|
||||
|
||||
test("getAntigravityLoadCodeAssistUrls returns URLs matching ANTIGRAVITY_BASE_URLS", () => {
|
||||
const urls = getAntigravityLoadCodeAssistUrls();
|
||||
assert.ok(urls.length >= 1, "must return at least one URL");
|
||||
for (const url of urls) {
|
||||
assert.ok(url.endsWith(":loadCodeAssist"), `URL must end with :loadCodeAssist, got: ${url}`);
|
||||
assert.ok(url.startsWith("https://"), `URL must be HTTPS, got: ${url}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Ordering guarantee: loadCodeAssist BEFORE :models ─────────────────────────
|
||||
//
|
||||
// This test simulates the full discovery flow: a test-controlled fetch
|
||||
// that records call order, and verifies that :loadCodeAssist precedes
|
||||
// any :models request. The integration is verified by calling
|
||||
// ensureAntigravityProjectAssigned then simulating a :models request.
|
||||
|
||||
describe("ordering guarantee: loadCodeAssist before :models", () => {
|
||||
test("loadCodeAssist is called before :models in a simulated discovery flow", async () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
const mockFetch = async (url: string, _init?: RequestInit): Promise<Response> => {
|
||||
if (url.endsWith(":loadCodeAssist")) {
|
||||
callOrder.push("loadCodeAssist");
|
||||
return new Response(JSON.stringify({ cloudaicompanionProject: "proj-order-test" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url.endsWith(":models")) {
|
||||
callOrder.push("models");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
models: [{ id: "gemini-3-pro-antigravity", displayName: "Gemini 3 Pro" }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
|
||||
// Step 1: bootstrap project (what route.ts now does before the models loop).
|
||||
await ensureAntigravityProjectAssigned("order-token", mockFetch);
|
||||
|
||||
// Step 2: simulate a :models discovery request (what the loop does).
|
||||
const modelsUrl = "https://cloudcode-pa.googleapis.com/v1internal:models";
|
||||
await mockFetch(modelsUrl);
|
||||
|
||||
const loadIdx = callOrder.indexOf("loadCodeAssist");
|
||||
const modelsIdx = callOrder.indexOf("models");
|
||||
|
||||
assert.ok(loadIdx >= 0, ":loadCodeAssist must be called");
|
||||
assert.ok(modelsIdx >= 0, ":models must be called");
|
||||
assert.ok(loadIdx < modelsIdx, ":loadCodeAssist must be called BEFORE :models");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
// Split-guard for the antigravity executor SSE-collect extraction.
|
||||
// The pure SSE-payload -> collected-stream parser lives in antigravity/sseCollect.ts
|
||||
// (no host state, no fetch/auth). Host imports the helpers it uses and re-exports
|
||||
// processAntigravitySSEPayload for external importers (tests).
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const EXE = join(HERE, "../../open-sse/executors");
|
||||
const HOST = join(EXE, "antigravity.ts");
|
||||
const LEAF = join(EXE, "antigravity/sseCollect.ts");
|
||||
|
||||
test("leaf hosts the SSE-collect helpers and does not import the host", () => {
|
||||
const src = readFileSync(LEAF, "utf8");
|
||||
for (const sym of [
|
||||
"processAntigravitySSEPayload",
|
||||
"processAntigravitySSEText",
|
||||
"flushAntigravitySSEText",
|
||||
"stripZeroWidth",
|
||||
]) {
|
||||
assert.match(src, new RegExp(`export (function|type) ${sym}\\b`));
|
||||
}
|
||||
assert.doesNotMatch(src, /from "\.\.\/antigravity\.ts"/);
|
||||
});
|
||||
|
||||
test("host re-exports processAntigravitySSEPayload", () => {
|
||||
const host = readFileSync(HOST, "utf8");
|
||||
assert.match(
|
||||
host,
|
||||
/export \{ processAntigravitySSEPayload \} from "\.\/antigravity\/sseCollect\.ts"/
|
||||
);
|
||||
assert.match(host, /from "\.\/antigravity\/sseCollect\.ts"/);
|
||||
});
|
||||
|
||||
test("SSE-collect helpers are callable and tolerate empty/garbage input", async () => {
|
||||
const { processAntigravitySSEPayload, stripZeroWidth } =
|
||||
await import("../../open-sse/executors/antigravity/sseCollect.ts");
|
||||
assert.equal(typeof processAntigravitySSEPayload, "function");
|
||||
// stripZeroWidth removes zero-width markers from strings, passes through non-strings.
|
||||
assert.equal(stripZeroWidth("ab"), "ab");
|
||||
assert.deepEqual(stripZeroWidth(42), 42);
|
||||
// A malformed payload must not throw (defensive parse).
|
||||
const collected = { textContent: "" };
|
||||
assert.doesNotThrow(() => processAntigravitySSEPayload("not-json", collected));
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts";
|
||||
|
||||
type ChatCompletionPayload = {
|
||||
choices: Array<{
|
||||
message: { content: string };
|
||||
finish_reason: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
test("AntigravityExecutor.collectStreamToResponse normalizes prohibited content finish reasons", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const response = new Response(
|
||||
'data: {"response":{"candidates":[{"content":{"parts":[{"text":"partial text"}]},"finishReason":"PROHIBITED_CONTENT"}]}}\n\n',
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
}
|
||||
);
|
||||
|
||||
const result = await executor.collectStreamToResponse(
|
||||
response,
|
||||
"gemini-2.5-flash",
|
||||
"https://example.com",
|
||||
{ Authorization: "Bearer ag-token" },
|
||||
{ request: {} }
|
||||
);
|
||||
const payload = (await result.response.json()) as ChatCompletionPayload;
|
||||
|
||||
assert.equal(payload.choices[0].message.content, "partial text");
|
||||
assert.equal(payload.choices[0].finish_reason, "content_filter");
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { detectFormatFromEndpoint } from "@omniroute/open-sse/services/provider.ts";
|
||||
|
||||
const ENVELOPE = {
|
||||
model: "gemini-2.5-pro",
|
||||
project: "projects/test",
|
||||
request: { contents: [{ role: "user", parts: [{ text: "oi" }] }] },
|
||||
};
|
||||
|
||||
test("detectFormatFromEndpoint classifies the /v1/antigravity path as antigravity", () => {
|
||||
assert.equal(detectFormatFromEndpoint(ENVELOPE, "/v1/antigravity"), "antigravity");
|
||||
assert.equal(detectFormatFromEndpoint(ENVELOPE, "/api/v1/antigravity"), "antigravity");
|
||||
assert.equal(
|
||||
detectFormatFromEndpoint(ENVELOPE, "/v1/antigravity:streamGenerateContent"),
|
||||
"antigravity"
|
||||
);
|
||||
});
|
||||
|
||||
test("the antigravity carve-out does not disturb the other endpoint formats", () => {
|
||||
assert.equal(detectFormatFromEndpoint({ messages: [] }, "/v1/messages"), "claude");
|
||||
assert.equal(detectFormatFromEndpoint({ messages: [] }, "/v1/chat/completions"), "openai");
|
||||
assert.equal(detectFormatFromEndpoint({ input: "x" }, "/v1/responses"), "openai-responses");
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { getAntigravityLoadCodeAssistMetadata } from "../../open-sse/services/antigravityHeaders.ts";
|
||||
|
||||
test("loadCodeAssist metadata matches Antigravity Manager (ideType only)", () => {
|
||||
assert.deepEqual(getAntigravityLoadCodeAssistMetadata(), { ideType: "ANTIGRAVITY" });
|
||||
assert.equal("platform" in getAntigravityLoadCodeAssistMetadata(), false);
|
||||
assert.equal("pluginType" in getAntigravityLoadCodeAssistMetadata(), false);
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* LEDGER-3 (#3821-review) — the Antigravity local-usage fallback (#3604) replaces a
|
||||
* stale full `fetchAvailableModels` bucket (used=0) with real consumption summed from
|
||||
* `usage_history`, flipping quotaSource to "localUsageHistory". Every prior #3604 test
|
||||
* mocks only the HTTP layer, so the model-id match against usage_history.model was never
|
||||
* exercised end-to-end. This seeds a real usage_history row keyed by the CLIENT tier id
|
||||
* the fallback queries and asserts the flip — the regression guard for the id contract.
|
||||
*
|
||||
* Contract note: the fallback queries `usage_history WHERE model = <client tier id>`
|
||||
* (e.g. gemini-3.5-flash-high), so the executor MUST log usage under that same client id
|
||||
* for the fallback to fire. This test pins exactly that join.
|
||||
*/
|
||||
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-ag-local-usage-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-ag-local-usage-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
// Load usage.ts up-front (its index.ts proxyFetch patch runs at module eval) before mocks.
|
||||
const usageModule = await import("../../open-sse/services/usage.ts");
|
||||
const { getUsageForProvider } = usageModule;
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("Antigravity fetchAvailableModels(used=0) → localUsageHistory when usage_history has rows", async () => {
|
||||
core.resetDbInstance();
|
||||
|
||||
// resetTime an hour out → the 5h local-usage window is [now-4h, now+1h).
|
||||
const resetTime = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
const seededTimestamp = new Date(Date.now() - 30 * 60 * 1000).toISOString(); // within window
|
||||
|
||||
// Seed a usage_history row keyed by the CLIENT tier id the fallback queries.
|
||||
const db = core.getDbInstance() as unknown as { prepare: (sql: string) => { run: (...a: unknown[]) => unknown } };
|
||||
db.prepare(
|
||||
`INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, tokens_reasoning, success, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, ?)`
|
||||
).run("antigravity", "gemini-3.5-flash-high", "conn-local-1", 1000, 1500, 500, seededTimestamp);
|
||||
// Total seeded tokens = 3000 → ceil(3000/1000) = 3 units used.
|
||||
|
||||
globalThis.fetch = (async (input: any) => {
|
||||
const url = typeof input === "string" ? input : input?.url || "";
|
||||
// retrieveUserQuota (the live signal) is unavailable → falls back to fetchAvailableModels.
|
||||
if (url.includes("retrieveUserQuota")) {
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
}
|
||||
// fetchAvailableModels returns a FULL (stale) bucket: remainingFraction 1.0 + resetTime.
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-3.5-flash-high": {
|
||||
quotaInfo: { remainingFraction: 1.0, resetTime },
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as Response;
|
||||
}) as typeof fetch;
|
||||
|
||||
const connection = {
|
||||
id: "conn-local-1",
|
||||
provider: "antigravity",
|
||||
accessToken: "fake-token-local-usage-unique",
|
||||
providerSpecificData: {},
|
||||
projectId: undefined,
|
||||
};
|
||||
|
||||
const result = await getUsageForProvider(connection, { forceRefresh: true });
|
||||
assert.ok(result && "quotas" in result, "should return quotas");
|
||||
const quota = (result as any).quotas["gemini-3.5-flash-high"];
|
||||
assert.ok(quota, "should have the gemini-3.5-flash-high quota");
|
||||
assert.equal(quota.quotaSource, "localUsageHistory", "stale full bucket replaced by local usage");
|
||||
assert.equal(quota.used, 3, "3000 seeded tokens → 3 units used");
|
||||
});
|
||||
|
||||
test("Antigravity stays fetchAvailableModels when usage_history has no matching rows", async () => {
|
||||
core.resetDbInstance();
|
||||
|
||||
const resetTime = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
|
||||
globalThis.fetch = (async (input: any) => {
|
||||
const url = typeof input === "string" ? input : input?.url || "";
|
||||
if (url.includes("retrieveUserQuota")) {
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-3.5-flash-high": { quotaInfo: { remainingFraction: 1.0, resetTime } },
|
||||
},
|
||||
}),
|
||||
} as Response;
|
||||
}) as typeof fetch;
|
||||
|
||||
const connection = {
|
||||
id: "conn-local-2",
|
||||
provider: "antigravity",
|
||||
accessToken: "fake-token-local-usage-empty",
|
||||
providerSpecificData: {},
|
||||
projectId: undefined,
|
||||
};
|
||||
|
||||
const result = await getUsageForProvider(connection, { forceRefresh: true });
|
||||
const quota = (result as any).quotas["gemini-3.5-flash-high"];
|
||||
assert.ok(quota, "should have the quota");
|
||||
assert.equal(quota.quotaSource, "fetchAvailableModels", "no local rows → keep the catalog view");
|
||||
assert.equal(quota.used, 0, "full bucket stays at 0 used");
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
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-antigravity-mitm-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const modelsDb = await import("../../src/lib/db/models.ts");
|
||||
const { AntigravityExecutor } = await import("../../open-sse/executors/antigravity.ts");
|
||||
|
||||
test.beforeEach(() => {
|
||||
core.resetDbInstance();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// #3144: the executor resolves the upstream model through the dynamic MITM alias
|
||||
// table (populated by model sync) before falling back to the static alias map.
|
||||
test("transformRequest resolves the upstream model via the dynamic MITM alias table (#3144)", async () => {
|
||||
await modelsDb.setMitmAliasAll("antigravity", {
|
||||
"gemini-dynamic": "antigravity/gemini-3.1-pro-low",
|
||||
});
|
||||
|
||||
const executor = new AntigravityExecutor();
|
||||
const result = await executor.transformRequest(
|
||||
"antigravity/gemini-dynamic",
|
||||
{ request: { contents: [] } },
|
||||
true,
|
||||
{ projectId: "project-mitm" }
|
||||
);
|
||||
|
||||
if (result instanceof Response) throw new Error("Unexpected Response from transformRequest");
|
||||
// The MITM alias wins and the "antigravity/" prefix is stripped.
|
||||
assert.equal(result.model, "gemini-3.1-pro-low");
|
||||
});
|
||||
|
||||
// #3144 regression: a corrupted (non-string) MITM alias value must NOT short-circuit
|
||||
// cleanModelName into returning undefined — it has to fall through to the static
|
||||
// resolution and still produce a valid string model (the pre-fix code returned
|
||||
// undefined here, which then threw on `upstreamModel.toLowerCase()`).
|
||||
test("transformRequest tolerates a corrupted non-string MITM alias value (#3144)", async () => {
|
||||
await modelsDb.setMitmAliasAll("antigravity", {
|
||||
"gemini-3.1-pro": { unexpected: "object" },
|
||||
});
|
||||
|
||||
const executor = new AntigravityExecutor();
|
||||
const result = await executor.transformRequest(
|
||||
"antigravity/gemini-3.1-pro",
|
||||
{ request: { contents: [] } },
|
||||
true,
|
||||
{ projectId: "project-mitm" }
|
||||
);
|
||||
|
||||
if (result instanceof Response) throw new Error("Unexpected Response from transformRequest");
|
||||
assert.equal(typeof result.model, "string");
|
||||
assert.equal(result.model, "gemini-3.1-pro");
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
ANTIGRAVITY_PUBLIC_MODELS,
|
||||
getClientVisibleAntigravityModelName,
|
||||
isUserCallableAntigravityModelId,
|
||||
resolveAntigravityModelId,
|
||||
toClientAntigravityModelId,
|
||||
toClientAntigravityQuotaModelId,
|
||||
} from "../../open-sse/config/antigravityModelAliases.ts";
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts";
|
||||
import { openaiToAntigravityRequest } from "../../open-sse/translator/request/openai-to-gemini.ts";
|
||||
|
||||
function getPublicModel(id: string) {
|
||||
return ANTIGRAVITY_PUBLIC_MODELS.find((model) => model.id === id) as any;
|
||||
}
|
||||
|
||||
// #3821-review LEDGER-5 — the upstream quota-bucket → client-tier remap is now the single
|
||||
// source of truth here (was duplicated as an inline if-ladder in usage.ts). It operates on
|
||||
// the UPSTREAM quota namespace, where `gemini-3.5-flash-low` is the Medium tier's bucket.
|
||||
test("toClientAntigravityQuotaModelId maps upstream quota buckets to client tiers", () => {
|
||||
assert.equal(
|
||||
toClientAntigravityQuotaModelId("gemini-3.5-flash-extra-low"),
|
||||
"gemini-3.5-flash-low"
|
||||
);
|
||||
// Dual-meaning id: in the quota namespace this bucket is the Medium tier.
|
||||
assert.equal(toClientAntigravityQuotaModelId("gemini-3.5-flash-low"), "gemini-3.5-flash-medium");
|
||||
assert.equal(toClientAntigravityQuotaModelId("gemini-3-flash-agent"), "gemini-3.5-flash-high");
|
||||
// Non-tier ids fall back to the standard reverse alias map.
|
||||
assert.equal(toClientAntigravityQuotaModelId("gemini-3.1-pro"), "gemini-3-pro-preview");
|
||||
// Always-allowed bucket passes through unchanged.
|
||||
assert.equal(toClientAntigravityQuotaModelId("credits"), "credits");
|
||||
// Retired preview buckets are dropped (hidden from clients).
|
||||
assert.equal(toClientAntigravityQuotaModelId("gemini-3.5-flash-preview"), null);
|
||||
assert.equal(toClientAntigravityQuotaModelId("gemini-3-flash-preview"), null);
|
||||
assert.equal(toClientAntigravityQuotaModelId(""), null);
|
||||
});
|
||||
|
||||
test("resolveAntigravityModelId maps the documented Antigravity aliases to upstream IDs", () => {
|
||||
assert.equal(resolveAntigravityModelId("gemini-3-pro-preview"), "gemini-3.1-pro");
|
||||
assert.equal(resolveAntigravityModelId("gemini-3-pro-image-preview"), "gemini-3-pro-image");
|
||||
assert.equal(
|
||||
resolveAntigravityModelId("gemini-2.5-computer-use-preview-10-2025"),
|
||||
"rev19-uic3-1p"
|
||||
);
|
||||
assert.equal(resolveAntigravityModelId("gemini-3.5-flash-low"), "gemini-3.5-flash-extra-low");
|
||||
assert.equal(resolveAntigravityModelId("gemini-3.5-flash-medium"), "gemini-3.5-flash-low");
|
||||
assert.equal(resolveAntigravityModelId("gemini-3.5-flash-high"), "gemini-3-flash-agent");
|
||||
// Backward-compat: retired flagship public id routes to the High tier upstream.
|
||||
assert.equal(resolveAntigravityModelId("gemini-3.5-flash-preview"), "gemini-3-flash-agent");
|
||||
assert.equal(resolveAntigravityModelId("gemini-claude-sonnet-4-5"), "claude-sonnet-4-6");
|
||||
assert.equal(resolveAntigravityModelId("gemini-claude-sonnet-4-5-thinking"), "claude-sonnet-4-6");
|
||||
assert.equal(
|
||||
resolveAntigravityModelId("gemini-claude-opus-4-5-thinking"),
|
||||
"claude-opus-4-6-thinking"
|
||||
);
|
||||
assert.equal(resolveAntigravityModelId("unknown-model"), "unknown-model");
|
||||
});
|
||||
|
||||
test("toClientAntigravityModelId exposes client-visible aliases for known upstream IDs", () => {
|
||||
assert.equal(toClientAntigravityModelId("gemini-3.1-pro"), "gemini-3-pro-preview");
|
||||
assert.equal(toClientAntigravityModelId("gemini-3.5-flash-extra-low"), "gemini-3.5-flash-low");
|
||||
assert.equal(toClientAntigravityModelId("gemini-3-flash-agent"), "gemini-3.5-flash-high");
|
||||
assert.equal(toClientAntigravityModelId("gpt-oss-120b-medium"), "gpt-oss-120b-medium");
|
||||
assert.equal(toClientAntigravityModelId("claude-sonnet-4-6"), "claude-sonnet-4-6");
|
||||
assert.equal(toClientAntigravityModelId("claude-opus-4-6-thinking"), "claude-opus-4-6-thinking");
|
||||
});
|
||||
|
||||
test("isUserCallableAntigravityModelId only allows public chat-capable model IDs", () => {
|
||||
assert.equal(isUserCallableAntigravityModelId("gemini-3-pro-preview"), true);
|
||||
assert.equal(isUserCallableAntigravityModelId("gemini-3.1-pro"), true);
|
||||
// Retired flagship id stays callable as a hidden backward-compat alias (routes to High),
|
||||
// even though it is no longer exposed in the public catalog.
|
||||
assert.equal(isUserCallableAntigravityModelId("gemini-3.5-flash-preview"), true);
|
||||
assert.equal(isUserCallableAntigravityModelId("gemini-3-flash-agent"), true);
|
||||
assert.equal(isUserCallableAntigravityModelId("gemini-3.1-flash-lite"), true);
|
||||
assert.equal(isUserCallableAntigravityModelId("gemini-2.5-pro"), true);
|
||||
assert.equal(isUserCallableAntigravityModelId("gemini-2.5-flash"), true);
|
||||
assert.equal(isUserCallableAntigravityModelId("gemini-2.5-flash-lite"), true);
|
||||
assert.equal(isUserCallableAntigravityModelId("gemini-2.5-flash-thinking"), true);
|
||||
assert.equal(isUserCallableAntigravityModelId("gemini-pro-agent"), true);
|
||||
// #3184: Claude IS user-callable through the Antigravity OAuth provider (same backend as
|
||||
// `agy`, verified empirically). An earlier assumption that it was removed in Antigravity
|
||||
// 2.0 was wrong.
|
||||
assert.equal(isUserCallableAntigravityModelId("claude-opus-4-6-thinking"), true);
|
||||
assert.equal(isUserCallableAntigravityModelId("claude-sonnet-4-6"), true);
|
||||
assert.equal(isUserCallableAntigravityModelId("claude-sonnet-5"), true);
|
||||
// Antigravity 2.0.4 exposes Gemini 3.5 Flash as separate UI tiers.
|
||||
assert.equal(isUserCallableAntigravityModelId("gemini-3.1-pro-high"), true);
|
||||
assert.equal(isUserCallableAntigravityModelId("gemini-3.1-pro-low"), true);
|
||||
assert.equal(isUserCallableAntigravityModelId("gemini-3.5-flash-low"), true);
|
||||
assert.equal(isUserCallableAntigravityModelId("gemini-3.5-flash-medium"), true);
|
||||
assert.equal(isUserCallableAntigravityModelId("gemini-3.5-flash-high"), true);
|
||||
assert.equal(isUserCallableAntigravityModelId("gemini-3.5-flash-extra-low"), true);
|
||||
assert.equal(isUserCallableAntigravityModelId("tab_flash_lite_preview"), false);
|
||||
assert.equal(isUserCallableAntigravityModelId("unknown-model"), false);
|
||||
});
|
||||
|
||||
test("ANTIGRAVITY_PUBLIC_MODELS exposes captured Antigravity 2.0.1 names and capabilities", () => {
|
||||
// #3184: Claude is exposed in the antigravity catalog (same backend as `agy`, verified).
|
||||
assert.deepEqual(getPublicModel("claude-opus-4-6-thinking"), {
|
||||
id: "claude-opus-4-6-thinking",
|
||||
name: "Claude Opus 4.6 (Thinking)",
|
||||
contextLength: 200000,
|
||||
maxOutputTokens: 65536,
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
toolCalling: true,
|
||||
});
|
||||
assert.equal(getPublicModel("claude-sonnet-4-6").name, "Claude Sonnet 4.6 (Thinking)");
|
||||
// claude-sonnet-5 was added to the Antigravity catalog alongside the existing Claude entries.
|
||||
assert.deepEqual(getPublicModel("claude-sonnet-5"), {
|
||||
id: "claude-sonnet-5",
|
||||
name: "Claude Sonnet 5 (Thinking)",
|
||||
contextLength: 200000,
|
||||
maxOutputTokens: 65536,
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
toolCalling: true,
|
||||
});
|
||||
assert.deepEqual(getPublicModel("gemini-3.5-flash-high"), {
|
||||
id: "gemini-3.5-flash-high",
|
||||
name: "Gemini 3.5 Flash (High)",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 65536,
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
toolCalling: true,
|
||||
});
|
||||
assert.equal(
|
||||
getClientVisibleAntigravityModelName("gemini-3.5-flash-medium"),
|
||||
"Gemini 3.5 Flash (Medium)"
|
||||
);
|
||||
assert.equal(getClientVisibleAntigravityModelName("gemini-2.5-flash"), "Gemini 2.5 Flash");
|
||||
assert.equal(
|
||||
getClientVisibleAntigravityModelName("gemini-2.5-flash-lite"),
|
||||
"Gemini 2.5 Flash Lite"
|
||||
);
|
||||
assert.equal(
|
||||
getClientVisibleAntigravityModelName("gemini-2.5-flash-thinking"),
|
||||
"Gemini 2.5 Flash Thinking"
|
||||
);
|
||||
assert.deepEqual(getPublicModel("gpt-oss-120b-medium"), {
|
||||
id: "gpt-oss-120b-medium",
|
||||
name: "GPT-OSS 120B (Medium)",
|
||||
contextLength: 131072,
|
||||
maxOutputTokens: 32768,
|
||||
supportsReasoning: true,
|
||||
toolCalling: true,
|
||||
});
|
||||
assert.equal(getPublicModel("gemini-3-pro-image-preview").contextLength, undefined);
|
||||
assert.equal(
|
||||
getPublicModel("gemini-2.5-computer-use-preview-10-2025").maxOutputTokens,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test("ANTIGRAVITY_PUBLIC_MODELS has no duplicate model IDs", () => {
|
||||
const ids = ANTIGRAVITY_PUBLIC_MODELS.map((model) => model.id);
|
||||
const seen = new Set<string>();
|
||||
const duplicates = ids.filter((id) => {
|
||||
if (seen.has(id)) return true;
|
||||
seen.add(id);
|
||||
return false;
|
||||
});
|
||||
assert.deepEqual(duplicates, [], `duplicate model IDs found: ${duplicates.join(", ")}`);
|
||||
});
|
||||
|
||||
test("AntigravityExecutor.transformRequest resolves alias models before dispatching upstream", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const result = await executor.transformRequest(
|
||||
"antigravity/gemini-3-pro-preview",
|
||||
{
|
||||
request: {
|
||||
contents: [{ role: "user", parts: [{ text: "Hello" }] }],
|
||||
},
|
||||
},
|
||||
true,
|
||||
{ projectId: "project-1" }
|
||||
);
|
||||
|
||||
if (result instanceof Response) throw new Error("Unexpected Response from transformRequest");
|
||||
assert.equal(result.model, "gemini-3.1-pro");
|
||||
});
|
||||
|
||||
test("AntigravityExecutor.transformRequest maps Gemini 3.5 Flash tiers to live upstream IDs", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const result = await executor.transformRequest(
|
||||
"antigravity/gemini-3.5-flash-high",
|
||||
{
|
||||
request: {
|
||||
contents: [{ role: "user", parts: [{ text: "Hello" }] }],
|
||||
},
|
||||
},
|
||||
true,
|
||||
{ projectId: "project-1" }
|
||||
);
|
||||
|
||||
if (result instanceof Response) throw new Error("Unexpected Response from transformRequest");
|
||||
// The "High" tier resolves to the live upstream id; the request body is forwarded
|
||||
// under that id. (Dropped four assertions on modelConfigId/model_config_id — the
|
||||
// executor never sets those fields, so they were vacuously true and gave false
|
||||
// confidence. #3821-review LEDGER-10.)
|
||||
assert.equal(result.model, "gemini-3-flash-agent");
|
||||
assert.deepEqual(result.request.contents, [{ role: "user", parts: [{ text: "Hello" }] }]);
|
||||
});
|
||||
|
||||
test("AntigravityExecutor.transformRequest sends Claude through Gemini-compatible Cloud Code schema", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const bridged = openaiToAntigravityRequest(
|
||||
"claude-opus-4-6-thinking",
|
||||
{
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
max_completion_tokens: 32_000,
|
||||
temperature: 0.5,
|
||||
reasoning_effort: "high",
|
||||
},
|
||||
true,
|
||||
{ projectId: "project-1" } as any
|
||||
);
|
||||
|
||||
const result = await executor.transformRequest(
|
||||
"antigravity/claude-opus-4-6-thinking",
|
||||
bridged,
|
||||
true,
|
||||
{
|
||||
projectId: "project-1",
|
||||
}
|
||||
);
|
||||
|
||||
if (result instanceof Response) throw new Error("Unexpected Response from transformRequest");
|
||||
const request = result.request as any;
|
||||
assert.deepEqual(request.contents, [{ role: "user", parts: [{ text: "Hello" }] }]);
|
||||
// Capped to MAX_ANTIGRAVITY_OUTPUT_TOKENS (16384) by the executor (#4636) to avoid
|
||||
// the Antigravity Cloud Code 400 on maxOutputTokens > 16384, overriding the
|
||||
// thinkingBudget+1 bump (which would otherwise be 32769).
|
||||
assert.equal(request.generationConfig.maxOutputTokens, 16384);
|
||||
assert.equal(request.generationConfig.temperature, 0.5);
|
||||
assert.equal(request.generationConfig.topK, 40);
|
||||
assert.equal(request.generationConfig.topP, 1);
|
||||
assert.equal(request.messages, undefined);
|
||||
assert.equal(request.system, undefined);
|
||||
assert.equal(request.max_tokens, undefined);
|
||||
assert.equal(request.stream, undefined);
|
||||
assert.equal(request.temperature, undefined);
|
||||
assert.equal(request.thinking, undefined);
|
||||
assert.equal(request.generationConfig.thinkingConfig, undefined);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// Regression guard for the Antigravity OAuth login hang on Google's consent page.
|
||||
//
|
||||
// The embedded Antigravity client is a Google "Desktop/native" OAuth client.
|
||||
// Sending a PKCE code_challenge AND the `openid` scope pushed Google into the
|
||||
// `signin/oauth/firstparty/nativeapp` consent flow, which hung and never redirected
|
||||
// back (operator report 2026-06-27). The working 9router flow uses a plain
|
||||
// authorization_code grant (client_secret, no code_challenge) and does NOT request
|
||||
// `openid`. This test pins our antigravity (and the `agy` alias) to that shape.
|
||||
//
|
||||
// Flip-proof: set flowType back to "authorization_code_pkce" → generateAuthData emits
|
||||
// code_challenge → first assertion fails. Re-add "openid" → scope assertion fails.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { generateAuthData } from "../../src/lib/oauth/providers.ts";
|
||||
import PROVIDERS from "../../src/lib/oauth/providers/index.ts";
|
||||
|
||||
const REDIRECT = "http://127.0.0.1:20128/callback";
|
||||
|
||||
for (const providerId of ["antigravity", "agy"]) {
|
||||
test(`${providerId}: no PKCE + no openid in the auth URL (matches working 9router flow)`, () => {
|
||||
assert.equal(
|
||||
PROVIDERS[providerId].flowType,
|
||||
"authorization_code",
|
||||
`${providerId} must use a plain authorization_code grant (no PKCE) for the Google native client`
|
||||
);
|
||||
|
||||
const authData = generateAuthData(providerId, REDIRECT);
|
||||
assert.ok(authData.authUrl, `${providerId} must produce an auth URL`);
|
||||
|
||||
const url = new URL(authData.authUrl);
|
||||
assert.equal(url.origin, "https://accounts.google.com");
|
||||
|
||||
// No PKCE challenge — its presence triggers the hanging nativeapp consent.
|
||||
assert.equal(
|
||||
url.searchParams.get("code_challenge"),
|
||||
null,
|
||||
`${providerId} auth URL must NOT carry a PKCE code_challenge`
|
||||
);
|
||||
assert.equal(url.searchParams.get("code_challenge_method"), null);
|
||||
|
||||
// No openid scope — only the Cloud Code / userinfo scopes 9router requests.
|
||||
const scopes = (url.searchParams.get("scope") || "").split(" ");
|
||||
assert.ok(!scopes.includes("openid"), `${providerId} must not request the openid scope`);
|
||||
assert.ok(
|
||||
scopes.includes("https://www.googleapis.com/auth/cloud-platform"),
|
||||
`${providerId} must still request the cloud-platform scope`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("antigravity.exchangeToken never forwards code_verifier (no PKCE → no invalid_grant 500)", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
let sentBody = "";
|
||||
globalThis.fetch = (async (_url: unknown, init: { body?: unknown } = {}) => {
|
||||
sentBody = String(init.body ?? "");
|
||||
return new Response(
|
||||
JSON.stringify({ access_token: "t", refresh_token: "r", expires_in: 3600 }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
// Pass a codeVerifier (as the modal does — generateAuthData always mints one).
|
||||
// It MUST be ignored: the authorize URL had no code_challenge, so forwarding a
|
||||
// code_verifier makes Google reject the exchange (invalid_grant → 500).
|
||||
await PROVIDERS.antigravity.exchangeToken(
|
||||
{
|
||||
clientId: "cid",
|
||||
clientSecret: "sec",
|
||||
tokenUrl: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
"the-code",
|
||||
"http://127.0.0.1:20128/callback",
|
||||
"should-be-ignored-verifier"
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
const params = new URLSearchParams(sentBody);
|
||||
assert.equal(params.get("code_verifier"), null, "must NOT forward code_verifier (no PKCE)");
|
||||
assert.equal(params.get("client_secret"), "sec", "must authenticate via client_secret");
|
||||
assert.equal(params.get("grant_type"), "authorization_code");
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// Regression guard for the Antigravity OAuth login hang.
|
||||
//
|
||||
// The dashboard login "just spun forever" because postExchange `await`ed the
|
||||
// onboardUser retry loop (up to 10×5s, each fetch un-timed) inline, so a slow/
|
||||
// unreachable Antigravity upstream blocked the /exchange response indefinitely.
|
||||
//
|
||||
// Fix: onboarding is fire-and-forget (matches the 9router web flow) and every
|
||||
// blocking call is AbortSignal.timeout-bounded. This test proves postExchange
|
||||
// returns promptly regardless of onboarding, and never hangs when an upstream
|
||||
// stalls.
|
||||
//
|
||||
// Flip-proof: revert onboarding to an inline `await` loop and test 1 hangs on the
|
||||
// onboard gate → times out → fails. Drop the AbortSignal.timeout and test 2
|
||||
// hangs → fails.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { antigravity } from "../../src/lib/oauth/providers/antigravity.ts";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function jsonRes(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// A fetch that rejects when its AbortSignal fires, and otherwise never resolves.
|
||||
// Mirrors real fetch: an already-aborted signal rejects immediately (so a shared
|
||||
// deadline reused across fallback endpoints fails fast after the first abort).
|
||||
function stalledFetch(init?: { signal?: AbortSignal }): Promise<Response> {
|
||||
return new Promise((_resolve, reject) => {
|
||||
const abortErr = () => new DOMException("The operation was aborted.", "AbortError");
|
||||
const signal = init?.signal;
|
||||
if (signal?.aborted) {
|
||||
reject(abortErr());
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", () => reject(abortErr()));
|
||||
});
|
||||
}
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("postExchange returns before onboarding finishes (fire-and-forget — never blocks login)", async () => {
|
||||
// The onboard call is gated: it does not resolve until we release it AFTER
|
||||
// postExchange has already returned. With the old inline `await` loop,
|
||||
// postExchange would block on this gate forever → the test times out. With the
|
||||
// fire-and-forget fix it returns immediately.
|
||||
let releaseOnboard: () => void = () => {};
|
||||
const onboardGate = new Promise<void>((r) => {
|
||||
releaseOnboard = r;
|
||||
});
|
||||
let onboardStarted = false;
|
||||
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (u.includes("userinfo")) return jsonRes({ email: "user@example.com" });
|
||||
if (u.includes("loadCodeAssist")) {
|
||||
return jsonRes({
|
||||
cloudaicompanionProject: "proj-123",
|
||||
allowedTiers: [{ id: "legacy-tier", isDefault: true }],
|
||||
});
|
||||
}
|
||||
if (u.includes("onboardUser")) {
|
||||
onboardStarted = true;
|
||||
await onboardGate;
|
||||
return jsonRes({ done: true });
|
||||
}
|
||||
return jsonRes({});
|
||||
}) as typeof fetch;
|
||||
|
||||
const start = Date.now();
|
||||
const result = await antigravity.postExchange({ access_token: "tok" } as never);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
assert.ok(elapsed < 3000, `postExchange must not block on onboarding; took ${elapsed}ms`);
|
||||
assert.equal(result.projectId, "proj-123", "projectId still resolved from loadCodeAssist");
|
||||
|
||||
// Let the backgrounded onboarding complete cleanly (no lingering work).
|
||||
releaseOnboard();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
assert.ok(onboardStarted, "onboarding still runs — in the background, after the response");
|
||||
});
|
||||
|
||||
test("postExchange stays timeout-bounded when loadCodeAssist/userinfo stall (no infinite hang)", async () => {
|
||||
globalThis.fetch = (async (url: unknown, init?: { signal?: AbortSignal }) => {
|
||||
const u = String(url);
|
||||
if (u.includes("userinfo") || u.includes("loadCodeAssist")) return stalledFetch(init);
|
||||
return jsonRes({});
|
||||
}) as typeof fetch;
|
||||
|
||||
const start = Date.now();
|
||||
const result = await antigravity.postExchange({ access_token: "tok" } as never);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
// userInfo + loadCodeAssist are AbortSignal.timeout(8s)-bounded (one shared
|
||||
// deadline each), so the worst case is ~16s — never an infinite hang.
|
||||
assert.ok(elapsed < 22000, `postExchange must be timeout-bounded; took ${elapsed}ms`);
|
||||
assert.equal(result.projectId, "", "no project when loadCodeAssist times out");
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Regression test for #6026.
|
||||
*
|
||||
* Antigravity IDE (via AgentBridge/MITM → `/v1/antigravity` → translator) can ship a
|
||||
* truncated history whose FIRST turn is a tool result (`functionResponse`) with no
|
||||
* preceding tool call. When that survives the antigravity→openai→claude chain, Anthropic
|
||||
* (Vertex `claude-opus-4.6`) rejects it with HTTP 400:
|
||||
*
|
||||
* messages.0.content.1: unexpected tool_use_id found in tool_result blocks:
|
||||
* toolu_vrtx_...: Each tool_result block must have a corresponding tool_use block in
|
||||
* the previous message.
|
||||
*
|
||||
* The fix strips orphan tool_results at the antigravity message-assembly point
|
||||
* (`antigravityToOpenAIRequest`) by reusing `fixToolPairs`, so the orphan never reaches
|
||||
* the upstream Claude request.
|
||||
*
|
||||
* PURE-FUNCTION ONLY — this test imports the translator + sanitizer functions directly.
|
||||
* It must NEVER start the MITM proxy, bind :443/:80, touch /etc/hosts, or install a CA.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { antigravityToOpenAIRequest } = await import(
|
||||
"../../open-sse/translator/request/antigravity-to-openai.ts"
|
||||
);
|
||||
const { fixToolPairs } = await import("../../open-sse/services/contextManager.ts");
|
||||
|
||||
test("#6026: antigravityToOpenAIRequest strips an orphan functionResponse (no preceding functionCall)", () => {
|
||||
const result = antigravityToOpenAIRequest(
|
||||
"ag/claude-opus-4-6",
|
||||
{
|
||||
request: {
|
||||
contents: [
|
||||
{
|
||||
// First (and only) turn is a tool result with NO preceding tool call.
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: "toolu_vrtx_test",
|
||||
name: "read_file",
|
||||
response: { result: { ok: true } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
// The orphan tool message must be gone — otherwise the openai→claude step would emit an
|
||||
// orphan tool_result block and Anthropic would 400.
|
||||
const orphan = result.messages.find(
|
||||
(m: Record<string, unknown>) => m.role === "tool" && m.tool_call_id === "toolu_vrtx_test"
|
||||
);
|
||||
assert.equal(orphan, undefined, "orphan tool_result message must be stripped");
|
||||
assert.equal(
|
||||
result.messages.some((m: Record<string, unknown>) => m.role === "tool"),
|
||||
false,
|
||||
"no orphan tool messages should remain"
|
||||
);
|
||||
});
|
||||
|
||||
test("#6026: well-formed functionCall/functionResponse pair is preserved (no regression)", () => {
|
||||
const result = antigravityToOpenAIRequest(
|
||||
"ag/claude-opus-4-6",
|
||||
{
|
||||
request: {
|
||||
contents: [
|
||||
{
|
||||
role: "model",
|
||||
parts: [{ functionCall: { id: "toolu_vrtx_ok", name: "read_file", args: {} } }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: "toolu_vrtx_ok",
|
||||
name: "read_file",
|
||||
response: { result: { ok: true } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const assistant = result.messages.find(
|
||||
(m: Record<string, unknown>) => m.role === "assistant"
|
||||
);
|
||||
const tool = result.messages.find((m: Record<string, unknown>) => m.role === "tool");
|
||||
assert.ok(assistant, "assistant tool_call message must survive");
|
||||
assert.ok(tool, "matched tool_result message must survive");
|
||||
assert.equal((tool as Record<string, unknown>).tool_call_id, "toolu_vrtx_ok");
|
||||
});
|
||||
|
||||
test("#6026: fixToolPairs removes the exact Anthropic-shape orphan tool_result block", () => {
|
||||
// Mirrors the reporter's failing body: messages[0] is a user message whose content array
|
||||
// holds a tool_result block with no matching tool_use anywhere in the request.
|
||||
const messages: Record<string, unknown>[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "continue" },
|
||||
{ type: "tool_result", tool_use_id: "toolu_vrtx_test", content: "stale" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fixed = fixToolPairs(messages);
|
||||
|
||||
const stillHasOrphan = fixed.some(
|
||||
(m) =>
|
||||
m.role === "user" &&
|
||||
Array.isArray(m.content) &&
|
||||
(m.content as Record<string, unknown>[]).some(
|
||||
(b) => b.type === "tool_result" && b.tool_use_id === "toolu_vrtx_test"
|
||||
)
|
||||
);
|
||||
assert.equal(stillHasOrphan, false, "orphan tool_result block must be stripped");
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { updateProviderConnectionSchema } from "../../src/shared/validation/schemas.js";
|
||||
|
||||
describe("Antigravity Project ID Schema Validation", () => {
|
||||
it("should accept projectId and providerSpecificData.projectId", () => {
|
||||
const result = updateProviderConnectionSchema.safeParse({
|
||||
projectId: "anti-project",
|
||||
providerSpecificData: { projectId: "anti-project" },
|
||||
});
|
||||
assert.strictEqual(result.success, true);
|
||||
});
|
||||
|
||||
it("should accept null projectId and preserve nested null through JSON serialization", () => {
|
||||
const payload = {
|
||||
projectId: null,
|
||||
providerSpecificData: { projectId: null },
|
||||
};
|
||||
|
||||
const serialized = JSON.stringify(payload);
|
||||
assert.match(serialized, /"projectId":null/);
|
||||
|
||||
const result = updateProviderConnectionSchema.safeParse(payload);
|
||||
assert.strictEqual(result.success, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Issue #3850 — Antigravity refresh nulls the stored refresh_token.
|
||||
*
|
||||
* Google's OAuth token endpoint normally OMITS `refresh_token` on a refresh
|
||||
* (its refresh tokens are non-rotating), and occasionally returns it as an
|
||||
* EMPTY STRING. The canonical `refreshGoogleToken` preserves the existing token
|
||||
* via `tokens.refresh_token || refreshToken` (treats "" as absent), but the
|
||||
* Antigravity executor's `refreshCredentials` used
|
||||
* `typeof tokens.refresh_token === "string" ? tokens.refresh_token : credentials.refreshToken`
|
||||
* — and `typeof "" === "string"` is true, so an empty-string response
|
||||
* OVERWROTE the good token with "", effectively nulling it on first refresh.
|
||||
*
|
||||
* This regression guard asserts the executor preserves the existing refresh
|
||||
* token when the upstream returns it empty or omits it.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts";
|
||||
|
||||
const OLD_REFRESH = "1//old-non-rotating-refresh-token";
|
||||
|
||||
async function withStubbedFetch<T>(
|
||||
jsonBody: Record<string, unknown>,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify(jsonBody), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})) as typeof fetch;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
}
|
||||
|
||||
test("#3850 empty-string refresh_token from Google preserves the existing token", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const refreshed = await withStubbedFetch(
|
||||
{ access_token: "new-access", refresh_token: "", expires_in: 3600 },
|
||||
() => executor.refreshCredentials({ refreshToken: OLD_REFRESH, accessToken: "stale" })
|
||||
);
|
||||
|
||||
assert.ok(refreshed, "refreshCredentials should return credentials, not null");
|
||||
assert.equal(refreshed.accessToken, "new-access");
|
||||
assert.equal(
|
||||
refreshed.refreshToken,
|
||||
OLD_REFRESH,
|
||||
"empty-string refresh_token must NOT overwrite the stored token"
|
||||
);
|
||||
});
|
||||
|
||||
test("#3850 omitted refresh_token from Google preserves the existing token", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const refreshed = await withStubbedFetch({ access_token: "new-access", expires_in: 3600 }, () =>
|
||||
executor.refreshCredentials({ refreshToken: OLD_REFRESH, accessToken: "stale" })
|
||||
);
|
||||
|
||||
assert.ok(refreshed);
|
||||
assert.equal(refreshed.refreshToken, OLD_REFRESH);
|
||||
});
|
||||
|
||||
test("#3850 a real rotated refresh_token still replaces the stored token", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const refreshed = await withStubbedFetch(
|
||||
{ access_token: "new-access", refresh_token: "1//brand-new-token", expires_in: 3600 },
|
||||
() => executor.refreshCredentials({ refreshToken: OLD_REFRESH, accessToken: "stale" })
|
||||
);
|
||||
|
||||
assert.ok(refreshed);
|
||||
assert.equal(refreshed.refreshToken, "1//brand-new-token");
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* TDD tests for port of upstream PR #2054 (decolua/9router):
|
||||
* fix(antigravity): retry transient upstream failures
|
||||
*
|
||||
* Covers:
|
||||
* 1. isTransientAntigravityError classifies 5xx + body patterns correctly
|
||||
* 2. extractErrorMessage extracts message from various JSON shapes
|
||||
* 3. Tool-name deduplication in buildGeminiTools (via geminiToolsSanitizer)
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts";
|
||||
import { buildGeminiTools } from "../../open-sse/translator/helpers/geminiToolsSanitizer.ts";
|
||||
|
||||
// ── isTransientAntigravityError ────────────────────────────────────────────────
|
||||
|
||||
test("isTransientAntigravityError: 503 → true (transient status)", () => {
|
||||
const ex = new AntigravityExecutor();
|
||||
assert.equal(ex.isTransientAntigravityError(503, ""), true);
|
||||
});
|
||||
|
||||
test("isTransientAntigravityError: 500 + 'Agent execution terminated due to error' body → true", () => {
|
||||
const ex = new AntigravityExecutor();
|
||||
assert.equal(
|
||||
ex.isTransientAntigravityError(500, "Agent execution terminated due to error"),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("isTransientAntigravityError: 500 + 'high traffic' body → true", () => {
|
||||
const ex = new AntigravityExecutor();
|
||||
assert.equal(ex.isTransientAntigravityError(500, "high traffic"), true);
|
||||
});
|
||||
|
||||
test("isTransientAntigravityError: 500 + 'capacity' body → true", () => {
|
||||
const ex = new AntigravityExecutor();
|
||||
assert.equal(ex.isTransientAntigravityError(500, "service is at capacity"), true);
|
||||
});
|
||||
|
||||
test("isTransientAntigravityError: 400 + 'Invalid request' body → false (veto)", () => {
|
||||
const ex = new AntigravityExecutor();
|
||||
assert.equal(ex.isTransientAntigravityError(400, "Invalid request"), false);
|
||||
});
|
||||
|
||||
test("isTransientAntigravityError: 404 → false", () => {
|
||||
const ex = new AntigravityExecutor();
|
||||
assert.equal(ex.isTransientAntigravityError(404, "not found"), false);
|
||||
});
|
||||
|
||||
test("isTransientAntigravityError: 429 → true (rate limited is transient)", () => {
|
||||
const ex = new AntigravityExecutor();
|
||||
assert.equal(ex.isTransientAntigravityError(429, ""), true);
|
||||
});
|
||||
|
||||
test("isTransientAntigravityError: 502 → true (bad gateway is transient)", () => {
|
||||
const ex = new AntigravityExecutor();
|
||||
assert.equal(ex.isTransientAntigravityError(502, ""), true);
|
||||
});
|
||||
|
||||
test("isTransientAntigravityError: 504 → true (gateway timeout is transient)", () => {
|
||||
const ex = new AntigravityExecutor();
|
||||
assert.equal(ex.isTransientAntigravityError(504, ""), true);
|
||||
});
|
||||
|
||||
// ── extractErrorMessage ────────────────────────────────────────────────────────
|
||||
|
||||
test("extractErrorMessage: extracts error.message from standard JSON shape", () => {
|
||||
const ex = new AntigravityExecutor();
|
||||
const json = { error: { message: "quota exceeded" } };
|
||||
const msg = ex.extractErrorMessage(json, "");
|
||||
assert.ok(msg.includes("quota exceeded"), `expected 'quota exceeded' in: ${msg}`);
|
||||
});
|
||||
|
||||
test("extractErrorMessage: falls back to top-level message field", () => {
|
||||
const ex = new AntigravityExecutor();
|
||||
const json = { message: "service unavailable" };
|
||||
const msg = ex.extractErrorMessage(json, "");
|
||||
assert.ok(msg.includes("service unavailable"), `expected 'service unavailable' in: ${msg}`);
|
||||
});
|
||||
|
||||
test("extractErrorMessage: falls back to bodyText when json has no message", () => {
|
||||
const ex = new AntigravityExecutor();
|
||||
const msg = ex.extractErrorMessage(null, "raw error body text");
|
||||
assert.ok(msg.includes("raw error body text"), `expected body text in: ${msg}`);
|
||||
});
|
||||
|
||||
// ── Tool-name deduplication ────────────────────────────────────────────────────
|
||||
|
||||
test("buildGeminiTools: deduplicates tool names that sanitize to the same value", () => {
|
||||
// "read/file" and "read/file" appear twice → only one after dedup
|
||||
const tools = [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: "read/file",
|
||||
description: "Read a file",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "read/file",
|
||||
description: "Read a file (dup)",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildGeminiTools(tools);
|
||||
assert.ok(Array.isArray(result), "should return array");
|
||||
const decls = result![0]?.functionDeclarations ?? [];
|
||||
// After sanitizing "read/file" → "read_file", both resolve to same name; only 1 should remain
|
||||
const names = decls.map((d) => d.name);
|
||||
assert.equal(new Set(names).size, names.length, `Duplicate names found: ${names}`);
|
||||
assert.equal(names.length, 1, `Expected 1 declaration, got ${names.length}: ${names}`);
|
||||
});
|
||||
|
||||
test("buildGeminiTools: tools with different sanitized names are both kept", () => {
|
||||
const tools = [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: "read_file",
|
||||
description: "Read a file",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "write_file",
|
||||
description: "Write a file",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildGeminiTools(tools);
|
||||
assert.ok(Array.isArray(result), "should return array");
|
||||
const decls = result![0]?.functionDeclarations ?? [];
|
||||
assert.equal(decls.length, 2, `Expected 2 declarations, got ${decls.length}`);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts";
|
||||
import { DEFAULT_SAFETY_SETTINGS } from "../../open-sse/translator/helpers/geminiHelper.ts";
|
||||
|
||||
// Regression for #5003: the Antigravity (Google Cloud Code) request builder explicitly set
|
||||
// `safetySettings: undefined`, which `JSON.stringify` drops entirely. With no safetySettings
|
||||
// reaching Cloud Code, Google applies its server-side safety defaults that false-flag benign
|
||||
// technical prompts as `prohibited_content` (HTTP 200 with a blocked body that combo failover
|
||||
// treats as terminal). The native Gemini paths all default to all-OFF
|
||||
// (DEFAULT_SAFETY_SETTINGS); Antigravity must match for parity.
|
||||
|
||||
test("transformRequest defaults safetySettings to all-OFF when none supplied (#5003)", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const body = {
|
||||
request: {
|
||||
contents: [{ role: "user", parts: [{ text: "hi" }] }],
|
||||
generationConfig: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executor.transformRequest("antigravity/claude-sonnet-4-6", body, true, {
|
||||
projectId: "project-1",
|
||||
});
|
||||
|
||||
if (result instanceof Response) throw new Error("Unexpected Response from transformRequest");
|
||||
const innerRequest = result.request as Record<string, unknown>;
|
||||
assert.deepEqual(
|
||||
innerRequest.safetySettings,
|
||||
DEFAULT_SAFETY_SETTINGS,
|
||||
"safetySettings must default to all-OFF for parity with native Gemini paths"
|
||||
);
|
||||
});
|
||||
|
||||
test("transformRequest honors a caller-supplied safetySettings (#5003)", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const callerSafety = [
|
||||
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_MEDIUM_AND_ABOVE" },
|
||||
];
|
||||
const body = {
|
||||
request: {
|
||||
contents: [{ role: "user", parts: [{ text: "hi" }] }],
|
||||
generationConfig: {},
|
||||
safetySettings: callerSafety,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executor.transformRequest("antigravity/claude-sonnet-4-6", body, true, {
|
||||
projectId: "project-1",
|
||||
});
|
||||
|
||||
if (result instanceof Response) throw new Error("Unexpected Response from transformRequest");
|
||||
const innerRequest = result.request as Record<string, unknown>;
|
||||
assert.deepEqual(
|
||||
innerRequest.safetySettings,
|
||||
callerSafety,
|
||||
"a caller-supplied safetySettings must not be clobbered"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts";
|
||||
|
||||
// Regression guard for #4309 (thanks @Ardem2025 / Dmitry Kuznetsov): when the upstream
|
||||
// SSE collection aborts or errors, AntigravityExecutor.collectStreamToResponse must
|
||||
// release the stream reader AND cancel the response body — otherwise the underlying
|
||||
// socket stays checked out of the Undici agent pool (a slow socket/memory leak under
|
||||
// abort-heavy load). Previously the catch block just fell through ("return whatever was
|
||||
// collected"), leaving the body uncancelled and the reader locked.
|
||||
//
|
||||
// We drive the error path deterministically with a pre-aborted signal (the first loop
|
||||
// iteration throws "Request aborted during SSE collection" before any read), then assert
|
||||
// the reader was released and the body cancelled exactly as the fix does.
|
||||
test("collectStreamToResponse releases the reader and cancels the body on abort (Undici socket leak #4309)", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
|
||||
let releaseLockCalls = 0;
|
||||
let cancelCalls = 0;
|
||||
|
||||
const fakeReader = {
|
||||
// Never resolves — with a pre-aborted signal the loop throws before ever reading,
|
||||
// so read() must not be needed to reach the cleanup path.
|
||||
read: () => new Promise<never>(() => {}),
|
||||
releaseLock: () => {
|
||||
releaseLockCalls += 1;
|
||||
},
|
||||
};
|
||||
|
||||
const fakeBody = {
|
||||
getReader: () => fakeReader,
|
||||
cancel: () => {
|
||||
cancelCalls += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
const response = { body: fakeBody } as unknown as Response;
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort(); // pre-aborted → the collect loop throws on its first guard check
|
||||
|
||||
await executor.collectStreamToResponse(
|
||||
response,
|
||||
"antigravity-model",
|
||||
"https://example.invalid/sse",
|
||||
{},
|
||||
{},
|
||||
null,
|
||||
controller.signal
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
releaseLockCalls >= 1,
|
||||
"reader.releaseLock() must be called on the abort/error path (was never released before the fix)"
|
||||
);
|
||||
assert.equal(
|
||||
cancelCalls,
|
||||
1,
|
||||
"response.body.cancel() must be called exactly once to return the socket to the Undici pool"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { openaiToAntigravityRequest } from "../../open-sse/translator/request/openai-to-gemini.ts";
|
||||
|
||||
// Regression guard for the Antigravity v1internal 400 that fires when a Gemini
|
||||
// built-in tool (google_search / web_search / googleSearch ...) is sent in the
|
||||
// same request as functionDeclarations. The Cloud Code envelope must strip the
|
||||
// built-in tool names out of functionDeclarations before dispatch.
|
||||
// Inspired-by decolua/9router#1095 (author @vanszs).
|
||||
|
||||
type EnvelopeTool = { functionDeclarations?: Array<{ name: string }> };
|
||||
|
||||
function declaredToolNames(result: { request: { tools?: EnvelopeTool[] } }): string[] {
|
||||
return (result.request.tools ?? []).flatMap((tool) =>
|
||||
(tool.functionDeclarations ?? []).map((fn) => fn.name)
|
||||
);
|
||||
}
|
||||
|
||||
const CREDS = { projectId: "proj-1" } as never;
|
||||
|
||||
test("Antigravity envelope strips built-in tool names mixed with custom functionDeclarations (#1095)", () => {
|
||||
const result = openaiToAntigravityRequest(
|
||||
"gemini-2.5-pro",
|
||||
{
|
||||
messages: [{ role: "user", content: "Search and read" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: { name: "google_search", parameters: { type: "object", properties: {} } },
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: { name: "read_file", parameters: { type: "object", properties: {} } },
|
||||
},
|
||||
],
|
||||
},
|
||||
false,
|
||||
CREDS
|
||||
);
|
||||
|
||||
const names = declaredToolNames(result);
|
||||
// Built-in tool name must be gone from functionDeclarations...
|
||||
assert.ok(
|
||||
!names.includes("google_search") && !names.includes("googleSearch"),
|
||||
`expected built-in tool name stripped, got ${JSON.stringify(names)}`
|
||||
);
|
||||
// ...while the custom declaration survives.
|
||||
assert.ok(names.includes("read_file"), `expected custom tool kept, got ${JSON.stringify(names)}`);
|
||||
// toolConfig stays set because custom declarations remain.
|
||||
assert.deepEqual(result.request.toolConfig, {
|
||||
functionCallingConfig: { mode: "VALIDATED" },
|
||||
});
|
||||
});
|
||||
|
||||
test("Antigravity envelope keeps a normal custom-only tool request intact (#1095 regression)", () => {
|
||||
const result = openaiToAntigravityRequest(
|
||||
"gemini-2.5-pro",
|
||||
{
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: { name: "weather", parameters: { type: "object", properties: {} } },
|
||||
},
|
||||
],
|
||||
},
|
||||
false,
|
||||
CREDS
|
||||
);
|
||||
|
||||
const names = declaredToolNames(result);
|
||||
assert.deepEqual(names, ["weather"]);
|
||||
assert.deepEqual(result.request.toolConfig, {
|
||||
functionCallingConfig: { mode: "VALIDATED" },
|
||||
});
|
||||
});
|
||||
|
||||
test("Antigravity envelope drops tools + toolConfig when only built-in tools are present (#1095)", () => {
|
||||
const result = openaiToAntigravityRequest(
|
||||
"gemini-2.5-pro",
|
||||
{
|
||||
messages: [{ role: "user", content: "Search the web" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: { name: "google_search", parameters: { type: "object", properties: {} } },
|
||||
},
|
||||
],
|
||||
},
|
||||
false,
|
||||
CREDS
|
||||
);
|
||||
|
||||
const names = declaredToolNames(result);
|
||||
assert.ok(
|
||||
!names.includes("google_search") && !names.includes("googleSearch"),
|
||||
`expected no built-in functionDeclarations, got ${JSON.stringify(names)}`
|
||||
);
|
||||
// No custom declarations remain -> no VALIDATED toolConfig is forced.
|
||||
assert.equal(result.request.toolConfig, undefined);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts";
|
||||
|
||||
// Regression for #1944: Claude models served via Antigravity (Google Cloud Code) started
|
||||
// returning `400 Invalid JSON payload received. Unknown name "output_config"` (path "/").
|
||||
// `output_config` is an Anthropic/Claude-Code-only field; Google's Cloud Code envelope
|
||||
// rejects unknown top-level fields. The Antigravity envelope spreads `...passthroughFields`
|
||||
// (every top-level body field except a known few), so a top-level `output_config` (or the
|
||||
// legacy `output_format`) leaked straight into the envelope Google validates.
|
||||
|
||||
test("transformRequest drops a top-level output_config from the Antigravity envelope (#1944)", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const body = {
|
||||
output_config: { effort: "high", format: { type: "json_schema" } },
|
||||
output_format: "json",
|
||||
request: {
|
||||
contents: [{ role: "user", parts: [{ text: "hi" }] }],
|
||||
generationConfig: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executor.transformRequest("antigravity/claude-sonnet-4-6", body, true, {
|
||||
projectId: "project-1",
|
||||
});
|
||||
|
||||
if (result instanceof Response) throw new Error("Unexpected Response from transformRequest");
|
||||
assert.equal(
|
||||
(result as Record<string, unknown>).output_config,
|
||||
undefined,
|
||||
"output_config must not reach the Google Cloud Code envelope"
|
||||
);
|
||||
assert.equal(
|
||||
(result as Record<string, unknown>).output_format,
|
||||
undefined,
|
||||
"legacy output_format must not reach the envelope either"
|
||||
);
|
||||
// The sanitized inner request (Claude path) must also be free of output_config.
|
||||
assert.equal(
|
||||
(result.request as Record<string, unknown>).output_config,
|
||||
undefined,
|
||||
"output_config must not survive in the inner Gemini request"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts";
|
||||
|
||||
// Regression for #1926: Claude/OpenAI "thinking" fields leaked into the Antigravity
|
||||
// (Google Cloud Code) request envelope, which Google rejects at the top level with
|
||||
// `400 Bad input: Error: oneOf at '/' not met` (and, for named fields, `Unknown name
|
||||
// "thinking"`). This broke every reasoning/thinking model served via Antigravity
|
||||
// (e.g. `claude-opus-4-x-thinking`). The envelope spreads `...passthroughFields`
|
||||
// (every top-level body field except a known few), so a top-level thinking field set
|
||||
// by the unified thinking adapter leaked straight into the envelope Google validates.
|
||||
//
|
||||
// The #1944 fix only dropped `output_config`/`output_format`; the thinking family
|
||||
// (`thinking`, `reasoning_effort`, `reasoning`, `enable_thinking`, `thinking_budget`)
|
||||
// still passed through. This test guards that the whole family is stripped.
|
||||
|
||||
const THINKING_FIELDS = [
|
||||
"thinking",
|
||||
"reasoning_effort",
|
||||
"reasoning",
|
||||
"enable_thinking",
|
||||
"thinking_budget",
|
||||
] as const;
|
||||
|
||||
test("transformRequest drops Claude/OpenAI thinking fields from the Antigravity envelope (#1926)", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const body = {
|
||||
thinking: { type: "enabled", budget_tokens: 4096 },
|
||||
reasoning_effort: "high",
|
||||
reasoning: { effort: "high" },
|
||||
enable_thinking: true,
|
||||
thinking_budget: 4096,
|
||||
request: {
|
||||
contents: [{ role: "user", parts: [{ text: "hi" }] }],
|
||||
generationConfig: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executor.transformRequest("antigravity/claude-opus-4-8-thinking", body, true, {
|
||||
projectId: "project-1",
|
||||
});
|
||||
|
||||
if (result instanceof Response) throw new Error("Unexpected Response from transformRequest");
|
||||
const envelope = result as Record<string, unknown>;
|
||||
|
||||
for (const field of THINKING_FIELDS) {
|
||||
assert.equal(
|
||||
envelope[field],
|
||||
undefined,
|
||||
`top-level "${field}" must not reach the Google Cloud Code envelope`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
AG_DECOY_TOOLS,
|
||||
AG_TOOL_SUFFIX,
|
||||
cloakAntigravityToolPayload,
|
||||
stripEnumDescriptions,
|
||||
} from "../../open-sse/config/toolCloaking.ts";
|
||||
|
||||
test("cloakAntigravityToolPayload cloaks custom tools, preserves native tools and injects decoys", () => {
|
||||
const payload = {
|
||||
request: {
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: "workspace_read",
|
||||
description: "Read a file",
|
||||
parameters: { type: "OBJECT", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "run_command",
|
||||
description: "Native tool should stay visible",
|
||||
parameters: { type: "OBJECT", properties: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
contents: [
|
||||
{
|
||||
role: "model",
|
||||
parts: [{ functionCall: { name: "workspace_read", args: { path: "/tmp/a" } } }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ functionResponse: { name: "workspace_read", response: { ok: true } } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = cloakAntigravityToolPayload(payload);
|
||||
const declarations = (result.body.request.tools?.[0] as any)?.functionDeclarations || [];
|
||||
const names = declarations.map((tool: { name: string }) => tool.name);
|
||||
|
||||
assert.ok(names.includes(`workspace_read${AG_TOOL_SUFFIX}`));
|
||||
assert.ok(names.includes("run_command"));
|
||||
assert.ok(names.includes("browser_subagent"));
|
||||
assert.ok(names.includes("mcp_sequential_thinking_sequentialthinking"));
|
||||
for (const name of names) {
|
||||
assert.match(name, /^[a-zA-Z0-9_]+$/);
|
||||
}
|
||||
assert.equal(
|
||||
result.body.request.contents[0].parts[0].functionCall.name,
|
||||
`workspace_read${AG_TOOL_SUFFIX}`
|
||||
);
|
||||
assert.equal(
|
||||
result.body.request.contents[1].parts[0].functionResponse.name,
|
||||
`workspace_read${AG_TOOL_SUFFIX}`
|
||||
);
|
||||
assert.equal(result.toolNameMap?.get(`workspace_read${AG_TOOL_SUFFIX}`), "workspace_read");
|
||||
assert.equal(
|
||||
declarations.filter((tool: { name: string }) => tool.name === "browser_subagent").length,
|
||||
1
|
||||
);
|
||||
assert.ok(AG_DECOY_TOOLS.length > 20);
|
||||
});
|
||||
|
||||
test("cloakAntigravityToolPayload composes namespace sanitization maps with Antigravity cloaking", () => {
|
||||
const payload = {
|
||||
_toolNameMap: new Map([["workspace_read", "mcp__filesystem__workspace_read"]]),
|
||||
request: {
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: "workspace_read",
|
||||
description: "Read a file",
|
||||
parameters: { type: "OBJECT", properties: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
contents: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = cloakAntigravityToolPayload(payload);
|
||||
|
||||
assert.equal(
|
||||
result.toolNameMap?.get(`workspace_read${AG_TOOL_SUFFIX}`),
|
||||
"mcp__filesystem__workspace_read"
|
||||
);
|
||||
});
|
||||
|
||||
test("stripEnumDescriptions removes enumDescriptions at every nesting level", () => {
|
||||
const schema = {
|
||||
type: "OBJECT",
|
||||
enumDescriptions: ["should be removed at root"],
|
||||
properties: {
|
||||
mode: {
|
||||
type: "STRING",
|
||||
enum: ["a", "b"],
|
||||
enumDescriptions: ["desc a", "desc b"],
|
||||
},
|
||||
nested: {
|
||||
type: "OBJECT",
|
||||
properties: {
|
||||
choice: {
|
||||
type: "STRING",
|
||||
enumDescriptions: ["deep desc"],
|
||||
},
|
||||
},
|
||||
},
|
||||
list: {
|
||||
type: "ARRAY",
|
||||
items: {
|
||||
type: "STRING",
|
||||
enumDescriptions: ["item desc"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const stripped = stripEnumDescriptions(schema) as any;
|
||||
|
||||
assert.equal("enumDescriptions" in stripped, false);
|
||||
assert.equal("enumDescriptions" in stripped.properties.mode, false);
|
||||
assert.equal("enumDescriptions" in stripped.properties.nested.properties.choice, false);
|
||||
assert.equal("enumDescriptions" in stripped.properties.list.items, false);
|
||||
// Non-target fields are preserved.
|
||||
assert.deepEqual(stripped.properties.mode.enum, ["a", "b"]);
|
||||
// Input is not mutated.
|
||||
assert.ok(Array.isArray((schema.properties.mode as any).enumDescriptions));
|
||||
});
|
||||
|
||||
test("cloakAntigravityToolPayload strips enumDescriptions from declaration parameters", () => {
|
||||
const payload = {
|
||||
request: {
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: "workspace_read",
|
||||
description: "Read a file",
|
||||
parameters: {
|
||||
type: "OBJECT",
|
||||
enumDescriptions: ["root-level"],
|
||||
properties: {
|
||||
mode: {
|
||||
type: "STRING",
|
||||
enum: ["read", "write"],
|
||||
enumDescriptions: ["read mode", "write mode"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "run_command",
|
||||
description: "Native tool keeps visible name but loses enumDescriptions",
|
||||
parameters: {
|
||||
type: "OBJECT",
|
||||
properties: {
|
||||
shell: {
|
||||
type: "STRING",
|
||||
enumDescriptions: ["bash", "zsh"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
contents: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = cloakAntigravityToolPayload(payload);
|
||||
const declarations = (result.body.request.tools?.[0] as any)?.functionDeclarations || [];
|
||||
|
||||
const cloaked = declarations.find(
|
||||
(d: { name: string }) => d.name === `workspace_read${AG_TOOL_SUFFIX}`
|
||||
);
|
||||
assert.ok(cloaked, "cloaked client tool present");
|
||||
assert.equal("enumDescriptions" in cloaked.parameters, false);
|
||||
assert.equal("enumDescriptions" in cloaked.parameters.properties.mode, false);
|
||||
assert.deepEqual(cloaked.parameters.properties.mode.enum, ["read", "write"]);
|
||||
|
||||
const native = declarations.find((d: { name: string }) => d.name === "run_command");
|
||||
assert.ok(native, "native tool preserved");
|
||||
assert.equal("enumDescriptions" in native.parameters.properties.shell, false);
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Tests for src/lib/usage/fetcher.ts — Antigravity quota parsing.
|
||||
*
|
||||
* Verifies that remainingFraction is correctly parsed:
|
||||
* - undefined → 0% remaining (exhausted quota)
|
||||
* - 0 → 0% remaining (exhausted quota, explicit)
|
||||
* - 1.0 → 100% remaining (full quota)
|
||||
* - 0.5 → 50% remaining (partial quota)
|
||||
*/
|
||||
|
||||
import { describe, it, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
describe("getUsageForProvider (antigravity in fetcher.ts)", () => {
|
||||
const connectionBase = {
|
||||
provider: "antigravity",
|
||||
accessToken: "fake-token",
|
||||
providerSpecificData: {},
|
||||
projectId: undefined,
|
||||
id: "test-conn",
|
||||
};
|
||||
|
||||
it("defaults to 0% remaining when remainingFraction is undefined", async () => {
|
||||
const fetcherModule = await import("../../src/lib/usage/fetcher.ts");
|
||||
const { getUsageForProvider } = fetcherModule;
|
||||
|
||||
const mockFetch = mock.method(global, "fetch", async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-2.5-pro": {
|
||||
quotaInfo: {
|
||||
remainingFraction: undefined,
|
||||
resetTime: "2026-05-26T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await getUsageForProvider(connectionBase);
|
||||
assert.ok(result, "should return a result");
|
||||
|
||||
if ("modelQuotas" in result) {
|
||||
const quota = result.modelQuotas["gemini-2.5-pro"];
|
||||
assert.ok(quota, "should have quota for gemini-2.5-pro");
|
||||
assert.equal(quota.remaining, 0, "remaining should be 0%");
|
||||
assert.equal(quota.limited, true, "should be marked as limited");
|
||||
}
|
||||
} finally {
|
||||
mockFetch.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults to 0% remaining when remainingFraction key is absent (exhausted quota)", async () => {
|
||||
const fetcherModule = await import("../../src/lib/usage/fetcher.ts");
|
||||
const { getUsageForProvider } = fetcherModule;
|
||||
|
||||
const mockFetch = mock.method(global, "fetch", async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-2.5-pro": {
|
||||
quotaInfo: {
|
||||
// remainingFraction key is omitted (exhausted quota)
|
||||
resetTime: "2026-05-26T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await getUsageForProvider(connectionBase);
|
||||
assert.ok(result, "should return a result");
|
||||
|
||||
if ("modelQuotas" in result) {
|
||||
const quota = result.modelQuotas["gemini-2.5-pro"];
|
||||
assert.ok(quota, "should have quota for gemini-2.5-pro");
|
||||
assert.equal(quota.remaining, 0, "remaining should be 0% when key is absent");
|
||||
assert.equal(quota.limited, true, "should be marked as limited");
|
||||
assert.equal(quota.resetAt, "2026-05-26T00:00:00Z", "should preserve resetTime");
|
||||
}
|
||||
} finally {
|
||||
mockFetch.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("parses remainingFraction=0 as exhausted quota", async () => {
|
||||
const fetcherModule = await import("../../src/lib/usage/fetcher.ts");
|
||||
const { getUsageForProvider } = fetcherModule;
|
||||
|
||||
const mockFetch = mock.method(global, "fetch", async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-2.5-pro": {
|
||||
quotaInfo: {
|
||||
remainingFraction: 0,
|
||||
resetTime: "2026-05-26T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await getUsageForProvider(connectionBase);
|
||||
assert.ok(result, "should return a result");
|
||||
|
||||
if ("modelQuotas" in result) {
|
||||
const quota = result.modelQuotas["gemini-2.5-pro"];
|
||||
assert.ok(quota, "should have quota for gemini-2.5-pro");
|
||||
assert.equal(quota.remaining, 0, "remaining should be 0%");
|
||||
assert.equal(quota.limited, true, "should be marked as limited");
|
||||
}
|
||||
} finally {
|
||||
mockFetch.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("parses remainingFraction=1.0 with resetTime as full quota", async () => {
|
||||
const fetcherModule = await import("../../src/lib/usage/fetcher.ts");
|
||||
const { getUsageForProvider } = fetcherModule;
|
||||
|
||||
const mockFetch = mock.method(global, "fetch", async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-2.5-pro": {
|
||||
quotaInfo: {
|
||||
remainingFraction: 1.0,
|
||||
resetTime: "2026-05-26T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await getUsageForProvider(connectionBase);
|
||||
assert.ok(result, "should return a result");
|
||||
|
||||
if ("modelQuotas" in result) {
|
||||
const quota = result.modelQuotas["gemini-2.5-pro"];
|
||||
assert.ok(quota, "should have quota for gemini-2.5-pro");
|
||||
assert.equal(quota.remaining, 100, "remaining should be 100%");
|
||||
assert.equal(quota.limited, false, "should not be marked as limited");
|
||||
}
|
||||
} finally {
|
||||
mockFetch.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("parses remainingFraction=0.5 as partial quota", async () => {
|
||||
const fetcherModule = await import("../../src/lib/usage/fetcher.ts");
|
||||
const { getUsageForProvider } = fetcherModule;
|
||||
|
||||
const mockFetch = mock.method(global, "fetch", async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-2.5-pro": {
|
||||
quotaInfo: {
|
||||
remainingFraction: 0.5,
|
||||
resetTime: "2026-05-26T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await getUsageForProvider(connectionBase);
|
||||
assert.ok(result, "should return a result");
|
||||
|
||||
if ("modelQuotas" in result) {
|
||||
const quota = result.modelQuotas["gemini-2.5-pro"];
|
||||
assert.ok(quota, "should have quota for gemini-2.5-pro");
|
||||
assert.equal(quota.remaining, 50, "remaining should be 50%");
|
||||
assert.equal(quota.limited, false, "should not be marked as limited");
|
||||
}
|
||||
} finally {
|
||||
mockFetch.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips credit-based models without remainingFraction", async () => {
|
||||
const fetcherModule = await import("../../src/lib/usage/fetcher.ts");
|
||||
const { getUsageForProvider } = fetcherModule;
|
||||
|
||||
const mockFetch = mock.method(global, "fetch", async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-2.5-pro": {
|
||||
quotaInfo: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await getUsageForProvider(connectionBase);
|
||||
assert.ok(result, "should return a result");
|
||||
|
||||
if ("modelQuotas" in result) {
|
||||
assert.equal(
|
||||
Object.keys(result.modelQuotas).length,
|
||||
0,
|
||||
"should not include credit-based models"
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
mockFetch.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps remainingFraction > 1 to 100%", async () => {
|
||||
const fetcherModule = await import("../../src/lib/usage/fetcher.ts");
|
||||
const { getUsageForProvider } = fetcherModule;
|
||||
|
||||
const mockFetch = mock.method(global, "fetch", async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-2.5-pro": {
|
||||
quotaInfo: {
|
||||
remainingFraction: 1.5,
|
||||
resetTime: "2026-05-26T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await getUsageForProvider(connectionBase);
|
||||
assert.ok(result, "should return a result");
|
||||
|
||||
if ("modelQuotas" in result) {
|
||||
const quota = result.modelQuotas["gemini-2.5-pro"];
|
||||
assert.ok(quota, "should have quota for gemini-2.5-pro");
|
||||
assert.equal(quota.remaining, 100, "remaining should be clamped to 100%");
|
||||
assert.equal(quota.limited, false, "should not be marked as limited");
|
||||
}
|
||||
} finally {
|
||||
mockFetch.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps negative remainingFraction to 0%", async () => {
|
||||
const fetcherModule = await import("../../src/lib/usage/fetcher.ts");
|
||||
const { getUsageForProvider } = fetcherModule;
|
||||
|
||||
const mockFetch = mock.method(global, "fetch", async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-2.5-pro": {
|
||||
quotaInfo: {
|
||||
remainingFraction: -0.5,
|
||||
resetTime: "2026-05-26T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await getUsageForProvider(connectionBase);
|
||||
assert.ok(result, "should return a result");
|
||||
|
||||
if ("modelQuotas" in result) {
|
||||
const quota = result.modelQuotas["gemini-2.5-pro"];
|
||||
assert.ok(quota, "should have quota for gemini-2.5-pro");
|
||||
assert.equal(quota.remaining, 0, "remaining should be clamped to 0%");
|
||||
assert.equal(quota.limited, true, "should be marked as limited");
|
||||
}
|
||||
} finally {
|
||||
mockFetch.mock.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Tests for open-sse/services/usage.ts — Antigravity quota parsing.
|
||||
*
|
||||
* Verifies that remainingFraction is correctly parsed:
|
||||
* - undefined → 0% remaining (exhausted quota)
|
||||
* - 0 → 0% remaining (exhausted quota, explicit)
|
||||
* - 1.0 → 100% remaining (full quota)
|
||||
* - 1.0 without resetTime → unlimited (e.g. tab-completion)
|
||||
* - 0.5 → 50% remaining (partial quota)
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// IMPORTANT: load usage.ts up-front so the proxyFetch patch in
|
||||
// `open-sse/index.ts` (which runs at module evaluation) finishes BEFORE we
|
||||
// install fetch mocks. Otherwise the first test races the patch and ends up
|
||||
// hitting the real network instead of the mock.
|
||||
const usageModule = await import("../../open-sse/services/usage.ts");
|
||||
const { getUsageForProvider } = usageModule;
|
||||
|
||||
describe("getUsageForProvider (antigravity in usage.ts)", () => {
|
||||
const connectionBase = {
|
||||
id: "test-conn",
|
||||
provider: "antigravity",
|
||||
accessToken: "fake-token",
|
||||
providerSpecificData: {},
|
||||
projectId: undefined,
|
||||
};
|
||||
|
||||
it("defaults to 0% remaining when remainingFraction is undefined", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-3.5-flash-high": {
|
||||
quotaInfo: {
|
||||
remainingFraction: undefined,
|
||||
resetTime: "2026-05-26T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}) as Response;
|
||||
|
||||
try {
|
||||
const result = await getUsageForProvider(connectionBase, { forceRefresh: true });
|
||||
assert.ok(result, "should return a result");
|
||||
assert.ok("quotas" in result, "should have quotas");
|
||||
|
||||
if ("quotas" in result) {
|
||||
const quota = result.quotas["gemini-3.5-flash-high"];
|
||||
assert.ok(quota, "should have quota for gemini-3.5-flash-high");
|
||||
assert.equal(quota.remainingPercentage, 0, "remaining should be 0%");
|
||||
assert.equal(quota.unlimited, false, "should not be unlimited");
|
||||
assert.equal(quota.used > 0, true, "used should be > 0 when quota is exhausted");
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("parses remainingFraction=0 as exhausted quota", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-3.5-flash-high": {
|
||||
quotaInfo: {
|
||||
remainingFraction: 0,
|
||||
resetTime: "2026-05-26T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}) as Response;
|
||||
|
||||
try {
|
||||
const usageModule = await import("../../open-sse/services/usage.ts");
|
||||
const { getUsageForProvider } = usageModule;
|
||||
|
||||
const result = await getUsageForProvider(connectionBase, { forceRefresh: true });
|
||||
assert.ok(result, "should return a result");
|
||||
assert.ok("quotas" in result, "should have quotas");
|
||||
|
||||
if ("quotas" in result) {
|
||||
const quota = result.quotas["gemini-3.5-flash-high"];
|
||||
assert.ok(quota, "should have quota for gemini-3.5-flash-high");
|
||||
assert.equal(quota.remainingPercentage, 0, "remaining should be 0%");
|
||||
assert.equal(quota.unlimited, false, "should not be unlimited");
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("parses remainingFraction=1.0 with resetTime as full quota", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-3.5-flash-high": {
|
||||
quotaInfo: {
|
||||
remainingFraction: 1.0,
|
||||
resetTime: "2026-05-26T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}) as Response;
|
||||
|
||||
try {
|
||||
const usageModule = await import("../../open-sse/services/usage.ts");
|
||||
const { getUsageForProvider } = usageModule;
|
||||
|
||||
const result = await getUsageForProvider(connectionBase, { forceRefresh: true });
|
||||
assert.ok(result, "should return a result");
|
||||
assert.ok("quotas" in result, "should have quotas");
|
||||
|
||||
if ("quotas" in result) {
|
||||
const quota = result.quotas["gemini-3.5-flash-high"];
|
||||
assert.ok(quota, "should have quota for gemini-3.5-flash-high");
|
||||
assert.equal(quota.remainingPercentage, 100, "remaining should be 100%");
|
||||
assert.equal(quota.unlimited, false, "should not be unlimited (has resetTime)");
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("parses remainingFraction=1.0 without resetTime as unlimited (e.g. tab-completion)", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-3.1-flash-lite": {
|
||||
quotaInfo: {
|
||||
remainingFraction: 1.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}) as Response;
|
||||
|
||||
try {
|
||||
const usageModule = await import("../../open-sse/services/usage.ts");
|
||||
const { getUsageForProvider } = usageModule;
|
||||
|
||||
const result = await getUsageForProvider(connectionBase, { forceRefresh: true });
|
||||
assert.ok(result, "should return a result");
|
||||
assert.ok("quotas" in result, "should have quotas");
|
||||
|
||||
if ("quotas" in result) {
|
||||
const quota = result.quotas["gemini-3.1-flash-lite"];
|
||||
assert.ok(quota, "should have quota for gemini-3.1-flash-lite");
|
||||
assert.equal(quota.remainingPercentage, 100, "remaining should be 100%");
|
||||
assert.equal(quota.unlimited, true, "should be unlimited (no resetTime)");
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("parses remainingFraction=0.5 as partial quota", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-3.5-flash-high": {
|
||||
quotaInfo: {
|
||||
remainingFraction: 0.5,
|
||||
resetTime: "2026-05-26T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}) as Response;
|
||||
|
||||
try {
|
||||
const usageModule = await import("../../open-sse/services/usage.ts");
|
||||
const { getUsageForProvider } = usageModule;
|
||||
|
||||
const result = await getUsageForProvider(connectionBase, { forceRefresh: true });
|
||||
assert.ok(result, "should return a result");
|
||||
assert.ok("quotas" in result, "should have quotas");
|
||||
|
||||
if ("quotas" in result) {
|
||||
const quota = result.quotas["gemini-3.5-flash-high"];
|
||||
assert.ok(quota, "should have quota for gemini-3.5-flash-high");
|
||||
assert.equal(quota.remainingPercentage, 50, "remaining should be 50%");
|
||||
assert.equal(quota.unlimited, false, "should not be unlimited");
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps remainingFraction > 1 to 100%", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-3.5-flash-high": {
|
||||
quotaInfo: {
|
||||
remainingFraction: 1.5,
|
||||
resetTime: "2026-05-26T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}) as Response;
|
||||
|
||||
try {
|
||||
const usageModule = await import("../../open-sse/services/usage.ts");
|
||||
const { getUsageForProvider } = usageModule;
|
||||
|
||||
const result = await getUsageForProvider(connectionBase, { forceRefresh: true });
|
||||
assert.ok(result, "should return a result");
|
||||
assert.ok("quotas" in result, "should have quotas");
|
||||
|
||||
if ("quotas" in result) {
|
||||
const quota = result.quotas["gemini-3.5-flash-high"];
|
||||
assert.ok(quota, "should have quota for gemini-3.5-flash-high");
|
||||
assert.equal(quota.remainingPercentage, 100, "remaining should be clamped to 100%");
|
||||
assert.equal(quota.unlimited, false, "should not be unlimited (has resetTime)");
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps negative remainingFraction to 0%", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-3.5-flash-high": {
|
||||
quotaInfo: {
|
||||
remainingFraction: -0.5,
|
||||
resetTime: "2026-05-26T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}) as Response;
|
||||
|
||||
try {
|
||||
const usageModule = await import("../../open-sse/services/usage.ts");
|
||||
const { getUsageForProvider } = usageModule;
|
||||
|
||||
const result = await getUsageForProvider(connectionBase, { forceRefresh: true });
|
||||
assert.ok(result, "should return a result");
|
||||
assert.ok("quotas" in result, "should have quotas");
|
||||
|
||||
if ("quotas" in result) {
|
||||
const quota = result.quotas["gemini-3.5-flash-high"];
|
||||
assert.ok(quota, "should have quota for gemini-3.5-flash-high");
|
||||
assert.equal(quota.remainingPercentage, 0, "remaining should be clamped to 0%");
|
||||
assert.equal(quota.unlimited, false, "should not be unlimited");
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
ANTIGRAVITY_FALLBACK_VERSION,
|
||||
ANTIGRAVITY_VERSION_CACHE_TTL_MS,
|
||||
clearAntigravityVersionCache,
|
||||
getCachedAntigravityVersion,
|
||||
resolveAntigravityVersion,
|
||||
seedAntigravityVersionCache,
|
||||
} from "../../open-sse/services/antigravityVersion.ts";
|
||||
|
||||
const originalDateNow = Date.now;
|
||||
|
||||
test.afterEach(() => {
|
||||
Date.now = originalDateNow;
|
||||
clearAntigravityVersionCache();
|
||||
});
|
||||
|
||||
test("resolveAntigravityVersion uses the official release feed and caches the result for 6 hours", async () => {
|
||||
let calls = 0;
|
||||
const fetchMock = async () => {
|
||||
calls += 1;
|
||||
return new Response(JSON.stringify([{ version: "4.2.0", execution_id: "4781536860569600" }]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
const first = await resolveAntigravityVersion(fetchMock as typeof fetch);
|
||||
const second = await resolveAntigravityVersion(fetchMock as typeof fetch);
|
||||
|
||||
assert.equal(first, "4.2.0");
|
||||
assert.equal(second, "4.2.0");
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(getCachedAntigravityVersion(), "4.2.0");
|
||||
});
|
||||
|
||||
test("resolveAntigravityVersion refreshes the cache after the TTL elapses", async () => {
|
||||
let now = 1_000;
|
||||
Date.now = () => now;
|
||||
|
||||
const firstFetch = async () =>
|
||||
new Response(JSON.stringify([{ version: "4.2.0" }]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const secondFetch = async () =>
|
||||
new Response(JSON.stringify([{ version: "4.3.0" }]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
assert.equal(await resolveAntigravityVersion(firstFetch as typeof fetch), "4.2.0");
|
||||
|
||||
now += ANTIGRAVITY_VERSION_CACHE_TTL_MS + 1;
|
||||
|
||||
assert.equal(await resolveAntigravityVersion(secondFetch as typeof fetch), "4.3.0");
|
||||
assert.equal(getCachedAntigravityVersion(), "4.3.0");
|
||||
});
|
||||
|
||||
test("resolveAntigravityVersion falls back to the last known good version or bundled fallback", async () => {
|
||||
const failingFetch = async () => {
|
||||
throw new Error("network down");
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
await resolveAntigravityVersion(failingFetch as typeof fetch),
|
||||
ANTIGRAVITY_FALLBACK_VERSION
|
||||
);
|
||||
|
||||
seedAntigravityVersionCache("4.2.1", 0);
|
||||
assert.equal(await resolveAntigravityVersion(failingFetch as typeof fetch), "4.2.1");
|
||||
});
|
||||
|
||||
test("resolveAntigravityVersion parses GitHub-style tag_name payloads with or without a v prefix", async () => {
|
||||
let calls = 0;
|
||||
const fetchMock = async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return new Response(JSON.stringify({ malformed: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ tag_name: "v4.2.3" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
assert.equal(await resolveAntigravityVersion(fetchMock as typeof fetch), "4.2.3");
|
||||
});
|
||||
@@ -0,0 +1,482 @@
|
||||
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";
|
||||
import { SignJWT } from "jose";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-api-auth-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-api-key-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const localDb = await import("../../src/lib/localDb.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const apiAuth = await import("../../src/shared/utils/apiAuth.ts");
|
||||
const { requireManagementAuth } = await import("../../src/lib/api/requireManagementAuth.ts");
|
||||
const { getLegacyCliTokenSync, getMachineTokenSync } =
|
||||
await import("../../src/lib/machineToken.ts");
|
||||
const { CLI_TOKEN_HEADER } = await import("../../src/server/authz/headers.ts");
|
||||
|
||||
const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
|
||||
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
delete process.env.JWT_SECRET;
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
}
|
||||
|
||||
function makeCookieRequest(token: string) {
|
||||
return {
|
||||
cookies: {
|
||||
get(name: string) {
|
||||
return name === "auth_token" && token ? { value: token } : undefined;
|
||||
},
|
||||
},
|
||||
headers: new Headers(),
|
||||
};
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
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("isPublicRoute recognizes allowed API prefixes", () => {
|
||||
assert.equal(apiAuth.isPublicRoute("/api/auth/login"), true);
|
||||
assert.equal(apiAuth.isPublicRoute("/api/v1/chat/completions"), true);
|
||||
assert.equal(apiAuth.isPublicRoute("/api/sync/bundle"), true);
|
||||
assert.equal(apiAuth.isPublicRoute("/api/monitoring/health", "GET"), true);
|
||||
assert.equal(apiAuth.isPublicRoute("/api/monitoring/health", "DELETE"), false);
|
||||
assert.equal(apiAuth.isPublicRoute("/api/settings"), false);
|
||||
});
|
||||
|
||||
test("verifyAuth accepts a valid JWT session cookie", async () => {
|
||||
process.env.JWT_SECRET = "jwt-secret-for-tests";
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
const token = await new SignJWT({ authenticated: true })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("1h")
|
||||
.sign(secret);
|
||||
|
||||
const result = await apiAuth.verifyAuth(makeCookieRequest(token));
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("verifyAuth falls back to bearer API key validation after a bad JWT", async () => {
|
||||
process.env.JWT_SECRET = "jwt-secret-for-tests";
|
||||
const key = await apiKeysDb.createApiKey("integration", "machine1234567890");
|
||||
const request = {
|
||||
cookies: {
|
||||
get() {
|
||||
return { value: "definitely-not-a-valid-jwt" };
|
||||
},
|
||||
},
|
||||
headers: new Headers({ authorization: `Bearer ${key.key}` }),
|
||||
url: "https://example.com/api/v1/models",
|
||||
};
|
||||
|
||||
const result = await apiAuth.verifyAuth(request);
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("verifyAuth no longer accepts API keys supplied via query string (#3300 follow-up)", async () => {
|
||||
// Query-string token fallbacks were removed (credential-in-URL leaks into logs).
|
||||
const key = await apiKeysDb.createApiKey("query-auth", "machine1234567890");
|
||||
|
||||
const result = await apiAuth.verifyAuth({
|
||||
cookies: {
|
||||
get() {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
headers: new Headers(),
|
||||
url: `https://example.com/api/v1/models?token=${encodeURIComponent(key.key)}`,
|
||||
});
|
||||
|
||||
// No usable credential → authentication fails (was incorrectly accepted before).
|
||||
assert.notEqual(result, null);
|
||||
});
|
||||
|
||||
test("isAuthenticated accepts API keys embedded in vscode path aliases", async () => {
|
||||
const key = await apiKeysDb.createApiKey("path-auth", "machine1234567890");
|
||||
const request = new Request(
|
||||
`https://example.com/api/v1/vscode/${encodeURIComponent(key.key)}/models`
|
||||
);
|
||||
|
||||
const result = await apiAuth.isAuthenticated(request);
|
||||
|
||||
assert.equal(result, true);
|
||||
});
|
||||
|
||||
test("verifyAuth never honours a URL-borne token on MANAGEMENT routes (#3300 follow-up)", async () => {
|
||||
// The historical escalation: a credential in the query string on a management
|
||||
// route (/api/* but not /api/v1/*). It must not be extracted at all, so the
|
||||
// failure is "Authentication required" (no credential) — NOT "Invalid
|
||||
// management token" (which the pre-fix code returned, proving the URL token
|
||||
// had been picked up and tried against management validation).
|
||||
const key = await apiKeysDb.createApiKey("mgmt-url", "machine1234567890");
|
||||
|
||||
const result = await apiAuth.verifyAuth({
|
||||
cookies: {
|
||||
get() {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
headers: new Headers(),
|
||||
url: `https://example.com/api/providers?token=${encodeURIComponent(key.key)}`,
|
||||
});
|
||||
|
||||
assert.equal(result, "Authentication required");
|
||||
});
|
||||
|
||||
test("verifyAuth rejects bearer API keys on management routes", async () => {
|
||||
const key = await apiKeysDb.createApiKey("integration", "machine1234567890");
|
||||
const result = await apiAuth.verifyAuth({
|
||||
cookies: {
|
||||
get() {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
headers: new Headers({ authorization: `Bearer ${key.key}` }),
|
||||
url: "https://example.com/api/providers",
|
||||
});
|
||||
|
||||
assert.equal(result, "Invalid management token");
|
||||
});
|
||||
|
||||
test("verifyAuth rejects requests without valid credentials", async () => {
|
||||
const result = await apiAuth.verifyAuth({
|
||||
cookies: {
|
||||
get() {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
headers: new Headers({ authorization: "Bearer sk-invalid" }),
|
||||
url: "https://example.com/api/v1/models",
|
||||
});
|
||||
|
||||
assert.equal(result, "Authentication required");
|
||||
});
|
||||
|
||||
test("isAuthenticated accepts bearer API keys on client-facing routes", async () => {
|
||||
const key = await apiKeysDb.createApiKey("integration", "machine1234567890");
|
||||
const request = new Request("https://example.com/api/v1/models", {
|
||||
headers: { authorization: `Bearer ${key.key}` },
|
||||
});
|
||||
|
||||
const result = await apiAuth.isAuthenticated(request);
|
||||
|
||||
assert.equal(result, true);
|
||||
});
|
||||
|
||||
test("isAuthenticated rejects bearer API keys on management routes", async () => {
|
||||
process.env.INITIAL_PASSWORD = "bootstrap-password";
|
||||
await localDb.updateSettings({ requireLogin: true, password: "" });
|
||||
|
||||
const key = await apiKeysDb.createApiKey("integration", "machine1234567890");
|
||||
const request = new Request("https://example.com/api/providers", {
|
||||
headers: { authorization: `Bearer ${key.key}` },
|
||||
});
|
||||
|
||||
const result = await apiAuth.isAuthenticated(request);
|
||||
|
||||
assert.equal(result, false);
|
||||
});
|
||||
|
||||
test("verifyAuth accepts bearer API keys with manage scope on management routes", async () => {
|
||||
const key = await apiKeysDb.createApiKey("mcp-management", "machine1234567890", ["manage"]);
|
||||
const result = await apiAuth.verifyAuth({
|
||||
cookies: {
|
||||
get() {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
headers: new Headers({ authorization: `Bearer ${key.key}` }),
|
||||
url: "https://example.com/api/providers",
|
||||
});
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("verifyAuth accepts bearer API keys with admin scope on management routes", async () => {
|
||||
const key = await apiKeysDb.createApiKey("mcp-admin", "machine1234567890", ["admin"]);
|
||||
const result = await apiAuth.verifyAuth({
|
||||
cookies: {
|
||||
get() {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
headers: new Headers({ authorization: `Bearer ${key.key}` }),
|
||||
url: "https://example.com/api/settings",
|
||||
});
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("verifyAuth still rejects unscoped bearer API keys on management routes", async () => {
|
||||
const key = await apiKeysDb.createApiKey("integration-no-scope", "machine1234567890");
|
||||
const result = await apiAuth.verifyAuth({
|
||||
cookies: {
|
||||
get() {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
headers: new Headers({ authorization: `Bearer ${key.key}` }),
|
||||
url: "https://example.com/api/providers",
|
||||
});
|
||||
|
||||
assert.equal(result, "Invalid management token");
|
||||
});
|
||||
|
||||
test("isAuthenticated accepts bearer API keys with manage scope on management routes", async () => {
|
||||
process.env.INITIAL_PASSWORD = "bootstrap-password";
|
||||
await localDb.updateSettings({ requireLogin: true, password: "" });
|
||||
|
||||
const key = await apiKeysDb.createApiKey("mcp-management", "machine1234567890", ["manage"]);
|
||||
const request = new Request("https://example.com/api/providers", {
|
||||
headers: { authorization: `Bearer ${key.key}` },
|
||||
});
|
||||
|
||||
const result = await apiAuth.isAuthenticated(request);
|
||||
|
||||
assert.equal(result, true);
|
||||
});
|
||||
|
||||
test("monitoring health reset route requires dashboard authentication", async () => {
|
||||
process.env.INITIAL_PASSWORD = "bootstrap-password";
|
||||
await localDb.updateSettings({ requireLogin: true, password: "" });
|
||||
|
||||
const healthRoute = await import("../../src/app/api/monitoring/health/route.ts");
|
||||
const response = await healthRoute.DELETE(
|
||||
new Request("https://example.com/api/monitoring/health", { method: "DELETE" })
|
||||
);
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
});
|
||||
|
||||
test("isAuthenticated returns false when auth is required without valid credentials", async () => {
|
||||
// Force requireLogin to be active
|
||||
process.env.INITIAL_PASSWORD = "bootstrap-password";
|
||||
await localDb.updateSettings({ requireLogin: true, password: "" });
|
||||
|
||||
const request = new Request("https://example.com/api/providers");
|
||||
|
||||
const result = await apiAuth.isAuthenticated(request);
|
||||
|
||||
assert.equal(result, false);
|
||||
});
|
||||
|
||||
test("isAuthRequired is disabled when requireLogin is false", async () => {
|
||||
await localDb.updateSettings({ requireLogin: false });
|
||||
|
||||
const result = await apiAuth.isAuthRequired();
|
||||
|
||||
assert.equal(result, false);
|
||||
});
|
||||
|
||||
test("isAuthRequired is disabled while no password exists", async () => {
|
||||
await localDb.updateSettings({ requireLogin: true, password: "" });
|
||||
|
||||
const result = await apiAuth.isAuthRequired();
|
||||
|
||||
assert.equal(result, false);
|
||||
});
|
||||
|
||||
test("isAuthRequired keeps fresh bootstrap open only on loopback", async () => {
|
||||
await localDb.updateSettings({ requireLogin: true, password: "" });
|
||||
|
||||
assert.equal(await apiAuth.isAuthRequired(new Request("http://localhost/api/providers")), false);
|
||||
assert.equal(await apiAuth.isAuthRequired(new Request("http://127.0.0.1/api/providers")), false);
|
||||
assert.equal(
|
||||
await apiAuth.isAuthRequired(new Request("https://example.com/api/providers")),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("isAuthenticated rejects remote management bootstrap without a configured password", async () => {
|
||||
await localDb.updateSettings({ requireLogin: true, password: "" });
|
||||
|
||||
const request = new Request("https://example.com/api/providers");
|
||||
|
||||
assert.equal(await apiAuth.isAuthenticated(request), false);
|
||||
});
|
||||
|
||||
test("isAuthRequired stays enabled when a password exists", async () => {
|
||||
await localDb.updateSettings({ requireLogin: true, password: "hashed-password" });
|
||||
|
||||
const result = await apiAuth.isAuthRequired();
|
||||
|
||||
assert.equal(result, true);
|
||||
});
|
||||
|
||||
test("isAuthRequired stays enabled when INITIAL_PASSWORD is present", async () => {
|
||||
process.env.INITIAL_PASSWORD = "bootstrap-password";
|
||||
await localDb.updateSettings({ requireLogin: true, password: "" });
|
||||
|
||||
const result = await apiAuth.isAuthRequired();
|
||||
|
||||
assert.equal(result, true);
|
||||
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
});
|
||||
|
||||
test("getApiKeyMetadata recognizes OMNIROUTE_API_KEY environment variable", async () => {
|
||||
const envKey = "sk-test-env-key-" + Date.now();
|
||||
process.env.OMNIROUTE_API_KEY = envKey;
|
||||
|
||||
const metadata = await apiKeysDb.getApiKeyMetadata(envKey);
|
||||
|
||||
assert.ok(metadata);
|
||||
assert.equal(metadata.id, "env-key");
|
||||
assert.equal(metadata.name, "Environment Key");
|
||||
|
||||
delete process.env.OMNIROUTE_API_KEY;
|
||||
});
|
||||
|
||||
test("getApiKeyMetadata recognizes ROUTER_API_KEY environment variable", async () => {
|
||||
const envKey = "sk-test-router-key-" + Date.now();
|
||||
process.env.ROUTER_API_KEY = envKey;
|
||||
|
||||
const metadata = await apiKeysDb.getApiKeyMetadata(envKey);
|
||||
|
||||
assert.ok(metadata);
|
||||
assert.equal(metadata.id, "env-key");
|
||||
|
||||
delete process.env.ROUTER_API_KEY;
|
||||
});
|
||||
|
||||
// ──── requireManagementAuth ────
|
||||
|
||||
async function setupAuth() {
|
||||
process.env.INITIAL_PASSWORD = "bootstrap-password";
|
||||
await localDb.updateSettings({ requireLogin: true, password: "" });
|
||||
}
|
||||
|
||||
function managementRequest(bearerKey?: string) {
|
||||
return new Request("https://example.com/api/combos", {
|
||||
headers: bearerKey ? { authorization: `Bearer ${bearerKey}` } : {},
|
||||
});
|
||||
}
|
||||
|
||||
function managementCliRequest(token: string) {
|
||||
const request = new Request("http://localhost:20128/api/combos", {
|
||||
headers: { [CLI_TOKEN_HEADER]: token },
|
||||
});
|
||||
return Object.assign(request, { ip: "127.0.0.1" }) as Request;
|
||||
}
|
||||
|
||||
test("requireManagementAuth returns 401 with no credentials", async () => {
|
||||
await setupAuth();
|
||||
const res = await requireManagementAuth(managementRequest());
|
||||
assert.ok(res);
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
test("requireManagementAuth returns 403 for an invalid management token", async () => {
|
||||
await setupAuth();
|
||||
const res = await requireManagementAuth(managementRequest("sk-not-a-real-key"));
|
||||
assert.ok(res);
|
||||
assert.equal(res.status, 403);
|
||||
const body = await res.json();
|
||||
assert.equal(body.error?.message, "Invalid management token");
|
||||
});
|
||||
|
||||
test("requireManagementAuth returns 403 for valid key without manage scope", async () => {
|
||||
await setupAuth();
|
||||
const key = await apiKeysDb.createApiKey("inference-only", "machine-test");
|
||||
const res = await requireManagementAuth(managementRequest(key.key));
|
||||
assert.ok(res);
|
||||
assert.equal(res.status, 403);
|
||||
const body = await res.json();
|
||||
assert.ok(body.error?.message?.includes("manage"));
|
||||
});
|
||||
|
||||
test("requireManagementAuth returns null for valid key with manage scope", async () => {
|
||||
await setupAuth();
|
||||
const key = await apiKeysDb.createApiKey("admin-key", "machine-test", ["manage"]);
|
||||
const res = await requireManagementAuth(managementRequest(key.key));
|
||||
assert.equal(res, null);
|
||||
});
|
||||
|
||||
test("requireManagementAuth accepts the 64-character local machine token", async () => {
|
||||
await setupAuth();
|
||||
const token = getMachineTokenSync();
|
||||
assert.equal(token.length, 64);
|
||||
const res = await requireManagementAuth(managementCliRequest(token));
|
||||
assert.equal(res, null);
|
||||
});
|
||||
|
||||
test("requireManagementAuth accepts the legacy 32-character local CLI token", async () => {
|
||||
await setupAuth();
|
||||
const token = getLegacyCliTokenSync();
|
||||
assert.equal(token.length, 32);
|
||||
const res = await requireManagementAuth(managementCliRequest(token));
|
||||
assert.equal(res, null);
|
||||
});
|
||||
|
||||
test("requireManagementAuth returns null for OMNIROUTE_API_KEY env passthrough", async () => {
|
||||
await setupAuth();
|
||||
const envKey = "sk-env-root-" + Date.now();
|
||||
process.env.OMNIROUTE_API_KEY = envKey;
|
||||
try {
|
||||
const res = await requireManagementAuth(managementRequest(envKey));
|
||||
assert.equal(res, null);
|
||||
} finally {
|
||||
delete process.env.OMNIROUTE_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
test("requireManagementAuth returns 403 for revoked key with manage scope", async () => {
|
||||
await setupAuth();
|
||||
const key = await apiKeysDb.createApiKey("revoked-admin", "machine-test", ["manage"]);
|
||||
await apiKeysDb.revokeApiKey(key.id);
|
||||
const res = await requireManagementAuth(managementRequest(key.key));
|
||||
assert.ok(res);
|
||||
assert.equal(res.status, 403);
|
||||
const body = await res.json();
|
||||
assert.equal(body.error?.message, "Invalid management token");
|
||||
});
|
||||
|
||||
test("requireManagementAuth returns null for valid JWT cookie", async () => {
|
||||
await setupAuth();
|
||||
process.env.JWT_SECRET = "jwt-secret-for-tests";
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
const token = await new SignJWT({ authenticated: true })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("1h")
|
||||
.sign(secret);
|
||||
|
||||
const request = {
|
||||
cookies: { get: (name: string) => (name === "auth_token" ? { value: token } : undefined) },
|
||||
headers: new Headers(),
|
||||
url: "https://example.com/api/combos",
|
||||
};
|
||||
const res = await requireManagementAuth(request as unknown as Request);
|
||||
assert.equal(res, null);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// GET /api/docs serves the Redoc-rendered API reference (#4781). Static HTML
|
||||
// shell that loads Redoc from a CDN and points it at /openapi.yaml (the
|
||||
// canonical spec served from public/openapi.yaml). PUBLIC tier — no auth.
|
||||
const docsRoute = await import("../../src/app/api/docs/route.ts");
|
||||
|
||||
test("GET /api/docs returns a 200 text/html Redoc shell", async () => {
|
||||
const response = docsRoute.GET();
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers.get("content-type") ?? "", /text\/html/);
|
||||
|
||||
const body = await response.text();
|
||||
// Renders Redoc and points it at the canonical spec.
|
||||
assert.match(body, /redoc/i);
|
||||
assert.ok(body.includes("/openapi.yaml"), "Redoc must load the canonical /openapi.yaml spec");
|
||||
// No stack traces / secrets leaked into the static shell.
|
||||
assert.ok(!body.includes("at /"), "static shell must not leak stack-trace frames");
|
||||
});
|
||||
|
||||
test("GET /api/docs is statically renderable (no per-request work)", () => {
|
||||
assert.equal(docsRoute.dynamic, "force-static");
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { extractApiErrorMessage } from "@/shared/http/apiErrorMessage";
|
||||
|
||||
describe("extractApiErrorMessage (#5340)", () => {
|
||||
it("surfaces the message from a structured error envelope", () => {
|
||||
const body = {
|
||||
error: { code: "INVALID_ORIGIN", message: "Invalid request origin", correlation_id: "x" },
|
||||
};
|
||||
assert.equal(extractApiErrorMessage(body, "fallback"), "Invalid request origin");
|
||||
});
|
||||
|
||||
it("returns a plain string error as-is", () => {
|
||||
assert.equal(extractApiErrorMessage({ error: "boom" }, "fallback"), "boom");
|
||||
});
|
||||
|
||||
it("never renders a raw error object — falls back when message is missing", () => {
|
||||
assert.equal(extractApiErrorMessage({ error: { code: "X" } }, "fallback"), "fallback");
|
||||
});
|
||||
|
||||
it("falls back for empty, null, or malformed bodies", () => {
|
||||
assert.equal(extractApiErrorMessage({ error: " " }, "fallback"), "fallback");
|
||||
assert.equal(extractApiErrorMessage(null, "fallback"), "fallback");
|
||||
assert.equal(extractApiErrorMessage({}, "fallback"), "fallback");
|
||||
assert.equal(extractApiErrorMessage({ error: { message: 42 } }, "fallback"), "fallback");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
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(), "omr-apikey-lifecycle-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
|
||||
const ORIGINAL_OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY;
|
||||
const ORIGINAL_ROUTER_API_KEY = process.env.ROUTER_API_KEY;
|
||||
|
||||
function reset() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
delete process.env.OMNIROUTE_API_KEY;
|
||||
delete process.env.ROUTER_API_KEY;
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
reset();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (ORIGINAL_OMNIROUTE_API_KEY === undefined) delete process.env.OMNIROUTE_API_KEY;
|
||||
else process.env.OMNIROUTE_API_KEY = ORIGINAL_OMNIROUTE_API_KEY;
|
||||
if (ORIGINAL_ROUTER_API_KEY === undefined) delete process.env.ROUTER_API_KEY;
|
||||
else process.env.ROUTER_API_KEY = ORIGINAL_ROUTER_API_KEY;
|
||||
});
|
||||
|
||||
async function makeKey(name = "lifecycle-test", machineId = "machine-lifecycle") {
|
||||
const created = await apiKeysDb.createApiKey(name, machineId);
|
||||
assert.ok(created?.key, "createApiKey returned a key");
|
||||
return created;
|
||||
}
|
||||
|
||||
test("validateApiKey returns true for a fresh active key", async () => {
|
||||
const created = await makeKey();
|
||||
assert.equal(await apiKeysDb.validateApiKey(created.key), true);
|
||||
});
|
||||
|
||||
test("validateApiKey rejects revoked keys after revokeApiKey", async () => {
|
||||
const created = await makeKey();
|
||||
assert.equal(await apiKeysDb.validateApiKey(created.key), true);
|
||||
|
||||
const ok = await apiKeysDb.revokeApiKey(created.id);
|
||||
assert.equal(ok, true);
|
||||
|
||||
assert.equal(await apiKeysDb.validateApiKey(created.key), false);
|
||||
});
|
||||
|
||||
test("validateApiKey rejects keys whose expires_at has passed", async () => {
|
||||
const created = await makeKey();
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
const ok = await apiKeysDb.setApiKeyExpiry(created.id, past);
|
||||
assert.equal(ok, true);
|
||||
assert.equal(await apiKeysDb.validateApiKey(created.key), false);
|
||||
});
|
||||
|
||||
test("validateApiKey accepts keys with future expires_at", async () => {
|
||||
const created = await makeKey();
|
||||
const future = new Date(Date.now() + 60 * 60_000).toISOString();
|
||||
const ok = await apiKeysDb.setApiKeyExpiry(created.id, future);
|
||||
assert.equal(ok, true);
|
||||
assert.equal(await apiKeysDb.validateApiKey(created.key), true);
|
||||
});
|
||||
|
||||
test("validateApiKey rejects deactivated keys (is_active=false)", async () => {
|
||||
const created = await makeKey();
|
||||
const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { isActive: false });
|
||||
assert.equal(ok, true);
|
||||
assert.equal(await apiKeysDb.validateApiKey(created.key), false);
|
||||
});
|
||||
|
||||
test("revokeApiKey is idempotent and returns false for missing id", async () => {
|
||||
const created = await makeKey();
|
||||
assert.equal(await apiKeysDb.revokeApiKey(created.id), true);
|
||||
assert.equal(await apiKeysDb.revokeApiKey(created.id), true);
|
||||
assert.equal(await apiKeysDb.revokeApiKey("00000000-0000-0000-0000-000000000000"), false);
|
||||
});
|
||||
|
||||
test("getApiKeyMetadata exposes lifecycle and policy fields", async () => {
|
||||
const created = await makeKey();
|
||||
await apiKeysDb.setApiKeyExpiry(created.id, new Date(Date.now() + 86_400_000).toISOString());
|
||||
|
||||
const md = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(md);
|
||||
assert.equal(md!.isActive, true);
|
||||
assert.equal(md!.revokedAt, null);
|
||||
assert.ok(md!.expiresAt && Date.parse(md!.expiresAt) > Date.now());
|
||||
assert.deepEqual(md!.ipAllowlist, []);
|
||||
assert.deepEqual(md!.scopes, []);
|
||||
});
|
||||
|
||||
test("validateApiKey accepts configured environment API keys", async () => {
|
||||
process.env.OMNIROUTE_API_KEY = "sk-env-lifecycle-test";
|
||||
assert.equal(await apiKeysDb.validateApiKey("sk-env-lifecycle-test"), true);
|
||||
});
|
||||
|
||||
test("validateApiKey updates last_used_at for persisted keys", async () => {
|
||||
const created = await makeKey();
|
||||
|
||||
assert.equal(await apiKeysDb.validateApiKey(created.key), true);
|
||||
|
||||
const db = core.getDbInstance() as {
|
||||
prepare(sql: string): { get(id: string): { last_used_at: string | null } | undefined };
|
||||
};
|
||||
const row = db.prepare("SELECT last_used_at FROM api_keys WHERE id = ?").get(created.id);
|
||||
|
||||
assert.ok(row?.last_used_at, "last_used_at should be set on successful validation");
|
||||
assert.ok(Date.parse(row.last_used_at) > 0, "last_used_at should be an ISO timestamp");
|
||||
});
|
||||
|
||||
test("getApiKeyMetadata returns proxyId for a key with proxy_id set", async () => {
|
||||
const created = await makeKey("proxy-test", "machine-proxy");
|
||||
|
||||
const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { proxyId: "test-proxy-id" });
|
||||
assert.equal(ok, true);
|
||||
|
||||
const metadata = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(metadata);
|
||||
assert.equal(metadata!.proxyId, "test-proxy-id");
|
||||
});
|
||||
|
||||
test("getApiKeyMetadata returns proxyId null for a key without proxy_id", async () => {
|
||||
const created = await makeKey("no-proxy-test", "machine-no-proxy");
|
||||
|
||||
const metadata = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(metadata);
|
||||
assert.equal(metadata!.proxyId, null);
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Unit tests for the masked API key fix (#523).
|
||||
*
|
||||
* GET /api/keys returns masked API keys (e.g. "sk-31c4e****8600").
|
||||
* CLI tool card dropdowns used `key.key` (the masked value) as the select
|
||||
* option value, so the masked key got written to config files, causing 401s.
|
||||
*
|
||||
* The fix: frontends send `keyId` (DB row id) instead, and backends resolve
|
||||
* the full key from DB via `resolveApiKey()`.
|
||||
*
|
||||
* This test inlines the resolver logic (ESM modules are read-only) with a
|
||||
* mock DB lookup function.
|
||||
*/
|
||||
import test, { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// ─── Mock DB + inlined resolveApiKey ────────────────────────────────────
|
||||
|
||||
/** In-memory key store keyed by id */
|
||||
const mockKeyStore = new Map();
|
||||
|
||||
/**
|
||||
* Mock getApiKeyById — mirrors the real function's contract:
|
||||
* returns the key record (with .key field) or null.
|
||||
*/
|
||||
async function getApiKeyById(id) {
|
||||
return mockKeyStore.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inlined resolveApiKey from src/shared/services/apiKeyResolver.ts
|
||||
* (can't import ESM modules in test runner without tsx overhead).
|
||||
*/
|
||||
async function resolveApiKey(apiKeyId, apiKey) {
|
||||
if (apiKeyId) {
|
||||
try {
|
||||
const keyRecord = await getApiKeyById(apiKeyId);
|
||||
if (keyRecord?.key) return keyRecord.key;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return apiKey || "sk_omniroute";
|
||||
}
|
||||
|
||||
// ─── Server-side masking function (matches /api/keys endpoint) ─────────
|
||||
|
||||
/**
|
||||
* Mask an API key for display: first 8 chars + "****" + last 4 chars.
|
||||
* This is the server-side masking that created the original bug.
|
||||
*/
|
||||
function maskApiKey(key) {
|
||||
if (!key || key.length <= 12) return key;
|
||||
return key.slice(0, 8) + "****" + key.slice(-4);
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("resolveApiKey", () => {
|
||||
it("resolves full key from apiKeyId when DB lookup succeeds", async () => {
|
||||
mockKeyStore.clear();
|
||||
mockKeyStore.set("key-001", { id: "key-001", key: "sk-31c4eabcd1234efgh8600" });
|
||||
|
||||
const result = await resolveApiKey("key-001", null);
|
||||
assert.equal(result, "sk-31c4eabcd1234efgh8600");
|
||||
});
|
||||
|
||||
it("falls back to apiKey when apiKeyId lookup returns null", async () => {
|
||||
mockKeyStore.clear();
|
||||
|
||||
const result = await resolveApiKey("nonexistent-id", "sk-fallback-key");
|
||||
assert.equal(result, "sk-fallback-key");
|
||||
});
|
||||
|
||||
it("falls back to apiKey when apiKeyId lookup throws", async () => {
|
||||
mockKeyStore.clear();
|
||||
// Override getApiKeyById to throw
|
||||
const originalGet = getApiKeyById;
|
||||
const throwingGet = async () => {
|
||||
throw new Error("DB connection failed");
|
||||
};
|
||||
|
||||
// Temporarily replace
|
||||
const savedRef = mockKeyStore.get.bind(mockKeyStore);
|
||||
// We'll call resolveApiKey with a custom approach — since the inlined
|
||||
// function calls our local getApiKeyById, let's just test by setting
|
||||
// up the store to throw via a different mechanism
|
||||
// Actually, let's just test the inline function directly with a mock:
|
||||
async function resolveApiKeyWithThrowingDb(apiKeyId, apiKey) {
|
||||
if (apiKeyId) {
|
||||
try {
|
||||
throw new Error("DB connection failed");
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return apiKey || "sk_omniroute";
|
||||
}
|
||||
|
||||
const result = await resolveApiKeyWithThrowingDb("key-001", "sk-fallback-key");
|
||||
assert.equal(result, "sk-fallback-key");
|
||||
});
|
||||
|
||||
it("falls back to sk_omniroute when both are null", async () => {
|
||||
mockKeyStore.clear();
|
||||
|
||||
const result = await resolveApiKey(null, null);
|
||||
assert.equal(result, "sk_omniroute");
|
||||
});
|
||||
|
||||
it("falls back to sk_omniroute when both are undefined", async () => {
|
||||
mockKeyStore.clear();
|
||||
|
||||
const result = await resolveApiKey(undefined, undefined);
|
||||
assert.equal(result, "sk_omniroute");
|
||||
});
|
||||
|
||||
it("prefers resolved key from apiKeyId over masked apiKey", async () => {
|
||||
mockKeyStore.clear();
|
||||
mockKeyStore.set("key-002", { id: "key-002", key: "sk-fullkey1234567890abcdef" });
|
||||
|
||||
// The masked apiKey is what /api/keys returns — should NOT be used
|
||||
const maskedKey = maskApiKey("sk-fullkey1234567890abcdef");
|
||||
const result = await resolveApiKey("key-002", maskedKey);
|
||||
assert.equal(result, "sk-fullkey1234567890abcdef");
|
||||
assert.notEqual(result, maskedKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskApiKey", () => {
|
||||
it("masks a long key correctly", () => {
|
||||
const result = maskApiKey("sk-31c4eabcd1234efgh8600");
|
||||
assert.equal(result, "sk-31c4e****8600");
|
||||
});
|
||||
|
||||
it("does not mask short keys", () => {
|
||||
const result = maskApiKey("sk-short12");
|
||||
assert.equal(result, "sk-short12");
|
||||
});
|
||||
|
||||
it("handles null/undefined gracefully", () => {
|
||||
assert.equal(maskApiKey(null), null);
|
||||
assert.equal(maskApiKey(undefined), undefined);
|
||||
});
|
||||
|
||||
it("produces a key that is NOT usable for auth", () => {
|
||||
const fullKey = "sk-31c4eabcd1234efgh8600";
|
||||
const masked = maskApiKey(fullKey);
|
||||
assert.notEqual(masked, fullKey);
|
||||
assert.ok(masked.includes("****"));
|
||||
assert.ok(masked.startsWith(fullKey.slice(0, 8)));
|
||||
assert.ok(masked.endsWith(fullKey.slice(-4)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bug reproduction: masked key written to config", () => {
|
||||
it("reproduces the original bug — masked key fails auth", () => {
|
||||
const fullKey = "sk-31c4eabcd1234efgh8600";
|
||||
const masked = maskApiKey(fullKey);
|
||||
|
||||
// Simulating what happened before the fix: dropdown used masked key as value
|
||||
// and sent it directly to the backend, which wrote it to config
|
||||
const writtenToConfig = masked; // BUG: masked key saved to config
|
||||
|
||||
// Auth with masked key would fail
|
||||
assert.notEqual(writtenToConfig, fullKey);
|
||||
assert.ok(writtenToConfig.includes("****"));
|
||||
|
||||
// This proves the bug: the config file contains "sk-31c4e****8600"
|
||||
// which is NOT a valid API key and would cause 401 errors
|
||||
});
|
||||
|
||||
it("verifies the fix — keyId resolves to full key", async () => {
|
||||
mockKeyStore.clear();
|
||||
const fullKey = "sk-31c4eabcd1234efgh8600";
|
||||
mockKeyStore.set("key-003", { id: "key-003", key: fullKey });
|
||||
|
||||
// After the fix: frontend sends keyId, backend resolves full key
|
||||
const resolved = await resolveApiKey("key-003", null);
|
||||
assert.equal(resolved, fullKey);
|
||||
assert.ok(!resolved.includes("****"));
|
||||
});
|
||||
|
||||
it("simulates full flow: masked dropdown -> keyId -> resolved full key", async () => {
|
||||
mockKeyStore.clear();
|
||||
const fullKey = "sk-31c4eabcd1234efgh8600";
|
||||
const keyId = "key-004";
|
||||
mockKeyStore.set(keyId, { id: keyId, key: fullKey });
|
||||
|
||||
// Step 1: /api/keys returns masked list
|
||||
const apiKeysResponse = [{ id: keyId, key: maskApiKey(fullKey) }];
|
||||
|
||||
// Step 2: Frontend dropdown now uses key.id as value (not key.key)
|
||||
const selectedValue = apiKeysResponse[0].id; // "key-004" (was key.key before fix)
|
||||
assert.equal(selectedValue, keyId);
|
||||
|
||||
// Step 3: Frontend sends keyId to backend
|
||||
const requestBody = { keyId: selectedValue };
|
||||
|
||||
// Step 4: Backend resolves full key from DB
|
||||
const resolvedKey = await resolveApiKey(requestBody.keyId, null);
|
||||
assert.equal(resolvedKey, fullKey);
|
||||
assert.ok(!resolvedKey.includes("****"));
|
||||
});
|
||||
|
||||
it("handles prefix/suffix matching for restoring saved key from file", () => {
|
||||
const fullKey = "sk-31c4eabcd1234efgh8600";
|
||||
const masked = maskApiKey(fullKey);
|
||||
|
||||
// Simulates what ClaudeToolCard does when reading a key from file:
|
||||
// The file contains the full key, and we match against the masked list
|
||||
const fileKeyPrefix = fullKey.slice(0, 8); // "sk-31c4e"
|
||||
const fileKeySuffix = fullKey.slice(-4); // "8600"
|
||||
|
||||
const apiKeysResponse = [{ id: "key-005", key: masked }];
|
||||
|
||||
// Match by prefix/suffix
|
||||
const matchedKey = apiKeysResponse.find(
|
||||
(k) => k.key && k.key.startsWith(fileKeyPrefix) && k.key.endsWith(fileKeySuffix)
|
||||
);
|
||||
|
||||
assert.ok(matchedKey);
|
||||
assert.equal(matchedKey.id, "key-005");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,645 @@
|
||||
/**
|
||||
* Unit tests for API key policy helpers:
|
||||
* - parseIsActive (via parseAccessSchedule indirect coverage)
|
||||
* - isWithinSchedule logic (tested directly via a re-export or by mocking Date)
|
||||
*
|
||||
* Because isWithinSchedule is module-private, we test it through observable
|
||||
* behavior: feeding real Date overrides via globalThis.Date stubbing.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Extract the schedule-check logic into a standalone helper exported only
|
||||
* for tests — OR test it end-to-end through enforceApiKeyPolicy.
|
||||
* 2. Since enforceApiKeyPolicy needs a full DB + HTTP Request, we isolate
|
||||
* isWithinSchedule by copying its logic into this test file and verifying
|
||||
* the exact same algorithm.
|
||||
*/
|
||||
|
||||
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";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-api-key-policy-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "task-607-api-key-secret";
|
||||
|
||||
const coreDb = await import("../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const combosDb = await import("../../src/lib/db/combos.ts");
|
||||
const modelComboMappingsDb = await import("../../src/lib/db/modelComboMappings.ts");
|
||||
const costRules = await import("../../src/domain/costRules.ts");
|
||||
const rateLimiter = await import("../../src/shared/utils/rateLimiter.ts");
|
||||
|
||||
rateLimiter.setRateLimiterTestMode(true);
|
||||
|
||||
function getFsErrorCode(error: unknown): string | undefined {
|
||||
if (typeof error !== "object" || error === null || !("code" in error)) return undefined;
|
||||
const { code } = error as { code?: unknown };
|
||||
return typeof code === "string" ? code : undefined;
|
||||
}
|
||||
|
||||
async function resetStorage() {
|
||||
apiKeysDb.resetApiKeyState();
|
||||
costRules.resetCostData();
|
||||
coreDb.resetDbInstance();
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
break;
|
||||
} catch (error: unknown) {
|
||||
const code = getFsErrorCode(error);
|
||||
if ((code === "EBUSY" || code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function loadPolicy(label) {
|
||||
const modulePath = path.join(process.cwd(), "src/shared/utils/apiKeyPolicy.ts");
|
||||
return import(`${pathToFileURL(modulePath).href}?case=${label}-${Date.now()}`);
|
||||
}
|
||||
|
||||
async function createKeyWithPolicy(update = {}) {
|
||||
const created = await apiKeysDb.createApiKey("Policy Key", "machine-607");
|
||||
if (Object.keys(update).length > 0) {
|
||||
await apiKeysDb.updateApiKeyPermissions(created.id, update);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
function makePolicyRequest(apiKey) {
|
||||
return new Request("http://localhost/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
|
||||
});
|
||||
}
|
||||
|
||||
function makeAnthropicPolicyRequest(apiKey) {
|
||||
return new Request("http://localhost/v1/messages", {
|
||||
method: "POST",
|
||||
headers: apiKey
|
||||
? {
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
}
|
||||
: { "anthropic-version": "2023-06-01" },
|
||||
});
|
||||
}
|
||||
|
||||
async function readErrorMessage(response) {
|
||||
const body = (await response.json()) as { error?: { message?: unknown } };
|
||||
return typeof body.error?.message === "string" ? body.error.message : "";
|
||||
}
|
||||
|
||||
function getCurrentUtcDay() {
|
||||
const dayName = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "UTC",
|
||||
weekday: "short",
|
||||
}).format(new Date());
|
||||
return { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }[dayName];
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
delete process.env.DEFAULT_RATE_LIMIT_PER_DAY;
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
apiKeysDb.resetApiKeyState();
|
||||
costRules.resetCostData();
|
||||
coreDb.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── Replicate the isWithinSchedule logic for pure unit testing ───────────────
|
||||
//
|
||||
// This mirrors the implementation in apiKeyPolicy.ts exactly.
|
||||
// If the production code changes, update this copy too.
|
||||
|
||||
/**
|
||||
* @param {{ enabled: boolean; from: string; until: string; days: number[]; tz: string }} schedule
|
||||
* @param {Date} now — injectable "current time"
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isWithinSchedule(schedule, now = new Date()) {
|
||||
if (!schedule.enabled) return true;
|
||||
|
||||
let localTimeStr;
|
||||
try {
|
||||
localTimeStr = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: schedule.tz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(now);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedTime = localTimeStr.replace(/^24:/, "00:");
|
||||
const [localHour, localMin] = normalizedTime.split(":").map(Number);
|
||||
const localMinutes = localHour * 60 + localMin;
|
||||
|
||||
let localDayStr;
|
||||
try {
|
||||
localDayStr = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: schedule.tz,
|
||||
weekday: "short",
|
||||
}).format(now);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
const dayMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
||||
const localDay = dayMap[localDayStr] ?? now.getDay();
|
||||
|
||||
if (!schedule.days.includes(localDay)) return false;
|
||||
|
||||
const [fromHour, fromMin] = schedule.from.split(":").map(Number);
|
||||
const [untilHour, untilMin] = schedule.until.split(":").map(Number);
|
||||
const fromMinutes = fromHour * 60 + fromMin;
|
||||
const untilMinutes = untilHour * 60 + untilMin;
|
||||
|
||||
if (untilMinutes < fromMinutes) {
|
||||
return localMinutes >= fromMinutes || localMinutes < untilMinutes;
|
||||
}
|
||||
|
||||
return localMinutes >= fromMinutes && localMinutes < untilMinutes;
|
||||
}
|
||||
|
||||
// ─── parseIsActive helper (mirrors production code) ──────────────────────────
|
||||
function parseIsActive(value) {
|
||||
if (value === 0 || value === "0" || value === false) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── parseAccessSchedule helper (mirrors production code) ────────────────────
|
||||
function parseAccessSchedule(value) {
|
||||
if (!value || typeof value !== "string" || value.trim() === "") return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||||
if (
|
||||
typeof parsed.enabled !== "boolean" ||
|
||||
typeof parsed.from !== "string" ||
|
||||
typeof parsed.until !== "string" ||
|
||||
!Array.isArray(parsed.days) ||
|
||||
typeof parsed.tz !== "string"
|
||||
)
|
||||
return null;
|
||||
const days = parsed.days.filter(
|
||||
(d) => typeof d === "number" && Number.isInteger(d) && d >= 0 && d <= 6
|
||||
);
|
||||
return { enabled: parsed.enabled, from: parsed.from, until: parsed.until, days, tz: parsed.tz };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── parseIsActive ────────────────────────────────────────────────────────────
|
||||
|
||||
test("parseIsActive: undefined → true (default active)", () => {
|
||||
assert.equal(parseIsActive(undefined), true);
|
||||
});
|
||||
|
||||
test("parseIsActive: null → true", () => {
|
||||
assert.equal(parseIsActive(null), true);
|
||||
});
|
||||
|
||||
test("parseIsActive: 1 → true", () => {
|
||||
assert.equal(parseIsActive(1), true);
|
||||
});
|
||||
|
||||
test("parseIsActive: true → true", () => {
|
||||
assert.equal(parseIsActive(true), true);
|
||||
});
|
||||
|
||||
test("parseIsActive: 0 → false", () => {
|
||||
assert.equal(parseIsActive(0), false);
|
||||
});
|
||||
|
||||
test("parseIsActive: false → false", () => {
|
||||
assert.equal(parseIsActive(false), false);
|
||||
});
|
||||
|
||||
test("parseIsActive: '0' → false", () => {
|
||||
assert.equal(parseIsActive("0"), false);
|
||||
});
|
||||
|
||||
// ─── parseAccessSchedule ──────────────────────────────────────────────────────
|
||||
|
||||
test("parseAccessSchedule: null/empty → null", () => {
|
||||
assert.equal(parseAccessSchedule(null), null);
|
||||
assert.equal(parseAccessSchedule(""), null);
|
||||
assert.equal(parseAccessSchedule(" "), null);
|
||||
});
|
||||
|
||||
test("parseAccessSchedule: valid JSON → object", () => {
|
||||
const input = JSON.stringify({
|
||||
enabled: true,
|
||||
from: "08:00",
|
||||
until: "18:00",
|
||||
days: [1, 2, 3, 4, 5],
|
||||
tz: "America/Sao_Paulo",
|
||||
});
|
||||
const result = parseAccessSchedule(input);
|
||||
assert.deepEqual(result, {
|
||||
enabled: true,
|
||||
from: "08:00",
|
||||
until: "18:00",
|
||||
days: [1, 2, 3, 4, 5],
|
||||
tz: "America/Sao_Paulo",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseAccessSchedule: invalid day values are filtered out", () => {
|
||||
const input = JSON.stringify({
|
||||
enabled: true,
|
||||
from: "08:00",
|
||||
until: "18:00",
|
||||
days: [1, 7, -1, 5],
|
||||
tz: "UTC",
|
||||
});
|
||||
const result = parseAccessSchedule(input);
|
||||
assert.deepEqual(result.days, [1, 5]);
|
||||
});
|
||||
|
||||
test("parseAccessSchedule: missing required field → null", () => {
|
||||
const input = JSON.stringify({ enabled: true, from: "08:00", until: "18:00", days: [1] });
|
||||
assert.equal(parseAccessSchedule(input), null); // tz missing
|
||||
});
|
||||
|
||||
test("parseAccessSchedule: invalid JSON → null", () => {
|
||||
assert.equal(parseAccessSchedule("{broken json}"), null);
|
||||
});
|
||||
|
||||
// ─── isWithinSchedule ────────────────────────────────────────────────────────
|
||||
|
||||
// Helper: create a Date at a specific UTC datetime
|
||||
function utc(y, m, d, h, min) {
|
||||
return new Date(Date.UTC(y, m - 1, d, h, min));
|
||||
}
|
||||
|
||||
test("isWithinSchedule: enabled=false → always true", () => {
|
||||
const schedule = { enabled: false, from: "00:00", until: "00:01", days: [1], tz: "UTC" };
|
||||
// Even a time that would be blocked
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 12, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: time within window → true", () => {
|
||||
// Monday 2024-03-11, 09:00 UTC (UTC timezone)
|
||||
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 9, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: time before window → false", () => {
|
||||
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
|
||||
// 07:59
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 7, 59)), false);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: time exactly at 'from' → true (inclusive)", () => {
|
||||
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 8, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: time exactly at 'until' → false (exclusive)", () => {
|
||||
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 18, 0)), false);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: time after window → false", () => {
|
||||
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 20, 0)), false);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: wrong weekday → false", () => {
|
||||
// Monday (day 1) schedule, but 2024-03-12 is Tuesday (day 2)
|
||||
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 12, 10, 0)), false);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: multiple days — matching day → true", () => {
|
||||
// Mon-Fri schedule, Wednesday (3)
|
||||
const schedule = {
|
||||
enabled: true,
|
||||
from: "09:00",
|
||||
until: "17:00",
|
||||
days: [1, 2, 3, 4, 5],
|
||||
tz: "UTC",
|
||||
};
|
||||
// 2024-03-13 is Wednesday
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 13, 12, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: multiple days — Saturday blocked", () => {
|
||||
const schedule = {
|
||||
enabled: true,
|
||||
from: "09:00",
|
||||
until: "17:00",
|
||||
days: [1, 2, 3, 4, 5],
|
||||
tz: "UTC",
|
||||
};
|
||||
// 2024-03-09 is Saturday (day 6)
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 9, 12, 0)), false);
|
||||
});
|
||||
|
||||
// ─── Overnight schedule tests ─────────────────────────────────────────────────
|
||||
|
||||
test("isWithinSchedule: overnight window — time after midnight → true", () => {
|
||||
// 22:00 → 06:00, Monday
|
||||
const schedule = { enabled: true, from: "22:00", until: "06:00", days: [1], tz: "UTC" };
|
||||
// 02:30 Monday
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 2, 30)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: overnight window — time before start → false", () => {
|
||||
const schedule = { enabled: true, from: "22:00", until: "06:00", days: [1], tz: "UTC" };
|
||||
// 21:59 Monday
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 21, 59)), false);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: overnight window — time after end → false", () => {
|
||||
const schedule = { enabled: true, from: "22:00", until: "06:00", days: [1], tz: "UTC" };
|
||||
// 06:01 Monday
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 6, 1)), false);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: overnight window — time exactly at start → true", () => {
|
||||
const schedule = { enabled: true, from: "22:00", until: "06:00", days: [1], tz: "UTC" };
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 22, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: invalid timezone → fail-open (true)", () => {
|
||||
const schedule = {
|
||||
enabled: true,
|
||||
from: "08:00",
|
||||
until: "18:00",
|
||||
days: [1, 2, 3, 4, 5],
|
||||
tz: "Invalid/Zone",
|
||||
};
|
||||
// Should not throw, should return true (fail-open)
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 12, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: America/Sao_Paulo timezone conversion", () => {
|
||||
// UTC 2024-03-11 15:00 = BRT (UTC-3) 12:00, Monday
|
||||
const schedule = {
|
||||
enabled: true,
|
||||
from: "08:00",
|
||||
until: "18:00",
|
||||
days: [1],
|
||||
tz: "America/Sao_Paulo",
|
||||
};
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 15, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: America/Sao_Paulo — outside window", () => {
|
||||
// UTC 2024-03-11 22:00 = BRT 19:00, Monday — after 18:00
|
||||
const schedule = {
|
||||
enabled: true,
|
||||
from: "08:00",
|
||||
until: "18:00",
|
||||
days: [1],
|
||||
tz: "America/Sao_Paulo",
|
||||
};
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 22, 0)), false);
|
||||
});
|
||||
|
||||
test("enforceApiKeyPolicy bypasses local mode and unknown keys", async () => {
|
||||
const policy = await loadPolicy("bypass");
|
||||
|
||||
assert.deepEqual(await policy.enforceApiKeyPolicy(makePolicyRequest(null), "openai/gpt-4.1"), {
|
||||
apiKey: null,
|
||||
apiKeyInfo: null,
|
||||
rejection: null,
|
||||
});
|
||||
|
||||
const unknown = await policy.enforceApiKeyPolicy(
|
||||
makePolicyRequest("sk-unknown"),
|
||||
"openai/gpt-4.1"
|
||||
);
|
||||
assert.equal(unknown.apiKey, "sk-unknown");
|
||||
assert.equal(unknown.apiKeyInfo, null);
|
||||
assert.equal(unknown.rejection, null);
|
||||
});
|
||||
|
||||
test("enforceApiKeyPolicy rejects disabled keys and blocked schedules", async () => {
|
||||
const disabledKey = await createKeyWithPolicy({ isActive: false });
|
||||
const blockedDay = (getCurrentUtcDay() + 1) % 7;
|
||||
const scheduledKey = await createKeyWithPolicy({
|
||||
accessSchedule: {
|
||||
enabled: true,
|
||||
from: "00:00",
|
||||
until: "23:59",
|
||||
days: [blockedDay],
|
||||
tz: "UTC",
|
||||
},
|
||||
});
|
||||
const policy = await loadPolicy("disabled-and-schedule");
|
||||
|
||||
const disabled = await policy.enforceApiKeyPolicy(makePolicyRequest(disabledKey.key), null);
|
||||
assert.equal(disabled.rejection.status, 403);
|
||||
assert.equal(await readErrorMessage(disabled.rejection), "This API key is disabled");
|
||||
|
||||
const blocked = await policy.enforceApiKeyPolicy(makePolicyRequest(scheduledKey.key), null);
|
||||
assert.equal(blocked.rejection.status, 403);
|
||||
assert.match(await readErrorMessage(blocked.rejection), /Access denied outside allowed hours/);
|
||||
});
|
||||
|
||||
test("enforceApiKeyPolicy rejects disallowed models and exhausted budgets", async () => {
|
||||
const restrictedKey = await createKeyWithPolicy({
|
||||
allowedModels: ["openai/gpt-4.1"],
|
||||
});
|
||||
const budgetedKey = await createKeyWithPolicy();
|
||||
const policy = await loadPolicy("model-and-budget");
|
||||
|
||||
const disallowed = await policy.enforceApiKeyPolicy(
|
||||
makePolicyRequest(restrictedKey.key),
|
||||
"anthropic/claude-3-7-sonnet"
|
||||
);
|
||||
assert.equal(disallowed.rejection.status, 403);
|
||||
assert.match(await readErrorMessage(disallowed.rejection), /not allowed/);
|
||||
|
||||
const budgetMeta = await apiKeysDb.getApiKeyMetadata(budgetedKey.key);
|
||||
costRules.setBudget(budgetMeta.id, { dailyLimitUsd: 1, warningThreshold: 0.5 });
|
||||
costRules.recordCost(budgetMeta.id, 2);
|
||||
|
||||
const overBudget = await policy.enforceApiKeyPolicy(
|
||||
makePolicyRequest(budgetedKey.key),
|
||||
"openai/gpt-4.1"
|
||||
);
|
||||
assert.equal(overBudget.rejection.status, 429);
|
||||
assert.match(await readErrorMessage(overBudget.rejection), /Daily budget exceeded/);
|
||||
});
|
||||
|
||||
test("enforceApiKeyPolicy returns Anthropic error envelope for /v1/messages model denials", async () => {
|
||||
const restrictedKey = await createKeyWithPolicy({
|
||||
allowedModels: ["cc/*"],
|
||||
blockedModels: ["claude-fable*", "fable"],
|
||||
});
|
||||
const policy = await loadPolicy("anthropic-model-denial");
|
||||
|
||||
const denied = await policy.enforceApiKeyPolicy(
|
||||
makeAnthropicPolicyRequest(restrictedKey.key),
|
||||
"claude-fable-5"
|
||||
);
|
||||
|
||||
assert.equal(denied.rejection.status, 400);
|
||||
const body = await denied.rejection.json();
|
||||
assert.equal(body.type, "error");
|
||||
assert.equal(body.error.type, "invalid_request_error");
|
||||
assert.match(body.error.message, /claude-fable-5/);
|
||||
assert.equal(
|
||||
body.error.message,
|
||||
'Model "claude-fable-5" is not enabled or quota is insufficient. Choose another allowed model.'
|
||||
);
|
||||
assert.doesNotMatch(body.error.message, /login|authenticate|api key|credential|omniroute/i);
|
||||
assert.equal(body.error.code, undefined);
|
||||
});
|
||||
|
||||
test("enforceApiKeyPolicy does not rate-limit unrestricted keys by default", async () => {
|
||||
const unrestrictedKey = await createKeyWithPolicy({ allowedModels: ["openai/*"] });
|
||||
const policy = await loadPolicy("default-no-request-limit");
|
||||
|
||||
for (let i = 0; i < 1005; i += 1) {
|
||||
const result = await policy.enforceApiKeyPolicy(
|
||||
makePolicyRequest(unrestrictedKey.key),
|
||||
"openai/gpt-4.1"
|
||||
);
|
||||
assert.equal(result.rejection, null);
|
||||
}
|
||||
});
|
||||
|
||||
test("enforceApiKeyPolicy enforces explicit env fallback request limits", async () => {
|
||||
process.env.DEFAULT_RATE_LIMIT_PER_DAY = "1";
|
||||
const unrestrictedKey = await createKeyWithPolicy({ allowedModels: ["openai/*"] });
|
||||
const policy = await loadPolicy("env-request-limit");
|
||||
|
||||
const first = await policy.enforceApiKeyPolicy(
|
||||
makePolicyRequest(unrestrictedKey.key),
|
||||
"openai/gpt-4.1"
|
||||
);
|
||||
assert.equal(first.rejection, null);
|
||||
|
||||
const second = await policy.enforceApiKeyPolicy(
|
||||
makePolicyRequest(unrestrictedKey.key),
|
||||
"openai/gpt-4.1"
|
||||
);
|
||||
assert.equal(second.rejection.status, 429);
|
||||
assert.match(await readErrorMessage(second.rejection), /Request limit exceeded/);
|
||||
});
|
||||
|
||||
test("enforceApiKeyPolicy enforces custom multi-window rate limits", async () => {
|
||||
const limitedKey = await createKeyWithPolicy({
|
||||
allowedModels: ["openai/*"],
|
||||
rateLimits: [{ limit: 1, window: 60 }],
|
||||
});
|
||||
const policy = await loadPolicy("custom-request-limits");
|
||||
|
||||
const first = await policy.enforceApiKeyPolicy(
|
||||
makePolicyRequest(limitedKey.key),
|
||||
"openai/gpt-4.1"
|
||||
);
|
||||
assert.equal(first.rejection, null);
|
||||
|
||||
const second = await policy.enforceApiKeyPolicy(
|
||||
makePolicyRequest(limitedKey.key),
|
||||
"openai/gpt-4.1"
|
||||
);
|
||||
assert.equal(second.rejection.status, 429);
|
||||
assert.match(await readErrorMessage(second.rejection), /Request limit exceeded/);
|
||||
});
|
||||
|
||||
test("enforceApiKeyPolicy enforces combo allowlists separately from model allowlists", async () => {
|
||||
const allowedKey = await createKeyWithPolicy({
|
||||
allowedModels: ["openai/*"],
|
||||
allowedCombos: ["fast-chat", "mapped-chat"],
|
||||
});
|
||||
const blockedKey = await createKeyWithPolicy({
|
||||
allowedCombos: ["slow-chat"],
|
||||
});
|
||||
await combosDb.createCombo({
|
||||
name: "fast-chat",
|
||||
strategy: "priority",
|
||||
models: ["anthropic/claude-3-5-sonnet"],
|
||||
});
|
||||
await combosDb.createCombo({
|
||||
name: "mapped-chat",
|
||||
strategy: "priority",
|
||||
models: ["openai/gpt-4.1"],
|
||||
});
|
||||
const mappedCombo = await combosDb.getComboByName("mapped-chat");
|
||||
assert.ok(mappedCombo?.id);
|
||||
await modelComboMappingsDb.createModelComboMapping({
|
||||
pattern: "mapped-model-*",
|
||||
comboId: mappedCombo.id as string,
|
||||
});
|
||||
const policy = await loadPolicy("combo-access");
|
||||
|
||||
const allowed = await policy.enforceApiKeyPolicy(
|
||||
makePolicyRequest(allowedKey.key),
|
||||
"combo/fast-chat"
|
||||
);
|
||||
assert.equal(allowed.rejection, null);
|
||||
|
||||
const blocked = await policy.enforceApiKeyPolicy(
|
||||
makePolicyRequest(blockedKey.key),
|
||||
"combo/fast-chat"
|
||||
);
|
||||
assert.equal(blocked.rejection.status, 403);
|
||||
assert.match(await readErrorMessage(blocked.rejection), /Combo "fast-chat" is not allowed/);
|
||||
|
||||
const mapped = await policy.enforceApiKeyPolicy(
|
||||
makePolicyRequest(allowedKey.key),
|
||||
"mapped-model-1"
|
||||
);
|
||||
assert.equal(mapped.rejection, null);
|
||||
});
|
||||
|
||||
test("enforceApiKeyPolicy applies configured throttle delay", async () => {
|
||||
const delayedKey = await createKeyWithPolicy({ throttleDelayMs: 25 });
|
||||
const policy = await loadPolicy("throttle-delay");
|
||||
|
||||
const startedAt = Date.now();
|
||||
const result = await policy.enforceApiKeyPolicy(
|
||||
makePolicyRequest(delayedKey.key),
|
||||
"openai/gpt-4.1"
|
||||
);
|
||||
|
||||
assert.equal(result.rejection, null);
|
||||
assert.equal(result.apiKeyInfo.throttleDelayMs, 25);
|
||||
assert.ok(Date.now() - startedAt >= 20);
|
||||
});
|
||||
|
||||
test("enforceApiKeyPolicy enforces request-per-minute limits and returns success when allowed", async () => {
|
||||
const limitedKey = await createKeyWithPolicy({
|
||||
allowedModels: ["openai/*"],
|
||||
maxRequestsPerMinute: 1,
|
||||
});
|
||||
const policy = await loadPolicy("request-limits");
|
||||
|
||||
const first = await policy.enforceApiKeyPolicy(
|
||||
makePolicyRequest(limitedKey.key),
|
||||
"openai/gpt-4.1"
|
||||
);
|
||||
assert.equal(first.rejection, null);
|
||||
assert.equal(first.apiKeyInfo.maxRequestsPerMinute, 1);
|
||||
|
||||
const second = await policy.enforceApiKeyPolicy(
|
||||
makePolicyRequest(limitedKey.key),
|
||||
"openai/gpt-4.1"
|
||||
);
|
||||
assert.equal(second.rejection.status, 429);
|
||||
assert.match(await readErrorMessage(second.rejection), /Request limit exceeded/);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
API_KEY_BYPASS_PROVIDER_QUOTA_SCOPE,
|
||||
hasProviderQuotaBypassScope,
|
||||
} from "../../src/shared/constants/apiKeyPolicyScopes.ts";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
test("provider quota bypass scope helper is explicit and strict", () => {
|
||||
assert.equal(API_KEY_BYPASS_PROVIDER_QUOTA_SCOPE, "policy:bypass-provider-quota");
|
||||
assert.equal(hasProviderQuotaBypassScope([API_KEY_BYPASS_PROVIDER_QUOTA_SCOPE]), true);
|
||||
assert.equal(hasProviderQuotaBypassScope(["policy:other"]), false);
|
||||
assert.equal(hasProviderQuotaBypassScope(null), false);
|
||||
});
|
||||
|
||||
test("chat handler maps API key provider quota bypass scope to auth bypass option", () => {
|
||||
const source = fs.readFileSync(path.join(repoRoot, "src/sse/handlers/chat.ts"), "utf8");
|
||||
|
||||
assert.match(source, /hasProviderQuotaBypassScope\(apiKeyInfo\?\.scopes\)/);
|
||||
assert.match(source, /bypassProviderQuotaPolicy[\s\S]*bypassQuotaPolicy: true/);
|
||||
assert.match(source, /relayOptions[\s\S]*bypassProviderQuotaPolicy: true/);
|
||||
});
|
||||
|
||||
test("auto combo disables hard provider quota cutoffs when relay requests bypass", () => {
|
||||
// The auto-strategy bypass logic was extracted verbatim from combo.ts into the
|
||||
// resolveAutoStrategy leaf (Block J Task 2); the source scan follows the code.
|
||||
const source = fs.readFileSync(
|
||||
path.join(repoRoot, "open-sse/services/combo/resolveAutoStrategy.ts"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert.match(source, /relayOptions\?\.bypassProviderQuotaPolicy === true/);
|
||||
assert.match(source, /quotaPreflight:[\s\S]*enabled: false/);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
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(), "omr-apikey-regen-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-secret-regen";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
|
||||
function reset() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
reset();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("regenerateApiKey creates a new key and invalidates the old one", async () => {
|
||||
const machineId = "test-machine-regen";
|
||||
const created = await apiKeysDb.createApiKey("Regen Test", machineId);
|
||||
const oldKey = created.key;
|
||||
const oldId = created.id;
|
||||
|
||||
assert.ok(oldKey);
|
||||
assert.equal(await apiKeysDb.validateApiKey(oldKey), true);
|
||||
|
||||
// Regenerate
|
||||
const result = await apiKeysDb.regenerateApiKey(oldId);
|
||||
assert.ok(result?.key);
|
||||
const regenerated = result!.key;
|
||||
|
||||
assert.notEqual(regenerated, oldKey);
|
||||
|
||||
// New key should be valid
|
||||
assert.equal(await apiKeysDb.validateApiKey(regenerated), true);
|
||||
|
||||
// Old key should be invalid
|
||||
assert.equal(await apiKeysDb.validateApiKey(oldKey), false);
|
||||
|
||||
// Name and machineId should persist
|
||||
const md = await apiKeysDb.getApiKeyMetadata(regenerated);
|
||||
assert.equal(md?.name, "Regen Test");
|
||||
assert.ok(regenerated.startsWith(`sk-${machineId}-`));
|
||||
});
|
||||
|
||||
test("regenerateApiKey returns null for non-existent ID", async () => {
|
||||
const result = await apiKeysDb.regenerateApiKey("00000000-0000-0000-0000-000000000000");
|
||||
assert.equal(result, null);
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
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-api-key-reveal-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-api-key-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const featureFlagsDb = await import("../../src/lib/db/featureFlags.ts");
|
||||
const listRoute = await import("../../src/app/api/keys/route.ts");
|
||||
const revealRoute = await import("../../src/app/api/keys/[id]/reveal/route.ts");
|
||||
|
||||
const MACHINE_ID = "1234567890abcdef";
|
||||
|
||||
async function resetStorage() {
|
||||
delete process.env.ALLOW_API_KEY_REVEAL;
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function maskKey(key) {
|
||||
return key.slice(0, 8) + "****" + key.slice(-4);
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
delete process.env.ALLOW_API_KEY_REVEAL;
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("GET /api/keys stays masked even when reveal is enabled", async () => {
|
||||
process.env.ALLOW_API_KEY_REVEAL = "true";
|
||||
const created = await apiKeysDb.createApiKey("Primary Key", MACHINE_ID);
|
||||
|
||||
const response = await listRoute.GET(new Request("http://localhost/api/keys"));
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.allowKeyReveal, true);
|
||||
assert.equal(Array.isArray(body.keys), true);
|
||||
assert.equal(body.keys[0].key, maskKey(created.key));
|
||||
});
|
||||
|
||||
test("GET /api/keys falls back to default pagination for invalid query params", async () => {
|
||||
await apiKeysDb.createApiKey("Alpha", MACHINE_ID);
|
||||
await apiKeysDb.createApiKey("Beta", MACHINE_ID);
|
||||
|
||||
const response = await listRoute.GET(
|
||||
new Request("http://localhost/api/keys?limit=abc&offset=xyz")
|
||||
);
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.allowKeyReveal, false);
|
||||
assert.equal(body.total, 2);
|
||||
assert.equal(body.keys.length, 2);
|
||||
assert.equal(
|
||||
body.keys.every((entry) => entry.key.includes("****")),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("GET /api/keys returns 500 when key loading fails unexpectedly", async () => {
|
||||
await apiKeysDb.createApiKey("Primary Key", MACHINE_ID);
|
||||
|
||||
const db = core.getDbInstance();
|
||||
const originalPrepare = db.prepare.bind(db);
|
||||
|
||||
db.prepare = (sql, ...args) => {
|
||||
if (typeof sql === "string" && sql.includes("SELECT * FROM api_keys")) {
|
||||
throw new Error("db exploded");
|
||||
}
|
||||
return originalPrepare(sql, ...args);
|
||||
};
|
||||
apiKeysDb.resetApiKeyState();
|
||||
|
||||
try {
|
||||
const response = await listRoute.GET(new Request("http://localhost/api/keys"));
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 500);
|
||||
assert.equal(body.error, "Failed to fetch keys");
|
||||
} finally {
|
||||
db.prepare = originalPrepare;
|
||||
apiKeysDb.resetApiKeyState();
|
||||
}
|
||||
});
|
||||
|
||||
test("GET /api/keys/[id]/reveal rejects requests when reveal is disabled", async () => {
|
||||
const created = await apiKeysDb.createApiKey("Primary Key", MACHINE_ID);
|
||||
const request = new Request(`http://localhost/api/keys/${created.id}/reveal`);
|
||||
|
||||
const response = await revealRoute.GET(request, {
|
||||
params: Promise.resolve({ id: created.id }),
|
||||
});
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.equal(body.error, "API key reveal is disabled");
|
||||
});
|
||||
|
||||
test("GET /api/keys/[id]/reveal returns the full key when reveal is enabled", async () => {
|
||||
process.env.ALLOW_API_KEY_REVEAL = "true";
|
||||
const created = await apiKeysDb.createApiKey("Primary Key", MACHINE_ID);
|
||||
const request = new Request(`http://localhost/api/keys/${created.id}/reveal`);
|
||||
|
||||
const response = await revealRoute.GET(request, {
|
||||
params: Promise.resolve({ id: created.id }),
|
||||
});
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.key, created.key);
|
||||
});
|
||||
|
||||
test("GET /api/keys/[id]/reveal honors the ALLOW_API_KEY_REVEAL feature flag override", async () => {
|
||||
featureFlagsDb.setFeatureFlagOverride("ALLOW_API_KEY_REVEAL", "true");
|
||||
const created = await apiKeysDb.createApiKey("Primary Key", MACHINE_ID);
|
||||
const request = new Request(`http://localhost/api/keys/${created.id}/reveal`);
|
||||
|
||||
const response = await revealRoute.GET(request, {
|
||||
params: Promise.resolve({ id: created.id }),
|
||||
});
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.key, created.key);
|
||||
});
|
||||
|
||||
test("GET /api/keys/[id]/reveal returns 404 for unknown keys even when reveal is enabled", async () => {
|
||||
process.env.ALLOW_API_KEY_REVEAL = "true";
|
||||
const request = new Request("http://localhost/api/keys/missing/reveal");
|
||||
|
||||
const response = await revealRoute.GET(request, {
|
||||
params: Promise.resolve({ id: "missing" }),
|
||||
});
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 404);
|
||||
assert.equal(body.error, "Key not found");
|
||||
});
|
||||
|
||||
test("GET /api/keys/[id]/reveal returns 500 when params resolution fails", async () => {
|
||||
process.env.ALLOW_API_KEY_REVEAL = "true";
|
||||
const request = new Request("http://localhost/api/keys/broken/reveal");
|
||||
|
||||
const response = await revealRoute.GET(request, {
|
||||
params: Promise.reject(new Error("params exploded")),
|
||||
});
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 500);
|
||||
assert.equal(body.error, "Failed to reveal key");
|
||||
});
|
||||
@@ -0,0 +1,665 @@
|
||||
/**
|
||||
* apiKeyRotator 健康状态追踪测试
|
||||
*
|
||||
* 测试 T07 功能:API Key 健康状态追踪
|
||||
* 当 API Key 连续认证失败 3 次后,应被标记为 "invalid" 并在轮换时自动跳过
|
||||
*/
|
||||
import { describe, it, beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
getValidApiKey,
|
||||
recordKeyFailure,
|
||||
recordKeySuccess,
|
||||
connectionHasExtraKeys,
|
||||
trackConnectionExtraKeys,
|
||||
getInvalidKeyCount,
|
||||
resetKeyStatus,
|
||||
getAllKeyHealth,
|
||||
syncHealthFromDB,
|
||||
type KeyHealth,
|
||||
} from "../../open-sse/services/apiKeyRotator.ts";
|
||||
|
||||
// Helper to reset module state between tests
|
||||
function resetModuleState() {
|
||||
// Clear all internal maps by re-importing or using exported reset functions
|
||||
// Since apiKeyRotator doesn't export a clearAll function, we use resetKeyStatus
|
||||
const allHealth = getAllKeyHealth();
|
||||
for (const scopedKey of Object.keys(allHealth)) {
|
||||
// scopedKey format is "connectionId:keyId"
|
||||
const keyId = scopedKey.includes(":") ? scopedKey.split(":")[1] : scopedKey;
|
||||
resetKeyStatus("test-conn", keyId);
|
||||
}
|
||||
}
|
||||
|
||||
describe("apiKeyRotator Health Tracking", () => {
|
||||
beforeEach(() => {
|
||||
resetModuleState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetModuleState();
|
||||
});
|
||||
|
||||
describe("getValidApiKey", () => {
|
||||
it("should return primary key when no extra keys", () => {
|
||||
const connectionId = "test-conn-1";
|
||||
const primaryKey = "pk-test-123";
|
||||
const result = getValidApiKey(connectionId, primaryKey, []);
|
||||
assert.equal(result?.key, primaryKey);
|
||||
assert.equal(result?.keyId, "primary");
|
||||
});
|
||||
|
||||
it("should return null when no keys available", () => {
|
||||
const connectionId = "test-conn-2";
|
||||
const result = getValidApiKey(connectionId, "", []);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it("should return object with key and keyId when a valid key is found", () => {
|
||||
const connectionId = "test-conn-keyid";
|
||||
const primaryKey = "pk-keyid-test";
|
||||
const result = getValidApiKey(connectionId, primaryKey, []);
|
||||
assert.ok(result, "should return an object");
|
||||
assert.equal(typeof result!.key, "string");
|
||||
assert.equal(typeof result!.keyId, "string");
|
||||
assert.equal(result!.key, primaryKey);
|
||||
assert.equal(result!.keyId, "primary");
|
||||
});
|
||||
|
||||
it("should skip invalid primary key and return first extra key", () => {
|
||||
const connectionId = "test-conn-3";
|
||||
const primaryKey = "pk-invalid";
|
||||
const extraKeys = ["extra-1", "extra-2"];
|
||||
const health: Record<string, KeyHealth> = {
|
||||
primary: {
|
||||
status: "invalid",
|
||||
failures: 3,
|
||||
lastFailure: null,
|
||||
lastSuccess: null,
|
||||
totalRequests: 5,
|
||||
totalFailures: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const result = getValidApiKey(connectionId, primaryKey, extraKeys, health);
|
||||
assert.equal(result?.key, "extra-1");
|
||||
assert.equal(result?.keyId, "extra_0");
|
||||
});
|
||||
|
||||
it("should skip all invalid keys and return null", () => {
|
||||
const connectionId = "test-conn-4";
|
||||
const primaryKey = "pk-invalid";
|
||||
const extraKeys = ["extra-invalid"];
|
||||
const health: Record<string, KeyHealth> = {
|
||||
primary: {
|
||||
status: "invalid",
|
||||
failures: 3,
|
||||
lastFailure: null,
|
||||
lastSuccess: null,
|
||||
totalRequests: 5,
|
||||
totalFailures: 3,
|
||||
},
|
||||
extra_0: {
|
||||
status: "invalid",
|
||||
failures: 3,
|
||||
lastFailure: null,
|
||||
lastSuccess: null,
|
||||
totalRequests: 3,
|
||||
totalFailures: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const result = getValidApiKey(connectionId, primaryKey, extraKeys, health);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it("should return keyId in result", () => {
|
||||
const connectionId = "test-conn-5";
|
||||
const primaryKey = "pk-test";
|
||||
const extraKeys = ["extra-1", "extra-2"];
|
||||
|
||||
const result = getValidApiKey(connectionId, primaryKey, extraKeys);
|
||||
assert.ok(result, "should have a result");
|
||||
assert.ok(["primary", "extra_0", "extra_1"].includes(result!.keyId), "keyId should be valid");
|
||||
});
|
||||
|
||||
it("should round-robin among valid keys only (skipping invalid)", () => {
|
||||
const connectionId = "test-conn-6";
|
||||
const primaryKey = "pk-test";
|
||||
const extraKeys = ["extra-1", "extra-2", "extra-3"];
|
||||
const health: Record<string, KeyHealth> = {
|
||||
extra_1: {
|
||||
status: "invalid",
|
||||
failures: 3,
|
||||
lastFailure: null,
|
||||
lastSuccess: null,
|
||||
totalRequests: 3,
|
||||
totalFailures: 3,
|
||||
}, // extra-2 is index 1
|
||||
};
|
||||
|
||||
// First call
|
||||
const key1 = getValidApiKey(connectionId, primaryKey, extraKeys, health);
|
||||
const key2 = getValidApiKey(connectionId, primaryKey, extraKeys, health);
|
||||
const key3 = getValidApiKey(connectionId, primaryKey, extraKeys, health);
|
||||
|
||||
// Should only rotate among primary, extra-1 (index 0), extra-3 (index 2)
|
||||
// Invalid extra_1 (which corresponds to extraKeys[1]) should be skipped
|
||||
const validKeys = [primaryKey, "extra-1", "extra-3"];
|
||||
assert.ok(validKeys.includes(key1!.key));
|
||||
assert.ok(validKeys.includes(key2!.key));
|
||||
assert.ok(validKeys.includes(key3!.key));
|
||||
|
||||
// extra-2 should never be selected
|
||||
assert.notEqual(key1!.key, "extra-2");
|
||||
assert.notEqual(key2!.key, "extra-2");
|
||||
assert.notEqual(key3!.key, "extra-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordKeyFailure", () => {
|
||||
it("should increment failure count", () => {
|
||||
const health = recordKeyFailure("test-conn", "primary");
|
||||
assert.equal(health.failures, 1);
|
||||
assert.equal(health.status, "warning");
|
||||
});
|
||||
|
||||
it("should mark as invalid after 3 consecutive failures", () => {
|
||||
recordKeyFailure("test-conn", "primary");
|
||||
recordKeyFailure("test-conn", "primary");
|
||||
const health = recordKeyFailure("test-conn", "primary");
|
||||
|
||||
assert.equal(health.failures, 3);
|
||||
assert.equal(health.status, "invalid");
|
||||
});
|
||||
|
||||
it("should track totalRequests and totalFailures", () => {
|
||||
const h1 = recordKeyFailure("test-conn", "test-key-1");
|
||||
assert.equal(h1.totalRequests, 1);
|
||||
assert.equal(h1.totalFailures, 1);
|
||||
|
||||
const h2 = recordKeyFailure("test-conn", "test-key-1");
|
||||
assert.equal(h2.totalRequests, 2);
|
||||
assert.equal(h2.totalFailures, 2);
|
||||
});
|
||||
|
||||
it("should set lastFailure timestamp", () => {
|
||||
const health = recordKeyFailure("test-conn", "primary");
|
||||
assert.ok(health.lastFailure, "lastFailure should be set");
|
||||
assert.ok(new Date(health.lastFailure!).getTime() > 0, "lastFailure should be valid date");
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordKeySuccess", () => {
|
||||
it("should reset failure count and mark as active", () => {
|
||||
// First record some failures
|
||||
recordKeyFailure("test-conn", "primary");
|
||||
recordKeyFailure("test-conn", "primary");
|
||||
|
||||
// Then record success
|
||||
const health = recordKeySuccess("test-conn", "primary");
|
||||
assert.equal(health.failures, 0);
|
||||
assert.equal(health.status, "active");
|
||||
});
|
||||
|
||||
it("should recover from warning status", () => {
|
||||
recordKeyFailure("test-conn", "primary"); // 1 failure -> warning
|
||||
const afterSuccess = recordKeySuccess("test-conn", "primary");
|
||||
|
||||
assert.equal(afterSuccess.status, "active");
|
||||
assert.equal(afterSuccess.failures, 0);
|
||||
});
|
||||
|
||||
it("should recover from invalid status", () => {
|
||||
recordKeyFailure("test-conn", "primary");
|
||||
recordKeyFailure("test-conn", "primary");
|
||||
recordKeyFailure("test-conn", "primary"); // 3 failures -> invalid
|
||||
|
||||
const afterSuccess = recordKeySuccess("test-conn", "primary");
|
||||
assert.equal(afterSuccess.status, "active");
|
||||
assert.equal(afterSuccess.failures, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInvalidKeyCount", () => {
|
||||
it("should return 0 when no invalid keys", () => {
|
||||
const count = getInvalidKeyCount({});
|
||||
assert.equal(count, 0);
|
||||
});
|
||||
|
||||
it("should count invalid keys correctly", () => {
|
||||
const health: Record<string, KeyHealth> = {
|
||||
primary: {
|
||||
status: "invalid",
|
||||
failures: 3,
|
||||
lastFailure: null,
|
||||
lastSuccess: null,
|
||||
totalRequests: 5,
|
||||
totalFailures: 3,
|
||||
},
|
||||
extra_0: {
|
||||
status: "active",
|
||||
failures: 0,
|
||||
lastFailure: null,
|
||||
lastSuccess: null,
|
||||
totalRequests: 10,
|
||||
totalFailures: 0,
|
||||
},
|
||||
extra_1: {
|
||||
status: "invalid",
|
||||
failures: 3,
|
||||
lastFailure: null,
|
||||
lastSuccess: null,
|
||||
totalRequests: 3,
|
||||
totalFailures: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const count = getInvalidKeyCount(health);
|
||||
assert.equal(count, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trackConnectionExtraKeys", () => {
|
||||
it("should track connection has extra keys", () => {
|
||||
trackConnectionExtraKeys("conn-with-extras", ["key1", "key2"]);
|
||||
assert.equal(connectionHasExtraKeys("conn-with-extras"), true);
|
||||
});
|
||||
|
||||
it("should return false for connection without extra keys", () => {
|
||||
trackConnectionExtraKeys("conn-no-extras", []);
|
||||
assert.equal(connectionHasExtraKeys("conn-no-extras"), false);
|
||||
});
|
||||
|
||||
it("should return false for unknown connection", () => {
|
||||
assert.equal(connectionHasExtraKeys("unknown-conn"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncHealthFromDB", () => {
|
||||
it("should sync health status from DB to in-memory map", () => {
|
||||
const connectionId = "test-sync-conn";
|
||||
const health: Record<string, KeyHealth> = {
|
||||
primary: {
|
||||
status: "invalid",
|
||||
failures: 3,
|
||||
lastFailure: "2026-01-01T00:00:00Z",
|
||||
lastSuccess: null,
|
||||
totalRequests: 10,
|
||||
totalFailures: 5,
|
||||
},
|
||||
};
|
||||
|
||||
syncHealthFromDB(connectionId, health);
|
||||
|
||||
// After sync, getAllKeyHealth should include the synced data
|
||||
// Note: syncHealthFromDB uses `${connectionId}:${keyId}` as the key
|
||||
const allHealth = getAllKeyHealth();
|
||||
const syncedKey = `${connectionId}:primary`;
|
||||
|
||||
// This test will FAIL because syncHealthFromDB stores with connectionId prefix
|
||||
// but getValidApiKey reads from health parameter without connectionId prefix
|
||||
// This is a BUG - the key namespace mismatch
|
||||
if (allHealth[syncedKey]) {
|
||||
assert.equal(allHealth[syncedKey].status, "invalid");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Key isolation per connection", () => {
|
||||
it("should isolate health state per connection", () => {
|
||||
// Connection A has primary key that failed
|
||||
const connA = "conn-A";
|
||||
const connB = "conn-B";
|
||||
const primaryKeyA = "pk-A";
|
||||
const primaryKeyB = "pk-B";
|
||||
|
||||
// Record failure for connection A's primary key
|
||||
recordKeyFailure(connA, "primary");
|
||||
recordKeyFailure(connA, "primary");
|
||||
recordKeyFailure(connA, "primary"); // 3 failures -> invalid for connA only
|
||||
|
||||
// Connection B should NOT be affected - health is isolated
|
||||
const keyA = getValidApiKey(connA, primaryKeyA, []);
|
||||
const keyB = getValidApiKey(connB, primaryKeyB, []);
|
||||
|
||||
// keyA should be null (primary is invalid, no extra keys)
|
||||
assert.equal(keyA, null);
|
||||
// keyB should work (no failures recorded for connB)
|
||||
assert.equal(keyB?.key, primaryKeyB);
|
||||
});
|
||||
|
||||
it("should return correct keyId per connection", () => {
|
||||
const connA = "conn-A";
|
||||
const connB = "conn-B";
|
||||
|
||||
const resultA = getValidApiKey(connA, "pk-A", ["extra-A"]);
|
||||
const resultB = getValidApiKey(connB, "pk-B", ["extra-B"]);
|
||||
|
||||
// Both should have a valid keyId
|
||||
assert.ok(resultA, "connA should have a result");
|
||||
assert.ok(resultB, "connB should have a result");
|
||||
assert.ok(resultA!.keyId, "connA should have keyId");
|
||||
assert.ok(resultB!.keyId, "connB should have keyId");
|
||||
// keyId is per-connection scoped
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Integration: 401 handling should skip key not connection", () => {
|
||||
it("should track extra keys for A3 guard", () => {
|
||||
const connectionId = "conn-with-backup";
|
||||
const extraKeys = ["backup-key-1", "backup-key-2"];
|
||||
|
||||
trackConnectionExtraKeys(connectionId, extraKeys);
|
||||
assert.equal(connectionHasExtraKeys(connectionId), true);
|
||||
});
|
||||
|
||||
it("should not mark connection unavailable when extra keys exist (A3 guard)", () => {
|
||||
// This test documents the expected behavior:
|
||||
// When 401 occurs and connection has extra keys,
|
||||
// the system should:
|
||||
// 1. Record failure against the specific key
|
||||
// 2. Skip that key in future rotation
|
||||
// 3. NOT disable the entire connection
|
||||
|
||||
const connectionId = "test-a3-guard";
|
||||
const primaryKey = "pk-failing";
|
||||
const extraKeys = ["extra-1-valid", "extra-2-valid"];
|
||||
|
||||
// Track that this connection has extra keys
|
||||
trackConnectionExtraKeys(connectionId, extraKeys);
|
||||
|
||||
// Record 3 failures on primary key
|
||||
recordKeyFailure(connectionId, "primary");
|
||||
recordKeyFailure(connectionId, "primary");
|
||||
const finalHealth = recordKeyFailure(connectionId, "primary");
|
||||
|
||||
// Primary key should now be invalid
|
||||
assert.equal(finalHealth.status, "invalid");
|
||||
|
||||
// Build health state
|
||||
const health: Record<string, KeyHealth> = {
|
||||
primary: finalHealth,
|
||||
};
|
||||
|
||||
// getValidApiKey should now skip primary and return an extra key
|
||||
const nextKey = getValidApiKey(connectionId, primaryKey, extraKeys, health);
|
||||
assert.ok(nextKey, "should return an extra key");
|
||||
assert.notEqual(nextKey!.key, primaryKey, "should NOT return primary (invalid)");
|
||||
assert.ok(extraKeys.includes(nextKey!.key), "should return one of the extra keys");
|
||||
|
||||
// The connection should still be usable because extra keys are available
|
||||
// This is the core behavior the A3 guard should enable
|
||||
});
|
||||
});
|
||||
|
||||
describe("E2E: Complete 401 flow simulation", () => {
|
||||
beforeEach(() => {
|
||||
resetModuleState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetModuleState();
|
||||
});
|
||||
|
||||
it("should simulate full 401 failure → key rotation → success flow", () => {
|
||||
const connectionId = "e2e-test-conn";
|
||||
const primaryKey = "pk-primary";
|
||||
const extraKeys = ["extra-key-1", "extra-key-2"];
|
||||
|
||||
// Step 1: Initial state - track extra keys
|
||||
trackConnectionExtraKeys(connectionId, extraKeys);
|
||||
assert.equal(connectionHasExtraKeys(connectionId), true);
|
||||
|
||||
// Step 2: First request - should get primary key
|
||||
const key1 = getValidApiKey(connectionId, primaryKey, extraKeys);
|
||||
assert.equal(key1?.key, primaryKey);
|
||||
assert.equal(key1?.keyId, "primary");
|
||||
|
||||
// Step 3: Simulate 401 failure on primary key
|
||||
const health1 = recordKeyFailure(connectionId, "primary");
|
||||
assert.equal(health1.status, "warning");
|
||||
assert.equal(health1.failures, 1);
|
||||
|
||||
// Step 4: Retry - should get a valid key (primary is still active, but round-robin may pick extra)
|
||||
const key2 = getValidApiKey(connectionId, primaryKey, extraKeys, { primary: health1 });
|
||||
assert.ok(
|
||||
key2!.key === primaryKey || extraKeys.includes(key2!.key),
|
||||
"should return a valid key"
|
||||
);
|
||||
|
||||
// Step 5: Second 401 failure
|
||||
const health2 = recordKeyFailure(connectionId, "primary");
|
||||
assert.equal(health2.failures, 2);
|
||||
assert.equal(health2.status, "invalid");
|
||||
|
||||
// Step 6: Third 401 failure - now primary becomes invalid
|
||||
const health3 = recordKeyFailure(connectionId, "primary");
|
||||
assert.equal(health3.failures, 3);
|
||||
assert.equal(health3.status, "invalid");
|
||||
|
||||
// Step 7: Next request - should skip invalid primary and return extra key
|
||||
const healthState = { primary: health3 };
|
||||
const key3 = getValidApiKey(connectionId, primaryKey, extraKeys, healthState);
|
||||
assert.ok(key3, "should return a key");
|
||||
assert.notEqual(key3!.key, primaryKey, "should NOT return invalid primary key");
|
||||
assert.ok(extraKeys.includes(key3!.key), "should return an extra key");
|
||||
|
||||
// Step 8: Verify the invalid key count
|
||||
assert.equal(getInvalidKeyCount(healthState), 1);
|
||||
|
||||
// Step 9: A3 guard check - connection with extra keys should NOT be disabled
|
||||
// This simulates what chat.ts does: check connectionHasExtraKeys before markAccountUnavailable
|
||||
const shouldSkipConnectionDisable = connectionHasExtraKeys(connectionId);
|
||||
assert.equal(shouldSkipConnectionDisable, true);
|
||||
|
||||
// Step 10: Success on extra key - mark it as successful
|
||||
assert.ok(key3!.keyId.startsWith("extra_"));
|
||||
const successHealth = recordKeySuccess(connectionId, key3!.keyId);
|
||||
assert.equal(successHealth.status, "active");
|
||||
assert.equal(successHealth.failures, 0);
|
||||
|
||||
// Step 11: Recover primary key
|
||||
const recoveredHealth = recordKeySuccess(connectionId, "primary");
|
||||
assert.equal(recoveredHealth.status, "active");
|
||||
assert.equal(recoveredHealth.failures, 0);
|
||||
|
||||
// Step 12: Next request - can use primary again
|
||||
const recoveredState = { primary: recoveredHealth };
|
||||
const key4 = getValidApiKey(connectionId, primaryKey, extraKeys, recoveredState);
|
||||
assert.ok(
|
||||
key4!.key === primaryKey || extraKeys.includes(key4!.key),
|
||||
"should rotate among valid keys"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle multiple invalid keys and still find valid ones", () => {
|
||||
const connectionId = "multi-invalid-test";
|
||||
const primaryKey = "pk-primary";
|
||||
const extraKeys = ["extra-1", "extra-2", "extra-3"];
|
||||
|
||||
trackConnectionExtraKeys(connectionId, extraKeys);
|
||||
|
||||
// Mark primary and extra_0 as invalid
|
||||
for (let i = 0; i < 3; i++) {
|
||||
recordKeyFailure(connectionId, "primary");
|
||||
recordKeyFailure(connectionId, "extra_0");
|
||||
}
|
||||
|
||||
const health: Record<string, KeyHealth> = {
|
||||
primary: {
|
||||
status: "invalid",
|
||||
failures: 3,
|
||||
lastFailure: null,
|
||||
lastSuccess: null,
|
||||
totalRequests: 3,
|
||||
totalFailures: 3,
|
||||
},
|
||||
extra_0: {
|
||||
status: "invalid",
|
||||
failures: 3,
|
||||
lastFailure: null,
|
||||
lastSuccess: null,
|
||||
totalRequests: 3,
|
||||
totalFailures: 3,
|
||||
},
|
||||
};
|
||||
|
||||
// Should still find valid keys (extra_1 or extra_2)
|
||||
const validKeys: string[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const key = getValidApiKey(connectionId, primaryKey, extraKeys, health);
|
||||
if (key) validKeys.push(key.key);
|
||||
}
|
||||
|
||||
assert.ok(validKeys.length > 0, "should return valid keys");
|
||||
// Verify no invalid keys are returned
|
||||
const invalidKeys = ["pk-primary", "extra-1"];
|
||||
for (const invalidKey of invalidKeys) {
|
||||
assert.ok(!validKeys.includes(invalidKey), `${invalidKey} should be skipped`);
|
||||
}
|
||||
// Verify only valid keys (extra-2, extra-3) are returned
|
||||
const validSet = new Set(["extra-2", "extra-3"]);
|
||||
assert.ok(
|
||||
validKeys.every((k) => validSet.has(k)),
|
||||
"only extra_2 and extra_3 should be returned"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle edge case: all keys invalid", () => {
|
||||
const connectionId = "all-invalid-test";
|
||||
const primaryKey = "pk-primary";
|
||||
const extraKeys = ["extra-1"];
|
||||
|
||||
trackConnectionExtraKeys(connectionId, extraKeys);
|
||||
|
||||
// Mark all keys as invalid
|
||||
const health: Record<string, KeyHealth> = {
|
||||
primary: {
|
||||
status: "invalid",
|
||||
failures: 3,
|
||||
lastFailure: null,
|
||||
lastSuccess: null,
|
||||
totalRequests: 3,
|
||||
totalFailures: 3,
|
||||
},
|
||||
extra_0: {
|
||||
status: "invalid",
|
||||
failures: 3,
|
||||
lastFailure: null,
|
||||
lastSuccess: null,
|
||||
totalRequests: 3,
|
||||
totalFailures: 3,
|
||||
},
|
||||
};
|
||||
|
||||
// Should return null - no valid keys
|
||||
const key = getValidApiKey(connectionId, primaryKey, extraKeys, health);
|
||||
assert.equal(key, null);
|
||||
|
||||
// A3 guard: even with extra keys, if ALL keys are invalid,
|
||||
// the connection might need to be disabled
|
||||
// But current behavior: don't disable, just return null and let caller handle
|
||||
assert.equal(connectionHasExtraKeys(connectionId), true);
|
||||
assert.equal(getInvalidKeyCount(health), 2);
|
||||
});
|
||||
|
||||
it("should track health persistence round-trip", () => {
|
||||
const connectionId = "persist-test";
|
||||
const primaryKey = "pk-persist";
|
||||
const extraKeys = ["extra-1"];
|
||||
|
||||
trackConnectionExtraKeys(connectionId, extraKeys);
|
||||
|
||||
// Record failures
|
||||
const healthBefore = recordKeyFailure(connectionId, "primary");
|
||||
assert.equal(healthBefore.failures, 1);
|
||||
|
||||
// Simulate DB sync (persist health)
|
||||
const dbHealth: Record<string, KeyHealth> = {
|
||||
primary: healthBefore,
|
||||
};
|
||||
|
||||
// Sync from DB
|
||||
syncHealthFromDB(connectionId, dbHealth);
|
||||
|
||||
// Verify key is still marked in internal state (with connection prefix)
|
||||
const allHealth = getAllKeyHealth();
|
||||
const prefixedKey = `${connectionId}:primary`;
|
||||
assert.equal(allHealth[prefixedKey]?.status, "warning");
|
||||
|
||||
// Get valid key should still work
|
||||
const key = getValidApiKey(connectionId, primaryKey, extraKeys, dbHealth);
|
||||
assert.equal(key?.key, primaryKey); // Still valid (only 1 failure)
|
||||
});
|
||||
});
|
||||
|
||||
describe("A3 Guard Integration Test", () => {
|
||||
beforeEach(() => {
|
||||
resetModuleState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetModuleState();
|
||||
});
|
||||
|
||||
it("should document the expected A3 guard behavior for chat.ts", () => {
|
||||
// This test documents how chat.ts should behave with A3 guard
|
||||
|
||||
const connectionId = "a3-guard-test";
|
||||
const primaryKey = "pk-401";
|
||||
const extraKeys = ["backup-1", "backup-2"];
|
||||
|
||||
// Simulate: Connection has extra keys
|
||||
trackConnectionExtraKeys(connectionId, extraKeys);
|
||||
assert.equal(connectionHasExtraKeys(connectionId), true);
|
||||
|
||||
// Simulate: 401 occurs on primary key
|
||||
// Step 1: T07 code in chatCore.ts records key failure
|
||||
const health = recordKeyFailure(connectionId, "primary");
|
||||
|
||||
// Step 2: A3 guard in chat.ts checks if connection has extra keys
|
||||
const hasExtraKeys = connectionHasExtraKeys(connectionId);
|
||||
const is401 = true; // Simulated 401
|
||||
const skipConnectionDisable = is401 && hasExtraKeys;
|
||||
|
||||
// Step 3: With A3 guard, markAccountUnavailable should be skipped
|
||||
assert.equal(skipConnectionDisable, true);
|
||||
|
||||
// Step 4: Next request should get backup key
|
||||
const nextKey = getValidApiKey(connectionId, primaryKey, extraKeys, {
|
||||
primary: health,
|
||||
});
|
||||
|
||||
if (health.status === "invalid") {
|
||||
assert.ok(extraKeys.includes(nextKey!.key!), "should use backup key");
|
||||
}
|
||||
|
||||
// Step 5: Connection remains usable
|
||||
// This is the key behavior: connection is NOT disabled
|
||||
});
|
||||
|
||||
it("should document behavior when NO extra keys exist", () => {
|
||||
const connectionId = "no-extras-test";
|
||||
const extraKeys: string[] = [];
|
||||
|
||||
// No extra keys
|
||||
trackConnectionExtraKeys(connectionId, extraKeys);
|
||||
assert.equal(connectionHasExtraKeys(connectionId), false);
|
||||
|
||||
// Simulate: 401 occurs
|
||||
recordKeyFailure(connectionId, "primary");
|
||||
|
||||
// A3 guard check
|
||||
const hasExtraKeys = connectionHasExtraKeys(connectionId);
|
||||
const is401 = true;
|
||||
const skipConnectionDisable = is401 && hasExtraKeys;
|
||||
|
||||
// Without extra keys, connection SHOULD be disabled
|
||||
assert.equal(skipConnectionDisable, false);
|
||||
|
||||
// chat.ts would call markAccountUnavailable here
|
||||
// This is the expected behavior: disable the connection
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
DEFAULT_SELF_SERVICE_SCOPES,
|
||||
SELF_ACCOUNT_QUOTA_SCOPE,
|
||||
SELF_USAGE_SCOPE,
|
||||
hasSelfAccountQuotaScope,
|
||||
hasSelfUsageScope,
|
||||
normalizeSelfServiceScopesForCreate,
|
||||
} from "../../src/shared/constants/selfServiceScopes.ts";
|
||||
import { createKeySchema, updateKeyPermissionsSchema } from "../../src/shared/validation/schemas.ts";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
test("self-service scope constants are distinct and usage defaults on create", () => {
|
||||
assert.equal(SELF_USAGE_SCOPE, "self:usage");
|
||||
assert.equal(SELF_ACCOUNT_QUOTA_SCOPE, "self:account-quota");
|
||||
assert.deepEqual(DEFAULT_SELF_SERVICE_SCOPES, [SELF_USAGE_SCOPE]);
|
||||
|
||||
assert.deepEqual(normalizeSelfServiceScopesForCreate(undefined), [SELF_USAGE_SCOPE]);
|
||||
assert.deepEqual(normalizeSelfServiceScopesForCreate([]), [SELF_USAGE_SCOPE]);
|
||||
assert.deepEqual(normalizeSelfServiceScopesForCreate(["manage"]), ["manage", SELF_USAGE_SCOPE]);
|
||||
assert.deepEqual(normalizeSelfServiceScopesForCreate([SELF_ACCOUNT_QUOTA_SCOPE]), [
|
||||
SELF_ACCOUNT_QUOTA_SCOPE,
|
||||
SELF_USAGE_SCOPE,
|
||||
]);
|
||||
});
|
||||
|
||||
test("self-service scope helpers do not treat account quota as own-usage visibility", () => {
|
||||
assert.equal(hasSelfUsageScope([SELF_USAGE_SCOPE]), true);
|
||||
assert.equal(hasSelfUsageScope([SELF_ACCOUNT_QUOTA_SCOPE]), false);
|
||||
assert.equal(hasSelfAccountQuotaScope([SELF_ACCOUNT_QUOTA_SCOPE]), true);
|
||||
assert.equal(hasSelfAccountQuotaScope([SELF_USAGE_SCOPE]), false);
|
||||
});
|
||||
|
||||
test("api key validation accepts more than sixteen scopes", () => {
|
||||
const scopes = Array.from({ length: 18 }, (_, index) => `custom:${index}`);
|
||||
|
||||
assert.equal(createKeySchema.safeParse({ name: "heavy-scope-key", scopes }).success, true);
|
||||
assert.equal(updateKeyPermissionsSchema.safeParse({ scopes }).success, true);
|
||||
});
|
||||
|
||||
test("api key create route normalizes omitted scopes to self-service usage", () => {
|
||||
const source = fs.readFileSync(path.join(repoRoot, "src/app/api/keys/route.ts"), "utf8");
|
||||
|
||||
assert.match(source, /normalizeSelfServiceScopesForCreate/);
|
||||
assert.ok(
|
||||
source.indexOf("normalizeSelfServiceScopesForCreate(scopes)") <
|
||||
source.indexOf("createApiKey(name, machineId"),
|
||||
"create route must add default self-service scope before persistence"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,427 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import DatabaseSync from "better-sqlite3";
|
||||
|
||||
import { SELF_ACCOUNT_QUOTA_SCOPE, SELF_USAGE_SCOPE } from "../../src/shared/constants/selfServiceScopes.ts";
|
||||
import { buildApiKeySelfServiceStatus } from "../../src/lib/usage/apiKeySelfService.ts";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const migrationPath = path.join(
|
||||
repoRoot,
|
||||
"src/lib/db/migrations/075_api_key_self_service_usage_scopes.sql"
|
||||
);
|
||||
|
||||
test("self-service scope migration backfills own usage once and preserves explicit account quota opt-in", () => {
|
||||
const sql = fs.readFileSync(migrationPath, "utf8");
|
||||
const db = new DatabaseSync(":memory:");
|
||||
db.exec(`
|
||||
CREATE TABLE api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
scopes TEXT
|
||||
);
|
||||
|
||||
INSERT INTO api_keys (id, scopes) VALUES
|
||||
('legacy-empty', '[]'),
|
||||
('legacy-null', NULL),
|
||||
('custom', '["custom:scope"]'),
|
||||
('quota-opt-in', '["${SELF_ACCOUNT_QUOTA_SCOPE}"]'),
|
||||
('already-disabled-after-migration', '["custom:scope"]');
|
||||
`);
|
||||
|
||||
db.exec(sql);
|
||||
db.prepare("UPDATE api_keys SET scopes = ? WHERE id = ?").run(
|
||||
JSON.stringify(["custom:scope"]),
|
||||
"already-disabled-after-migration"
|
||||
);
|
||||
db.exec(sql);
|
||||
|
||||
const rows = db.prepare("SELECT id, scopes FROM api_keys ORDER BY id").all() as Array<{
|
||||
id: string;
|
||||
scopes: string;
|
||||
}>;
|
||||
const scopesById = new Map(rows.map((row) => [row.id, JSON.parse(row.scopes) as string[]]));
|
||||
|
||||
assert.deepEqual(scopesById.get("legacy-empty"), [SELF_USAGE_SCOPE]);
|
||||
assert.deepEqual(scopesById.get("legacy-null"), [SELF_USAGE_SCOPE]);
|
||||
assert.deepEqual(scopesById.get("custom"), ["custom:scope", SELF_USAGE_SCOPE]);
|
||||
assert.deepEqual(scopesById.get("quota-opt-in"), [
|
||||
SELF_ACCOUNT_QUOTA_SCOPE,
|
||||
SELF_USAGE_SCOPE,
|
||||
]);
|
||||
assert.deepEqual(scopesById.get("already-disabled-after-migration"), ["custom:scope"]);
|
||||
});
|
||||
|
||||
function makeDeps(overrides: Record<string, unknown> = {}) {
|
||||
const tokenRows = overrides.tokenRows ?? {
|
||||
inputTokens: 900,
|
||||
outputTokens: 30,
|
||||
cacheReadTokens: 120,
|
||||
cacheCreationTokens: 10,
|
||||
reasoningTokens: 5,
|
||||
};
|
||||
const dbParams: unknown[][] = [];
|
||||
|
||||
return {
|
||||
dbParams,
|
||||
deps: {
|
||||
now: () => Date.UTC(2026, 4, 29, 12, 0, 0),
|
||||
getCostSummary: () => ({
|
||||
budget: null,
|
||||
totalCostMonth: 12.34,
|
||||
totalCostPeriod: 0,
|
||||
activeLimitUsd: 0,
|
||||
resetInterval: null,
|
||||
resetTime: null,
|
||||
budgetResetAt: null,
|
||||
lastBudgetResetAt: null,
|
||||
periodStartAt: null,
|
||||
nextResetAt: null,
|
||||
warningThreshold: null,
|
||||
}),
|
||||
checkBudget: () => ({ allowed: true }),
|
||||
getDbInstance: () => ({
|
||||
prepare: () => ({
|
||||
get: (...params: unknown[]) => {
|
||||
dbParams.push(params);
|
||||
return tokenRows;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
getProviderConnectionById: async () => null,
|
||||
getProviderConnections: async () => [],
|
||||
fetchAndPersistProviderLimits: async () => {
|
||||
throw new Error("unexpected quota fetch");
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("self-service status reports own cost and token usage with null budget fields when no budget exists", async () => {
|
||||
const metadata = {
|
||||
id: "key-a",
|
||||
name: "team-a",
|
||||
scopes: [SELF_USAGE_SCOPE],
|
||||
allowedConnections: [],
|
||||
};
|
||||
const { deps, dbParams } = makeDeps();
|
||||
|
||||
const status = await buildApiKeySelfServiceStatus(metadata, deps);
|
||||
|
||||
assert.deepEqual(status.apiKey, { id: "key-a", name: "team-a" });
|
||||
assert.equal(status.usage.cost.usedUsd, 12.34);
|
||||
assert.equal(status.usage.cost.limitUsd, null);
|
||||
assert.equal(status.usage.cost.remainingUsd, null);
|
||||
assert.equal(status.usage.cost.usedPercent, null);
|
||||
assert.equal(status.usage.cost.period, "monthly");
|
||||
assert.equal(status.usage.tokens.totalTokens, 1065);
|
||||
assert.equal(dbParams[0][0], "key-a");
|
||||
assert.equal(dbParams[0][1], "2026-05-01T00:00:00.000Z");
|
||||
assert.equal("accountQuota" in status, false);
|
||||
});
|
||||
|
||||
test("self-service status reports USD budget percentage using the budget period", async () => {
|
||||
const metadata = {
|
||||
id: "key-budget",
|
||||
name: "budgeted",
|
||||
scopes: [SELF_USAGE_SCOPE],
|
||||
allowedConnections: [],
|
||||
};
|
||||
const periodStart = Date.UTC(2026, 4, 1, 0, 0, 0);
|
||||
const nextReset = Date.UTC(2026, 5, 1, 0, 0, 0);
|
||||
const { deps } = makeDeps({
|
||||
getCostSummary: () => ({
|
||||
budget: { resetInterval: "monthly" },
|
||||
totalCostMonth: 99,
|
||||
totalCostPeriod: 12.5,
|
||||
activeLimitUsd: 50,
|
||||
resetInterval: "monthly",
|
||||
resetTime: "00:00",
|
||||
budgetResetAt: nextReset,
|
||||
lastBudgetResetAt: periodStart,
|
||||
periodStartAt: periodStart,
|
||||
nextResetAt: nextReset,
|
||||
warningThreshold: 0.8,
|
||||
}),
|
||||
});
|
||||
|
||||
const status = await buildApiKeySelfServiceStatus(metadata, deps);
|
||||
|
||||
assert.equal(status.usage.cost.usedUsd, 12.5);
|
||||
assert.equal(status.usage.cost.limitUsd, 50);
|
||||
assert.equal(status.usage.cost.remainingUsd, 37.5);
|
||||
assert.equal(status.usage.cost.usedPercent, 25);
|
||||
assert.equal(status.usage.cost.periodStartAt, "2026-05-01T00:00:00.000Z");
|
||||
assert.equal(status.usage.cost.resetAt, "2026-06-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
test("self-service status preserves ISO and Date budget timestamps", async () => {
|
||||
const metadata = {
|
||||
id: "key-budget-date",
|
||||
name: "budgeted date",
|
||||
scopes: [SELF_USAGE_SCOPE],
|
||||
allowedConnections: [],
|
||||
};
|
||||
const { deps, dbParams } = makeDeps({
|
||||
getCostSummary: () => ({
|
||||
budget: { resetInterval: "weekly" },
|
||||
totalCostMonth: 99,
|
||||
totalCostPeriod: 15,
|
||||
activeLimitUsd: 60,
|
||||
resetInterval: "weekly",
|
||||
resetTime: "00:00",
|
||||
budgetResetAt: null,
|
||||
lastBudgetResetAt: null,
|
||||
periodStartAt: "2026-05-18T00:00:00.000Z",
|
||||
nextResetAt: new Date("2026-05-25T00:00:00.000Z"),
|
||||
warningThreshold: 0.8,
|
||||
}),
|
||||
});
|
||||
|
||||
const status = await buildApiKeySelfServiceStatus(metadata, deps);
|
||||
|
||||
assert.equal(status.usage.cost.periodStartAt, "2026-05-18T00:00:00.000Z");
|
||||
assert.equal(status.usage.cost.resetAt, "2026-05-25T00:00:00.000Z");
|
||||
assert.equal(dbParams[0][1], "2026-05-18T00:00:00.000Z");
|
||||
});
|
||||
|
||||
test("self-service status reports all explicitly allowed provider account quotas", async () => {
|
||||
const metadata = {
|
||||
id: "key-multi",
|
||||
name: "multi",
|
||||
scopes: [SELF_USAGE_SCOPE, SELF_ACCOUNT_QUOTA_SCOPE],
|
||||
allowedConnections: ["conn-codex", "conn-claude"],
|
||||
};
|
||||
const fetches: string[] = [];
|
||||
const { deps } = makeDeps({
|
||||
getProviderConnectionById: async (connectionId: string) => ({
|
||||
id: connectionId,
|
||||
provider: connectionId === "conn-codex" ? "codex" : "claude",
|
||||
isActive: true,
|
||||
}),
|
||||
fetchAndPersistProviderLimits: async (connectionId: string) => {
|
||||
fetches.push(connectionId);
|
||||
if (connectionId === "conn-codex") {
|
||||
return {
|
||||
connection: { id: connectionId, provider: "codex" },
|
||||
usage: {
|
||||
plan: "ChatGPT Plus",
|
||||
quotas: {
|
||||
session: { used: 1, remaining: 99, resetAt: "2026-05-29T18:11:44.000Z" },
|
||||
},
|
||||
},
|
||||
cache: { quotas: null, plan: null, message: null, fetchedAt: "" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
connection: { id: connectionId, provider: "claude" },
|
||||
usage: {
|
||||
plan: "Claude Max",
|
||||
quotas: {
|
||||
daily: { usedPercentage: 35, remainingPercentage: 65, resetAt: "2026-05-30T00:00:00.000Z" },
|
||||
},
|
||||
},
|
||||
cache: { quotas: null, plan: null, message: null, fetchedAt: "" },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const status = await buildApiKeySelfServiceStatus(metadata, deps);
|
||||
|
||||
assert.deepEqual(fetches, ["conn-codex", "conn-claude"]);
|
||||
assert.deepEqual(
|
||||
status.accountQuotas.map((quota: { connectionId: string }) => quota.connectionId),
|
||||
["conn-codex", "conn-claude"]
|
||||
);
|
||||
assert.equal(status.accountQuotas[0].provider, "codex");
|
||||
assert.equal(status.accountQuotas[0].plan, "ChatGPT Plus");
|
||||
assert.equal(status.accountQuotas[0].quotas.session.remainingPercentage, 99);
|
||||
assert.equal(status.accountQuotas[1].provider, "claude");
|
||||
assert.equal(status.accountQuotas[1].plan, "Claude Max");
|
||||
assert.equal(status.accountQuotas[1].quotas.daily.usedPercentage, 35);
|
||||
});
|
||||
|
||||
test("self-service status reports all active provider account quotas for unrestricted keys", async () => {
|
||||
const metadata = {
|
||||
id: "key-unrestricted",
|
||||
name: "unrestricted",
|
||||
scopes: [SELF_USAGE_SCOPE, SELF_ACCOUNT_QUOTA_SCOPE],
|
||||
allowedConnections: [],
|
||||
};
|
||||
const { deps } = makeDeps({
|
||||
getProviderConnections: async () => [
|
||||
{ id: "conn-codex", provider: "codex", isActive: true },
|
||||
{ id: "conn-cursor", provider: "cursor", isActive: true },
|
||||
{ id: "conn-disabled", provider: "claude", isActive: false },
|
||||
],
|
||||
fetchAndPersistProviderLimits: async (connectionId: string) => ({
|
||||
connection: { id: connectionId, provider: connectionId === "conn-codex" ? "codex" : "cursor" },
|
||||
usage: {
|
||||
plan: connectionId === "conn-codex" ? "ChatGPT Plus" : "Cursor Pro",
|
||||
quotas: {
|
||||
monthly: { used: 25, remaining: 75, resetAt: "2026-06-01T00:00:00.000Z" },
|
||||
},
|
||||
},
|
||||
cache: { quotas: null, plan: null, message: null, fetchedAt: "" },
|
||||
}),
|
||||
});
|
||||
|
||||
const status = await buildApiKeySelfServiceStatus(metadata, deps);
|
||||
|
||||
assert.deepEqual(
|
||||
status.accountQuotas.map((quota: { connectionId: string }) => quota.connectionId),
|
||||
["conn-codex", "conn-cursor"]
|
||||
);
|
||||
assert.equal(status.accountQuotas[0].quotas.monthly.remainingPercentage, 75);
|
||||
assert.equal(status.accountQuotas[1].plan, "Cursor Pro");
|
||||
});
|
||||
|
||||
test("self-service status isolates provider account quota fetch failures per connection", async () => {
|
||||
const metadata = {
|
||||
id: "key-partial",
|
||||
name: "partial",
|
||||
scopes: [SELF_USAGE_SCOPE, SELF_ACCOUNT_QUOTA_SCOPE],
|
||||
allowedConnections: ["conn-codex", "conn-cursor"],
|
||||
};
|
||||
const { deps } = makeDeps({
|
||||
getProviderConnectionById: async (connectionId: string) => ({
|
||||
id: connectionId,
|
||||
provider: connectionId === "conn-codex" ? "codex" : "cursor",
|
||||
isActive: true,
|
||||
}),
|
||||
fetchAndPersistProviderLimits: async (connectionId: string) => {
|
||||
if (connectionId === "conn-cursor") throw new Error("upstream unavailable");
|
||||
return {
|
||||
connection: { id: connectionId, provider: "codex" },
|
||||
usage: {
|
||||
quotas: {
|
||||
weekly: { used: 40, remaining: 60, resetAt: "2026-06-01T00:00:00.000Z" },
|
||||
},
|
||||
},
|
||||
cache: { quotas: null, plan: null, message: null, fetchedAt: "" },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const status = await buildApiKeySelfServiceStatus(metadata, deps);
|
||||
|
||||
assert.equal(status.accountQuotas[0].connectionId, "conn-codex");
|
||||
assert.equal(status.accountQuotas[0].quotas.weekly.remainingPercentage, 60);
|
||||
assert.deepEqual(status.accountQuotas[1], {
|
||||
provider: "cursor",
|
||||
connectionId: "conn-cursor",
|
||||
shared: true,
|
||||
available: false,
|
||||
reason: "fetch_failed",
|
||||
});
|
||||
});
|
||||
|
||||
test("self-service status isolates explicit provider connection lookup failures", async () => {
|
||||
const metadata = {
|
||||
id: "key-lookup-partial",
|
||||
name: "lookup partial",
|
||||
scopes: [SELF_USAGE_SCOPE, SELF_ACCOUNT_QUOTA_SCOPE],
|
||||
allowedConnections: ["conn-codex", "conn-missing"],
|
||||
};
|
||||
const { deps } = makeDeps({
|
||||
getProviderConnectionById: async (connectionId: string) => {
|
||||
if (connectionId === "conn-missing") throw new Error("database unavailable");
|
||||
return {
|
||||
id: connectionId,
|
||||
provider: "codex",
|
||||
isActive: true,
|
||||
};
|
||||
},
|
||||
fetchAndPersistProviderLimits: async (connectionId: string) => ({
|
||||
connection: { id: connectionId, provider: "codex" },
|
||||
usage: {
|
||||
quotas: {
|
||||
weekly: { used: 40, remaining: 60, resetAt: "2026-06-01T00:00:00.000Z" },
|
||||
},
|
||||
},
|
||||
cache: { quotas: null, plan: null, message: null, fetchedAt: "" },
|
||||
}),
|
||||
});
|
||||
|
||||
const status = await buildApiKeySelfServiceStatus(metadata, deps);
|
||||
|
||||
assert.equal(status.accountQuotas[0].connectionId, "conn-codex");
|
||||
assert.equal(status.accountQuotas[0].quotas.weekly.remainingPercentage, 60);
|
||||
assert.deepEqual(status.accountQuotas[1], {
|
||||
provider: "unknown",
|
||||
connectionId: "conn-missing",
|
||||
shared: true,
|
||||
available: false,
|
||||
reason: "connection_lookup_failed",
|
||||
});
|
||||
});
|
||||
|
||||
test("self-service status keeps usage visible when unrestricted provider lookup fails", async () => {
|
||||
const metadata = {
|
||||
id: "key-lookup-failed",
|
||||
name: "lookup failed",
|
||||
scopes: [SELF_USAGE_SCOPE, SELF_ACCOUNT_QUOTA_SCOPE],
|
||||
allowedConnections: [],
|
||||
};
|
||||
const { deps } = makeDeps({
|
||||
getProviderConnections: async () => {
|
||||
throw new Error("database unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
const status = await buildApiKeySelfServiceStatus(metadata, deps);
|
||||
|
||||
assert.equal(status.usage.cost.usedUsd, 12.34);
|
||||
assert.deepEqual(status.accountQuotas, []);
|
||||
assert.equal("accountQuota" in status, false);
|
||||
});
|
||||
|
||||
test("self-service status normalizes Codex account quota for one explicit connection", async () => {
|
||||
const metadata = {
|
||||
id: "key-codex",
|
||||
name: "codex",
|
||||
scopes: [SELF_USAGE_SCOPE, SELF_ACCOUNT_QUOTA_SCOPE],
|
||||
allowedConnections: ["conn-codex"],
|
||||
};
|
||||
const { deps } = makeDeps({
|
||||
getProviderConnectionById: async (connectionId: string) => ({
|
||||
id: connectionId,
|
||||
provider: "codex",
|
||||
}),
|
||||
fetchAndPersistProviderLimits: async () => ({
|
||||
connection: { id: "conn-codex", provider: "codex" },
|
||||
usage: {
|
||||
quotas: {
|
||||
session: { used: 1, remaining: 99, resetAt: "2026-05-29T18:11:44.000Z" },
|
||||
weekly: { used: 97, remaining: 3, resetAt: "2026-05-31T01:23:38.000Z" },
|
||||
},
|
||||
},
|
||||
cache: { quotas: null, plan: null, message: null, fetchedAt: "" },
|
||||
}),
|
||||
});
|
||||
|
||||
const status = await buildApiKeySelfServiceStatus(metadata, deps);
|
||||
|
||||
assert.equal(status.accountQuotas.length, 1);
|
||||
assert.deepEqual(status.accountQuotas[0], status.accountQuota);
|
||||
assert.deepEqual(status.accountQuota, {
|
||||
provider: "codex",
|
||||
connectionId: "conn-codex",
|
||||
shared: true,
|
||||
quotas: {
|
||||
session: {
|
||||
usedPercentage: 1,
|
||||
remainingPercentage: 99,
|
||||
resetAt: "2026-05-29T18:11:44.000Z",
|
||||
},
|
||||
weekly: {
|
||||
usedPercentage: 97,
|
||||
remainingPercentage: 3,
|
||||
resetAt: "2026-05-31T01:23:38.000Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,436 @@
|
||||
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-api-key-usage-limits-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "usage-limit-test-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const localDb = await import("../../src/lib/localDb.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const usageHistory = await import("../../src/lib/usage/usageHistory.ts");
|
||||
const usageLimits = await import("../../src/lib/usage/apiKeyUsageLimits.ts");
|
||||
|
||||
const NOW = Date.parse("2026-06-19T20:00:00.000Z");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
usageHistory.clearPendingRequests();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("API key USD usage limits persist and default off", async () => {
|
||||
const created = await apiKeysDb.createApiKey("Usage Limit Key", "machine-limit-01");
|
||||
|
||||
let metadata = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.equal(metadata?.usageLimitEnabled, false);
|
||||
assert.equal(metadata?.dailyUsageLimitUsd, null);
|
||||
assert.equal(metadata?.weeklyUsageLimitUsd, null);
|
||||
|
||||
await apiKeysDb.updateApiKeyPermissions(created.id, {
|
||||
usageLimitEnabled: true,
|
||||
dailyUsageLimitUsd: 10.5,
|
||||
weeklyUsageLimitUsd: 50,
|
||||
});
|
||||
apiKeysDb.clearApiKeyCaches();
|
||||
|
||||
metadata = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.equal(metadata?.usageLimitEnabled, true);
|
||||
assert.equal(metadata?.dailyUsageLimitUsd, 10.5);
|
||||
assert.equal(metadata?.weeklyUsageLimitUsd, 50);
|
||||
});
|
||||
|
||||
test("getApiKeyUsageLimitStatus aligns weekly USD spend with provider resetAt when available", async () => {
|
||||
await localDb.updatePricing({
|
||||
claude: {
|
||||
"claude-opus-4-8": {
|
||||
input: 1,
|
||||
cached: 1,
|
||||
output: 1,
|
||||
reasoning: 1,
|
||||
cache_creation: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const created = await apiKeysDb.createApiKey("Metered Key", "machine-limit-02");
|
||||
await apiKeysDb.updateApiKeyPermissions(created.id, {
|
||||
usageLimitEnabled: true,
|
||||
dailyUsageLimitUsd: 10,
|
||||
weeklyUsageLimitUsd: 20,
|
||||
});
|
||||
|
||||
await usageHistory.saveRequestUsage({
|
||||
provider: "claude",
|
||||
model: "claude-opus-4-8",
|
||||
apiKeyId: created.id,
|
||||
apiKeyName: "Metered Key",
|
||||
tokens: { input: 2_000_000, output: 0 },
|
||||
success: true,
|
||||
timestamp: "2026-06-19T12:00:00.000Z",
|
||||
});
|
||||
await usageHistory.saveRequestUsage({
|
||||
provider: "claude",
|
||||
model: "claude-opus-4-8",
|
||||
apiKeyId: created.id,
|
||||
apiKeyName: "Metered Key",
|
||||
tokens: { input: 3_000_000, output: 0 },
|
||||
success: true,
|
||||
timestamp: "2026-06-18T21:00:00.000Z",
|
||||
});
|
||||
await usageHistory.saveRequestUsage({
|
||||
provider: "claude",
|
||||
model: "claude-opus-4-8",
|
||||
apiKeyId: created.id,
|
||||
apiKeyName: "Metered Key",
|
||||
tokens: { input: 7_000_000, output: 0 },
|
||||
success: true,
|
||||
timestamp: "2026-06-18T12:00:00.000Z",
|
||||
});
|
||||
|
||||
const metadata = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(metadata);
|
||||
|
||||
const weeklyResetAt = "2026-06-25T20:00:00.000Z";
|
||||
const status = await usageLimits.getApiKeyUsageLimitStatus(
|
||||
{ ...metadata, allowedConnections: ["conn-claude"] },
|
||||
{
|
||||
now: () => NOW,
|
||||
getProviderConnectionById: async () => ({
|
||||
id: "conn-claude",
|
||||
provider: "claude",
|
||||
isActive: true,
|
||||
}),
|
||||
getProviderConnections: async () => [],
|
||||
getProviderLimitsCache: () => ({
|
||||
plan: "Claude Max",
|
||||
quotas: {
|
||||
"weekly (7d)": {
|
||||
used: 27,
|
||||
total: 100,
|
||||
resetAt: weeklyResetAt,
|
||||
},
|
||||
},
|
||||
message: null,
|
||||
fetchedAt: new Date(NOW).toISOString(),
|
||||
}),
|
||||
getAllProviderLimitsCache: () => ({}),
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(status.enabled, true);
|
||||
assert.equal(status.dailySpentUsd, 2);
|
||||
assert.equal(status.weeklySpentUsd, 5);
|
||||
assert.equal(status.dailyLimitUsd, 10);
|
||||
assert.equal(status.weeklyLimitUsd, 20);
|
||||
assert.equal(status.dailyResetAtIso, "2026-06-20T03:00:00.000Z");
|
||||
assert.equal(status.weeklyWindowStartIso, "2026-06-18T20:00:00.000Z");
|
||||
assert.equal(status.weeklyResetAtIso, weeklyResetAt);
|
||||
assert.equal(status.dailyExceeded, false);
|
||||
assert.equal(status.weeklyExceeded, false);
|
||||
});
|
||||
|
||||
test("getApiKeyUsageLimitStatus cuts weekly USD spend at observed provider quota reset", async () => {
|
||||
await localDb.updatePricing({
|
||||
claude: {
|
||||
"claude-opus-4-8": {
|
||||
input: 1,
|
||||
cached: 1,
|
||||
output: 1,
|
||||
reasoning: 1,
|
||||
cache_creation: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const created = await apiKeysDb.createApiKey("Reset Cut Key", "machine-limit-reset");
|
||||
await apiKeysDb.updateApiKeyPermissions(created.id, {
|
||||
usageLimitEnabled: true,
|
||||
dailyUsageLimitUsd: 10,
|
||||
weeklyUsageLimitUsd: 20,
|
||||
});
|
||||
|
||||
await usageHistory.saveRequestUsage({
|
||||
provider: "claude",
|
||||
model: "claude-opus-4-8",
|
||||
apiKeyId: created.id,
|
||||
apiKeyName: "Reset Cut Key",
|
||||
tokens: { input: 7_000_000, output: 0 },
|
||||
success: true,
|
||||
timestamp: "2026-06-19T23:30:00.000Z",
|
||||
});
|
||||
await usageHistory.saveRequestUsage({
|
||||
provider: "claude",
|
||||
model: "claude-opus-4-8",
|
||||
apiKeyId: created.id,
|
||||
apiKeyName: "Reset Cut Key",
|
||||
tokens: { input: 2_000_000, output: 0 },
|
||||
success: true,
|
||||
timestamp: "2026-06-20T02:00:00.000Z",
|
||||
});
|
||||
|
||||
const db = core.getDbInstance();
|
||||
const insertSnapshot = db.prepare(`
|
||||
INSERT INTO quota_snapshots (
|
||||
provider,
|
||||
connection_id,
|
||||
window_key,
|
||||
remaining_percentage,
|
||||
is_exhausted,
|
||||
next_reset_at,
|
||||
window_duration_ms,
|
||||
raw_data,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
insertSnapshot.run(
|
||||
"claude",
|
||||
"conn-claude",
|
||||
"weekly (7d)",
|
||||
72,
|
||||
0,
|
||||
"2026-06-25T23:00:00.000Z",
|
||||
null,
|
||||
null,
|
||||
"2026-06-19T23:55:00.000Z"
|
||||
);
|
||||
insertSnapshot.run(
|
||||
"claude",
|
||||
"conn-claude",
|
||||
"weekly (7d)",
|
||||
100,
|
||||
0,
|
||||
"2026-06-25T23:00:00.000Z",
|
||||
null,
|
||||
null,
|
||||
"2026-06-20T01:42:52.590Z"
|
||||
);
|
||||
insertSnapshot.run(
|
||||
"claude",
|
||||
"conn-claude",
|
||||
"weekly (7d)",
|
||||
99,
|
||||
0,
|
||||
"2026-06-25T23:00:00.000Z",
|
||||
null,
|
||||
null,
|
||||
"2026-06-20T02:10:00.000Z"
|
||||
);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_quota_reset_events
|
||||
(provider, connection_id, window_key, window_started_at, window_resets_at,
|
||||
observed_at, previous_remaining_percentage, new_remaining_percentage,
|
||||
previous_used_percentage, new_used_percentage, raw_data)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
).run(
|
||||
"claude",
|
||||
"conn-claude",
|
||||
"weekly (7d)",
|
||||
"2026-06-18T23:00:00.000Z",
|
||||
"2026-06-25T23:00:00.000Z",
|
||||
"2026-06-18T23:04:00.000Z",
|
||||
0,
|
||||
100,
|
||||
100,
|
||||
0,
|
||||
null
|
||||
);
|
||||
|
||||
const metadata = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(metadata);
|
||||
|
||||
const weeklyResetAt = "2026-06-25T23:00:00.000Z";
|
||||
const status = await usageLimits.getApiKeyUsageLimitStatus(
|
||||
{ ...metadata, allowedConnections: ["conn-claude"] },
|
||||
{
|
||||
now: () => Date.parse("2026-06-20T14:30:00.000Z"),
|
||||
getProviderConnectionById: async () => ({
|
||||
id: "conn-claude",
|
||||
provider: "claude",
|
||||
isActive: true,
|
||||
}),
|
||||
getProviderConnections: async () => [],
|
||||
getProviderLimitsCache: () => ({
|
||||
plan: "Claude Max",
|
||||
quotas: {
|
||||
"weekly (7d)": {
|
||||
used: 5,
|
||||
total: 100,
|
||||
resetAt: weeklyResetAt,
|
||||
},
|
||||
},
|
||||
message: null,
|
||||
fetchedAt: "2026-06-20T14:30:00.000Z",
|
||||
}),
|
||||
getAllProviderLimitsCache: () => ({}),
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(status.weeklyWindowStartIso, "2026-06-20T01:42:52.590Z");
|
||||
assert.equal(status.weeklySpentUsd, 2);
|
||||
});
|
||||
|
||||
test("buildApiKeyUsageLimitText returns API-key quota spend percentage and reset lines", async () => {
|
||||
const text = usageLimits.buildApiKeyUsageLimitText(
|
||||
{
|
||||
enabled: true,
|
||||
dailyLimitUsd: 10,
|
||||
weeklyLimitUsd: 50,
|
||||
dailySpentUsd: 2,
|
||||
weeklySpentUsd: 5.25,
|
||||
dailyWindowStartIso: "2026-06-19T03:00:00.000Z",
|
||||
dailyResetAtIso: "2026-06-20T03:00:00.000Z",
|
||||
weeklyWindowStartIso: "2026-06-12T20:00:00.000Z",
|
||||
weeklyResetAtIso: "2026-06-25T20:00:00.000Z",
|
||||
dailyExceeded: false,
|
||||
weeklyExceeded: false,
|
||||
},
|
||||
Date.parse("2026-06-19T20:00:00.000Z")
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
text,
|
||||
[
|
||||
"Daily quota",
|
||||
"$10.00",
|
||||
"Daily spent",
|
||||
"$2.00",
|
||||
"Daily used",
|
||||
"20%",
|
||||
"Resets in 7h 0m",
|
||||
"",
|
||||
"Weekly quota",
|
||||
"$50.00",
|
||||
"Weekly spent",
|
||||
"$5.25",
|
||||
"Weekly used",
|
||||
"11%",
|
||||
"Resets in 6d 0h 0m",
|
||||
].join("\n")
|
||||
);
|
||||
});
|
||||
|
||||
test("buildApiKeyUsageLimitPercentText returns remaining percentages only", () => {
|
||||
const text = usageLimits.buildApiKeyUsageLimitPercentText(
|
||||
{
|
||||
enabled: true,
|
||||
dailyLimitUsd: 10,
|
||||
weeklyLimitUsd: 50,
|
||||
dailySpentUsd: 2,
|
||||
weeklySpentUsd: 5.25,
|
||||
dailyWindowStartIso: "2026-06-19T03:00:00.000Z",
|
||||
dailyResetAtIso: "2026-06-20T03:00:00.000Z",
|
||||
weeklyWindowStartIso: "2026-06-12T20:00:00.000Z",
|
||||
weeklyResetAtIso: "2026-06-25T20:00:00.000Z",
|
||||
dailyExceeded: false,
|
||||
weeklyExceeded: false,
|
||||
},
|
||||
Date.parse("2026-06-19T20:00:00.000Z")
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
text,
|
||||
["Daily", "80% left", "⏱ reset in 7h 0m", "", "Weekly", "90% left", "⏱ reset in 6d 0h 0m"].join(
|
||||
"\n"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test("buildApiKeyUsageLimitRejection includes over-quota percentage and reset hint", async () => {
|
||||
const response = usageLimits.buildApiKeyUsageLimitRejection(
|
||||
new Request("http://localhost/v1/messages", {
|
||||
headers: { "anthropic-version": "2023-06-01" },
|
||||
}),
|
||||
{
|
||||
enabled: true,
|
||||
dailyLimitUsd: 10,
|
||||
weeklyLimitUsd: 1,
|
||||
dailySpentUsd: 0.25,
|
||||
weeklySpentUsd: 1.09,
|
||||
dailyWindowStartIso: "2026-06-19T03:00:00.000Z",
|
||||
dailyResetAtIso: "2026-06-20T03:00:00.000Z",
|
||||
weeklyWindowStartIso: "2026-06-12T20:00:00.000Z",
|
||||
weeklyResetAtIso: "2026-06-25T20:00:00.000Z",
|
||||
dailyExceeded: false,
|
||||
weeklyExceeded: true,
|
||||
},
|
||||
Date.parse("2026-06-19T20:00:00.000Z")
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
const body = (await response.json()) as { error: { message: string } };
|
||||
assert.equal(
|
||||
body.error.message,
|
||||
"This API key reached its weekly USD usage quota ($1.09 of $1.00, 109%). Resets in 6d 0h 0m. Choose another allowed model after reset."
|
||||
);
|
||||
});
|
||||
|
||||
test("buildApiKeyUsageLimitRejection can hide USD amounts for client-facing policy errors", async () => {
|
||||
const response = usageLimits.buildApiKeyUsageLimitRejection(
|
||||
new Request("http://localhost/v1/messages", {
|
||||
headers: { "anthropic-version": "2023-06-01" },
|
||||
}),
|
||||
{
|
||||
enabled: true,
|
||||
dailyLimitUsd: 10,
|
||||
weeklyLimitUsd: 1,
|
||||
dailySpentUsd: 0.25,
|
||||
weeklySpentUsd: 1.09,
|
||||
dailyWindowStartIso: "2026-06-19T03:00:00.000Z",
|
||||
dailyResetAtIso: "2026-06-20T03:00:00.000Z",
|
||||
weeklyWindowStartIso: "2026-06-12T20:00:00.000Z",
|
||||
weeklyResetAtIso: "2026-06-25T20:00:00.000Z",
|
||||
dailyExceeded: false,
|
||||
weeklyExceeded: true,
|
||||
},
|
||||
Date.parse("2026-06-19T20:00:00.000Z"),
|
||||
{ showUsd: false }
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
const body = (await response.json()) as { error: { message: string } };
|
||||
assert.equal(
|
||||
body.error.message,
|
||||
"This API key reached its weekly usage quota (109%). Resets in 6d 0h 0m. Choose another allowed model after reset."
|
||||
);
|
||||
});
|
||||
|
||||
test("buildApiKeyUsageLimitRejection uses 400 so Claude Code does not trigger login", () => {
|
||||
const response = usageLimits.buildApiKeyUsageLimitRejection(
|
||||
new Request("http://localhost/v1/messages", {
|
||||
headers: { "anthropic-version": "2023-06-01" },
|
||||
}),
|
||||
{
|
||||
enabled: true,
|
||||
dailyLimitUsd: 10,
|
||||
weeklyLimitUsd: 50,
|
||||
dailySpentUsd: 12,
|
||||
weeklySpentUsd: 20,
|
||||
dailyWindowStartIso: "2026-06-19T03:00:00.000Z",
|
||||
dailyResetAtIso: "2026-06-20T03:00:00.000Z",
|
||||
weeklyWindowStartIso: "2026-06-12T20:00:00.000Z",
|
||||
weeklyResetAtIso: "2026-06-19T20:00:00.000Z",
|
||||
dailyExceeded: true,
|
||||
weeklyExceeded: false,
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
process.env.API_KEY_SECRET = "test-api-key-utils-secret";
|
||||
|
||||
const apiKeyUtils = await import("../../src/shared/utils/apiKey.ts");
|
||||
|
||||
test("api key utility public surface keeps generation and parsing only", () => {
|
||||
const machineId = "testmachine";
|
||||
const { key, keyId } = apiKeyUtils.generateApiKeyWithMachine(machineId);
|
||||
|
||||
assert.equal(typeof key, "string");
|
||||
assert.equal(typeof keyId, "string");
|
||||
assert.match(key, new RegExp(`^sk-${machineId}-${keyId}-[a-f0-9]{8}$`));
|
||||
assert.deepEqual(apiKeyUtils.parseApiKey(key), {
|
||||
machineId,
|
||||
keyId,
|
||||
isNewFormat: true,
|
||||
});
|
||||
assert.deepEqual(apiKeyUtils.parseApiKey("sk-legacykey"), {
|
||||
machineId: null,
|
||||
keyId: "legacykey",
|
||||
isNewFormat: false,
|
||||
});
|
||||
assert.equal(apiKeyUtils.parseApiKey("not-a-key"), null);
|
||||
|
||||
assert.equal("verifyApiKeyCrc" in apiKeyUtils, false);
|
||||
assert.equal("isNewFormatKey" in apiKeyUtils, false);
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
isKeyActive,
|
||||
isExpired,
|
||||
isRestricted,
|
||||
classifyKeyStatus,
|
||||
classifyKeyType,
|
||||
computeApiKeyCounts,
|
||||
} from "../../src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.js";
|
||||
import type { ApiKeyShape } from "../../src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const futureDate = new Date(Date.now() + 86_400_000).toISOString(); // +1 day
|
||||
const pastDate = new Date(Date.now() - 86_400_000).toISOString(); // -1 day
|
||||
|
||||
function makeKey(overrides: Partial<ApiKeyShape> = {}): ApiKeyShape {
|
||||
return {
|
||||
isActive: true,
|
||||
isBanned: false,
|
||||
expiresAt: null,
|
||||
scopes: [],
|
||||
allowedModels: null,
|
||||
allowedConnections: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario 1 — isKeyActive
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("isKeyActive", () => {
|
||||
it("returns true for a fully active key", () => {
|
||||
assert.equal(isKeyActive(makeKey()), true);
|
||||
});
|
||||
|
||||
it("returns false when isBanned is true", () => {
|
||||
assert.equal(isKeyActive(makeKey({ isBanned: true })), false);
|
||||
});
|
||||
|
||||
it("returns false when isActive is false", () => {
|
||||
assert.equal(isKeyActive(makeKey({ isActive: false })), false);
|
||||
});
|
||||
|
||||
it("returns false when expiresAt is in the past", () => {
|
||||
assert.equal(isKeyActive(makeKey({ expiresAt: pastDate })), false);
|
||||
});
|
||||
|
||||
it("returns true when expiresAt is in the future", () => {
|
||||
assert.equal(isKeyActive(makeKey({ expiresAt: futureDate })), true);
|
||||
});
|
||||
|
||||
it("returns false when banned even if isActive true and not expired", () => {
|
||||
assert.equal(
|
||||
isKeyActive(makeKey({ isBanned: true, isActive: true, expiresAt: futureDate })),
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario 2 — isExpired
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("isExpired", () => {
|
||||
it("returns false when expiresAt is null", () => {
|
||||
assert.equal(isExpired(makeKey({ expiresAt: null })), false);
|
||||
});
|
||||
|
||||
it("returns false when expiresAt is in the future", () => {
|
||||
assert.equal(isExpired(makeKey({ expiresAt: futureDate })), false);
|
||||
});
|
||||
|
||||
it("returns true when expiresAt is in the past", () => {
|
||||
assert.equal(isExpired(makeKey({ expiresAt: pastDate })), true);
|
||||
});
|
||||
|
||||
it("returns false for an invalid date string (NaN)", () => {
|
||||
assert.equal(isExpired(makeKey({ expiresAt: "not-a-date" })), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario 3 — isRestricted
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("isRestricted", () => {
|
||||
it("returns false when both allowedModels and allowedConnections are null", () => {
|
||||
assert.equal(isRestricted(makeKey()), false);
|
||||
});
|
||||
|
||||
it("returns false when allowedModels is an empty array", () => {
|
||||
assert.equal(isRestricted(makeKey({ allowedModels: [] })), false);
|
||||
});
|
||||
|
||||
it("returns true when allowedModels has entries", () => {
|
||||
assert.equal(isRestricted(makeKey({ allowedModels: ["gpt-4"] })), true);
|
||||
});
|
||||
|
||||
it("returns true when allowedConnections has entries", () => {
|
||||
assert.equal(isRestricted(makeKey({ allowedConnections: ["conn-1"] })), true);
|
||||
});
|
||||
|
||||
it("returns true when both have entries", () => {
|
||||
assert.equal(
|
||||
isRestricted(makeKey({ allowedModels: ["gpt-4"], allowedConnections: ["conn-1"] })),
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario 4 — classifyKeyStatus (banned takes highest priority)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("classifyKeyStatus", () => {
|
||||
it("returns 'banned' for a banned key (highest priority)", () => {
|
||||
assert.equal(classifyKeyStatus(makeKey({ isBanned: true })), "banned");
|
||||
});
|
||||
|
||||
it("returns 'banned' even if also expired", () => {
|
||||
assert.equal(classifyKeyStatus(makeKey({ isBanned: true, expiresAt: pastDate })), "banned");
|
||||
});
|
||||
|
||||
it("returns 'expired' before 'disabled' when key has past expiry and isActive false", () => {
|
||||
// expiresAt in the past AND isActive false — expired is checked before disabled
|
||||
assert.equal(classifyKeyStatus(makeKey({ expiresAt: pastDate, isActive: false })), "expired");
|
||||
});
|
||||
|
||||
it("returns 'expired' for a non-banned, expired key", () => {
|
||||
assert.equal(classifyKeyStatus(makeKey({ expiresAt: pastDate })), "expired");
|
||||
});
|
||||
|
||||
it("returns 'disabled' when isActive false and not expired/banned", () => {
|
||||
assert.equal(classifyKeyStatus(makeKey({ isActive: false })), "disabled");
|
||||
});
|
||||
|
||||
it("returns 'active' for a healthy key", () => {
|
||||
assert.equal(classifyKeyStatus(makeKey()), "active");
|
||||
});
|
||||
|
||||
it("returns 'active' for key with future expiry and no bans", () => {
|
||||
assert.equal(classifyKeyStatus(makeKey({ expiresAt: futureDate })), "active");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario 4 continued — classifyKeyType
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("classifyKeyType", () => {
|
||||
it("returns 'standard' for a plain key", () => {
|
||||
assert.equal(classifyKeyType(makeKey()), "standard");
|
||||
});
|
||||
|
||||
it("returns 'manage' when scopes includes manage", () => {
|
||||
assert.equal(classifyKeyType(makeKey({ scopes: ["manage"] })), "manage");
|
||||
});
|
||||
|
||||
it("returns 'manage' even if also restricted (manage takes priority)", () => {
|
||||
assert.equal(
|
||||
classifyKeyType(makeKey({ scopes: ["manage"], allowedModels: ["gpt-4"] })),
|
||||
"manage"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 'restricted' when has allowedModels and no manage scope", () => {
|
||||
assert.equal(classifyKeyType(makeKey({ allowedModels: ["gpt-4"] })), "restricted");
|
||||
});
|
||||
|
||||
it("returns 'restricted' when has allowedConnections and no manage scope", () => {
|
||||
assert.equal(classifyKeyType(makeKey({ allowedConnections: ["c1"] })), "restricted");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario 5 — computeApiKeyCounts with 10 varied keys
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("computeApiKeyCounts", () => {
|
||||
it("returns all zeros for empty array (edge case)", () => {
|
||||
const counts = computeApiKeyCounts([]);
|
||||
assert.equal(counts.total, 0);
|
||||
assert.equal(counts.active, 0);
|
||||
assert.equal(counts.banned, 0);
|
||||
assert.equal(counts.expired, 0);
|
||||
assert.equal(counts.disabled, 0);
|
||||
assert.equal(counts.standard, 0);
|
||||
assert.equal(counts.manage, 0);
|
||||
assert.equal(counts.restricted, 0);
|
||||
});
|
||||
|
||||
it("correctly tallies 10 mixed keys", () => {
|
||||
const keys: ApiKeyShape[] = [
|
||||
makeKey(), // active + standard
|
||||
makeKey({ expiresAt: futureDate }), // active + standard
|
||||
makeKey({ scopes: ["manage"] }), // active + manage
|
||||
makeKey({ allowedModels: ["gpt-4"] }), // active + restricted
|
||||
makeKey({ isActive: false }), // disabled + standard
|
||||
makeKey({ isBanned: true }), // banned + standard
|
||||
makeKey({ expiresAt: pastDate }), // expired + standard
|
||||
makeKey({ expiresAt: pastDate, allowedModels: ["gpt-4"] }), // expired + restricted (type=restricted)
|
||||
makeKey({ scopes: ["manage"], allowedConnections: ["c1"] }), // active + manage (manage priority)
|
||||
makeKey({ allowedConnections: ["c1"] }), // active + restricted
|
||||
];
|
||||
|
||||
const counts = computeApiKeyCounts(keys);
|
||||
|
||||
assert.equal(counts.total, 10);
|
||||
// statuses: active(6: keys 1,2,3,4,9,10), disabled(1), banned(1), expired(2)
|
||||
assert.equal(counts.active, 6);
|
||||
assert.equal(counts.disabled, 1);
|
||||
assert.equal(counts.banned, 1);
|
||||
assert.equal(counts.expired, 2);
|
||||
// types: manage(2), restricted(3), standard(5 — active×2, disabled, banned, expired×1)
|
||||
assert.equal(counts.manage, 2);
|
||||
assert.equal(counts.restricted, 3);
|
||||
assert.equal(counts.standard, 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario 6 — filteredKeys logic (inline simulation of useMemo)
|
||||
// ---------------------------------------------------------------------------
|
||||
function applyFilters(
|
||||
keys: ApiKeyShape[],
|
||||
opts: {
|
||||
activeOnly?: boolean;
|
||||
statusFilter?: string | null;
|
||||
typeFilter?: string | null;
|
||||
searchQuery?: string;
|
||||
}
|
||||
): ApiKeyShape[] {
|
||||
let list = keys;
|
||||
if (opts.activeOnly) list = list.filter(isKeyActive);
|
||||
if (opts.statusFilter === "active") list = list.filter(isKeyActive);
|
||||
else if (opts.statusFilter === "disabled") list = list.filter((k) => k.isActive === false);
|
||||
else if (opts.statusFilter === "banned") list = list.filter((k) => k.isBanned === true);
|
||||
else if (opts.statusFilter === "expired") list = list.filter(isExpired);
|
||||
if (opts.typeFilter === "manage") list = list.filter((k) => k.scopes?.includes("manage"));
|
||||
else if (opts.typeFilter === "restricted") list = list.filter(isRestricted);
|
||||
else if (opts.typeFilter === "standard")
|
||||
list = list.filter((k) => !k.scopes?.includes("manage") && !isRestricted(k));
|
||||
if (opts.searchQuery?.trim()) {
|
||||
const q = opts.searchQuery.toLowerCase();
|
||||
// We cast to any to allow name/key fields for the filter simulation
|
||||
list = list.filter(
|
||||
(k) =>
|
||||
((k as Record<string, unknown>)["name"] as string | undefined)?.toLowerCase().includes(q) ||
|
||||
((k as Record<string, unknown>)["key"] as string | undefined)?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
interface TestApiKey extends ApiKeyShape {
|
||||
name: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
function makeTestKey(name: string, key: string, overrides: Partial<ApiKeyShape> = {}): TestApiKey {
|
||||
return { ...makeKey(overrides), name, key };
|
||||
}
|
||||
|
||||
describe("filter composition", () => {
|
||||
const testKeys: TestApiKey[] = [
|
||||
makeTestKey("Alpha", "sk-alpha", {}),
|
||||
makeTestKey("Beta", "sk-beta", { isBanned: true }),
|
||||
makeTestKey("Gamma", "sk-gamma", { isActive: false }),
|
||||
makeTestKey("Delta", "sk-delta", { expiresAt: pastDate }),
|
||||
makeTestKey("Epsilon", "sk-epsilon", { scopes: ["manage"] }),
|
||||
];
|
||||
|
||||
it("activeOnly=true excludes banned and inactive keys", () => {
|
||||
const result = applyFilters(testKeys, { activeOnly: true });
|
||||
const names = result.map((k) => (k as TestApiKey).name);
|
||||
assert.ok(names.includes("Alpha"), "Active key should be included");
|
||||
assert.ok(names.includes("Epsilon"), "Manage key should be included");
|
||||
assert.ok(!names.includes("Beta"), "Banned should be excluded");
|
||||
assert.ok(!names.includes("Gamma"), "Disabled should be excluded");
|
||||
assert.ok(!names.includes("Delta"), "Expired should be excluded");
|
||||
});
|
||||
|
||||
it("statusFilter='banned' returns only banned keys", () => {
|
||||
const result = applyFilters(testKeys, { statusFilter: "banned" });
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal((result[0] as TestApiKey).name, "Beta");
|
||||
});
|
||||
|
||||
it("statusFilter='disabled' returns only disabled keys", () => {
|
||||
const result = applyFilters(testKeys, { statusFilter: "disabled" });
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal((result[0] as TestApiKey).name, "Gamma");
|
||||
});
|
||||
|
||||
it("typeFilter='manage' returns only manage-scoped keys", () => {
|
||||
const result = applyFilters(testKeys, { typeFilter: "manage" });
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal((result[0] as TestApiKey).name, "Epsilon");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario 7 — searchQuery filters by name case-insensitively
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("searchQuery filtering", () => {
|
||||
const testKeys: TestApiKey[] = [
|
||||
makeTestKey("Production Key", "sk-prod-abc123", {}),
|
||||
makeTestKey("Development Key", "sk-dev-xyz789", {}),
|
||||
makeTestKey("Staging", "sk-stg-qwerty", {}),
|
||||
];
|
||||
|
||||
it("matches by name case-insensitively", () => {
|
||||
const result = applyFilters(testKeys, { searchQuery: "production" });
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal((result[0] as TestApiKey).name, "Production Key");
|
||||
});
|
||||
|
||||
it("matches by key prefix case-insensitively", () => {
|
||||
const result = applyFilters(testKeys, { searchQuery: "SK-DEV" });
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal((result[0] as TestApiKey).name, "Development Key");
|
||||
});
|
||||
|
||||
it("returns all keys when searchQuery is empty", () => {
|
||||
const result = applyFilters(testKeys, { searchQuery: "" });
|
||||
assert.equal(result.length, 3);
|
||||
});
|
||||
|
||||
it("returns empty when no key matches", () => {
|
||||
const result = applyFilters(testKeys, { searchQuery: "zzznomatch" });
|
||||
assert.equal(result.length, 0);
|
||||
});
|
||||
|
||||
it("matches multiple keys with partial substring", () => {
|
||||
const result = applyFilters(testKeys, { searchQuery: "key" });
|
||||
assert.equal(result.length, 2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
maskKey,
|
||||
toggleKeyVisibility,
|
||||
} from "../../src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.js";
|
||||
|
||||
describe("maskKey", () => {
|
||||
it("returns empty string when key is missing or empty", () => {
|
||||
assert.equal(maskKey(""), "");
|
||||
assert.equal(maskKey(null), "");
|
||||
assert.equal(maskKey(undefined), "");
|
||||
});
|
||||
|
||||
it("returns the key untouched when it fits the visible budget (<=8 chars)", () => {
|
||||
assert.equal(maskKey("sk"), "sk");
|
||||
assert.equal(maskKey("sk-12345"), "sk-12345");
|
||||
});
|
||||
|
||||
it("does not double-mask API keys that are already masked by the server", () => {
|
||||
assert.equal(maskKey("sk-live-****1002"), "sk-live-****1002");
|
||||
});
|
||||
|
||||
it("keeps the first 8 chars and appends an ellipsis when the key is longer", () => {
|
||||
const full = "sk-or-1234567890abcdef";
|
||||
const masked = maskKey(full);
|
||||
assert.equal(masked.startsWith("sk-or-12"), true);
|
||||
assert.equal(masked.endsWith("..."), true);
|
||||
// Must not leak the tail
|
||||
assert.equal(masked.includes("90abcdef"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleKeyVisibility", () => {
|
||||
it("adds an id when it is not present", () => {
|
||||
const next = toggleKeyVisibility(new Set<string>(), "k1");
|
||||
assert.equal(next.has("k1"), true);
|
||||
assert.equal(next.size, 1);
|
||||
});
|
||||
|
||||
it("removes an id when it is already present", () => {
|
||||
const next = toggleKeyVisibility(new Set<string>(["k1", "k2"]), "k1");
|
||||
assert.equal(next.has("k1"), false);
|
||||
assert.equal(next.has("k2"), true);
|
||||
assert.equal(next.size, 1);
|
||||
});
|
||||
|
||||
it("returns a NEW Set (does not mutate the input — React state safety)", () => {
|
||||
const input = new Set<string>(["k1"]);
|
||||
const output = toggleKeyVisibility(input, "k2");
|
||||
assert.notEqual(output, input);
|
||||
assert.equal(input.has("k2"), false);
|
||||
assert.equal(output.has("k2"), true);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user