Skip to content

Commit aeb2adf

Browse files
committed
fix(ci): split redact snapshot restore coverage
1 parent 38807ff commit aeb2adf

3 files changed

Lines changed: 271 additions & 226 deletions

File tree

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
REDACTED_SENTINEL,
4+
redactConfigSnapshot,
5+
restoreRedactedValues as restoreRedactedValues_orig,
6+
} from "./redact-snapshot.js";
7+
import { __test__ } from "./schema.hints.js";
8+
import type { ConfigUiHints } from "./schema.js";
9+
import type { ConfigFileSnapshot } from "./types.openclaw.js";
10+
import { OpenClawSchema } from "./zod-schema.js";
11+
12+
const { mapSensitivePaths } = __test__;
13+
const mainSchemaHints = mapSensitivePaths(OpenClawSchema, "", {});
14+
15+
type TestSnapshot<TConfig extends Record<string, unknown>> = ConfigFileSnapshot & {
16+
parsed: TConfig;
17+
resolved: TConfig;
18+
config: TConfig;
19+
};
20+
21+
function makeSnapshot<TConfig extends Record<string, unknown>>(
22+
config: TConfig,
23+
raw?: string,
24+
): TestSnapshot<TConfig> {
25+
return {
26+
path: "/home/user/.openclaw/config.json5",
27+
exists: true,
28+
raw: raw ?? JSON.stringify(config),
29+
parsed: config,
30+
resolved: config as ConfigFileSnapshot["resolved"],
31+
valid: true,
32+
config: config as ConfigFileSnapshot["config"],
33+
hash: "abc123",
34+
issues: [],
35+
warnings: [],
36+
legacyIssues: [],
37+
} as unknown as TestSnapshot<TConfig>;
38+
}
39+
40+
function restoreRedactedValues<TOriginal>(
41+
incoming: unknown,
42+
original: TOriginal,
43+
hints?: ConfigUiHints,
44+
): TOriginal {
45+
const result = restoreRedactedValues_orig(incoming, original, hints);
46+
expect(result.ok).toBe(true);
47+
return result.result as TOriginal;
48+
}
49+
50+
describe("restoreRedactedValues", () => {
51+
it("restores redacted URL endpoint fields on round-trip", () => {
52+
const incoming = {
53+
models: {
54+
providers: {
55+
openai: { baseUrl: REDACTED_SENTINEL },
56+
},
57+
},
58+
};
59+
const original = {
60+
models: {
61+
providers: {
62+
openai: { baseUrl: "https://alice:secret@example.test/v1" },
63+
},
64+
},
65+
};
66+
const result = restoreRedactedValues(incoming, original, mainSchemaHints);
67+
expect(result.models.providers.openai.baseUrl).toBe("https://alice:secret@example.test/v1");
68+
});
69+
70+
it("restores sentinel values from original config", () => {
71+
const incoming = {
72+
gateway: { auth: { token: REDACTED_SENTINEL } },
73+
};
74+
const original = {
75+
gateway: { auth: { token: "real-secret-token-value" } },
76+
};
77+
const result = restoreRedactedValues(incoming, original) as typeof incoming;
78+
expect(result.gateway.auth.token).toBe("real-secret-token-value");
79+
});
80+
81+
it("preserves explicitly changed sensitive values", () => {
82+
const incoming = {
83+
gateway: { auth: { token: "new-token-value-from-user" } },
84+
};
85+
const original = {
86+
gateway: { auth: { token: "old-token-value" } },
87+
};
88+
const result = restoreRedactedValues(incoming, original) as typeof incoming;
89+
expect(result.gateway.auth.token).toBe("new-token-value-from-user");
90+
});
91+
92+
it("preserves non-sensitive fields unchanged", () => {
93+
const incoming = {
94+
ui: { seamColor: "#ff0000" },
95+
gateway: { port: 9999, auth: { token: REDACTED_SENTINEL } },
96+
};
97+
const original = {
98+
ui: { seamColor: "#0088cc" },
99+
gateway: { port: 18789, auth: { token: "real-secret" } },
100+
};
101+
const result = restoreRedactedValues(incoming, original) as typeof incoming;
102+
expect(result.ui.seamColor).toBe("#ff0000");
103+
expect(result.gateway.port).toBe(9999);
104+
expect(result.gateway.auth.token).toBe("real-secret");
105+
});
106+
107+
it("handles deeply nested sentinel restoration", () => {
108+
const incoming = {
109+
channels: {
110+
slack: {
111+
accounts: {
112+
ws1: { botToken: REDACTED_SENTINEL },
113+
ws2: { botToken: "user-typed-new-token-value" },
114+
},
115+
},
116+
},
117+
};
118+
const original = {
119+
channels: {
120+
slack: {
121+
accounts: {
122+
ws1: { botToken: "original-ws1-token-value" },
123+
ws2: { botToken: "original-ws2-token-value" },
124+
},
125+
},
126+
},
127+
};
128+
const result = restoreRedactedValues(incoming, original) as typeof incoming;
129+
expect(result.channels.slack.accounts.ws1.botToken).toBe("original-ws1-token-value");
130+
expect(result.channels.slack.accounts.ws2.botToken).toBe("user-typed-new-token-value");
131+
});
132+
133+
it("handles missing original gracefully", () => {
134+
const incoming = {
135+
channels: { newChannel: { token: REDACTED_SENTINEL } },
136+
};
137+
const original = {};
138+
expect(restoreRedactedValues_orig(incoming, original).ok).toBe(false);
139+
});
140+
141+
it("rejects invalid restore inputs", () => {
142+
const invalidInputs = [null, undefined, "token-value"] as const;
143+
for (const input of invalidInputs) {
144+
const result = restoreRedactedValues_orig(input, { token: "x" });
145+
expect(result.ok).toBe(false);
146+
}
147+
expect(restoreRedactedValues_orig("token-value", { token: "x" })).toEqual({
148+
ok: false,
149+
error: "input not an object",
150+
});
151+
});
152+
153+
it("returns a human-readable error when sentinel cannot be restored", () => {
154+
const incoming = {
155+
channels: { newChannel: { token: REDACTED_SENTINEL } },
156+
};
157+
const result = restoreRedactedValues_orig(incoming, {});
158+
expect(result.ok).toBe(false);
159+
expect(result.humanReadableMessage).toContain(REDACTED_SENTINEL);
160+
expect(result.humanReadableMessage).toContain("channels.newChannel.token");
161+
});
162+
163+
it("keeps unmatched wildcard array entries unchanged outside extension paths", () => {
164+
const hints: ConfigUiHints = {
165+
"custom.*": { sensitive: true },
166+
};
167+
const incoming = {
168+
custom: { items: [REDACTED_SENTINEL] },
169+
};
170+
const original = {
171+
custom: { items: ["original-secret-value"] },
172+
};
173+
const result = restoreRedactedValues(incoming, original, hints) as typeof incoming;
174+
expect(result.custom.items[0]).toBe(REDACTED_SENTINEL);
175+
});
176+
177+
it("round-trips config through redact → restore", () => {
178+
const originalConfig = {
179+
gateway: { auth: { token: "gateway-auth-secret-token-value" }, port: 18789 },
180+
channels: {
181+
slack: { botToken: "fake-slack-token-placeholder-value" },
182+
telegram: {
183+
botToken: "fake-telegram-token-placeholder-value",
184+
webhookSecret: "fake-tg-secret-placeholder-value",
185+
},
186+
},
187+
models: {
188+
providers: {
189+
openai: {
190+
apiKey: "sk-proj-fake-openai-api-key-value",
191+
baseUrl: "https://api.openai.com",
192+
},
193+
},
194+
},
195+
ui: { seamColor: "#0088cc" },
196+
};
197+
const snapshot = makeSnapshot(originalConfig);
198+
const redacted = redactConfigSnapshot(snapshot);
199+
const restored = restoreRedactedValues(redacted.config, snapshot.config);
200+
expect(restored).toEqual(originalConfig);
201+
});
202+
203+
it("round-trips with uiHints for custom sensitive fields", () => {
204+
const hints: ConfigUiHints = {
205+
"custom.myApiKey": { sensitive: true },
206+
"custom.displayName": { sensitive: false },
207+
};
208+
const originalConfig = {
209+
custom: { myApiKey: "secret-custom-api-key-value", displayName: "My Bot" },
210+
};
211+
const snapshot = makeSnapshot(originalConfig);
212+
const redacted = redactConfigSnapshot(snapshot, hints);
213+
const custom = (redacted.config as typeof originalConfig).custom as Record<string, string>;
214+
expect(custom.myApiKey).toBe(REDACTED_SENTINEL);
215+
expect(custom.displayName).toBe("My Bot");
216+
217+
const restored = restoreRedactedValues(
218+
redacted.config,
219+
snapshot.config,
220+
hints,
221+
) as typeof originalConfig;
222+
expect(restored).toEqual(originalConfig);
223+
});
224+
225+
it("restores with uiHints respecting sensitive:false override", () => {
226+
const hints: ConfigUiHints = {
227+
"gateway.auth.token": { sensitive: false },
228+
};
229+
const incoming = {
230+
gateway: { auth: { token: REDACTED_SENTINEL } },
231+
};
232+
const original = {
233+
gateway: { auth: { token: "real-secret" } },
234+
};
235+
const result = restoreRedactedValues(incoming, original, hints) as typeof incoming;
236+
expect(result.gateway.auth.token).toBe(REDACTED_SENTINEL);
237+
});
238+
239+
it("restores array items using wildcard uiHints", () => {
240+
const hints: ConfigUiHints = {
241+
"channels.slack.accounts[].botToken": { sensitive: true },
242+
};
243+
const incoming = {
244+
channels: {
245+
slack: {
246+
accounts: [
247+
{ botToken: REDACTED_SENTINEL },
248+
{ botToken: "user-provided-new-token-value" },
249+
],
250+
},
251+
},
252+
};
253+
const original = {
254+
channels: {
255+
slack: {
256+
accounts: [
257+
{ botToken: "original-token-first-account" },
258+
{ botToken: "original-token-second-account" },
259+
],
260+
},
261+
},
262+
};
263+
const result = restoreRedactedValues(incoming, original, hints) as typeof incoming;
264+
expect(result.channels.slack.accounts[0].botToken).toBe("original-token-first-account");
265+
expect(result.channels.slack.accounts[1].botToken).toBe("user-provided-new-token-value");
266+
});
267+
});

0 commit comments

Comments
 (0)