Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
26 changes: 26 additions & 0 deletions src/__tests__/handler/api-misc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,32 @@ describe("Routing", () => {
expect(res.status).toBe(404);
});
});

// ---- Admin auth in production mode (ACCESS_AUD configured) ----

describe("Admin auth with ACCESS_AUD configured", () => {
it("unauthenticated /_/admin/api requests get 401 JSON instead of a redirect", async () => {
(env as unknown as Record<string, unknown>).ACCESS_AUD = "test-aud-tag";
try {
const res = await SELF.fetch(unauthed("/_/admin/api/keys"), { redirect: "manual" });
expect(res.status).toBe(401);
expect(res.headers.get("Content-Type")).toContain("application/json");
} finally {
delete (env as unknown as Record<string, unknown>).ACCESS_AUD;
}
});

it("unauthenticated /_/admin page requests still redirect to the landing page", async () => {
(env as unknown as Record<string, unknown>).ACCESS_AUD = "test-aud-tag";
try {
const res = await SELF.fetch(unauthed("/_/admin/dashboard"), { redirect: "manual" });
expect(res.status).toBe(302);
expect(res.headers.get("Location")).toContain("/");
} finally {
delete (env as unknown as Record<string, unknown>).ACCESS_AUD;
}
});
});
// ---- Redirect ----

describe("Redirect", () => {
Expand Down
40 changes: 40 additions & 0 deletions src/__tests__/handler/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,46 @@ async function initSession(): Promise<string> {
return sessionId!;
}

describe("MCP get_link_qr", () => {
it("encodes the short URL with the utm_medium=qr tracking parameter", async () => {
const created = await createLink(env as any, { url: "https://example.com/qr-target" });
expect(created.ok).toBe(true);
if (!created.ok) return;

const sessionId = await initSession();
const res = await SELF.fetch(
new Request("https://shrtnr.test/_/mcp", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"mcp-session-id": sessionId,
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 4,
method: "tools/call",
params: {
name: "get_link_qr",
arguments: { link_id: created.data.id, base_url: "https://shrtnr.test" },
},
}),
}),
);
expect(res.status).toBe(200);
const message = await readFirstSseMessage(res);
expect(message).not.toBeNull();
const result = message!.result as { isError?: boolean; content?: { type: string; text?: string }[] } | undefined;
expect(result?.isError).toBeFalsy();

const text = result?.content?.find((c) => c.type === "text")?.text ?? "";
const slug = created.data.slugs[0].slug;
// The QR must encode the same tracked URL shape as the REST endpoint
// (api/qr.ts), so redirect.ts records the scan with link_mode = "qr".
expect(text).toContain(`https://shrtnr.test/${slug}?utm_medium=qr`);
});
});

