259 lines
11 KiB
TypeScript
259 lines
11 KiB
TypeScript
/**
|
|
* T-013 — GET /api/settings/authz-inventory.
|
|
*
|
|
* Covers spec AC-1, AC-2, AC-12 (and AC-13 PATCH coverage continues to live
|
|
* in `tests/unit/settings/authz-bypass.test.ts`; this file only asserts the
|
|
* inventory endpoint itself).
|
|
*
|
|
* - AC-1 response shape: 5 tiers, each with prefixes; bypassEnabled / bypassPrefixes / spawnCapablePrefixes present.
|
|
* - AC-2 bypass-state flags match getSettings() and update after PATCH.
|
|
* - AC-12 anonymous request → 401/403 (no inventory leak).
|
|
*/
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { setupSettingsFixture } from "../_mocks/settings.ts";
|
|
import { makeManagementSessionRequest } from "../../helpers/managementSession.ts";
|
|
|
|
const fixture = setupSettingsFixture("authz-inventory");
|
|
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");
|
|
const runtime = await import("../../../src/lib/config/runtimeSettings.ts");
|
|
const inventoryRoute = await import("../../../src/app/api/settings/authz-inventory/route.ts");
|
|
const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts");
|
|
|
|
test.beforeEach(async () => {
|
|
await fixture.resetStorage();
|
|
apiKeysDb.resetApiKeyState();
|
|
runtime.resetRuntimeSettingsStateForTests();
|
|
});
|
|
|
|
test.after(() => {
|
|
core.resetDbInstance();
|
|
fixture.cleanup();
|
|
if (ORIGINAL_JWT_SECRET === undefined) delete process.env.JWT_SECRET;
|
|
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 ─────────────────────────────────────────────────────────
|
|
|
|
test("AC-1: GET returns 5 tiers with prefixes + bypass state envelope", async () => {
|
|
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
|
process.env.INITIAL_PASSWORD = "initial-pass-ac1";
|
|
await settingsDb.updateSettings({ requireLogin: true });
|
|
|
|
const request = await makeManagementSessionRequest(
|
|
"http://localhost/api/settings/authz-inventory",
|
|
{ method: "GET" }
|
|
);
|
|
const response = await inventoryRoute.GET(request);
|
|
assert.equal(response.status, 200);
|
|
|
|
const body = (await response.json()) as {
|
|
tiers: Array<{ name: string; prefixes: string[]; description: string; bypassable: boolean }>;
|
|
bypassEnabled: boolean;
|
|
bypassPrefixes: string[];
|
|
spawnCapablePrefixes: string[];
|
|
};
|
|
|
|
assert.equal(body.tiers.length, 5);
|
|
const names = body.tiers.map((t) => t.name).sort();
|
|
assert.deepEqual(names, ["ALWAYS_PROTECTED", "CLIENT_API", "LOCAL_ONLY", "MANAGEMENT", "PUBLIC"]);
|
|
|
|
const localOnly = body.tiers.find((t) => t.name === "LOCAL_ONLY");
|
|
assert.ok(localOnly);
|
|
assert.ok(localOnly!.prefixes.includes("/api/mcp/"));
|
|
assert.ok(localOnly!.prefixes.includes("/api/cli-tools/runtime/"));
|
|
assert.equal(localOnly!.bypassable, true);
|
|
|
|
const alwaysProtected = body.tiers.find((t) => t.name === "ALWAYS_PROTECTED");
|
|
assert.ok(alwaysProtected);
|
|
assert.ok(alwaysProtected!.prefixes.includes("/api/shutdown"));
|
|
assert.equal(alwaysProtected!.bypassable, false);
|
|
|
|
const clientApi = body.tiers.find((t) => t.name === "CLIENT_API");
|
|
assert.ok(clientApi);
|
|
assert.ok(clientApi!.prefixes.includes("/v1/"));
|
|
assert.ok(clientApi!.prefixes.includes("/api/v1/"));
|
|
assert.ok(clientApi!.prefixes.includes("/v1beta/"));
|
|
assert.ok(clientApi!.prefixes.includes("/api/v1beta/"));
|
|
|
|
// Every tier carries a non-empty description.
|
|
for (const tier of body.tiers) {
|
|
assert.ok(tier.description.length > 0, `tier ${tier.name} missing description`);
|
|
}
|
|
|
|
assert.ok(body.spawnCapablePrefixes.includes("/api/cli-tools/runtime/"));
|
|
});
|
|
|
|
// ─── AC-2 — flags match getSettings() pre- and post-mutation ──────────────
|
|
|
|
test("AC-2: bypassEnabled + bypassPrefixes reflect getSettings() (defaults)", async () => {
|
|
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
|
process.env.INITIAL_PASSWORD = "initial-pass-ac2a";
|
|
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 {
|
|
bypassEnabled: boolean;
|
|
bypassPrefixes: string[];
|
|
};
|
|
// Default snapshot: kill-switch ON, single prefix /api/mcp/.
|
|
assert.equal(body.bypassEnabled, true);
|
|
assert.deepEqual(body.bypassPrefixes, ["/api/mcp/"]);
|
|
});
|
|
|
|
test("AC-2: bypassEnabled flips after settings mutation", async () => {
|
|
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
|
process.env.INITIAL_PASSWORD = "initial-pass-ac2b";
|
|
await settingsDb.updateSettings({ requireLogin: true });
|
|
|
|
// Mutate directly through the settings DB (bypasses the password gate —
|
|
// we are not testing the gate here, only the inventory's reflection of
|
|
// the persisted state).
|
|
await settingsDb.updateSettings({ localOnlyManageScopeBypassEnabled: false });
|
|
|
|
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 { bypassEnabled: boolean };
|
|
assert.equal(body.bypassEnabled, false);
|
|
});
|
|
|
|
test("AC-2: bypassPrefixes additions land in the inventory", async () => {
|
|
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
|
process.env.INITIAL_PASSWORD = "initial-pass-ac2c";
|
|
await settingsDb.updateSettings({ requireLogin: true });
|
|
|
|
await settingsDb.updateSettings({
|
|
localOnlyManageScopeBypassPrefixes: ["/api/mcp/", "/api/mcp/v2/"],
|
|
});
|
|
|
|
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 { bypassPrefixes: string[] };
|
|
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 () => {
|
|
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
|
process.env.INITIAL_PASSWORD = "initial-pass-ac12";
|
|
// Bootstrap a password so isAuthRequired() returns true even on loopback.
|
|
await settingsDb.updateSettings({ requireLogin: true });
|
|
const { ensurePersistentManagementPasswordHash } =
|
|
await import("../../../src/lib/auth/managementPassword.ts");
|
|
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
|
|
|
|
const anonRequest = new Request("https://dashboard.example/api/settings/authz-inventory", {
|
|
method: "GET",
|
|
});
|
|
const response = await inventoryRoute.GET(anonRequest);
|
|
assert.ok(
|
|
response.status === 401 || response.status === 403,
|
|
`expected 401/403, got ${response.status}`
|
|
);
|
|
const body = (await response.json()) as { error?: { message?: string } };
|
|
// Should NOT leak the inventory shape.
|
|
assert.ok(!("tiers" in body));
|
|
});
|
|
|
|
test("AC-12: anonymous request with bogus Bearer → 403", async () => {
|
|
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
|
process.env.INITIAL_PASSWORD = "initial-pass-ac12b";
|
|
await settingsDb.updateSettings({ requireLogin: true });
|
|
const { ensurePersistentManagementPasswordHash } =
|
|
await import("../../../src/lib/auth/managementPassword.ts");
|
|
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
|
|
|
|
const bogus = new Request("https://dashboard.example/api/settings/authz-inventory", {
|
|
method: "GET",
|
|
headers: new Headers({ authorization: "Bearer not-a-real-key" }),
|
|
});
|
|
const response = await inventoryRoute.GET(bogus);
|
|
assert.equal(response.status, 403);
|
|
});
|
|
|
|
// ─── OQ-5 — any valid API key (no manage scope required) → 200 ────────────
|
|
|
|
test("OQ-5: any valid API key (read-only scope) → 200 inventory", async () => {
|
|
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
|
process.env.INITIAL_PASSWORD = "initial-pass-oq5";
|
|
await settingsDb.updateSettings({ requireLogin: true });
|
|
const { ensurePersistentManagementPasswordHash } =
|
|
await import("../../../src/lib/auth/managementPassword.ts");
|
|
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
|
|
|
|
// Key with NO manage scope — would be rejected by /api/settings PATCH,
|
|
// but the inventory read endpoint is intentionally one rung lower (OQ-5).
|
|
const created = await apiKeysDb.createApiKey("oq5-read", "machine-oq5", []);
|
|
const request = new Request("https://dashboard.example/api/settings/authz-inventory", {
|
|
method: "GET",
|
|
headers: new Headers({ authorization: `Bearer ${created.key}` }),
|
|
});
|
|
const response = await inventoryRoute.GET(request);
|
|
assert.equal(response.status, 200);
|
|
const body = (await response.json()) as { tiers: unknown[] };
|
|
assert.equal(body.tiers.length, 5);
|
|
});
|