From 0e270153fe6f5fe59bcc606a7450c1c0e3d52a43 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 13 Jun 2026 19:11:07 -0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20BYOK=20frontend=20polish=20?= =?UTF-8?q?=E2=80=94=20panel=20tests,=20UX/a11y,=20maintainer=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final round of the BYOK security-audit follow-ups (after #670, #680). - #673 Stand up a UI test runner (vitest + @testing-library/react + jsdom) in the gittensory-ui workspace and add the first component tests: the MaintainerPanel role gate (non-maintainer → "Maintainer access required", BYOK field never mounts; maintainer → dashboard) and AiReviewSettings (key field is write-only/password and never hydrates a stored key; provider/key mismatch is rejected client-side without a request; a valid key posts then clears). Wired `ui:test` into the CI "UI check" step. - #678 AI review panel UX + a11y: an initial loading state, an empty-state hint when no registered repos are detected, role="status" aria-live on the result banner, and aria-busy on the action buttons. - #675 Maintainer-facing BYOK documentation (docs/maintainer-byok-ai-review.md): how the advisory vs consensus layers work, that BYOK calls the provider directly and bills the maintainer (consensus always free), encrypted/write-only key handling, and dashboard vs .gittensory.yml config. Kept out of docs.github-app.tsx to avoid conflicting with the open onboarding-docs PR #648. Verified: ui:typecheck, ui:lint, ui:test (6 tests), ui:build, ui:openapi:check, actionlint, npm audit (0 vulns). Closes #673, #675, #678. Part of #525. --- .github/workflows/ci.yml | 2 +- apps/gittensory-ui/package.json | 4 + .../app-panels/ai-review-settings.test.tsx | 73 ++ .../site/app-panels/ai-review-settings.tsx | 39 +- .../site/app-panels/maintainer-panel.test.tsx | 36 + apps/gittensory-ui/vitest.config.ts | 16 + apps/gittensory-ui/vitest.setup.ts | 7 + docs/maintainer-byok-ai-review.md | 77 ++ package-lock.json | 729 ++++++++++++++++++ package.json | 1 + 10 files changed, 973 insertions(+), 11 deletions(-) create mode 100644 apps/gittensory-ui/src/components/site/app-panels/ai-review-settings.test.tsx create mode 100644 apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.test.tsx create mode 100644 apps/gittensory-ui/vitest.config.ts create mode 100644 apps/gittensory-ui/vitest.setup.ts create mode 100644 docs/maintainer-byok-ai-review.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2626472422..fa3aec142b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: - name: UI check env: VITE_GITTENSORY_API_ORIGIN: https://gittensory-api.aethereal.dev - run: npm run ui:openapi:check && npm run ui:lint && npm run ui:typecheck && npm run ui:build + run: npm run ui:openapi:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build - name: Audit dependencies run: npm audit --audit-level=moderate diff --git a/apps/gittensory-ui/package.json b/apps/gittensory-ui/package.json index d2941031f1..2b310fbf18 100644 --- a/apps/gittensory-ui/package.json +++ b/apps/gittensory-ui/package.json @@ -14,6 +14,7 @@ "version:built": "wrangler versions upload --config dist/server/wrangler.json", "typecheck": "tsc --noEmit", "lint": "eslint .", + "test": "vitest run", "format": "prettier --write ." }, "dependencies": { @@ -74,6 +75,8 @@ "devDependencies": { "@eslint/js": "^9.32.0", "@lovable.dev/vite-tanstack-config": "^2.1.1", + "@testing-library/dom": "^10.4.0", + "@testing-library/react": "^16.1.0", "@types/node": "^22.16.5", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", @@ -84,6 +87,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^15.15.0", + "jsdom": "^25.0.1", "nitro": "3.0.260429-beta", "prettier": "^3.7.3", "typescript": "^5.8.3", diff --git a/apps/gittensory-ui/src/components/site/app-panels/ai-review-settings.test.tsx b/apps/gittensory-ui/src/components/site/app-panels/ai-review-settings.test.tsx new file mode 100644 index 0000000000..244fbef767 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/ai-review-settings.test.tsx @@ -0,0 +1,73 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the API layer so the component never touches the network. +const apiFetch = vi.fn(); +vi.mock("@/lib/api/request", () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) })); +vi.mock("@/lib/api/origin", () => ({ getApiOrigin: () => "https://api.test" })); + +import { AiReviewSettings } from "@/components/site/app-panels/ai-review-settings"; + +const REVIEWABILITY = [{ pr: "acme/widgets#1" }]; + +describe("AiReviewSettings", () => { + beforeEach(() => { + apiFetch.mockReset(); + apiFetch.mockResolvedValue({ ok: true, data: { configured: false } }); + }); + + it("renders the provider key field as write-only (password) and never hydrates a stored key", async () => { + // GET settings + GET ai-key both report a configured key, but only the last4 status comes back. + apiFetch.mockResolvedValue({ + ok: true, + data: { configured: true, last4: "7890", provider: "anthropic" }, + }); + render(); + + const keyInput = (await screen.findByPlaceholderText("sk-ant-…")) as HTMLInputElement; + expect(keyInput.type).toBe("password"); + expect(keyInput.value).toBe(""); // the stored key is NEVER written back into the field + await waitFor(() => expect(screen.getByText(/configured/)).toBeTruthy()); + // The raw key never appears anywhere in the rendered DOM. + expect(document.body.textContent).not.toContain("sk-ant-"); + }); + + it("rejects a provider/key mismatch client-side without calling the key endpoint", async () => { + render(); + await screen.findByPlaceholderText("sk-ant-…"); + await waitFor(() => expect(apiFetch).toHaveBeenCalled()); // initial load (GETs) settled + apiFetch.mockClear(); + + // Provider defaults to anthropic; paste an OpenAI-shaped key. + fireEvent.change(screen.getByPlaceholderText("sk-ant-…"), { + target: { value: "sk-openai-not-anthropic-123456" }, + }); + fireEvent.click(screen.getByRole("button", { name: /save key/i })); + + expect(await screen.findByText(/Anthropic keys start with sk-ant-/)).toBeTruthy(); + // No write request was attempted. + expect(apiFetch).not.toHaveBeenCalled(); + }); + + it("posts a valid key, clears the input, and surfaces only the returned last4 status", async () => { + render(); + await screen.findByPlaceholderText("sk-ant-…"); + await waitFor(() => expect(apiFetch).toHaveBeenCalled()); + apiFetch.mockClear(); + apiFetch.mockResolvedValue({ + ok: true, + data: { configured: true, last4: "4242", provider: "anthropic" }, + }); + + const keyInput = screen.getByPlaceholderText("sk-ant-…") as HTMLInputElement; + fireEvent.change(keyInput, { target: { value: "sk-ant-valid-key-123456789" } }); + fireEvent.click(screen.getByRole("button", { name: /save key/i })); + + await waitFor(() => expect(screen.getByText(/Provider key stored/)).toBeTruthy()); + const post = apiFetch.mock.calls.find( + ([, opts]) => (opts as { method?: string })?.method === "POST", + ); + expect(post?.[0]).toContain("/ai-key"); + expect(keyInput.value).toBe(""); // input cleared after a successful save + }); +}); diff --git a/apps/gittensory-ui/src/components/site/app-panels/ai-review-settings.tsx b/apps/gittensory-ui/src/components/site/app-panels/ai-review-settings.tsx index c04919610a..0af70fc997 100644 --- a/apps/gittensory-ui/src/components/site/app-panels/ai-review-settings.tsx +++ b/apps/gittensory-ui/src/components/site/app-panels/ai-review-settings.tsx @@ -46,14 +46,17 @@ export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr: const [keyInput, setKeyInput] = useState(""); const [keyStatus, setKeyStatus] = useState(null); const [busy, setBusy] = useState(false); + const [loading, setLoading] = useState(false); const [message, setMessage] = useState(null); const base = repoApiBase(repoFullName); + const hasRepos = repoOptions.length > 0; const load = useCallback(async () => { const apiBase = repoApiBase(repoFullName); if (!apiBase) return; setMessage(null); + setLoading(true); const [settings, key] = await Promise.all([ apiFetch(`${apiBase}/settings`, { label: "AI review settings", @@ -73,6 +76,7 @@ export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr: setModel(settings.data.aiReviewModel ?? ""); } setKeyStatus(key.ok ? key.data : null); + setLoading(false); }, [repoFullName]); useEffect(() => { @@ -198,6 +202,12 @@ export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr: