disclaw/tests/unit/send-response.test.ts
Nick Tabeling 00f7909e6b
Some checks failed
CI / build-and-test (ubuntu-latest) (pull_request) Has been cancelled
CI / build-and-test (windows-latest) (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
feat(DIS-154): agent file sending via [ATTACH: path] convention
- parseAttachments() extracts [ATTACH: path] markers from response text
- validateAttachPath() enforces workspace boundary + readability + 8MB limit
- sendResponse() sends validated files as Discord attachments before text
- CLAUDE.md template teaches agents the [ATTACH: ...] syntax
- Path-traversal guard: only files within workspace are allowed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:16:50 +02:00

159 lines
5.7 KiB
TypeScript

import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { sendResponse, parseAttachments, validateAttachPath } from "../../src/discord/send-response";
import type { TextChannel, Message } from "discord.js";
function makeChannel() {
return {
send: vi.fn().mockResolvedValue({ id: "msg-1" }),
} as unknown as TextChannel;
}
function makeMessage() {
return {} as Message;
}
describe("parseAttachments", () => {
it("returns empty attachPaths and original text when no markers", () => {
const result = parseAttachments("Hello world", "/workspace");
expect(result.text).toBe("Hello world");
expect(result.attachPaths).toHaveLength(0);
});
it("extracts a single relative [ATTACH: ...] marker", () => {
const result = parseAttachments("Here is the file [ATTACH: images/cat.jpg]", "/workspace");
expect(result.text).toBe("Here is the file");
expect(result.attachPaths).toHaveLength(1);
expect(result.attachPaths[0]).toBe(path.resolve("/workspace", "images/cat.jpg"));
});
it("extracts an absolute [ATTACH: ...] marker unchanged", () => {
const abs = path.resolve("/workspace/file.txt");
const result = parseAttachments(`See [ATTACH: ${abs}]`, "/workspace");
expect(result.attachPaths[0]).toBe(abs);
});
it("extracts multiple markers", () => {
const result = parseAttachments(
"Files: [ATTACH: a.txt] and [ATTACH: b.png]",
"/workspace"
);
expect(result.attachPaths).toHaveLength(2);
expect(result.text).toBe("Files: and");
});
});
describe("validateAttachPath", () => {
let tmpDir: string;
let tmpFile: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-test-"));
tmpFile = path.join(tmpDir, "file.txt");
fs.writeFileSync(tmpFile, "hello");
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("returns null for a valid readable file within workspace", () => {
expect(validateAttachPath(tmpFile, tmpDir)).toBeNull();
});
it("rejects paths outside workspace (path traversal)", () => {
const outsideFile = path.join(os.tmpdir(), "outside.txt");
fs.writeFileSync(outsideFile, "secret");
const err = validateAttachPath(outsideFile, tmpDir);
expect(err).toMatch(/außerhalb/);
fs.unlinkSync(outsideFile);
});
it("rejects non-existent files", () => {
const missing = path.join(tmpDir, "ghost.txt");
const err = validateAttachPath(missing, tmpDir);
expect(err).toMatch(/nicht lesbar/);
});
});
describe("sendResponse", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("sends chunks via channel.send() for text <= 4000 chars", async () => {
const channel = makeChannel();
await sendResponse(channel, "Hello, world!", makeMessage());
expect(channel.send).toHaveBeenCalledTimes(1);
const call = vi.mocked(channel.send).mock.calls[0][0];
expect(typeof call).toBe("string");
});
it("sends an attachment for text > 4000 chars", async () => {
const channel = makeChannel();
await sendResponse(channel, "x".repeat(4001), makeMessage());
expect(channel.send).toHaveBeenCalledTimes(1);
const call = vi.mocked(channel.send).mock.calls[0][0];
expect(typeof call).toBe("object");
expect((call as { files: unknown[] }).files).toHaveLength(1);
});
it("does not call channel.send() for empty text", async () => {
const channel = makeChannel();
await sendResponse(channel, "", makeMessage());
expect(channel.send).not.toHaveBeenCalled();
});
it("sends [ATTACH: ...] file as Discord attachment", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-attach-"));
const filePath = path.join(tmpDir, "result.txt");
fs.writeFileSync(filePath, "data");
try {
const channel = makeChannel();
await sendResponse(channel, `Here it is [ATTACH: result.txt]`, makeMessage(), tmpDir);
// One call for the file, one for the text
expect(channel.send).toHaveBeenCalledTimes(2);
const fileCall = vi.mocked(channel.send).mock.calls[0][0] as { files: unknown[] };
expect(fileCall.files).toHaveLength(1);
const textCall = vi.mocked(channel.send).mock.calls[1][0];
expect(typeof textCall).toBe("string");
expect(textCall as string).toContain("Here it is");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("sends warning message for file outside workspace", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-ws-"));
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-out-"));
const outsideFile = path.join(outsideDir, "secret.txt");
fs.writeFileSync(outsideFile, "secret");
try {
const channel = makeChannel();
await sendResponse(channel, `[ATTACH: ${outsideFile}]`, makeMessage(), tmpDir);
expect(channel.send).toHaveBeenCalledTimes(1);
const call = vi.mocked(channel.send).mock.calls[0][0] as string;
expect(call).toMatch(/⚠️/);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
fs.rmSync(outsideDir, { recursive: true, force: true });
}
});
it("only sends text when no [ATTACH: ...] present and workspacePath given", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-plain-"));
try {
const channel = makeChannel();
await sendResponse(channel, "Plain text response", makeMessage(), tmpDir);
expect(channel.send).toHaveBeenCalledTimes(1);
expect(vi.mocked(channel.send).mock.calls[0][0]).toBe("Plain text response");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});