fix(radar): bypass stale flag-off responses (#10464)

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-15 08:15:41 -03:00
committed by GitHub
parent 774127be3f
commit ee221d870c
5 changed files with 94 additions and 77 deletions

View File

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

View File

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

View File

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

View File

@@ -63,7 +63,7 @@ async function authHeaders(): Promise<Record<string, string>> {
// Helper to create a mock NextRequest-like object
function mockGetRequest(
url = "http://localhost:20128/api/radar/catalog",
headers: Record<string, string> = {},
headers: Record<string, string> = {}
): Request {
return new Request(url, { method: "GET", headers });
}
@@ -71,7 +71,7 @@ function mockGetRequest(
function mockPostRequest(
url: string,
body?: unknown,
headers: Record<string, string> = {},
headers: Record<string, string> = {}
): 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<Response> }).GET(mockGetRequest());
} else {
response = await (route as { POST: (r: Request) => Promise<Response> }).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<Response> }).GET(mockGetRequest());
} else {
response = await (route as { POST: (r: Request) => Promise<Response> }).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)}`
);
}
});

View File

@@ -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"'),