Skip to content
Merged
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
8 changes: 5 additions & 3 deletions internal/api/handler_purchases.go
Original file line number Diff line number Diff line change
Expand Up @@ -1268,11 +1268,13 @@ func (h *Handler) getPurchaseDetails(ctx context.Context, req *events.LambdaFunc

execution, err := h.config.GetExecutionByID(ctx, executionID)
if err != nil {
// Map any DB-level "not found" error to 404 so callers cannot infer
// whether a UUID exists by observing a 500 vs a 404 (issue #431).
return nil, NewClientError(404, "execution not found")
// Real DB failure (connection error, timeout, etc.) - surface as 500
// so infrastructure issues are visible to operators and not silently
// masked as 404s (issue #976).
return nil, fmt.Errorf("failed to get execution: %w", err)
}
if execution == nil {
// GetExecutionByID returns (nil, nil) for a missing row (issue #976).
return nil, NewClientError(404, "execution not found")
}

Expand Down
15 changes: 8 additions & 7 deletions internal/api/handler_purchases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1720,7 +1720,8 @@ func TestHandler_getPurchaseDetails_NotFound(t *testing.T) {

mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil)
mockAuth.grantAdmin()
mockStore.On("GetExecutionByID", ctx, "99999999-9999-9999-9999-999999999999").Return(nil, errors.New("not found"))
// GetExecutionByID returns (nil, nil) for a missing row (issue #976).
mockStore.On("GetExecutionByID", ctx, "99999999-9999-9999-9999-999999999999").Return(nil, nil)

handler := &Handler{config: mockStore, auth: mockAuth}

Expand Down Expand Up @@ -1771,10 +1772,10 @@ func TestHandler_getPurchaseDetails_NilExecution(t *testing.T) {
}

// TestHandler_getPurchaseDetails_NotFound_IsClientError is a focused
// regression for issue #431: a DB-level "not found" error from
// GetExecutionByID must produce a 404 ClientError, never a 500, so that
// unauthenticated callers cannot infer UUID existence by observing a
// status-code difference.
// regression for issue #431: a missing execution must produce a 404 ClientError,
// never a 500, so that callers cannot infer UUID existence by observing a
// status-code difference. GetExecutionByID returns (nil, nil) for a missing row
// (issue #976); the handler maps that sentinel to a 404.
func TestHandler_getPurchaseDetails_NotFound_IsClientError(t *testing.T) {
ctx := context.Background()
mockStore := new(MockConfigStore)
Expand All @@ -1792,8 +1793,8 @@ func TestHandler_getPurchaseDetails_NotFound_IsClientError(t *testing.T) {
mockAuth.grantAdmin()

const missingID = "dddddddd-dddd-dddd-dddd-dddddddddddd"
// Simulate the DB returning a generic error (as pgx does on a missing row).
mockStore.On("GetExecutionByID", ctx, missingID).Return(nil, errors.New("no rows in result set"))
// GetExecutionByID returns (nil, nil) for a missing row (issue #976).
mockStore.On("GetExecutionByID", ctx, missingID).Return(nil, nil)

handler := &Handler{config: mockStore, auth: mockAuth}
req := &events.LambdaFunctionURLRequest{
Expand Down
8 changes: 6 additions & 2 deletions internal/config/store_postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -1125,7 +1125,11 @@ func (s *PostgresStore) GetPendingExecutionsTx(ctx context.Context, tx pgx.Tx) (
return scanExecutionRows(rows)
}

// GetExecutionByID retrieves a purchase execution by execution ID
// GetExecutionByID retrieves a purchase execution by execution ID.
// Returns (nil, nil) when no row matches executionID so callers can cleanly
// distinguish "not found" (nil execution, nil error) from a real DB failure
// (nil execution, non-nil error). All callers must check the execution for
// nil before use (closes issue #976).
func (s *PostgresStore) GetExecutionByID(ctx context.Context, executionID string) (*PurchaseExecution, error) {
query := `
SELECT plan_id, execution_id, status, step_number, scheduled_date,
Expand All @@ -1146,7 +1150,7 @@ func (s *PostgresStore) GetExecutionByID(ctx context.Context, executionID string
}

if len(executions) == 0 {
return nil, fmt.Errorf("execution not found: %s", executionID)
return nil, nil
}

return &executions[0], nil
Expand Down
Loading