test: collapse msteams state and monitor suites

This commit is contained in:
Peter Steinberger
2026-03-25 05:56:16 +00:00
parent e53809035e
commit 1c82b06645
9 changed files with 277 additions and 290 deletions

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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" }));

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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"]);
});
});

View File

@@ -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();
});
});

View File

@@ -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<Server, "setTimeout" | "requestTimeout" | "headersTimeout"> = {
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<Server, "setTimeout" | "requestTimeout" | "headersTimeout"> = {
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);
});
});