mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 19:52:50 +03:00
fix(radar): bypass stale flag-off responses (#10464)
Co-authored-by: Xiangzhe <bakryun0718@proton.me>
This commit is contained in:
committed by
GitHub
parent
774127be3f
commit
ee221d870c
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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"'),
|
||||
|
||||
Reference in New Issue
Block a user