import { describe, expect, it, vi } from "vitest"; import { validateConfigObjectRaw } from "./validation.js"; vi.mock("../channels/plugins/legacy-config.js", () => ({ collectChannelLegacyConfigRules: () => [], })); vi.mock("../plugins/doctor-contract-registry.js", () => ({ collectRelevantDoctorPluginIds: () => [], listPluginDoctorLegacyConfigRules: () => [], })); vi.mock("../secrets/unsupported-surface-policy.js", async () => { const { isRecord } = await import("../utils.js"); return { collectUnsupportedSecretRefConfigCandidates: (raw: unknown) => { if (!isRecord(raw)) { return []; } const candidates: Array<{ path: string; value: unknown }> = []; const hooks = isRecord(raw.hooks) ? raw.hooks : null; if (hooks) { candidates.push({ path: "hooks.token", value: hooks.token }); } const channels = isRecord(raw.channels) ? raw.channels : null; const discord = channels && isRecord(channels.discord) ? channels.discord : null; const threadBindings = discord && isRecord(discord.threadBindings) ? discord.threadBindings : null; if (threadBindings) { candidates.push({ path: "channels.discord.threadBindings.webhookToken", value: threadBindings.webhookToken, }); } return candidates; }, }; }); describe("config validation SecretRef policy guards", () => { it("surfaces a policy error for hooks.token SecretRef objects", () => { const result = validateConfigObjectRaw({ hooks: { token: { source: "env", provider: "default", id: "HOOK_TOKEN", }, }, }); expect(result.ok).toBe(false); if (!result.ok) { const issue = result.issues.find((entry) => entry.path === "hooks.token"); expect(issue).toBeDefined(); expect(issue?.message).toContain("SecretRef objects are not supported at hooks.token"); expect(issue?.message).toContain( "https://docs.openclaw.ai/reference/secretref-credential-surface", ); expect( result.issues.some( (entry) => entry.path === "hooks.token" && entry.message.includes("Invalid input: expected string, received object"), ), ).toBe(false); } }); it("keeps standard schema errors for non-SecretRef objects", () => { const result = validateConfigObjectRaw({ hooks: { token: { unexpected: "value", }, }, }); expect(result.ok).toBe(false); if (!result.ok) { const issue = result.issues.find((entry) => entry.path === "hooks.token"); expect(issue).toBeDefined(); expect(issue?.message).toBe("Invalid input: expected string, received object"); } }); it("allows env-template strings on unsupported mutable paths", () => { const result = validateConfigObjectRaw({ hooks: { token: "${HOOK_TOKEN}", }, }); expect(result.ok).toBe(true); }); it("rejects legacy secretref-env markers on supported SecretRef credential paths", () => { const result = validateConfigObjectRaw({ secrets: { defaults: { env: "gateway-env", }, }, channels: { discord: { token: "secretref-env:DISCORD_BOT_TOKEN", }, }, }); expect(result.ok).toBe(false); if (!result.ok) { const issue = result.issues.find((entry) => entry.path === "channels.discord.token"); expect(issue).toBeDefined(); expect(issue?.message).toContain( '"secretref-env:DISCORD_BOT_TOKEN" is a legacy SecretRef marker', ); expect(issue?.message).toContain( '{"source":"env","provider":"gateway-env","id":"DISCORD_BOT_TOKEN"}', ); expect(issue?.message).toContain('Run "openclaw doctor --fix"'); } }); it("rejects invalid legacy secretref-env markers that doctor cannot migrate", () => { const result = validateConfigObjectRaw({ channels: { discord: { token: "secretref-env:not-valid", }, }, }); expect(result.ok).toBe(false); if (!result.ok) { const issue = result.issues.find((entry) => entry.path === "channels.discord.token"); expect(issue?.message).toContain('"secretref-env:not-valid" is a legacy SecretRef marker'); expect(issue?.message).toContain('{"source":"env","provider":"default","id":"ENV_VAR"}'); } }); it("replaces derived unrecognized-key errors with policy guidance for discord thread binding webhookToken", () => { const result = validateConfigObjectRaw({ channels: { discord: { threadBindings: { webhookToken: { source: "env", provider: "default", id: "DISCORD_THREAD_BINDING_WEBHOOK_TOKEN", }, }, }, }, }); expect(result.ok).toBe(false); if (!result.ok) { const policyIssue = result.issues.find( (entry) => entry.path === "channels.discord.threadBindings.webhookToken", ); expect(policyIssue).toBeDefined(); expect(policyIssue?.message).toContain( "SecretRef objects are not supported at channels.discord.threadBindings.webhookToken", ); expect( result.issues.some( (entry) => entry.path === "channels.discord.threadBindings" && entry.message.includes('Unrecognized key: "webhookToken"'), ), ).toBe(false); } }); it("preserves unrelated unknown-key errors when policy and typos coexist", () => { const result = validateConfigObjectRaw({ channels: { discord: { threadBindings: { webhookToken: { source: "env", provider: "default", id: "DISCORD_THREAD_BINDING_WEBHOOK_TOKEN", }, webhookTokne: "typo", }, }, }, }); expect(result.ok).toBe(false); if (!result.ok) { expect( result.issues.some( (entry) => entry.path === "channels.discord.threadBindings.webhookToken" && entry.message.includes("SecretRef objects are not supported"), ), ).toBe(true); expect( result.issues.some( (entry) => entry.path === "channels.discord.threadBindings" && entry.message.includes("webhookTokne"), ), ).toBe(true); } }); });