feat(radar): shared supporter-key format validator

Extract the "omr_" + 40 hex supporter-key regex out of the
POST /api/radar/settings Zod schema into a pure, client-safe helper
(src/lib/radar/supporterKey.ts) so the format rule lives in exactly one
place and the upcoming activation-screen input can reuse it for a
UX-only pre-check. Server-side Zod validation stays authoritative.

Adds regression coverage: both directions of the format check, a
combined opt-in+supporterKey POST persisting both fields with the key
always masked (never raw) in either the POST or GET response body, and
a flag-off inertia case for the same combined payload shape.
This commit is contained in:
diegosouzapw
2026-08-08 00:18:35 -03:00
parent ee0f4298ca
commit 5a54a733c1
5 changed files with 154 additions and 2 deletions

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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";

View File

@@ -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", () => {

View File

@@ -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));
});