Files
Plexum-fabric-channel-plugin/internal/attachments/attachments_test.go
hzhang 6def33161b feat: Phase F-6 — attachments + final docs (F-8 = no-op)
F-6 attachments:
- internal/attachments/ (~145 LOC + 8 tests): per-message Downloader
  that fetches Fabric attachment URLs into $TMPDIR/plexum-fabric/<msg-id>/
  with the agent's guild token, sanitizing filenames + message ids
  against path-traversal
- AppendFooter renders downloaded paths as a markdown footer
  ("Attachments:\n  - /tmp/... (mime, N bytes)\n")
- Agents access via the exec MCP tool (cat / file / etc.)

internal/inbound.Supervisor:
- new Attachments *attachments.Downloader field (nil → skip with warn)
- inbound.dispatch: when message has attachments, blocking-download +
  AppendFooter before emitting notification (so agent's first turn
  sees the paths)

cmd/plexum-fabric-channel-plugin:
- sup.Attachments = attachments.New("") wired at init

F-8 coalesce: no-op. openclaw plugin's coalesce buffered the
text→thinking→tool→text segments openclaw emits across multiple
deliver() calls per turn. Plexum's loop.Run returns ONE final assistant
text per turn (via extractFinalText), so coalescing isn't a concern.
The channel outbound posts a single message naturally.

F-5b presence-sync: deferred. The openclaw plugin pushes HarborForge
on-call status to Fabric's per-recipient presence so the backend can
busy-discard 'announce' deliveries. Plexum's state machine has
different semantics (idle/working/busy/offline) and is per-Plexum-agent
not per-user; mapping requires more design.

README updated with the phase status table + 16-tool list.

Tests: 8 new in internal/attachments (35 total in this repo).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:42:23 +01:00

135 lines
3.8 KiB
Go

package attachments
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestSanitizeFilename(t *testing.T) {
cases := map[string]string{
"proposal.md": "proposal.md",
"../etc/passwd": "passwd",
"": "attachment.bin",
"...": "attachment.bin",
"a?b=c": "a",
"weird name.png": "weird name.png",
"name%20encoded.txt": "name encoded.txt",
}
for in, want := range cases {
got := sanitizeFilename(in)
if got != want {
t.Errorf("sanitizeFilename(%q) = %q, want %q", in, got, want)
}
}
}
func TestSanitizeID(t *testing.T) {
cases := map[string]string{
"m-abc-123": "m-abc-123",
"../../etc/passwd": "______etc_passwd",
"": "msg",
"with space": "with_space",
}
for in, want := range cases {
got := sanitizeID(in)
if got != want {
t.Errorf("sanitizeID(%q) = %q, want %q", in, got, want)
}
}
}
func TestAppendFooterEmpty(t *testing.T) {
got := AppendFooter("hello", nil)
if got != "hello" {
t.Errorf("empty list should preserve body, got %q", got)
}
}
func TestAppendFooterFormats(t *testing.T) {
files := []DownloadResult{
{LocalPath: "/tmp/a.md", Name: "a.md", MimeType: "text/markdown", Size: 42},
{LocalPath: "/tmp/b.png", Name: "b.png", MimeType: "", Size: 1024},
}
out := AppendFooter("hello\nworld", files)
if !strings.Contains(out, "hello\nworld\n\nAttachments:\n") {
t.Errorf("footer placement wrong: %q", out)
}
if !strings.Contains(out, "- /tmp/a.md (text/markdown, 42 bytes)") {
t.Errorf("first entry: %q", out)
}
if !strings.Contains(out, "- /tmp/b.png (application/octet-stream, 1024 bytes)") {
t.Errorf("default mime: %q", out)
}
}
func TestFetchAllHappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte("file contents " + r.URL.Path))
}))
defer srv.Close()
d := New(filepath.Join(t.TempDir(), "att"))
files := d.FetchAll(context.Background(), "tok", "msg-1", []AttachmentRef{
{URL: srv.URL + "/foo.md", Name: "foo.md", MimeType: "text/markdown"},
{URL: srv.URL + "/bar.png", Name: "bar.png"},
})
if len(files) != 2 {
t.Fatalf("len = %d", len(files))
}
if !strings.HasSuffix(files[0].LocalPath, "/msg-1/foo.md") {
t.Errorf("path = %s", files[0].LocalPath)
}
raw, _ := os.ReadFile(files[0].LocalPath)
if !strings.HasPrefix(string(raw), "file contents") {
t.Errorf("file content = %q", raw)
}
}
func TestFetchAllSkipsErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "ok") {
w.Write([]byte("ok"))
} else {
w.WriteHeader(404)
}
}))
defer srv.Close()
d := New(filepath.Join(t.TempDir(), "att"))
files := d.FetchAll(context.Background(), "", "m", []AttachmentRef{
{URL: srv.URL + "/ok.txt", Name: "ok.txt"},
{URL: srv.URL + "/missing.txt", Name: "missing.txt"},
})
if len(files) != 1 {
t.Errorf("expected 1 result (404 skipped), got %d", len(files))
}
}
func TestFetchAllEmptyListNoMkdir(t *testing.T) {
d := New(filepath.Join(t.TempDir(), "att"))
got := d.FetchAll(context.Background(), "", "m", nil)
if got != nil {
t.Errorf("empty → nil, got %v", got)
}
}
func TestFetchAllSendsAuthHeader(t *testing.T) {
gotAuth := ""
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("authorization")
w.Write([]byte("x"))
}))
defer srv.Close()
d := New(filepath.Join(t.TempDir(), "att"))
d.FetchAll(context.Background(), "the-token", "m", []AttachmentRef{
{URL: srv.URL + "/a", Name: "a"},
})
if gotAuth != "Bearer the-token" {
t.Errorf("auth header = %q", gotAuth)
}
}