Files
openclaw/extensions/googlechat/src/channel.test.ts
Josh Avant fbd330b7aa fix(channels): honor configured read target policies (#99905)
* fix(channels): enforce configured read targets

* test(channels): align policy checks with boundaries

* fix: bind channel reads to trusted turn context

* test: satisfy gateway lint

* fix: narrow message action channel imports

* fix(feishu): authorize message reads before provider access

* fix(slack): await reaction clear authorization

* fix(channels): align provider action contracts

* fix(matrix): read direct-room account data before sync

* fix(channels): reject unsupported attachment actions early

* fix: restore trusted operator conversation reads

* fix(matrix): authorize pin actions before provider reads

* fix: preserve trusted channel read workflows

* fix(discord): resolve current channel ids consistently

* fix(agents): preserve message action turn capability

* fix(plugins): enforce host-owned read provenance

* fix(channels): harden Teams and Discord read policy

* fix(channels): preserve exact-current action compatibility

* fix(imessage): authorize trusted current chat aliases

* fix(channels): preserve normalized current aliases

* fix(channels): preserve external current target aliases

* fix: reconcile channel policy with current main

* fix(discord): isolate DM read policy

* fix(channels): enforce provider read gates

* fix(gateway): await serialized message action identity tokens

* fix(ci): refresh channel protocol contracts
2026-07-10 22:29:37 -05:00

591 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Googlechat tests cover channel plugin behavior.
import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-outbound";
import {
createDirectoryTestRuntime,
expectDirectorySurface,
} from "openclaw/plugin-sdk/channel-test-helpers";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import {
googlechatDirectoryAdapter,
googlechatMessageAdapter,
googlechatOutboundAdapter,
googlechatPairingTextAdapter,
googlechatSecurityAdapter,
googlechatThreadingAdapter,
} from "./channel.adapters.js";
const sendGoogleChatMessageMock = vi.hoisted(() => vi.fn());
const resolveGoogleChatAccountMock = vi.hoisted(() => vi.fn());
const resolveGoogleChatOutboundSpaceMock = vi.hoisted(() => vi.fn());
const probeGoogleChatMock = vi.hoisted(() => vi.fn());
const startGoogleChatMonitorMock = vi.hoisted(() => vi.fn());
const DEFAULT_ACCOUNT_ID = "default";
function normalizeGoogleChatTarget(raw?: string | null): string | undefined {
const trimmed = raw?.trim();
if (!trimmed) {
return undefined;
}
const withoutPrefix = trimmed.replace(/^(googlechat|google-chat|gchat):/i, "");
const normalized = withoutPrefix
.replace(/^user:(users\/)?/i, "users/")
.replace(/^space:(spaces\/)?/i, "spaces/");
if (normalized.toLowerCase().startsWith("users/")) {
const suffix = normalized.slice("users/".length);
return suffix.includes("@") ? `users/${suffix.toLowerCase()}` : normalized;
}
if (normalized.toLowerCase().startsWith("spaces/")) {
return normalized;
}
if (normalized.includes("@")) {
return `users/${normalized.toLowerCase()}`;
}
return normalized;
}
function resolveGoogleChatAccountImpl(params: { cfg: OpenClawConfig; accountId?: string | null }) {
const accountId = params.accountId?.trim() || DEFAULT_ACCOUNT_ID;
const channelConfig = (params.cfg.channels?.googlechat ?? {}) as Record<string, unknown>;
const accounts =
(channelConfig.accounts as Record<string, Record<string, unknown>> | undefined) ?? {};
const scoped = accountId === DEFAULT_ACCOUNT_ID ? {} : (accounts[accountId] ?? {});
const config = { ...channelConfig, ...scoped } as Record<string, unknown>;
const serviceAccount = config.serviceAccount;
return {
accountId,
name: typeof config.name === "string" ? config.name : undefined,
enabled: channelConfig.enabled !== false && scoped.enabled !== false,
config,
credentialSource: serviceAccount ? ("inline" as const) : ("none" as const),
};
}
function mockGoogleChatOutboundSpaceResolution() {
resolveGoogleChatOutboundSpaceMock.mockImplementation(async ({ target }: { target: string }) => {
const normalized = normalizeGoogleChatTarget(target);
if (!normalized) {
throw new Error("Missing Google Chat target.");
}
return normalized.toLowerCase().startsWith("users/")
? `spaces/DM-${normalized.slice("users/".length)}`
: normalized.replace(/\/messages\/.+$/, "");
});
}
vi.mock("./channel.runtime.js", () => {
return {
googleChatChannelRuntime: {
probeGoogleChat: (...args: unknown[]) => probeGoogleChatMock(...args),
resolveGoogleChatWebhookPath: () => "/googlechat/webhook",
sendGoogleChatMessage: (...args: unknown[]) => sendGoogleChatMessageMock(...args),
startGoogleChatMonitor: (...args: unknown[]) => startGoogleChatMonitorMock(...args),
},
};
});
vi.mock("./channel.deps.runtime.js", () => {
return {
DEFAULT_ACCOUNT_ID: "default",
GoogleChatConfigSchema: {},
buildChannelConfigSchema: () => ({}),
chunkTextForOutbound: (text: string, maxChars: number) => {
const chunks: string[] = [];
let current = "";
for (const word of text.split(/\s+/)) {
if (!word) {
continue;
}
const next = current ? `${current} ${word}` : word;
if (current && next.length > maxChars) {
chunks.push(current);
current = word;
continue;
}
current = next;
}
if (current) {
chunks.push(current);
}
return chunks;
},
createAccountStatusSink: () => () => {},
getChatChannelMeta: (id: string) => ({ id, name: id }),
isGoogleChatSpaceTarget: (value: string) => value.toLowerCase().startsWith("spaces/"),
isGoogleChatUserTarget: (value: string) => value.toLowerCase().startsWith("users/"),
listGoogleChatAccountIds: (cfg: OpenClawConfig) => {
const ids = Object.keys(cfg.channels?.googlechat?.accounts ?? {});
return ids.length > 0 ? ids : ["default"];
},
missingTargetError: (channel: string, hint: string) =>
new Error(`${channel} target is required (${hint})`),
normalizeGoogleChatTarget,
PAIRING_APPROVED_MESSAGE: "approved",
resolveDefaultGoogleChatAccountId: () => "default",
resolveGoogleChatAccount: (...args: Parameters<typeof resolveGoogleChatAccountImpl>) =>
resolveGoogleChatAccountMock(...args),
resolveGoogleChatOutboundSpace: (...args: unknown[]) =>
resolveGoogleChatOutboundSpaceMock(...args),
runPassiveAccountLifecycle: async (params: { start: () => Promise<unknown> }) =>
await params.start(),
};
});
resolveGoogleChatAccountMock.mockImplementation(resolveGoogleChatAccountImpl);
mockGoogleChatOutboundSpaceResolution();
afterEach(() => {
vi.clearAllMocks();
resolveGoogleChatAccountMock.mockImplementation(resolveGoogleChatAccountImpl);
mockGoogleChatOutboundSpaceResolution();
});
afterAll(() => {
vi.doUnmock("./channel.runtime.js");
vi.doUnmock("./channel.deps.runtime.js");
vi.resetModules();
});
function createGoogleChatCfg(): OpenClawConfig {
return {
channels: {
googlechat: {
enabled: true,
serviceAccount: {
type: "service_account",
client_email: "bot@example.com",
private_key: "test-key", // pragma: allowlist secret
token_uri: "https://oauth2.googleapis.com/token",
},
},
},
};
}
function requireMockArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0): unknown {
const call = mock.mock.calls[callIndex];
if (!call) {
throw new Error(`expected mock call ${callIndex}`);
}
return call[argIndex];
}
describe("googlechatPlugin outbound", () => {
it("declares durable text and thread delivery with receipt proofs", async () => {
sendGoogleChatMessageMock.mockResolvedValue({
messageName: "spaces/AAA/messages/msg-1",
});
const cfg = createGoogleChatCfg();
const proofs = await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "googlechat",
adapter: googlechatMessageAdapter,
proofs: {
text: async () => {
const result = await googlechatMessageAdapter.send?.text?.({
cfg,
to: "spaces/AAA",
text: "hello",
});
expect(result?.receipt.parts[0]?.kind).toBe("text");
expect(result?.receipt.platformMessageIds).toEqual(["spaces/AAA/messages/msg-1"]);
},
thread: async () => {
sendGoogleChatMessageMock.mockClear();
await googlechatMessageAdapter.send?.text?.({
cfg,
to: "spaces/AAA",
text: "threaded",
threadId: "thread-1",
});
const request = requireMockArg(sendGoogleChatMessageMock) as {
space?: string;
thread?: string;
};
expect(request.space).toBe("spaces/AAA");
expect(request.thread).toBe("thread-1");
},
messageSendingHooks: () => {
expect(googlechatMessageAdapter.send?.text).toBeTypeOf("function");
},
},
});
expect(proofs).toStrictEqual([
{ capability: "text", status: "verified" },
{ capability: "media", status: "not_declared" },
{ capability: "poll", status: "not_declared" },
{ capability: "payload", status: "not_declared" },
{ capability: "silent", status: "not_declared" },
{ capability: "replyTo", status: "not_declared" },
{ capability: "thread", status: "verified" },
{ capability: "nativeQuote", status: "not_declared" },
{ capability: "messageSendingHooks", status: "verified" },
{ capability: "batch", status: "not_declared" },
{ capability: "reconcileUnknownSend", status: "not_declared" },
{ capability: "afterSendSuccess", status: "not_declared" },
{ capability: "afterCommit", status: "not_declared" },
]);
});
it("chunks outbound text without requiring Google Chat runtime initialization", () => {
const chunker = googlechatOutboundAdapter.base.chunker;
expect(chunker("alpha beta", 5)).toEqual(["alpha", "beta"]);
});
});
describe("googlechatPlugin threading", () => {
it("honors per-account replyToMode overrides", () => {
const cfg = {
channels: {
googlechat: {
replyToMode: "all",
accounts: {
work: {
replyToMode: "first",
},
},
},
},
} as OpenClawConfig;
const workAccount = googlechatThreadingAdapter.scopedAccountReplyToMode.resolveAccount(
cfg,
"work",
);
const defaultAccount = googlechatThreadingAdapter.scopedAccountReplyToMode.resolveAccount(
cfg,
"default",
);
expect(
googlechatThreadingAdapter.scopedAccountReplyToMode.resolveReplyToMode(workAccount),
).toBe("first");
expect(
googlechatThreadingAdapter.scopedAccountReplyToMode.resolveReplyToMode(defaultAccount),
).toBe("all");
});
it("uses the inbound thread resource as the current tool reply target", () => {
const cfg = {
channels: {
googlechat: {
replyToMode: "all",
},
},
} as OpenClawConfig;
const hasRepliedRef = { value: false };
const context = googlechatThreadingAdapter.buildToolContext({
cfg,
accountId: "default",
context: {
To: "googlechat:spaces/AAA",
CurrentMessageId: "spaces/AAA/messages/msg-1",
ReplyToId: "spaces/AAA/threads/thread-1",
},
hasRepliedRef,
});
expect(context).toMatchObject({
currentChannelId: "spaces/AAA",
currentMessageId: "spaces/AAA/threads/thread-1",
currentThreadTs: "spaces/AAA/threads/thread-1",
replyToMode: "all",
hasRepliedRef,
});
});
it("does not use message resources as implicit Google Chat reply targets", () => {
const cfg = {
channels: {
googlechat: {
replyToMode: "all",
},
},
} as OpenClawConfig;
const context = googlechatThreadingAdapter.buildToolContext({
cfg,
accountId: "default",
context: {
To: "googlechat:spaces/AAA",
CurrentMessageId: "spaces/AAA/messages/msg-1",
},
});
expect(context).toMatchObject({
currentChannelId: "spaces/AAA",
replyToMode: "all",
});
expect(context.currentMessageId).toBeUndefined();
expect(context.currentThreadTs).toBeUndefined();
});
});
const resolveTarget = googlechatOutboundAdapter.base.resolveTarget;
describe("googlechatPlugin outbound resolveTarget", () => {
it("resolves valid chat targets", () => {
const result = resolveTarget({
to: "spaces/AAA",
});
expect(result.ok).toBe(true);
if (!result.ok) {
throw result.error;
}
expect(result.to).toBe("spaces/AAA");
});
it("resolves email targets", () => {
const result = resolveTarget({
to: "user@example.com",
});
expect(result.ok).toBe(true);
if (!result.ok) {
throw result.error;
}
expect(result.to).toBe("users/user@example.com");
});
it("errors on invalid targets", () => {
const result = resolveTarget({
to: " ",
});
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("Expected invalid target to fail");
}
expect(result.error.message).toBe(
"Google Chat target is required (<spaces/{space}|users/{user}>)",
);
});
it("errors when no target is provided", () => {
const result = resolveTarget({
to: undefined,
});
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("Expected missing target to fail");
}
expect(result.error.message).toBe(
"Google Chat target is required (<spaces/{space}|users/{user}>)",
);
});
});
describe("googlechatPlugin outbound cfg threading", () => {
it("preserves accountId when sending pairing approvals", async () => {
const cfg = {
channels: {
googlechat: {
enabled: true,
accounts: {
work: {
serviceAccount: {
type: "service_account",
},
},
},
},
},
};
const account = {
accountId: "work",
config: {},
credentialSource: "inline" as const,
};
resolveGoogleChatAccountMock.mockReturnValue(account);
resolveGoogleChatOutboundSpaceMock.mockResolvedValue("spaces/WORK");
sendGoogleChatMessageMock.mockResolvedValue({
messageName: "spaces/WORK/messages/msg-1",
});
await googlechatPairingTextAdapter.notify({
cfg: cfg as never,
id: "user@example.com",
message: googlechatPairingTextAdapter.message,
accountId: "work",
} as never);
expect(resolveGoogleChatAccountMock).toHaveBeenCalledWith({
cfg,
accountId: "work",
});
const request = requireMockArg(sendGoogleChatMessageMock) as {
account?: unknown;
space?: string;
text?: string;
};
expect(request.account).toBe(account);
expect(request.space).toBe("spaces/WORK");
expect(request.text).toBe(googlechatPairingTextAdapter.message);
});
it("threads resolved cfg into sendText account resolution", async () => {
const cfg = {
channels: {
googlechat: {
serviceAccount: {
type: "service_account",
},
},
},
};
const account = {
accountId: "default",
config: {},
credentialSource: "inline" as const,
};
resolveGoogleChatAccountMock.mockReturnValue(account);
resolveGoogleChatOutboundSpaceMock.mockResolvedValue("spaces/AAA");
sendGoogleChatMessageMock.mockResolvedValue({
messageName: "spaces/AAA/messages/msg-1",
});
await googlechatOutboundAdapter.attachedResults.sendText({
cfg: cfg as never,
to: "users/123",
text: "hello",
accountId: "default",
});
expect(resolveGoogleChatAccountMock).toHaveBeenCalledWith({
cfg,
accountId: "default",
});
const request = requireMockArg(sendGoogleChatMessageMock) as {
account?: unknown;
space?: string;
text?: string;
};
expect(request.account).toBe(account);
expect(request.space).toBe("spaces/AAA");
expect(request.text).toBe("hello");
});
});
describe("googlechat directory", () => {
const runtimeEnv = createDirectoryTestRuntime() as never;
it("lists peers and groups from config", async () => {
const cfg = {
channels: {
googlechat: {
serviceAccount: { client_email: "bot@example.com" },
dm: { allowFrom: ["users/alice", "googlechat:bob"] },
groups: {
"spaces/AAA": {},
"spaces/BBB": {},
},
},
},
} as unknown as OpenClawConfig;
const directory = expectDirectorySurface(googlechatDirectoryAdapter);
const peers = await directory.listPeers({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
});
expect(peers).toStrictEqual([
{ kind: "user", id: "users/alice" },
{ kind: "user", id: "bob" },
]);
const groups = await directory.listGroups({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
});
expect(groups).toStrictEqual([
{ kind: "group", id: "spaces/AAA" },
{ kind: "group", id: "spaces/BBB" },
]);
});
it("normalizes spaced provider-prefixed dm allowlist entries", async () => {
const cfg = {
channels: {
googlechat: {
serviceAccount: { client_email: "bot@example.com" },
dm: { allowFrom: [" users/alice ", " googlechat:user:Bob@Example.com "] },
},
},
} as unknown as OpenClawConfig;
const directory = expectDirectorySurface(googlechatDirectoryAdapter);
const peers = await directory.listPeers({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
});
expect(peers).toStrictEqual([
{ kind: "user", id: "users/alice" },
{ kind: "user", id: "users/bob@example.com" },
]);
});
});
describe("googlechatPlugin security", () => {
it("normalizes prefixed DM allowlist entries to lowercase user ids", () => {
const cfg = {
channels: {
googlechat: {
serviceAccount: { client_email: "bot@example.com" },
dm: {
policy: "allowlist",
allowFrom: [" googlechat:user:Bob@Example.com "],
},
},
},
} as OpenClawConfig;
const account = resolveGoogleChatAccountImpl({ cfg, accountId: "default" });
expect(googlechatSecurityAdapter.dm.resolvePolicy(account)).toBe("allowlist");
expect(googlechatSecurityAdapter.dm.resolveAllowFrom(account)).toEqual([
" googlechat:user:Bob@Example.com ",
]);
expect(googlechatSecurityAdapter.dm.normalizeEntry(" googlechat:user:Bob@Example.com ")).toBe(
"bob@example.com",
);
expect(googlechatPairingTextAdapter.normalizeAllowEntry(" users/Alice@Example.com ")).toBe(
"alice@example.com",
);
});
});
describe("googlechatPlugin outbound sanitizeText", () => {
const sanitizeText = googlechatOutboundAdapter.base.sanitizeText;
it("strips internal tool-trace failure banners from outbound text (#90684)", () => {
const text =
"Visible answer.\n⚠ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed";
const out = sanitizeText({ text });
expect(out).toBe("Visible answer.");
expect(out).not.toContain("failed");
expect(out).not.toContain("🛠️");
});
it("preserves ordinary assistant prose untouched", () => {
const text = "El pipeline tiene 3 deals abiertos por USD 12.000.";
expect(sanitizeText({ text })).toBe(text);
});
});