diff --git a/extensions/telegram/src/button-types.test.ts b/extensions/telegram/src/button-types.test.ts deleted file mode 100644 index 849caac62ac..00000000000 --- a/extensions/telegram/src/button-types.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildTelegramInteractiveButtons, resolveTelegramInlineButtons } from "./button-types.js"; - -describe("buildTelegramInteractiveButtons", () => { - it("maps shared buttons and selects into Telegram inline rows", () => { - expect( - buildTelegramInteractiveButtons({ - blocks: [ - { - type: "buttons", - buttons: [ - { label: "Approve", value: "approve", style: "success" }, - { label: "Reject", value: "reject", style: "danger" }, - { label: "Later", value: "later" }, - { label: "Archive", value: "archive" }, - ], - }, - { - type: "select", - options: [{ label: "Alpha", value: "alpha" }], - }, - ], - }), - ).toEqual([ - [ - { text: "Approve", callback_data: "approve", style: "success" }, - { text: "Reject", callback_data: "reject", style: "danger" }, - { text: "Later", callback_data: "later", style: undefined }, - ], - [{ text: "Archive", callback_data: "archive", style: undefined }], - [{ text: "Alpha", callback_data: "alpha", style: undefined }], - ]); - }); -}); - -describe("resolveTelegramInlineButtons", () => { - it("prefers explicit buttons over shared interactive blocks", () => { - const explicit = [[{ text: "Keep", callback_data: "keep" }]] as const; - - expect( - resolveTelegramInlineButtons({ - buttons: explicit, - interactive: { - blocks: [ - { - type: "buttons", - buttons: [{ label: "Override", value: "override" }], - }, - ], - }, - }), - ).toBe(explicit); - }); - - it("derives buttons from raw interactive payloads", () => { - expect( - resolveTelegramInlineButtons({ - interactive: { - blocks: [ - { - type: "buttons", - buttons: [{ label: "Retry", value: "retry", style: "primary" }], - }, - ], - }, - }), - ).toEqual([[{ text: "Retry", callback_data: "retry", style: "primary" }]]); - }); -}); diff --git a/extensions/telegram/src/group-access.base-access.test.ts b/extensions/telegram/src/group-access.base-access.test.ts index eae2e160076..57d6847a198 100644 --- a/extensions/telegram/src/group-access.base-access.test.ts +++ b/extensions/telegram/src/group-access.base-access.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.js"; import { normalizeAllowFrom, type NormalizedAllowFrom } from "./bot-access.js"; -import { evaluateTelegramGroupBaseAccess } from "./group-access.js"; +import { + evaluateTelegramGroupBaseAccess, + evaluateTelegramGroupPolicyAccess, +} from "./group-access.js"; function allow(entries: string[], hasWildcard = false): NormalizedAllowFrom { return { @@ -65,3 +70,210 @@ describe("evaluateTelegramGroupBaseAccess", () => { expect(result).toEqual({ allowed: true }); }); }); + +/** + * Minimal stubs shared across group policy tests. + */ +const baseCfg = { + channels: { telegram: {} }, +} as unknown as OpenClawConfig; + +const baseTelegramCfg: TelegramAccountConfig = { + groupPolicy: "allowlist", +} as unknown as TelegramAccountConfig; + +const emptyAllow = { entries: [], hasWildcard: false, hasEntries: false, invalidEntries: [] }; +const senderAllow = { + entries: ["111"], + hasWildcard: false, + hasEntries: true, + invalidEntries: [], +}; + +type GroupAccessParams = Parameters[0]; + +const DEFAULT_GROUP_ACCESS_PARAMS: GroupAccessParams = { + isGroup: true, + chatId: "-100123456", + cfg: baseCfg, + telegramCfg: baseTelegramCfg, + effectiveGroupAllow: emptyAllow, + senderId: "999", + senderUsername: "user", + resolveGroupPolicy: () => ({ + allowlistEnabled: true, + allowed: true, + groupConfig: { requireMention: false }, + }), + enforcePolicy: true, + useTopicAndGroupOverrides: false, + enforceAllowlistAuthorization: true, + allowEmptyAllowlistEntries: false, + requireSenderForAllowlistAuthorization: true, + checkChatAllowlist: true, +}; + +function runAccess(overrides: Partial) { + return evaluateTelegramGroupPolicyAccess({ + ...DEFAULT_GROUP_ACCESS_PARAMS, + ...overrides, + resolveGroupPolicy: + overrides.resolveGroupPolicy ?? DEFAULT_GROUP_ACCESS_PARAMS.resolveGroupPolicy, + }); +} + +describe("evaluateTelegramGroupPolicyAccess", () => { + it("allows a group explicitly listed in groups config even when no allowFrom entries exist", () => { + const result = runAccess({ + resolveGroupPolicy: () => ({ + allowlistEnabled: true, + allowed: true, + groupConfig: { requireMention: false }, + }), + }); + + expect(result).toEqual({ allowed: true, groupPolicy: "allowlist" }); + }); + + it("still blocks when only wildcard match and no allowFrom entries", () => { + const result = runAccess({ + resolveGroupPolicy: () => ({ + allowlistEnabled: true, + allowed: true, + groupConfig: undefined, + }), + }); + + expect(result).toEqual({ + allowed: false, + reason: "group-policy-allowlist-empty", + groupPolicy: "allowlist", + }); + }); + + it("rejects a group not in groups config", () => { + const result = runAccess({ + chatId: "-100999999", + resolveGroupPolicy: () => ({ + allowlistEnabled: true, + allowed: false, + }), + }); + + expect(result).toEqual({ + allowed: false, + reason: "group-chat-not-allowed", + groupPolicy: "allowlist", + }); + }); + + it("still enforces sender allowlist when checkChatAllowlist is disabled", () => { + const result = runAccess({ + resolveGroupPolicy: () => ({ + allowlistEnabled: true, + allowed: true, + groupConfig: { requireMention: false }, + }), + checkChatAllowlist: false, + }); + + expect(result).toEqual({ + allowed: false, + reason: "group-policy-allowlist-empty", + groupPolicy: "allowlist", + }); + }); + + it("blocks unauthorized sender even when chat is explicitly allowed and sender entries exist", () => { + const result = runAccess({ + effectiveGroupAllow: senderAllow, + senderId: "222", + senderUsername: "other", + resolveGroupPolicy: () => ({ + allowlistEnabled: true, + allowed: true, + groupConfig: { requireMention: false }, + }), + }); + + expect(result).toEqual({ + allowed: false, + reason: "group-policy-allowlist-unauthorized", + groupPolicy: "allowlist", + }); + }); + + it("allows when groupPolicy is open regardless of allowlist state", () => { + const result = runAccess({ + telegramCfg: { groupPolicy: "open" } as TelegramAccountConfig, + resolveGroupPolicy: () => ({ + allowlistEnabled: false, + allowed: false, + }), + }); + + expect(result).toEqual({ allowed: true, groupPolicy: "open" }); + }); + + it("rejects when groupPolicy is disabled", () => { + const result = runAccess({ + telegramCfg: { groupPolicy: "disabled" } as TelegramAccountConfig, + resolveGroupPolicy: () => ({ + allowlistEnabled: false, + allowed: false, + }), + }); + + expect(result).toEqual({ + allowed: false, + reason: "group-policy-disabled", + groupPolicy: "disabled", + }); + }); + + it("allows non-group messages without any checks", () => { + const result = runAccess({ + isGroup: false, + chatId: "12345", + resolveGroupPolicy: () => ({ + allowlistEnabled: true, + allowed: false, + }), + }); + + expect(result).toEqual({ allowed: true, groupPolicy: "allowlist" }); + }); + + it("blocks allowlist groups without sender identity before sender matching", () => { + const result = runAccess({ + senderId: undefined, + senderUsername: undefined, + effectiveGroupAllow: senderAllow, + resolveGroupPolicy: () => ({ + allowlistEnabled: true, + allowed: true, + groupConfig: { requireMention: false }, + }), + }); + + expect(result).toEqual({ + allowed: false, + reason: "group-policy-allowlist-no-sender", + groupPolicy: "allowlist", + }); + }); + + it("allows authorized sender in wildcard-matched group with sender entries", () => { + const result = runAccess({ + effectiveGroupAllow: senderAllow, + senderId: "111", + resolveGroupPolicy: () => ({ + allowlistEnabled: true, + allowed: true, + groupConfig: undefined, + }), + }); + + expect(result).toEqual({ allowed: true, groupPolicy: "allowlist" }); + }); +}); diff --git a/extensions/telegram/src/group-access.policy-access.test.ts b/extensions/telegram/src/group-access.policy-access.test.ts deleted file mode 100644 index 812dda9af49..00000000000 --- a/extensions/telegram/src/group-access.policy-access.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { TelegramAccountConfig } from "../../../src/config/types.js"; -import { evaluateTelegramGroupPolicyAccess } from "./group-access.js"; - -/** - * Minimal stubs shared across tests. - */ -const baseCfg = { - channels: { telegram: {} }, -} as unknown as OpenClawConfig; - -const baseTelegramCfg: TelegramAccountConfig = { - groupPolicy: "allowlist", -} as unknown as TelegramAccountConfig; - -const emptyAllow = { entries: [], hasWildcard: false, hasEntries: false, invalidEntries: [] }; -const senderAllow = { - entries: ["111"], - hasWildcard: false, - hasEntries: true, - invalidEntries: [], -}; - -type GroupAccessParams = Parameters[0]; - -const DEFAULT_GROUP_ACCESS_PARAMS: GroupAccessParams = { - isGroup: true, - chatId: "-100123456", - cfg: baseCfg, - telegramCfg: baseTelegramCfg, - effectiveGroupAllow: emptyAllow, - senderId: "999", - senderUsername: "user", - resolveGroupPolicy: () => ({ - allowlistEnabled: true, - allowed: true, - groupConfig: { requireMention: false }, - }), - enforcePolicy: true, - useTopicAndGroupOverrides: false, - enforceAllowlistAuthorization: true, - allowEmptyAllowlistEntries: false, - requireSenderForAllowlistAuthorization: true, - checkChatAllowlist: true, -}; - -function runAccess(overrides: Partial) { - return evaluateTelegramGroupPolicyAccess({ - ...DEFAULT_GROUP_ACCESS_PARAMS, - ...overrides, - resolveGroupPolicy: - overrides.resolveGroupPolicy ?? DEFAULT_GROUP_ACCESS_PARAMS.resolveGroupPolicy, - }); -} - -describe("evaluateTelegramGroupPolicyAccess – chat allowlist vs sender allowlist ordering", () => { - it("allows a group explicitly listed in groups config even when no allowFrom entries exist", () => { - // Issue #30613: a group configured with a dedicated entry (groupConfig set) - // should be allowed even without any allowFrom / groupAllowFrom entries. - const result = runAccess({ - resolveGroupPolicy: () => ({ - allowlistEnabled: true, - allowed: true, - groupConfig: { requireMention: false }, // dedicated entry — not just wildcard - }), - }); - - expect(result).toEqual({ allowed: true, groupPolicy: "allowlist" }); - }); - - it("still blocks when only wildcard match and no allowFrom entries", () => { - // groups: { "*": ... } with no allowFrom → wildcard does NOT bypass sender checks. - const result = runAccess({ - resolveGroupPolicy: () => ({ - allowlistEnabled: true, - allowed: true, - groupConfig: undefined, // wildcard match only — no dedicated entry - }), - }); - - expect(result).toEqual({ - allowed: false, - reason: "group-policy-allowlist-empty", - groupPolicy: "allowlist", - }); - }); - - it("rejects a group NOT in groups config", () => { - const result = runAccess({ - chatId: "-100999999", - resolveGroupPolicy: () => ({ - allowlistEnabled: true, - allowed: false, - }), - }); - - expect(result).toEqual({ - allowed: false, - reason: "group-chat-not-allowed", - groupPolicy: "allowlist", - }); - }); - - it("still enforces sender allowlist when checkChatAllowlist is disabled", () => { - const result = runAccess({ - resolveGroupPolicy: () => ({ - allowlistEnabled: true, - allowed: true, - groupConfig: { requireMention: false }, - }), - checkChatAllowlist: false, - }); - - expect(result).toEqual({ - allowed: false, - reason: "group-policy-allowlist-empty", - groupPolicy: "allowlist", - }); - }); - - it("blocks unauthorized sender even when chat is explicitly allowed and sender entries exist", () => { - const result = runAccess({ - effectiveGroupAllow: senderAllow, // entries: ["111"] - senderId: "222", // not in senderAllow.entries - senderUsername: "other", - resolveGroupPolicy: () => ({ - allowlistEnabled: true, - allowed: true, - groupConfig: { requireMention: false }, - }), - }); - - // Chat is explicitly allowed, but sender entries exist and sender is not in them. - expect(result).toEqual({ - allowed: false, - reason: "group-policy-allowlist-unauthorized", - groupPolicy: "allowlist", - }); - }); - - it("allows when groupPolicy is open regardless of allowlist state", () => { - const result = runAccess({ - telegramCfg: { groupPolicy: "open" } as unknown as TelegramAccountConfig, - resolveGroupPolicy: () => ({ - allowlistEnabled: false, - allowed: false, - }), - }); - - expect(result).toEqual({ allowed: true, groupPolicy: "open" }); - }); - - it("rejects when groupPolicy is disabled", () => { - const result = runAccess({ - telegramCfg: { groupPolicy: "disabled" } as unknown as TelegramAccountConfig, - resolveGroupPolicy: () => ({ - allowlistEnabled: false, - allowed: false, - }), - }); - - expect(result).toEqual({ - allowed: false, - reason: "group-policy-disabled", - groupPolicy: "disabled", - }); - }); - - it("allows non-group messages without any checks", () => { - const result = runAccess({ - isGroup: false, - chatId: "12345", - resolveGroupPolicy: () => ({ - allowlistEnabled: true, - allowed: false, - }), - }); - - expect(result).toEqual({ allowed: true, groupPolicy: "allowlist" }); - }); - - it("blocks allowlist groups without sender identity before sender matching", () => { - const result = runAccess({ - senderId: undefined, - senderUsername: undefined, - effectiveGroupAllow: senderAllow, - resolveGroupPolicy: () => ({ - allowlistEnabled: true, - allowed: true, - groupConfig: { requireMention: false }, - }), - }); - - expect(result).toEqual({ - allowed: false, - reason: "group-policy-allowlist-no-sender", - groupPolicy: "allowlist", - }); - }); - - it("allows authorized sender in wildcard-matched group with sender entries", () => { - const result = runAccess({ - effectiveGroupAllow: senderAllow, // entries: ["111"] - senderId: "111", // IS in senderAllow.entries - resolveGroupPolicy: () => ({ - allowlistEnabled: true, - allowed: true, - groupConfig: undefined, // wildcard only - }), - }); - - expect(result).toEqual({ allowed: true, groupPolicy: "allowlist" }); - }); -}); diff --git a/extensions/telegram/src/inline-buttons.test.ts b/extensions/telegram/src/inline-buttons.test.ts index e2821d47073..068691334b8 100644 --- a/extensions/telegram/src/inline-buttons.test.ts +++ b/extensions/telegram/src/inline-buttons.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildTelegramExecApprovalButtons } from "./approval-buttons.js"; +import { buildTelegramInteractiveButtons, resolveTelegramInlineButtons } from "./button-types.js"; import { resolveTelegramTargetChatType } from "./inline-buttons.js"; describe("telegram approval buttons", () => { @@ -52,3 +53,70 @@ describe("resolveTelegramTargetChatType", () => { expect(resolveTelegramTargetChatType(" ")).toBe("unknown"); }); }); + +describe("buildTelegramInteractiveButtons", () => { + it("maps shared buttons and selects into Telegram inline rows", () => { + expect( + buildTelegramInteractiveButtons({ + blocks: [ + { + type: "buttons", + buttons: [ + { label: "Approve", value: "approve", style: "success" }, + { label: "Reject", value: "reject", style: "danger" }, + { label: "Later", value: "later" }, + { label: "Archive", value: "archive" }, + ], + }, + { + type: "select", + options: [{ label: "Alpha", value: "alpha" }], + }, + ], + }), + ).toEqual([ + [ + { text: "Approve", callback_data: "approve", style: "success" }, + { text: "Reject", callback_data: "reject", style: "danger" }, + { text: "Later", callback_data: "later", style: undefined }, + ], + [{ text: "Archive", callback_data: "archive", style: undefined }], + [{ text: "Alpha", callback_data: "alpha", style: undefined }], + ]); + }); +}); + +describe("resolveTelegramInlineButtons", () => { + it("prefers explicit buttons over shared interactive blocks", () => { + const explicit = [[{ text: "Keep", callback_data: "keep" }]] as const; + + expect( + resolveTelegramInlineButtons({ + buttons: explicit, + interactive: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Override", value: "override" }], + }, + ], + }, + }), + ).toBe(explicit); + }); + + it("derives buttons from raw interactive payloads", () => { + expect( + resolveTelegramInlineButtons({ + interactive: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Retry", value: "retry", style: "primary" }], + }, + ], + }, + }), + ).toEqual([[{ text: "Retry", callback_data: "retry", style: "primary" }]]); + }); +});