env/headers are sealed into secretCipher with the BYOK AES-GCM/HKDF box. The admin GET returns masked values; a mask posted back for the same server and key keeps the stored value, any other mask is a 400. The chat and the test button use the stored values. upgradeAiData seals plaintext v1 rows. Co-Authored-By: Claude Opus 5.5 <[email protected]>
124 lines
7.8 KiB
TypeScript
124 lines
7.8 KiB
TypeScript
import { expect } from "bun:test";
|
|
import { forEachAiDb as forEachDb } from "./helpers";
|
|
import { createSecretBox } from "../src/crypto";
|
|
import { aiSettingsRepo, maskSecret, MaskedSecretError } from "../src/settings";
|
|
|
|
const box = createSecretBox("test-secret");
|
|
|
|
forEachDb("server config is empty by default and hides the key", async (db) => {
|
|
const r = aiSettingsRepo(db, box);
|
|
expect(await r.getServer()).toEqual({ provider: null, mcpServers: [], allowedBaseUrls: [] });
|
|
expect(await r.serverApiKey()).toBeNull();
|
|
|
|
await r.saveServer({ provider: { kind: "anthropic", model: "claude-opus-5-5", apiKey: "sk-ant-SECRET" }, mcpServers: [] });
|
|
const got = await r.getServer();
|
|
expect(got.provider).toEqual({ kind: "anthropic", model: "claude-opus-5-5", hasApiKey: true });
|
|
expect(JSON.stringify(got)).not.toContain("SECRET");
|
|
const raw = await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'server'`;
|
|
expect(raw[0].config_json).not.toContain("SECRET");
|
|
expect(await r.serverApiKey()).toBe("sk-ant-SECRET");
|
|
|
|
// Saving without apiKey keeps the old key; clearApiKey removes it.
|
|
await r.saveServer({ provider: { kind: "anthropic", model: "other" }, mcpServers: [{ id: "fs", name: "Files", transport: "stdio", command: "x" }] });
|
|
expect(await r.serverApiKey()).toBe("sk-ant-SECRET");
|
|
expect((await r.getServer()).mcpServers).toHaveLength(1);
|
|
await r.saveServer({ provider: { kind: "openai", baseUrl: "http://ollama:11434/v1", model: "llama3", clearApiKey: true }, mcpServers: [] });
|
|
expect(await r.serverApiKey()).toBeNull();
|
|
expect((await r.getServer()).provider).toEqual({ kind: "openai", baseUrl: "http://ollama:11434/v1", model: "llama3", hasApiKey: false });
|
|
await r.saveServer({ provider: null, mcpServers: [] });
|
|
expect((await r.getServer()).provider).toBeNull();
|
|
});
|
|
|
|
forEachDb("BYOK keys are encrypted, per user and removable", async (db) => {
|
|
const r = aiSettingsRepo(db, box);
|
|
expect((await r.getUser("u1")).byok).toBeNull();
|
|
await r.saveUserKey("u1", { kind: "anthropic", apiKey: "sk-user-SECRET" });
|
|
expect((await r.getUser("u1")).byok).toEqual({ kind: "anthropic", hasApiKey: true });
|
|
expect((await r.getUser("u2")).byok).toBeNull();
|
|
const raw = await db.sql`SELECT ciphertext FROM user_api_keys WHERE user_id = 'u1'`;
|
|
expect(raw[0].ciphertext).not.toContain("SECRET");
|
|
expect(await r.userApiKey("u1")).toBe("sk-user-SECRET");
|
|
expect(await r.userApiKey("u2")).toBeNull();
|
|
// replacing with another provider drops the old key
|
|
await r.saveUserKey("u1", { kind: "openai", baseUrl: "https://api.example.com/v1", model: "m", apiKey: "k2" });
|
|
expect(await r.userApiKey("u1")).toBe("k2");
|
|
expect((await db.sql`SELECT COUNT(*) AS n FROM user_api_keys WHERE user_id = 'u1'`)[0].n.toString()).toBe("1");
|
|
await r.removeUserKey("u1");
|
|
expect((await r.getUser("u1")).byok).toBeNull();
|
|
expect(await r.userApiKey("u1")).toBeNull();
|
|
});
|
|
|
|
forEachDb("mentor mode is on by default and toggles per course", async (db) => {
|
|
const r = aiSettingsRepo(db, box);
|
|
expect(await r.isMentorOff("u1", "sample")).toBe(false);
|
|
await r.setMentorOff("u1", "sample", true);
|
|
expect(await r.isMentorOff("u1", "sample")).toBe(true);
|
|
expect(await r.isMentorOff("u1", "pure-snake")).toBe(false);
|
|
expect(await r.isMentorOff("u2", "sample")).toBe(false);
|
|
await r.saveUserKey("u1", { kind: "anthropic", apiKey: "k" });
|
|
expect(await r.isMentorOff("u1", "sample")).toBe(true);
|
|
await r.setMentorOff("u1", "sample", false);
|
|
expect(await r.isMentorOff("u1", "sample")).toBe(false);
|
|
expect((await r.getUser("u1")).byok?.kind).toBe("anthropic");
|
|
});
|
|
|
|
forEachDb("mentor toggles are rows in ai_mentor_prefs, not config_json", async (db) => {
|
|
const r = aiSettingsRepo(db, box);
|
|
await r.saveUserKey("u1", { kind: "anthropic", apiKey: "k" });
|
|
await r.setMentorOff("u1", "sample", true);
|
|
await r.setMentorOff("u1", "sample", true); // twice is fine
|
|
const rows = await db.sql`SELECT user_id, course_id, off FROM ai_mentor_prefs`;
|
|
expect(rows.map((x: { user_id: string; course_id: string; off: number }) => [x.user_id, x.course_id, Number(x.off)])).toEqual([["u1", "sample", 1]]);
|
|
const cfg = (await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'user' AND owner_id = 'u1'`)[0].config_json;
|
|
expect(cfg).not.toContain("mentorOff");
|
|
expect((await r.getUser("u1")).mentorOff).toEqual({ sample: true });
|
|
await r.setMentorOff("u1", "sample", false);
|
|
expect(await db.sql`SELECT user_id FROM ai_mentor_prefs`).toHaveLength(0);
|
|
});
|
|
|
|
forEachDb("MCP env and headers are encrypted, masked when read and kept when the mask is sent back", async (db) => {
|
|
const r = aiSettingsRepo(db, box);
|
|
await r.saveServer({ provider: null, mcpServers: [
|
|
{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "tok-SECRET-123456", SHORT: "abc" } },
|
|
{ id: "web", name: "Web", transport: "http", url: "https://mcp.example.com/mcp", headers: { authorization: "Bearer hdr-SECRET-abcdef" } },
|
|
] });
|
|
const raw = (await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'server'`)[0].config_json;
|
|
expect(raw).not.toContain("SECRET");
|
|
expect(raw).not.toContain("abc\"");
|
|
|
|
const shown = await r.getServer();
|
|
expect(JSON.stringify(shown)).not.toContain("SECRET");
|
|
expect(shown.mcpServers[0]!.env).toEqual({ FS_TOKEN: "••••3456", SHORT: "••••••••" });
|
|
expect(shown.mcpServers[1]!.headers).toEqual({ authorization: "••••cdef" });
|
|
expect(maskSecret("abc")).toBe("••••••••");
|
|
|
|
// The admin page sends the masks back unchanged (plus a new variable): stored values are kept.
|
|
await r.saveServer({ provider: null, mcpServers: [
|
|
{ ...shown.mcpServers[0]!, env: { ...shown.mcpServers[0]!.env, NEW: "plain-new-value" } },
|
|
shown.mcpServers[1]!,
|
|
] });
|
|
const full = await r.mcpServersWithSecrets();
|
|
expect(full[0]!.env).toEqual({ FS_TOKEN: "tok-SECRET-123456", SHORT: "abc", NEW: "plain-new-value" });
|
|
expect(full[1]!.headers).toEqual({ authorization: "Bearer hdr-SECRET-abcdef" });
|
|
|
|
// A new value replaces the old one; a removed key is gone.
|
|
await r.saveServer({ provider: null, mcpServers: [{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "tok-ROTATED-999999" } }] });
|
|
expect((await r.mcpServersWithSecrets())).toEqual([{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "tok-ROTATED-999999" } }]);
|
|
|
|
// A mask that does not match the stored value (other server id, or a stale mask) is refused, never stored.
|
|
await expect(r.saveServer({ provider: null, mcpServers: [{ id: "fs2", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "••••9999" } }] })).rejects.toThrow(MaskedSecretError);
|
|
await expect(r.saveServer({ provider: null, mcpServers: [{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "••••0000" } }] })).rejects.toThrow(/Re-enter/);
|
|
expect((await r.mcpServersWithSecrets())[0]!.env).toEqual({ FS_TOKEN: "tok-ROTATED-999999" });
|
|
|
|
// resolveMcpMasks: the admin "test" button sends a masked draft; the server fills in the stored values.
|
|
expect(await r.resolveMcpMasks({ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "••••9999" } }))
|
|
.toEqual({ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "tok-ROTATED-999999" } });
|
|
});
|
|
|
|
forEachDb("MCP secrets sealed with another STC_SECRET are dropped, not crashed on", async (db) => {
|
|
await aiSettingsRepo(db, createSecretBox("old-secret")).saveServer({ provider: null, mcpServers: [{ id: "fs", name: "Files", transport: "stdio", command: "x", env: { T: "tok-SECRET-123456" } }] });
|
|
const r = aiSettingsRepo(db, box);
|
|
expect((await r.getServer()).mcpServers).toEqual([{ id: "fs", name: "Files", transport: "stdio", command: "x" }]);
|
|
expect(await r.mcpServersWithSecrets()).toEqual([{ id: "fs", name: "Files", transport: "stdio", command: "x" }]);
|
|
});
|