test(config): avoid duplicate include resolution in throw assertions

This commit is contained in:
Peter Steinberger
2026-02-21 23:12:54 +00:00
parent c78ea8ec3f
commit b97691f3a7

View File

@@ -45,6 +45,23 @@ function resolve(obj: unknown, files: Record<string, unknown> = {}, basePath = D
return resolveConfigIncludes(obj, basePath, createMockResolver(files)); return resolveConfigIncludes(obj, basePath, createMockResolver(files));
} }
function expectResolveIncludeError(
run: () => unknown,
expectedPattern?: RegExp,
): ConfigIncludeError {
let thrown: unknown;
try {
run();
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(ConfigIncludeError);
if (expectedPattern) {
expect((thrown as Error).message).toMatch(expectedPattern);
}
return thrown as ConfigIncludeError;
}
describe("resolveConfigIncludes", () => { describe("resolveConfigIncludes", () => {
it("passes through primitives unchanged", () => { it("passes through primitives unchanged", () => {
expect(resolve("hello")).toBe("hello"); expect(resolve("hello")).toBe("hello");
@@ -74,8 +91,7 @@ describe("resolveConfigIncludes", () => {
const absolute = etcOpenClawPath("agents.json"); const absolute = etcOpenClawPath("agents.json");
const files = { [absolute]: { list: [{ id: "main" }] } }; const files = { [absolute]: { list: [{ id: "main" }] } };
const obj = { agents: { $include: absolute } }; const obj = { agents: { $include: absolute } };
expect(() => resolve(obj, files)).toThrow(ConfigIncludeError); expectResolveIncludeError(() => resolve(obj, files), /escapes config directory/);
expect(() => resolve(obj, files)).toThrow(/escapes config directory/);
}); });
it("resolves array $include with deep merge", () => { it("resolves array $include with deep merge", () => {
@@ -146,8 +162,7 @@ describe("resolveConfigIncludes", () => {
it("throws ConfigIncludeError for missing file", () => { it("throws ConfigIncludeError for missing file", () => {
const obj = { $include: "./missing.json" }; const obj = { $include: "./missing.json" };
expect(() => resolve(obj)).toThrow(ConfigIncludeError); expectResolveIncludeError(() => resolve(obj), /Failed to read include file/);
expect(() => resolve(obj)).toThrow(/Failed to read include file/);
}); });
it("throws ConfigIncludeError for invalid JSON", () => { it("throws ConfigIncludeError for invalid JSON", () => {
@@ -156,10 +171,8 @@ describe("resolveConfigIncludes", () => {
parseJson: JSON.parse, parseJson: JSON.parse,
}; };
const obj = { $include: "./bad.json" }; const obj = { $include: "./bad.json" };
expect(() => resolveConfigIncludes(obj, DEFAULT_BASE_PATH, resolver)).toThrow( expectResolveIncludeError(
ConfigIncludeError, () => resolveConfigIncludes(obj, DEFAULT_BASE_PATH, resolver),
);
expect(() => resolveConfigIncludes(obj, DEFAULT_BASE_PATH, resolver)).toThrow(
/Failed to parse include file/, /Failed to parse include file/,
); );
}); });
@@ -215,8 +228,7 @@ describe("resolveConfigIncludes", () => {
] as const; ] as const;
for (const testCase of cases) { for (const testCase of cases) {
expect(() => resolve(testCase.obj, files)).toThrow(ConfigIncludeError); expectResolveIncludeError(() => resolve(testCase.obj, files), testCase.expectedPattern);
expect(() => resolve(testCase.obj, files)).toThrow(testCase.expectedPattern);
} }
}); });
@@ -230,8 +242,7 @@ describe("resolveConfigIncludes", () => {
files[configPath("level15.json")] = { done: true }; files[configPath("level15.json")] = { done: true };
const obj = { $include: "./level0.json" }; const obj = { $include: "./level0.json" };
expect(() => resolve(obj, files)).toThrow(ConfigIncludeError); expectResolveIncludeError(() => resolve(obj, files), /Maximum include depth/);
expect(() => resolve(obj, files)).toThrow(/Maximum include depth/);
}); });
it("allows depth 10 but rejects depth 11", () => { it("allows depth 10 but rejects depth 11", () => {
@@ -251,8 +262,10 @@ describe("resolveConfigIncludes", () => {
}; };
} }
failFiles[configPath("fail10.json")] = { done: true }; failFiles[configPath("fail10.json")] = { done: true };
expect(() => resolve({ $include: "./fail0.json" }, failFiles)).toThrow(ConfigIncludeError); expectResolveIncludeError(
expect(() => resolve({ $include: "./fail0.json" }, failFiles)).toThrow(/Maximum include depth/); () => resolve({ $include: "./fail0.json" }, failFiles),
/Maximum include depth/,
);
}); });
it("handles relative paths correctly", () => { it("handles relative paths correctly", () => {
@@ -279,10 +292,8 @@ describe("resolveConfigIncludes", () => {
it("rejects parent directory traversal escaping config directory (CWE-22)", () => { it("rejects parent directory traversal escaping config directory (CWE-22)", () => {
const files = { [sharedPath("common.json")]: { shared: true } }; const files = { [sharedPath("common.json")]: { shared: true } };
const obj = { $include: "../../shared/common.json" }; const obj = { $include: "../../shared/common.json" };
expect(() => resolve(obj, files, configPath("sub", "openclaw.json"))).toThrow( expectResolveIncludeError(
ConfigIncludeError, () => resolve(obj, files, configPath("sub", "openclaw.json")),
);
expect(() => resolve(obj, files, configPath("sub", "openclaw.json"))).toThrow(
/escapes config directory/, /escapes config directory/,
); );
}); });
@@ -388,9 +399,9 @@ describe("security: path traversal protection (CWE-22)", () => {
] as const; ] as const;
for (const testCase of cases) { for (const testCase of cases) {
const obj = { $include: testCase.includePath }; const obj = { $include: testCase.includePath };
expect(() => resolve(obj, {}), testCase.includePath).toThrow(ConfigIncludeError); expectResolveIncludeError(() => resolve(obj, {}));
if (testCase.expectEscapesMessage) { if (testCase.expectEscapesMessage) {
expect(() => resolve(obj, {}), testCase.includePath).toThrow(/escapes config directory/); expectResolveIncludeError(() => resolve(obj, {}), /escapes config directory/);
} }
} }
}); });
@@ -407,9 +418,9 @@ describe("security: path traversal protection (CWE-22)", () => {
] as const; ] as const;
for (const testCase of cases) { for (const testCase of cases) {
const obj = { $include: testCase.includePath }; const obj = { $include: testCase.includePath };
expect(() => resolve(obj, {}), testCase.includePath).toThrow(ConfigIncludeError); expectResolveIncludeError(() => resolve(obj, {}));
if (testCase.expectEscapesMessage) { if (testCase.expectEscapesMessage) {
expect(() => resolve(obj, {}), testCase.includePath).toThrow(/escapes config directory/); expectResolveIncludeError(() => resolve(obj, {}), /escapes config directory/);
} }
} }
}); });
@@ -558,7 +569,7 @@ describe("security: path traversal protection (CWE-22)", () => {
for (const testCase of cases) { for (const testCase of cases) {
const obj = { $include: testCase.includePath }; const obj = { $include: testCase.includePath };
if (testCase.expectedError) { if (testCase.expectedError) {
expect(() => resolve(obj, {}), testCase.includePath).toThrow(testCase.expectedError); expectResolveIncludeError(() => resolve(obj, {}));
continue; continue;
} }
// Path with null byte should be rejected or handled safely. // Path with null byte should be rejected or handled safely.