diff --git a/extensions/nextcloud-talk/src/channel.lifecycle.test.ts b/extensions/nextcloud-talk/src/channel.lifecycle.test.ts new file mode 100644 index 00000000000..2b0474f4d28 --- /dev/null +++ b/extensions/nextcloud-talk/src/channel.lifecycle.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createStartAccountContext } from "../../../test/helpers/plugins/start-account-context.js"; +import { + expectStopPendingUntilAbort, + startAccountAndTrackLifecycle, + waitForStartedMocks, +} from "../../../test/helpers/plugins/start-account-lifecycle.js"; +import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; + +vi.mock("../../../src/config/bundled-channel-config-runtime.js", () => ({ + getBundledChannelRuntimeMap: () => new Map(), + getBundledChannelConfigSchemaMap: () => new Map(), +})); + +vi.mock("../../../src/channels/plugins/bundled.js", () => ({ + bundledChannelPlugins: [], + bundledChannelSetupPlugins: [], +})); + +const hoisted = vi.hoisted(() => ({ + monitorNextcloudTalkProvider: vi.fn(), +})); + +vi.mock("./monitor.js", async () => { + const actual = await vi.importActual("./monitor.js"); + return { + ...actual, + monitorNextcloudTalkProvider: hoisted.monitorNextcloudTalkProvider, + }; +}); + +const { nextcloudTalkPlugin } = await import("./channel.js"); + +function buildAccount(): ResolvedNextcloudTalkAccount { + return { + accountId: "default", + enabled: true, + baseUrl: "https://nextcloud.example.com", + secret: "secret", // pragma: allowlist secret + secretSource: "config", // pragma: allowlist secret + config: { + baseUrl: "https://nextcloud.example.com", + botSecret: "secret", // pragma: allowlist secret + webhookPath: "/nextcloud-talk-webhook", + webhookPort: 8788, + }, + }; +} + +function mockStartedMonitor() { + const stop = vi.fn(); + hoisted.monitorNextcloudTalkProvider.mockResolvedValue({ stop }); + return stop; +} + +function startNextcloudAccount(abortSignal?: AbortSignal) { + return nextcloudTalkPlugin.gateway!.startAccount!( + createStartAccountContext({ + account: buildAccount(), + abortSignal, + }), + ); +} + +describe("nextcloud-talk startAccount lifecycle", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("keeps startAccount pending until abort, then stops the monitor", async () => { + const stop = mockStartedMonitor(); + const { abort, task, isSettled } = startAccountAndTrackLifecycle({ + startAccount: nextcloudTalkPlugin.gateway!.startAccount!, + account: buildAccount(), + }); + await expectStopPendingUntilAbort({ + waitForStarted: waitForStartedMocks(hoisted.monitorNextcloudTalkProvider), + isSettled, + abort, + task, + stop, + }); + }); + + it("stops immediately when startAccount receives an already-aborted signal", async () => { + const stop = mockStartedMonitor(); + const abort = new AbortController(); + abort.abort(); + + await startNextcloudAccount(abort.signal); + + expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce(); + expect(stop).toHaveBeenCalledOnce(); + }); +}); diff --git a/extensions/nextcloud-talk/src/send.cfg-threading.test.ts b/extensions/nextcloud-talk/src/send.cfg-threading.test.ts new file mode 100644 index 00000000000..7e800500c41 --- /dev/null +++ b/extensions/nextcloud-talk/src/send.cfg-threading.test.ts @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createSendCfgThreadingRuntime, + expectProvidedCfgSkipsRuntimeLoad, + expectRuntimeCfgFallback, +} from "../../../test/helpers/plugins/send-config.js"; + +const hoisted = vi.hoisted(() => ({ + loadConfig: vi.fn(), + resolveMarkdownTableMode: vi.fn(() => "preserve"), + convertMarkdownTables: vi.fn((text: string) => text), + record: vi.fn(), + resolveNextcloudTalkAccount: vi.fn(), + generateNextcloudTalkSignature: vi.fn(() => ({ + random: "r", + signature: "s", + })), + mockFetchGuard: vi.fn(), +})); + +vi.mock("./runtime.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getNextcloudTalkRuntime: () => createSendCfgThreadingRuntime(hoisted), + }; +}); + +vi.mock("./accounts.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveNextcloudTalkAccount: hoisted.resolveNextcloudTalkAccount, + }; +}); + +vi.mock("./signature.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateNextcloudTalkSignature: hoisted.generateNextcloudTalkSignature, + }; +}); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const original = (await importOriginal()) as Record; + return { + ...original, + fetchWithSsrFGuard: hoisted.mockFetchGuard, + }; +}); + +vi.mock("../../../src/infra/net/fetch-guard.js", async (importOriginal) => { + const original = (await importOriginal()) as Record; + return { + ...original, + fetchWithSsrFGuard: hoisted.mockFetchGuard, + }; +}); + +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const original = (await importOriginal()) as Record; + return { + ...original, + resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode, + }; +}); + +vi.mock("openclaw/plugin-sdk/text-runtime", async (importOriginal) => { + const original = (await importOriginal()) as Record; + return { + ...original, + convertMarkdownTables: hoisted.convertMarkdownTables, + }; +}); + +const accountsActual = await vi.importActual("./accounts.js"); +hoisted.resolveNextcloudTalkAccount.mockImplementation(accountsActual.resolveNextcloudTalkAccount); + +const { sendMessageNextcloudTalk, sendReactionNextcloudTalk } = await import("./send.js"); + +describe("nextcloud-talk send cfg threading", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + // Route the SSRF guard mock through the global fetch mock. + hoisted.mockFetchGuard.mockImplementation(async (p: { url: string; init?: RequestInit }) => { + const response = await globalThis.fetch(p.url, p.init); + return { response, release: async () => {}, finalUrl: p.url }; + }); + hoisted.resolveNextcloudTalkAccount.mockImplementation( + accountsActual.resolveNextcloudTalkAccount, + ); + }); + + afterEach(() => { + fetchMock.mockReset(); + hoisted.mockFetchGuard.mockReset(); + vi.unstubAllGlobals(); + }); + + it("uses provided cfg for sendMessage and skips runtime loadConfig", async () => { + const cfg = { source: "provided" } as const; + hoisted.resolveNextcloudTalkAccount.mockReturnValue({ + accountId: "default", + baseUrl: "https://nextcloud.example.com", + secret: "secret-value", // pragma: allowlist secret + }); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + ocs: { data: { id: 12345, timestamp: 1_706_000_000 } }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + const result = await sendMessageNextcloudTalk("room:abc123", "hello", { + cfg, + accountId: "work", + }); + + expectProvidedCfgSkipsRuntimeLoad({ + loadConfig: hoisted.loadConfig, + resolveAccount: hoisted.resolveNextcloudTalkAccount, + cfg, + accountId: "work", + }); + expect(hoisted.resolveMarkdownTableMode).toHaveBeenCalledWith({ + cfg, + channel: "nextcloud-talk", + accountId: "default", + }); + expect(hoisted.convertMarkdownTables).toHaveBeenCalledWith("hello", "preserve"); + expect(hoisted.record).toHaveBeenCalledWith({ + channel: "nextcloud-talk", + accountId: "default", + direction: "outbound", + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + messageId: "12345", + roomToken: "abc123", + timestamp: 1_706_000_000, + }); + }); + + it("sends with provided cfg even when the runtime store is not initialized", async () => { + const cfg = { source: "provided" } as const; + hoisted.resolveNextcloudTalkAccount.mockReturnValue({ + accountId: "default", + baseUrl: "https://nextcloud.example.com", + secret: "secret-value", + }); + hoisted.record.mockImplementation(() => { + throw new Error("Nextcloud Talk runtime not initialized"); + }); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + ocs: { data: { id: 12346, timestamp: 1_706_000_001 } }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + const result = await sendMessageNextcloudTalk("room:abc123", "hello", { + cfg, + accountId: "work", + }); + + expectProvidedCfgSkipsRuntimeLoad({ + loadConfig: hoisted.loadConfig, + resolveAccount: hoisted.resolveNextcloudTalkAccount, + cfg, + accountId: "work", + }); + expect(hoisted.resolveMarkdownTableMode).toHaveBeenCalledWith({ + cfg, + channel: "nextcloud-talk", + accountId: "default", + }); + expect(hoisted.convertMarkdownTables).toHaveBeenCalledWith("hello", "preserve"); + expect(result).toEqual({ + messageId: "12346", + roomToken: "abc123", + timestamp: 1_706_000_001, + }); + }); + + it("falls back to runtime cfg for sendReaction when cfg is omitted", async () => { + const runtimeCfg = { source: "runtime" } as const; + hoisted.loadConfig.mockReturnValueOnce(runtimeCfg); + hoisted.resolveNextcloudTalkAccount.mockReturnValue({ + accountId: "default", + baseUrl: "https://nextcloud.example.com", + secret: "secret-value", // pragma: allowlist secret + }); + fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 })); + + const result = await sendReactionNextcloudTalk("room:ops", "m-1", "👍", { + accountId: "default", + }); + + expect(result).toEqual({ ok: true }); + expectRuntimeCfgFallback({ + loadConfig: hoisted.loadConfig, + resolveAccount: hoisted.resolveNextcloudTalkAccount, + cfg: runtimeCfg, + accountId: "default", + }); + }); +}); diff --git a/extensions/nextcloud-talk/src/setup.test.ts b/extensions/nextcloud-talk/src/setup.test.ts index 83c03672f03..8772d0a224a 100644 --- a/extensions/nextcloud-talk/src/setup.test.ts +++ b/extensions/nextcloud-talk/src/setup.test.ts @@ -2,164 +2,20 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { resolveNextcloudTalkAccount } from "./accounts.js"; import { - createSendCfgThreadingRuntime, - expectProvidedCfgSkipsRuntimeLoad, - expectRuntimeCfgFallback, -} from "../../../test/helpers/plugins/send-config.js"; -import { createStartAccountContext } from "../../../test/helpers/plugins/start-account-context.js"; -import { - expectStopPendingUntilAbort, - startAccountAndTrackLifecycle, - waitForStartedMocks, -} from "../../../test/helpers/plugins/start-account-lifecycle.js"; -import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; -import type { CoreConfig } from "./types.js"; - -vi.mock("../../../src/config/bundled-channel-config-runtime.js", () => ({ - getBundledChannelRuntimeMap: () => new Map(), - getBundledChannelConfigSchemaMap: () => new Map(), -})); - -vi.mock("../../../src/channels/plugins/bundled.js", () => ({ - bundledChannelPlugins: [], - bundledChannelSetupPlugins: [], -})); - -const hoisted = vi.hoisted(() => ({ - monitorNextcloudTalkProvider: vi.fn(), - loadConfig: vi.fn(), - resolveMarkdownTableMode: vi.fn(() => "preserve"), - convertMarkdownTables: vi.fn((text: string) => text), - record: vi.fn(), - resolveNextcloudTalkAccount: vi.fn(), - generateNextcloudTalkSignature: vi.fn(() => ({ - random: "r", - signature: "s", - })), - mockFetchGuard: vi.fn(), -})); - -vi.mock("./monitor.js", async () => { - const actual = await vi.importActual("./monitor.js"); - return { - ...actual, - monitorNextcloudTalkProvider: hoisted.monitorNextcloudTalkProvider, - }; -}); - -vi.mock("./runtime.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getNextcloudTalkRuntime: () => createSendCfgThreadingRuntime(hoisted), - }; -}); - -vi.mock("./accounts.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - resolveNextcloudTalkAccount: hoisted.resolveNextcloudTalkAccount, - }; -}); - -vi.mock("./signature.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - generateNextcloudTalkSignature: hoisted.generateNextcloudTalkSignature, - }; -}); - -vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { - const original = (await importOriginal()) as Record; - return { - ...original, - fetchWithSsrFGuard: hoisted.mockFetchGuard, - }; -}); - -vi.mock("../../../src/infra/net/fetch-guard.js", async (importOriginal) => { - const original = (await importOriginal()) as Record; - return { - ...original, - fetchWithSsrFGuard: hoisted.mockFetchGuard, - }; -}); - -vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { - const original = (await importOriginal()) as Record; - return { - ...original, - resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode, - }; -}); - -vi.mock("openclaw/plugin-sdk/text-runtime", async (importOriginal) => { - const original = (await importOriginal()) as Record; - return { - ...original, - convertMarkdownTables: hoisted.convertMarkdownTables, - }; -}); - -const accountsActual = await vi.importActual("./accounts.js"); -hoisted.resolveNextcloudTalkAccount.mockImplementation(accountsActual.resolveNextcloudTalkAccount); - -const { resolveNextcloudTalkAccount } = await import("./accounts.js"); -const { nextcloudTalkPlugin } = await import("./channel.js"); -const { clearNextcloudTalkAccountFields, nextcloudTalkDmPolicy, nextcloudTalkSetupAdapter, normalizeNextcloudTalkBaseUrl, setNextcloudTalkAccountConfig, validateNextcloudTalkBaseUrl, -} = await import("./setup-core.js"); -const { nextcloudTalkSetupWizard } = await import("./setup-surface.js"); -const { sendMessageNextcloudTalk, sendReactionNextcloudTalk } = await import("./send.js"); - -function buildAccount(): ResolvedNextcloudTalkAccount { - return { - accountId: "default", - enabled: true, - baseUrl: "https://nextcloud.example.com", - secret: "secret", // pragma: allowlist secret - secretSource: "config", // pragma: allowlist secret - config: { - baseUrl: "https://nextcloud.example.com", - botSecret: "secret", // pragma: allowlist secret - webhookPath: "/nextcloud-talk-webhook", - webhookPort: 8788, - }, - }; -} - -function mockStartedMonitor() { - const stop = vi.fn(); - hoisted.monitorNextcloudTalkProvider.mockResolvedValue({ stop }); - return stop; -} - -function startNextcloudAccount(abortSignal?: AbortSignal) { - return nextcloudTalkPlugin.gateway!.startAccount!( - createStartAccountContext({ - account: buildAccount(), - abortSignal, - }), - ); -} +} from "./setup-core.js"; +import { nextcloudTalkSetupWizard } from "./setup-surface.js"; +import type { CoreConfig } from "./types.js"; describe("nextcloud talk setup", () => { - afterEach(() => { - vi.clearAllMocks(); - hoisted.resolveNextcloudTalkAccount.mockImplementation( - accountsActual.resolveNextcloudTalkAccount, - ); - }); - it("normalizes and validates base urls", () => { expect(normalizeNextcloudTalkBaseUrl(" https://cloud.example.com/// ")).toBe( "https://cloud.example.com", @@ -385,32 +241,6 @@ describe("nextcloud talk setup", () => { expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret"); expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile"); }); - - it("keeps startAccount pending until abort, then stops the monitor", async () => { - const stop = mockStartedMonitor(); - const { abort, task, isSettled } = startAccountAndTrackLifecycle({ - startAccount: nextcloudTalkPlugin.gateway!.startAccount!, - account: buildAccount(), - }); - await expectStopPendingUntilAbort({ - waitForStarted: waitForStartedMocks(hoisted.monitorNextcloudTalkProvider), - isSettled, - abort, - task, - stop, - }); - }); - - it("stops immediately when startAccount receives an already-aborted signal", async () => { - const stop = mockStartedMonitor(); - const abort = new AbortController(); - abort.abort(); - - await startNextcloudAccount(abort.signal); - - expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce(); - expect(stop).toHaveBeenCalledOnce(); - }); }); describe("resolveNextcloudTalkAccount", () => { @@ -459,137 +289,3 @@ describe("resolveNextcloudTalkAccount", () => { fs.rmSync(dir, { recursive: true, force: true }); }); }); - -describe("nextcloud-talk send cfg threading", () => { - const fetchMock = vi.fn(); - - beforeEach(() => { - vi.stubGlobal("fetch", fetchMock); - // Wire the SSRF guard mock to delegate to the global fetch mock - hoisted.mockFetchGuard.mockImplementation(async (p: { url: string; init?: RequestInit }) => { - const response = await globalThis.fetch(p.url, p.init); - return { response, release: async () => {}, finalUrl: p.url }; - }); - hoisted.resolveNextcloudTalkAccount.mockImplementation( - accountsActual.resolveNextcloudTalkAccount, - ); - }); - - afterEach(() => { - fetchMock.mockReset(); - hoisted.mockFetchGuard.mockReset(); - vi.unstubAllGlobals(); - }); - - it("uses provided cfg for sendMessage and skips runtime loadConfig", async () => { - const cfg = { source: "provided" } as const; - hoisted.resolveNextcloudTalkAccount.mockReturnValue({ - accountId: "default", - baseUrl: "https://nextcloud.example.com", - secret: "secret-value", // pragma: allowlist secret - }); - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - ocs: { data: { id: 12345, timestamp: 1_706_000_000 } }, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ); - - const result = await sendMessageNextcloudTalk("room:abc123", "hello", { - cfg, - accountId: "work", - }); - - expectProvidedCfgSkipsRuntimeLoad({ - loadConfig: hoisted.loadConfig, - resolveAccount: hoisted.resolveNextcloudTalkAccount, - cfg, - accountId: "work", - }); - expect(hoisted.resolveMarkdownTableMode).toHaveBeenCalledWith({ - cfg, - channel: "nextcloud-talk", - accountId: "default", - }); - expect(hoisted.convertMarkdownTables).toHaveBeenCalledWith("hello", "preserve"); - expect(hoisted.record).toHaveBeenCalledWith({ - channel: "nextcloud-talk", - accountId: "default", - direction: "outbound", - }); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(result).toEqual({ - messageId: "12345", - roomToken: "abc123", - timestamp: 1_706_000_000, - }); - }); - - it("sends with provided cfg even when the runtime store is not initialized", async () => { - const cfg = { source: "provided" } as const; - hoisted.resolveNextcloudTalkAccount.mockReturnValue({ - accountId: "default", - baseUrl: "https://nextcloud.example.com", - secret: "secret-value", - }); - hoisted.record.mockImplementation(() => { - throw new Error("Nextcloud Talk runtime not initialized"); - }); - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - ocs: { data: { id: 12346, timestamp: 1_706_000_001 } }, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ); - - const result = await sendMessageNextcloudTalk("room:abc123", "hello", { - cfg, - accountId: "work", - }); - - expectProvidedCfgSkipsRuntimeLoad({ - loadConfig: hoisted.loadConfig, - resolveAccount: hoisted.resolveNextcloudTalkAccount, - cfg, - accountId: "work", - }); - expect(hoisted.resolveMarkdownTableMode).toHaveBeenCalledWith({ - cfg, - channel: "nextcloud-talk", - accountId: "default", - }); - expect(hoisted.convertMarkdownTables).toHaveBeenCalledWith("hello", "preserve"); - expect(result).toEqual({ - messageId: "12346", - roomToken: "abc123", - timestamp: 1_706_000_001, - }); - }); - - it("falls back to runtime cfg for sendReaction when cfg is omitted", async () => { - const runtimeCfg = { source: "runtime" } as const; - hoisted.loadConfig.mockReturnValueOnce(runtimeCfg); - hoisted.resolveNextcloudTalkAccount.mockReturnValue({ - accountId: "default", - baseUrl: "https://nextcloud.example.com", - secret: "secret-value", // pragma: allowlist secret - }); - fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 })); - - const result = await sendReactionNextcloudTalk("room:ops", "m-1", "👍", { - accountId: "default", - }); - - expect(result).toEqual({ ok: true }); - expectRuntimeCfgFallback({ - loadConfig: hoisted.loadConfig, - resolveAccount: hoisted.resolveNextcloudTalkAccount, - cfg: runtimeCfg, - accountId: "default", - }); - }); -});