Skip to content
Open
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
Loading