diff --git a/extensions/msteams/src/conversation-store-fs.test.ts b/extensions/msteams/src/conversation-store-fs.test.ts index 47a595606a0..eb2baacd526 100644 --- a/extensions/msteams/src/conversation-store-fs.test.ts +++ b/extensions/msteams/src/conversation-store-fs.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js"; +import { createMSTeamsConversationStoreMemory } from "./conversation-store-memory.js"; import type { StoredConversationReference } from "./conversation-store.js"; import { setMSTeamsRuntime } from "./runtime.js"; import { msteamsRuntimeStub } from "./test-runtime.js"; @@ -123,3 +124,64 @@ describe("msteams conversation store (fs)", () => { expect(retrieved!.timezone).toBe("Europe/London"); }); }); + +describe("msteams conversation store (memory)", () => { + it("upserts, lists, removes, and resolves users by both AAD and Bot Framework ids", async () => { + const store = createMSTeamsConversationStoreMemory([ + { + conversationId: "conv-a", + reference: { + conversation: { id: "conv-a" }, + user: { id: "user-a", aadObjectId: "aad-a", name: "Alice" }, + }, + }, + ]); + + await store.upsert("conv-b", { + conversation: { id: "conv-b" }, + user: { id: "user-b", aadObjectId: "aad-b", name: "Bob" }, + }); + + await expect(store.get("conv-a")).resolves.toEqual({ + conversation: { id: "conv-a" }, + user: { id: "user-a", aadObjectId: "aad-a", name: "Alice" }, + }); + + await expect(store.list()).resolves.toEqual([ + { + conversationId: "conv-a", + reference: { + conversation: { id: "conv-a" }, + user: { id: "user-a", aadObjectId: "aad-a", name: "Alice" }, + }, + }, + { + conversationId: "conv-b", + reference: { + conversation: { id: "conv-b" }, + user: { id: "user-b", aadObjectId: "aad-b", name: "Bob" }, + }, + }, + ]); + + await expect(store.findByUserId(" aad-b ")).resolves.toEqual({ + conversationId: "conv-b", + reference: { + conversation: { id: "conv-b" }, + user: { id: "user-b", aadObjectId: "aad-b", name: "Bob" }, + }, + }); + await expect(store.findByUserId("user-a")).resolves.toEqual({ + conversationId: "conv-a", + reference: { + conversation: { id: "conv-a" }, + user: { id: "user-a", aadObjectId: "aad-a", name: "Alice" }, + }, + }); + await expect(store.findByUserId(" ")).resolves.toBeNull(); + + await expect(store.remove("conv-a")).resolves.toBe(true); + await expect(store.get("conv-a")).resolves.toBeNull(); + await expect(store.remove("missing")).resolves.toBe(false); + }); +}); diff --git a/extensions/msteams/src/conversation-store-memory.test.ts b/extensions/msteams/src/conversation-store-memory.test.ts deleted file mode 100644 index 095bc0f70f5..00000000000 --- a/extensions/msteams/src/conversation-store-memory.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createMSTeamsConversationStoreMemory } from "./conversation-store-memory.js"; - -describe("createMSTeamsConversationStoreMemory", () => { - it("upserts, lists, removes, and resolves users by both AAD and Bot Framework ids", async () => { - const store = createMSTeamsConversationStoreMemory([ - { - conversationId: "conv-a", - reference: { - conversation: { id: "conv-a" }, - user: { id: "user-a", aadObjectId: "aad-a", name: "Alice" }, - }, - }, - ]); - - await store.upsert("conv-b", { - conversation: { id: "conv-b" }, - user: { id: "user-b", aadObjectId: "aad-b", name: "Bob" }, - }); - - await expect(store.get("conv-a")).resolves.toEqual({ - conversation: { id: "conv-a" }, - user: { id: "user-a", aadObjectId: "aad-a", name: "Alice" }, - }); - - await expect(store.list()).resolves.toEqual([ - { - conversationId: "conv-a", - reference: { - conversation: { id: "conv-a" }, - user: { id: "user-a", aadObjectId: "aad-a", name: "Alice" }, - }, - }, - { - conversationId: "conv-b", - reference: { - conversation: { id: "conv-b" }, - user: { id: "user-b", aadObjectId: "aad-b", name: "Bob" }, - }, - }, - ]); - - await expect(store.findByUserId(" aad-b ")).resolves.toEqual({ - conversationId: "conv-b", - reference: { - conversation: { id: "conv-b" }, - user: { id: "user-b", aadObjectId: "aad-b", name: "Bob" }, - }, - }); - await expect(store.findByUserId("user-a")).resolves.toEqual({ - conversationId: "conv-a", - reference: { - conversation: { id: "conv-a" }, - user: { id: "user-a", aadObjectId: "aad-a", name: "Alice" }, - }, - }); - await expect(store.findByUserId(" ")).resolves.toBeNull(); - - await expect(store.remove("conv-a")).resolves.toBe(true); - await expect(store.get("conv-a")).resolves.toBeNull(); - await expect(store.remove("missing")).resolves.toBe(false); - }); -}); diff --git a/extensions/msteams/src/file-consent-helpers.test.ts b/extensions/msteams/src/file-consent-helpers.test.ts index c781787c73a..26ef871b31e 100644 --- a/extensions/msteams/src/file-consent-helpers.test.ts +++ b/extensions/msteams/src/file-consent-helpers.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js"; +import { + clearPendingUploads, + getPendingUpload, + getPendingUploadCount, + removePendingUpload, + storePendingUpload, +} from "./pending-uploads.js"; import * as pendingUploads from "./pending-uploads.js"; describe("requiresFileConsent", () => { @@ -241,3 +248,79 @@ describe("prepareFileConsentActivity", () => { expect(result.activity.type).toBe("message"); }); }); + +describe("msteams pending uploads", () => { + beforeEach(() => { + vi.useFakeTimers(); + clearPendingUploads(); + }); + + afterEach(() => { + clearPendingUploads(); + vi.useRealTimers(); + }); + + it("stores uploads, exposes them by id, and tracks count", () => { + const id = storePendingUpload({ + buffer: Buffer.from("hello"), + filename: "hello.txt", + contentType: "text/plain", + conversationId: "conv-1", + }); + + expect(getPendingUploadCount()).toBe(1); + expect(getPendingUpload(id)).toEqual( + expect.objectContaining({ + id, + filename: "hello.txt", + contentType: "text/plain", + conversationId: "conv-1", + }), + ); + }); + + it("removes uploads explicitly and ignores empty ids", () => { + const id = storePendingUpload({ + buffer: Buffer.from("hello"), + filename: "hello.txt", + conversationId: "conv-1", + }); + + removePendingUpload(undefined); + expect(getPendingUploadCount()).toBe(1); + + removePendingUpload(id); + expect(getPendingUpload(id)).toBeUndefined(); + expect(getPendingUploadCount()).toBe(0); + }); + + it("expires uploads by ttl even if the timeout callback has not been observed yet", () => { + const id = storePendingUpload({ + buffer: Buffer.from("hello"), + filename: "hello.txt", + conversationId: "conv-1", + }); + + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + + expect(getPendingUpload(id)).toBeUndefined(); + expect(getPendingUploadCount()).toBe(0); + }); + + it("clears all uploads for test cleanup", () => { + storePendingUpload({ + buffer: Buffer.from("a"), + filename: "a.txt", + conversationId: "conv-1", + }); + storePendingUpload({ + buffer: Buffer.from("b"), + filename: "b.txt", + conversationId: "conv-2", + }); + + clearPendingUploads(); + + expect(getPendingUploadCount()).toBe(0); + }); +}); diff --git a/extensions/msteams/src/monitor.test.ts b/extensions/msteams/src/monitor.test.ts index c1c7ad40418..0d21bf2758f 100644 --- a/extensions/msteams/src/monitor.test.ts +++ b/extensions/msteams/src/monitor.test.ts @@ -37,6 +37,21 @@ async function waitForSlowBodySocketClose(port: number, timeoutMs: number): Prom } describe("msteams monitor webhook hardening", () => { + it("applies default timeouts and header clamp", async () => { + const app = express(); + const server = app.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + applyMSTeamsWebhookTimeouts(server); + + expect(server.timeout).toBe(30_000); + expect(server.requestTimeout).toBe(30_000); + expect(server.headersTimeout).toBe(15_000); + } finally { + await closeServer(server); + } + }); + it("applies explicit webhook timeout values", async () => { const app = express(); const server = app.listen(0, "127.0.0.1"); @@ -56,6 +71,25 @@ describe("msteams monitor webhook hardening", () => { } }); + it("clamps headers timeout when explicit value exceeds request timeout", async () => { + const app = express(); + const server = app.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + applyMSTeamsWebhookTimeouts(server, { + inactivityTimeoutMs: 12_000, + requestTimeoutMs: 9_000, + headersTimeoutMs: 15_000, + }); + + expect(server.timeout).toBe(12_000); + expect(server.requestTimeout).toBe(9_000); + expect(server.headersTimeout).toBe(9_000); + } finally { + await closeServer(server); + } + }); + it("drops slow-body webhook requests within configured inactivity timeout", async () => { const app = express(); app.use(express.json({ limit: "1mb" })); diff --git a/extensions/msteams/src/pending-uploads.test.ts b/extensions/msteams/src/pending-uploads.test.ts deleted file mode 100644 index 9c8da0b8e88..00000000000 --- a/extensions/msteams/src/pending-uploads.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - clearPendingUploads, - getPendingUpload, - getPendingUploadCount, - removePendingUpload, - storePendingUpload, -} from "./pending-uploads.js"; - -describe("msteams pending uploads", () => { - beforeEach(() => { - vi.useFakeTimers(); - clearPendingUploads(); - }); - - afterEach(() => { - clearPendingUploads(); - vi.useRealTimers(); - }); - - it("stores uploads, exposes them by id, and tracks count", () => { - const id = storePendingUpload({ - buffer: Buffer.from("hello"), - filename: "hello.txt", - contentType: "text/plain", - conversationId: "conv-1", - }); - - expect(getPendingUploadCount()).toBe(1); - expect(getPendingUpload(id)).toEqual( - expect.objectContaining({ - id, - filename: "hello.txt", - contentType: "text/plain", - conversationId: "conv-1", - }), - ); - }); - - it("removes uploads explicitly and ignores empty ids", () => { - const id = storePendingUpload({ - buffer: Buffer.from("hello"), - filename: "hello.txt", - conversationId: "conv-1", - }); - - removePendingUpload(undefined); - expect(getPendingUploadCount()).toBe(1); - - removePendingUpload(id); - expect(getPendingUpload(id)).toBeUndefined(); - expect(getPendingUploadCount()).toBe(0); - }); - - it("expires uploads by ttl even if the timeout callback has not been observed yet", () => { - const id = storePendingUpload({ - buffer: Buffer.from("hello"), - filename: "hello.txt", - conversationId: "conv-1", - }); - - vi.advanceTimersByTime(5 * 60 * 1000 + 1); - - expect(getPendingUpload(id)).toBeUndefined(); - expect(getPendingUploadCount()).toBe(0); - }); - - it("clears all uploads for test cleanup", () => { - storePendingUpload({ - buffer: Buffer.from("a"), - filename: "a.txt", - conversationId: "conv-1", - }); - storePendingUpload({ - buffer: Buffer.from("b"), - filename: "b.txt", - conversationId: "conv-2", - }); - - clearPendingUploads(); - - expect(getPendingUploadCount()).toBe(0); - }); -}); diff --git a/extensions/msteams/src/polls-store-memory.test.ts b/extensions/msteams/src/polls-store-memory.test.ts deleted file mode 100644 index 3b4ae1cd4ec..00000000000 --- a/extensions/msteams/src/polls-store-memory.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createMSTeamsPollStoreMemory } from "./polls-store-memory.js"; - -describe("createMSTeamsPollStoreMemory", () => { - it("creates polls, reads them back, and records normalized votes", async () => { - const store = createMSTeamsPollStoreMemory([ - { - id: "poll-1", - question: "Pick one", - options: ["A", "B"], - maxSelections: 1, - votes: {}, - createdAt: "2026-03-22T00:00:00.000Z", - updatedAt: "2026-03-22T00:00:00.000Z", - }, - ]); - - await expect(store.getPoll("poll-1")).resolves.toEqual( - expect.objectContaining({ - id: "poll-1", - question: "Pick one", - }), - ); - - const originalUpdatedAt = "2026-03-22T00:00:00.000Z"; - await store.getPoll("poll-1"); - const result = await store.recordVote({ - pollId: "poll-1", - voterId: "user-1", - selections: ["1", "0", "missing"], - }); - - expect(result?.votes["user-1"]).toEqual(["1"]); - expect(result?.updatedAt).not.toBe(originalUpdatedAt); - - await store.createPoll({ - id: "poll-2", - question: "Pick many", - options: ["X", "Y"], - maxSelections: 2, - votes: {}, - createdAt: "2026-03-22T00:00:00.000Z", - updatedAt: "2026-03-22T00:00:00.000Z", - }); - - await expect( - store.recordVote({ - pollId: "poll-2", - voterId: "user-2", - selections: ["1", "0", "1"], - }), - ).resolves.toEqual( - expect.objectContaining({ - id: "poll-2", - votes: { - "user-2": ["1", "0"], - }, - }), - ); - - await expect( - store.recordVote({ pollId: "missing", voterId: "nobody", selections: ["x"] }), - ).resolves.toBeNull(); - }); -}); diff --git a/extensions/msteams/src/polls-store.test.ts b/extensions/msteams/src/polls-store.test.ts deleted file mode 100644 index 48deecfd01f..00000000000 --- a/extensions/msteams/src/polls-store.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { createMSTeamsPollStoreMemory } from "./polls-store-memory.js"; -import { createMSTeamsPollStoreFs } from "./polls.js"; - -const createFsStore = async () => { - const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-polls-")); - return createMSTeamsPollStoreFs({ stateDir }); -}; - -const createMemoryStore = () => createMSTeamsPollStoreMemory(); - -describe.each([ - { name: "memory", createStore: createMemoryStore }, - { name: "fs", createStore: createFsStore }, -])("$name poll store", ({ createStore }) => { - it("stores polls and records normalized votes", async () => { - const store = await createStore(); - await store.createPoll({ - id: "poll-1", - question: "Lunch?", - options: ["Pizza", "Sushi"], - maxSelections: 1, - createdAt: new Date().toISOString(), - votes: {}, - }); - - const poll = await store.recordVote({ - pollId: "poll-1", - voterId: "user-1", - selections: ["0", "1"], - }); - - if (!poll) { - throw new Error("poll store did not return the updated poll"); - } - expect(poll.votes["user-1"]).toEqual(["0"]); - }); -}); diff --git a/extensions/msteams/src/polls.test.ts b/extensions/msteams/src/polls.test.ts index 283e95951dc..2959bcb68ed 100644 --- a/extensions/msteams/src/polls.test.ts +++ b/extensions/msteams/src/polls.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; +import { createMSTeamsPollStoreMemory } from "./polls-store-memory.js"; import { buildMSTeamsPollCard, createMSTeamsPollStoreFs, extractMSTeamsPollVote } from "./polls.js"; import { setMSTeamsRuntime } from "./runtime.js"; import { msteamsRuntimeStub } from "./test-runtime.js"; @@ -60,3 +61,100 @@ describe("msteams polls", () => { expect(stored.votes["user-1"]).toEqual(["0"]); }); }); + +const createFsStore = async () => { + const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-polls-")); + return createMSTeamsPollStoreFs({ stateDir }); +}; + +const createMemoryStore = () => createMSTeamsPollStoreMemory(); + +describe.each([ + { name: "memory", createStore: createMemoryStore }, + { name: "fs", createStore: createFsStore }, +])("$name poll store", ({ createStore }) => { + it("stores polls and records normalized votes", async () => { + const store = await createStore(); + await store.createPoll({ + id: "poll-1", + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 1, + createdAt: new Date().toISOString(), + votes: {}, + }); + + const poll = await store.recordVote({ + pollId: "poll-1", + voterId: "user-1", + selections: ["0", "1"], + }); + + if (!poll) { + throw new Error("poll store did not return the updated poll"); + } + expect(poll.votes["user-1"]).toEqual(["0"]); + }); +}); + +describe("memory poll store", () => { + it("reads seeded polls back, updates timestamps, and returns null for missing polls", async () => { + const store = createMSTeamsPollStoreMemory([ + { + id: "poll-1", + question: "Pick one", + options: ["A", "B"], + maxSelections: 1, + votes: {}, + createdAt: "2026-03-22T00:00:00.000Z", + updatedAt: "2026-03-22T00:00:00.000Z", + }, + ]); + + await expect(store.getPoll("poll-1")).resolves.toEqual( + expect.objectContaining({ + id: "poll-1", + question: "Pick one", + }), + ); + + const originalUpdatedAt = "2026-03-22T00:00:00.000Z"; + const result = await store.recordVote({ + pollId: "poll-1", + voterId: "user-1", + selections: ["1", "0", "missing"], + }); + + expect(result?.votes["user-1"]).toEqual(["1"]); + expect(result?.updatedAt).not.toBe(originalUpdatedAt); + + await store.createPoll({ + id: "poll-2", + question: "Pick many", + options: ["X", "Y"], + maxSelections: 2, + votes: {}, + createdAt: "2026-03-22T00:00:00.000Z", + updatedAt: "2026-03-22T00:00:00.000Z", + }); + + await expect( + store.recordVote({ + pollId: "poll-2", + voterId: "user-2", + selections: ["1", "0", "1"], + }), + ).resolves.toEqual( + expect.objectContaining({ + id: "poll-2", + votes: { + "user-2": ["1", "0"], + }, + }), + ); + + await expect( + store.recordVote({ pollId: "missing", voterId: "nobody", selections: ["x"] }), + ).resolves.toBeNull(); + }); +}); diff --git a/extensions/msteams/src/webhook-timeouts.test.ts b/extensions/msteams/src/webhook-timeouts.test.ts deleted file mode 100644 index c5780c9ce73..00000000000 --- a/extensions/msteams/src/webhook-timeouts.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Server } from "node:http"; -import { describe, expect, it, vi } from "vitest"; -import { applyMSTeamsWebhookTimeouts } from "./webhook-timeouts.js"; - -describe("applyMSTeamsWebhookTimeouts", () => { - it("applies default timeouts and header clamp", () => { - const httpServer: Pick = { - setTimeout: vi.fn(), - requestTimeout: 0, - headersTimeout: 0, - }; - - applyMSTeamsWebhookTimeouts(httpServer as Server); - - expect(httpServer.setTimeout).toHaveBeenCalledWith(30_000); - expect(httpServer.requestTimeout).toBe(30_000); - expect(httpServer.headersTimeout).toBe(15_000); - }); - - it("uses explicit overrides and clamps headers timeout to request timeout", () => { - const httpServer: Pick = { - setTimeout: vi.fn(), - requestTimeout: 0, - headersTimeout: 0, - }; - - applyMSTeamsWebhookTimeouts(httpServer as Server, { - inactivityTimeoutMs: 12_000, - requestTimeoutMs: 9_000, - headersTimeoutMs: 15_000, - }); - - expect(httpServer.setTimeout).toHaveBeenCalledWith(12_000); - expect(httpServer.requestTimeout).toBe(9_000); - expect(httpServer.headersTimeout).toBe(9_000); - }); -});