mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 01:32:22 +03:00
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:
@@ -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
|
||||
|
||||
20
src/lib/radar/supporterKey.ts
Normal file
20
src/lib/radar/supporterKey.ts
Normal 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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
66
tests/unit/radar-supporter-key-format.test.ts
Normal file
66
tests/unit/radar-supporter-key-format.test.ts
Normal 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));
|
||||
});
|
||||
Reference in New Issue
Block a user