mirror of https://github.com/openclaw/openclaw.git
343 lines
10 KiB
TypeScript
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",
|
|
);
|
|
});
|
|
});
|