Skip to content
Merged
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
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
18 changes: 18 additions & 0 deletions src/__tests__/integration/expiration-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,21 @@ describe("expires_at flow", () => {
}
});
});

describe("expires_at = 0 boundary", () => {
it("treats 0 as an epoch timestamp (expired), not as no expiry", async () => {
// The Link schema documents "null means no expiry". A stored 0 is a real
// timestamp (1970-01-01) and must 404, so the expiry check has to be
// null-aware rather than truthy.
const slug = "expzero";
await LinkRepository.create(env.DB, {
url: "https://example.com/expzero",
slug,
expiresAt: 0,
createdBy: DEV_IDENTITY,
});

const res = await SELF.fetch(req(slug));
expect(res.status).toBe(404);
});
});
37 changes: 36 additions & 1 deletion src/__tests__/repository/link-repository.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 { ClickRepository, LinkRepository, SlugRepository } from "../../db";
Expand Down Expand Up @@ -411,6 +411,41 @@ describe("LinkRepository click_count options", () => {
}
});

it("delete guard holds when a click lands between the pre-read and the batch", async () => {
// Simulate the check-then-act race: the pre-read reports zero clicks,
// but a click row exists by the time the transactional batch runs. The
// in-transaction NOT EXISTS guard must block the delete so the click
// history survives.
const link = await LinkRepository.create(env.DB, { url: "https://example.com", slug: "racewin" });
await ClickRepository.record(env.DB, "racewin", {});

const spy = vi
.spyOn(LinkRepository, "getById")
.mockResolvedValueOnce({ ...link, total_clicks: 0 });
const removed = await LinkRepository.delete(env.DB, link.id).finally(() => spy.mockRestore());

expect(removed).toBe(false);
const linkRow = await env.DB.prepare("SELECT 1 FROM links WHERE id = ?").bind(link.id).first();
const slugRow = await env.DB.prepare("SELECT 1 FROM slugs WHERE slug = 'racewin'").first();
const clickRow = await env.DB.prepare("SELECT 1 FROM clicks WHERE slug = 'racewin'").first();
expect(linkRow).not.toBeNull();
expect(slugRow).not.toBeNull();
expect(clickRow).not.toBeNull();
});

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
103 changes: 103 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,107 @@ 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);
});

it("clamps a legacy stored theme outside the allowed set to null on read", async () => {
// Rows written before write-side validation existed (or edited directly
// in D1) must not leak unknown values to settings consumers.
await env.DB.prepare(
"INSERT INTO settings (identity, key, value) VALUES (?, 'theme', 'neon')",
).bind(TEST_IDENTITY).run();

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

it("clamps a legacy stored language outside the allowed set to null on read", async () => {
await env.DB.prepare(
"INSERT INTO settings (identity, key, value) VALUES (?, 'lang', 'xx')",
).bind(TEST_IDENTITY).run();

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

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
Loading