371 lines
12 KiB
TypeScript
Executable File
371 lines
12 KiB
TypeScript
Executable File
// Admin API integration: provider CRUD, model refresh, import/export,
|
|
// reload, and the optional bearer-token gate — against the real handler.
|
|
|
|
import { assert, assertEquals } from "@std/assert";
|
|
import { createHandler } from "../../apps/gateway/main.ts";
|
|
import {
|
|
type AppContext,
|
|
NullToolExecutor,
|
|
VERSION,
|
|
} from "../../apps/gateway/context.ts";
|
|
import { ConfigService } from "../../packages/config/src/service.ts";
|
|
import { ConfigCrypto } from "../../packages/config/src/crypto.ts";
|
|
import { ProviderManager } from "../../packages/providers/src/mod.ts";
|
|
import { Metrics } from "../../packages/telemetry/src/metrics.ts";
|
|
import { LogBus } from "../../packages/telemetry/src/logbus.ts";
|
|
import { VirtualKeyManager } from "../../packages/governance/src/virtual_keys.ts";
|
|
import { MCPRegistry } from "../../packages/mcp/src/registry.ts";
|
|
import { PluginManager } from "../../packages/plugins/src/lifecycle.ts";
|
|
import { jsonResponse, MockProvider } from "../../packages/testing/src/mod.ts";
|
|
|
|
const base = "http://gateway.test";
|
|
|
|
async function makeContext(
|
|
adminToken?: string,
|
|
encrypted = false,
|
|
): Promise<AppContext> {
|
|
const config = await ConfigService.open(":memory:");
|
|
if (encrypted) {
|
|
const dek = crypto.getRandomValues(new Uint8Array(32));
|
|
config.setCrypto(await ConfigCrypto.fromRawDek(dek));
|
|
}
|
|
return {
|
|
providers: new ProviderManager([]),
|
|
metrics: new Metrics(),
|
|
logBus: new LogBus(),
|
|
virtualKeys: new VirtualKeyManager(),
|
|
mcp: new MCPRegistry(),
|
|
plugins: new PluginManager(),
|
|
toolExecutor: new NullToolExecutor(),
|
|
version: VERSION,
|
|
config,
|
|
adminToken,
|
|
};
|
|
}
|
|
|
|
Deno.test("admin API: full provider CRUD lifecycle", async (t) => {
|
|
const ctx = await makeContext();
|
|
const handler = createHandler(ctx);
|
|
|
|
await t.step("create", async () => {
|
|
const res = await handler(
|
|
new Request(`${base}/api/providers`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
id: "openai",
|
|
type: "openai",
|
|
apiKey: "sk-secret",
|
|
enabled: true,
|
|
models: ["gpt-4o"],
|
|
priority: 0,
|
|
}),
|
|
}),
|
|
);
|
|
assertEquals(res.status, 201);
|
|
const body = await res.json();
|
|
assertEquals(body.hasApiKey, true);
|
|
assertEquals("apiKey" in body, false); // never echo secrets
|
|
});
|
|
|
|
await t.step("list is redacted", async () => {
|
|
const res = await handler(new Request(`${base}/api/providers`));
|
|
const body = await res.json();
|
|
assertEquals(body.providers.length, 1);
|
|
assertEquals(body.providers[0].hasApiKey, true);
|
|
assertEquals("apiKey" in body.providers[0], false);
|
|
});
|
|
|
|
await t.step("update merges patch", async () => {
|
|
const res = await handler(
|
|
new Request(`${base}/api/providers/openai`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ models: ["gpt-4o", "gpt-4o-mini"] }),
|
|
}),
|
|
);
|
|
assertEquals(res.status, 200);
|
|
assertEquals((await res.json()).models, ["gpt-4o", "gpt-4o-mini"]);
|
|
// secret survives a patch that does not mention it
|
|
const persisted = await ctx.config!.getProvider("openai");
|
|
assertEquals(persisted?.apiKey, "sk-secret");
|
|
});
|
|
|
|
await t.step("default provider setting", async () => {
|
|
const res = await handler(
|
|
new Request(`${base}/api/config`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ defaultProvider: "openai" }),
|
|
}),
|
|
);
|
|
assertEquals(res.status, 200);
|
|
assertEquals(ctx.providers.getDefaultProvider(), "openai");
|
|
});
|
|
|
|
await t.step("reload rebuilds the manager from KV", async () => {
|
|
ctx.providers.remove("openai"); // simulate in-memory drift
|
|
assertEquals(ctx.providers.list().length, 0);
|
|
const res = await handler(
|
|
new Request(`${base}/api/config/reload`, { method: "POST" }),
|
|
);
|
|
assertEquals((await res.json()).providers, 1);
|
|
assertEquals(ctx.providers.list()[0].id, "openai");
|
|
});
|
|
|
|
await t.step("delete", async () => {
|
|
const res = await handler(
|
|
new Request(`${base}/api/providers/openai`, { method: "DELETE" }),
|
|
);
|
|
assertEquals(res.status, 204);
|
|
assertEquals(ctx.providers.list().length, 0);
|
|
assertEquals(await ctx.config!.getProvider("openai"), null);
|
|
});
|
|
|
|
ctx.config!.close();
|
|
});
|
|
|
|
Deno.test("admin API: model refresh pulls the live catalog", async () => {
|
|
const mock = new MockProvider(() =>
|
|
jsonResponse({
|
|
data: [{ id: "gpt-4o" }, { id: "gpt-4o-mini" }, { id: "o3-mini" }],
|
|
})
|
|
);
|
|
try {
|
|
const ctx = await makeContext();
|
|
const handler = createHandler(ctx);
|
|
await handler(
|
|
new Request(`${base}/api/providers`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
id: "openai",
|
|
type: "openai",
|
|
apiKey: "k",
|
|
baseUrl: mock.url,
|
|
enabled: true,
|
|
models: [],
|
|
priority: 0,
|
|
retry: { maxRetries: 0 },
|
|
}),
|
|
}),
|
|
);
|
|
const res = await handler(
|
|
new Request(`${base}/api/providers/openai/refresh-models`, {
|
|
method: "POST",
|
|
}),
|
|
);
|
|
assertEquals(res.status, 200);
|
|
const body = await res.json();
|
|
assertEquals(body.models, ["gpt-4o", "gpt-4o-mini", "o3-mini"]);
|
|
assertEquals(ctx.providers.get("openai")?.models.length, 3);
|
|
assertEquals((await ctx.config!.getProvider("openai"))?.models.length, 3);
|
|
ctx.config!.close();
|
|
} finally {
|
|
await mock.close();
|
|
}
|
|
});
|
|
|
|
Deno.test("admin API: export redacts secrets unless explicitly included", async () => {
|
|
const ctx = await makeContext();
|
|
const handler = createHandler(ctx);
|
|
await handler(
|
|
new Request(`${base}/api/providers`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
id: "openai",
|
|
type: "openai",
|
|
apiKey: "sk-secret",
|
|
enabled: true,
|
|
models: [],
|
|
priority: 0,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
const redacted = await (await handler(
|
|
new Request(`${base}/api/config/export`),
|
|
)).json();
|
|
// Redacted export strips the secret entirely via canonical redaction (no
|
|
// "***redacted***" placeholder, no value); only a hasApiKey marker remains.
|
|
assertEquals(redacted.config.providers[0].apiKey, undefined);
|
|
assertEquals(redacted.config.providers[0].hasApiKey, true);
|
|
|
|
const full = await (await handler(
|
|
new Request(`${base}/api/config/export?include_secrets=true`),
|
|
)).json();
|
|
assertEquals(full.config.providers[0].apiKey, "sk-secret");
|
|
|
|
// import the full export into a fresh context
|
|
const ctx2 = await makeContext();
|
|
const handler2 = createHandler(ctx2);
|
|
const res = await handler2(
|
|
new Request(`${base}/api/config/import`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(full),
|
|
}),
|
|
);
|
|
assertEquals((await res.json()).imported, true);
|
|
assertEquals(ctx2.providers.get("openai")?.apiKey, "sk-secret");
|
|
ctx.config!.close();
|
|
ctx2.config!.close();
|
|
});
|
|
|
|
Deno.test("admin API: malformed import wrapper is a 400, never a silent wipe", async () => {
|
|
const ctx = await makeContext();
|
|
const handler = createHandler(ctx);
|
|
// Seed one provider that must survive a rejected import.
|
|
await handler(
|
|
new Request(`${base}/api/providers`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
id: "keep",
|
|
type: "openai",
|
|
apiKey: "sk-keep",
|
|
enabled: true,
|
|
models: [],
|
|
priority: 0,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
// A ConfigExport wrapper missing the required `exportedAt`. Before the
|
|
// GatewayConfigSchema-strict fix this parsed as a bare config with zero
|
|
// providers and WIPED the set; now it is rejected.
|
|
const res = await handler(
|
|
new Request(`${base}/api/config/import`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
version: 1,
|
|
config: { defaultProvider: "gone", providers: [] },
|
|
}),
|
|
}),
|
|
);
|
|
assertEquals(res.status, 400);
|
|
await res.body?.cancel();
|
|
|
|
// Both the runtime manager and the persisted store still hold "keep".
|
|
assertEquals(ctx.providers.get("keep")?.apiKey, "sk-keep");
|
|
const persisted = await ctx.config!.loadAll();
|
|
assertEquals(persisted.providers.map((p) => p.id), ["keep"]);
|
|
ctx.config!.close();
|
|
});
|
|
|
|
Deno.test("admin API: bearer token gate protects /api/* when configured", async () => {
|
|
const ctx = await makeContext("s3cret");
|
|
const handler = createHandler(ctx);
|
|
|
|
const denied = await handler(new Request(`${base}/api/providers`));
|
|
assertEquals(denied.status, 401);
|
|
|
|
const wrongToken = await handler(
|
|
new Request(`${base}/api/providers`, {
|
|
headers: { Authorization: "Bearer wrong" },
|
|
}),
|
|
);
|
|
assertEquals(wrongToken.status, 401);
|
|
|
|
const allowed = await handler(
|
|
new Request(`${base}/api/providers`, {
|
|
headers: { Authorization: "Bearer s3cret" },
|
|
}),
|
|
);
|
|
assertEquals(allowed.status, 200);
|
|
await allowed.body?.cancel();
|
|
|
|
// health and inference surfaces stay open
|
|
const health = await handler(new Request(`${base}/healthz`));
|
|
assertEquals(health.status, 200);
|
|
await health.body?.cancel();
|
|
const version = await handler(new Request(`${base}/api/version`));
|
|
assertEquals(version.status, 200);
|
|
await version.body?.cancel();
|
|
|
|
assert(ctx.config);
|
|
ctx.config.close();
|
|
});
|
|
|
|
Deno.test("admin API: global provider proxy is encrypted, redacted, and reloadable", async () => {
|
|
const ctx = await makeContext(undefined, true);
|
|
const handler = createHandler(ctx);
|
|
const proxy = {
|
|
proxyUrl: "http://proxy.internal:8080",
|
|
proxyUsername: "operator",
|
|
proxyPassword: "secret",
|
|
noProxy: [".private.example"],
|
|
};
|
|
|
|
const put = await handler(
|
|
new Request(`${base}/api/proxy-config`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(proxy),
|
|
}),
|
|
);
|
|
assertEquals(put.status, 200);
|
|
const putBody = await put.json();
|
|
assertEquals(putBody, {
|
|
enabled: true,
|
|
proxyType: "http",
|
|
hasCredentials: true,
|
|
noProxyCount: 1,
|
|
});
|
|
assertEquals(await ctx.config!.getGlobalProxy(), proxy);
|
|
|
|
const getBody = await (await handler(
|
|
new Request(`${base}/api/proxy-config`),
|
|
)).json();
|
|
const serialized = JSON.stringify(getBody);
|
|
for (
|
|
const secret of ["proxy.internal", "operator", "secret", "private.example"]
|
|
) {
|
|
assert(!serialized.includes(secret), `GET leaked ${secret}`);
|
|
}
|
|
|
|
const rejected = await handler(
|
|
new Request(`${base}/api/proxy-config`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ proxyUrl: "ftp://bad.proxy" }),
|
|
}),
|
|
);
|
|
assertEquals(rejected.status, 400);
|
|
assertEquals(await ctx.config!.getGlobalProxy(), proxy);
|
|
|
|
ctx.providers.configureGlobalProxy(undefined); // simulate in-memory drift
|
|
const reload = await handler(
|
|
new Request(`${base}/api/config/reload`, {
|
|
method: "POST",
|
|
}),
|
|
);
|
|
assertEquals(reload.status, 200);
|
|
assertEquals(ctx.providers.getGlobalProxy(), proxy);
|
|
|
|
const deleted = await handler(
|
|
new Request(`${base}/api/proxy-config`, {
|
|
method: "DELETE",
|
|
}),
|
|
);
|
|
assertEquals(deleted.status, 204);
|
|
assertEquals(await ctx.config!.getGlobalProxy(), undefined);
|
|
ctx.config!.close();
|
|
});
|
|
|
|
Deno.test("admin API: global provider proxy refuses plaintext persistence", async () => {
|
|
const ctx = await makeContext();
|
|
const response = await createHandler(ctx)(
|
|
new Request(`${base}/api/proxy-config`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ proxyUrl: "http://proxy.internal:8080" }),
|
|
}),
|
|
);
|
|
assertEquals(response.status, 409);
|
|
assertEquals(await ctx.config!.getGlobalProxy(), undefined);
|
|
ctx.config!.close();
|
|
});
|