mirror of https://github.com/openclaw/openclaw.git
Co-authored-by: Brad Groux <bradgroux@users.noreply.github.com>
This commit is contained in:
parent
dd2faa3764
commit
c88d6d67c8
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock shared.js to avoid transitive runtime-api imports that pull in uninstalled packages.
|
||||
vi.mock("./shared.js", () => ({
|
||||
|
|
@ -45,6 +45,8 @@ vi.mock("./remote-media.js", () => ({
|
|||
}));
|
||||
|
||||
import { fetchWithSsrFGuard } from "../../runtime-api.js";
|
||||
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
|
||||
import { safeFetchWithPolicy } from "./shared.js";
|
||||
import { downloadMSTeamsGraphMedia } from "./graph.js";
|
||||
|
||||
function mockFetchResponse(body: unknown, status = 200) {
|
||||
|
|
@ -57,6 +59,10 @@ function mockBinaryResponse(data: Uint8Array, status = 200) {
|
|||
}
|
||||
|
||||
describe("downloadMSTeamsGraphMedia hosted content $value fallback", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("fetches $value endpoint when contentBytes is null but item.id exists", async () => {
|
||||
const imageBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes
|
||||
|
||||
|
|
@ -271,4 +277,121 @@ describe("downloadMSTeamsGraphMedia hosted content $value fallback", () => {
|
|||
expect(valueCall).toBeUndefined();
|
||||
expect(result.media.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("adds the OpenClaw User-Agent to guarded Graph attachment fetches", async () => {
|
||||
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: { url: string; init?: RequestInit }) => {
|
||||
const url = params.url;
|
||||
if (url.endsWith("/messages/msg-ua") && !url.includes("hostedContents")) {
|
||||
return {
|
||||
response: mockFetchResponse({ body: {}, attachments: [] }),
|
||||
release: async () => {},
|
||||
finalUrl: params.url,
|
||||
};
|
||||
}
|
||||
if (url.endsWith("/hostedContents")) {
|
||||
return {
|
||||
response: mockFetchResponse({ value: [] }),
|
||||
release: async () => {},
|
||||
finalUrl: params.url,
|
||||
};
|
||||
}
|
||||
if (url.endsWith("/attachments")) {
|
||||
return {
|
||||
response: mockFetchResponse({ value: [] }),
|
||||
release: async () => {},
|
||||
finalUrl: params.url,
|
||||
};
|
||||
}
|
||||
return {
|
||||
response: mockFetchResponse({}, 404),
|
||||
release: async () => {},
|
||||
finalUrl: params.url,
|
||||
};
|
||||
});
|
||||
|
||||
await downloadMSTeamsGraphMedia({
|
||||
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-ua",
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
|
||||
maxBytes: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const guardCalls = vi.mocked(fetchWithSsrFGuard).mock.calls;
|
||||
for (const [call] of guardCalls) {
|
||||
const headers = call.init?.headers;
|
||||
expect(headers).toBeInstanceOf(Headers);
|
||||
expect((headers as Headers).get("Authorization")).toBe("Bearer test-token");
|
||||
expect((headers as Headers).get("User-Agent")).toMatch(
|
||||
/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("adds the OpenClaw User-Agent to Graph shares downloads for reference attachments", async () => {
|
||||
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: { url: string }) => {
|
||||
const url = params.url;
|
||||
if (url.endsWith("/messages/msg-share") && !url.includes("hostedContents")) {
|
||||
return {
|
||||
response: mockFetchResponse({
|
||||
body: {},
|
||||
attachments: [
|
||||
{
|
||||
contentType: "reference",
|
||||
contentUrl: "https://tenant.sharepoint.com/file.docx",
|
||||
name: "file.docx",
|
||||
},
|
||||
],
|
||||
}),
|
||||
release: async () => {},
|
||||
finalUrl: params.url,
|
||||
};
|
||||
}
|
||||
if (url.endsWith("/hostedContents")) {
|
||||
return {
|
||||
response: mockFetchResponse({ value: [] }),
|
||||
release: async () => {},
|
||||
finalUrl: params.url,
|
||||
};
|
||||
}
|
||||
if (url.endsWith("/attachments")) {
|
||||
return {
|
||||
response: mockFetchResponse({ value: [] }),
|
||||
release: async () => {},
|
||||
finalUrl: params.url,
|
||||
};
|
||||
}
|
||||
return {
|
||||
response: mockFetchResponse({}, 404),
|
||||
release: async () => {},
|
||||
finalUrl: params.url,
|
||||
};
|
||||
});
|
||||
vi.mocked(safeFetchWithPolicy).mockResolvedValue(new Response(null, { status: 200 }));
|
||||
vi.mocked(downloadAndStoreMSTeamsRemoteMedia).mockImplementation(async (params) => {
|
||||
if (params.fetchImpl) {
|
||||
await params.fetchImpl(params.url, {});
|
||||
}
|
||||
return {
|
||||
path: "/tmp/file.docx",
|
||||
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
placeholder: "[file]",
|
||||
};
|
||||
});
|
||||
|
||||
await downloadMSTeamsGraphMedia({
|
||||
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-share",
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
|
||||
maxBytes: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
expect(safeFetchWithPolicy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requestInit: expect.objectContaining({
|
||||
headers: expect.any(Headers),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const requestInit = vi.mocked(safeFetchWithPolicy).mock.calls[0]?.[0]?.requestInit;
|
||||
const headers = requestInit?.headers as Headers;
|
||||
expect(headers.get("User-Agent")).toMatch(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { fetchWithSsrFGuard, type SsrFPolicy } from "../../runtime-api.js";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import { ensureUserAgentHeader } from "../user-agent.js";
|
||||
import { downloadMSTeamsAttachments } from "./download.js";
|
||||
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
|
||||
import {
|
||||
|
|
@ -130,7 +131,7 @@ async function fetchGraphCollection<T>(params: {
|
|||
url: params.url,
|
||||
fetchImpl: fetchFn,
|
||||
init: {
|
||||
headers: { Authorization: `Bearer ${params.accessToken}` },
|
||||
headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }),
|
||||
},
|
||||
policy: params.ssrfPolicy,
|
||||
auditContext: "msteams.graph.collection",
|
||||
|
|
@ -209,7 +210,7 @@ async function downloadGraphHostedContent(params: {
|
|||
url: valueUrl,
|
||||
fetchImpl: params.fetchFn ?? fetch,
|
||||
init: {
|
||||
headers: { Authorization: `Bearer ${params.accessToken}` },
|
||||
headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }),
|
||||
},
|
||||
policy: params.ssrfPolicy,
|
||||
auditContext: "msteams.graph.hostedContent.value",
|
||||
|
|
@ -297,7 +298,7 @@ export async function downloadMSTeamsGraphMedia(params: {
|
|||
url: messageUrl,
|
||||
fetchImpl: fetchFn,
|
||||
init: {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
headers: ensureUserAgentHeader({ Authorization: `Bearer ${accessToken}` }),
|
||||
},
|
||||
policy: ssrfPolicy,
|
||||
auditContext: "msteams.graph.message",
|
||||
|
|
@ -340,7 +341,7 @@ export async function downloadMSTeamsGraphMedia(params: {
|
|||
ssrfPolicy,
|
||||
fetchImpl: async (input, init) => {
|
||||
const requestUrl = resolveRequestUrl(input);
|
||||
const headers = new Headers(init?.headers);
|
||||
const headers = ensureUserAgentHeader(init?.headers);
|
||||
applyAuthorizationHeaderForUrl({
|
||||
headers,
|
||||
url: requestUrl,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { uploadToConsentUrl } from "./file-consent.js";
|
||||
|
||||
describe("uploadToConsentUrl", () => {
|
||||
it("sends the OpenClaw User-Agent header with consent uploads", async () => {
|
||||
const fetchFn = vi.fn(async () => new Response(null, { status: 200 }));
|
||||
|
||||
await uploadToConsentUrl({
|
||||
url: "https://upload.example.com/file",
|
||||
buffer: Buffer.from("hello"),
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
"https://upload.example.com/file",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
headers: expect.objectContaining({
|
||||
"Content-Range": "bytes 0-4/5",
|
||||
"Content-Type": "application/octet-stream",
|
||||
"User-Agent": expect.stringMatching(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -34,6 +34,7 @@ describe("graph upload helpers", () => {
|
|||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer graph-token",
|
||||
"Content-Type": "application/octet-stream",
|
||||
"User-Agent": expect.stringMatching(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
@ -71,6 +72,7 @@ describe("graph upload helpers", () => {
|
|||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer graph-token",
|
||||
"Content-Type": "application/octet-stream",
|
||||
"User-Agent": expect.stringMatching(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
@ -138,7 +140,10 @@ describe("resolveGraphChatId", () => {
|
|||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/me/chats"),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: "Bearer graph-token" }),
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer graph-token",
|
||||
"User-Agent": expect.stringMatching(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const firstCall = fetchFn.mock.calls[0];
|
||||
|
|
|
|||
|
|
@ -327,7 +327,7 @@ export async function resolveGraphChatId(params: {
|
|||
}
|
||||
|
||||
const res = await fetchFn(`${GRAPH_ROOT}${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
headers: { "User-Agent": buildUserAgent(), Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ const jwtValidatorState = vi.hoisted(() => ({
|
|||
calls: [] as Array<{ jwksUri: string; token: string; overrideOptions?: unknown }>,
|
||||
}));
|
||||
|
||||
const clientConstructorState = vi.hoisted(() => ({
|
||||
calls: [] as Array<{ serviceUrl: string; options: unknown }>,
|
||||
}));
|
||||
|
||||
vi.mock("@microsoft/teams.apps/dist/middleware/auth/jwt-validator.js", () => ({
|
||||
JwtValidator: class JwtValidator {
|
||||
private readonly config: Record<string, unknown>;
|
||||
|
|
@ -38,6 +42,7 @@ const originalFetch = globalThis.fetch;
|
|||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
clientConstructorState.calls.length = 0;
|
||||
jwtValidatorState.instances.length = 0;
|
||||
jwtValidatorState.calls.length = 0;
|
||||
jwtValidatorState.behaviorByJwks.clear();
|
||||
|
|
@ -56,7 +61,9 @@ function createSdkStub(): MSTeamsTeamsSdk {
|
|||
}
|
||||
|
||||
class ClientStub {
|
||||
constructor(_serviceUrl: string, _options: unknown) {}
|
||||
constructor(serviceUrl: string, options: unknown) {
|
||||
clientConstructorState.calls.push({ serviceUrl, options });
|
||||
}
|
||||
|
||||
conversations = {
|
||||
activities: (_conversationId: string) => ({
|
||||
|
|
@ -134,6 +141,43 @@ describe("createMSTeamsAdapter", () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the OpenClaw User-Agent to the Bot Framework connector client", async () => {
|
||||
const creds = {
|
||||
appId: "app-id",
|
||||
appPassword: "secret",
|
||||
tenantId: "tenant-id",
|
||||
} satisfies MSTeamsCredentials;
|
||||
const sdk = createSdkStub();
|
||||
const app = new sdk.App({
|
||||
clientId: creds.appId,
|
||||
clientSecret: creds.appPassword,
|
||||
tenantId: creds.tenantId,
|
||||
});
|
||||
const adapter = createMSTeamsAdapter(app, sdk);
|
||||
|
||||
await adapter.continueConversation(
|
||||
creds.appId,
|
||||
{
|
||||
serviceUrl: "https://service.example.com/",
|
||||
conversation: { id: "19:conversation@thread.tacv2" },
|
||||
channelId: "msteams",
|
||||
},
|
||||
async (ctx) => {
|
||||
await ctx.sendActivity("hello");
|
||||
},
|
||||
);
|
||||
|
||||
expect(clientConstructorState.calls).toHaveLength(1);
|
||||
expect(clientConstructorState.calls[0]).toMatchObject({
|
||||
serviceUrl: "https://service.example.com/",
|
||||
options: {
|
||||
headers: {
|
||||
"User-Agent": expect.stringMatching(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBotFrameworkJwtValidator", () => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ vi.mock("./runtime.js", () => ({
|
|||
|
||||
import { fetchGraphJson } from "./graph.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import { buildUserAgent, resetUserAgentCache } from "./user-agent.js";
|
||||
import { buildUserAgent, ensureUserAgentHeader, resetUserAgentCache } from "./user-agent.js";
|
||||
|
||||
describe("buildUserAgent", () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -75,4 +75,12 @@ describe("buildUserAgent", () => {
|
|||
const [, init] = mockFetch.mock.calls[0];
|
||||
expect(init.headers["User-Agent"]).toBe("custom-agent/1.0");
|
||||
});
|
||||
|
||||
it("adds the generated User-Agent to Headers instances without overwriting callers", () => {
|
||||
const generated = ensureUserAgentHeader();
|
||||
expect(generated.get("User-Agent")).toMatch(/^teams\.ts\[apps\]\/.+ OpenClaw\/2026\.3\.19$/);
|
||||
|
||||
const custom = ensureUserAgentHeader({ "User-Agent": "custom-agent/2.0" });
|
||||
expect(custom.get("User-Agent")).toBe("custom-agent/2.0");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,3 +43,11 @@ export function buildUserAgent(): string {
|
|||
cachedUserAgent = `teams.ts[apps]/${resolveTeamsSdkVersion()} OpenClaw/${resolveOpenClawVersion()}`;
|
||||
return cachedUserAgent;
|
||||
}
|
||||
|
||||
export function ensureUserAgentHeader(headers?: HeadersInit): Headers {
|
||||
const nextHeaders = new Headers(headers);
|
||||
if (!nextHeaders.has("User-Agent")) {
|
||||
nextHeaders.set("User-Agent", buildUserAgent());
|
||||
}
|
||||
return nextHeaders;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue