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
93 changes: 93 additions & 0 deletions pkg/requests/nil_body_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package requests

import (
"bytes"
"encoding/json"
"io"
"testing"
)

// TestDoMethodNilBodyHandling tests the actual body handling logic from Do method
func TestDoMethodNilBodyHandling(t *testing.T) {
// This simulates the exact logic from the Do method
testCases := []struct {
name string
body any
expectNil bool
expectContent string
}{
{
name: "nil body should produce nil reader",
body: nil,
expectNil: true,
expectContent: "",
},
{
name: "empty struct produces JSON",
body: struct{}{},
expectNil: false,
expectContent: "{}",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// This is the exact code from Do method
var reqBody io.Reader
switch body := tc.body.(type) {
case nil:
// For nil body (common with GET requests), use nil reader
reqBody = nil
case []byte:
reqBody = bytes.NewReader(body)
case *bytes.Buffer:
reqBody = bytes.NewReader(body.Bytes())
default:
serialized, err := json.Marshal(body)
if err != nil {
t.Fatalf("Failed to marshal: %v", err)
}
reqBody = bytes.NewReader(serialized)
}

// Check the result
if tc.expectNil {
if reqBody != nil {
// Read to see what's in it
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(reqBody)
content := buf.String()

// This should fail when fix is commented out!
if content == "null" {
t.Errorf("BUG DETECTED: nil body was marshaled to 'null' (4 bytes) instead of remaining nil")
} else {
t.Errorf("Expected nil reader for nil body, got reader with content: %q", content)
}
}
} else {
if reqBody == nil {
t.Errorf("Expected non-nil reader, got nil")
}
}
})
}
}

