mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-20 22:40:58 +00:00
fix(ci): split redact snapshot restore coverage
This commit is contained in:
267
src/config/redact-snapshot.restore.test.ts
Normal file
267
src/config/redact-snapshot.restore.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
4
test/fixtures/test-parallel.behavior.json
vendored
4
test/fixtures/test-parallel.behavior.json
vendored
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user