File size: 12,488 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
/**
 * 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);
});