diff options
Diffstat (limited to 'internal/api')
| -rw-r--r-- | internal/api/scripts.go | 64 | ||||
| -rw-r--r-- | internal/api/scripts_test.go | 83 |
2 files changed, 92 insertions, 55 deletions
diff --git a/internal/api/scripts.go b/internal/api/scripts.go index 9afbb75..822bd32 100644 --- a/internal/api/scripts.go +++ b/internal/api/scripts.go @@ -5,62 +5,33 @@ import ( "context" "net/http" "os/exec" - "path/filepath" "time" ) const scriptTimeout = 30 * time.Second -func (s *Server) startNextTaskScriptPath() string { - if s.startNextTaskScript != "" { - return s.startNextTaskScript - } - return filepath.Join(s.workDir, "scripts", "start-next-task") -} +// ScriptRegistry maps endpoint names to executable script paths. +// Only registered scripts are exposed via POST /api/scripts/{name}. +type ScriptRegistry map[string]string -func (s *Server) deployScriptPath() string { - if s.deployScript != "" { - return s.deployScript - } - return filepath.Join(s.workDir, "scripts", "deploy") +// SetScripts configures the script registry. The mux is not re-registered; +// the handler looks up the registry at request time, so this may be called +// after NewServer but before the first request. +func (s *Server) SetScripts(r ScriptRegistry) { + s.scripts = r } -func (s *Server) handleStartNextTask(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), scriptTimeout) - defer cancel() - - scriptPath := s.startNextTaskScriptPath() - cmd := exec.CommandContext(ctx, scriptPath) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - exitCode := 0 - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - exitCode = exitErr.ExitCode() - } else { - s.logger.Error("start-next-task: script execution failed", "error", err, "path", scriptPath) - writeJSON(w, http.StatusInternalServerError, map[string]string{ - "error": "script execution failed: " + err.Error(), - }) - return - } +func (s *Server) handleScript(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + scriptPath, ok := s.scripts[name] + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "script not found: " + name}) + return } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "output": stdout.String(), - "exit_code": exitCode, - }) -} - -func (s *Server) handleDeploy(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), scriptTimeout) defer cancel() - scriptPath := s.deployScriptPath() cmd := exec.CommandContext(ctx, scriptPath) var stdout, stderr bytes.Buffer @@ -72,17 +43,18 @@ func (s *Server) handleDeploy(w http.ResponseWriter, r *http.Request) { if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { exitCode = exitErr.ExitCode() + s.logger.Warn("script exited non-zero", "name", name, "exit_code", exitCode, "stderr", stderr.String()) } else { - s.logger.Error("deploy: script execution failed", "error", err, "path", scriptPath) + s.logger.Error("script execution failed", "name", name, "error", err, "path", scriptPath) writeJSON(w, http.StatusInternalServerError, map[string]string{ - "error": "script execution failed: " + err.Error(), + "error": "script execution failed", }) return } } writeJSON(w, http.StatusOK, map[string]interface{}{ - "output": stdout.String() + stderr.String(), + "output": stdout.String(), "exit_code": exitCode, }) } diff --git a/internal/api/scripts_test.go b/internal/api/scripts_test.go index 7da133e..f5ece20 100644 --- a/internal/api/scripts_test.go +++ b/internal/api/scripts_test.go @@ -6,20 +6,65 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" ) +func TestServer_NoScripts_Returns404(t *testing.T) { + srv, _ := testServer(t) + // No scripts configured — all /api/scripts/* should return 404. + for _, name := range []string{"deploy", "start-next-task", "unknown"} { + req := httptest.NewRequest("POST", "/api/scripts/"+name, nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("POST /api/scripts/%s: want 404, got %d", name, w.Code) + } + } +} + +func TestServer_WithScripts_RunsRegisteredScript(t *testing.T) { + srv, _ := testServer(t) + + scriptDir := t.TempDir() + scriptPath := filepath.Join(scriptDir, "my-script") + if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\necho hello"), 0o755); err != nil { + t.Fatal(err) + } + + srv.SetScripts(ScriptRegistry{"my-script": scriptPath}) + + req := httptest.NewRequest("POST", "/api/scripts/my-script", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String()) + } +} + +func TestServer_WithScripts_UnregisteredReturns404(t *testing.T) { + srv, _ := testServer(t) + srv.SetScripts(ScriptRegistry{"deploy": "/some/path"}) + + req := httptest.NewRequest("POST", "/api/scripts/other", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("want 404, got %d", w.Code) + } +} + func TestHandleDeploy_Success(t *testing.T) { srv, _ := testServer(t) - // Create a fake deploy script that exits 0 and prints output. scriptDir := t.TempDir() scriptPath := filepath.Join(scriptDir, "deploy") - script := "#!/bin/sh\necho 'deployed successfully'" - if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\necho 'deployed successfully'"), 0o755); err != nil { t.Fatal(err) } - srv.deployScript = scriptPath + srv.SetScripts(ScriptRegistry{"deploy": scriptPath}) req := httptest.NewRequest("POST", "/api/scripts/deploy", nil) w := httptest.NewRecorder() @@ -35,8 +80,7 @@ func TestHandleDeploy_Success(t *testing.T) { if body["exit_code"] != float64(0) { t.Errorf("exit_code: want 0, got %v", body["exit_code"]) } - output, _ := body["output"].(string) - if output == "" { + if output, _ := body["output"].(string); output == "" { t.Errorf("expected non-empty output") } } @@ -46,11 +90,10 @@ func TestHandleDeploy_ScriptFails(t *testing.T) { scriptDir := t.TempDir() scriptPath := filepath.Join(scriptDir, "deploy") - script := "#!/bin/sh\necho 'build failed' && exit 1" - if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\necho 'build failed' && exit 1"), 0o755); err != nil { t.Fatal(err) } - srv.deployScript = scriptPath + srv.SetScripts(ScriptRegistry{"deploy": scriptPath}) req := httptest.NewRequest("POST", "/api/scripts/deploy", nil) w := httptest.NewRecorder() @@ -67,3 +110,25 @@ func TestHandleDeploy_ScriptFails(t *testing.T) { t.Errorf("expected non-zero exit_code") } } + +func TestHandleScript_StderrNotLeakedToResponse(t *testing.T) { + srv, _ := testServer(t) + + scriptDir := t.TempDir() + scriptPath := filepath.Join(scriptDir, "deploy") + // Script writes sensitive info to stderr and exits non-zero. + script := "#!/bin/sh\necho 'stdout output'\necho 'SECRET_TOKEN=abc123' >&2\nexit 1" + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + srv.SetScripts(ScriptRegistry{"deploy": scriptPath}) + + req := httptest.NewRequest("POST", "/api/scripts/deploy", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + body := w.Body.String() + if strings.Contains(body, "SECRET_TOKEN") { + t.Errorf("response must not contain stderr content; got: %s", body) + } +} |
