| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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("settings-audit");
|
| process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1";
|
|
|
| const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
|
| const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
|
|
|
| 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 settingsRoute = await import("../../../src/app/api/settings/route.ts");
|
| const compliance = await import("../../../src/lib/compliance/index.ts");
|
| const managementPassword = await import("../../../src/lib/auth/managementPassword.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_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD;
|
| else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
|
| if (ORIGINAL_JWT_SECRET === undefined) delete process.env.JWT_SECRET;
|
| else process.env.JWT_SECRET = ORIGINAL_JWT_SECRET;
|
| });
|
|
|
| async function bootstrapWithPassword(password: string): Promise<void> {
|
| process.env.JWT_SECRET = "test-jwt-secret-settings-audit";
|
| process.env.INITIAL_PASSWORD = password;
|
| await settingsDb.updateSettings({ requireLogin: true });
|
| await managementPassword.ensurePersistentManagementPasswordHash({
|
| source: "test.bootstrap",
|
| });
|
| }
|
|
|
| function settingsRows() {
|
|
|
|
|
|
|
| return compliance.getAuditLog({ target: "settings", limit: 50 });
|
| }
|
|
|
|
|
|
|
| test("AC-9: successful PATCH writes settings.update with diff of changed keys", async () => {
|
| await bootstrapWithPassword("initial-pass-ac9");
|
| const before = await settingsDb.getSettings();
|
| assert.equal(before.localOnlyManageScopeBypassEnabled, true);
|
|
|
| const response = await settingsRoute.PATCH(
|
| await makeManagementSessionRequest("http://localhost/api/settings", {
|
| method: "PATCH",
|
| body: {
|
| localOnlyManageScopeBypassEnabled: false,
|
| currentPassword: "initial-pass-ac9",
|
| },
|
| })
|
| );
|
|
|
| assert.equal(response.status, 200);
|
|
|
| const rows = settingsRows();
|
| const successRows = rows.filter((r) => r.action === "settings.update");
|
| assert.equal(successRows.length, 1, `expected 1 success row, got: ${JSON.stringify(rows)}`);
|
| const row = successRows[0];
|
| assert.equal(row.target, "settings");
|
| assert.equal(row.status, "success");
|
| assert.equal(row.resource_type, "settings");
|
|
|
| assert.equal(row.actor, "dashboard");
|
| const details = row.details as { diff: Record<string, { before: unknown; after: unknown }> };
|
| assert.ok(details && typeof details === "object", "details must be parsed JSON");
|
| assert.ok(details.diff, "diff present");
|
| assert.deepEqual(details.diff.localOnlyManageScopeBypassEnabled, {
|
| before: true,
|
| after: false,
|
| });
|
| });
|
|
|
|
|
|
|
| test("AC-10a: PASSWORD_REQUIRED failure writes settings.update_failed", async () => {
|
| await bootstrapWithPassword("initial-pass-ac10a");
|
|
|
| const response = await settingsRoute.PATCH(
|
| await makeManagementSessionRequest("http://localhost/api/settings", {
|
| method: "PATCH",
|
| body: { localOnlyManageScopeBypassEnabled: false },
|
| })
|
| );
|
|
|
| assert.equal(response.status, 400);
|
| const rows = settingsRows().filter((r) => r.action === "settings.update_failed");
|
| assert.equal(rows.length, 1);
|
| const details = rows[0].details as { reason: string; attempted_keys: string[] };
|
| assert.equal(details.reason, "PASSWORD_REQUIRED");
|
| assert.ok(details.attempted_keys.includes("localOnlyManageScopeBypassEnabled"));
|
|
|
|
|
|
|
| assert.deepEqual(
|
| Object.keys(details).sort(),
|
| ["attempted_keys", "reason"],
|
| "failure details must only contain reason + attempted_keys (no payload echo)"
|
| );
|
|
|
|
|
| const after = await settingsDb.getSettings();
|
| assert.equal(after.localOnlyManageScopeBypassEnabled, true);
|
| });
|
|
|
| test("AC-10b: PASSWORD_MISMATCH failure writes settings.update_failed", async () => {
|
| await bootstrapWithPassword("initial-pass-ac10b");
|
|
|
| const response = await settingsRoute.PATCH(
|
| await makeManagementSessionRequest("http://localhost/api/settings", {
|
| method: "PATCH",
|
| body: {
|
| localOnlyManageScopeBypassEnabled: false,
|
| currentPassword: "definitely-wrong",
|
| },
|
| })
|
| );
|
|
|
| assert.equal(response.status, 401);
|
| const rows = settingsRows().filter((r) => r.action === "settings.update_failed");
|
| assert.equal(rows.length, 1);
|
| const details = rows[0].details as { reason: string; attempted_keys: string[] };
|
| assert.equal(details.reason, "PASSWORD_MISMATCH");
|
|
|
| const serialized = JSON.stringify(rows[0]);
|
| assert.equal(
|
| serialized.includes("definitely-wrong"),
|
| false,
|
| "rejected currentPassword must not appear in audit row"
|
| );
|
|
|
| const after = await settingsDb.getSettings();
|
| assert.equal(after.localOnlyManageScopeBypassEnabled, true);
|
| });
|
|
|
| test("AC-10c: BYPASS_PREFIX_NOT_ALLOWED failure writes settings.update_failed", async () => {
|
| await bootstrapWithPassword("initial-pass-ac10c");
|
|
|
| const response = await settingsRoute.PATCH(
|
| await makeManagementSessionRequest("http://localhost/api/settings", {
|
| method: "PATCH",
|
| body: {
|
| localOnlyManageScopeBypassPrefixes: ["/api/mcp/", "/api/cli-tools/runtime/"],
|
| currentPassword: "initial-pass-ac10c",
|
| },
|
| })
|
| );
|
|
|
| assert.equal(response.status, 400);
|
| const rows = settingsRows().filter((r) => r.action === "settings.update_failed");
|
| assert.equal(rows.length, 1);
|
| const details = rows[0].details as { reason: string };
|
| assert.equal(details.reason, "BYPASS_PREFIX_NOT_ALLOWED");
|
|
|
|
|
| const after = await settingsDb.getSettings();
|
| assert.deepEqual(after.localOnlyManageScopeBypassPrefixes, ["/api/mcp/"]);
|
| });
|
|
|
| test("AC-10d: zod validation failure (wrong type) writes settings.update_failed", async () => {
|
| await bootstrapWithPassword("initial-pass-ac10d");
|
|
|
| const response = await settingsRoute.PATCH(
|
| await makeManagementSessionRequest("http://localhost/api/settings", {
|
| method: "PATCH",
|
| body: {
|
| localOnlyManageScopeBypassEnabled: "definitely-not-a-boolean",
|
| currentPassword: "initial-pass-ac10d",
|
| },
|
| })
|
| );
|
|
|
| assert.equal(response.status, 400);
|
| const rows = settingsRows().filter((r) => r.action === "settings.update_failed");
|
| assert.equal(rows.length, 1);
|
| const details = rows[0].details as { reason: string };
|
| assert.equal(details.reason, "VALIDATION_FAILED");
|
| });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| test("AC-11: diff records every changed key, including non-security keys", async () => {
|
| await bootstrapWithPassword("initial-pass-ac11");
|
|
|
|
|
| await settingsDb.updateSettings({ theme: "light", instanceName: "before" });
|
|
|
| const response = await settingsRoute.PATCH(
|
| await makeManagementSessionRequest("http://localhost/api/settings", {
|
| method: "PATCH",
|
| body: {
|
| theme: "dark",
|
| instanceName: "after",
|
| localOnlyManageScopeBypassEnabled: false,
|
| currentPassword: "initial-pass-ac11",
|
| },
|
| })
|
| );
|
|
|
| assert.equal(response.status, 200);
|
| const rows = settingsRows().filter((r) => r.action === "settings.update");
|
| assert.equal(rows.length, 1);
|
| const details = rows[0].details as { diff: Record<string, { before: unknown; after: unknown }> };
|
|
|
| assert.ok(details.diff.localOnlyManageScopeBypassEnabled, "security key in diff");
|
| assert.ok(details.diff.theme, "theme (non-security) in diff");
|
| assert.ok(details.diff.instanceName, "instanceName (non-security) in diff");
|
| assert.deepEqual(details.diff.theme, { before: "light", after: "dark" });
|
| assert.deepEqual(details.diff.instanceName, { before: "before", after: "after" });
|
| });
|
|
|
|
|
|
|
| test("idempotent PATCH (body matches current state) writes NO audit row", async () => {
|
| await bootstrapWithPassword("initial-pass-noop");
|
|
|
|
|
| const response = await settingsRoute.PATCH(
|
| await makeManagementSessionRequest("http://localhost/api/settings", {
|
| method: "PATCH",
|
| body: {
|
| localOnlyManageScopeBypassEnabled: true,
|
| currentPassword: "initial-pass-noop",
|
| },
|
| })
|
| );
|
|
|
| assert.equal(response.status, 200);
|
| const rows = settingsRows();
|
| assert.equal(
|
| rows.length,
|
| 0,
|
| `idempotent PATCH must not emit an audit row, got: ${JSON.stringify(rows)}`
|
| );
|
| });
|
|
|
|
|
|
|
| test("sequence: failure then success produces exactly 1 failure row + 1 success row", async () => {
|
| await bootstrapWithPassword("initial-pass-seq");
|
|
|
|
|
| await settingsRoute.PATCH(
|
| await makeManagementSessionRequest("http://localhost/api/settings", {
|
| method: "PATCH",
|
| body: { localOnlyManageScopeBypassEnabled: false, currentPassword: "wrong" },
|
| })
|
| );
|
|
|
| await settingsRoute.PATCH(
|
| await makeManagementSessionRequest("http://localhost/api/settings", {
|
| method: "PATCH",
|
| body: {
|
| localOnlyManageScopeBypassEnabled: false,
|
| currentPassword: "initial-pass-seq",
|
| },
|
| })
|
| );
|
|
|
| const rows = settingsRows();
|
| const failures = rows.filter((r) => r.action === "settings.update_failed");
|
| const successes = rows.filter((r) => r.action === "settings.update");
|
| assert.equal(failures.length, 1);
|
| assert.equal(successes.length, 1);
|
| });
|
|
|