openclaw/src/slack/monitor/media.test.ts
Yoshihiro Takahara b305640e69 fix: allow genuine HTML file downloads
Address Codex review feedback: only reject HTML responses when the file
metadata indicates a non-HTML type. Genuine HTML files (with mimetype
text/html or .html extension) are now allowed through.

- Check file.mimetype and file.name extension before rejecting HTML
- Add tests for legitimate HTML file downloads
2026-01-30 13:52:46 +00:00

434 lines
13 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Store original fetch
const originalFetch = globalThis.fetch;
let mockFetch: ReturnType<typeof vi.fn>;
describe("fetchWithSlackAuth", () => {
beforeEach(() => {
// Create a new mock for each test
mockFetch = vi.fn();
globalThis.fetch = mockFetch as typeof fetch;
});
afterEach(() => {
// Restore original fetch
globalThis.fetch = originalFetch;
vi.resetModules();
});
it("sends Authorization header on initial request with manual redirect", async () => {
// Import after mocking fetch
const { fetchWithSlackAuth } = await import("./media.js");
// Simulate direct 200 response (no redirect)
const mockResponse = new Response(Buffer.from("image data"), {
status: 200,
headers: { "content-type": "image/jpeg" },
});
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token");
expect(result).toBe(mockResponse);
// Verify fetch was called with correct params
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith("https://files.slack.com/test.jpg", {
headers: { Authorization: "Bearer xoxb-test-token" },
redirect: "manual",
});
});
it("follows redirects without Authorization header", async () => {
const { fetchWithSlackAuth } = await import("./media.js");
// First call: redirect response from Slack
const redirectResponse = new Response(null, {
status: 302,
headers: { location: "https://cdn.slack-edge.com/presigned-url?sig=abc123" },
});
// Second call: actual file content from CDN
const fileResponse = new Response(Buffer.from("actual image data"), {
status: 200,
headers: { "content-type": "image/jpeg" },
});
mockFetch.mockResolvedValueOnce(redirectResponse).mockResolvedValueOnce(fileResponse);
const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token");
expect(result).toBe(fileResponse);
expect(mockFetch).toHaveBeenCalledTimes(2);
// First call should have Authorization header and manual redirect
expect(mockFetch).toHaveBeenNthCalledWith(1, "https://files.slack.com/test.jpg", {
headers: { Authorization: "Bearer xoxb-test-token" },
redirect: "manual",
});
// Second call should follow the redirect without Authorization
expect(mockFetch).toHaveBeenNthCalledWith(
2,
"https://cdn.slack-edge.com/presigned-url?sig=abc123",
{ redirect: "follow" },
);
});
it("handles relative redirect URLs", async () => {
const { fetchWithSlackAuth } = await import("./media.js");
// Redirect with relative URL
const redirectResponse = new Response(null, {
status: 302,
headers: { location: "/files/redirect-target" },
});
const fileResponse = new Response(Buffer.from("image data"), {
status: 200,
headers: { "content-type": "image/jpeg" },
});
mockFetch.mockResolvedValueOnce(redirectResponse).mockResolvedValueOnce(fileResponse);
await fetchWithSlackAuth("https://files.slack.com/original.jpg", "xoxb-test-token");
// Second call should resolve the relative URL against the original
expect(mockFetch).toHaveBeenNthCalledWith(2, "https://files.slack.com/files/redirect-target", {
redirect: "follow",
});
});
it("returns redirect response when no location header is provided", async () => {
const { fetchWithSlackAuth } = await import("./media.js");
// Redirect without location header
const redirectResponse = new Response(null, {
status: 302,
// No location header
});
mockFetch.mockResolvedValueOnce(redirectResponse);
const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token");
// Should return the redirect response directly
expect(result).toBe(redirectResponse);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("returns 4xx/5xx responses directly without following", async () => {
const { fetchWithSlackAuth } = await import("./media.js");
const errorResponse = new Response("Not Found", {
status: 404,
});
mockFetch.mockResolvedValueOnce(errorResponse);
const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token");
expect(result).toBe(errorResponse);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("handles 301 permanent redirects", async () => {
const { fetchWithSlackAuth } = await import("./media.js");
const redirectResponse = new Response(null, {
status: 301,
headers: { location: "https://cdn.slack.com/new-url" },
});
const fileResponse = new Response(Buffer.from("image data"), {
status: 200,
});
mockFetch.mockResolvedValueOnce(redirectResponse).mockResolvedValueOnce(fileResponse);
await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token");
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenNthCalledWith(2, "https://cdn.slack.com/new-url", {
redirect: "follow",
});
});
});
describe("resolveSlackMedia", () => {
beforeEach(() => {
mockFetch = vi.fn();
globalThis.fetch = mockFetch as typeof fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.resetModules();
});
it("prefers url_private_download over url_private", async () => {
// Mock the store module
vi.doMock("../../media/store.js", () => ({
saveMediaBuffer: vi.fn().mockResolvedValue({
path: "/tmp/test.jpg",
contentType: "image/jpeg",
}),
}));
const { resolveSlackMedia } = await import("./media.js");
const mockResponse = new Response(Buffer.from("image data"), {
status: 200,
headers: { "content-type": "image/jpeg" },
});
mockFetch.mockResolvedValueOnce(mockResponse);
await resolveSlackMedia({
files: [
{
url_private: "https://files.slack.com/private.jpg",
url_private_download: "https://files.slack.com/download.jpg",
name: "test.jpg",
},
],
token: "xoxb-test-token",
maxBytes: 1024 * 1024,
});
expect(mockFetch).toHaveBeenCalledWith(
"https://files.slack.com/download.jpg",
expect.anything(),
);
});
it("returns null when download fails", async () => {
const { resolveSlackMedia } = await import("./media.js");
// Simulate a network error
mockFetch.mockRejectedValueOnce(new Error("Network error"));
const result = await resolveSlackMedia({
files: [{ url_private: "https://files.slack.com/test.jpg", name: "test.jpg" }],
token: "xoxb-test-token",
maxBytes: 1024 * 1024,
});
expect(result).toBeNull();
});
it("returns null when no files are provided", async () => {
const { resolveSlackMedia } = await import("./media.js");
const result = await resolveSlackMedia({
files: [],
token: "xoxb-test-token",
maxBytes: 1024 * 1024,
});
expect(result).toBeNull();
});
it("skips files without url_private", async () => {
const { resolveSlackMedia } = await import("./media.js");
const result = await resolveSlackMedia({
files: [{ name: "test.jpg" }], // No url_private
token: "xoxb-test-token",
maxBytes: 1024 * 1024,
});
expect(result).toBeNull();
expect(mockFetch).not.toHaveBeenCalled();
});
it("rejects HTML response (auth failure) and returns null", async () => {
const { resolveSlackMedia } = await import("./media.js");
// Simulate Slack returning an HTML login page instead of the image
const htmlContent = `<!DOCTYPE html>
<html data-cdn="https://a.slack-edge.com/">
<head><title>Slack</title></head>
<body>
<script>redirectURL: "/files-pri/T123-F456/download/image.png"</script>
</body>
</html>`;
const htmlResponse = new Response(htmlContent, {
status: 200,
headers: { "content-type": "text/html; charset=utf-8" },
});
mockFetch.mockResolvedValueOnce(htmlResponse);
const result = await resolveSlackMedia({
files: [{ url_private_download: "https://files.slack.com/test.png", name: "test.png" }],
token: "invalid-or-expired-token",
maxBytes: 10 * 1024 * 1024,
});
// Should reject HTML and return null
expect(result).toBeNull();
});
it("rejects HTML response detected by buffer content even if content-type header is missing", async () => {
const { resolveSlackMedia } = await import("./media.js");
// HTML content but no content-type header (edge case)
const htmlContent = `<!doctype html><html><body>Slack login page</body></html>`;
const htmlResponse = new Response(htmlContent, {
status: 200,
// No content-type header
});
mockFetch.mockResolvedValueOnce(htmlResponse);
const result = await resolveSlackMedia({
files: [{ url_private: "https://files.slack.com/test.jpg", name: "test.jpg" }],
token: "xoxb-test-token",
maxBytes: 10 * 1024 * 1024,
});
expect(result).toBeNull();
});
it("allows genuine HTML file when mimetype indicates text/html", async () => {
// Mock the store module
vi.doMock("../../media/store.js", () => ({
saveMediaBuffer: vi.fn().mockResolvedValue({
path: "/tmp/test.html",
contentType: "text/html",
}),
}));
const { resolveSlackMedia } = await import("./media.js");
// A genuine HTML file shared by a user
const htmlContent = `<!DOCTYPE html><html><body>User's HTML page</body></html>`;
const htmlResponse = new Response(htmlContent, {
status: 200,
headers: { "content-type": "text/html; charset=utf-8" },
});
mockFetch.mockResolvedValueOnce(htmlResponse);
const result = await resolveSlackMedia({
files: [
{
url_private: "https://files.slack.com/page.html",
name: "page.html",
mimetype: "text/html",
},
],
token: "xoxb-test-token",
maxBytes: 10 * 1024 * 1024,
});
// Should allow genuine HTML files
expect(result).not.toBeNull();
expect(result?.contentType).toBe("text/html");
});
it("allows HTML file based on .html extension even without mimetype", async () => {
// Mock the store module
vi.doMock("../../media/store.js", () => ({
saveMediaBuffer: vi.fn().mockResolvedValue({
path: "/tmp/test.html",
contentType: "text/html",
}),
}));
const { resolveSlackMedia } = await import("./media.js");
const htmlContent = `<!DOCTYPE html><html><body>User's HTML snippet</body></html>`;
const htmlResponse = new Response(htmlContent, {
status: 200,
headers: { "content-type": "text/html" },
});
mockFetch.mockResolvedValueOnce(htmlResponse);
const result = await resolveSlackMedia({
files: [
{
url_private: "https://files.slack.com/snippet.html",
name: "snippet.html",
// No mimetype set
},
],
token: "xoxb-test-token",
maxBytes: 10 * 1024 * 1024,
});
// Should allow based on .html extension
expect(result).not.toBeNull();
});
it("falls through to next file when first returns HTML", async () => {
// Mock the store module
vi.doMock("../../media/store.js", () => ({
saveMediaBuffer: vi.fn().mockResolvedValue({
path: "/tmp/test.jpg",
contentType: "image/jpeg",
}),
}));
const { resolveSlackMedia } = await import("./media.js");
// First file: HTML (auth failure)
const htmlResponse = new Response("<!DOCTYPE html><html>login</html>", {
status: 200,
headers: { "content-type": "text/html" },
});
// Second file: actual image
const imageResponse = new Response(Buffer.from("image data"), {
status: 200,
headers: { "content-type": "image/jpeg" },
});
mockFetch.mockResolvedValueOnce(htmlResponse).mockResolvedValueOnce(imageResponse);
const result = await resolveSlackMedia({
files: [
{ url_private: "https://files.slack.com/first.png", name: "first.png" },
{ url_private: "https://files.slack.com/second.jpg", name: "second.jpg" },
],
token: "xoxb-test-token",
maxBytes: 10 * 1024 * 1024,
});
// Should skip HTML and succeed with the second file
expect(result).not.toBeNull();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("falls through to next file when first file returns error", async () => {
// Mock the store module
vi.doMock("../../media/store.js", () => ({
saveMediaBuffer: vi.fn().mockResolvedValue({
path: "/tmp/test.jpg",
contentType: "image/jpeg",
}),
}));
const { resolveSlackMedia } = await import("./media.js");
// First file: 404
const errorResponse = new Response("Not Found", { status: 404 });
// Second file: success
const successResponse = new Response(Buffer.from("image data"), {
status: 200,
headers: { "content-type": "image/jpeg" },
});
mockFetch.mockResolvedValueOnce(errorResponse).mockResolvedValueOnce(successResponse);
const result = await resolveSlackMedia({
files: [
{ url_private: "https://files.slack.com/first.jpg", name: "first.jpg" },
{ url_private: "https://files.slack.com/second.jpg", name: "second.jpg" },
],
token: "xoxb-test-token",
maxBytes: 1024 * 1024,
});
expect(result).not.toBeNull();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});