openclaw/extensions/msteams/src/graph.test.ts

343 lines
10 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
loadMSTeamsSdkWithAuthMock,
createMSTeamsTokenProviderMock,
readAccessTokenMock,
resolveMSTeamsCredentialsMock,
} = vi.hoisted(() => {
return {
loadMSTeamsSdkWithAuthMock: vi.fn(),
createMSTeamsTokenProviderMock: vi.fn(),
readAccessTokenMock: vi.fn(),
resolveMSTeamsCredentialsMock: vi.fn(),
};
});
vi.mock("./sdk.js", () => ({
loadMSTeamsSdkWithAuth: loadMSTeamsSdkWithAuthMock,
createMSTeamsTokenProvider: createMSTeamsTokenProviderMock,
}));
vi.mock("./token-response.js", () => ({
readAccessToken: readAccessTokenMock,
}));
vi.mock("./token.js", () => ({
resolveMSTeamsCredentials: resolveMSTeamsCredentialsMock,
}));
import { searchGraphUsers } from "./graph-users.js";
import {
deleteGraphRequest,
escapeOData,
fetchGraphJson,
listChannelsForTeam,
listTeamsByName,
normalizeQuery,
postGraphBetaJson,
postGraphJson,
resolveGraphToken,
} from "./graph.js";
const originalFetch = globalThis.fetch;
const graphToken = "graph-token";
const mockCredentials = {
appId: "app-id",
appPassword: "app-password",
tenantId: "tenant-id",
};
const mockApp = { id: "mock-app" };
const groupOne = { id: "group-1" };
const opsTeam = { id: "team-1", displayName: "Ops" };
const deploymentsChannel = { id: "chan-1", displayName: "Deployments" };
const userOne = { id: "user-1", displayName: "User One" };
const bobUser = { id: "user-2", displayName: "Bob" };
function jsonResponse(body: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
function textResponse(body: string, init?: ResponseInit): Response {
return new Response(body, init);
}
function mockFetch(handler: Parameters<typeof vi.fn>[0]) {
globalThis.fetch = vi.fn(handler) as unknown as typeof fetch;
}
function mockJsonFetchResponse(body: unknown, init?: ResponseInit) {
mockFetch(async () => jsonResponse(body, init));
}
function mockTextFetchResponse(body: string, init?: ResponseInit) {
mockFetch(async () => textResponse(body, init));
}
function graphCollection<T>(...items: T[]) {
return { value: items };
}
function mockGraphCollection<T>(...items: T[]) {
mockJsonFetchResponse(graphCollection(...items));
}
function requestUrl(input: string | URL | Request) {
return typeof input === "string" ? input : String(input);
}
function fetchCallUrl(index: number) {
return String(vi.mocked(globalThis.fetch).mock.calls[index]?.[0]);
}
function expectFetchPathContains(index: number, expectedPath: string) {
expect(fetchCallUrl(index)).toContain(expectedPath);
}
async function expectSearchGraphUsers(
query: string,
expected: Array<Record<string, unknown>>,
options?: { token?: string; top?: number },
) {
await expect(
searchGraphUsers({
token: options?.token ?? graphToken,
query,
top: options?.top,
}),
).resolves.toEqual(expected);
}
async function expectRejectsToThrow(promise: Promise<unknown>, message: string) {
await expect(promise).rejects.toThrow(message);
}
function mockGraphTokenResolution(options?: {
rawToken?: string | null;
resolvedToken?: string | null;
}) {
const rawToken = options && "rawToken" in options ? options.rawToken : "raw-graph-token";
const resolvedToken =
options && "resolvedToken" in options ? options.resolvedToken : "resolved-token";
const getAccessToken = vi.fn(async () => rawToken);
loadMSTeamsSdkWithAuthMock.mockResolvedValue({ app: mockApp });
createMSTeamsTokenProviderMock.mockReturnValue({ getAccessToken });
resolveMSTeamsCredentialsMock.mockReturnValue(mockCredentials);
readAccessTokenMock.mockReturnValue(resolvedToken);
return { getAccessToken };
}
describe("msteams graph helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("normalizes queries and escapes OData apostrophes", () => {
expect(normalizeQuery(" Team Alpha ")).toBe("Team Alpha");
expect(normalizeQuery(" ")).toBe("");
expect(escapeOData("alice.o'hara")).toBe("alice.o''hara");
});
it("fetches Graph JSON and surfaces Graph errors with response text", async () => {
mockGraphCollection(groupOne);
await expect(
fetchGraphJson<{ value: Array<{ id: string }> }>({
token: graphToken,
path: "/groups?$select=id",
headers: { ConsistencyLevel: "eventual" },
}),
).resolves.toEqual(graphCollection(groupOne));
expect(globalThis.fetch).toHaveBeenCalledWith(
"https://graph.microsoft.com/v1.0/groups?$select=id",
{
headers: expect.objectContaining({
Authorization: `Bearer ${graphToken}`,
ConsistencyLevel: "eventual",
}),
},
);
mockTextFetchResponse("forbidden", { status: 403 });
await expectRejectsToThrow(
fetchGraphJson({
token: graphToken,
path: "/teams/team-1/channels",
}),
"Graph /teams/team-1/channels failed (403): forbidden",
);
});
it("posts Graph JSON to v1 and beta roots and treats empty mutation responses as undefined", async () => {
mockFetch(async (input) => {
if (requestUrl(input).startsWith("https://graph.microsoft.com/beta")) {
return new Response(null, { status: 204 });
}
return jsonResponse({ id: "created-1" });
});
await expect(
postGraphJson<{ id: string }>({
token: graphToken,
path: "/chats/chat-1/pinnedMessages",
body: { messageId: "msg-1" },
}),
).resolves.toEqual({ id: "created-1" });
await expect(
postGraphBetaJson<undefined>({
token: graphToken,
path: "/chats/chat-1/messages/msg-1/setReaction",
body: { reactionType: "like" },
}),
).resolves.toBeUndefined();
expect(globalThis.fetch).toHaveBeenNthCalledWith(
1,
"https://graph.microsoft.com/v1.0/chats/chat-1/pinnedMessages",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ messageId: "msg-1" }),
headers: expect.objectContaining({
Authorization: `Bearer ${graphToken}`,
"Content-Type": "application/json",
}),
}),
);
expect(globalThis.fetch).toHaveBeenNthCalledWith(
2,
"https://graph.microsoft.com/beta/chats/chat-1/messages/msg-1/setReaction",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ reactionType: "like" }),
}),
);
});
it("surfaces POST and DELETE graph failures with method-specific labels", async () => {
mockFetch(async (_input, init) => {
const method = init?.method ?? "GET";
if (method === "DELETE") {
return textResponse("not found", { status: 404 });
}
return textResponse("denied", { status: 403 });
});
await expectRejectsToThrow(
postGraphJson({
token: graphToken,
path: "/teams/team-1/channels",
body: { displayName: "Deployments" },
}),
"Graph POST /teams/team-1/channels failed (403): denied",
);
await expectRejectsToThrow(
deleteGraphRequest({
token: graphToken,
path: "/teams/team-1/channels/channel-1",
}),
"Graph DELETE /teams/team-1/channels/channel-1 failed (404): not found",
);
});
it("resolves Graph tokens through the SDK auth provider", async () => {
const { getAccessToken } = mockGraphTokenResolution();
await expect(resolveGraphToken({ channels: { msteams: {} } })).resolves.toBe("resolved-token");
expect(createMSTeamsTokenProviderMock).toHaveBeenCalledWith(mockApp);
expect(getAccessToken).toHaveBeenCalledWith("https://graph.microsoft.com");
});
it("fails when credentials or access tokens are unavailable", async () => {
resolveMSTeamsCredentialsMock.mockReturnValue(undefined);
await expectRejectsToThrow(resolveGraphToken({ channels: {} }), "MS Teams credentials missing");
mockGraphTokenResolution({ rawToken: null, resolvedToken: null });
await expectRejectsToThrow(
resolveGraphToken({ channels: { msteams: {} } }),
"MS Teams graph token unavailable",
);
});
it("builds encoded Graph paths for teams and channels", async () => {
mockFetch(async (input) => {
if (requestUrl(input).includes("/groups?")) {
return jsonResponse(graphCollection(opsTeam));
}
return jsonResponse(graphCollection(deploymentsChannel));
});
await expect(listTeamsByName(graphToken, "Bob's Team")).resolves.toEqual([opsTeam]);
await expect(listChannelsForTeam(graphToken, "team/ops")).resolves.toEqual([
deploymentsChannel,
]);
expectFetchPathContains(
0,
"/groups?$filter=resourceProvisioningOptions%2FAny(x%3Ax%20eq%20'Team')%20and%20startsWith(displayName%2C'Bob''s%20Team')&$select=id,displayName",
);
expectFetchPathContains(1, "/teams/team%2Fops/channels?$select=id,displayName");
});
it("returns no graph users for blank queries", async () => {
mockJsonFetchResponse({});
await expectSearchGraphUsers(" ", [], { token: "token-1" });
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("uses exact mail or UPN lookup for email-like graph user queries", async () => {
mockGraphCollection(userOne);
await expectSearchGraphUsers("alice.o'hara@example.com", [userOne], {
token: "token-2",
});
expectFetchPathContains(
0,
"/users?$filter=(mail%20eq%20'alice.o''hara%40example.com'%20or%20userPrincipalName%20eq%20'alice.o''hara%40example.com')&$select=id,displayName,mail,userPrincipalName",
);
});
it("uses displayName search with eventual consistency and default top handling", async () => {
mockFetch(async (input) => {
if (requestUrl(input).includes("displayName%3Abob")) {
return jsonResponse(graphCollection(bobUser));
}
return jsonResponse({});
});
await expectSearchGraphUsers("bob", [bobUser], {
token: "token-3",
top: 25,
});
await expectSearchGraphUsers("carol", [], { token: "token-4" });
const calls = vi.mocked(globalThis.fetch).mock.calls;
expectFetchPathContains(
0,
"/users?$search=%22displayName%3Abob%22&$select=id,displayName,mail,userPrincipalName&$top=25",
);
expect(calls[0]?.[1]).toEqual(
expect.objectContaining({
headers: expect.objectContaining({ ConsistencyLevel: "eventual" }),
}),
);
expectFetchPathContains(
1,
"/users?$search=%22displayName%3Acarol%22&$select=id,displayName,mail,userPrincipalName&$top=10",
);
});
});