diff --git a/src/app/(dashboard)/dashboard/radar/page.tsx b/src/app/(dashboard)/dashboard/radar/page.tsx index 203d06e9b4..86111a803c 100644 --- a/src/app/(dashboard)/dashboard/radar/page.tsx +++ b/src/app/(dashboard)/dashboard/radar/page.tsx @@ -113,7 +113,7 @@ export default function RadarPage() { if (showLoading) setLoading(true); setError(""); try { - const res = await fetch("/api/radar/catalog"); + const res = await fetch("/api/radar/catalog", { cache: "no-store" }); if (res.status === 404) { // Flag off — treat as not found setOptIn(false); @@ -159,7 +159,7 @@ export default function RadarPage() { // the activation screen on every reload). const fetchSettings = useCallback(async () => { try { - const settingsRes = await fetch("/api/radar/settings"); + const settingsRes = await fetch("/api/radar/settings", { cache: "no-store" }); if (settingsRes.status === 404) { // Flag off setOptIn(false); diff --git a/src/app/api/radar/catalog/route.ts b/src/app/api/radar/catalog/route.ts index 4e080e955b..0cf713485d 100644 --- a/src/app/api/radar/catalog/route.ts +++ b/src/app/api/radar/catalog/route.ts @@ -27,30 +27,30 @@ export async function OPTIONS() { export async function GET(request: Request) { // Flag gate — surface doesn't exist when disabled. MUST run before auth. if (!isFeatureFlagEnabled("RADAR_ENABLED")) { - return NextResponse.json( - buildErrorBody(404, "Not found"), - { status: 404, headers: CORS_HEADERS }, - ); + return NextResponse.json(buildErrorBody(404, "Not found"), { + status: 404, + headers: { ...CORS_HEADERS, "Cache-Control": "no-store" }, + }); } if (!(await isAuthenticated(request))) { - return NextResponse.json( - buildErrorBody(401, "Unauthorized"), - { status: 401, headers: CORS_HEADERS }, - ); + return NextResponse.json(buildErrorBody(401, "Unauthorized"), { + status: 401, + headers: CORS_HEADERS, + }); } try { const result = getRadarCatalog(); return NextResponse.json( { entries: result.entries, meta: result.meta }, - { headers: { ...CORS_HEADERS, "Cache-Control": "no-store" } }, + { headers: { ...CORS_HEADERS, "Cache-Control": "no-store" } } ); } catch (err: unknown) { const { sanitizeErrorMessage } = await import("@omniroute/open-sse/utils/error"); return NextResponse.json( buildErrorBody(500, sanitizeErrorMessage(err) || "Failed to load Radar catalog"), - { status: 500, headers: CORS_HEADERS }, + { status: 500, headers: CORS_HEADERS } ); } } diff --git a/src/app/api/radar/settings/route.ts b/src/app/api/radar/settings/route.ts index 8c35840d21..eb804afb2d 100644 --- a/src/app/api/radar/settings/route.ts +++ b/src/app/api/radar/settings/route.ts @@ -62,17 +62,17 @@ export async function OPTIONS() { export async function GET(request: Request) { // Flag gate — MUST run before auth (byte-identical flag-off inertia). if (!isFeatureFlagEnabled("RADAR_ENABLED")) { - return NextResponse.json( - buildErrorBody(404, "Not found"), - { status: 404, headers: CORS_HEADERS }, - ); + return NextResponse.json(buildErrorBody(404, "Not found"), { + status: 404, + headers: { ...CORS_HEADERS, "Cache-Control": "no-store" }, + }); } if (!(await isAuthenticated(request))) { - return NextResponse.json( - buildErrorBody(401, "Unauthorized"), - { status: 401, headers: CORS_HEADERS }, - ); + return NextResponse.json(buildErrorBody(401, "Unauthorized"), { + status: 401, + headers: CORS_HEADERS, + }); } try { @@ -85,13 +85,13 @@ export async function GET(request: Request) { contributorClaimUrl: getContributorClaimUrl(), supporterPlansUrl: getSupporterPlansUrl(), }, - { headers: { ...CORS_HEADERS, "Cache-Control": "no-store" } }, + { headers: { ...CORS_HEADERS, "Cache-Control": "no-store" } } ); } catch (err: unknown) { const { sanitizeErrorMessage } = await import("@omniroute/open-sse/utils/error"); return NextResponse.json( buildErrorBody(500, sanitizeErrorMessage(err) || "Failed to load Radar settings"), - { status: 500, headers: CORS_HEADERS }, + { status: 500, headers: CORS_HEADERS } ); } } @@ -99,34 +99,34 @@ export async function GET(request: Request) { export async function POST(request: Request) { // Flag gate — MUST run before auth (byte-identical flag-off inertia). if (!isFeatureFlagEnabled("RADAR_ENABLED")) { - return NextResponse.json( - buildErrorBody(404, "Not found"), - { status: 404, headers: CORS_HEADERS }, - ); + return NextResponse.json(buildErrorBody(404, "Not found"), { + status: 404, + headers: CORS_HEADERS, + }); } if (!(await isAuthenticated(request))) { - return NextResponse.json( - buildErrorBody(401, "Unauthorized"), - { status: 401, headers: CORS_HEADERS }, - ); + return NextResponse.json(buildErrorBody(401, "Unauthorized"), { + status: 401, + headers: CORS_HEADERS, + }); } let body: unknown; try { body = await request.json(); } catch { - return NextResponse.json( - buildErrorBody(400, "Invalid JSON body"), - { status: 400, headers: CORS_HEADERS }, - ); + return NextResponse.json(buildErrorBody(400, "Invalid JSON body"), { + status: 400, + headers: CORS_HEADERS, + }); } const parsed = SettingsBodySchema.safeParse(body); if (!parsed.success) { return NextResponse.json( buildErrorBody(400, "Invalid request body", parsed.error.flatten().fieldErrors), - { status: 400, headers: CORS_HEADERS }, + { status: 400, headers: CORS_HEADERS } ); } @@ -136,7 +136,7 @@ export async function POST(request: Request) { if (optIn === undefined && supporterKey === undefined) { return NextResponse.json( buildErrorBody(400, "At least one of optIn or supporterKey is required"), - { status: 400, headers: CORS_HEADERS }, + { status: 400, headers: CORS_HEADERS } ); } @@ -165,13 +165,13 @@ export async function POST(request: Request) { optIn: optIn ?? undefined, supporterKey: supporterKey !== undefined ? maskKey(supporterKey) : undefined, }, - { headers: CORS_HEADERS }, + { headers: CORS_HEADERS } ); } catch (err: unknown) { const { sanitizeErrorMessage } = await import("@omniroute/open-sse/utils/error"); return NextResponse.json( buildErrorBody(500, sanitizeErrorMessage(err) || "Failed to update Radar settings"), - { status: 500, headers: CORS_HEADERS }, + { status: 500, headers: CORS_HEADERS } ); } } diff --git a/tests/unit/radar-api-routes.test.ts b/tests/unit/radar-api-routes.test.ts index e3551b46bb..ae95272bef 100644 --- a/tests/unit/radar-api-routes.test.ts +++ b/tests/unit/radar-api-routes.test.ts @@ -63,7 +63,7 @@ async function authHeaders(): Promise> { // Helper to create a mock NextRequest-like object function mockGetRequest( url = "http://localhost:20128/api/radar/catalog", - headers: Record = {}, + headers: Record = {} ): Request { return new Request(url, { method: "GET", headers }); } @@ -71,7 +71,7 @@ function mockGetRequest( function mockPostRequest( url: string, body?: unknown, - headers: Record = {}, + headers: Record = {} ): Request { return new Request(url, { method: "POST", @@ -108,6 +108,11 @@ test("GET /api/radar/catalog: flag off => 404", async () => { const body = await response.json(); assert.equal(response.status, 404); + assert.equal( + response.headers.get("cache-control"), + "no-store", + "flag-off catalog must not be cached or remain stale after RADAR_ENABLED is enabled" + ); assert.ok(body.error, "Response should have error field"); assert.ok(!JSON.stringify(body).includes("at /"), "Response must not leak stack traces"); }); @@ -131,7 +136,7 @@ test("POST /api/radar/settings: flag off => 404", async () => { const { POST } = await import("../../src/app/api/radar/settings/route.ts"); const response = await POST( - mockPostRequest("http://localhost:20128/api/radar/settings", { optIn: true }), + mockPostRequest("http://localhost:20128/api/radar/settings", { optIn: true }) ); const body = await response.json(); @@ -149,6 +154,11 @@ test("GET /api/radar/settings: flag off => 404", async () => { const body = await response.json(); assert.equal(response.status, 404); + assert.equal( + response.headers.get("cache-control"), + "no-store", + "flag-off settings must not be cached or the page can stay 404 after RADAR_ENABLED is enabled" + ); assert.ok(body.error); assert.ok(!JSON.stringify(body).includes("at /"), "Response must not leak stack traces"); }); @@ -190,7 +200,7 @@ test("POST /api/radar/settings: flag on, no auth => 401", async () => { const { POST } = await import("../../src/app/api/radar/settings/route.ts"); const response = await POST( - mockPostRequest("http://localhost:20128/api/radar/settings", { optIn: true }), + mockPostRequest("http://localhost:20128/api/radar/settings", { optIn: true }) ); const body = await response.json(); @@ -242,8 +252,8 @@ test("POST /api/radar/settings: flag on, authenticated, set opt-in => success, n optIn: true, supporterKey: "omr_abcdef01234567890abcdef01234567890abcdef", }, - await authHeaders(), - ), + await authHeaders() + ) ); const body = await response.json(); @@ -254,7 +264,7 @@ test("POST /api/radar/settings: flag on, authenticated, set opt-in => success, n assert.ok(body.supporterKey, "should return masked key"); assert.ok( !body.supporterKey.includes("abcdef01234567890abcdef01234567890abcdef"), - "Must NOT echo the clear key", + "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"); @@ -269,8 +279,8 @@ test("POST /api/radar/settings: authenticated, invalid body => 400", async () => mockPostRequest( "http://localhost:20128/api/radar/settings", { supporterKey: "invalid-key-format" }, - await authHeaders(), - ), + await authHeaders() + ) ); assert.equal(response.status, 400); @@ -284,7 +294,7 @@ test("POST /api/radar/settings: authenticated, empty body => 400", async () => { 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()), + mockPostRequest("http://localhost:20128/api/radar/settings", {}, await authHeaders()) ); assert.equal(response.status, 400); @@ -304,17 +314,13 @@ test("POST /api/radar/settings: authenticated, null key clears it", async () => mockPostRequest( "http://localhost:20128/api/radar/settings", { supporterKey: "omr_abcdef01234567890abcdef01234567890abcdef" }, - headers, - ), + headers + ) ); // Then clear it const response = await settingsRoute.POST( - mockPostRequest( - "http://localhost:20128/api/radar/settings", - { supporterKey: null }, - headers, - ), + mockPostRequest("http://localhost:20128/api/radar/settings", { supporterKey: null }, headers) ); const body = await response.json(); @@ -329,7 +335,7 @@ test("POST /api/radar/sync: flag on, authenticated, not opted in => status opt_o 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()), + mockPostRequest("http://localhost:20128/api/radar/sync", undefined, await authHeaders()) ); const body = await response.json(); @@ -346,8 +352,8 @@ test("POST /api/radar/sync: authenticated, invalid body => 400", async () => { mockPostRequest( "http://localhost:20128/api/radar/sync", { unexpected: true }, - await authHeaders(), - ), + await authHeaders() + ) ); assert.equal(response.status, 400); @@ -364,7 +370,7 @@ test("GET /api/radar/settings: flag on, authenticated, default state => optIn fa const { GET } = await import("../../src/app/api/radar/settings/route.ts"); const response = await GET( - mockGetRequest("http://localhost:20128/api/radar/settings", await authHeaders()), + mockGetRequest("http://localhost:20128/api/radar/settings", await authHeaders()) ); const body = await response.json(); @@ -389,12 +395,12 @@ test("GET /api/radar/settings: flag on, authenticated, after opt-in + key => ref mockPostRequest( "http://localhost:20128/api/radar/settings", { optIn: true, supporterKey: RAW_KEY }, - headers, - ), + headers + ) ); const response = await settingsRoute.GET( - mockGetRequest("http://localhost:20128/api/radar/settings", headers), + mockGetRequest("http://localhost:20128/api/radar/settings", headers) ); const text = await response.text(); const body = JSON.parse(text); @@ -424,8 +430,8 @@ test("POST /api/radar/settings: opt-in+key submitted together => both persist, P mockPostRequest( "http://localhost:20128/api/radar/settings", { optIn: true, supporterKey: RAW_KEY }, - headers, - ), + headers + ) ); const postText = await postResponse.text(); const postBody = JSON.parse(postText); @@ -438,14 +444,11 @@ test("POST /api/radar/settings: opt-in+key submitted together => both persist, P "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" - ); + 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), + mockGetRequest("http://localhost:20128/api/radar/settings", headers) ); const getText = await getResponse.text(); const getBody = JSON.parse(getText); @@ -466,7 +469,7 @@ test("GET /api/radar/settings: F4/T7 claim/plans links honor env overrides (fork 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()), + mockGetRequest("http://localhost:20128/api/radar/settings", await authHeaders()) ); const body = await response.json(); @@ -499,17 +502,17 @@ test("all radar routes: 404 error responses (flag off) do NOT leak stack traces" response = await (route as { GET: (r: Request) => Promise }).GET(mockGetRequest()); } else { response = await (route as { POST: (r: Request) => Promise }).POST( - mockPostRequest(`http://localhost:20128/api/radar/${route.name}`, {}), + 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)}`, + `${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)}`, + `${route.name}: response must not contain file:line references. Got: ${text.slice(0, 200)}` ); } }); @@ -521,7 +524,10 @@ test("all radar routes: 401 error responses (flag on, no auth) do NOT leak stack 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-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 }, ]; @@ -531,18 +537,18 @@ test("all radar routes: 401 error responses (flag on, no auth) do NOT leak stack response = await (route as { GET: (r: Request) => Promise }).GET(mockGetRequest()); } else { response = await (route as { POST: (r: Request) => Promise }).POST( - mockPostRequest(`http://localhost:20128/api/radar/${route.name.replace("-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)}`, + `${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)}`, + `${route.name}: response must not contain file:line references. Got: ${text.slice(0, 200)}` ); } }); diff --git a/tests/unit/radar-key-input.test.ts b/tests/unit/radar-key-input.test.ts index 0c73d0b1c3..6e89d270b8 100644 --- a/tests/unit/radar-key-input.test.ts +++ b/tests/unit/radar-key-input.test.ts @@ -37,6 +37,17 @@ const NEW_KEYS = [ "changeKeyButton", ]; +test("radar page: flag-gated GETs bypass cached 404 responses after enablement", () => { + assert.ok( + PAGE_SRC.includes('fetch("/api/radar/settings", { cache: "no-store" })'), + "settings fetch must bypass the flag-off 404 cache after RADAR_ENABLED changes" + ); + assert.ok( + PAGE_SRC.includes('fetch("/api/radar/catalog", { cache: "no-store" })'), + "catalog fetch must bypass the flag-off 404 cache after RADAR_ENABLED changes" + ); +}); + test("radar page: imports and calls the shared isValidSupporterKeyFormat() helper", () => { assert.ok( PAGE_SRC.includes('from "@/lib/radar/supporterKey"'),