mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-02 08:30:30 +00:00
221 lines
6.7 KiB
TypeScript
221 lines
6.7 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { PassThrough } from "node:stream";
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createPinnedLookup } from "../infra/net/ssrf.js";
|
|
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
|
|
import { captureEnv } from "../test-utils/env.js";
|
|
import { saveMediaSource, setMediaStoreNetworkDepsForTest } from "./store.js";
|
|
|
|
const homeRootTracker = createSuiteTempRootTracker({
|
|
prefix: "openclaw-home-redirect-",
|
|
});
|
|
const mockRequest = vi.fn();
|
|
|
|
function createMockHttpExchange() {
|
|
const res = Object.assign(new PassThrough(), {
|
|
statusCode: 0,
|
|
headers: {} as Record<string, string>,
|
|
});
|
|
const req = {
|
|
on: (event: string, handler: (...args: unknown[]) => void) => {
|
|
if (event === "error") {
|
|
res.on("error", handler);
|
|
}
|
|
return req;
|
|
},
|
|
end: () => undefined,
|
|
destroy: () => res.destroy(),
|
|
} as const;
|
|
return { req, res };
|
|
}
|
|
|
|
function mockRedirectExchange(params: { location?: string }) {
|
|
const { req, res } = createMockHttpExchange();
|
|
res.statusCode = 302;
|
|
res.headers = params.location ? { location: params.location } : {};
|
|
return {
|
|
req,
|
|
send(cb: (value: unknown) => void) {
|
|
setImmediate(() => {
|
|
cb(res as unknown);
|
|
res.end();
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
function mockSuccessfulTextExchange(params: { text: string; contentType: string }) {
|
|
const { req, res } = createMockHttpExchange();
|
|
res.statusCode = 200;
|
|
res.headers = { "content-type": params.contentType };
|
|
return {
|
|
req,
|
|
send(cb: (value: unknown) => void) {
|
|
setImmediate(() => {
|
|
cb(res as unknown);
|
|
res.write(params.text);
|
|
res.end();
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
function getRequestHeaders(callIndex: number): Headers {
|
|
const [, options] = mockRequest.mock.calls[callIndex] as [
|
|
URL,
|
|
{ headers?: HeadersInit | Record<string, string> } | undefined,
|
|
];
|
|
return new Headers(options?.headers);
|
|
}
|
|
|
|
async function expectRedirectSaveResult(params: {
|
|
expectedText: string;
|
|
expectedContentType: string;
|
|
expectedExtension: string;
|
|
headers?: Record<string, string>;
|
|
assertRequests?: () => void;
|
|
}) {
|
|
const saved = await saveMediaSource("https://example.com/start", params.headers);
|
|
expect(mockRequest).toHaveBeenCalledTimes(2);
|
|
params.assertRequests?.();
|
|
expect(saved.contentType).toBe(params.expectedContentType);
|
|
expect(path.extname(saved.path)).toBe(params.expectedExtension);
|
|
expect(await fs.readFile(saved.path, "utf8")).toBe(params.expectedText);
|
|
const stat = await fs.stat(saved.path);
|
|
const expectedMode = process.platform === "win32" ? 0o666 : 0o644 & ~process.umask();
|
|
expect(stat.mode & 0o777).toBe(expectedMode);
|
|
}
|
|
|
|
async function expectRedirectSaveFailure(expectedMessage: string) {
|
|
await expect(saveMediaSource("https://example.com/start")).rejects.toThrow(expectedMessage);
|
|
expect(mockRequest).toHaveBeenCalledTimes(1);
|
|
}
|
|
|
|
describe("media store redirects", () => {
|
|
let envSnapshot: ReturnType<typeof captureEnv>;
|
|
let home = "";
|
|
|
|
beforeAll(async () => {
|
|
envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
|
|
await homeRootTracker.setup();
|
|
home = await homeRootTracker.make("state");
|
|
process.env.OPENCLAW_STATE_DIR = home;
|
|
});
|
|
|
|
beforeEach(() => {
|
|
mockRequest.mockClear();
|
|
setMediaStoreNetworkDepsForTest({
|
|
httpRequest: (...args) => mockRequest(...args),
|
|
httpsRequest: (...args) => mockRequest(...args),
|
|
resolvePinnedHostname: async (hostname) => ({
|
|
hostname,
|
|
addresses: ["93.184.216.34"],
|
|
lookup: createPinnedLookup({ hostname, addresses: ["93.184.216.34"] }),
|
|
}),
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await homeRootTracker.cleanup();
|
|
home = "";
|
|
envSnapshot.restore();
|
|
setMediaStoreNetworkDepsForTest();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("follows redirects and keeps detected mime/extension", async () => {
|
|
let call = 0;
|
|
mockRequest.mockImplementation((_url, _opts, cb) => {
|
|
call += 1;
|
|
if (call === 1) {
|
|
const exchange = mockRedirectExchange({ location: "https://example.com/final" });
|
|
exchange.send(cb);
|
|
return exchange.req;
|
|
}
|
|
|
|
const exchange = mockSuccessfulTextExchange({
|
|
text: "redirected",
|
|
contentType: "text/plain",
|
|
});
|
|
exchange.send(cb);
|
|
return exchange.req;
|
|
});
|
|
|
|
await expectRedirectSaveResult({
|
|
expectedText: "redirected",
|
|
expectedContentType: "text/plain",
|
|
expectedExtension: ".txt",
|
|
});
|
|
});
|
|
|
|
it("strips sensitive headers when a redirect crosses origins", async () => {
|
|
let call = 0;
|
|
mockRequest.mockImplementation((_url, _opts, cb) => {
|
|
call += 1;
|
|
if (call === 1) {
|
|
const exchange = mockRedirectExchange({ location: "https://cdn.example.com/final" });
|
|
exchange.send(cb);
|
|
return exchange.req;
|
|
}
|
|
|
|
const exchange = mockSuccessfulTextExchange({
|
|
text: "redirected",
|
|
contentType: "text/plain",
|
|
});
|
|
exchange.send(cb);
|
|
return exchange.req;
|
|
});
|
|
|
|
await saveMediaSource("https://example.com/start", {
|
|
Authorization: "Bearer secret",
|
|
Cookie: "session=abc",
|
|
"X-Api-Key": "custom-secret",
|
|
Accept: "text/plain",
|
|
"User-Agent": "OpenClaw-Test/1.0",
|
|
});
|
|
|
|
expect(mockRequest).toHaveBeenCalledTimes(2);
|
|
const secondHeaders = getRequestHeaders(1);
|
|
expect(secondHeaders.get("authorization")).toBeNull();
|
|
expect(secondHeaders.get("cookie")).toBeNull();
|
|
expect(secondHeaders.get("x-api-key")).toBeNull();
|
|
expect(secondHeaders.get("accept")).toBe("text/plain");
|
|
expect(secondHeaders.get("user-agent")).toBe("OpenClaw-Test/1.0");
|
|
});
|
|
|
|
it("keeps headers when a redirect stays on the same origin", async () => {
|
|
let call = 0;
|
|
mockRequest.mockImplementation((_url, _opts, cb) => {
|
|
call += 1;
|
|
if (call === 1) {
|
|
const exchange = mockRedirectExchange({ location: "/final" });
|
|
exchange.send(cb);
|
|
return exchange.req;
|
|
}
|
|
|
|
const exchange = mockSuccessfulTextExchange({
|
|
text: "redirected",
|
|
contentType: "text/plain",
|
|
});
|
|
exchange.send(cb);
|
|
return exchange.req;
|
|
});
|
|
|
|
await saveMediaSource("https://example.com/start", {
|
|
Authorization: "Bearer secret",
|
|
});
|
|
|
|
expect(getRequestHeaders(1).get("authorization")).toBe("Bearer secret");
|
|
});
|
|
|
|
it("fails when redirect response omits location header", async () => {
|
|
mockRequest.mockImplementationOnce((_url, _opts, cb) => {
|
|
const exchange = mockRedirectExchange({});
|
|
exchange.send(cb);
|
|
return exchange.req;
|
|
});
|
|
await expectRedirectSaveFailure("Redirect loop or missing Location header");
|
|
});
|
|
});
|