Files
OmniRoute/tests/unit/radar-api-routes.test.ts
Diego Rodrigues de Sa e Souza 0d7c019eec campo de colar chave omr_ na tela de ativação (#9758)
* 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.

* feat(dashboard): paste-key input on the Radar activation screen

The Radar activation screen had opt-in and the two "get a key" claim
buttons, but nowhere to paste a key someone already has — the last
piece of the supporter flow. Add the field to the activation screen
itself, as the primary path: pasting a key and submitting sends
POST /api/radar/settings with { optIn: true, supporterKey } together,
so pasting a valid key both sets it and unlocks the screen in one step.

Client-side format validation (via the shared isValidSupporterKeyFormat
helper) is a UX nicety only; the server's Zod schema already validates
authoritatively. When a key is already set (hasSupporterKey from
GET /api/radar/settings — e.g. set out of band before this UI existed),
the screen shows the masked form instead of an empty input, with a
"change key" control to paste a new one; the raw key is never
displayed. The existing plain "Activate" button (no key, community
tier) and the two claim/plans buttons are unchanged and still present
below, so all three paths to this screen coexist.

Adds 4 new i18n keys (keySectionTitle, keyInvalidFormatError,
activateWithKeyButton, changeKeyButton) with an English fallback across
all 43 locale files (172 entries) — no __MISSING__ sentinel, no price.

* docs(radar): close the paste-key-input known gap

RADAR.md documented a known gap: the activation screen had no
dedicated key-paste input, only the two claim/plans buttons. That gap
is closed — describe the new input, the combined opt-in+supporterKey
submission, and the masked-key "already activated" state instead.

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-08 08:10:14 -03:00

565 lines
21 KiB
TypeScript

/**
* tests/unit/radar-api-routes.test.ts
*
* TDD regression guard for the Radar API routes:
* - GET /api/radar/catalog: flag off => 404, flag on + no auth => 401, flag on + auth => shape validated
* - POST /api/radar/sync: flag off => 404, flag on + no auth => 401, flag on + auth => delegates to syncRadar
* - POST /api/radar/settings: flag off => 404, flag on + no auth => 401, never echoes clear key
* - GET /api/radar/settings: flag off => 404, flag on + no auth => 401, flag on + auth => masked snapshot
*
* Auth wiring (FIX 1 / FIX 3): the flag-off 404 gate must run BEFORE the auth
* check (byte-identical inertia with the flag off, no auth required to learn
* the surface doesn't exist), auth runs AFTER it and before any DB read/write.
*
* Error responses must NOT leak stack traces (Hard Rule #12).
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { SignJWT } from "jose";
// ---------------------------------------------------------------------------
// Isolate DB + feature flag state
// ---------------------------------------------------------------------------
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-radar-api-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.STORAGE_ENCRYPTION_KEY = "test-encryption-key-for-radar-api-tests-32b!";
process.env.JWT_SECRET = "test-jwt-secret-for-radar-api-tests";
// Force isAuthRequired() to always require auth (mirrors tests/unit/api-auth.test.ts):
// without a configured password/OIDC, a loopback bootstrap request would otherwise
// be treated as pre-authenticated. Setting INITIAL_PASSWORD closes that bootstrap
// path so the "no auth => 401" assertions are meaningful.
process.env.INITIAL_PASSWORD = "test-bootstrap-password-for-radar-api-tests";
const core = await import("../../src/lib/db/core.ts");
const radarDb = await import("../../src/lib/db/radar.ts");
const featureFlags = await import("../../src/shared/utils/featureFlags.ts");
// We need to test the route handlers. Since Next.js route handlers are just
// exported functions, we can import and call them directly with mock Request
// objects. However, the routes import from @/lib/radar which reads the DB,
// so we need the DB to be set up.
/** Mint a valid dashboard-session JWT cookie header value (see apiAuth.ts::isDashboardSessionAuthenticated). */
async function authCookieHeader(): Promise<string> {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const token = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(secret);
return `auth_token=${token}`;
}
/** Headers carrying a valid auth cookie, for the "authenticated" branch of each test. */
async function authHeaders(): Promise<Record<string, string>> {
return { Cookie: await authCookieHeader() };
}
// Helper to create a mock NextRequest-like object
function mockGetRequest(
url = "http://localhost:20128/api/radar/catalog",
headers: Record<string, string> = {},
): Request {
return new Request(url, { method: "GET", headers });
}
function mockPostRequest(
url: string,
body?: unknown,
headers: Record<string, string> = {},
): Request {
return new Request(url, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
// Helper to reset DB state
function resetStorage() {
core.resetDbInstance();
try {
if (fs.existsSync(TEST_DATA_DIR)) {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
}
} catch {
// ignore
}
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
// ---------------------------------------------------------------------------
// Tests: flag-off behavior (all routes => 404, no auth required to learn this)
// ---------------------------------------------------------------------------
test("GET /api/radar/catalog: flag off => 404", async () => {
resetStorage();
// Ensure flag is off (default)
delete process.env.RADAR_ENABLED;
// Dynamic import to get fresh module state
const { GET } = await import("../../src/app/api/radar/catalog/route.ts");
const response = await GET(mockGetRequest());
const body = await response.json();
assert.equal(response.status, 404);
assert.ok(body.error, "Response should have error field");
assert.ok(!JSON.stringify(body).includes("at /"), "Response must not leak stack traces");
});
test("POST /api/radar/sync: flag off => 404", async () => {
resetStorage();
delete process.env.RADAR_ENABLED;
const { POST } = await import("../../src/app/api/radar/sync/route.ts");
const response = await POST(mockPostRequest("http://localhost:20128/api/radar/sync"));
const body = await response.json();
assert.equal(response.status, 404);
assert.ok(body.error);
assert.ok(!JSON.stringify(body).includes("at /"), "Response must not leak stack traces");
});
test("POST /api/radar/settings: flag off => 404", async () => {
resetStorage();
delete process.env.RADAR_ENABLED;
const { POST } = await import("../../src/app/api/radar/settings/route.ts");
const response = await POST(
mockPostRequest("http://localhost:20128/api/radar/settings", { optIn: true }),
);
const body = await response.json();
assert.equal(response.status, 404);
assert.ok(body.error);
assert.ok(!JSON.stringify(body).includes("at /"), "Response must not leak stack traces");
});
test("GET /api/radar/settings: flag off => 404", async () => {
resetStorage();
delete process.env.RADAR_ENABLED;
const { GET } = await import("../../src/app/api/radar/settings/route.ts");
const response = await GET(mockGetRequest("http://localhost:20128/api/radar/settings"));
const body = await response.json();
assert.equal(response.status, 404);
assert.ok(body.error);
assert.ok(!JSON.stringify(body).includes("at /"), "Response must not leak stack traces");
});
// ---------------------------------------------------------------------------
// FIX 1 — auth required on all 3 (now 4, with GET settings) routes once the
// flag is on. Order: flag-off 404 stays first (byte-identical inertia,
// verified above); auth (401) comes AFTER it, BEFORE any DB access.
// ---------------------------------------------------------------------------
test("GET /api/radar/catalog: flag on, no auth => 401", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const { GET } = await import("../../src/app/api/radar/catalog/route.ts");
const response = await GET(mockGetRequest());
const body = await response.json();
assert.equal(response.status, 401);
assert.ok(body.error, "Response should have error field");
assert.ok(!JSON.stringify(body).includes("at /"), "Response must not leak stack traces");
});
test("POST /api/radar/sync: flag on, no auth => 401", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const { POST } = await import("../../src/app/api/radar/sync/route.ts");
const response = await POST(mockPostRequest("http://localhost:20128/api/radar/sync"));
const body = await response.json();
assert.equal(response.status, 401);
assert.ok(body.error);
});
test("POST /api/radar/settings: flag on, no auth => 401", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const { POST } = await import("../../src/app/api/radar/settings/route.ts");
const response = await POST(
mockPostRequest("http://localhost:20128/api/radar/settings", { optIn: true }),
);
const body = await response.json();
assert.equal(response.status, 401);
assert.ok(body.error);
});
test("GET /api/radar/settings: flag on, no auth => 401", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const { GET } = await import("../../src/app/api/radar/settings/route.ts");
const response = await GET(mockGetRequest("http://localhost:20128/api/radar/settings"));
const body = await response.json();
assert.equal(response.status, 401);
assert.ok(body.error);
});
// ---------------------------------------------------------------------------
// Tests: flag-on + authenticated behavior (previous "flag on" tests, now
// wired with a valid session cookie so they exercise the post-auth branch)
// ---------------------------------------------------------------------------
test("GET /api/radar/catalog: flag on, authenticated, empty cache => baseline entries, meta null", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
// Fresh import to pick up the flag
const catalogRoute = await import("../../src/app/api/radar/catalog/route.ts");
const response = await catalogRoute.GET(mockGetRequest(undefined, await authHeaders()));
const body = await response.json();
assert.equal(response.status, 200);
assert.ok(Array.isArray(body.entries), "entries should be an array");
assert.ok(body.entries.length > 0, "should have baseline entries");
assert.equal(body.meta, null, "meta should be null when no cache");
});
test("POST /api/radar/settings: flag on, authenticated, set opt-in => success, no key in response", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const settingsRoute = await import("../../src/app/api/radar/settings/route.ts");
const response = await settingsRoute.POST(
mockPostRequest(
"http://localhost:20128/api/radar/settings",
{
optIn: true,
supporterKey: "omr_abcdef01234567890abcdef01234567890abcdef",
},
await authHeaders(),
),
);
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.ok, true);
assert.equal(body.optIn, true);
// Key must be masked, never the clear value
assert.ok(body.supporterKey, "should return masked key");
assert.ok(
!body.supporterKey.includes("abcdef01234567890abcdef01234567890abcdef"),
"Must NOT echo the clear key",
);
assert.ok(body.supporterKey.startsWith("omr_****"), "Key should be masked with omr_**** prefix");
assert.ok(body.supporterKey.length <= 12, "Masked key should be short");
});
test("POST /api/radar/settings: authenticated, invalid body => 400", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const settingsRoute = await import("../../src/app/api/radar/settings/route.ts");
const response = await settingsRoute.POST(
mockPostRequest(
"http://localhost:20128/api/radar/settings",
{ supporterKey: "invalid-key-format" },
await authHeaders(),
),
);
assert.equal(response.status, 400);
const body = await response.json();
assert.ok(body.error);
});
test("POST /api/radar/settings: authenticated, empty body => 400", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const settingsRoute = await import("../../src/app/api/radar/settings/route.ts");
const response = await settingsRoute.POST(
mockPostRequest("http://localhost:20128/api/radar/settings", {}, await authHeaders()),
);
assert.equal(response.status, 400);
const body = await response.json();
assert.ok(body.error);
});
test("POST /api/radar/settings: authenticated, null key clears it", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const settingsRoute = await import("../../src/app/api/radar/settings/route.ts");
const headers = await authHeaders();
// First set a key
await settingsRoute.POST(
mockPostRequest(
"http://localhost:20128/api/radar/settings",
{ supporterKey: "omr_abcdef01234567890abcdef01234567890abcdef" },
headers,
),
);
// Then clear it
const response = await settingsRoute.POST(
mockPostRequest(
"http://localhost:20128/api/radar/settings",
{ supporterKey: null },
headers,
),
);
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.supporterKey, null, "Cleared key should return null");
});
test("POST /api/radar/sync: flag on, authenticated, not opted in => status opt_out", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
// Don't set opt-in
const syncRoute = await import("../../src/app/api/radar/sync/route.ts");
const response = await syncRoute.POST(
mockPostRequest("http://localhost:20128/api/radar/sync", undefined, await authHeaders()),
);
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.status, "opt_out");
});
test("POST /api/radar/sync: authenticated, invalid body => 400", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const syncRoute = await import("../../src/app/api/radar/sync/route.ts");
const response = await syncRoute.POST(
mockPostRequest(
"http://localhost:20128/api/radar/sync",
{ unexpected: true },
await authHeaders(),
),
);
assert.equal(response.status, 400);
});
// ---------------------------------------------------------------------------
// FIX 3 — GET /api/radar/settings: { optIn, hasSupporterKey, supporterKeyMasked }
// F4/T7 — same response also relays contributorClaimUrl/supporterPlansUrl.
// ---------------------------------------------------------------------------
test("GET /api/radar/settings: flag on, authenticated, default state => optIn false, no key", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const { GET } = await import("../../src/app/api/radar/settings/route.ts");
const response = await GET(
mockGetRequest("http://localhost:20128/api/radar/settings", await authHeaders()),
);
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.optIn, false);
assert.equal(body.hasSupporterKey, false);
assert.equal(body.supporterKeyMasked, null);
// F4/T7: default claim/plans links are always present, opt-in or not.
assert.equal(body.contributorClaimUrl, "https://radar.omniroute.online/auth/github");
assert.equal(body.supporterPlansUrl, "https://radar.omniroute.online/planos");
});
test("GET /api/radar/settings: flag on, authenticated, after opt-in + key => reflects persisted state, never raw key", 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_abcdef01234567890abcdef01234567890abcdef";
await settingsRoute.POST(
mockPostRequest(
"http://localhost:20128/api/radar/settings",
{ optIn: true, supporterKey: RAW_KEY },
headers,
),
);
const response = await settingsRoute.GET(
mockGetRequest("http://localhost:20128/api/radar/settings", headers),
);
const text = await response.text();
const body = JSON.parse(text);
assert.equal(response.status, 200);
assert.equal(body.optIn, true);
assert.equal(body.hasSupporterKey, true);
assert.equal(body.supporterKeyMasked, "omr_****cdef", "must mask to last 4 hex chars");
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";
process.env.RADAR_CONTRIBUTOR_CLAIM_URL = "https://fork.example.com/auth/github";
process.env.RADAR_SUPPORTER_PLANS_URL = "https://fork.example.com/plans";
try {
const { GET } = await import("../../src/app/api/radar/settings/route.ts");
const response = await GET(
mockGetRequest("http://localhost:20128/api/radar/settings", await authHeaders()),
);
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.contributorClaimUrl, "https://fork.example.com/auth/github");
assert.equal(body.supporterPlansUrl, "https://fork.example.com/plans");
} finally {
delete process.env.RADAR_CONTRIBUTOR_CLAIM_URL;
delete process.env.RADAR_SUPPORTER_PLANS_URL;
}
});
// ---------------------------------------------------------------------------
// Tests: error sanitization (Hard Rule #12)
// ---------------------------------------------------------------------------
test("all radar routes: 404 error responses (flag off) do NOT leak stack traces", async () => {
resetStorage();
delete process.env.RADAR_ENABLED;
const routes = [
{ name: "catalog", GET: (await import("../../src/app/api/radar/catalog/route.ts")).GET },
{ name: "sync", POST: (await import("../../src/app/api/radar/sync/route.ts")).POST },
{ name: "settings", POST: (await import("../../src/app/api/radar/settings/route.ts")).POST },
];
for (const route of routes) {
let response: Response;
if ("GET" in route && route.GET) {
response = await (route as { GET: (r: Request) => Promise<Response> }).GET(mockGetRequest());
} else {
response = await (route as { POST: (r: Request) => Promise<Response> }).POST(
mockPostRequest(`http://localhost:20128/api/radar/${route.name}`, {}),
);
}
const text = await response.text();
assert.ok(
!text.includes("at /"),
`${route.name}: response must not contain stack-like paths. Got: ${text.slice(0, 200)}`,
);
assert.ok(
!text.includes(".ts:") && !text.includes(".js:"),
`${route.name}: response must not contain file:line references. Got: ${text.slice(0, 200)}`,
);
}
});
test("all radar routes: 401 error responses (flag on, no auth) do NOT leak stack traces", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const routes = [
{ name: "catalog", GET: (await import("../../src/app/api/radar/catalog/route.ts")).GET },
{ name: "sync", POST: (await import("../../src/app/api/radar/sync/route.ts")).POST },
{ name: "settings-post", POST: (await import("../../src/app/api/radar/settings/route.ts")).POST },
{ name: "settings-get", GET: (await import("../../src/app/api/radar/settings/route.ts")).GET },
];
for (const route of routes) {
let response: Response;
if ("GET" in route && route.GET) {
response = await (route as { GET: (r: Request) => Promise<Response> }).GET(mockGetRequest());
} else {
response = await (route as { POST: (r: Request) => Promise<Response> }).POST(
mockPostRequest(`http://localhost:20128/api/radar/${route.name.replace("-post", "")}`, {}),
);
}
assert.equal(response.status, 401, `${route.name}: expected 401 without auth`);
const text = await response.text();
assert.ok(
!text.includes("at /"),
`${route.name}: response must not contain stack-like paths. Got: ${text.slice(0, 200)}`,
);
assert.ok(
!text.includes(".ts:") && !text.includes(".js:"),
`${route.name}: response must not contain file:line references. Got: ${text.slice(0, 200)}`,
);
}
});
// ---------------------------------------------------------------------------
// Cleanup
// ---------------------------------------------------------------------------
test.after(() => {
core.resetDbInstance();
delete process.env.RADAR_ENABLED;
delete process.env.JWT_SECRET;
delete process.env.INITIAL_PASSWORD;
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
} catch {
// ignore
}
});