Review fix round 1. - Secret box v2 tokens: one HKDF key per purpose (byok, server-api-key, mcp) and AES-GCM AAD bound to the row (user id + provider; server id + field). MCP env and headers are sealed per field. open() refuses v1 tokens; the data upgrade re-seals v1 keys, v1 plaintext MCP values and the draft secretCipher, and marks ai_secrets_v2 only when nothing legacy is left (retried until the previous secret is back). - Unreadable secrets are reported (admin: unreadableSecrets with server and field; user: byok.keyUnreadable), logged at startup, and kept on save unless replaced or cleared (clearApiKey, clearSecrets). The admin and account pages show the warnings. - allowedOrigins and provider resolution no longer open MCP secrets. Co-Authored-By: Claude Opus 5.5 <[email protected]>
74 lines
4.4 KiB
TypeScript
74 lines
4.4 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { renderToString } from "react-dom/server";
|
|
import { readNdjson } from "../src/web/api";
|
|
import { MentorBanner, Markdown } from "../src/web/components";
|
|
import { aiWeb } from "../src/web";
|
|
|
|
test("readNdjson splits events across chunk boundaries", async () => {
|
|
const text = '{"type":"meta","n":1}\n{"type":"text","text":"héllo"}\n{"type":"done"}\n';
|
|
const bytes = new TextEncoder().encode(text);
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(c) { for (let i = 0; i < bytes.length; i += 5) c.enqueue(bytes.slice(i, i + 5)); c.close(); },
|
|
});
|
|
const out: unknown[] = [];
|
|
for await (const ev of readNdjson(stream)) out.push(ev);
|
|
expect(out).toEqual([{ type: "meta", n: 1 }, { type: "text", text: "héllo" }, { type: "done" }]);
|
|
});
|
|
|
|
test("the mentor banner says so plainly when mentor mode is off", () => {
|
|
expect(renderToString(<MentorBanner on={false} available />)).toContain("Mentor mode off: I'll give full answers");
|
|
expect(renderToString(<MentorBanner on available />)).toBe("");
|
|
expect(renderToString(<MentorBanner on={false} available={false} />)).toBe("");
|
|
});
|
|
|
|
test("markdown renders code and text without raw HTML", () => {
|
|
const html = renderToString(<Markdown text={"Use **this**:\n\n```js\nconst a = 1 < 2\n```\n\n- one `x`\n- <b>two</b>"} />);
|
|
expect(html).toContain("<strong>this</strong>");
|
|
expect(html).toContain('<pre class="code">');
|
|
expect(html).toContain("const a = 1 < 2");
|
|
expect(html).toContain("<code>x</code>");
|
|
expect(html).toContain("<b>two</b>");
|
|
});
|
|
|
|
test("web routes", () => {
|
|
expect(aiWeb.routes.map((r) => r.path)).toEqual(["/c/:courseId/assistant", "/admin/ai", "/account/ai", "/studio/generate"]);
|
|
expect(aiWeb.routes.find((r) => r.path === "/admin/ai")!.nav).toEqual({ label: "AI", area: "admin", roles: ["owner"] });
|
|
});
|
|
|
|
test("lesson links go through one builder", async () => {
|
|
const { lessonUrl, courseUrl } = await import("../src/web/urls");
|
|
expect(lessonUrl("sample", "m2-ex-add")).toBe("/c/sample/m2-ex-add");
|
|
expect(lessonUrl("S08 - Docker", "l 1/x")).toBe("/c/S08%20-%20Docker/l%201%2Fx");
|
|
expect(courseUrl("sample")).toBe("/c/sample");
|
|
const src = await Bun.file(new URL("../src/web/AssistantPage.tsx", import.meta.url)).text();
|
|
expect(src).toContain("lessonUrl(courseId, c.lessonId)");
|
|
expect(src).not.toMatch(/`\/c\//);
|
|
});
|
|
|
|
test("citation links use the learner's registered lesson route", async () => {
|
|
const { learnerWeb } = await import("@stc/feature-learner/web");
|
|
const { matchPath } = await import("react-router-dom");
|
|
const { lessonUrl } = await import("../src/web/urls");
|
|
const url = lessonUrl("pure snake", "m1-ü/x");
|
|
const hit = learnerWeb.routes.map((r) => matchPath(r.path, url)).find((m) => m && m.pattern.path === "/c/:courseId/:lessonId");
|
|
// matchPath leaves params encoded (the router decodes the pathname before matching).
|
|
expect(Object.fromEntries(Object.entries(hit?.params ?? {}).map(([k, v]) => [k, decodeURIComponent(v!)]))).toEqual({ courseId: "pure snake", lessonId: "m1-ü/x" });
|
|
});
|
|
|
|
test("the unreadable-secrets warnings name what is affected", async () => {
|
|
const { UnreadableSecretsWarning, UNREADABLE_SECRETS_TEXT, UNREADABLE_KEY_TEXT } = await import("../src/web/components");
|
|
expect(UNREADABLE_SECRETS_TEXT).toBe("Some stored secrets can't be decrypted with the current STC_SECRET. Re-enter them or restore the previous secret.");
|
|
expect(UNREADABLE_KEY_TEXT).toBe("Your saved key can't be used; please re-enter it.");
|
|
const html = renderToString(<UnreadableSecretsWarning items={[{ kind: "api-key" }, { kind: "mcp", serverId: "web", name: "Web", field: "headers" }, { kind: "mcp", serverId: "fs", name: "Files", field: "env" }]} />);
|
|
expect(html).toContain("Some stored secrets can't be decrypted with the current STC_SECRET.");
|
|
expect(html).toContain("Server provider: API key");
|
|
expect(html).toContain("MCP server "Web" (web): headers");
|
|
expect(html).toContain("MCP server "Files" (fs): environment");
|
|
expect(html).toContain('role="alert"');
|
|
expect(renderToString(<UnreadableSecretsWarning items={[]} />)).toBe("");
|
|
const account = await Bun.file(new URL("../src/web/AccountAiPage.tsx", import.meta.url)).text();
|
|
expect(account).toContain("UNREADABLE_KEY_TEXT");
|
|
const admin = await Bun.file(new URL("../src/web/AdminAiPage.tsx", import.meta.url)).text();
|
|
expect(admin).toContain("<UnreadableSecretsWarning");
|
|
});
|