308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
/**
|
|
* T-012 — Settings PATCH audit log.
|
|
*
|
|
* Covers spec AC-9 / AC-10 / AC-11 (plus idempotent no-op case):
|
|
* - AC-9 success diff row carries `action=settings.update`, target,
|
|
* actor, ip, and per-key {before, after} diff for every changed key.
|
|
* - AC-10 each rejection path (PASSWORD_REQUIRED, PASSWORD_MISMATCH,
|
|
* BYPASS_PREFIX_NOT_ALLOWED, zod validation failure) writes a
|
|
* `settings.update_failed` row with the matching `reason` code and
|
|
* NEVER persists settings.
|
|
* - AC-11 every changed key shows up in the success diff — not only
|
|
* security-impacting keys.
|
|
* - Idempotent PATCH (body matches stored state) writes NO row.
|
|
*
|
|
* Runs through the real PATCH handler + `setupSettingsFixture` mock so the
|
|
* production `updateSettings → applyRuntimeSettings → logAuditEvent` pipeline
|
|
* fires exactly as it does in deployment.
|
|
*
|
|
* INSUFFICIENT_SCOPE is intentionally NOT exercised here — per spec AC-13 it
|
|
* is rejected by `requireManagementAuth` before the audit-aware handler body
|
|
* runs, so no row is written.
|
|
*/
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { setupSettingsFixture } from "../_mocks/settings.ts";
|
|
import { makeManagementSessionRequest } from "../../helpers/managementSession.ts";
|
|
|
|
// Allocate fixture FIRST so DATA_DIR is set before any DB import resolves.
|
|
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() {
|
|
// `getAuditLog`'s `AuditLogEntry[]` return type now exposes `action`,
|
|
// `actor`, `target`, `status`, `details`, etc. directly — no local cast
|
|
// needed. See src/lib/compliance/index.ts.
|
|
return compliance.getAuditLog({ target: "settings", limit: 50 });
|
|
}
|
|
|
|
// ─── AC-9 — success diff row written ──────────────────────────────────────
|
|
|
|
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");
|
|
// Cookie session ⇒ actor=dashboard.
|
|
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,
|
|
});
|
|
});
|
|
|
|
// ─── AC-10 — failure rows for each rejection path ────────────────────────
|
|
|
|
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"));
|
|
// No raw payload values — only the key NAMES are recorded under
|
|
// `attempted_keys`. There must be no `before`/`after` or `diff` block on a
|
|
// failure row, and no other fields beyond reason+attempted_keys in details.
|
|
assert.deepEqual(
|
|
Object.keys(details).sort(),
|
|
["attempted_keys", "reason"],
|
|
"failure details must only contain reason + attempted_keys (no payload echo)"
|
|
);
|
|
|
|
// Persisted state unchanged.
|
|
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");
|
|
// Password attempt MUST NOT leak — only key names.
|
|
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");
|
|
|
|
// Snapshot untouched.
|
|
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");
|
|
});
|
|
|
|
// AC-13 sanity: INSUFFICIENT_SCOPE rejection happens upstream in
|
|
// requireManagementAuth and never reaches the handler body, so no audit row.
|
|
// We cover it implicitly by NOT having an INSUFFICIENT_SCOPE failure test —
|
|
// the route-level rejection is already covered by api-auth.test.ts.
|
|
|
|
// ─── AC-11 — diff covers every changed key (not only security keys) ──────
|
|
|
|
test("AC-11: diff records every changed key, including non-security keys", async () => {
|
|
await bootstrapWithPassword("initial-pass-ac11");
|
|
|
|
// Seed an initial value for a non-security key so the diff is meaningful.
|
|
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 }> };
|
|
// Security key AND multiple non-security keys must all be in diff.
|
|
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" });
|
|
});
|
|
|
|
// ─── Idempotent no-op writes NO row ──────────────────────────────────────
|
|
|
|
test("idempotent PATCH (body matches current state) writes NO audit row", async () => {
|
|
await bootstrapWithPassword("initial-pass-noop");
|
|
|
|
// Settings already at default — patch the same value back.
|
|
const response = await settingsRoute.PATCH(
|
|
await makeManagementSessionRequest("http://localhost/api/settings", {
|
|
method: "PATCH",
|
|
body: {
|
|
localOnlyManageScopeBypassEnabled: true, // same as default
|
|
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)}`
|
|
);
|
|
});
|
|
|
|
// ─── Multi-row sanity: success + failure sequence ─────────────────────────
|
|
|
|
test("sequence: failure then success produces exactly 1 failure row + 1 success row", async () => {
|
|
await bootstrapWithPassword("initial-pass-seq");
|
|
|
|
// 1) failure
|
|
await settingsRoute.PATCH(
|
|
await makeManagementSessionRequest("http://localhost/api/settings", {
|
|
method: "PATCH",
|
|
body: { localOnlyManageScopeBypassEnabled: false, currentPassword: "wrong" },
|
|
})
|
|
);
|
|
// 2) success
|
|
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);
|
|
});
|