describe("MCP error surface", () => {
it("malformed JSON-RPC body returns parse error (-32700)", async () => {
// No session needed: the parse error fires before session validation.
Expand Down
13 changes: 13 additions & 0 deletions src/__tests__/repository/link-repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,19 @@ describe("LinkRepository click_count options", () => {
}
});

it("delete leaves no orphan rows in links or slugs", async () => {
const link = await LinkRepository.create(env.DB, { url: "https://example.com", slug: "wipe" });
await SlugRepository.addCustom(env.DB, link.id, "wipe-custom");

const removed = await LinkRepository.delete(env.DB, link.id);
expect(removed).not.toBe(false);

const linkRow = await env.DB.prepare("SELECT 1 FROM links WHERE id = ?").bind(link.id).first();
const slugRows = await env.DB.prepare("SELECT COUNT(*) as cnt FROM slugs WHERE link_id = ?").bind(link.id).first<{ cnt: number }>();
expect(linkRow).toBeNull();
expect(slugRows?.cnt).toBe(0);
});

it("exists reports whether a link row is present", async () => {
const link = await LinkRepository.create(env.DB, { url: "https://example.com", slug: "present" });

Expand Down
21 changes: 21 additions & 0 deletions src/__tests__/repository/slug-repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,27 @@ describe("SlugRepository.setPrimary", () => {
expect(final!.slugs.find((s) => s.slug === "custom-1")!.is_primary).toBe(0);
expect(final!.slugs.find((s) => s.slug === "abc")!.is_primary).toBe(0);
});

it("leaves primary flags unchanged when the slug does not belong to the link", async () => {
const linkA = await LinkRepository.create(env.DB, { url: "https://example.com/a", slug: "aaa" });
const linkB = await LinkRepository.create(env.DB, { url: "https://example.com/b", slug: "bbb" });

await SlugRepository.setPrimary(env.DB, linkA.id, "bbb");

const a = await LinkRepository.getById(env.DB, linkA.id);
const b = await LinkRepository.getById(env.DB, linkB.id);
expect(a!.slugs.find((s) => s.slug === "aaa")!.is_primary).toBe(1);
expect(b!.slugs.find((s) => s.slug === "bbb")!.is_primary).toBe(1);
});

it("leaves primary flags unchanged when the slug does not exist at all", async () => {
const link = await LinkRepository.create(env.DB, { url: "https://example.com", slug: "abc" });

await SlugRepository.setPrimary(env.DB, link.id, "ghost");

const updated = await LinkRepository.getById(env.DB, link.id);
expect(updated!.slugs.find((s) => s.slug === "abc")!.is_primary).toBe(1);
});
});

describe("SlugRepository.disable", () => {
Expand Down
81 changes: 81 additions & 0 deletions src/__tests__/service/admin-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,85 @@ describe("admin-management service", () => {
expect(result.ok).toBe(false);
if (!result.ok) expect(result.status).toBe(400);
});

it("falls back to the default slug length when the stored setting is not a number", async () => {
await env.DB.prepare(
"INSERT INTO settings (identity, key, value) VALUES (?, 'slug_default_length', 'garbage')",
).bind(TEST_IDENTITY).run();

const settings = await getAppSettings(env as any, TEST_IDENTITY);
expect(settings.ok).toBe(true);
if (settings.ok) {
expect(settings.data.slug_default_length).toBe(3);
}
});

it("falls back to the default slug length when the stored setting is out of bounds", async () => {
await env.DB.prepare(
"INSERT INTO settings (identity, key, value) VALUES (?, 'slug_default_length', '9999')",
).bind(TEST_IDENTITY).run();

const settings = await getAppSettings(env as any, TEST_IDENTITY);
expect(settings.ok).toBe(true);
if (settings.ok) {
expect(settings.data.slug_default_length).toBe(3);
}
});
});

describe("settings theme and language validation", () => {
it("accepts every known theme", async () => {
for (const theme of ["oddbit", "dark", "light"]) {
const result = await updateAppSettings(env as any, TEST_IDENTITY, { theme });
expect(result.ok).toBe(true);
const settings = await getAppSettings(env as any, TEST_IDENTITY);
if (settings.ok) expect(settings.data.theme).toBe(theme);
}
});

it("rejects an unknown theme", async () => {
const result = await updateAppSettings(env as any, TEST_IDENTITY, { theme: "neon" });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.status).toBe(400);
});

it("rejects a non-string theme", async () => {
const result = await updateAppSettings(env as any, TEST_IDENTITY, { theme: 42 as any });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.status).toBe(400);
});

it("accepts every supported language", async () => {
for (const lang of ["en", "id", "sv"]) {
const result = await updateAppSettings(env as any, TEST_IDENTITY, { lang });
expect(result.ok).toBe(true);
const settings = await getAppSettings(env as any, TEST_IDENTITY);
if (settings.ok) expect(settings.data.lang).toBe(lang);
}
});

it("rejects an unsupported language", async () => {
const result = await updateAppSettings(env as any, TEST_IDENTITY, { lang: "xx" });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.status).toBe(400);
});
});

