mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
Integrated into release/v3.8.44 — wildcard-CORS runtime warning banner + docs/security/CORS.md security guide (#5602). Re-cut clean onto the release tip (branch was fossilized). Validated: 20+9 backend + 2 banner(vitest) tests green, typecheck:core 0, docs-sync/symbols/fabricated/doc-links pass. UNSTABLE red is the inherited environmental setup-claude base-red.
This commit is contained in:
committed by
GitHub
parent
cbd08ef780
commit
f7dea02853
162
docs/security/CORS.md
Normal file
162
docs/security/CORS.md
Normal file
@@ -0,0 +1,162 @@
|
||||
---
|
||||
title: CORS Configuration & Security
|
||||
---
|
||||
|
||||
# CORS Configuration & Security
|
||||
|
||||
OmniRoute controls which **browser origins** may read cross-origin responses
|
||||
from a single, centralized allowlist. The model is **fail-closed by default**:
|
||||
no origin is allowed until you opt one in. This page documents how the allowlist
|
||||
resolves, what `CORS_ALLOW_ALL=true` actually exposes (and, importantly, what it
|
||||
does **not**), how to configure dev vs production safely, and the runtime warning
|
||||
the dashboard shows when a wildcard is live.
|
||||
|
||||
**Source of truth:** `src/server/cors/origins.ts` (`resolveAllowedOrigin`,
|
||||
`applyCorsHeaders`, `getCorsStatus`). The allowlist is applied once, in the
|
||||
middleware (`src/server/authz/pipeline.ts`) — per-route handlers do not set
|
||||
`Access-Control-Allow-Origin` themselves.
|
||||
|
||||
## How an origin is resolved
|
||||
|
||||
For each request the middleware computes the `Access-Control-Allow-Origin` value
|
||||
in this order:
|
||||
|
||||
1. **`CORS_ALLOW_ALL=true`** (or the legacy `CORS_ORIGIN=*`) → echo the caller's
|
||||
`Origin` back (or `*` when there is no `Origin` header), with `Vary: Origin`
|
||||
so caches stay correct.
|
||||
2. Otherwise, the request `Origin` is normalized (lower-cased, trailing slash
|
||||
stripped) and matched against the **merged allowlist**:
|
||||
- env **`CORS_ALLOWED_ORIGINS`** — comma-separated list, and
|
||||
- the runtime **`corsOrigins`** setting (Dashboard → Security → _CORS Allowed
|
||||
Origins_), injected via `setRuntimeAllowedOrigins()` from
|
||||
`src/lib/config/runtimeSettings.ts`.
|
||||
3. No match → **no `Access-Control-Allow-Origin` header is emitted**. The browser
|
||||
blocks the cross-origin read. This is the intended fail-closed default.
|
||||
|
||||
| Env var | Meaning |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `CORS_ALLOWED_ORIGINS` | CSV of exact origins to allow (recommended). |
|
||||
| `CORS_ALLOW_ALL` | `true`/`1` → echo any origin (wildcard). Dev only. |
|
||||
| `CORS_ORIGIN` | Legacy. `*` behaves like `CORS_ALLOW_ALL`; a single value is added to the allowlist. |
|
||||
|
||||
## Threat model — what `CORS_ALLOW_ALL=true` really exposes
|
||||
|
||||
The generic OWASP warning ("wildcard CORS = any site can call your API") is worth
|
||||
taking seriously, but OmniRoute's exposure is **narrower than the generic case**,
|
||||
because of one concrete implementation fact:
|
||||
|
||||
> **The central `applyCorsHeaders()` never emits
|
||||
> `Access-Control-Allow-Credentials`.** A browser will not expose a _credentialed_
|
||||
> (cookie-bearing) cross-origin response unless the server sends
|
||||
> `Access-Control-Allow-Credentials: true`. OmniRoute's shared CORS path never
|
||||
> does.
|
||||
|
||||
What that means per surface, even with `CORS_ALLOW_ALL=true`:
|
||||
|
||||
| Surface | Auth mechanism | Effect of wildcard CORS |
|
||||
| ----------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Dashboard / MANAGEMENT `/api/*` | Cookie session | Origin is echoed, but **without `Allow-Credentials`** the browser **blocks** the credentialed read. A malicious cross-origin site **cannot read** your authenticated dashboard responses, and the session cookie is not exposed. |
|
||||
| Client API `/v1/*`, `/v1beta/*` | Bearer / `x-api-key` header | Already permissive **by design** (`relaxForTokenAuth`): browsers never auto-attach `Authorization`/`x-api-key`, so an attacker's page cannot supply your key. `CORS_ALLOW_ALL` does not widen this. |
|
||||
| Public read-only (`/api/health`, …) | None | Non-sensitive; wildcard is harmless. |
|
||||
|
||||
So the **residual** exposure of `CORS_ALLOW_ALL=true` is limited to: (a)
|
||||
non-credentialed cross-origin **reads** of already-unauthenticated data, and (b)
|
||||
letting CORS **preflight pass** on management routes — which still require auth
|
||||
that a cross-origin page cannot provide. It is **not** a session-hijack or
|
||||
credential-theft vector on the shared CORS path.
|
||||
|
||||
### One genuine exception — `/api/v1/agents/`
|
||||
|
||||
The Cloud-Agent routes (`/api/v1/agents/{health,credentials,tasks,tasks/[id]}`) set
|
||||
their **own** CORS headers
|
||||
(`src/lib/cloudAgent/api.ts`, `getCloudAgentCorsHeaders`) and **do** emit
|
||||
`Access-Control-Allow-Origin: <origin>|*` together with
|
||||
`Access-Control-Allow-Credentials: true`. This is the single surface where
|
||||
origin-echo and credentials coexist, and it is **independent of
|
||||
`CORS_ALLOW_ALL`**. These routes are management-authenticated
|
||||
(`requireManagementAuth`); operators who expose the dashboard off-host should be
|
||||
aware that this is the one place a cross-origin credentialed read is permitted by
|
||||
the response headers. Tightening it to an explicit allowlist is tracked
|
||||
separately from this CORS guidance.
|
||||
|
||||
## Production checklist
|
||||
|
||||
- **Never set `CORS_ALLOW_ALL=true` in production.** Leave it unset.
|
||||
- Set an **explicit** origin list — either the env var or the Security-tab field:
|
||||
|
||||
```bash
|
||||
CORS_ALLOWED_ORIGINS="https://app.example.com, https://admin.example.com"
|
||||
```
|
||||
|
||||
- If OmniRoute runs behind a reverse proxy / tunnel (nginx, Caddy, Cloudflare
|
||||
Tunnel, Tailscale), CORS is **not** your only control — the loopback route
|
||||
guard still protects spawn-capable routes (see
|
||||
[ROUTE_GUARD_TIERS](./ROUTE_GUARD_TIERS.md)). Do not forge
|
||||
`X-Forwarded-For: 127.0.0.1` to "fix" a 403; that re-opens the RCE class the
|
||||
route guard closes.
|
||||
- Confirm the runtime state: the dashboard shows a **persistent amber banner**
|
||||
under Dashboard → Security → Authorization Inventory whenever
|
||||
`CORS_ALLOW_ALL=true` is live, and `/api/settings/authz-inventory` returns a
|
||||
`cors: { allowAll, allowedOrigins }` envelope monitoring tools can poll.
|
||||
|
||||
## Development convenience — allow specific local origins
|
||||
|
||||
You rarely need the wildcard even in dev. Allow just the dev servers you use:
|
||||
|
||||
```bash
|
||||
# Vite (5173) + Next.js (3000) dev servers calling a local OmniRoute
|
||||
CORS_ALLOWED_ORIGINS="http://localhost:5173, http://localhost:3000"
|
||||
```
|
||||
|
||||
Origins are matched case-insensitively with the trailing slash ignored, so
|
||||
`http://localhost:3000` and `http://localhost:3000/` are equivalent. The same CSV
|
||||
can be set at runtime in **Dashboard → Security → CORS Allowed Origins** without a
|
||||
restart.
|
||||
|
||||
## API keys vs cookie sessions
|
||||
|
||||
- **Bearer / `x-api-key` (the `/v1/*` inference surface):** browsers never attach
|
||||
these automatically. CORS is not a meaningful barrier here — the API key is the
|
||||
barrier — which is why that surface is intentionally permissive so browser and
|
||||
Electron clients can read responses they are already entitled to.
|
||||
- **Cookie session (the dashboard):** protected by the fail-closed default **and**
|
||||
by the absence of `Access-Control-Allow-Credentials` on the shared path. Keep
|
||||
management/dashboard origins out of any permissive config; they must stay exactly
|
||||
fail-closed.
|
||||
|
||||
## Example: reverse proxy in front of OmniRoute
|
||||
|
||||
CORS is enforced by OmniRoute itself, so the proxy generally should **not** add or
|
||||
rewrite `Access-Control-*` headers (double headers break browsers). Terminate TLS
|
||||
and forward — let OmniRoute answer preflight:
|
||||
|
||||
```nginx
|
||||
# nginx — forward to OmniRoute; do NOT inject Access-Control-* here
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:20128;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# Do NOT set X-Forwarded-For to 127.0.0.1 — it defeats the loopback route guard.
|
||||
}
|
||||
```
|
||||
|
||||
Set the allowed browser origins in OmniRoute (`CORS_ALLOWED_ORIGINS` or the
|
||||
Security tab), not in the proxy.
|
||||
|
||||
## Source files
|
||||
|
||||
| Concern | File |
|
||||
| ----------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| Allowlist resolution + `getCorsStatus()` | `src/server/cors/origins.ts` |
|
||||
| Middleware application (single source of truth) | `src/server/authz/pipeline.ts` |
|
||||
| Settings → runtime origin injection | `src/lib/config/runtimeSettings.ts` |
|
||||
| Runtime status for the dashboard | `src/app/api/settings/authz-inventory/route.ts` |
|
||||
| Dashboard warning banner | `src/app/(dashboard)/dashboard/settings/components/AuthzSection.tsx` |
|
||||
| CORS Allowed Origins field | `src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx` |
|
||||
| Cloud-Agent per-route CORS (the exception) | `src/lib/cloudAgent/api.ts` |
|
||||
|
||||
## See also
|
||||
|
||||
- [Route Guard Tiers](./ROUTE_GUARD_TIERS.md) — loopback enforcement for
|
||||
spawn-capable routes (a separate, complementary control).
|
||||
- [Authorization Guide](../architecture/AUTHZ_GUIDE.md) — the full auth pipeline.
|
||||
@@ -13,11 +13,17 @@ interface TierEntry {
|
||||
bypassable: boolean;
|
||||
}
|
||||
|
||||
interface CorsStatus {
|
||||
allowAll: boolean;
|
||||
allowedOrigins: string[];
|
||||
}
|
||||
|
||||
interface InventoryPayload {
|
||||
tiers: TierEntry[];
|
||||
bypassEnabled: boolean;
|
||||
bypassPrefixes: string[];
|
||||
spawnCapablePrefixes: string[];
|
||||
cors?: CorsStatus;
|
||||
}
|
||||
|
||||
interface StatusMessage {
|
||||
@@ -241,6 +247,24 @@ export default function AuthzSection() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* #5602: persistent wildcard-CORS warning — only visible while
|
||||
CORS_ALLOW_ALL=true is live. See docs/security/CORS.md. */}
|
||||
{inventory.cors?.allowAll && (
|
||||
<div
|
||||
data-testid="cors-wildcard-banner"
|
||||
role="alert"
|
||||
className="flex items-start gap-3 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px] mt-0.5" aria-hidden="true">
|
||||
warning
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-semibold">{t("authz.cors.wildcard.title")}</p>
|
||||
<p className="text-sm">{t("authz.cors.wildcard.desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bypass policy editor */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES,
|
||||
SPAWN_CAPABLE_PREFIXES,
|
||||
} from "@/server/authz/routeGuard";
|
||||
import { getCorsStatus } from "@/server/cors/origins";
|
||||
|
||||
/**
|
||||
* Static MANAGEMENT-tier example prefixes. Render-only — never consulted by
|
||||
@@ -153,6 +154,9 @@ export async function GET(request: Request) {
|
||||
bypassEnabled,
|
||||
bypassPrefixes,
|
||||
spawnCapablePrefixes: [...SPAWN_CAPABLE_PREFIXES],
|
||||
// #5602: surface the effective CORS allowlist so the dashboard can warn
|
||||
// when `CORS_ALLOW_ALL=true` is set (wildcard origins). See docs/security/CORS.md.
|
||||
cors: getCorsStatus(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error loading authz inventory:", error);
|
||||
|
||||
@@ -96,6 +96,28 @@ export function resolveAllowedOrigin(requestOrigin: string | null | undefined):
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only snapshot of the effective CORS allowlist configuration, for
|
||||
* dashboards / monitoring (`/api/settings/authz-inventory`). `allowAll` mirrors
|
||||
* the `CORS_ALLOW_ALL` opt-in (and the legacy `CORS_ORIGIN=*`); `allowedOrigins`
|
||||
* is the merged, normalized, sorted, deduped env + runtime allowlist.
|
||||
*
|
||||
* A `true` `allowAll` is what the dashboard surfaces as a wildcard-CORS warning.
|
||||
* See `docs/security/CORS.md`.
|
||||
*/
|
||||
export interface CorsStatus {
|
||||
allowAll: boolean;
|
||||
allowedOrigins: string[];
|
||||
}
|
||||
|
||||
export function getCorsStatus(): CorsStatus {
|
||||
const merged = new Set<string>([...envAllowedOrigins(), ...runtimeAllowedOrigins]);
|
||||
return {
|
||||
allowAll: envAllowAll(),
|
||||
allowedOrigins: [...merged].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply CORS headers to a response in-place. Safe to call on any response
|
||||
* (rejections, preflight, normal `next()` continuations). When the origin
|
||||
|
||||
@@ -19,6 +19,7 @@ process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1";
|
||||
|
||||
const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
|
||||
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
|
||||
const ORIGINAL_CORS_ALLOW_ALL = process.env.CORS_ALLOW_ALL;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../../src/lib/db/settings.ts");
|
||||
@@ -39,6 +40,8 @@ test.after(() => {
|
||||
else process.env.JWT_SECRET = ORIGINAL_JWT_SECRET;
|
||||
if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD;
|
||||
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
|
||||
if (ORIGINAL_CORS_ALLOW_ALL === undefined) delete process.env.CORS_ALLOW_ALL;
|
||||
else process.env.CORS_ALLOW_ALL = ORIGINAL_CORS_ALLOW_ALL;
|
||||
});
|
||||
|
||||
// ─── AC-1 — shape ─────────────────────────────────────────────────────────
|
||||
@@ -153,6 +156,44 @@ test("AC-2: bypassPrefixes additions land in the inventory", async () => {
|
||||
assert.deepEqual(body.bypassPrefixes, ["/api/mcp/", "/api/mcp/v2/"]);
|
||||
});
|
||||
|
||||
// ─── #5602 — CORS status surfaced for the dashboard wildcard warning ──────
|
||||
|
||||
test("#5602: cors.allowAll is false by default (no CORS_ALLOW_ALL)", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-cors-a";
|
||||
delete process.env.CORS_ALLOW_ALL;
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
const response = await inventoryRoute.GET(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings/authz-inventory", {
|
||||
method: "GET",
|
||||
})
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as {
|
||||
cors: { allowAll: boolean; allowedOrigins: string[] };
|
||||
};
|
||||
assert.ok(body.cors, "response should carry a cors envelope");
|
||||
assert.equal(body.cors.allowAll, false);
|
||||
assert.deepEqual(body.cors.allowedOrigins, []);
|
||||
});
|
||||
|
||||
test("#5602: cors.allowAll reflects CORS_ALLOW_ALL=true", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-cors-b";
|
||||
process.env.CORS_ALLOW_ALL = "true";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
const response = await inventoryRoute.GET(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings/authz-inventory", {
|
||||
method: "GET",
|
||||
})
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as { cors: { allowAll: boolean } };
|
||||
assert.equal(body.cors.allowAll, true);
|
||||
});
|
||||
|
||||
// ─── AC-12 — anonymous request rejected (no inventory leak) ───────────────
|
||||
|
||||
test("AC-12: anonymous request (no cookie, no Bearer) → 401", async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
applyCorsHeaders,
|
||||
getCorsStatus,
|
||||
resolveAllowedOrigin,
|
||||
setRuntimeAllowedOrigins,
|
||||
STATIC_CORS_HEADERS,
|
||||
@@ -188,6 +189,48 @@ describe("cors/origins.applyCorsHeaders", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cors/origins.getCorsStatus", () => {
|
||||
let envSnap: Record<string, string | undefined>;
|
||||
|
||||
beforeEach(() => {
|
||||
envSnap = snapshotEnv();
|
||||
for (const key of ENV_KEYS) delete process.env[key];
|
||||
setRuntimeAllowedOrigins("");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv(envSnap);
|
||||
setRuntimeAllowedOrigins("");
|
||||
});
|
||||
|
||||
it("default (no env, no runtime) → allowAll false, empty origins", () => {
|
||||
assert.deepEqual(getCorsStatus(), { allowAll: false, allowedOrigins: [] });
|
||||
});
|
||||
|
||||
it("CORS_ALLOW_ALL=true → allowAll true", () => {
|
||||
process.env.CORS_ALLOW_ALL = "true";
|
||||
assert.equal(getCorsStatus().allowAll, true);
|
||||
});
|
||||
|
||||
it("legacy CORS_ORIGIN=* → allowAll true", () => {
|
||||
process.env.CORS_ORIGIN = "*";
|
||||
assert.equal(getCorsStatus().allowAll, true);
|
||||
});
|
||||
|
||||
it("merges env + runtime allowlists, normalized, sorted, deduped", () => {
|
||||
process.env.CORS_ALLOWED_ORIGINS = "https://Env.Example.com/, https://shared.example.com";
|
||||
setRuntimeAllowedOrigins("https://runtime.example.com, https://shared.example.com/");
|
||||
assert.deepEqual(getCorsStatus(), {
|
||||
allowAll: false,
|
||||
allowedOrigins: [
|
||||
"https://env.example.com",
|
||||
"https://runtime.example.com",
|
||||
"https://shared.example.com",
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cors/origins.STATIC_CORS_HEADERS", () => {
|
||||
it("never contains Access-Control-Allow-Origin", () => {
|
||||
assert.equal(
|
||||
|
||||
104
tests/unit/ui/authz-cors-banner.test.tsx
Normal file
104
tests/unit/ui/authz-cors-banner.test.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* UI unit test for the AuthzSection wildcard-CORS banner (#5602).
|
||||
*
|
||||
* The banner must appear when `/api/settings/authz-inventory` reports
|
||||
* `cors.allowAll === true` (i.e. `CORS_ALLOW_ALL=true` at runtime) and stay
|
||||
* hidden otherwise. It is the only runtime signal a wildcard-CORS
|
||||
* misconfiguration is live. See docs/security/CORS.md.
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Stable identity fn — the real next-intl `t` is memoized per render. A fresh
|
||||
// closure each render would flip AuthzSection's `useCallback([t])` dep and loop
|
||||
// its mount fetch forever, so we return the SAME reference every call.
|
||||
const translate = (key: string) => key;
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => translate,
|
||||
}));
|
||||
|
||||
// Import the (heavy) dashboard component ONCE at module load rather than inside every
|
||||
// render call. The dynamic import pulls the whole settings-page dependency graph through
|
||||
// esbuild on first use (~20s cold); doing it per-test made the first test tip over the
|
||||
// 30s per-test timeout while the second (warm, cached) passed. Hoisting moves that cost to
|
||||
// module-eval time (outside any per-test timeout) and keeps each test body fast.
|
||||
const AuthzSection = (
|
||||
await import("../../../src/app/(dashboard)/dashboard/settings/components/AuthzSection")
|
||||
).default;
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
function inventoryPayload(cors: { allowAll: boolean; allowedOrigins: string[] }) {
|
||||
return {
|
||||
tiers: [
|
||||
{
|
||||
name: "PUBLIC",
|
||||
prefixes: ["/api/health"],
|
||||
description: "public",
|
||||
bypassable: false,
|
||||
},
|
||||
],
|
||||
bypassEnabled: true,
|
||||
bypassPrefixes: ["/api/mcp/"],
|
||||
spawnCapablePrefixes: ["/api/cli-tools/runtime/"],
|
||||
cors,
|
||||
};
|
||||
}
|
||||
|
||||
function mockInventoryFetch(cors: { allowAll: boolean; allowedOrigins: string[] }) {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(inventoryPayload(cors)),
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
async function renderAuthzSection(): Promise<HTMLElement> {
|
||||
const container = makeContainer();
|
||||
await act(async () => {
|
||||
createRoot(container).render(React.createElement(AuthzSection));
|
||||
});
|
||||
// Flush the mount-time inventory fetch + resulting state update.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("AuthzSection wildcard-CORS banner (#5602)", { timeout: 60000 }, () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the banner when cors.allowAll is true", async () => {
|
||||
mockInventoryFetch({ allowAll: true, allowedOrigins: [] });
|
||||
await renderAuthzSection();
|
||||
const banner = document.querySelector('[data-testid="cors-wildcard-banner"]');
|
||||
expect(banner).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT render the banner when cors.allowAll is false", async () => {
|
||||
mockInventoryFetch({ allowAll: false, allowedOrigins: ["https://app.example.com"] });
|
||||
await renderAuthzSection();
|
||||
const banner = document.querySelector('[data-testid="cors-wildcard-banner"]');
|
||||
expect(banner).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user