From 18ce8b53d66e5f3b0b03f39e3cb69d8c7ff8292c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 19:28:30 +0000 Subject: [PATCH 1/5] Fix case-sensitive slug lookup in slug mutation endpoints setSlugPrimary, disableSlug, enableSlug, and removeSlug matched the caller's slug against stored slugs with a case-sensitive comparison, while getLinkBySlug and the redirect handler normalize to lowercase first. Slugs are always stored lowercase (addCustomSlugToLink lowercases on insert), and SlugParamSchema permits uppercase in the path, so a request against an existing slug with different casing spuriously returned 404. Lowercase the slug argument at the top of each of the four functions, matching the pattern already used elsewhere. --- src/__tests__/service/ownership.test.ts | 39 +++++++++++++++++++++++++ src/services/link-management.ts | 4 +++ 2 files changed, 43 insertions(+) diff --git a/src/__tests__/service/ownership.test.ts b/src/__tests__/service/ownership.test.ts index 5aaacbc..a5d8d25 100644 --- a/src/__tests__/service/ownership.test.ts +++ b/src/__tests__/service/ownership.test.ts @@ -168,6 +168,45 @@ describe("Slug ownership: disable", () => { }); }); +describe("Slug case sensitivity: mutation endpoints match stored (lowercase) slugs", () => { + it("disableSlug matches a differently-cased slug argument", async () => { + const link = await createOwnedLink(); + await addCustomSlugToLink(env as any, link.id, { slug: "custom-slug" }); + + const result = await disableSlug(env as any, link.id, "Custom-Slug", OWNER); + expect(result.ok).toBe(true); + }); + + it("enableSlug matches a differently-cased slug argument", async () => { + const link = await createOwnedLink(); + await addCustomSlugToLink(env as any, link.id, { slug: "custom-slug" }); + await disableSlug(env as any, link.id, "custom-slug", OWNER); + + const result = await enableSlug(env as any, link.id, "CUSTOM-SLUG", OWNER); + expect(result.ok).toBe(true); + }); + + it("removeSlug matches a differently-cased slug argument", async () => { + const link = await createOwnedLink(); + await addCustomSlugToLink(env as any, link.id, { slug: "custom-slug" }); + + const result = await removeSlug(env as any, link.id, "Custom-Slug", OWNER); + expect(result.ok).toBe(true); + }); + + it("setSlugPrimary matches a differently-cased slug argument", async () => { + const link = await createOwnedLink(); + await addCustomSlugToLink(env as any, link.id, { slug: "custom-slug" }); + + const result = await setSlugPrimary(env as any, link.id, "Custom-Slug", OWNER); + expect(result.ok).toBe(true); + if (result.ok) { + const primary = result.data.slugs.find((s) => s.is_primary === 1); + expect(primary?.slug).toBe("custom-slug"); + } + }); +}); + describe("Slug ownership: enable", () => { it("link owner can enable a disabled slug", async () => { const link = await createOwnedLink(); diff --git a/src/services/link-management.ts b/src/services/link-management.ts index b35c53e..7478e00 100644 --- a/src/services/link-management.ts +++ b/src/services/link-management.ts @@ -297,6 +297,7 @@ export async function setSlugPrimary( slug: string, identity: string, ): Promise> { + slug = slug.toLowerCase(); const link = await LinkRepository.getById(env.DB, linkId); if (!link) return fail(404, "Link not found"); if (link.created_by !== identity) return fail(403, "Only the link owner can change the primary slug"); @@ -316,6 +317,7 @@ export async function disableSlug( slug: string, identity: string, ): Promise> { + slug = slug.toLowerCase(); const link = await LinkRepository.getById(env.DB, linkId); if (!link) return fail(404, "Link not found"); if (link.created_by !== identity) return fail(403, "Only the link owner can disable slugs on this link"); @@ -342,6 +344,7 @@ export async function enableSlug( slug: string, identity: string, ): Promise> { + slug = slug.toLowerCase(); const link = await LinkRepository.getById(env.DB, linkId); if (!link) return fail(404, "Link not found"); if (link.created_by !== identity) return fail(403, "Only the link owner can enable slugs on this link"); @@ -367,6 +370,7 @@ export async function removeSlug( slug: string, identity: string, ): Promise> { + slug = slug.toLowerCase(); const link = await LinkRepository.getById(env.DB, linkId); if (!link) return fail(404, "Link not found"); if (link.created_by !== identity) return fail(403, "Only the link owner can remove slugs on this link"); From 3fca3e7f678168be92955f6ce5f846fefeed6874 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 19:32:24 +0000 Subject: [PATCH 2/5] Fix dashboard widgets ignoring a link's is_primary slug recentLinksWidget's primarySlug() and LinkRepository.primarySlugByIds (used by top-links) both picked the first auto-generated (non-custom) slug, never checking is_primary. Once an owner sets a custom slug as the link's primary via setSlugPrimary, the link list and detail pages correctly show it everywhere, but the dashboard's Recent Links and Most Clicked widgets kept showing the old auto-generated slug, copying the wrong URL to the clipboard. Prefer the slug flagged is_primary in both places, matching the pick already used on the link list/detail pages. --- .../admin/widgets/dashboard/recent-links.test.tsx | 13 +++++++++++++ .../admin/widgets/dashboard/top-links.test.tsx | 11 +++++++++++ src/admin/widgets/dashboard/recent-links.tsx | 7 ++++--- src/db/link-repository.ts | 11 +++++------ 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/__tests__/admin/widgets/dashboard/recent-links.test.tsx b/src/__tests__/admin/widgets/dashboard/recent-links.test.tsx index f7f9d54..4e138c3 100644 --- a/src/__tests__/admin/widgets/dashboard/recent-links.test.tsx +++ b/src/__tests__/admin/widgets/dashboard/recent-links.test.tsx @@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { env } from "cloudflare:test"; import { applyMigrations, resetData } from "../../../setup"; import { LinkRepository } from "../../../../db"; +import { addCustomSlugToLink, setSlugPrimary } from "../../../../services/link-management"; import { recentLinksWidget } from "../../../../admin/widgets/dashboard/recent-links"; import type { WidgetCtx } from "../../../../admin/widgets/types"; @@ -52,6 +53,18 @@ describe("dashboard.recent-links widget", () => { expect(out).not.toContain("bento-card"); }); + it("shows a custom slug the owner set as primary, not the auto-generated one", async () => { + const link = await LinkRepository.create(env.DB, { url: "https://e.com", slug: "auto123" }); + await addCustomSlugToLink(env as any, link.id, { slug: "promo" }); + await setSlugPrimary(env as any, link.id, "promo", "anonymous"); + + const data = await recentLinksWidget.load(env, ctx, { range: "all" }); + const out = String(recentLinksWidget.render(data, ctx)); + + expect(out).toContain('data-copy-slug="promo"'); + expect(out).not.toContain('data-copy-slug="auto123"'); + }); + it("exposes the copy chip via data-copy-slug, not an inline onclick", async () => { await LinkRepository.create(env.DB, { url: "https://e.com", slug: "abc" }); const data = await recentLinksWidget.load(env, ctx, { range: "all" }); diff --git a/src/__tests__/admin/widgets/dashboard/top-links.test.tsx b/src/__tests__/admin/widgets/dashboard/top-links.test.tsx index a321f3f..fe324d1 100644 --- a/src/__tests__/admin/widgets/dashboard/top-links.test.tsx +++ b/src/__tests__/admin/widgets/dashboard/top-links.test.tsx @@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { env } from "cloudflare:test"; import { applyMigrations, resetData } from "../../../setup"; import { LinkRepository, ClickRepository } from "../../../../db"; +import { addCustomSlugToLink, setSlugPrimary } from "../../../../services/link-management"; import { topLinksWidget } from "../../../../admin/widgets/dashboard/top-links"; import type { WidgetCtx } from "../../../../admin/widgets/types"; @@ -48,4 +49,14 @@ describe("dashboard.top-links widget", () => { const out = String(topLinksWidget.render(data, ctx)); expect(out).toContain("primo"); // primary slug shown, matching the page }); + + it("names a row by the custom slug the owner set as primary, not the auto-generated one", async () => { + const link = await LinkRepository.create(env.DB, { url: "https://e.com", slug: "auto123" }); + await addCustomSlugToLink(env as any, link.id, { slug: "promo" }); + await setSlugPrimary(env as any, link.id, "promo", "anonymous"); + await ClickRepository.record(env.DB, "promo", {}); + + const data = await topLinksWidget.load(env, ctx, { range: "all" }); + expect(data.rows[0].slug).toBe("promo"); + }); }); diff --git a/src/admin/widgets/dashboard/recent-links.tsx b/src/admin/widgets/dashboard/recent-links.tsx index cf8168e..73c25af 100644 --- a/src/admin/widgets/dashboard/recent-links.tsx +++ b/src/admin/widgets/dashboard/recent-links.tsx @@ -11,11 +11,12 @@ interface RecentLinksData { } /** - * Mirrors the primary-slug pick on the dashboard page: the first - * auto-generated slug, falling back to the first slug of any kind. + * Picks the slug marked is_primary, falling back to the first auto-generated + * slug, then the first slug of any kind. Mirrors the primary-slug pick used + * on the link list and detail pages. */ function primarySlug(link: LinkWithSlugs): string { - const p = link.slugs.find((s) => !s.is_custom); + const p = link.slugs.find((s) => s.is_primary) ?? link.slugs.find((s) => !s.is_custom); return p ? p.slug : link.slugs[0]?.slug || ""; } diff --git a/src/db/link-repository.ts b/src/db/link-repository.ts index 731965d..72352f6 100644 --- a/src/db/link-repository.ts +++ b/src/db/link-repository.ts @@ -270,11 +270,10 @@ export class LinkRepository { /** * Batch-resolve the display slug for a set of link ids in one query. Picks - * the primary slug per link the way the dashboard does: the first - * auto-generated (non-custom) slug, falling back to the first slug of any - * kind. Mirrors the `is_custom ASC, created_at ASC` ordering the slug loaders - * use, so the pick matches recent-links exactly. Returns a Map keyed by - * link_id; ids with no slug are absent. + * the slug marked is_primary per link, falling back to the first + * auto-generated (non-custom) slug, then the first slug of any kind, so the + * pick matches the one used on the link list and detail pages. Returns a + * Map keyed by link_id; ids with no slug are absent. */ static async primarySlugByIds(db: D1Database, ids: number[]): Promise> { const out = new Map(); @@ -283,7 +282,7 @@ export class LinkRepository { const rows = await db .prepare( `SELECT link_id, slug FROM slugs WHERE link_id IN (${placeholders}) - ORDER BY is_custom ASC, created_at ASC`, + ORDER BY is_primary DESC, is_custom ASC, created_at ASC`, ) .bind(...ids) .all<{ link_id: number; slug: string }>(); From f58914c9aeafe7ae9e47a75483396a2588076c25 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 19:34:05 +0000 Subject: [PATCH 3/5] Translate API key scope badges instead of rendering raw tokens The keys table rendered each key's scope badge as the literal internal token ("create"/"read"), untranslated, while the New Key modal translates the same concept via client.scopeCreate/client.scopeRead. Non-English admin UIs showed English scope names in this one table. Map each scope token through the existing client.scopeCreate/ client.scopeRead keys before rendering. --- src/__tests__/page/keys-page.test.ts | 14 ++++++++++++++ src/pages/keys.tsx | 8 +++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/__tests__/page/keys-page.test.ts b/src/__tests__/page/keys-page.test.ts index c39ecf6..5b5b669 100644 --- a/src/__tests__/page/keys-page.test.ts +++ b/src/__tests__/page/keys-page.test.ts @@ -110,6 +110,20 @@ describe("Keys page table", () => { expect(html).not.toContain(`${prefix}…`); }); + it("translates the scope badge instead of rendering the raw scope token", async () => { + await seedApiKey(); + const html = await fetchKeysHtml(); + expect(html).toMatch(/class="scope-badge create"[^>]*>Create]*>create { + await seedApiKey(); + const res = await SELF.fetch(authed("/_/admin/keys", { headers: { Cookie: "lang=id" } })); + const html = await res.text(); + expect(html).toMatch(/class="scope-badge create"[^>]*>Buat { await seedApiKey(); const html = await fetchKeysHtml(); diff --git a/src/pages/keys.tsx b/src/pages/keys.tsx index a178447..1465b13 100644 --- a/src/pages/keys.tsx +++ b/src/pages/keys.tsx @@ -8,6 +8,12 @@ import { SdkList } from "./sdk-list"; // Bullets that stand in for the redacted tail of a key prefix, e.g. sk_84cc••••••. const KEY_MASK = "••••••"; +function scopeLabel(scope: string, t: TranslateFn): string { + if (scope === "create") return t("client.scopeCreate"); + if (scope === "read") return t("client.scopeRead"); + return scope; +} + function formatDate(ts: number, lang: string): string { const d = new Date(ts * 1000); return d.toLocaleDateString(lang, { @@ -132,7 +138,7 @@ export const KeysPage: FC = ({ keys, t, lang, origin }) => { {scopes.map((s) => ( - {s} + {scopeLabel(s, t)} ))} From 543619e643de5684afa3bc793e04b084537b2ad3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 19:35:07 +0000 Subject: [PATCH 4/5] Fix TS SDK swallowing transport failures on non-2xx responses request()'s and requestText()'s !res.ok branch read the body via res.json() directly and folded any failure (JSON parse error or a connection reset mid-transfer) into the same generic "HTTP {status}" message. A transport failure while streaming an *error* response body therefore surfaced as ShrtnrError(status, ...) instead of the ShrtnrError(0, ...) every other read path reports, unlike the success path (already split into readBody() + JSON.parse after the 1.1.3 fix) and unlike the Python and Dart SDKs, which are consistent here. Route the error branch's body read through the existing readBody() helper before parsing, mirroring the success path. --- sdk/typescript/src/internal/http.ts | 12 ++++++++-- sdk/typescript/tests/client.test.ts | 34 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/src/internal/http.ts b/sdk/typescript/src/internal/http.ts index 7a0cf2a..89bd669 100644 --- a/sdk/typescript/src/internal/http.ts +++ b/sdk/typescript/src/internal/http.ts @@ -67,9 +67,13 @@ export class HttpClient { } if (!res.ok) { + // Read and parse in separate steps, same as the success path below: a + // connection reset while streaming an error body is a transport + // failure (status 0), not the server's declared HTTP status. + const text = await this.readBody(res); let serverMessage = `HTTP ${res.status}`; try { - const json = (await res.json()) as { error?: string }; + const json = JSON.parse(text) as { error?: string }; if (typeof json.error === "string") serverMessage = json.error; } catch { // ignore parse failure; keep default message @@ -115,9 +119,13 @@ export class HttpClient { } if (!res.ok) { + // Read and parse in separate steps, same as the success path below: a + // connection reset while streaming an error body is a transport + // failure (status 0), not the server's declared HTTP status. + const text = await this.readBody(res); let serverMessage = `HTTP ${res.status}`; try { - const json = (await res.json()) as { error?: string }; + const json = JSON.parse(text) as { error?: string }; if (typeof json.error === "string") serverMessage = json.error; } catch { // ignore parse failure; keep default message diff --git a/sdk/typescript/tests/client.test.ts b/sdk/typescript/tests/client.test.ts index 4885fb9..75164da 100644 --- a/sdk/typescript/tests/client.test.ts +++ b/sdk/typescript/tests/client.test.ts @@ -243,6 +243,21 @@ describe("Error handling", () => { } }); + it("reports status 0 when the connection drops mid-body on an error response", async () => { + // The error branch read the body via res.json() directly, so a + // connection reset while streaming a 500's error body was swallowed by + // the catch and reported as ShrtnrError(500, "HTTP 500") instead of the + // status-0 transport failure every other read path reports. + fetchSpy.mockResolvedValueOnce(new Response(erroringBody(), { status: 500 })); + try { + await client().links.list(); + expect.unreachable(); + } catch (e) { + expect(e).toBeInstanceOf(ShrtnrError); + expect((e as ShrtnrError).status).toBe(0); + } + }); + it("wraps a mid-body stream failure on a text response in ShrtnrError", async () => { // requestText handed back res.text() unwrapped, so a reset partway // through a QR download escaped as a raw TypeError past the documented @@ -261,6 +276,25 @@ describe("Error handling", () => { expect((e as ShrtnrError).status).toBe(0); } }); + + it("reports status 0 when the connection drops mid-body on a non-2xx text response", async () => { + // requestText's error branch had the same res.json()-without-readBody + // gap as request(): a reset while streaming a non-2xx QR error body + // reported the stale HTTP status instead of status 0. + fetchSpy.mockResolvedValueOnce( + new Response(erroringBody(), { + status: 500, + headers: { "Content-Type": "image/svg+xml" }, + }), + ); + try { + await client().links.qr(5); + expect.unreachable(); + } catch (e) { + expect(e).toBeInstanceOf(ShrtnrError); + expect((e as ShrtnrError).status).toBe(0); + } + }); }); // ============================================================ From 5990dd8fe3ce781bc16e3404df5ee35d5d5fd466 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 19:38:09 +0000 Subject: [PATCH 5/5] Fix broken 'Test connection' in browser extension config form handleTest() called testConnection() directly, without requesting host permission the way handleSave() does, and without normalizing the entered URL to its origin. Two user-visible failures resulted: - Host permission is optional and runtime-granted (no host_permissions in the manifest); without it, the extension's fetch is subject to normal CORS enforcement and the server sends no CORS headers on /api/*, so Test always reported a working server as unreachable on first use. - A URL pasted with a path (e.g. copied from the address bar) hit that path instead of the origin Save would actually use, so Test could 404 on a server that works fine once saved. Request the host permission and normalize to origin in handleTest, mirroring handleSave. Adds error.permissionDeniedTest (en/id/sv) since the existing permissionDenied message says "Click Save again", which doesn't fit the Test button. --- .../src/components/ConfigForm.tsx | 36 +++++++++++- browser-extensions/src/i18n/en.ts | 1 + browser-extensions/src/i18n/id.ts | 1 + browser-extensions/src/i18n/sv.ts | 1 + browser-extensions/tests/options.test.tsx | 58 +++++++++++++++++++ 5 files changed, 95 insertions(+), 2 deletions(-) diff --git a/browser-extensions/src/components/ConfigForm.tsx b/browser-extensions/src/components/ConfigForm.tsx index 82cc1e6..a6cba2d 100644 --- a/browser-extensions/src/components/ConfigForm.tsx +++ b/browser-extensions/src/components/ConfigForm.tsx @@ -73,11 +73,43 @@ export function ConfigForm({ t, initial, onSaved, showCancel, onCancel }: Props) async function handleTest() { if (!formValid) return; setTestState({ kind: "running" }); + + // Mirror handleSave's normalization and permission request: without the + // origin-only URL and a granted host permission, the fetch below is + // subject to the same-origin/CORS restrictions of a normal page and a + // reachable server is reported as a network failure. + let origin: string; + try { + origin = new URL(baseUrl.trim()).origin; + } catch { + setTestState({ + kind: "error", + messageKey: "error.validation", + params: { message: "Invalid URL" }, + }); + return; + } + + let granted = false; + try { + granted = await chrome.permissions.request({ origins: [`${origin}/*`] }); + } catch { + granted = false; + } + if (!granted) { + setTestState({ + kind: "error", + messageKey: "error.permissionDeniedTest", + params: { host: hostFromBaseUrl(origin) }, + }); + return; + } + try { - await testConnection({ baseUrl: baseUrl.trim(), apiKey: trimmedKey }); + await testConnection({ baseUrl: origin, apiKey: trimmedKey }); setTestState({ kind: "ok" }); } catch (err) { - const host = hostFromBaseUrl(baseUrl.trim()); + const host = hostFromBaseUrl(origin); if (err instanceof ExtensionError) { const mapped = categoryToMessage(err.category); setTestState({ diff --git a/browser-extensions/src/i18n/en.ts b/browser-extensions/src/i18n/en.ts index 2a6f378..1b0b6db 100644 --- a/browser-extensions/src/i18n/en.ts +++ b/browser-extensions/src/i18n/en.ts @@ -58,6 +58,7 @@ const en = { "error.validation": "{message}", "error.clipboard": "Copy failed. Select the link above to copy it.", "error.permissionDenied": "shrtnr needs permission to talk to {host}. Click Save again and accept.", + "error.permissionDeniedTest": "shrtnr needs permission to talk to {host}. Click Test again and accept.", "error.tabUnknown": "Couldn't read the active tab.", // Options page diff --git a/browser-extensions/src/i18n/id.ts b/browser-extensions/src/i18n/id.ts index 1ff0ded..cf3308e 100644 --- a/browser-extensions/src/i18n/id.ts +++ b/browser-extensions/src/i18n/id.ts @@ -60,6 +60,7 @@ const id: Translations = { "error.validation": "{message}", "error.clipboard": "Penyalinan gagal. Pilih tautan di atas untuk menyalinnya.", "error.permissionDenied": "shrtnr memerlukan izin untuk berkomunikasi dengan {host}. Klik Simpan lagi dan setujui.", + "error.permissionDeniedTest": "shrtnr memerlukan izin untuk berkomunikasi dengan {host}. Klik Uji lagi dan setujui.", "error.tabUnknown": "Tidak dapat membaca tab aktif.", // Options page diff --git a/browser-extensions/src/i18n/sv.ts b/browser-extensions/src/i18n/sv.ts index 474a355..17325dd 100644 --- a/browser-extensions/src/i18n/sv.ts +++ b/browser-extensions/src/i18n/sv.ts @@ -60,6 +60,7 @@ const sv: Translations = { "error.validation": "{message}", "error.clipboard": "Kopiering misslyckades. Markera länken ovan för att kopiera den.", "error.permissionDenied": "shrtnr behöver tillstånd att kommunicera med {host}. Klicka på Spara igen och godkänn.", + "error.permissionDeniedTest": "shrtnr behöver tillstånd att kommunicera med {host}. Klicka på Testa igen och godkänn.", "error.tabUnknown": "Kunde inte läsa den aktiva fliken.", // Options page diff --git a/browser-extensions/tests/options.test.tsx b/browser-extensions/tests/options.test.tsx index 8e7a38b..297b802 100644 --- a/browser-extensions/tests/options.test.tsx +++ b/browser-extensions/tests/options.test.tsx @@ -48,6 +48,7 @@ describe("Options — banner visibility", () => { describe("Options — Test connection", () => { it("shows connected status on success", async () => { + chrome.permissions.request = vi.fn(async () => true); await renderOptions(); await waitFor(() => screen.getByLabelText(/server url/i)); fireEvent.input(screen.getByLabelText(/server url/i), { @@ -62,6 +63,63 @@ describe("Options — Test connection", () => { }); }); + it("requests host permission before testing", async () => { + chrome.permissions.request = vi.fn(async () => true); + await renderOptions(); + await waitFor(() => screen.getByLabelText(/server url/i)); + fireEvent.input(screen.getByLabelText(/server url/i), { + target: { value: "https://x.com" }, + }); + fireEvent.input(screen.getByLabelText(/api key/i), { + target: { value: "sk_abc" }, + }); + fireEvent.click(screen.getByRole("button", { name: /test/i })); + await waitFor(() => { + expect(chrome.permissions.request).toHaveBeenCalledWith({ + origins: ["https://x.com/*"], + }); + }); + await waitFor(() => { + expect(mockedTest).toHaveBeenCalledWith({ baseUrl: "https://x.com", apiKey: "sk_abc" }); + }); + }); + + it("normalizes a server URL with a path to its origin before testing", async () => { + // A URL copied from the address bar (with a path) must resolve the same + // way handleSave does, or a server that will work fine once saved tests + // as unreachable. + chrome.permissions.request = vi.fn(async () => true); + await renderOptions(); + await waitFor(() => screen.getByLabelText(/server url/i)); + fireEvent.input(screen.getByLabelText(/server url/i), { + target: { value: "https://x.com/_/admin" }, + }); + fireEvent.input(screen.getByLabelText(/api key/i), { + target: { value: "sk_abc" }, + }); + fireEvent.click(screen.getByRole("button", { name: /test/i })); + await waitFor(() => { + expect(mockedTest).toHaveBeenCalledWith({ baseUrl: "https://x.com", apiKey: "sk_abc" }); + }); + }); + + it("shows a permission error and does not call testConnection when permission is denied", async () => { + chrome.permissions.request = vi.fn(async () => false); + await renderOptions(); + await waitFor(() => screen.getByLabelText(/server url/i)); + fireEvent.input(screen.getByLabelText(/server url/i), { + target: { value: "https://x.com" }, + }); + fireEvent.input(screen.getByLabelText(/api key/i), { + target: { value: "sk_abc" }, + }); + fireEvent.click(screen.getByRole("button", { name: /test/i })); + await waitFor(() => { + expect(screen.getByText(/needs permission/i)).toBeTruthy(); + }); + expect(mockedTest).not.toHaveBeenCalled(); + }); + it("shows the auth error message on 401", async () => { mockedTest.mockRejectedValue(new ExtensionError("unauthorized", "bad", 401)); await renderOptions();