describe("API key title validation", () => {
it("rejects a title longer than 120 characters", async () => {
const result = await createNewApiKey(env as any, TEST_IDENTITY, {
title: "x".repeat(121),
scope: "create",
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.status).toBe(400);
});

it("accepts a title of exactly 120 characters", async () => {
const result = await createNewApiKey(env as any, TEST_IDENTITY, {
title: "x".repeat(120),
scope: "create",
});
expect(result.ok).toBe(true);
});
});
20 changes: 20 additions & 0 deletions src/__tests__/service/bundle-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,26 @@ describe("getBundle / updateBundle", () => {
}
});

it("applies the viewer's click filter preferences to range summaries, matching listBundles", async () => {
const link = await LinkRepository.create(env.DB, { url: "https://a.com", slug: "fff", createdBy: "a@b" });
const bundle = await BundleRepository.create(env.DB, { name: "Filtered", createdBy: "a@b" });
await BundleRepository.addLink(env.DB, bundle.id, link.id);
const now = Math.floor(Date.now() / 1000);
await env.DB.prepare("INSERT INTO clicks (slug, clicked_at, link_mode, is_bot) VALUES (?, ?, 'link', 0)")
.bind(link.slugs[0].slug, now).run();
await env.DB.prepare("INSERT INTO clicks (slug, clicked_at, link_mode, is_bot) VALUES (?, ?, 'link', 1)")
.bind(link.slugs[0].slug, now).run();

// Default settings filter bots, so both views must agree on 1 click.
const listRes = await svc.listBundles(e, "a@b", { range: "30d" });
const getRes = await svc.getBundle(e, bundle.id, "a@b", { range: "30d" });
expect(listRes.ok && getRes.ok).toBe(true);
if (listRes.ok && getRes.ok) {
expect(listRes.data[0].total_clicks).toBe(1);
expect((getRes.data as { total_clicks: number }).total_clicks).toBe(1);
}
});

it("enforces ownership on update", async () => {
const b = await BundleRepository.create(env.DB, { name: "Mine", createdBy: "a@b" });
const res = await svc.updateBundle(e, b.id, { name: "Stolen" }, "not-owner@x");
Expand Down
106 changes: 104 additions & 2 deletions src/__tests__/service/link-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { env } from "cloudflare:test";
import { applyMigrations, resetData } from "../setup";
import {
Expand All @@ -8,7 +8,7 @@ import {
getLinkBySlug,
updateLink,
} from "../../services/link-management";
import { SettingRepository } from "../../db";
import { SettingRepository, SlugRepository } from "../../db";

beforeAll(applyMigrations);
beforeEach(resetData);
Expand Down Expand Up @@ -338,3 +338,105 @@ describe("URL normalization in updateLink", () => {
}
});
});

describe("field type validation in createLink", () => {
// The admin API path parses raw JSON without a zod schema, so the service
// layer is the only guard against malformed field types reaching D1.
it("rejects a string expires_at", async () => {
const result = await createLink(env as any, { url: "https://example.com", expires_at: "tomorrow" as any });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.status).toBe(400);
});

it("rejects a negative expires_at", async () => {
const result = await createLink(env as any, { url: "https://example.com", expires_at: -5 });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.status).toBe(400);
});

it("rejects a fractional expires_at", async () => {
const result = await createLink(env as any, { url: "https://example.com", expires_at: 1.5 });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.status).toBe(400);
});

it("rejects a non-string label", async () => {
const result = await createLink(env as any, { url: "https://example.com", label: 42 as any });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.status).toBe(400);
});

it("accepts a valid integer expires_at", async () => {
const result = await createLink(env as any, { url: "https://example.com", expires_at: 4102444800 });
expect(result.ok).toBe(true);
});

it("falls back to the default slug length when the stored setting is corrupted", async () => {
await SettingRepository.set(env.DB, "anonymous", "slug_default_length", "garbage");

const result = await createLink(env as any, { url: "https://example.com" });
expect(result.ok).toBe(true);
if (result.ok) {
const autoSlug = result.data.slugs.find((s) => s.is_custom === 0);
expect(autoSlug?.slug).toHaveLength(3);
}
});
});

