From aeb2adf240f3720c655f8945f6adfce1ff349acb Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 14:46:46 -0700 Subject: [PATCH] fix(ci): split redact snapshot restore coverage --- src/config/redact-snapshot.restore.test.ts | 267 +++++++++++++++++++++ src/config/redact-snapshot.test.ts | 226 ----------------- test/fixtures/test-parallel.behavior.json | 4 + 3 files changed, 271 insertions(+), 226 deletions(-) create mode 100644 src/config/redact-snapshot.restore.test.ts diff --git a/src/config/redact-snapshot.restore.test.ts b/src/config/redact-snapshot.restore.test.ts new file mode 100644 index 00000000000..1dce823d2b4 --- /dev/null +++ b/src/config/redact-snapshot.restore.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it } from "vitest"; +import { + REDACTED_SENTINEL, + redactConfigSnapshot, + restoreRedactedValues as restoreRedactedValues_orig, +} from "./redact-snapshot.js"; +import { __test__ } from "./schema.hints.js"; +import type { ConfigUiHints } from "./schema.js"; +import type { ConfigFileSnapshot } from "./types.openclaw.js"; +import { OpenClawSchema } from "./zod-schema.js"; + +const { mapSensitivePaths } = __test__; +const mainSchemaHints = mapSensitivePaths(OpenClawSchema, "", {}); + +type TestSnapshot> = ConfigFileSnapshot & { + parsed: TConfig; + resolved: TConfig; + config: TConfig; +}; + +function makeSnapshot>( + config: TConfig, + raw?: string, +): TestSnapshot { + return { + path: "/home/user/.openclaw/config.json5", + exists: true, + raw: raw ?? JSON.stringify(config), + parsed: config, + resolved: config as ConfigFileSnapshot["resolved"], + valid: true, + config: config as ConfigFileSnapshot["config"], + hash: "abc123", + issues: [], + warnings: [], + legacyIssues: [], + } as unknown as TestSnapshot; +} + +function restoreRedactedValues( + incoming: unknown, + original: TOriginal, + hints?: ConfigUiHints, +): TOriginal { + const result = restoreRedactedValues_orig(incoming, original, hints); + expect(result.ok).toBe(true); + return result.result as TOriginal; +} + +describe("restoreRedactedValues", () => { + it("restores redacted URL endpoint fields on round-trip", () => { + const incoming = { + models: { + providers: { + openai: { baseUrl: REDACTED_SENTINEL }, + }, + }, + }; + const original = { + models: { + providers: { + openai: { baseUrl: "https://alice:secret@example.test/v1" }, + }, + }, + }; + const result = restoreRedactedValues(incoming, original, mainSchemaHints); + expect(result.models.providers.openai.baseUrl).toBe("https://alice:secret@example.test/v1"); + }); + + it("restores sentinel values from original config", () => { + const incoming = { + gateway: { auth: { token: REDACTED_SENTINEL } }, + }; + const original = { + gateway: { auth: { token: "real-secret-token-value" } }, + }; + const result = restoreRedactedValues(incoming, original) as typeof incoming; + expect(result.gateway.auth.token).toBe("real-secret-token-value"); + }); + + it("preserves explicitly changed sensitive values", () => { + const incoming = { + gateway: { auth: { token: "new-token-value-from-user" } }, + }; + const original = { + gateway: { auth: { token: "old-token-value" } }, + }; + const result = restoreRedactedValues(incoming, original) as typeof incoming; + expect(result.gateway.auth.token).toBe("new-token-value-from-user"); + }); + + it("preserves non-sensitive fields unchanged", () => { + const incoming = { + ui: { seamColor: "#ff0000" }, + gateway: { port: 9999, auth: { token: REDACTED_SENTINEL } }, + }; + const original = { + ui: { seamColor: "#0088cc" }, + gateway: { port: 18789, auth: { token: "real-secret" } }, + }; + const result = restoreRedactedValues(incoming, original) as typeof incoming; + expect(result.ui.seamColor).toBe("#ff0000"); + expect(result.gateway.port).toBe(9999); + expect(result.gateway.auth.token).toBe("real-secret"); + }); + + it("handles deeply nested sentinel restoration", () => { + const incoming = { + channels: { + slack: { + accounts: { + ws1: { botToken: REDACTED_SENTINEL }, + ws2: { botToken: "user-typed-new-token-value" }, + }, + }, + }, + }; + const original = { + channels: { + slack: { + accounts: { + ws1: { botToken: "original-ws1-token-value" }, + ws2: { botToken: "original-ws2-token-value" }, + }, + }, + }, + }; + const result = restoreRedactedValues(incoming, original) as typeof incoming; + expect(result.channels.slack.accounts.ws1.botToken).toBe("original-ws1-token-value"); + expect(result.channels.slack.accounts.ws2.botToken).toBe("user-typed-new-token-value"); + }); + + it("handles missing original gracefully", () => { + const incoming = { + channels: { newChannel: { token: REDACTED_SENTINEL } }, + }; + const original = {}; + expect(restoreRedactedValues_orig(incoming, original).ok).toBe(false); + }); + + it("rejects invalid restore inputs", () => { + const invalidInputs = [null, undefined, "token-value"] as const; + for (const input of invalidInputs) { + const result = restoreRedactedValues_orig(input, { token: "x" }); + expect(result.ok).toBe(false); + } + expect(restoreRedactedValues_orig("token-value", { token: "x" })).toEqual({ + ok: false, + error: "input not an object", + }); + }); + + it("returns a human-readable error when sentinel cannot be restored", () => { + const incoming = { + channels: { newChannel: { token: REDACTED_SENTINEL } }, + }; + const result = restoreRedactedValues_orig(incoming, {}); + expect(result.ok).toBe(false); + expect(result.humanReadableMessage).toContain(REDACTED_SENTINEL); + expect(result.humanReadableMessage).toContain("channels.newChannel.token"); + }); + + it("keeps unmatched wildcard array entries unchanged outside extension paths", () => { + const hints: ConfigUiHints = { + "custom.*": { sensitive: true }, + }; + const incoming = { + custom: { items: [REDACTED_SENTINEL] }, + }; + const original = { + custom: { items: ["original-secret-value"] }, + }; + const result = restoreRedactedValues(incoming, original, hints) as typeof incoming; + expect(result.custom.items[0]).toBe(REDACTED_SENTINEL); + }); + + it("round-trips config through redact → restore", () => { + const originalConfig = { + gateway: { auth: { token: "gateway-auth-secret-token-value" }, port: 18789 }, + channels: { + slack: { botToken: "fake-slack-token-placeholder-value" }, + telegram: { + botToken: "fake-telegram-token-placeholder-value", + webhookSecret: "fake-tg-secret-placeholder-value", + }, + }, + models: { + providers: { + openai: { + apiKey: "sk-proj-fake-openai-api-key-value", + baseUrl: "https://api.openai.com", + }, + }, + }, + ui: { seamColor: "#0088cc" }, + }; + const snapshot = makeSnapshot(originalConfig); + const redacted = redactConfigSnapshot(snapshot); + const restored = restoreRedactedValues(redacted.config, snapshot.config); + expect(restored).toEqual(originalConfig); + }); + + it("round-trips with uiHints for custom sensitive fields", () => { + const hints: ConfigUiHints = { + "custom.myApiKey": { sensitive: true }, + "custom.displayName": { sensitive: false }, + }; + const originalConfig = { + custom: { myApiKey: "secret-custom-api-key-value", displayName: "My Bot" }, + }; + const snapshot = makeSnapshot(originalConfig); + const redacted = redactConfigSnapshot(snapshot, hints); + const custom = (redacted.config as typeof originalConfig).custom as Record; + expect(custom.myApiKey).toBe(REDACTED_SENTINEL); + expect(custom.displayName).toBe("My Bot"); + + const restored = restoreRedactedValues( + redacted.config, + snapshot.config, + hints, + ) as typeof originalConfig; + expect(restored).toEqual(originalConfig); + }); + + it("restores with uiHints respecting sensitive:false override", () => { + const hints: ConfigUiHints = { + "gateway.auth.token": { sensitive: false }, + }; + const incoming = { + gateway: { auth: { token: REDACTED_SENTINEL } }, + }; + const original = { + gateway: { auth: { token: "real-secret" } }, + }; + const result = restoreRedactedValues(incoming, original, hints) as typeof incoming; + expect(result.gateway.auth.token).toBe(REDACTED_SENTINEL); + }); + + it("restores array items using wildcard uiHints", () => { + const hints: ConfigUiHints = { + "channels.slack.accounts[].botToken": { sensitive: true }, + }; + const incoming = { + channels: { + slack: { + accounts: [ + { botToken: REDACTED_SENTINEL }, + { botToken: "user-provided-new-token-value" }, + ], + }, + }, + }; + const original = { + channels: { + slack: { + accounts: [ + { botToken: "original-token-first-account" }, + { botToken: "original-token-second-account" }, + ], + }, + }, + }; + const result = restoreRedactedValues(incoming, original, hints) as typeof incoming; + expect(result.channels.slack.accounts[0].botToken).toBe("original-token-first-account"); + expect(result.channels.slack.accounts[1].botToken).toBe("user-provided-new-token-value"); + }); +}); diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index 89aa4e1d121..d4c14b29ae6 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -919,232 +919,6 @@ describe("redactConfigSnapshot", () => { }); }); -describe("restoreRedactedValues", () => { - it("restores redacted URL endpoint fields on round-trip", () => { - const incoming = { - models: { - providers: { - openai: { baseUrl: REDACTED_SENTINEL }, - }, - }, - }; - const original = { - models: { - providers: { - openai: { baseUrl: "https://alice:secret@example.test/v1" }, - }, - }, - }; - const result = restoreRedactedValues(incoming, original, mainSchemaHints); - expect(result.models.providers.openai.baseUrl).toBe("https://alice:secret@example.test/v1"); - }); - - it("restores sentinel values from original config", () => { - const incoming = { - gateway: { auth: { token: REDACTED_SENTINEL } }, - }; - const original = { - gateway: { auth: { token: "real-secret-token-value" } }, - }; - const result = restoreRedactedValues(incoming, original) as typeof incoming; - expect(result.gateway.auth.token).toBe("real-secret-token-value"); - }); - - it("preserves explicitly changed sensitive values", () => { - const incoming = { - gateway: { auth: { token: "new-token-value-from-user" } }, - }; - const original = { - gateway: { auth: { token: "old-token-value" } }, - }; - const result = restoreRedactedValues(incoming, original) as typeof incoming; - expect(result.gateway.auth.token).toBe("new-token-value-from-user"); - }); - - it("preserves non-sensitive fields unchanged", () => { - const incoming = { - ui: { seamColor: "#ff0000" }, - gateway: { port: 9999, auth: { token: REDACTED_SENTINEL } }, - }; - const original = { - ui: { seamColor: "#0088cc" }, - gateway: { port: 18789, auth: { token: "real-secret" } }, - }; - const result = restoreRedactedValues(incoming, original) as typeof incoming; - expect(result.ui.seamColor).toBe("#ff0000"); - expect(result.gateway.port).toBe(9999); - expect(result.gateway.auth.token).toBe("real-secret"); - }); - - it("handles deeply nested sentinel restoration", () => { - const incoming = { - channels: { - slack: { - accounts: { - ws1: { botToken: REDACTED_SENTINEL }, - ws2: { botToken: "user-typed-new-token-value" }, - }, - }, - }, - }; - const original = { - channels: { - slack: { - accounts: { - ws1: { botToken: "original-ws1-token-value" }, - ws2: { botToken: "original-ws2-token-value" }, - }, - }, - }, - }; - const result = restoreRedactedValues(incoming, original) as typeof incoming; - expect(result.channels.slack.accounts.ws1.botToken).toBe("original-ws1-token-value"); - expect(result.channels.slack.accounts.ws2.botToken).toBe("user-typed-new-token-value"); - }); - - it("handles missing original gracefully", () => { - const incoming = { - channels: { newChannel: { token: REDACTED_SENTINEL } }, - }; - const original = {}; - expect(restoreRedactedValues_orig(incoming, original).ok).toBe(false); - }); - - it("rejects invalid restore inputs", () => { - const invalidInputs = [null, undefined, "token-value"] as const; - for (const input of invalidInputs) { - const result = restoreRedactedValues_orig(input, { token: "x" }); - expect(result.ok).toBe(false); - } - expect(restoreRedactedValues_orig("token-value", { token: "x" })).toEqual({ - ok: false, - error: "input not an object", - }); - }); - - it("returns a human-readable error when sentinel cannot be restored", () => { - const incoming = { - channels: { newChannel: { token: REDACTED_SENTINEL } }, - }; - const result = restoreRedactedValues_orig(incoming, {}); - expect(result.ok).toBe(false); - expect(result.humanReadableMessage).toContain(REDACTED_SENTINEL); - expect(result.humanReadableMessage).toContain("channels.newChannel.token"); - }); - - it("keeps unmatched wildcard array entries unchanged outside extension paths", () => { - const hints: ConfigUiHints = { - "custom.*": { sensitive: true }, - }; - const incoming = { - custom: { items: [REDACTED_SENTINEL] }, - }; - const original = { - custom: { items: ["original-secret-value"] }, - }; - const result = restoreRedactedValues(incoming, original, hints) as typeof incoming; - expect(result.custom.items[0]).toBe(REDACTED_SENTINEL); - }); - - it("round-trips config through redact → restore", () => { - const originalConfig = { - gateway: { auth: { token: "gateway-auth-secret-token-value" }, port: 18789 }, - channels: { - slack: { botToken: "fake-slack-token-placeholder-value" }, - telegram: { - botToken: "fake-telegram-token-placeholder-value", - webhookSecret: "fake-tg-secret-placeholder-value", - }, - }, - models: { - providers: { - openai: { - apiKey: "sk-proj-fake-openai-api-key-value", - baseUrl: "https://api.openai.com", - }, - }, - }, - ui: { seamColor: "#0088cc" }, - }; - const snapshot = makeSnapshot(originalConfig); - - // Redact (simulates config.get response) - const redacted = redactConfigSnapshot(snapshot); - - // Restore (simulates config.set before write) - const restored = restoreRedactedValues(redacted.config, snapshot.config); - - expect(restored).toEqual(originalConfig); - }); - - it("round-trips with uiHints for custom sensitive fields", () => { - const hints: ConfigUiHints = { - "custom.myApiKey": { sensitive: true }, - "custom.displayName": { sensitive: false }, - }; - const originalConfig = { - custom: { myApiKey: "secret-custom-api-key-value", displayName: "My Bot" }, - }; - const snapshot = makeSnapshot(originalConfig); - const redacted = redactConfigSnapshot(snapshot, hints); - const custom = (redacted.config as typeof originalConfig).custom as Record; - expect(custom.myApiKey).toBe(REDACTED_SENTINEL); - expect(custom.displayName).toBe("My Bot"); - - const restored = restoreRedactedValues( - redacted.config, - snapshot.config, - hints, - ) as typeof originalConfig; - expect(restored).toEqual(originalConfig); - }); - - it("restores with uiHints respecting sensitive:false override", () => { - const hints: ConfigUiHints = { - "gateway.auth.token": { sensitive: false }, - }; - const incoming = { - gateway: { auth: { token: REDACTED_SENTINEL } }, - }; - const original = { - gateway: { auth: { token: "real-secret" } }, - }; - // With sensitive:false, the sentinel is NOT on a sensitive path, - // so restore should NOT replace it (it's treated as a literal value) - const result = restoreRedactedValues(incoming, original, hints) as typeof incoming; - expect(result.gateway.auth.token).toBe(REDACTED_SENTINEL); - }); - - it("restores array items using wildcard uiHints", () => { - const hints: ConfigUiHints = { - "channels.slack.accounts[].botToken": { sensitive: true }, - }; - const incoming = { - channels: { - slack: { - accounts: [ - { botToken: REDACTED_SENTINEL }, - { botToken: "user-provided-new-token-value" }, - ], - }, - }, - }; - const original = { - channels: { - slack: { - accounts: [ - { botToken: "original-token-first-account" }, - { botToken: "original-token-second-account" }, - ], - }, - }, - }; - const result = restoreRedactedValues(incoming, original, hints) as typeof incoming; - expect(result.channels.slack.accounts[0].botToken).toBe("original-token-first-account"); - expect(result.channels.slack.accounts[1].botToken).toBe("user-provided-new-token-value"); - }); -}); - describe("realredactConfigSnapshot_real", () => { it("main schema redact works (samples)", () => { const schema = OpenClawSchema.toJSONSchema({ diff --git a/test/fixtures/test-parallel.behavior.json b/test/fixtures/test-parallel.behavior.json index bc23a5ab88c..15bb986fa8d 100644 --- a/test/fixtures/test-parallel.behavior.json +++ b/test/fixtures/test-parallel.behavior.json @@ -51,6 +51,10 @@ "file": "src/config/redact-snapshot.test.ts", "reason": "Snapshot redaction coverage produced a large retained heap jump in unit-fast on Linux CI." }, + { + "file": "src/config/redact-snapshot.restore.test.ts", + "reason": "Snapshot restore coverage retains a broad schema/redaction graph and is safer outside the shared lane." + }, { "file": "src/infra/outbound/message-action-runner.media.test.ts", "reason": "Outbound media action coverage retained a large media/plugin graph in unit-fast."