diff --git a/coordinator/cmd/coordinator/main.go b/coordinator/cmd/coordinator/main.go index 8447414..7e1c361 100644 --- a/coordinator/cmd/coordinator/main.go +++ b/coordinator/cmd/coordinator/main.go @@ -82,6 +82,7 @@ func run() error { DownloadArtifact: usecase.NewDownloadArtifact(artifactRepo, blobStore), GetTaskInput: usecase.NewGetTaskInput(taskRepo, artifactRepo, blobStore), Dashboard: usecase.NewDashboard(uiReadRepo), + PreviewArtifact: usecase.NewPreviewArtifact(uiReadRepo, blobStore), } // Background reapers are tracked so shutdown can wait for them. Without this diff --git a/coordinator/internal/transport/http/server.go b/coordinator/internal/transport/http/server.go index 00e80d8..293e706 100644 --- a/coordinator/internal/transport/http/server.go +++ b/coordinator/internal/transport/http/server.go @@ -29,6 +29,7 @@ type UseCases struct { DownloadArtifact *usecase.DownloadArtifact GetTaskInput *usecase.GetTaskInput Dashboard *usecase.Dashboard + PreviewArtifact *usecase.PreviewArtifact } type Server struct { @@ -82,6 +83,7 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler { ui.HandleFunc("POST /ui/api/jobs/{job_id}/cancel", s.handleCancelJob) ui.HandleFunc("POST /ui/api/jobs/upload", s.handleUploadDataset) ui.HandleFunc("GET /ui/jobs/{job_id}/artifacts/{artifact_id}", s.handleUIArtifactDownload) + ui.HandleFunc("GET /ui/jobs/{job_id}/artifacts/{artifact_id}/preview", s.handleUIArtifactPreview) mux.Handle("/ui", chain(ui, withRequestID, withAccessLog(s.log), withBasicAuth(uiToken[0]), withSameOrigin)) mux.Handle("/ui/", chain(ui, withRequestID, withAccessLog(s.log), withBasicAuth(uiToken[0]), withSameOrigin)) } else { diff --git a/coordinator/internal/transport/http/server_test.go b/coordinator/internal/transport/http/server_test.go index 52c453f..781a33e 100644 --- a/coordinator/internal/transport/http/server_test.go +++ b/coordinator/internal/transport/http/server_test.go @@ -42,6 +42,7 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur clk := memstore.NewClock(time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)) tx := memstore.Tx{} lease := 2 * time.Minute + uiRead := memstore.NewUIReadRepo(jobs, tasks, work, arts) uc := coordhttp.UseCases{ RegisterWorker: usecase.NewRegisterWorker(work, clk), @@ -56,7 +57,8 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur UploadArtifact: usecase.NewUploadArtifact(tasks, arts, blobs, tx, clk), DownloadArtifact: usecase.NewDownloadArtifact(arts, blobs), GetTaskInput: usecase.NewGetTaskInput(tasks, arts, blobs), - Dashboard: usecase.NewDashboard(memstore.NewUIReadRepo(jobs, tasks, work, arts)), + Dashboard: usecase.NewDashboard(uiRead), + PreviewArtifact: usecase.NewPreviewArtifact(uiRead, blobs), } worker, err := uc.RegisterWorker.Execute(context.Background(), usecase.RegisterWorkerInput{ Name: "test-worker", Capabilities: []string{"w", "similarity-search"}, @@ -287,6 +289,134 @@ func TestUIArtifactDownloadRejectsAnotherJobsArtifact(t *testing.T) { } } +func TestUIArtifactPreviewRequiresAuth(t *testing.T) { + e := newEnv(t, healthy) + code, job := e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in","chunks":[{"chunk_index":0,"input_uri":"s3://c","input_sha256":"sha"}]}`) + if code != http.StatusCreated { + t.Fatalf("create: %d", code) + } + _, claim := e.do(t, "POST", "/tasks/claim", `{"worker_id":"w1","capabilities":["w"]}`) + artifactID := e.putArtifact(t, claim["task_id"].(string), "w1", int(claim["attempt"].(float64)), "a,b\n1,2\n") + + req, _ := http.NewRequestWithContext(context.Background(), "GET", + e.ts.URL+"/ui/jobs/"+job["id"].(string)+"/artifacts/"+artifactID+"/preview", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("unauthenticated preview = %d, want 401", resp.StatusCode) + } +} + +func TestUIArtifactPreviewRendersEscapedCSVRows(t *testing.T) { + e := newEnv(t, healthy) + code, job := e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in","chunks":[{"chunk_index":0,"input_uri":"s3://c","input_sha256":"sha"}]}`) + if code != http.StatusCreated { + t.Fatalf("create: %d", code) + } + _, claim := e.do(t, "POST", "/tasks/claim", `{"worker_id":"w1","capabilities":["w"]}`) + csv := "chembl_id,note\nCHEMBL1,\n" + artifactID := e.putArtifact(t, claim["task_id"].(string), "w1", int(claim["attempt"].(float64)), csv) + + req, _ := http.NewRequestWithContext(context.Background(), "GET", + e.ts.URL+"/ui/jobs/"+job["id"].(string)+"/artifacts/"+artifactID+"/preview", nil) + req.SetBasicAuth("operator", uiToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("preview: %d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if strings.Contains(string(body), "") { + t.Error("preview must escape HTML-like CSV values, found raw