Files
openclaw/extensions/msteams/src/resolve-allowlist.test.ts
Brad Groux 568b0a22bb fix(msteams): use General channel conversation ID as team key for Bot Framework compatibility (#41838)
* fix(msteams): use General channel conversation ID as team key for Bot Framework compatibility

Bot Framework sends `activity.channelData.team.id` as the General channel's
conversation ID (e.g. `19:abc@thread.tacv2`), not the Graph API group GUID
(e.g. `fa101332-cf00-431b-b0ea-f701a85fde81`). The startup resolver was
storing the Graph GUID as the team config key, so runtime matching always
failed and every channel message was silently dropped.

Fix: always call `listChannelsForTeam` during resolution to find the General
channel, then use its conversation ID as the stored `teamId`. When a specific
channel is also configured, reuse the same channel list rather than issuing a
second API call. Falls back to the Graph GUID if the General channel cannot
be found (renamed/deleted edge case).

Fixes #41390

* fix(msteams): handle listChannelsForTeam failure gracefully

* fix(msteams): trim General channel ID and guard against empty string

* fix: document MS Teams allowlist team-key fix (#41838) (thanks @BradGroux)

---------

Co-authored-by: bradgroux <bradgroux@users.noreply.github.com>
Co-authored-by: Onur <onur@textcortex.com>
2026-03-10 09:13:41 +01:00

147 lines
4.8 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
const {
listTeamsByName,
listChannelsForTeam,
normalizeQuery,
resolveGraphToken,
searchGraphUsers,
} = vi.hoisted(() => ({
listTeamsByName: vi.fn(),
listChannelsForTeam: vi.fn(),
normalizeQuery: vi.fn((value: string) => value.trim().toLowerCase()),
resolveGraphToken: vi.fn(async () => "graph-token"),
searchGraphUsers: vi.fn(),
}));
vi.mock("./graph.js", () => ({
listTeamsByName,
listChannelsForTeam,
normalizeQuery,
resolveGraphToken,
}));
vi.mock("./graph-users.js", () => ({
searchGraphUsers,
}));
import {
resolveMSTeamsChannelAllowlist,
resolveMSTeamsUserAllowlist,
} from "./resolve-allowlist.js";
describe("resolveMSTeamsUserAllowlist", () => {
it("marks empty input unresolved", async () => {
const [result] = await resolveMSTeamsUserAllowlist({ cfg: {}, entries: [" "] });
expect(result).toEqual({ input: " ", resolved: false });
});
it("resolves first Graph user match", async () => {
searchGraphUsers.mockResolvedValueOnce([
{ id: "user-1", displayName: "Alice One" },
{ id: "user-2", displayName: "Alice Two" },
]);
const [result] = await resolveMSTeamsUserAllowlist({ cfg: {}, entries: ["alice"] });
expect(result).toEqual({
input: "alice",
resolved: true,
id: "user-1",
name: "Alice One",
note: "multiple matches; chose first",
});
});
});
describe("resolveMSTeamsChannelAllowlist", () => {
it("resolves team/channel by team name + channel display name", async () => {
// After the fix, listChannelsForTeam is called once and reused for both
// General channel resolution and channel matching.
listTeamsByName.mockResolvedValueOnce([{ id: "team-guid-1", displayName: "Product Team" }]);
listChannelsForTeam.mockResolvedValueOnce([
{ id: "19:general-conv-id@thread.tacv2", displayName: "General" },
{ id: "19:roadmap-conv-id@thread.tacv2", displayName: "Roadmap" },
]);
const [result] = await resolveMSTeamsChannelAllowlist({
cfg: {},
entries: ["Product Team/Roadmap"],
});
// teamId is now the General channel's conversation ID — not the Graph GUID —
// because that's what Bot Framework sends as channelData.team.id at runtime.
expect(result).toEqual({
input: "Product Team/Roadmap",
resolved: true,
teamId: "19:general-conv-id@thread.tacv2",
teamName: "Product Team",
channelId: "19:roadmap-conv-id@thread.tacv2",
channelName: "Roadmap",
note: "multiple channels; chose first",
});
});
it("uses General channel conversation ID as team key for team-only entry", async () => {
// When no channel is specified we still resolve the General channel so the
// stored key matches what Bot Framework sends as channelData.team.id.
listTeamsByName.mockResolvedValueOnce([{ id: "guid-engineering", displayName: "Engineering" }]);
listChannelsForTeam.mockResolvedValueOnce([
{ id: "19:eng-general@thread.tacv2", displayName: "General" },
{ id: "19:eng-standups@thread.tacv2", displayName: "Standups" },
]);
const [result] = await resolveMSTeamsChannelAllowlist({
cfg: {},
entries: ["Engineering"],
});
expect(result).toEqual({
input: "Engineering",
resolved: true,
teamId: "19:eng-general@thread.tacv2",
teamName: "Engineering",
});
});
it("falls back to Graph GUID when listChannelsForTeam throws", async () => {
// Edge case: API call fails (rate limit, network error). We fall back to
// the Graph GUID as the team key — the pre-fix behavior — so resolution
// still succeeds instead of propagating the error.
listTeamsByName.mockResolvedValueOnce([{ id: "guid-flaky", displayName: "Flaky Team" }]);
listChannelsForTeam.mockRejectedValueOnce(new Error("429 Too Many Requests"));
const [result] = await resolveMSTeamsChannelAllowlist({
cfg: {},
entries: ["Flaky Team"],
});
expect(result).toEqual({
input: "Flaky Team",
resolved: true,
teamId: "guid-flaky",
teamName: "Flaky Team",
});
});
it("falls back to Graph GUID when General channel is not found", async () => {
// Edge case: General channel was renamed or deleted. We fall back to the
// Graph GUID so resolution still succeeds rather than silently breaking.
listTeamsByName.mockResolvedValueOnce([{ id: "guid-ops", displayName: "Operations" }]);
listChannelsForTeam.mockResolvedValueOnce([
{ id: "19:ops-announce@thread.tacv2", displayName: "Announcements" },
{ id: "19:ops-random@thread.tacv2", displayName: "Random" },
]);
const [result] = await resolveMSTeamsChannelAllowlist({
cfg: {},
entries: ["Operations"],
});
expect(result).toEqual({
input: "Operations",
resolved: true,
teamId: "guid-ops",
teamName: "Operations",
});
});
});