Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion plugin/external/remote_step.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package external
import (
"context"
"fmt"
"io"
"net/http"

"github.com/GoCodeAlone/workflow/module"
pb "github.com/GoCodeAlone/workflow/plugin/external/proto"
Expand Down Expand Up @@ -70,8 +72,50 @@ func (s *RemoteStep) Execute(ctx context.Context, pc *module.PipelineContext) (*
return nil, fmt.Errorf("remote step execute: %s", resp.Error)
}

output := structToMap(resp.Output)

// When the plugin signals a pipeline stop with an HTTP response encoded in
// the output (response_status, response_body, response_headers), write that
// response to _http_response_writer so the caller sees the correct status
// code rather than a default 200/202. This mirrors the pattern used by
// step.auth_validate for 401 responses.
if resp.StopPipeline {
if w, ok := pc.Metadata["_http_response_writer"].(http.ResponseWriter); ok {
if statusRaw, hasStatus := output["response_status"]; hasStatus {
var statusCode int
switch v := statusRaw.(type) {
Comment on lines +75 to +86
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

output := structToMap(resp.Output) can be nil when the plugin returns a response with output unset. In the StopPipeline branch the code indexes output["response_status"], which will panic on a nil map. Initialize output to an empty map (or guard output != nil) before reading response_status/response_headers/response_body.

Copilot uses AI. Check for mistakes.
case float64:
statusCode = int(v)
case int:
statusCode = v
}
if statusCode > 0 {
if headersRaw, ok := output["response_headers"]; ok {
if headers, ok := headersRaw.(map[string]any); ok {
for k, v := range headers {
if vs, ok := v.(string); ok {
w.Header().Set(k, vs)
}
}
}
}
if w.Header().Get("Content-Type") == "" {
w.Header().Set("Content-Type", "application/json")
}
w.WriteHeader(statusCode)
if bodyRaw, ok := output["response_body"]; ok {
if bodyStr, ok := bodyRaw.(string); ok {
_, _ = io.WriteString(w, bodyStr)
}
}
pc.Metadata["_response_handled"] = true
}
}
}
}

return &module.StepResult{
Output: structToMap(resp.Output),
Output: output,
Stop: resp.StopPipeline,
}, nil
}
Expand Down
117 changes: 117 additions & 0 deletions plugin/external/remote_step_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package external

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/GoCodeAlone/workflow/module"
Expand Down Expand Up @@ -158,3 +160,118 @@ func TestRemoteStep_Execute_StaticConfigPassthrough(t *testing.T) {
t.Errorf("expected timeout=30, got %v", sent["timeout"])
}
}

// TestRemoteStep_Execute_WritesHTTPResponseOnStopPipeline verifies that when a
// remote plugin returns StopPipeline=true with response_status, response_body,
// and response_headers in the output, the RemoteStep writes those to the
// _http_response_writer and sets _response_handled=true in pipeline metadata.
func TestRemoteStep_Execute_WritesHTTPResponseOnStopPipeline(t *testing.T) {
output, _ := structpb.NewStruct(map[string]any{
"response_status": float64(http.StatusForbidden),
"response_body": `{"error":"forbidden: insufficient permissions"}`,
"response_headers": map[string]any{
"Content-Type": "application/json",
},
})
stub := &stubPluginServiceClient{
response: &pb.ExecuteStepResponse{
Output: output,
StopPipeline: true,
},
}
step := NewRemoteStep("authz-step", "handle-authz", stub, nil)

recorder := httptest.NewRecorder()
pc := module.NewPipelineContext(nil, nil)
pc.Metadata["_http_response_writer"] = http.ResponseWriter(recorder)

result, err := step.Execute(context.Background(), pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if !result.Stop {
t.Error("expected Stop=true")
}

if recorder.Code != http.StatusForbidden {
t.Errorf("expected HTTP 403, got %d", recorder.Code)
}

body := recorder.Body.String()
if body != `{"error":"forbidden: insufficient permissions"}` {
t.Errorf("unexpected body: %q", body)
}

if pc.Metadata["_response_handled"] != true {
t.Error("expected _response_handled=true in metadata")
}
}
Comment on lines +164 to +209
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a regression test for the nil-output case: when the plugin returns StopPipeline=true but ExecuteStepResponse.Output is nil, RemoteStep.Execute should not panic and should not attempt to index into the output map. This test will protect the response-writing path once the nil-map panic is fixed.

Copilot generated this review using guidance from organization custom instructions.

// TestRemoteStep_Execute_NoHTTPWriterOnStopPipeline verifies that when there is
// no _http_response_writer in the pipeline metadata, the step still returns the
// correct StepResult without panicking.
func TestRemoteStep_Execute_NoHTTPWriterOnStopPipeline(t *testing.T) {
output, _ := structpb.NewStruct(map[string]any{
"response_status": float64(http.StatusForbidden),
"response_body": `{"error":"forbidden"}`,
})
stub := &stubPluginServiceClient{
response: &pb.ExecuteStepResponse{
Output: output,
StopPipeline: true,
},
}
step := NewRemoteStep("authz-step", "handle-authz2", stub, nil)

pc := module.NewPipelineContext(nil, nil)

result, err := step.Execute(context.Background(), pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Stop {
t.Error("expected Stop=true")
}
if pc.Metadata["_response_handled"] == true {
t.Error("expected _response_handled to remain unset when no writer present")
}
}

// TestRemoteStep_Execute_StopPipelineWithoutResponseStatus verifies that a
// StopPipeline=true result without response_status does NOT write to the
// response writer and does NOT set _response_handled.
func TestRemoteStep_Execute_StopPipelineWithoutResponseStatus(t *testing.T) {
output, _ := structpb.NewStruct(map[string]any{
"some_output": "value",
})
stub := &stubPluginServiceClient{
response: &pb.ExecuteStepResponse{
Output: output,
StopPipeline: true,
},
}
step := NewRemoteStep("authz-step", "handle-authz3", stub, nil)

recorder := httptest.NewRecorder()
pc := module.NewPipelineContext(nil, nil)
pc.Metadata["_http_response_writer"] = http.ResponseWriter(recorder)

result, err := step.Execute(context.Background(), pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Stop {
t.Error("expected Stop=true")
}
// No response_status in output → should not write anything
if recorder.Code != http.StatusOK { // recorder default is 200 until WriteHeader is called
t.Errorf("expected recorder to be untouched (200), got %d", recorder.Code)
}
if recorder.Body.Len() != 0 {
t.Errorf("expected empty body, got %q", recorder.Body.String())
}
if pc.Metadata["_response_handled"] == true {
t.Error("expected _response_handled to remain unset")
}
}
Loading