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 = {