fix(ci): split redact snapshot restore coverage

This commit is contained in:
Vincent Koc
2026-03-19 14:46:46 -07:00
parent 38807fff20
commit aeb2adf240
3 changed files with 271 additions and 226 deletions

View File

@@ -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<TConfig extends Record<string, unknown>> = ConfigFileSnapshot & {
parsed: TConfig;
resolved: TConfig;
config: TConfig;
};
function makeSnapshot<TConfig extends Record<string, unknown>>(
config: TConfig,
raw?: string,
): TestSnapshot<TConfig> {
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<TConfig>;
}
function restoreRedactedValues<TOriginal>(
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<string, string>;
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");
});
});

View File

@@ -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<string, string>;
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({

View File

@@ -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."