describe("field type validation in updateLink", () => {
it("rejects a string expires_at", async () => {
const created = await createLink(env as any, { url: "https://example.com" });
expect(created.ok).toBe(true);
if (!created.ok) return;

const updated = await updateLink(env as any, created.data.id, { expires_at: "never" as any });
expect(updated.ok).toBe(false);
if (!updated.ok) expect(updated.status).toBe(400);
});

it("rejects a non-string label", async () => {
const created = await createLink(env as any, { url: "https://example.com" });
expect(created.ok).toBe(true);
if (!created.ok) return;

const updated = await updateLink(env as any, created.data.id, { label: 42 as any });
expect(updated.ok).toBe(false);
if (!updated.ok) expect(updated.status).toBe(400);
});

it("accepts null expires_at to clear an expiry", async () => {
const created = await createLink(env as any, { url: "https://example.com", expires_at: 4102444800 });
expect(created.ok).toBe(true);
if (!created.ok) return;

const updated = await updateLink(env as any, created.data.id, { expires_at: null });
expect(updated.ok).toBe(true);
if (updated.ok) expect(updated.data.expires_at).toBeNull();
});
});

describe("custom slug uniqueness under race conditions", () => {
it("returns 409 when the insert collides even if the pre-check missed it", async () => {
const first = await createLink(env as any, { url: "https://example.com/a" });
const second = await createLink(env as any, { url: "https://example.com/b" });
expect(first.ok && second.ok).toBe(true);
if (!first.ok || !second.ok) return;

const added = await addCustomSlugToLink(env as any, first.data.id, { slug: "taken-slug" });
expect(added.ok).toBe(true);

// Simulate the race: the existence pre-check reports the slug as free,
// but the UNIQUE index still rejects the insert.
const spy = vi.spyOn(SlugRepository, "exists").mockResolvedValue(false);
try {
const result = await addCustomSlugToLink(env as any, second.data.id, { slug: "taken-slug" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(409);
expect(result.error).toBe("Slug already exists");
}
} finally {
spy.mockRestore();
}
});
});
5 changes: 5 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ export const DEFAULT_SLUG_LENGTH = MIN_SLUG_LENGTH;
export const TIMELINE_RANGES = ["24h", "7d", "30d", "90d", "1y", "all"] as const;
export const DEFAULT_TIMELINE_RANGE: (typeof TIMELINE_RANGES)[number] = "30d";

export const THEMES = ["oddbit", "dark", "light"] as const;
export const DEFAULT_THEME: (typeof THEMES)[number] = "oddbit";

export const MAX_TITLE_LENGTH = 120;

export const MIN_QR_SIZE = 1;
export const MAX_QR_SIZE = 2048;
export const DEFAULT_QR_SIZE = 220;
10 changes: 7 additions & 3 deletions src/db/link-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,13 @@ export class LinkRepository {
// slug added in the window before this delete cascades the slug rows.
const slugs = link.slugs.map((s) => s.slug);

await db.prepare("DELETE FROM clicks WHERE slug IN (SELECT slug FROM slugs WHERE link_id = ?)").bind(id).run();
await db.prepare("DELETE FROM slugs WHERE link_id = ?").bind(id).run();
await db.prepare("DELETE FROM links WHERE id = ?").bind(id).run();
// All three deletes run in one transactional batch so a mid-sequence
// failure cannot leave orphan slug or click rows behind.
await db.batch([
db.prepare("DELETE FROM clicks WHERE slug IN (SELECT slug FROM slugs WHERE link_id = ?)").bind(id),
db.prepare("DELETE FROM slugs WHERE link_id = ?").bind(id),
db.prepare("DELETE FROM links WHERE id = ?").bind(id),
]);
return slugs;
}

Expand Down
Loading