package workflow_test import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "workflow" ) // ──────────────────────────────────────────────────────────────────────────── // LocalFileAdapter tests // ──────────────────────────────────────────────────────────────────────────── func TestLocalFileAdapter_WriteRead(t *testing.T) { root := t.TempDir() a, err := workflow.NewLocalFileAdapter(root, "proj1") if err != nil { t.Fatalf("NewLocalFileAdapter: %v", err) } ctx := context.Background() const path = "/Output/hello.txt" const content = "hello world" if err := a.Write(ctx, path, []byte(content), workflow.WriteModeOverwrite); err != nil { t.Fatalf("Write: %v", err) } got, err := a.Read(ctx, path) if err != nil { t.Fatalf("Read: %v", err) } if string(got) != content { t.Errorf("Read = %q, want %q", got, content) } } func TestLocalFileAdapter_Exists(t *testing.T) { root := t.TempDir() a, err := workflow.NewLocalFileAdapter(root, "proj1") if err != nil { t.Fatalf("NewLocalFileAdapter: %v", err) } ctx := context.Background() exists, err := a.Exists(ctx, "/Output/nope.txt") if err != nil { t.Fatalf("Exists: %v", err) } if exists { t.Error("expected file to not exist") } _ = a.Write(ctx, "/Output/nope.txt", []byte("x"), workflow.WriteModeOverwrite) exists, err = a.Exists(ctx, "/Output/nope.txt") if err != nil { t.Fatalf("Exists after write: %v", err) } if !exists { t.Error("expected file to exist after write") } } func TestLocalFileAdapter_WriteModeFailIfExists(t *testing.T) { root := t.TempDir() a, _ := workflow.NewLocalFileAdapter(root, "proj1") ctx := context.Background() if err := a.Write(ctx, "/f.txt", []byte("v1"), workflow.WriteModeOverwrite); err != nil { t.Fatalf("first write: %v", err) } err := a.Write(ctx, "/f.txt", []byte("v2"), workflow.WriteModeFailIfExists) if err == nil { t.Fatal("expected FileExistsError, got nil") } var fee *workflow.FileExistsError if !errors.As(err, &fee) { t.Fatalf("expected FileExistsError, got %T: %v", err, err) } // Content should be unchanged got, _ := a.Read(ctx, "/f.txt") if string(got) != "v1" { t.Errorf("content should be v1, got %q", got) } } func TestLocalFileAdapter_WriteModeAppend(t *testing.T) { root := t.TempDir() a, _ := workflow.NewLocalFileAdapter(root, "proj1") ctx := context.Background() _ = a.Write(ctx, "/log.txt", []byte("line1\n"), workflow.WriteModeOverwrite) _ = a.Write(ctx, "/log.txt", []byte("line2\n"), workflow.WriteModeAppend) got, _ := a.Read(ctx, "/log.txt") if string(got) != "line1\nline2\n" { t.Errorf("append result = %q, want %q", got, "line1\nline2\n") } } func TestLocalFileAdapter_WriteModePrepend(t *testing.T) { root := t.TempDir() a, _ := workflow.NewLocalFileAdapter(root, "proj1") ctx := context.Background() _ = a.Write(ctx, "/doc.txt", []byte("world"), workflow.WriteModeOverwrite) _ = a.Write(ctx, "/doc.txt", []byte("hello "), workflow.WriteModePrepend) got, _ := a.Read(ctx, "/doc.txt") if string(got) != "hello world" { t.Errorf("prepend result = %q, want %q", got, "hello world") } } func TestLocalFileAdapter_List(t *testing.T) { root := t.TempDir() a, _ := workflow.NewLocalFileAdapter(root, "proj1") ctx := context.Background() files := []string{"/Output/a.txt", "/Output/b.txt", "/Other/c.md"} for _, f := range files { _ = a.Write(ctx, f, []byte("x"), workflow.WriteModeOverwrite) } got, err := a.List(ctx, "Output/*") if err != nil { t.Fatalf("List: %v", err) } if len(got) != 2 { t.Errorf("List(Output/*) = %v, want 2 items", got) } for _, p := range got { if !strings.HasPrefix(p, "/Output/") { t.Errorf("path %q should start with /Output/", p) } } } func TestLocalFileAdapter_GIDIsolation(t *testing.T) { root := t.TempDir() a1, _ := workflow.NewLocalFileAdapter(root, "proj-A") a2, _ := workflow.NewLocalFileAdapter(root, "proj-B") ctx := context.Background() _ = a1.Write(ctx, "/file.txt", []byte("from A"), workflow.WriteModeOverwrite) exists, _ := a2.Exists(ctx, "/file.txt") if exists { t.Error("proj-B should not see proj-A's file") } _, err := a2.Read(ctx, "/file.txt") if err == nil { t.Error("proj-B reading proj-A file should fail") } } func TestLocalFileAdapter_PathTraversalBlocked(t *testing.T) { root := t.TempDir() a, _ := workflow.NewLocalFileAdapter(root, "proj1") ctx := context.Background() err := a.Write(ctx, "../../etc/passwd", []byte("evil"), workflow.WriteModeOverwrite) if err == nil { t.Error("path traversal should be rejected") } } func TestLocalFileAdapter_ReadNotFound(t *testing.T) { root := t.TempDir() a, _ := workflow.NewLocalFileAdapter(root, "proj1") ctx := context.Background() _, err := a.Read(ctx, "/nonexistent.txt") if err == nil { t.Fatal("expected FileNotFoundError") } var fnf *workflow.FileNotFoundError if !errors.As(err, &fnf) { t.Fatalf("expected FileNotFoundError, got %T: %v", err, err) } } func TestLocalFileAdapter_WorkspaceDir(t *testing.T) { root := t.TempDir() a, _ := workflow.NewLocalFileAdapter(root, "my-gid") // WorkspaceDir should be root/gid absRoot, _ := filepath.Abs(root) expected := filepath.Join(absRoot, "my-gid") if a.WorkspaceDir() != expected { t.Errorf("WorkspaceDir = %q, want %q", a.WorkspaceDir(), expected) } } func TestLocalFileAdapter_AutoCreatesParentDirs(t *testing.T) { root := t.TempDir() a, _ := workflow.NewLocalFileAdapter(root, "proj1") ctx := context.Background() // Deep nested path — parent dirs should be created err := a.Write(ctx, "/deep/nested/path/file.txt", []byte("data"), workflow.WriteModeOverwrite) if err != nil { t.Fatalf("Write nested path: %v", err) } absRoot, _ := filepath.Abs(root) expected := filepath.Join(absRoot, "proj1", "deep", "nested", "path", "file.txt") if _, err := os.Stat(expected); err != nil { t.Errorf("expected file at %s: %v", expected, err) } } // ──────────────────────────────────────────────────────────────────────────── // WorkspaceFileAdapter tests (mock HTTP server) // ──────────────────────────────────────────────────────────────────────────── // mockWorkspaceServer builds an httptest.Server that handles the four // workspace API endpoints used by WorkspaceFileAdapter. type mockWorkspaceServer struct { // files maps path → content (version=1 if present) files map[string]string // cookie expected in requests (without "ih5bearer=" prefix) cookie string } func (m *mockWorkspaceServer) handler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // Check cookie if m.cookie != "" { expected := "ih5bearer=" + m.cookie if r.Header.Get("Cookie") != expected { http.Error(w, "unauthorized", http.StatusUnauthorized) return } } // Parse body var body map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } path, _ := body["path"].(string) // Route endpoint := filepath.Base(r.URL.Path) switch endpoint { case "createFile": if _, exists := m.files[path]; !exists { m.files[path] = "" // mark as created (version=0) } w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"status":0}`) case "writeFile": content, _ := body["content"].(string) m.files[path] = content w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"gid":"test","path":%q,"version":1}`, path) case "readFile": content, exists := m.files[path] if !exists || content == "" { http.Error(w, "not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"version":1,"status":0,"content":%s}`, jsonStr(content)) case "listFile": type fileEntry struct { Path string `json:"path"` Version int `json:"version"` } var entries []fileEntry for p, c := range m.files { v := 0 if c != "" { v = 1 } entries = append(entries, fileEntry{Path: p, Version: v}) } out, _ := json.Marshal(map[string]interface{}{"files": entries}) w.Header().Set("Content-Type", "application/json") w.Write(out) default: http.Error(w, "unknown endpoint: "+endpoint, http.StatusNotFound) } } func jsonStr(s string) string { b, _ := json.Marshal(s) return string(b) } func newMockWorkspaceServer(t *testing.T, token string) (*httptest.Server, *mockWorkspaceServer) { t.Helper() mock := &mockWorkspaceServer{ files: make(map[string]string), cookie: token, } srv := httptest.NewServer(http.HandlerFunc(mock.handler)) t.Cleanup(srv.Close) return srv, mock } func newWorkspaceAdapter(t *testing.T, srv *httptest.Server, token, gid string) *workflow.WorkspaceFileAdapter { t.Helper() a, err := workflow.NewWorkspaceFileAdapter(workflow.WorkspaceFileConfig{ BaseURL: srv.URL, GID: gid, Cookie: "ih5bearer=" + token, }) if err != nil { t.Fatalf("NewWorkspaceFileAdapter: %v", err) } return a } func TestWorkspaceFileAdapter_WriteRead(t *testing.T) { srv, _ := newMockWorkspaceServer(t, "tok123") a := newWorkspaceAdapter(t, srv, "tok123", "proj1") ctx := context.Background() if err := a.Write(ctx, "/Output/result.txt", []byte("hello"), workflow.WriteModeOverwrite); err != nil { t.Fatalf("Write: %v", err) } got, err := a.Read(ctx, "/Output/result.txt") if err != nil { t.Fatalf("Read: %v", err) } if string(got) != "hello" { t.Errorf("Read = %q, want %q", got, "hello") } } func TestWorkspaceFileAdapter_Exists(t *testing.T) { srv, _ := newMockWorkspaceServer(t, "tok123") a := newWorkspaceAdapter(t, srv, "tok123", "proj1") ctx := context.Background() exists, err := a.Exists(ctx, "/Output/nope.txt") if err != nil { t.Fatalf("Exists: %v", err) } if exists { t.Error("expected file to not exist") } _ = a.Write(ctx, "/Output/nope.txt", []byte("x"), workflow.WriteModeOverwrite) exists, err = a.Exists(ctx, "/Output/nope.txt") if err != nil { t.Fatalf("Exists after write: %v", err) } if !exists { t.Error("expected file to exist after write") } } func TestWorkspaceFileAdapter_WriteModeAppend(t *testing.T) { srv, _ := newMockWorkspaceServer(t, "tok123") a := newWorkspaceAdapter(t, srv, "tok123", "proj1") ctx := context.Background() _ = a.Write(ctx, "/log.txt", []byte("line1\n"), workflow.WriteModeOverwrite) _ = a.Write(ctx, "/log.txt", []byte("line2\n"), workflow.WriteModeAppend) got, _ := a.Read(ctx, "/log.txt") if string(got) != "line1\nline2\n" { t.Errorf("append result = %q, want %q", got, "line1\nline2\n") } } func TestWorkspaceFileAdapter_WriteModePrepend(t *testing.T) { srv, _ := newMockWorkspaceServer(t, "tok123") a := newWorkspaceAdapter(t, srv, "tok123", "proj1") ctx := context.Background() _ = a.Write(ctx, "/doc.txt", []byte("world"), workflow.WriteModeOverwrite) _ = a.Write(ctx, "/doc.txt", []byte("hello "), workflow.WriteModePrepend) got, _ := a.Read(ctx, "/doc.txt") if string(got) != "hello world" { t.Errorf("prepend result = %q, want %q", got, "hello world") } } func TestWorkspaceFileAdapter_WriteModeFailIfExists(t *testing.T) { srv, _ := newMockWorkspaceServer(t, "tok123") a := newWorkspaceAdapter(t, srv, "tok123", "proj1") ctx := context.Background() _ = a.Write(ctx, "/f.txt", []byte("v1"), workflow.WriteModeOverwrite) err := a.Write(ctx, "/f.txt", []byte("v2"), workflow.WriteModeFailIfExists) if err == nil { t.Fatal("expected FileExistsError, got nil") } var fee *workflow.FileExistsError if !errors.As(err, &fee) { t.Fatalf("expected FileExistsError, got %T: %v", err, err) } } func TestWorkspaceFileAdapter_List(t *testing.T) { srv, _ := newMockWorkspaceServer(t, "tok123") a := newWorkspaceAdapter(t, srv, "tok123", "proj1") ctx := context.Background() _ = a.Write(ctx, "/Output/a.txt", []byte("x"), workflow.WriteModeOverwrite) _ = a.Write(ctx, "/Output/b.txt", []byte("x"), workflow.WriteModeOverwrite) _ = a.Write(ctx, "/Other/c.md", []byte("x"), workflow.WriteModeOverwrite) got, err := a.List(ctx, "Output/*") if err != nil { t.Fatalf("List: %v", err) } if len(got) != 2 { t.Errorf("List(Output/*) = %v, want 2 items", got) } for _, p := range got { if !strings.HasPrefix(p, "/Output/") { t.Errorf("path %q should start with /Output/", p) } } } func TestWorkspaceFileAdapter_GID(t *testing.T) { srv, _ := newMockWorkspaceServer(t, "tok123") a := newWorkspaceAdapter(t, srv, "tok123", "my-gid") if a.GID() != "my-gid" { t.Errorf("GID = %q, want %q", a.GID(), "my-gid") } }