From 51b7c010a4615c31d1c85c397aa9a5708d00c919 Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Tue, 9 Jun 2026 00:46:53 +0000 Subject: [PATCH 1/2] fix: register missing harness-config import routes (fixes #185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hub import screen returned HTTP 404 when importing harness-configs because two routes were never wired up: 1. The unified `/api/v1/resources/import` endpoint handler existed but was not registered in registerRoutes() — global-scope imports for both templates and harness-configs were unreachable. 2. The per-project `/api/v1/projects/{id}/import-harness-configs` endpoint had no backend route or handler (only import-templates existed). Add the missing route registration, implement the per-project handleProjectImportHarnessConfigs handler, and add tests covering global harness-config import, single-directory (leaf) import, and per-project import. --- pkg/hub/handlers.go | 96 ++++++++++++ pkg/hub/resource_import_handler_test.go | 195 ++++++++++++++++++++++++ pkg/hub/server.go | 3 + 3 files changed, 294 insertions(+) diff --git a/pkg/hub/handlers.go b/pkg/hub/handlers.go index 39ff168a5..afd562bd7 100644 --- a/pkg/hub/handlers.go +++ b/pkg/hub/handlers.go @@ -4770,6 +4770,12 @@ func (s *Server) handleProjectRoutes(w http.ResponseWriter, r *http.Request) { return } + // Check for nested /import-harness-configs path + if subPath == "import-harness-configs" { + s.handleProjectImportHarnessConfigs(w, r, projectID) + return + } + // Check for nested /dav/ path (WebDAV endpoint for project workspace sync) if strings.HasPrefix(subPath, "dav") { davPath := strings.TrimPrefix(subPath, "dav") @@ -9748,6 +9754,96 @@ func (s *Server) handleProjectImportTemplates(w http.ResponseWriter, r *http.Req }) } +// ImportHarnessConfigsRequest is the request body for direct harness-config import. +// Exactly one of SourceURL or WorkspacePath should be provided. +type ImportHarnessConfigsRequest struct { + SourceURL string `json:"sourceUrl"` + WorkspacePath string `json:"workspacePath"` +} + +// ImportHarnessConfigsResponse is returned after a direct harness-config import completes. +type ImportHarnessConfigsResponse struct { + HarnessConfigs []string `json:"harnessConfigs"` + Count int `json:"count"` +} + +// handleProjectImportHarnessConfigs imports harness-configs directly from a +// remote URL or workspace path into the project's harness-config store. +func (s *Server) handleProjectImportHarnessConfigs(w http.ResponseWriter, r *http.Request, projectID string) { + if r.Method != http.MethodPost { + MethodNotAllowed(w) + return + } + + ctx := r.Context() + + if agentIdent := GetAgentIdentityFromContext(ctx); agentIdent != nil { + if !agentIdent.HasScope(ScopeAgentCreate) { + writeError(w, http.StatusForbidden, ErrCodeForbidden, "Missing required scope: project:agent:create", nil) + return + } + if projectID != agentIdent.ProjectID() { + writeError(w, http.StatusForbidden, ErrCodeForbidden, "Agents can only import harness-configs within their own project", nil) + return + } + } else if userIdent := GetUserIdentityFromContext(ctx); userIdent != nil { + decision := s.authzService.CheckAccess(ctx, userIdent, Resource{ + Type: "harness_config", + ParentType: "project", + ParentID: projectID, + }, ActionCreate) + if !decision.Allowed { + writeError(w, http.StatusForbidden, ErrCodeForbidden, + "You don't have permission to import harness-configs in this project", nil) + return + } + } else { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required", nil) + return + } + + var req ImportHarnessConfigsRequest + if err := readJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body", nil) + return + } + + if req.SourceURL == "" && req.WorkspacePath == "" { + req.WorkspacePath = "/.scion/harness-configs" + } + + project, err := s.store.GetProject(ctx, projectID) + if err != nil { + if err == store.ErrNotFound { + NotFound(w, "Project") + return + } + writeErrorFromErr(w, err, "") + return + } + + if s.GetStorage() == nil { + writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "Harness-config storage is not configured", nil) + return + } + + var imported []string + if req.WorkspacePath != "" { + imported, err = s.importHarnessConfigsFromWorkspace(ctx, project, req.WorkspacePath) + } else { + imported, err = s.importHarnessConfigsFromRemote(ctx, projectID, req.SourceURL) + } + if err != nil { + writeError(w, http.StatusBadRequest, "import_failed", err.Error(), nil) + return + } + + writeJSON(w, http.StatusOK, ImportHarnessConfigsResponse{ + HarnessConfigs: imported, + Count: len(imported), + }) +} + // ImportResourcesRequest is the body for the unified import endpoint // (POST /api/v1/resources/import). It imports a single kind of resource from a // remote source URL into the given scope. diff --git a/pkg/hub/resource_import_handler_test.go b/pkg/hub/resource_import_handler_test.go index 98c521d78..31246ed82 100644 --- a/pkg/hub/resource_import_handler_test.go +++ b/pkg/hub/resource_import_handler_test.go @@ -334,6 +334,201 @@ func TestHandleResourcesImport_InvalidKind(t *testing.T) { } } +// mockHarnessConfigTarball installs a mock HTTP transport that serves a gzip +// tarball containing a single harness-config directory, and returns a cleanup +// func. It must not be used with t.Parallel(). +func mockHarnessConfigTarball(t *testing.T) func() { + t.Helper() + old := http.DefaultClient.Transport + http.DefaultClient.Transport = &mockRoundTripper{ + roundTrip: func(req *http.Request) (*http.Response, error) { + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gzw) + files := map[string]string{ + "repo-main/harness-configs/my-config/config.yaml": "harness: claude\n", + "repo-main/harness-configs/my-config/README.md": "hello", + } + for name, body := range files { + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0600, Size: int64(len(body))}); err != nil { + return nil, err + } + if _, err := tw.Write([]byte(body)); err != nil { + return nil, err + } + } + if err := tw.Close(); err != nil { + return nil, err + } + if err := gzw.Close(); err != nil { + return nil, err + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(buf.Bytes()))}, nil + }, + } + return func() { http.DefaultClient.Transport = old } +} + +// mockSingleHarnessConfigTarball serves a tarball where the pointed-to path IS +// the harness-config (leaf), not a parent of configs. +func mockSingleHarnessConfigTarball(t *testing.T) func() { + t.Helper() + old := http.DefaultClient.Transport + http.DefaultClient.Transport = &mockRoundTripper{ + roundTrip: func(req *http.Request) (*http.Response, error) { + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gzw) + files := map[string]string{ + "repo-main/harnesses/antigravity/config.yaml": "harness: claude\n", + "repo-main/harnesses/antigravity/README.md": "hello", + } + for name, body := range files { + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0600, Size: int64(len(body))}); err != nil { + return nil, err + } + if _, err := tw.Write([]byte(body)); err != nil { + return nil, err + } + } + if err := tw.Close(); err != nil { + return nil, err + } + if err := gzw.Close(); err != nil { + return nil, err + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(buf.Bytes()))}, nil + }, + } + return func() { http.DefaultClient.Transport = old } +} + +// TestHandleResourcesImport_HarnessConfigGlobal verifies importing harness-configs +// via the unified endpoint with global scope. +func TestHandleResourcesImport_HarnessConfigGlobal(t *testing.T) { + srv, s, _ := testTemplateBootstrapServer(t) + ctx := context.Background() + + admin := &store.User{ID: tid("user-admin-hc"), Email: "admin-hc@test.com", DisplayName: "Admin", Role: store.UserRoleAdmin} + if err := s.CreateUser(ctx, admin); err != nil { + t.Fatal(err) + } + ensureHubMembership(ctx, s, admin.ID) + + defer mockHarnessConfigTarball(t)() + + rec := doRequestAsUser(t, srv, admin, http.MethodPost, "/api/v1/resources/import", ImportResourcesRequest{ + Kind: "harness-config", + Scope: "global", + SourceURL: "https://github.com/acme/repo/tree/main/harness-configs", + }) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp ImportResourcesResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Count != 1 || len(resp.Imported) != 1 || resp.Imported[0] != "my-config" { + t.Fatalf("expected [my-config], got %+v", resp) + } + + result, err := s.ListHarnessConfigs(ctx, store.HarnessConfigFilter{Scope: store.HarnessConfigScopeGlobal}, store.ListOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if result.TotalCount != 1 { + t.Fatalf("expected 1 global harness-config, got %d", result.TotalCount) + } + if result.Items[0].Scope != store.HarnessConfigScopeGlobal { + t.Errorf("expected global scope, got %q", result.Items[0].Scope) + } +} + +// TestHandleResourcesImport_SingleHarnessConfig verifies importing a single +// harness-config directory (not a directory-of-directories) works correctly. +func TestHandleResourcesImport_SingleHarnessConfig(t *testing.T) { + srv, s, _ := testTemplateBootstrapServer(t) + ctx := context.Background() + + admin := &store.User{ID: tid("user-admin-single-hc"), Email: "admin-single-hc@test.com", DisplayName: "Admin", Role: store.UserRoleAdmin} + if err := s.CreateUser(ctx, admin); err != nil { + t.Fatal(err) + } + ensureHubMembership(ctx, s, admin.ID) + + defer mockSingleHarnessConfigTarball(t)() + + rec := doRequestAsUser(t, srv, admin, http.MethodPost, "/api/v1/resources/import", ImportResourcesRequest{ + Kind: "harness-config", + Scope: "global", + SourceURL: "https://github.com/acme/repo/tree/main/harnesses/antigravity", + }) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp ImportResourcesResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Count != 1 || len(resp.Imported) != 1 || resp.Imported[0] != "antigravity" { + t.Fatalf("expected [antigravity], got %+v", resp) + } + + result, err := s.ListHarnessConfigs(ctx, store.HarnessConfigFilter{Scope: store.HarnessConfigScopeGlobal}, store.ListOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if result.TotalCount != 1 { + t.Fatalf("expected 1 global harness-config, got %d", result.TotalCount) + } +} + +// TestHandleProjectImportHarnessConfigs verifies the per-project endpoint +// POST /api/v1/projects/{id}/import-harness-configs works for remote URLs. +func TestHandleProjectImportHarnessConfigs(t *testing.T) { + srv, s, project, _ := setupWorkspaceProject(t, "hc-proj-import") + ctx := context.Background() + + admin := &store.User{ID: tid("user-admin-proj-hc"), Email: "admin-proj-hc@test.com", DisplayName: "Admin", Role: store.UserRoleAdmin} + if err := s.CreateUser(ctx, admin); err != nil { + t.Fatal(err) + } + ensureHubMembership(ctx, s, admin.ID) + + defer mockHarnessConfigTarball(t)() + + rec := doRequestAsUser(t, srv, admin, http.MethodPost, + "/api/v1/projects/"+project.ID+"/import-harness-configs", + ImportHarnessConfigsRequest{ + SourceURL: "https://github.com/acme/repo/tree/main/harness-configs", + }) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp ImportHarnessConfigsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Count != 1 || len(resp.HarnessConfigs) != 1 || resp.HarnessConfigs[0] != "my-config" { + t.Fatalf("expected [my-config], got %+v", resp) + } + + result, err := s.ListHarnessConfigs(ctx, store.HarnessConfigFilter{ + Scope: store.HarnessConfigScopeProject, + ProjectID: project.ID, + }, store.ListOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if result.TotalCount != 1 { + t.Fatalf("expected 1 project-scoped harness-config, got %d", result.TotalCount) + } +} + // TestHandleResourcesImport_MissingSourceURL verifies sourceUrl is required. func TestHandleResourcesImport_MissingSourceURL(t *testing.T) { srv, s, _ := testTemplateBootstrapServer(t) diff --git a/pkg/hub/server.go b/pkg/hub/server.go index 91fb9802b..a5b82ab97 100644 --- a/pkg/hub/server.go +++ b/pkg/hub/server.go @@ -2532,6 +2532,9 @@ func (s *Server) registerRoutes() { s.mux.HandleFunc("/api/v1/discord/link/verify", s.handleDiscordLinkVerify) s.mux.HandleFunc("/api/v1/discord/link/status", s.handleDiscordLinkStatus) + // Unified resource import endpoint (templates + harness-configs, global + project) + s.mux.HandleFunc("/api/v1/resources/import", s.handleResourcesImport) + // GitHub App webhook and setup callback (unauthenticated — uses webhook signature) s.mux.HandleFunc("/api/v1/webhooks/github", s.handleGitHubWebhook) s.mux.HandleFunc("/github-app/setup", s.handleGitHubAppSetup) From e84d8a642809f17c7119f200891316a6d550f7de Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Tue, 9 Jun 2026 10:31:09 +0000 Subject: [PATCH 2/2] fix: normalize source URL in per-project harness-config import The handleProjectImportHarnessConfigs handler was passing raw user input to importHarnessConfigsFromRemote without URL normalization, unlike the analogous template handler and the unified import endpoint which both call config.NormalizeTemplateSourceURL. This meant bare hostnames (e.g. "github.com/org/repo") would fail with a non-remote URI error. --- pkg/hub/handlers.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/hub/handlers.go b/pkg/hub/handlers.go index afd562bd7..9bb0f4b80 100644 --- a/pkg/hub/handlers.go +++ b/pkg/hub/handlers.go @@ -9831,6 +9831,7 @@ func (s *Server) handleProjectImportHarnessConfigs(w http.ResponseWriter, r *htt if req.WorkspacePath != "" { imported, err = s.importHarnessConfigsFromWorkspace(ctx, project, req.WorkspacePath) } else { + req.SourceURL = config.NormalizeTemplateSourceURL(req.SourceURL) imported, err = s.importHarnessConfigsFromRemote(ctx, projectID, req.SourceURL) } if err != nil {