Files

539 lines
18 KiB
Go

package tools
// TDD red-state tests for send_file tool.
// NewSendFileTool does NOT exist yet — this file will fail to compile until
// phase 03 adds send_file.go. That compile failure is the intended red state.
//
// Run to confirm red state:
// go test -v ./internal/tools/... -run TestSendFile 2>&1 | grep -i 'undefined\|SendFileTool'
// Run to confirm no regression:
// go test ./internal/tools/... -run 'TestMessage|TestWriteFile|TestDeliveredMedia'
import (
"context"
"crypto/rand"
"os"
"path/filepath"
"strings"
"testing"
)
// mkSendFileWorkspace creates a temp workspace with a small set of test files.
// Returns (workspaceCanonical, reportPDF, subFile, subDir).
func mkSendFileWorkspace(t *testing.T) (ws, reportPDF, subFile, subDir string) {
t.Helper()
workspace := t.TempDir()
workspaceCanonical, _ := filepath.EvalSymlinks(workspace)
// workspace/report.pdf
reportPDF = filepath.Join(workspaceCanonical, "report.pdf")
if err := os.WriteFile(reportPDF, []byte("%PDF-1.4 body"), 0o644); err != nil {
t.Fatal(err)
}
// workspace/subdir/file.txt
subDir = filepath.Join(workspaceCanonical, "subdir")
if err := os.MkdirAll(subDir, 0o755); err != nil {
t.Fatal(err)
}
subFile = filepath.Join(subDir, "file.txt")
if err := os.WriteFile(subFile, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
return workspaceCanonical, reportPDF, subFile, subDir
}
// TestSendFile_T1_HappyPath verifies that send_file on an existing file succeeds
// with Result.Media populated, correct path, filename, and MIME type.
func TestSendFile_T1_HappyPath(t *testing.T) {
ws, reportPDF, _, _ := mkSendFileWorkspace(t)
tool := NewSendFileTool(ws, true)
ctx := context.Background()
result := tool.Execute(ctx, map[string]any{
"path": reportPDF,
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
if len(result.Media) != 1 {
t.Fatalf("expected 1 Media entry, got %d", len(result.Media))
}
if result.Media[0].Path != reportPDF {
t.Errorf("Media[0].Path = %q, want %q", result.Media[0].Path, reportPDF)
}
if result.Media[0].Filename != "report.pdf" {
t.Errorf("Media[0].Filename = %q, want %q", result.Media[0].Filename, "report.pdf")
}
if result.Media[0].MimeType != "application/pdf" {
t.Errorf("Media[0].MimeType = %q, want %q", result.Media[0].MimeType, "application/pdf")
}
}
// TestSendFile_T2_WithCaption verifies that a caption appears in ForLLM or ForUser
// and that Media is still populated.
func TestSendFile_T2_WithCaption(t *testing.T) {
ws, reportPDF, _, _ := mkSendFileWorkspace(t)
tool := NewSendFileTool(ws, true)
ctx := context.Background()
result := tool.Execute(ctx, map[string]any{
"path": reportPDF,
"caption": "Q4 report",
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
if len(result.Media) != 1 {
t.Fatalf("expected 1 Media entry, got %d", len(result.Media))
}
caption := result.ForLLM + result.ForUser
if !strings.Contains(caption, "Q4 report") {
t.Errorf("expected caption %q in ForLLM/ForUser, got ForLLM=%q ForUser=%q",
"Q4 report", result.ForLLM, result.ForUser)
}
}
// TestSendFile_T3_MissingPath verifies that omitting the "path" param returns an error.
func TestSendFile_T3_MissingPath(t *testing.T) {
ws, _, _, _ := mkSendFileWorkspace(t)
tool := NewSendFileTool(ws, true)
ctx := context.Background()
result := tool.Execute(ctx, map[string]any{})
if !result.IsError {
t.Fatal("expected error for missing path, got success")
}
if !strings.Contains(strings.ToLower(result.ForLLM), "path") {
t.Errorf("expected error to mention 'path', got: %s", result.ForLLM)
}
}
// TestSendFile_T4_FileNotFound verifies that a non-existent path returns an error.
func TestSendFile_T4_FileNotFound(t *testing.T) {
ws, _, _, _ := mkSendFileWorkspace(t)
tool := NewSendFileTool(ws, true)
ctx := context.Background()
result := tool.Execute(ctx, map[string]any{
"path": filepath.Join(ws, "nonexistent.pdf"),
})
if !result.IsError {
t.Fatal("expected error for non-existent file, got success")
}
msg := strings.ToLower(result.ForLLM)
if !strings.Contains(msg, "not found") && !strings.Contains(msg, "does not exist") &&
!strings.Contains(msg, "no such file") {
t.Errorf("expected error to mention 'not found' or similar, got: %s", result.ForLLM)
}
}
// TestSendFile_T5_DirectoryRejected verifies that passing a directory path returns an error.
func TestSendFile_T5_DirectoryRejected(t *testing.T) {
ws, _, _, subDir := mkSendFileWorkspace(t)
tool := NewSendFileTool(ws, true)
ctx := context.Background()
result := tool.Execute(ctx, map[string]any{
"path": subDir,
})
if !result.IsError {
t.Fatal("expected error for directory path, got success")
}
msg := strings.ToLower(result.ForLLM)
if !strings.Contains(msg, "regular file") && !strings.Contains(msg, "directory") &&
!strings.Contains(msg, "not a file") {
t.Errorf("expected error to mention directory/regular-file, got: %s", result.ForLLM)
}
}
// TestSendFile_T6_PathTraversalBlocked verifies that path traversal with restrict=true is blocked.
func TestSendFile_T6_PathTraversalBlocked(t *testing.T) {
ws, _, _, _ := mkSendFileWorkspace(t)
tool := NewSendFileTool(ws, true)
ctx := context.Background()
// Try to escape workspace using traversal.
traversalPath := filepath.Join(ws, "..", "..", "etc", "passwd")
result := tool.Execute(ctx, map[string]any{
"path": traversalPath,
})
if !result.IsError {
t.Fatal("expected error for path traversal, got success")
}
}
// TestSendFile_T7_RelativePathResolvesAgainstWorkspace verifies that a relative path
// like "subdir/file.txt" resolves against the workspace root.
func TestSendFile_T7_RelativePathResolvesAgainstWorkspace(t *testing.T) {
ws, _, subFile, _ := mkSendFileWorkspace(t)
tool := NewSendFileTool(ws, true)
ctx := context.Background()
result := tool.Execute(ctx, map[string]any{
"path": "subdir/file.txt",
})
if result.IsError {
t.Fatalf("expected success for relative path, got error: %s", result.ForLLM)
}
if len(result.Media) != 1 {
t.Fatalf("expected 1 Media entry, got %d", len(result.Media))
}
// Canonical form comparison — both paths should point to same file.
gotCanonical, _ := filepath.EvalSymlinks(result.Media[0].Path)
wantCanonical, _ := filepath.EvalSymlinks(subFile)
if gotCanonical != wantCanonical {
t.Errorf("Media[0].Path resolved to %q, want %q", gotCanonical, wantCanonical)
}
}
// TestSendFile_T8_AbsoluteInsideWorkspaceAllowed verifies that an absolute path
// inside the workspace succeeds with restrict=true.
func TestSendFile_T8_AbsoluteInsideWorkspaceAllowed(t *testing.T) {
ws, reportPDF, _, _ := mkSendFileWorkspace(t)
tool := NewSendFileTool(ws, true)
ctx := context.Background()
result := tool.Execute(ctx, map[string]any{
"path": reportPDF, // absolute path inside workspace
})
if result.IsError {
t.Fatalf("expected success for absolute path inside workspace, got error: %s", result.ForLLM)
}
if len(result.Media) != 1 {
t.Fatalf("expected 1 Media entry, got %d", len(result.Media))
}
}
// TestSendFile_T9_DuplicateBlocksSameToolCall verifies that calling send_file twice
// with the same path in the same ctx causes the second call to return an error.
func TestSendFile_T9_DuplicateBlocksSameToolCall(t *testing.T) {
ws, reportPDF, _, _ := mkSendFileWorkspace(t)
tool := NewSendFileTool(ws, true)
dm := NewDeliveredMedia()
ctx := WithDeliveredMedia(context.Background(), dm)
// First call — should succeed.
first := tool.Execute(ctx, map[string]any{
"path": reportPDF,
})
if first.IsError {
t.Fatalf("first send_file: expected success, got error: %s", first.ForLLM)
}
// Second call — same path, same ctx — must be blocked.
second := tool.Execute(ctx, map[string]any{
"path": reportPDF,
})
if !second.IsError {
t.Fatal("second send_file (dup): expected error (already delivered), got success")
}
msg := strings.ToLower(second.ForLLM)
if !strings.Contains(msg, "already") {
t.Errorf("expected error to mention 'already delivered/sent', got: %s", second.ForLLM)
}
}
// TestSendFile_T10_MarksDeliveredMedia verifies that after send_file succeeds,
// dm.IsDelivered(resolvedPath) returns true.
func TestSendFile_T10_MarksDeliveredMedia(t *testing.T) {
ws, reportPDF, _, _ := mkSendFileWorkspace(t)
tool := NewSendFileTool(ws, true)
dm := NewDeliveredMedia()
ctx := WithDeliveredMedia(context.Background(), dm)
result := tool.Execute(ctx, map[string]any{
"path": reportPDF,
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
if !dm.IsDelivered(reportPDF) {
t.Errorf("expected dm.IsDelivered(%q) = true after send_file, got false", reportPDF)
}
}
// TestSendFile_T11_SubsequentMessageMediaBlocked verifies that after send_file
// marks a path, a subsequent message(MEDIA:path) self-send is blocked.
func TestSendFile_T11_SubsequentMessageMediaBlocked(t *testing.T) {
ws, reportPDF, _, _ := mkSendFileWorkspace(t)
sendTool := NewSendFileTool(ws, true)
msgTool := NewMessageTool(ws, true)
msgTool.SetMessageBus(nil)
dm := NewDeliveredMedia()
ctx := context.Background()
ctx = WithDeliveredMedia(ctx, dm)
ctx = WithToolChannel(ctx, "telegram")
ctx = WithToolChatID(ctx, "chat-42")
// Step 1: send_file delivers and marks the file.
sendResult := sendTool.Execute(ctx, map[string]any{
"path": reportPDF,
})
if sendResult.IsError {
t.Fatalf("send_file step failed: %s", sendResult.ForLLM)
}
// Step 2: message(MEDIA:path) for same file — must be blocked by self-send guard.
msgResult := msgTool.Execute(ctx, map[string]any{
"action": "send",
"channel": "telegram",
"target": "chat-42",
"message": "MEDIA:" + reportPDF,
})
if !msgResult.IsError {
t.Fatal("expected message(MEDIA:path) to be blocked after send_file, but it was allowed")
}
}
// TestSendFile_T11b_BlocksAfterMessageMediaMarked verifies that send_file returns
// an error when the path was already marked by message(MEDIA:) success.
//
// NOTE: This test simulates the post-phase-03 state where message.go calls dm.Mark()
// on MEDIA: success. Currently message.go does NOT call Mark (documented gap in
// TestMessageMediaNoMark_DocumentedGap). Once phase 03 patches message.go, the
// manual dm.Mark call below can be replaced by running the message tool directly.
func TestSendFile_T11b_BlocksAfterMessageMediaMarked(t *testing.T) {
ws, reportPDF, _, _ := mkSendFileWorkspace(t)
tool := NewSendFileTool(ws, true)
dm := NewDeliveredMedia()
// Simulating post-phase-03 mark: once message.go is patched, this test can
// call the message tool directly and remove this manual Mark call.
dm.Mark(reportPDF)
ctx := WithDeliveredMedia(context.Background(), dm)
result := tool.Execute(ctx, map[string]any{
"path": reportPDF,
})
if !result.IsError {
t.Fatal("expected send_file to block path already marked by message(MEDIA:), got success")
}
msg := strings.ToLower(result.ForLLM)
if !strings.Contains(msg, "already") {
t.Errorf("expected error to mention 'already delivered/sent', got: %s", result.ForLLM)
}
}
// TestSendFile_T11c_BlocksAfterWriteFileDeliverTrue verifies that send_file blocks
// when the path was already delivered via write_file(deliver=true).
func TestSendFile_T11c_BlocksAfterWriteFileDeliverTrue(t *testing.T) {
ws, _, _, _ := mkSendFileWorkspace(t)
writeTool := NewWriteFileTool(ws, true)
sendTool := NewSendFileTool(ws, true)
dm := NewDeliveredMedia()
ctx := WithDeliveredMedia(context.Background(), dm)
// Step 1: write_file deliver=true creates + marks the file.
writeResult := writeTool.Execute(ctx, map[string]any{
"path": "data.csv",
"content": "col1,col2\n1,2\n",
"deliver": true,
})
if writeResult.IsError {
t.Fatalf("write_file step failed: %s", writeResult.ForLLM)
}
resolvedPath := filepath.Join(ws, "data.csv")
// Step 2: send_file for same path — must be blocked.
sendResult := sendTool.Execute(ctx, map[string]any{
"path": resolvedPath,
})
if !sendResult.IsError {
t.Fatal("expected send_file to block path already delivered by write_file deliver=true, got success")
}
msg := strings.ToLower(sendResult.ForLLM)
if !strings.Contains(msg, "already") {
t.Errorf("expected error to mention 'already delivered/sent', got: %s", sendResult.ForLLM)
}
}
// TestSendFile_T12_MimeDetection is a table-driven test for MIME type detection
// from file extension.
func TestSendFile_T12_MimeDetection(t *testing.T) {
ws := t.TempDir()
wsCanonical, _ := filepath.EvalSymlinks(ws)
tool := NewSendFileTool(wsCanonical, true)
cases := []struct {
ext string
wantMime string
}{
{".pdf", "application/pdf"},
{".png", "image/png"},
{".unknown", "application/octet-stream"},
}
for _, tc := range cases {
t.Run(tc.ext, func(t *testing.T) {
name := "testfile" + tc.ext
fpath := filepath.Join(wsCanonical, name)
if err := os.WriteFile(fpath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
ctx := context.Background()
result := tool.Execute(ctx, map[string]any{
"path": fpath,
})
if result.IsError {
t.Fatalf("expected success for %s, got error: %s", tc.ext, result.ForLLM)
}
if len(result.Media) != 1 {
t.Fatalf("expected 1 Media entry, got %d", len(result.Media))
}
if result.Media[0].MimeType != tc.wantMime {
t.Errorf("MimeType for %s = %q, want %q", tc.ext, result.Media[0].MimeType, tc.wantMime)
}
})
}
}
// TestSendFile_T14_DenyPathsBlocked verifies that send_file rejects paths covered by DenyPaths.
func TestSendFile_T14_DenyPathsBlocked(t *testing.T) {
ws := t.TempDir()
wsCanonical, _ := filepath.EvalSymlinks(ws)
tool := NewSendFileTool(wsCanonical, true)
tool.DenyPaths("memory.db", "config.json")
// Create a denied file inside the workspace.
deniedFile := filepath.Join(wsCanonical, "memory.db")
if err := os.WriteFile(deniedFile, []byte("db-data"), 0o644); err != nil {
t.Fatal(err)
}
ctx := context.Background()
result := tool.Execute(ctx, map[string]any{
"path": "memory.db",
})
if !result.IsError {
t.Fatal("expected error for denied path memory.db, got success")
}
msg := strings.ToLower(result.ForLLM)
if !strings.Contains(msg, "denied") && !strings.Contains(msg, "restricted") {
t.Errorf("expected error to mention 'denied' or 'restricted', got: %s", result.ForLLM)
}
}
// TestSendFile_T13_BinaryFileLargeNoContentRead verifies that a large binary file
// is handled efficiently — the tool must not read the file contents, only stat + path.
// Test should complete well under 1 second even for 10MB.
func TestSendFile_T13_BinaryFileLargeNoContentRead(t *testing.T) {
ws := t.TempDir()
wsCanonical, _ := filepath.EvalSymlinks(ws)
tool := NewSendFileTool(wsCanonical, true)
// Write a 10MB random binary file.
bigFile := filepath.Join(wsCanonical, "bigdata.bin")
const size = 10 * 1024 * 1024
data := make([]byte, size)
_, _ = rand.Read(data)
if err := os.WriteFile(bigFile, data, 0o644); err != nil {
t.Fatal(err)
}
ctx := context.Background()
result := tool.Execute(ctx, map[string]any{
"path": bigFile,
})
if result.IsError {
t.Fatalf("expected success for large binary, got error: %s", result.ForLLM)
}
if len(result.Media) != 1 {
t.Fatalf("expected 1 Media entry, got %d", len(result.Media))
}
if result.Media[0].Path != bigFile {
t.Errorf("Media[0].Path = %q, want %q", result.Media[0].Path, bigFile)
}
}
func TestSendFile_T15_BatchAttachmentsHappyPath(t *testing.T) {
ws, reportPDF, subFile, _ := mkSendFileWorkspace(t)
tool := NewSendFileTool(ws, true)
result := tool.Execute(context.Background(), map[string]any{
"attachments": []any{
map[string]any{"path": reportPDF, "caption": "Q4 report"},
map[string]any{"path": subFile, "caption": "Notes"},
},
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
if len(result.Media) != 2 {
t.Fatalf("expected 2 Media entries, got %d", len(result.Media))
}
if result.Media[0].Path != reportPDF || result.Media[1].Path != subFile {
t.Errorf("batch order = [%q, %q], want [%q, %q]",
result.Media[0].Path, result.Media[1].Path, reportPDF, subFile)
}
if result.Media[0].Caption != "Q4 report" || result.Media[1].Caption != "Notes" {
t.Errorf("captions = [%q, %q], want [Q4 report, Notes]",
result.Media[0].Caption, result.Media[1].Caption)
}
}
func TestSendFile_T16_BatchDuplicateRejectedBeforeMarking(t *testing.T) {
ws, reportPDF, subFile, _ := mkSendFileWorkspace(t)
tool := NewSendFileTool(ws, true)
dm := NewDeliveredMedia()
ctx := WithDeliveredMedia(context.Background(), dm)
result := tool.Execute(ctx, map[string]any{
"attachments": []any{
map[string]any{"path": reportPDF},
map[string]any{"path": subFile},
map[string]any{"path": reportPDF},
},
})
if !result.IsError {
t.Fatal("expected duplicate batch error, got success")
}
if !strings.Contains(strings.ToLower(result.ForLLM), "duplicate") {
t.Errorf("expected duplicate error, got: %s", result.ForLLM)
}
if dm.IsDelivered(reportPDF) || dm.IsDelivered(subFile) {
t.Fatal("duplicate batch must not mark any file delivered")
}
}
func TestSendFile_T17_BatchAlreadyDeliveredRejectedBeforeMarkingNewFiles(t *testing.T) {
ws, reportPDF, subFile, _ := mkSendFileWorkspace(t)
tool := NewSendFileTool(ws, true)
dm := NewDeliveredMedia()
dm.Mark(reportPDF)
ctx := WithDeliveredMedia(context.Background(), dm)
result := tool.Execute(ctx, map[string]any{
"attachments": []any{
map[string]any{"path": subFile},
map[string]any{"path": reportPDF},
},
})
if !result.IsError {
t.Fatal("expected already-delivered batch error, got success")
}
if !strings.Contains(strings.ToLower(result.ForLLM), "already") {
t.Errorf("expected already-delivered error, got: %s", result.ForLLM)
}
if dm.IsDelivered(subFile) {
t.Fatal("failed batch must not mark new files delivered")
}
}