Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions browser-extensions/src/components/ConfigForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
});
Comment on lines +85 to +89
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({
Expand Down
1 change: 1 addition & 0 deletions browser-extensions/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Comment on lines 58 to +61
"error.tabUnknown": "Couldn't read the active tab.",

// Options page
Expand Down
1 change: 1 addition & 0 deletions browser-extensions/src/i18n/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Comment on lines 60 to +63
"error.tabUnknown": "Tidak dapat membaca tab aktif.",

// Options page
Expand Down
1 change: 1 addition & 0 deletions browser-extensions/src/i18n/sv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Comment on lines 60 to +63
"error.tabUnknown": "Kunde inte läsa den aktiva fliken.",

// Options page
Expand Down
58 changes: 58 additions & 0 deletions browser-extensions/tests/options.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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), {
Expand All @@ -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();
Expand Down
12 changes: 10 additions & 2 deletions sdk/typescript/src/internal/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions sdk/typescript/tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
});
});

// ============================================================
Expand Down
13 changes: 13 additions & 0 deletions src/__tests__/admin/widgets/dashboard/recent-links.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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" });
Expand Down
11 changes: 11 additions & 0 deletions src/__tests__/admin/widgets/dashboard/top-links.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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");
});
});
14 changes: 14 additions & 0 deletions src/__tests__/page/keys-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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</);
expect(html).not.toMatch(/class="scope-badge create"[^>]*>create</);
});

it("localizes the scope badge for a non-English locale", async () => {
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</);
});

it("keeps the delete action for each key", async () => {
await seedApiKey();
const html = await fetchKeysHtml();
Expand Down
39 changes: 39 additions & 0 deletions src/__tests__/service/ownership.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
7 changes: 4 additions & 3 deletions src/admin/widgets/dashboard/recent-links.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 || "";
}

Expand Down
11 changes: 5 additions & 6 deletions src/db/link-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Map<number, string>> {
const out = new Map<number, string>();
Expand All @@ -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 }>();
Expand Down
8 changes: 7 additions & 1 deletion src/pages/keys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -132,7 +138,7 @@ export const KeysPage: FC<Props> = ({ keys, t, lang, origin }) => {
<td data-label={t("keys.colScope")}>
<span class="scope-badges">
{scopes.map((s) => (
<span class={`scope-badge ${s}`}>{s}</span>
<span class={`scope-badge ${s}`}>{scopeLabel(s, t)}</span>
))}
</span>
</td>
Expand Down
Loading