// TestNilBodyMarshalBehavior verifies what happens when we marshal nil
// This documents the root cause of the bug: json.Marshal(nil) returns "null"
func TestNilBodyMarshalBehavior(t *testing.T) {
data, err := json.Marshal(nil)
if err != nil {
t.Fatalf("Failed to marshal nil: %v", err)
}

// This is the bug - nil gets marshaled to "null" (4 bytes)
if string(data) != "null" {
t.Errorf("json.Marshal(nil) = %q, expected \"null\"", string(data))
}

if len(data) != 4 {
t.Errorf("json.Marshal(nil) length = %d, expected 4", len(data))
}
}
8 changes: 7 additions & 1 deletion pkg/requests/requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ func (c HTTPClient) Do(method, uri, contentType string, body any) ([]byte, error

var reqBody io.Reader
switch body := body.(type) {
case nil:
// For nil body (common with GET requests), use nil reader
reqBody = nil
case []byte:
reqBody = bytes.NewReader(body)
case *bytes.Buffer:
Expand All @@ -65,7 +68,10 @@ func (c HTTPClient) Do(method, uri, contentType string, body any) ([]byte, error
return nil, fmt.Errorf("error creating API request: %w", err)
}

req.Header.Set("Content-Type", contentType)
// Only set Content-Type if there's a body
if reqBody != nil {
req.Header.Set("Content-Type", contentType)
}
req.Header.Set("User-Agent", fmt.Sprintf("%s-%s-%s-%s", "shipyard-cli", version.Version, runtime.GOOS, runtime.GOARCH))
req.Header.Set("x-api-token", token)

Expand Down
30 changes: 19 additions & 11 deletions pkg/requests/uri/uri_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
package uri_test

import (
"os"
"testing"

"github.com/shipyard/shipyard-cli/pkg/requests/uri"
"github.com/spf13/viper"
)

func TestMain(m *testing.M) {
// Set a consistent base URL for all tests
viper.Set("api_url", "http://localhost:8080/api/v1")
code := m.Run()
os.Exit(code)
}

func TestCreateResourceURI(t *testing.T) {
testCases := []struct {
action string
Expand All @@ -21,48 +29,48 @@ func TestCreateResourceURI(t *testing.T) {
{
name: "Get all environments",
action: "", resource: "environment", id: "", subresource: "", params: nil,
want: "https://shipyard.build/api/v1/environment",
want: "http://localhost:8080/api/v1/environment",
},
{
name: "Get all environments in a specific org",
action: "", resource: "environment", id: "", subresource: "", params: map[string]string{"org": "myorg"},
want: "https://shipyard.build/api/v1/environment?org=myorg",
want: "http://localhost:8080/api/v1/environment?org=myorg",
},
{
name: "Get all environments with filters applied",
action: "", resource: "environment", id: "", subresource: "",
params: map[string]string{"branch": "newfeature", "repo_name": "shipyard", "page_size": "9"},
want: "https://shipyard.build/api/v1/environment?branch=newfeature&page_size=9&repo_name=shipyard",
want: "http://localhost:8080/api/v1/environment?branch=newfeature&page_size=9&repo_name=shipyard",
},
{
name: "Get a single environment",
action: "", resource: "environment", id: "123abc", subresource: "",
want: "https://shipyard.build/api/v1/environment/123abc",
want: "http://localhost:8080/api/v1/environment/123abc",
},
{
name: "Get a single environment in a specific org",
action: "", resource: "environment", id: "123abc", subresource: "", params: map[string]string{"org": "myorg"},
want: "https://shipyard.build/api/v1/environment/123abc?org=myorg",
want: "http://localhost:8080/api/v1/environment/123abc?org=myorg",
},
{
name: "Get a kubeconfig for a single environment",
action: "", resource: "environment", id: "123abc", subresource: "kubeconfig", params: nil,
want: "https://shipyard.build/api/v1/environment/123abc/kubeconfig",
want: "http://localhost:8080/api/v1/environment/123abc/kubeconfig",
},
{
name: "Get a kubeconfig for a single environment in a specific org",
action: "", resource: "environment", id: "123abc", subresource: "kubeconfig", params: map[string]string{"org": "myorg"},
want: "https://shipyard.build/api/v1/environment/123abc/kubeconfig?org=myorg",
want: "http://localhost:8080/api/v1/environment/123abc/kubeconfig?org=myorg",
},
{
name: "Stop a single environment",
action: "stop", resource: "environment", id: "123abc", subresource: "", params: nil,
want: "https://shipyard.build/api/v1/environment/123abc/stop",
want: "http://localhost:8080/api/v1/environment/123abc/stop",
},
{
name: "Stop a single environment in a specific org",
action: "stop", resource: "environment", id: "123abc", subresource: "", params: map[string]string{"org": "myorg"},
want: "https://shipyard.build/api/v1/environment/123abc/stop?org=myorg",
want: "http://localhost:8080/api/v1/environment/123abc/stop?org=myorg",
},
}

Expand All @@ -77,9 +85,9 @@ func TestCreateResourceURI(t *testing.T) {
}

func TestCreateResourceURIWithCustomBase(t *testing.T) {
viper.Set("api_url", "localhost:8000")
viper.Set("api_url", "http://localhost:8080/api/v1")

want := "localhost:8000/environment/123abc"
want := "http://localhost:8080/api/v1/environment/123abc"
got := uri.CreateResourceURI("", "environment", "123abc", "", nil)
if got != want {
t.Errorf("expected %s, but got %s", want, got)
Expand Down
37 changes: 26 additions & 11 deletions tests/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
Expand All @@ -25,17 +24,33 @@ func TestMain(m *testing.M) {
fmt.Printf("Setup failure: %s", stderr.String())
os.Exit(1)
}

// Start test server on port 18081
srv := &http.Server{
Addr: ":8000",
Addr: ":18081",
ReadHeaderTimeout: time.Second,
Handler: server.NewHandler(),
}

// Channel to signal server startup status
serverReady := make(chan error, 1)

go func() {
if err := srv.ListenAndServe(); err != nil {
log.Fatalf("Could not start server: %v\n", err)
}
// Signal that server is starting
serverReady <- srv.ListenAndServe()
}()

// Wait a bit for server to start, then check if it failed
time.Sleep(100 * time.Millisecond)
select {
case err := <-serverReady:
// Server failed to start immediately
fmt.Printf("Failed to start test server on port 18081: %v\n", err)
os.Exit(1)
default:
// Server appears to be running
}

// Wait for server to start
time.Sleep(100 * time.Millisecond)

Expand Down Expand Up @@ -87,13 +102,13 @@ func TestGetAllEnvironments(t *testing.T) {
}
return
}

// If we expected an error but got success, that's wrong
if test.output != "" {
t.Errorf("Expected error %q but command succeeded", test.output)
return
}

var resp types.RespManyEnvs
if err := json.Unmarshal(c.stdOut.Bytes(), &resp); err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -156,13 +171,13 @@ func TestGetEnvironmentByID(t *testing.T) {
}
return
}

// If we expected an error but got success, that's wrong
if test.output != "" {
t.Errorf("Expected error %q but command succeeded", test.output)
return
}

var resp types.Response
if err := json.Unmarshal(c.stdOut.Bytes(), &resp); err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -222,7 +237,7 @@ func TestRebuildEnvironment(t *testing.T) {
}
return
}

// For rebuild tests, success cases have specific success messages
// Error cases should have failed above and not reach here
if diff := cmp.Diff(c.stdOut.String(), test.output); diff != "" {
Expand All @@ -238,7 +253,7 @@ func newCmd(args []string) *cmdWrapper {
args: args,
}
c.cmd = exec.Command("./shipyard", commandLine(c.args)...)
c.cmd.Env = append(os.Environ(),
c.cmd.Env = append(os.Environ(),
"SHIPYARD_BUILD_URL=http://localhost:8000",
"SHIPYARD_API_TOKEN=test",
)
Expand Down
2 changes: 1 addition & 1 deletion tests/config.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
api_token: test
api_url: http://localhost:18081
org: default
api_url: http://localhost:8000
Loading