diff --git a/src/app/api/radar/settings/route.ts b/src/app/api/radar/settings/route.ts index 2b92e08bdb..8c35840d21 100644 --- a/src/app/api/radar/settings/route.ts +++ b/src/app/api/radar/settings/route.ts @@ -30,13 +30,12 @@ import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; import { isAuthenticated } from "@/shared/utils/apiAuth"; import { setRadarOptIn, setRadarKey, getRadarSettings } from "@/lib/db/radar"; import { getContributorClaimUrl, getSupporterPlansUrl } from "@/lib/radar/links"; +import { SUPPORTER_KEY_REGEX } from "@/lib/radar/supporterKey"; import { buildErrorBody } from "@omniroute/open-sse/utils/error"; export const dynamic = "force-dynamic"; export const revalidate = 0; -const SUPPORTER_KEY_REGEX = /^omr_[0-9a-f]{40}$/; - const SettingsBodySchema = z.object({ optIn: z.boolean().optional(), supporterKey: z diff --git a/src/lib/radar/supporterKey.ts b/src/lib/radar/supporterKey.ts new file mode 100644 index 0000000000..e716203be6 --- /dev/null +++ b/src/lib/radar/supporterKey.ts @@ -0,0 +1,20 @@ +/** + * supporterKey.ts — pure, client-safe validation for the Radar supporter-key + * format: "omr_" + 40 lowercase hex chars. + * + * Kept free of any server-only import (db, sync) — same pattern as + * autoSync.ts — so the "use client" Radar activation screen can import it + * directly for a UX-only pre-check before calling POST /api/radar/settings. + * + * This is NOT a security boundary: the server (src/app/api/radar/settings/ + * route.ts) re-validates with the same shape via its Zod schema, which is + * the authoritative check. Client-side rejection only saves a round trip. + */ + +/** "omr_" followed by exactly 40 lowercase hex characters. */ +export const SUPPORTER_KEY_REGEX = /^omr_[0-9a-f]{40}$/; + +/** Whether `key` matches the supporter-key format. No trimming is performed. */ +export function isValidSupporterKeyFormat(key: string): boolean { + return SUPPORTER_KEY_REGEX.test(key); +} diff --git a/tests/unit/radar-api-routes.test.ts b/tests/unit/radar-api-routes.test.ts index 7d10e4351b..e3551b46bb 100644 --- a/tests/unit/radar-api-routes.test.ts +++ b/tests/unit/radar-api-routes.test.ts @@ -406,6 +406,57 @@ test("GET /api/radar/settings: flag on, authenticated, after opt-in + key => ref assert.ok(!text.includes(RAW_KEY), "raw key must NEVER appear in the serialized response body"); }); +// --------------------------------------------------------------------------- +// Paste-key activation UI (Radar activation screen) — opt-in + supporterKey +// submitted TOGETHER in a single POST, the shape the new page.tsx paste-key +// form sends (pasting a key both sets it AND activates opt-in in one call). +// --------------------------------------------------------------------------- + +test("POST /api/radar/settings: opt-in+key submitted together => both persist, POST response masked, GET reflects both, raw key never in either body", async () => { + resetStorage(); + process.env.RADAR_ENABLED = "true"; + + const settingsRoute = await import("../../src/app/api/radar/settings/route.ts"); + const headers = await authHeaders(); + const RAW_KEY = "omr_1234567890abcdef1234567890abcdef12345678"; + + const postResponse = await settingsRoute.POST( + mockPostRequest( + "http://localhost:20128/api/radar/settings", + { optIn: true, supporterKey: RAW_KEY }, + headers, + ), + ); + const postText = await postResponse.text(); + const postBody = JSON.parse(postText); + + assert.equal(postResponse.status, 200); + assert.equal(postBody.ok, true); + assert.equal(postBody.optIn, true, "opt-in must be persisted in the same call"); + assert.equal( + postBody.supporterKey, + "omr_****5678", + "POST response must mask the key, never echo it raw" + ); + assert.ok( + !postText.includes(RAW_KEY), + "raw key must NEVER appear in the POST response body" + ); + + // Persistence check — a fresh GET must reflect BOTH fields set by the single POST. + const getResponse = await settingsRoute.GET( + mockGetRequest("http://localhost:20128/api/radar/settings", headers), + ); + const getText = await getResponse.text(); + const getBody = JSON.parse(getText); + + assert.equal(getResponse.status, 200); + assert.equal(getBody.optIn, true, "opt-in must persist across requests"); + assert.equal(getBody.hasSupporterKey, true, "supporter key must persist across requests"); + assert.equal(getBody.supporterKeyMasked, "omr_****5678"); + assert.ok(!getText.includes(RAW_KEY), "raw key must NEVER appear in the GET response body"); +}); + test("GET /api/radar/settings: F4/T7 claim/plans links honor env overrides (fork-friendly)", async () => { resetStorage(); process.env.RADAR_ENABLED = "true"; diff --git a/tests/unit/radar-inertia.test.ts b/tests/unit/radar-inertia.test.ts index e680f50b9b..b48b7bbb08 100644 --- a/tests/unit/radar-inertia.test.ts +++ b/tests/unit/radar-inertia.test.ts @@ -97,6 +97,22 @@ test("Radar inertia — flag off means zero behavioral delta", async (t) => { mockPostRequest("http://localhost:20128/api/radar/settings", { optIn: true }), ); assert.equal(settingsRes.status, 404, "POST /api/radar/settings must 404 when disabled"); + + // Paste-key activation UI: the new page.tsx form submits optIn+supporterKey + // together in one POST. Same 404-before-anything-else gate must apply to + // that combined shape — pasting a key with the flag off must be a no-op, + // never touching the DB or the Zod body validation. + const settingsWithKeyRes = await settingsPost( + mockPostRequest("http://localhost:20128/api/radar/settings", { + optIn: true, + supporterKey: "omr_abcdef01234567890abcdef01234567890abcdef", + }), + ); + assert.equal( + settingsWithKeyRes.status, + 404, + "POST /api/radar/settings with optIn+supporterKey together must also 404 when disabled", + ); }); await t.test("RADAR_ENABLED resolves to 'false' with no DB override", () => { diff --git a/tests/unit/radar-supporter-key-format.test.ts b/tests/unit/radar-supporter-key-format.test.ts new file mode 100644 index 0000000000..38a798942b --- /dev/null +++ b/tests/unit/radar-supporter-key-format.test.ts @@ -0,0 +1,66 @@ +/** + * tests/unit/radar-supporter-key-format.test.ts + * + * TDD guard for src/lib/radar/supporterKey.ts — the pure, client-safe + * "omr_" + 40 lowercase hex chars format check shared by: + * - the paste-key input on the activation screen (client-side UX check + * before the fetch — the server always revalidates, this is not a + * security boundary); + * - POST /api/radar/settings' Zod schema (server-side, authoritative). + * + * Pure module, no DB/network — both directions covered: valid accepted, + * every invalid shape rejected. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +const VALID_KEY = "omr_abcdef01234567890abcdef01234567890abcdef"; + +test("isValidSupporterKeyFormat: accepts 'omr_' + 40 lowercase hex chars", async () => { + const { isValidSupporterKeyFormat } = await import("../../src/lib/radar/supporterKey.ts"); + assert.equal(isValidSupporterKeyFormat(VALID_KEY), true); + // All-digit and all-letter (a-f) 40-char bodies are both valid hex. + assert.equal(isValidSupporterKeyFormat("omr_" + "0".repeat(40)), true); + assert.equal(isValidSupporterKeyFormat("omr_" + "f".repeat(40)), true); +}); + +test("isValidSupporterKeyFormat: rejects missing/wrong prefix", async () => { + const { isValidSupporterKeyFormat } = await import("../../src/lib/radar/supporterKey.ts"); + assert.equal(isValidSupporterKeyFormat("abcdef01234567890abcdef01234567890abcdef"), false); + assert.equal(isValidSupporterKeyFormat("omr-abcdef01234567890abcdef01234567890abcdef"), false); + assert.equal(isValidSupporterKeyFormat("OMR_abcdef01234567890abcdef01234567890abcdef"), false); +}); + +test("isValidSupporterKeyFormat: rejects short/long hex bodies", async () => { + const { isValidSupporterKeyFormat } = await import("../../src/lib/radar/supporterKey.ts"); + assert.equal(isValidSupporterKeyFormat("omr_abcdef"), false, "too short (6 hex chars)"); + assert.equal(isValidSupporterKeyFormat("omr_" + "a".repeat(39)), false, "39 hex chars — one short"); + assert.equal(isValidSupporterKeyFormat("omr_" + "a".repeat(41)), false, "41 hex chars — one over"); +}); + +test("isValidSupporterKeyFormat: rejects uppercase hex", async () => { + const { isValidSupporterKeyFormat } = await import("../../src/lib/radar/supporterKey.ts"); + assert.equal(isValidSupporterKeyFormat("omr_ABCDEF01234567890abcdef01234567890abcdef"), false); +}); + +test("isValidSupporterKeyFormat: rejects empty string and whitespace", async () => { + const { isValidSupporterKeyFormat } = await import("../../src/lib/radar/supporterKey.ts"); + assert.equal(isValidSupporterKeyFormat(""), false); + assert.equal(isValidSupporterKeyFormat(" "), false); + assert.equal(isValidSupporterKeyFormat(` ${VALID_KEY} `), false, "surrounding whitespace not trimmed by the helper itself"); +}); + +test("isValidSupporterKeyFormat: rejects non-hex characters in the body", async () => { + const { isValidSupporterKeyFormat } = await import("../../src/lib/radar/supporterKey.ts"); + assert.equal(isValidSupporterKeyFormat("omr_" + "g".repeat(40)), false); + assert.equal(isValidSupporterKeyFormat("omr_" + "z".repeat(40)), false); +}); + +test("SUPPORTER_KEY_REGEX: exported and matches the same behavior as the helper", async () => { + const { SUPPORTER_KEY_REGEX, isValidSupporterKeyFormat } = await import( + "../../src/lib/radar/supporterKey.ts" + ); + assert.ok(SUPPORTER_KEY_REGEX instanceof RegExp); + assert.equal(SUPPORTER_KEY_REGEX.test(VALID_KEY), isValidSupporterKeyFormat(VALID_KEY)); +});