* fix(slack): download all files in multi-image messages resolveSlackMedia() previously returned after downloading the first file, causing multi-image Slack messages to lose all but the first attachment. This changes the function to collect all successfully downloaded files into an array, matching the pattern already used by Telegram, Line, Discord, and iMessage adapters. The prepare handler now populates MediaPaths, MediaUrls, and MediaTypes arrays so downstream media processing (vision, sandbox staging, media notes) works correctly with multiple attachments. Fixes #11892, #7536 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(slack): preserve MediaTypes index alignment with MediaPaths/MediaUrls The filter(Boolean) on MediaTypes removed entries with undefined contentType, shrinking the array and breaking index correlation with MediaPaths and MediaUrls. Downstream code (media-note.ts, attachments.ts) requires these arrays to have equal lengths for correct per-attachment MIME type lookup. Replace filter(Boolean) with a nullish coalescing fallback to "application/octet-stream". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(slack): align MediaType fallback and tests (#15447) (thanks @CommanderCrowCode) * fix: unblock plugin-sdk account-id typing (#15447) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
115 lines
4.1 KiB
TypeScript
115 lines
4.1 KiB
TypeScript
import type { AddressInfo } from "node:net";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
|
const MEDIA_DIR = path.join(process.cwd(), "tmp-media-test");
|
|
const cleanOldMedia = vi.fn().mockResolvedValue(undefined);
|
|
|
|
vi.mock("./store.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("./store.js")>();
|
|
return {
|
|
...actual,
|
|
getMediaDir: () => MEDIA_DIR,
|
|
cleanOldMedia,
|
|
};
|
|
});
|
|
|
|
const { startMediaServer } = await import("./server.js");
|
|
const { MEDIA_MAX_BYTES } = await import("./store.js");
|
|
|
|
const waitForFileRemoval = async (file: string, timeoutMs = 200) => {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
try {
|
|
await fs.stat(file);
|
|
} catch {
|
|
return;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
throw new Error(`timed out waiting for ${file} removal`);
|
|
};
|
|
|
|
describe("media server", () => {
|
|
beforeAll(async () => {
|
|
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
|
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
|
});
|
|
|
|
it("serves media and cleans up after send", async () => {
|
|
const file = path.join(MEDIA_DIR, "file1");
|
|
await fs.writeFile(file, "hello");
|
|
const server = await startMediaServer(0, 5_000);
|
|
const port = (server.address() as AddressInfo).port;
|
|
const res = await fetch(`http://127.0.0.1:${port}/media/file1`);
|
|
expect(res.status).toBe(200);
|
|
expect(await res.text()).toBe("hello");
|
|
await waitForFileRemoval(file);
|
|
await new Promise((r) => server.close(r));
|
|
});
|
|
|
|
it("expires old media", async () => {
|
|
const file = path.join(MEDIA_DIR, "old");
|
|
await fs.writeFile(file, "stale");
|
|
const past = Date.now() - 10_000;
|
|
await fs.utimes(file, past / 1000, past / 1000);
|
|
const server = await startMediaServer(0, 1_000);
|
|
const port = (server.address() as AddressInfo).port;
|
|
const res = await fetch(`http://127.0.0.1:${port}/media/old`);
|
|
expect(res.status).toBe(410);
|
|
await expect(fs.stat(file)).rejects.toThrow();
|
|
await new Promise((r) => server.close(r));
|
|
});
|
|
|
|
it("blocks path traversal attempts", async () => {
|
|
const server = await startMediaServer(0, 5_000);
|
|
const port = (server.address() as AddressInfo).port;
|
|
// URL-encoded "../" to bypass client-side path normalization
|
|
const res = await fetch(`http://127.0.0.1:${port}/media/%2e%2e%2fpackage.json`);
|
|
expect(res.status).toBe(400);
|
|
expect(await res.text()).toBe("invalid path");
|
|
await new Promise((r) => server.close(r));
|
|
});
|
|
|
|
it("blocks symlink escaping outside media dir", async () => {
|
|
const target = path.join(process.cwd(), "package.json"); // outside MEDIA_DIR
|
|
const link = path.join(MEDIA_DIR, "link-out");
|
|
await fs.symlink(target, link);
|
|
|
|
const server = await startMediaServer(0, 5_000);
|
|
const port = (server.address() as AddressInfo).port;
|
|
const res = await fetch(`http://127.0.0.1:${port}/media/link-out`);
|
|
expect(res.status).toBe(400);
|
|
expect(await res.text()).toBe("invalid path");
|
|
await new Promise((r) => server.close(r));
|
|
});
|
|
|
|
it("rejects invalid media ids", async () => {
|
|
const file = path.join(MEDIA_DIR, "file2");
|
|
await fs.writeFile(file, "hello");
|
|
const server = await startMediaServer(0, 5_000);
|
|
const port = (server.address() as AddressInfo).port;
|
|
const res = await fetch(`http://127.0.0.1:${port}/media/invalid%20id`);
|
|
expect(res.status).toBe(400);
|
|
expect(await res.text()).toBe("invalid path");
|
|
await new Promise((r) => server.close(r));
|
|
});
|
|
|
|
it("rejects oversized media files", async () => {
|
|
const file = path.join(MEDIA_DIR, "big");
|
|
await fs.writeFile(file, "");
|
|
await fs.truncate(file, MEDIA_MAX_BYTES + 1);
|
|
const server = await startMediaServer(0, 5_000);
|
|
const port = (server.address() as AddressInfo).port;
|
|
const res = await fetch(`http://127.0.0.1:${port}/media/big`);
|
|
expect(res.status).toBe(413);
|
|
expect(await res.text()).toBe("too large");
|
|
await new Promise((r) => server.close(r));
|
|
});
|
|
});
|