From dc0285366efcfd3130f75349af030fdde3a86dcb Mon Sep 17 00:00:00 2001 From: wangmiao0668000666 Date: Sat, 18 Jul 2026 08:21:49 +0800 Subject: [PATCH] fix(msteams): bound probe token acquisition to request deadline (#106386) * fix(msteams): bound probe token acquisition to request deadline probeMSTeams() at extensions/msteams/src/probe.ts:75 and :89 awaited tokenProvider.getAccessToken(...) for the Bot Framework and Microsoft Graph token endpoints with no surrounding deadline. The Microsoft Teams SDK does not carry an inherent timeout on these calls, so a stalled Azure AD token endpoint pinned the probe indefinitely. Wrap both awaits with withMSTeamsRequestDeadline (default MSTEAMS_REQUEST_TIMEOUT_MS = 30_000), matching the pattern already used by six other MS Teams call sites: attachments/bot-framework.ts:252, attachments/graph.ts:258, monitor-handler/message-handler.ts:594/654/685/692, attachments/download.ts:167, team-identity.ts:37. The probe was the one missing site. No new helper, no SDK change. The existing outer catch at probe.ts:138 and inner catch at probe.ts:110 convert the timeout into a ProbeMSTeamsResult with ok: false and a structured error field. Added probe.timeout.test.ts: real probeMSTeams() with vi.mock injected never-resolving getBotToken/getGraphToken; asserts the call returns within the 30s bound instead of hanging to the proof budget. * test(msteams): drive probe timeout test with vi.useFakeTimers The original probe.timeout.test.ts waited 90 seconds of wall-clock per focused run (3 stalled cases racing against a real setTimeout budget). Per ClawSweeper P2 (automation), this material deterministic CI cost can slow or time out test shards. Drive the withTimeout race (from @openclaw/fs-safe/dist/timing.js, uses setTimeout + clearTimeout) via vi.useFakeTimers() so each stalled case resolves in milliseconds. Add one new case that spies on withTimeout's timeoutMs argument to assert the production default deadline is exactly MSTEAMS_REQUEST_TIMEOUT_MS = 30_000, so the production contract is not silently weakened by the fake-timer change. Per-case wall-clock: 25ms / 3ms / 2ms / 1ms / 2ms (was: 30s / 30s / 30s / 2ms / n/a). Co-Authored-By: Claude * fix(msteams): bound remaining token acquisition * test(msteams): keep credential fixture unchanged --------- Co-authored-by: Claude Co-authored-by: Peter Steinberger --- extensions/msteams/src/graph.timeout.test.ts | 64 ++++++++++++++ extensions/msteams/src/graph.ts | 6 +- extensions/msteams/src/probe.timeout.test.ts | 90 ++++++++++++++++++++ extensions/msteams/src/probe.ts | 13 ++- 4 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 extensions/msteams/src/graph.timeout.test.ts create mode 100644 extensions/msteams/src/probe.timeout.test.ts diff --git a/extensions/msteams/src/graph.timeout.test.ts b/extensions/msteams/src/graph.timeout.test.ts new file mode 100644 index 000000000000..3fdd26e05329 --- /dev/null +++ b/extensions/msteams/src/graph.timeout.test.ts @@ -0,0 +1,64 @@ +// Msteams tests cover Graph token request deadlines. +import { afterEach, describe, expect, it, vi } from "vitest"; + +const sdkMock = vi.hoisted(() => ({ + acquire: vi.fn<(scope: string) => Promise>(), +})); + +vi.mock("./sdk.js", () => ({ + createMSTeamsTokenProvider() { + return { + async getAccessToken(scope: string) { + return await sdkMock.acquire(scope); + }, + }; + }, + async loadMSTeamsSdkWithAuth() { + return { app: {} }; + }, +})); + +vi.mock("./token-response.js", () => ({ + readAccessToken(value: unknown) { + return typeof value === "string" ? value : null; + }, +})); + +vi.mock("./token.js", () => ({ + async resolveDelegatedAccessToken() { + return undefined; + }, + resolveMSTeamsCredentials() { + return { + type: "secret", + appId: "app-id", + appPassword: "test-app-password", + tenantId: "tenant-id", + }; + }, +})); + +import { resolveGraphToken } from "./graph.js"; +import { MSTEAMS_REQUEST_TIMEOUT_MS } from "./request-timeout.js"; + +describe("resolveGraphToken request deadline", () => { + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it("bounds stalled SDK token acquisition", async () => { + sdkMock.acquire.mockImplementation(() => new Promise(() => {})); + vi.useFakeTimers(); + + const result = resolveGraphToken({ channels: { msteams: {} } }); + const rejection = expect(result).rejects.toThrow( + `MS Teams Graph token timed out after ${MSTEAMS_REQUEST_TIMEOUT_MS}ms`, + ); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(MSTEAMS_REQUEST_TIMEOUT_MS); + + await rejection; + expect(sdkMock.acquire).toHaveBeenCalledWith("https://graph.microsoft.com"); + }); +}); diff --git a/extensions/msteams/src/graph.ts b/extensions/msteams/src/graph.ts index 3dae75b07e75..e1565ff99aaa 100644 --- a/extensions/msteams/src/graph.ts +++ b/extensions/msteams/src/graph.ts @@ -8,6 +8,7 @@ import { MSTEAMS_REQUEST_TIMEOUT_MS, resolveMSTeamsRequestTimeoutMs, type MSTeamsRequestDeadline, + withMSTeamsRequestDeadline, } from "./request-timeout.js"; import { responseWithRelease } from "./response-with-release.js"; import { createMSTeamsTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js"; @@ -243,7 +244,10 @@ export async function resolveGraphToken( const { app } = await loadMSTeamsSdkWithAuth(creds, resolveMSTeamsSdkCloudOptions(msteamsCfg)); const tokenProvider = createMSTeamsTokenProvider(app); - const graphTokenValue = await tokenProvider.getAccessToken("https://graph.microsoft.com"); + const graphTokenValue = await withMSTeamsRequestDeadline({ + label: "MS Teams Graph token", + work: () => tokenProvider.getAccessToken("https://graph.microsoft.com"), + }); const accessToken = readAccessToken(graphTokenValue); if (!accessToken) { throw new Error("MS Teams graph token unavailable"); diff --git a/extensions/msteams/src/probe.timeout.test.ts b/extensions/msteams/src/probe.timeout.test.ts new file mode 100644 index 000000000000..a3ef6b1f8c2d --- /dev/null +++ b/extensions/msteams/src/probe.timeout.test.ts @@ -0,0 +1,90 @@ +// Msteams tests cover probe token request deadlines. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { MSTeamsConfig } from "../runtime-api.js"; + +const sdkState = vi.hoisted(() => ({ + stall: null as "bot" | "graph" | null, +})); + +vi.mock("@microsoft/teams.apps", () => ({ + App: class { + tokenManager = { + async getBotToken() { + if (sdkState.stall === "bot") { + return await new Promise(() => {}); + } + return { toString: () => "test-token" }; + }, + async getGraphToken() { + if (sdkState.stall === "graph") { + return await new Promise(() => {}); + } + return { toString: () => "test-token" }; + }, + }; + }, + ExpressAdapter: vi.fn(), +})); + +vi.mock("@microsoft/teams.api", () => ({ + Client: function Client() {}, + cloudFromName: () => ({ + botScope: "https://api.botframework.com/.default", + graphScope: "https://graph.microsoft.com/.default", + }), +})); + +import { probeMSTeams } from "./probe.js"; +import { MSTEAMS_REQUEST_TIMEOUT_MS } from "./request-timeout.js"; + +const cfg = { + enabled: true, + appId: "app-id", + appPassword: "test-app-password", + tenantId: "tenant-id", +} as unknown as MSTeamsConfig; + +describe("probeMSTeams request deadline", () => { + beforeEach(() => { + sdkState.stall = null; + vi.stubEnv("MSTEAMS_APP_ID", ""); + vi.stubEnv("MSTEAMS_APP_PASSWORD", ""); + vi.stubEnv("MSTEAMS_TENANT_ID", ""); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.useRealTimers(); + }); + + it.each([ + { + stalled: "bot" as const, + expected: { + ok: false, + appId: "app-id", + error: `MS Teams Bot Framework probe token timed out after ${MSTEAMS_REQUEST_TIMEOUT_MS}ms`, + }, + }, + { + stalled: "graph" as const, + expected: { + ok: true, + appId: "app-id", + graph: { + ok: false, + error: `MS Teams Graph probe token timed out after ${MSTEAMS_REQUEST_TIMEOUT_MS}ms`, + }, + }, + }, + ])("bounds stalled $stalled token acquisition", async ({ stalled, expected }) => { + sdkState.stall = stalled; + vi.useFakeTimers(); + + const result = expect(probeMSTeams(cfg)).resolves.toEqual(expected); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(MSTEAMS_REQUEST_TIMEOUT_MS); + + await result; + }); +}); diff --git a/extensions/msteams/src/probe.ts b/extensions/msteams/src/probe.ts index e9df811b63ef..992eb25b09b5 100644 --- a/extensions/msteams/src/probe.ts +++ b/extensions/msteams/src/probe.ts @@ -7,6 +7,7 @@ import { } from "../runtime-api.js"; import { resolveMSTeamsSdkCloudOptions } from "./cloud.js"; import { formatUnknownError } from "./errors.js"; +import { withMSTeamsRequestDeadline } from "./request-timeout.js"; import { createMSTeamsTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js"; import { readAccessToken } from "./token-response.js"; import { loadDelegatedTokens, resolveMSTeamsCredentials } from "./token.js"; @@ -72,7 +73,12 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise tokenProvider.getAccessToken("https://api.botframework.com"), + }); if (!botTokenValue) { throw new Error("Failed to acquire bot token"); } @@ -86,7 +92,10 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise tokenProvider.getAccessToken("https://graph.microsoft.com"), + }); const accessToken = readAccessToken(graphTokenValue); const payload = accessToken ? decodeJwtPayload(accessToken) : null; graph = {