diff --git a/extensions/bluebubbles/src/config-schema.test.ts b/extensions/bluebubbles/src/config-schema.test.ts deleted file mode 100644 index 308ee9732b5..00000000000 --- a/extensions/bluebubbles/src/config-schema.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { BlueBubblesConfigSchema } from "./config-schema.js"; - -describe("BlueBubblesConfigSchema", () => { - it("accepts account config when serverUrl and password are both set", () => { - const parsed = BlueBubblesConfigSchema.safeParse({ - serverUrl: "http://localhost:1234", - password: "secret", // pragma: allowlist secret - }); - expect(parsed.success).toBe(true); - }); - - it("accepts SecretRef password when serverUrl is set", () => { - const parsed = BlueBubblesConfigSchema.safeParse({ - serverUrl: "http://localhost:1234", - password: { - source: "env", - provider: "default", - id: "BLUEBUBBLES_PASSWORD", - }, - }); - expect(parsed.success).toBe(true); - }); - - it("requires password when top-level serverUrl is configured", () => { - const parsed = BlueBubblesConfigSchema.safeParse({ - serverUrl: "http://localhost:1234", - }); - expect(parsed.success).toBe(false); - if (parsed.success) { - return; - } - expect(parsed.error.issues[0]?.path).toEqual(["password"]); - expect(parsed.error.issues[0]?.message).toBe( - "password is required when serverUrl is configured", - ); - }); - - it("requires password when account serverUrl is configured", () => { - const parsed = BlueBubblesConfigSchema.safeParse({ - accounts: { - work: { - serverUrl: "http://localhost:1234", - }, - }, - }); - expect(parsed.success).toBe(false); - if (parsed.success) { - return; - } - expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "password"]); - expect(parsed.error.issues[0]?.message).toBe( - "password is required when serverUrl is configured", - ); - }); - - it("allows password omission when serverUrl is not configured", () => { - const parsed = BlueBubblesConfigSchema.safeParse({ - accounts: { - work: { - name: "Work iMessage", - }, - }, - }); - expect(parsed.success).toBe(true); - }); -}); diff --git a/extensions/bluebubbles/src/group-policy.test.ts b/extensions/bluebubbles/src/group-policy.test.ts deleted file mode 100644 index 883f6c78b71..00000000000 --- a/extensions/bluebubbles/src/group-policy.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - resolveBlueBubblesGroupRequireMention, - resolveBlueBubblesGroupToolPolicy, -} from "./group-policy.js"; - -describe("bluebubbles group policy", () => { - it("uses generic channel group policy helpers", () => { - const cfg = { - channels: { - bluebubbles: { - groups: { - "chat:primary": { - requireMention: false, - tools: { deny: ["exec"] }, - }, - "*": { - requireMention: true, - tools: { allow: ["message.send"] }, - }, - }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:primary" })).toBe(false); - expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true); - expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:primary" })).toEqual({ - deny: ["exec"], - }); - expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({ - allow: ["message.send"], - }); - }); -}); diff --git a/extensions/bluebubbles/src/setup-surface.test.ts b/extensions/bluebubbles/src/setup-surface.test.ts index e2d78cb0a1f..cb2241e30e5 100644 --- a/extensions/bluebubbles/src/setup-surface.test.ts +++ b/extensions/bluebubbles/src/setup-surface.test.ts @@ -8,6 +8,11 @@ import { type WizardPrompter, } from "../../../test/helpers/extensions/setup-wizard.js"; import { resolveBlueBubblesAccount } from "./accounts.js"; +import { BlueBubblesConfigSchema } from "./config-schema.js"; +import { + resolveBlueBubblesGroupRequireMention, + resolveBlueBubblesGroupToolPolicy, +} from "./group-policy.js"; import { DEFAULT_WEBHOOK_PATH } from "./webhook-shared.js"; async function createBlueBubblesConfigureAdapter() { @@ -138,3 +143,99 @@ describe("bluebubbles setup surface", () => { expect(next?.channels?.bluebubbles?.enabled).toBe(false); }); }); + +describe("BlueBubblesConfigSchema", () => { + it("accepts account config when serverUrl and password are both set", () => { + const parsed = BlueBubblesConfigSchema.safeParse({ + serverUrl: "http://localhost:1234", + password: "secret", // pragma: allowlist secret + }); + expect(parsed.success).toBe(true); + }); + + it("accepts SecretRef password when serverUrl is set", () => { + const parsed = BlueBubblesConfigSchema.safeParse({ + serverUrl: "http://localhost:1234", + password: { + source: "env", + provider: "default", + id: "BLUEBUBBLES_PASSWORD", + }, + }); + expect(parsed.success).toBe(true); + }); + + it("requires password when top-level serverUrl is configured", () => { + const parsed = BlueBubblesConfigSchema.safeParse({ + serverUrl: "http://localhost:1234", + }); + expect(parsed.success).toBe(false); + if (parsed.success) { + return; + } + expect(parsed.error.issues[0]?.path).toEqual(["password"]); + expect(parsed.error.issues[0]?.message).toBe( + "password is required when serverUrl is configured", + ); + }); + + it("requires password when account serverUrl is configured", () => { + const parsed = BlueBubblesConfigSchema.safeParse({ + accounts: { + work: { + serverUrl: "http://localhost:1234", + }, + }, + }); + expect(parsed.success).toBe(false); + if (parsed.success) { + return; + } + expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "password"]); + expect(parsed.error.issues[0]?.message).toBe( + "password is required when serverUrl is configured", + ); + }); + + it("allows password omission when serverUrl is not configured", () => { + const parsed = BlueBubblesConfigSchema.safeParse({ + accounts: { + work: { + name: "Work iMessage", + }, + }, + }); + expect(parsed.success).toBe(true); + }); +}); + +describe("bluebubbles group policy", () => { + it("uses generic channel group policy helpers", () => { + const cfg = { + channels: { + bluebubbles: { + groups: { + "chat:primary": { + requireMention: false, + tools: { deny: ["exec"] }, + }, + "*": { + requireMention: true, + tools: { allow: ["message.send"] }, + }, + }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:primary" })).toBe(false); + expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true); + expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:primary" })).toEqual({ + deny: ["exec"], + }); + expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({ + allow: ["message.send"], + }); + }); +}); diff --git a/extensions/googlechat/src/channel.directory.test.ts b/extensions/googlechat/src/channel.directory.test.ts deleted file mode 100644 index 7228a91b973..00000000000 --- a/extensions/googlechat/src/channel.directory.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - createDirectoryTestRuntime, - expectDirectorySurface, -} from "../../../test/helpers/extensions/directory.ts"; -import type { OpenClawConfig } from "../runtime-api.js"; -import { googlechatPlugin } from "./channel.js"; - -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(googlechatPlugin.directory); - - await expect( - directory.listPeers({ - cfg, - accountId: undefined, - query: undefined, - limit: undefined, - runtime: runtimeEnv, - }), - ).resolves.toEqual( - expect.arrayContaining([ - { kind: "user", id: "users/alice" }, - { kind: "user", id: "bob" }, - ]), - ); - - await expect( - directory.listGroups({ - cfg, - accountId: undefined, - query: undefined, - limit: undefined, - runtime: runtimeEnv, - }), - ).resolves.toEqual( - expect.arrayContaining([ - { 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(googlechatPlugin.directory); - - await expect( - directory.listPeers({ - cfg, - accountId: undefined, - query: undefined, - limit: undefined, - runtime: runtimeEnv, - }), - ).resolves.toEqual( - expect.arrayContaining([ - { kind: "user", id: "users/alice" }, - { kind: "user", id: "users/bob@example.com" }, - ]), - ); - }); -}); diff --git a/extensions/googlechat/src/channel.security.test.ts b/extensions/googlechat/src/channel.security.test.ts deleted file mode 100644 index da7ebb27d74..00000000000 --- a/extensions/googlechat/src/channel.security.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../runtime-api.js"; -import { googlechatPlugin } from "./channel.js"; - -describe("googlechatPlugin security", () => { - it("normalizes prefixed DM allowlist entries to lowercase user ids", () => { - const security = googlechatPlugin.security; - if (!security) { - throw new Error("googlechat security unavailable"); - } - const resolveDmPolicy = security.resolveDmPolicy; - const normalizeAllowEntry = googlechatPlugin.pairing?.normalizeAllowEntry; - expect(resolveDmPolicy).toBeTypeOf("function"); - expect(normalizeAllowEntry).toBeTypeOf("function"); - - const cfg = { - channels: { - googlechat: { - serviceAccount: { client_email: "bot@example.com" }, - dm: { - policy: "allowlist", - allowFrom: [" googlechat:user:Bob@Example.com "], - }, - }, - }, - } as OpenClawConfig; - - const account = googlechatPlugin.config.resolveAccount(cfg, "default"); - const resolved = resolveDmPolicy!({ cfg, account }); - if (!resolved) { - throw new Error("googlechat resolveDmPolicy returned null"); - } - - expect(resolved.policy).toBe("allowlist"); - expect(resolved.allowFrom).toEqual([" googlechat:user:Bob@Example.com "]); - expect(resolved.normalizeEntry?.(" googlechat:user:Bob@Example.com ")).toBe( - "bob@example.com", - ); - expect(normalizeAllowEntry!(" users/Alice@Example.com ")).toBe("alice@example.com"); - }); -}); diff --git a/extensions/googlechat/src/channel.startup.test.ts b/extensions/googlechat/src/channel.startup.test.ts deleted file mode 100644 index 67c0e054dd9..00000000000 --- a/extensions/googlechat/src/channel.startup.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - expectLifecyclePatch, - expectPendingUntilAbort, - startAccountAndTrackLifecycle, - waitForStartedMocks, -} from "../../../test/helpers/extensions/start-account-lifecycle.js"; -import type { ResolvedGoogleChatAccount } from "./accounts.js"; - -const hoisted = vi.hoisted(() => ({ - startGoogleChatMonitor: vi.fn(), -})); - -vi.mock("./monitor.js", async () => { - const actual = await vi.importActual("./monitor.js"); - return { - ...actual, - startGoogleChatMonitor: hoisted.startGoogleChatMonitor, - }; -}); - -import { googlechatPlugin } from "./channel.js"; - -function buildAccount(): ResolvedGoogleChatAccount { - return { - accountId: "default", - enabled: true, - credentialSource: "inline", - credentials: {}, - config: { - webhookPath: "/googlechat", - webhookUrl: "https://example.com/googlechat", - audienceType: "app-url", - audience: "https://example.com/googlechat", - }, - }; -} - -describe("googlechatPlugin gateway.startAccount", () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - it("keeps startAccount pending until abort, then unregisters", async () => { - const unregister = vi.fn(); - hoisted.startGoogleChatMonitor.mockResolvedValue(unregister); - - const { abort, patches, task, isSettled } = startAccountAndTrackLifecycle({ - startAccount: googlechatPlugin.gateway!.startAccount!, - account: buildAccount(), - }); - await expectPendingUntilAbort({ - waitForStarted: waitForStartedMocks(hoisted.startGoogleChatMonitor), - isSettled, - abort, - task, - assertBeforeAbort: () => { - expect(unregister).not.toHaveBeenCalled(); - }, - assertAfterAbort: () => { - expect(unregister).toHaveBeenCalledOnce(); - }, - }); - expectLifecyclePatch(patches, { running: true }); - expectLifecyclePatch(patches, { running: false }); - }); -}); diff --git a/extensions/googlechat/src/channel.outbound.test.ts b/extensions/googlechat/src/channel.test.ts similarity index 56% rename from extensions/googlechat/src/channel.outbound.test.ts rename to extensions/googlechat/src/channel.test.ts index a3cbcd20d38..a90a1a899fb 100644 --- a/extensions/googlechat/src/channel.outbound.test.ts +++ b/extensions/googlechat/src/channel.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it, vi } from "vitest"; +import { + createDirectoryTestRuntime, + expectDirectorySurface, +} from "../../../test/helpers/extensions/directory.ts"; import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js"; const uploadGoogleChatAttachmentMock = vi.hoisted(() => vi.fn()); @@ -157,3 +161,120 @@ describe("googlechatPlugin outbound sendMedia", () => { }); }); }); + +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(googlechatPlugin.directory); + + await expect( + directory.listPeers({ + cfg, + accountId: undefined, + query: undefined, + limit: undefined, + runtime: runtimeEnv, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "user", id: "users/alice" }, + { kind: "user", id: "bob" }, + ]), + ); + + await expect( + directory.listGroups({ + cfg, + accountId: undefined, + query: undefined, + limit: undefined, + runtime: runtimeEnv, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { 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(googlechatPlugin.directory); + + await expect( + directory.listPeers({ + cfg, + accountId: undefined, + query: undefined, + limit: undefined, + runtime: runtimeEnv, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { 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 security = googlechatPlugin.security; + if (!security) { + throw new Error("googlechat security unavailable"); + } + const resolveDmPolicy = security.resolveDmPolicy; + const normalizeAllowEntry = googlechatPlugin.pairing?.normalizeAllowEntry; + expect(resolveDmPolicy).toBeTypeOf("function"); + expect(normalizeAllowEntry).toBeTypeOf("function"); + + const cfg = { + channels: { + googlechat: { + serviceAccount: { client_email: "bot@example.com" }, + dm: { + policy: "allowlist", + allowFrom: [" googlechat:user:Bob@Example.com "], + }, + }, + }, + } as OpenClawConfig; + + const account = googlechatPlugin.config.resolveAccount(cfg, "default"); + const resolved = resolveDmPolicy!({ cfg, account }); + if (!resolved) { + throw new Error("googlechat resolveDmPolicy returned null"); + } + + expect(resolved.policy).toBe("allowlist"); + expect(resolved.allowFrom).toEqual([" googlechat:user:Bob@Example.com "]); + expect(resolved.normalizeEntry?.(" googlechat:user:Bob@Example.com ")).toBe( + "bob@example.com", + ); + expect(normalizeAllowEntry!(" users/Alice@Example.com ")).toBe("alice@example.com"); + }); +}); diff --git a/extensions/googlechat/src/group-policy.test.ts b/extensions/googlechat/src/group-policy.test.ts deleted file mode 100644 index 5f907695aad..00000000000 --- a/extensions/googlechat/src/group-policy.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveGoogleChatGroupRequireMention } from "./group-policy.js"; - -describe("googlechat group policy", () => { - it("uses generic channel group policy helpers", () => { - const cfg = { - channels: { - googlechat: { - groups: { - "spaces/AAA": { - requireMention: false, - }, - "*": { - requireMention: true, - }, - }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/AAA" })).toBe(false); - expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/BBB" })).toBe(true); - }); -}); diff --git a/extensions/googlechat/src/setup-core.test.ts b/extensions/googlechat/src/setup-core.test.ts deleted file mode 100644 index 1fa8a00ffce..00000000000 --- a/extensions/googlechat/src/setup-core.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; -import { describe, expect, it } from "vitest"; -import { googlechatSetupAdapter } from "./setup-core.js"; - -describe("googlechat setup core", () => { - it("rejects env auth for non-default accounts", () => { - if (!googlechatSetupAdapter.validateInput) { - throw new Error("Expected googlechatSetupAdapter.validateInput to be defined"); - } - expect( - googlechatSetupAdapter.validateInput({ - accountId: "secondary", - input: { useEnv: true }, - } as never), - ).toBe("GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account."); - }); - - it("requires inline or file credentials when env auth is not used", () => { - if (!googlechatSetupAdapter.validateInput) { - throw new Error("Expected googlechatSetupAdapter.validateInput to be defined"); - } - expect( - googlechatSetupAdapter.validateInput({ - accountId: DEFAULT_ACCOUNT_ID, - input: { useEnv: false, token: "", tokenFile: "" }, - } as never), - ).toBe("Google Chat requires --token (service account JSON) or --token-file."); - }); - - it("builds a patch from token-file and trims optional webhook fields", () => { - if (!googlechatSetupAdapter.applyAccountConfig) { - throw new Error("Expected googlechatSetupAdapter.applyAccountConfig to be defined"); - } - expect( - googlechatSetupAdapter.applyAccountConfig({ - cfg: { channels: { googlechat: {} } }, - accountId: DEFAULT_ACCOUNT_ID, - input: { - name: "Default", - tokenFile: "/tmp/googlechat.json", - audienceType: " app-url ", - audience: " https://example.com/googlechat ", - webhookPath: " /googlechat ", - webhookUrl: " https://example.com/googlechat/hook ", - }, - } as never), - ).toEqual({ - channels: { - googlechat: { - enabled: true, - name: "Default", - serviceAccountFile: "/tmp/googlechat.json", - audienceType: "app-url", - audience: "https://example.com/googlechat", - webhookPath: "/googlechat", - webhookUrl: "https://example.com/googlechat/hook", - }, - }, - }); - }); - - it("prefers inline token patch when token-file is absent", () => { - if (!googlechatSetupAdapter.applyAccountConfig) { - throw new Error("Expected googlechatSetupAdapter.applyAccountConfig to be defined"); - } - expect( - googlechatSetupAdapter.applyAccountConfig({ - cfg: { channels: { googlechat: {} } }, - accountId: DEFAULT_ACCOUNT_ID, - input: { - name: "Default", - token: { client_email: "bot@example.com" }, - }, - } as never), - ).toEqual({ - channels: { - googlechat: { - enabled: true, - name: "Default", - serviceAccount: { client_email: "bot@example.com" }, - }, - }, - }); - }); -}); diff --git a/extensions/googlechat/src/setup-surface.test.ts b/extensions/googlechat/src/setup-surface.test.ts deleted file mode 100644 index 750bef39a71..00000000000 --- a/extensions/googlechat/src/setup-surface.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - createPluginSetupWizardConfigure, - createTestWizardPrompter, - runSetupWizardConfigure, - type WizardPrompter, -} from "../../../test/helpers/extensions/setup-wizard.js"; -import type { OpenClawConfig } from "../runtime-api.js"; -import { googlechatPlugin } from "./channel.js"; - -const googlechatConfigure = createPluginSetupWizardConfigure(googlechatPlugin); - -describe("googlechat setup wizard", () => { - it("configures service-account auth and webhook audience", async () => { - const prompter = createTestWizardPrompter({ - text: vi.fn(async ({ message }: { message: string }) => { - if (message === "Service account JSON path") { - return "/tmp/googlechat-service-account.json"; - } - if (message === "App URL") { - return "https://example.com/googlechat"; - } - throw new Error(`Unexpected prompt: ${message}`); - }) as WizardPrompter["text"], - }); - - const result = await runSetupWizardConfigure({ - configure: googlechatConfigure, - cfg: {} as OpenClawConfig, - prompter, - options: {}, - }); - - expect(result.accountId).toBe("default"); - expect(result.cfg.channels?.googlechat?.enabled).toBe(true); - expect(result.cfg.channels?.googlechat?.serviceAccountFile).toBe( - "/tmp/googlechat-service-account.json", - ); - expect(result.cfg.channels?.googlechat?.audienceType).toBe("app-url"); - expect(result.cfg.channels?.googlechat?.audience).toBe("https://example.com/googlechat"); - }); -}); diff --git a/extensions/googlechat/src/setup.test.ts b/extensions/googlechat/src/setup.test.ts new file mode 100644 index 00000000000..7d0f28fee9e --- /dev/null +++ b/extensions/googlechat/src/setup.test.ts @@ -0,0 +1,186 @@ +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createPluginSetupWizardConfigure, + createTestWizardPrompter, + runSetupWizardConfigure, + type WizardPrompter, +} from "../../../test/helpers/extensions/setup-wizard.js"; +import { + expectLifecyclePatch, + expectPendingUntilAbort, + startAccountAndTrackLifecycle, + waitForStartedMocks, +} from "../../../test/helpers/extensions/start-account-lifecycle.js"; +import type { OpenClawConfig } from "../runtime-api.js"; +import type { ResolvedGoogleChatAccount } from "./accounts.js"; +import { googlechatPlugin } from "./channel.js"; +import { googlechatSetupAdapter } from "./setup-core.js"; + +const hoisted = vi.hoisted(() => ({ + startGoogleChatMonitor: vi.fn(), +})); + +vi.mock("./monitor.js", async () => { + const actual = await vi.importActual("./monitor.js"); + return { + ...actual, + startGoogleChatMonitor: hoisted.startGoogleChatMonitor, + }; +}); + +const googlechatConfigure = createPluginSetupWizardConfigure(googlechatPlugin); + +function buildAccount(): ResolvedGoogleChatAccount { + return { + accountId: "default", + enabled: true, + credentialSource: "inline", + credentials: {}, + config: { + webhookPath: "/googlechat", + webhookUrl: "https://example.com/googlechat", + audienceType: "app-url", + audience: "https://example.com/googlechat", + }, + }; +} + +describe("googlechat setup", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("rejects env auth for non-default accounts", () => { + if (!googlechatSetupAdapter.validateInput) { + throw new Error("Expected googlechatSetupAdapter.validateInput to be defined"); + } + expect( + googlechatSetupAdapter.validateInput({ + accountId: "secondary", + input: { useEnv: true }, + } as never), + ).toBe("GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account."); + }); + + it("requires inline or file credentials when env auth is not used", () => { + if (!googlechatSetupAdapter.validateInput) { + throw new Error("Expected googlechatSetupAdapter.validateInput to be defined"); + } + expect( + googlechatSetupAdapter.validateInput({ + accountId: DEFAULT_ACCOUNT_ID, + input: { useEnv: false, token: "", tokenFile: "" }, + } as never), + ).toBe("Google Chat requires --token (service account JSON) or --token-file."); + }); + + it("builds a patch from token-file and trims optional webhook fields", () => { + if (!googlechatSetupAdapter.applyAccountConfig) { + throw new Error("Expected googlechatSetupAdapter.applyAccountConfig to be defined"); + } + expect( + googlechatSetupAdapter.applyAccountConfig({ + cfg: { channels: { googlechat: {} } }, + accountId: DEFAULT_ACCOUNT_ID, + input: { + name: "Default", + tokenFile: "/tmp/googlechat.json", + audienceType: " app-url ", + audience: " https://example.com/googlechat ", + webhookPath: " /googlechat ", + webhookUrl: " https://example.com/googlechat/hook ", + }, + } as never), + ).toEqual({ + channels: { + googlechat: { + enabled: true, + name: "Default", + serviceAccountFile: "/tmp/googlechat.json", + audienceType: "app-url", + audience: "https://example.com/googlechat", + webhookPath: "/googlechat", + webhookUrl: "https://example.com/googlechat/hook", + }, + }, + }); + }); + + it("prefers inline token patch when token-file is absent", () => { + if (!googlechatSetupAdapter.applyAccountConfig) { + throw new Error("Expected googlechatSetupAdapter.applyAccountConfig to be defined"); + } + expect( + googlechatSetupAdapter.applyAccountConfig({ + cfg: { channels: { googlechat: {} } }, + accountId: DEFAULT_ACCOUNT_ID, + input: { + name: "Default", + token: { client_email: "bot@example.com" }, + }, + } as never), + ).toEqual({ + channels: { + googlechat: { + enabled: true, + name: "Default", + serviceAccount: { client_email: "bot@example.com" }, + }, + }, + }); + }); + + it("configures service-account auth and webhook audience", async () => { + const prompter = createTestWizardPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Service account JSON path") { + return "/tmp/googlechat-service-account.json"; + } + if (message === "App URL") { + return "https://example.com/googlechat"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + }); + + const result = await runSetupWizardConfigure({ + configure: googlechatConfigure, + cfg: {} as OpenClawConfig, + prompter, + options: {}, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.googlechat?.enabled).toBe(true); + expect(result.cfg.channels?.googlechat?.serviceAccountFile).toBe( + "/tmp/googlechat-service-account.json", + ); + expect(result.cfg.channels?.googlechat?.audienceType).toBe("app-url"); + expect(result.cfg.channels?.googlechat?.audience).toBe("https://example.com/googlechat"); + }); + + it("keeps startAccount pending until abort, then unregisters", async () => { + const unregister = vi.fn(); + hoisted.startGoogleChatMonitor.mockResolvedValue(unregister); + + const { abort, patches, task, isSettled } = startAccountAndTrackLifecycle({ + startAccount: googlechatPlugin.gateway!.startAccount!, + account: buildAccount(), + }); + await expectPendingUntilAbort({ + waitForStarted: waitForStartedMocks(hoisted.startGoogleChatMonitor), + isSettled, + abort, + task, + assertBeforeAbort: () => { + expect(unregister).not.toHaveBeenCalled(); + }, + assertAfterAbort: () => { + expect(unregister).toHaveBeenCalledOnce(); + }, + }); + expectLifecyclePatch(patches, { running: true }); + expectLifecyclePatch(patches, { running: false }); + }); +}); diff --git a/extensions/googlechat/src/targets.test.ts b/extensions/googlechat/src/targets.test.ts index bb49bd0ec1f..ea2b80b0407 100644 --- a/extensions/googlechat/src/targets.test.ts +++ b/extensions/googlechat/src/targets.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { resolveGoogleChatGroupRequireMention } from "./group-policy.js"; import { isGoogleChatSpaceTarget, isGoogleChatUserTarget, @@ -30,3 +31,26 @@ describe("target helpers", () => { expect(isGoogleChatUserTarget("spaces/abc")).toBe(false); }); }); + +describe("googlechat group policy", () => { + it("uses generic channel group policy helpers", () => { + const cfg = { + channels: { + googlechat: { + groups: { + "spaces/AAA": { + requireMention: false, + }, + "*": { + requireMention: true, + }, + }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/AAA" })).toBe(false); + expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/BBB" })).toBe(true); + }); +}); diff --git a/extensions/nostr/src/channel.test.ts b/extensions/nostr/src/channel.test.ts index 848b15a4c41..d99fd5aa60d 100644 --- a/extensions/nostr/src/channel.test.ts +++ b/extensions/nostr/src/channel.test.ts @@ -1,6 +1,20 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { + createPluginSetupWizardConfigure, + createTestWizardPrompter, + runSetupWizardConfigure, + type WizardPrompter, +} from "../../../test/helpers/extensions/setup-wizard.js"; +import type { OpenClawConfig } from "../runtime-api.js"; import { nostrPlugin } from "./channel.js"; -import { TEST_HEX_PRIVATE_KEY, createConfiguredNostrCfg } from "./test-fixtures.js"; +import { + TEST_HEX_PRIVATE_KEY, + TEST_SETUP_RELAY_URLS, + createConfiguredNostrCfg, +} from "./test-fixtures.js"; +import { listNostrAccountIds, resolveDefaultNostrAccountId, resolveNostrAccount } from "./types.js"; + +const nostrConfigure = createPluginSetupWizardConfigure(nostrPlugin); function requireNostrLooksLikeId() { const looksLikeId = nostrPlugin.messaging?.targetResolver?.looksLikeId; @@ -161,3 +175,162 @@ describe("nostrPlugin", () => { }); }); }); + +describe("nostr setup wizard", () => { + it("configures a private key and relay URLs", async () => { + const prompter = createTestWizardPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Nostr private key (nsec... or hex)") { + return TEST_HEX_PRIVATE_KEY; + } + if (message === "Relay URLs (comma-separated, optional)") { + return TEST_SETUP_RELAY_URLS.join(", "); + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + }); + + const result = await runSetupWizardConfigure({ + configure: nostrConfigure, + cfg: {} as OpenClawConfig, + prompter, + options: {}, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.nostr?.enabled).toBe(true); + expect(result.cfg.channels?.nostr?.privateKey).toBe(TEST_HEX_PRIVATE_KEY); + expect(result.cfg.channels?.nostr?.relays).toEqual(TEST_SETUP_RELAY_URLS); + }); +}); + +describe("nostr account helpers", () => { + describe("listNostrAccountIds", () => { + it("returns empty array when not configured", () => { + const cfg = { channels: {} }; + expect(listNostrAccountIds(cfg)).toEqual([]); + }); + + it("returns empty array when nostr section exists but no privateKey", () => { + const cfg = { channels: { nostr: { enabled: true } } }; + expect(listNostrAccountIds(cfg)).toEqual([]); + }); + + it("returns default when privateKey is configured", () => { + const cfg = createConfiguredNostrCfg(); + expect(listNostrAccountIds(cfg)).toEqual(["default"]); + }); + + it("returns configured defaultAccount when privateKey is configured", () => { + const cfg = createConfiguredNostrCfg({ defaultAccount: "work" }); + expect(listNostrAccountIds(cfg)).toEqual(["work"]); + }); + }); + + describe("resolveDefaultNostrAccountId", () => { + it("returns default when configured", () => { + const cfg = createConfiguredNostrCfg(); + expect(resolveDefaultNostrAccountId(cfg)).toBe("default"); + }); + + it("returns default when not configured", () => { + const cfg = { channels: {} }; + expect(resolveDefaultNostrAccountId(cfg)).toBe("default"); + }); + + it("prefers configured defaultAccount when present", () => { + const cfg = createConfiguredNostrCfg({ defaultAccount: "work" }); + expect(resolveDefaultNostrAccountId(cfg)).toBe("work"); + }); + }); + + describe("resolveNostrAccount", () => { + it("resolves configured account", () => { + const cfg = createConfiguredNostrCfg({ + name: "Test Bot", + relays: ["wss://test.relay"], + dmPolicy: "pairing" as const, + }); + const account = resolveNostrAccount({ cfg }); + + expect(account.accountId).toBe("default"); + expect(account.name).toBe("Test Bot"); + expect(account.enabled).toBe(true); + expect(account.configured).toBe(true); + expect(account.privateKey).toBe(TEST_HEX_PRIVATE_KEY); + expect(account.publicKey).toMatch(/^[0-9a-f]{64}$/); + expect(account.relays).toEqual(["wss://test.relay"]); + }); + + it("resolves unconfigured account with defaults", () => { + const cfg = { channels: {} }; + const account = resolveNostrAccount({ cfg }); + + expect(account.accountId).toBe("default"); + expect(account.enabled).toBe(true); + expect(account.configured).toBe(false); + expect(account.privateKey).toBe(""); + expect(account.publicKey).toBe(""); + expect(account.relays).toContain("wss://relay.damus.io"); + expect(account.relays).toContain("wss://nos.lol"); + }); + + it("handles disabled channel", () => { + const cfg = createConfiguredNostrCfg({ enabled: false }); + const account = resolveNostrAccount({ cfg }); + + expect(account.enabled).toBe(false); + expect(account.configured).toBe(true); + }); + + it("handles custom accountId parameter", () => { + const cfg = createConfiguredNostrCfg(); + const account = resolveNostrAccount({ cfg, accountId: "custom" }); + + expect(account.accountId).toBe("custom"); + }); + + it("handles allowFrom config", () => { + const cfg = createConfiguredNostrCfg({ + allowFrom: ["npub1test", "0123456789abcdef"], + }); + const account = resolveNostrAccount({ cfg }); + + expect(account.config.allowFrom).toEqual(["npub1test", "0123456789abcdef"]); + }); + + it("handles invalid private key gracefully", () => { + const cfg = { + channels: { + nostr: { + privateKey: "invalid-key", + }, + }, + }; + const account = resolveNostrAccount({ cfg }); + + expect(account.configured).toBe(true); + expect(account.publicKey).toBe(""); + }); + + it("preserves all config options", () => { + const cfg = createConfiguredNostrCfg({ + name: "Bot", + enabled: true, + relays: ["wss://relay1", "wss://relay2"], + dmPolicy: "allowlist" as const, + allowFrom: ["pubkey1", "pubkey2"], + }); + const account = resolveNostrAccount({ cfg }); + + expect(account.config).toEqual({ + privateKey: TEST_HEX_PRIVATE_KEY, + name: "Bot", + enabled: true, + relays: ["wss://relay1", "wss://relay2"], + dmPolicy: "allowlist", + allowFrom: ["pubkey1", "pubkey2"], + }); + }); + }); +}); diff --git a/extensions/nostr/src/setup-surface.test.ts b/extensions/nostr/src/setup-surface.test.ts deleted file mode 100644 index 952110dc25f..00000000000 --- a/extensions/nostr/src/setup-surface.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - createPluginSetupWizardConfigure, - createTestWizardPrompter, - runSetupWizardConfigure, - type WizardPrompter, -} from "../../../test/helpers/extensions/setup-wizard.js"; -import type { OpenClawConfig } from "../runtime-api.js"; -import { nostrPlugin } from "./channel.js"; -import { TEST_HEX_PRIVATE_KEY, TEST_SETUP_RELAY_URLS } from "./test-fixtures.js"; - -const nostrConfigure = createPluginSetupWizardConfigure(nostrPlugin); - -describe("nostr setup wizard", () => { - it("configures a private key and relay URLs", async () => { - const prompter = createTestWizardPrompter({ - text: vi.fn(async ({ message }: { message: string }) => { - if (message === "Nostr private key (nsec... or hex)") { - return TEST_HEX_PRIVATE_KEY; - } - if (message === "Relay URLs (comma-separated, optional)") { - return TEST_SETUP_RELAY_URLS.join(", "); - } - throw new Error(`Unexpected prompt: ${message}`); - }) as WizardPrompter["text"], - }); - - const result = await runSetupWizardConfigure({ - configure: nostrConfigure, - cfg: {} as OpenClawConfig, - prompter, - options: {}, - }); - - expect(result.accountId).toBe("default"); - expect(result.cfg.channels?.nostr?.enabled).toBe(true); - expect(result.cfg.channels?.nostr?.privateKey).toBe(TEST_HEX_PRIVATE_KEY); - expect(result.cfg.channels?.nostr?.relays).toEqual(TEST_SETUP_RELAY_URLS); - }); -}); diff --git a/extensions/nostr/src/types.test.ts b/extensions/nostr/src/types.test.ts deleted file mode 100644 index 408c80d3976..00000000000 --- a/extensions/nostr/src/types.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { TEST_HEX_PRIVATE_KEY, createConfiguredNostrCfg } from "./test-fixtures.js"; -import { listNostrAccountIds, resolveDefaultNostrAccountId, resolveNostrAccount } from "./types.js"; - -describe("listNostrAccountIds", () => { - it("returns empty array when not configured", () => { - const cfg = { channels: {} }; - expect(listNostrAccountIds(cfg)).toEqual([]); - }); - - it("returns empty array when nostr section exists but no privateKey", () => { - const cfg = { channels: { nostr: { enabled: true } } }; - expect(listNostrAccountIds(cfg)).toEqual([]); - }); - - it("returns default when privateKey is configured", () => { - const cfg = createConfiguredNostrCfg(); - expect(listNostrAccountIds(cfg)).toEqual(["default"]); - }); - - it("returns configured defaultAccount when privateKey is configured", () => { - const cfg = createConfiguredNostrCfg({ defaultAccount: "work" }); - expect(listNostrAccountIds(cfg)).toEqual(["work"]); - }); -}); - -describe("resolveDefaultNostrAccountId", () => { - it("returns default when configured", () => { - const cfg = createConfiguredNostrCfg(); - expect(resolveDefaultNostrAccountId(cfg)).toBe("default"); - }); - - it("returns default when not configured", () => { - const cfg = { channels: {} }; - expect(resolveDefaultNostrAccountId(cfg)).toBe("default"); - }); - - it("prefers configured defaultAccount when present", () => { - const cfg = createConfiguredNostrCfg({ defaultAccount: "work" }); - expect(resolveDefaultNostrAccountId(cfg)).toBe("work"); - }); -}); - -describe("resolveNostrAccount", () => { - it("resolves configured account", () => { - const cfg = createConfiguredNostrCfg({ - name: "Test Bot", - relays: ["wss://test.relay"], - dmPolicy: "pairing" as const, - }); - const account = resolveNostrAccount({ cfg }); - - expect(account.accountId).toBe("default"); - expect(account.name).toBe("Test Bot"); - expect(account.enabled).toBe(true); - expect(account.configured).toBe(true); - expect(account.privateKey).toBe(TEST_HEX_PRIVATE_KEY); - expect(account.publicKey).toMatch(/^[0-9a-f]{64}$/); - expect(account.relays).toEqual(["wss://test.relay"]); - }); - - it("resolves unconfigured account with defaults", () => { - const cfg = { channels: {} }; - const account = resolveNostrAccount({ cfg }); - - expect(account.accountId).toBe("default"); - expect(account.enabled).toBe(true); - expect(account.configured).toBe(false); - expect(account.privateKey).toBe(""); - expect(account.publicKey).toBe(""); - expect(account.relays).toContain("wss://relay.damus.io"); - expect(account.relays).toContain("wss://nos.lol"); - }); - - it("handles disabled channel", () => { - const cfg = createConfiguredNostrCfg({ enabled: false }); - const account = resolveNostrAccount({ cfg }); - - expect(account.enabled).toBe(false); - expect(account.configured).toBe(true); - }); - - it("handles custom accountId parameter", () => { - const cfg = createConfiguredNostrCfg(); - const account = resolveNostrAccount({ cfg, accountId: "custom" }); - - expect(account.accountId).toBe("custom"); - }); - - it("handles allowFrom config", () => { - const cfg = createConfiguredNostrCfg({ - allowFrom: ["npub1test", "0123456789abcdef"], - }); - const account = resolveNostrAccount({ cfg }); - - expect(account.config.allowFrom).toEqual(["npub1test", "0123456789abcdef"]); - }); - - it("handles invalid private key gracefully", () => { - const cfg = { - channels: { - nostr: { - privateKey: "invalid-key", - }, - }, - }; - const account = resolveNostrAccount({ cfg }); - - expect(account.configured).toBe(true); // key is present - expect(account.publicKey).toBe(""); // but can't derive pubkey - }); - - it("preserves all config options", () => { - const cfg = createConfiguredNostrCfg({ - name: "Bot", - enabled: true, - relays: ["wss://relay1", "wss://relay2"], - dmPolicy: "allowlist" as const, - allowFrom: ["pubkey1", "pubkey2"], - }); - const account = resolveNostrAccount({ cfg }); - - expect(account.config).toEqual({ - privateKey: TEST_HEX_PRIVATE_KEY, - name: "Bot", - enabled: true, - relays: ["wss://relay1", "wss://relay2"], - dmPolicy: "allowlist", - allowFrom: ["pubkey1", "pubkey2"], - }); - }); -});