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(); 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); + } + }); }); // ============================================================ 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/__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/__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/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 }>(); 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)} ))} 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");