Skip to content
36 changes: 36 additions & 0 deletions cli/cmd/bulk_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -666,3 +666,39 @@ func TestBulkDeployCmd_MixedArgs(t *testing.T) {
out := buf.String()
assert.Contains(t, out, "ID")
}

func TestBulkDeployCmd_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(types.ErrorResponse{})
}))
defer server.Close()

_ = setupBulkTestCmd(t, server.URL)

bulkDeployCmd.Flags().Set("ids", "1,2")
t.Cleanup(func() { bulkDeployCmd.Flags().Set("ids", "") })

err := bulkDeployCmd.RunE(bulkDeployCmd, []string{})
require.Error(t, err)
assert.Contains(t, err.Error(), "Server error")
}

func TestBulkDeployCmd_Forbidden(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(types.ErrorResponse{})
}))
defer server.Close()

_ = setupBulkTestCmd(t, server.URL)

bulkDeployCmd.Flags().Set("ids", "1,2")
t.Cleanup(func() { bulkDeployCmd.Flags().Set("ids", "") })

err := bulkDeployCmd.RunE(bulkDeployCmd, []string{})
require.Error(t, err)
assert.Contains(t, err.Error(), "Permission denied")
}
15 changes: 15 additions & 0 deletions cli/cmd/cluster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,3 +363,18 @@ func TestClusterGetCmd_NotFound(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "cluster not found")
}

func TestClusterGetCmd_Unauthorized(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(types.ErrorResponse{})
}))
defer server.Close()

_ = setupClusterTestCmd(t, server.URL)

err := clusterGetCmd.RunE(clusterGetCmd, []string{"1"})
require.Error(t, err)
assert.Contains(t, err.Error(), "Not authenticated")
}
13 changes: 13 additions & 0 deletions cli/cmd/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,16 @@ func TestConfigDeleteContextCmd_NotFound(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}

func TestConfigUseContextCmd_EmptyName(t *testing.T) {
setupTestCmd(t)

var buf bytes.Buffer
printer.Writer = &buf

// Empty string is passed as the context name argument.
// ValidateContextName rejects it because the regex requires at least one alphanumeric char.
err := configUseContextCmd.RunE(configUseContextCmd, []string{""})
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid context name")
}
4 changes: 4 additions & 0 deletions cli/cmd/definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,10 @@ Examples:
return fmt.Errorf("reading file %s: %w", file, err)
}

if !json.Valid(data) {
return fmt.Errorf("file %s contains invalid JSON", file)
}

c, err := newClient()
if err != nil {
return err
Expand Down
44 changes: 44 additions & 0 deletions cli/cmd/definition_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -975,3 +975,47 @@ func TestDefinitionCreateCmd_FromFileMissingNameField(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "'name' field is required")
}

// ---------- definition delete auth error ----------

func TestDefinitionDeleteCmd_Forbidden(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(types.ErrorResponse{})
}))
defer server.Close()

_ = setupStackTestCmd(t, server.URL)

definitionDeleteCmd.Flags().Set("yes", "true")
t.Cleanup(func() { definitionDeleteCmd.Flags().Set("yes", "false") })

err := definitionDeleteCmd.RunE(definitionDeleteCmd, []string{"5"})
require.Error(t, err)
assert.Contains(t, err.Error(), "Permission denied")
}

// ---------- definition import edge cases ----------

func TestDefinitionImportCmd_InvalidJSON(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("API should not be called for invalid JSON content")
}))
defer server.Close()

tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "bad.json")
require.NoError(t, os.WriteFile(filePath, []byte(`{invalid json}`), 0644))

_ = setupStackTestCmd(t, server.URL)

definitionImportCmd.Flags().Set("file", filePath)
t.Cleanup(func() {
definitionImportCmd.Flags().Set("file", "")
})

err := definitionImportCmd.RunE(definitionImportCmd, []string{})
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid JSON")
}
Comment on lines +1001 to +1021
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

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

TestDefinitionImportCmd_InvalidJSON assumes invalid JSON should be rejected before any API call (the handler t.Fatals if called), but definitionImportCmd passes the file bytes directly to c.ImportDefinition(data). client.ImportDefinition wraps the bytes in json.RawMessage and json.Marshal does not validate the JSON, so the request will still be sent and this test will fail. Either add a json.Valid(data) check in the import command/client and return a clear "invalid JSON" error, or update the test to expect a server-side 400/validation error instead of asserting the API is not called.

Copilot uses AI. Check for mistakes.
18 changes: 18 additions & 0 deletions cli/cmd/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,24 @@ func TestGitBranchesCmd_APIError(t *testing.T) {
assert.Contains(t, err.Error(), "repository not found")
}

func TestGitBranchesCmd_Unauthorized(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(types.ErrorResponse{})
}))
defer server.Close()

_ = setupGitTestCmd(t, server.URL)

gitBranchesCmd.Flags().Set("repo", "https://github.com/org/repo")
t.Cleanup(func() { gitBranchesCmd.Flags().Set("repo", "") })

err := gitBranchesCmd.RunE(gitBranchesCmd, []string{})
require.Error(t, err)
assert.Contains(t, err.Error(), "Not authenticated")
}

// ---------- git validate ----------

func TestGitValidateCmd_ValidBranch(t *testing.T) {
Expand Down
4 changes: 4 additions & 0 deletions cli/cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ Environment variables:
return err
}

if resp.Token == "" {
return fmt.Errorf("server returned an empty token")
}

// Parse expiry from response
var expiresAt time.Time
if resp.ExpiresAt != "" {
Expand Down
28 changes: 28 additions & 0 deletions cli/cmd/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -606,3 +606,31 @@ func TestLoginLogout_RoundTrip(t *testing.T) {
_, err = os.Stat(tokenPath)
assert.True(t, os.IsNotExist(err), "token file should be removed after logout")
}

func TestLoginCmd_EmptyTokenFromServer(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(types.LoginResponse{
Token: "",
ExpiresAt: "2030-01-01T00:00:00Z",
User: types.User{Base: types.Base{ID: 1}, Username: "test", Role: "admin"},
})
}))
defer server.Close()

buf := setupLoginTestCmd(t, server.URL)

loginCmd.Flags().Set("username", "test")
loginCmd.Flags().Set("password", "pass")
loginCmd.SetOut(buf)

err := loginCmd.RunE(loginCmd, []string{})

// The login command should treat an empty token as an error and avoid writing a token file.
require.Error(t, err, "login should fail when server returns an empty token")
assert.Contains(t, err.Error(), "empty token")

tokenPath := filepath.Join(os.Getenv("STACKCTL_CONFIG_DIR"), "tokens", "test.json")
_, statErr := os.Stat(tokenPath)
assert.True(t, os.IsNotExist(statErr), "token file should not exist when server returns empty token")
}
16 changes: 16 additions & 0 deletions cli/cmd/stack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1747,3 +1747,19 @@ func TestStackCompareCmd_NotFound(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "instance not found")
}

// ---------- stack deploy auth error ----------

func TestStackDeployCmd_Forbidden(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(types.ErrorResponse{})
}))
defer server.Close()

_ = setupStackTestCmd(t, server.URL)
err := stackDeployCmd.RunE(stackDeployCmd, []string{"42"})
require.Error(t, err)
assert.Contains(t, err.Error(), "Permission denied")
}
38 changes: 38 additions & 0 deletions cli/cmd/template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -556,3 +556,41 @@ func TestTemplateQuickDeployCmd_InvalidID(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid ID")
}

// ---------- template list auth error ----------

func TestTemplateListCmd_Unauthorized(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(types.ErrorResponse{})
}))
defer server.Close()

_ = setupStackTestCmd(t, server.URL)
err := templateListCmd.RunE(templateListCmd, []string{})
require.Error(t, err)
assert.Contains(t, err.Error(), "Not authenticated")
}

// ---------- template instantiate auth error ----------

func TestTemplateInstantiateCmd_Forbidden(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(types.ErrorResponse{})
}))
defer server.Close()

_ = setupStackTestCmd(t, server.URL)

templateInstantiateCmd.Flags().Set("name", "test")
t.Cleanup(func() {
templateInstantiateCmd.Flags().Set("name", "")
})

err := templateInstantiateCmd.RunE(templateInstantiateCmd, []string{"10"})
require.Error(t, err)
assert.Contains(t, err.Error(), "Permission denied")
}
32 changes: 32 additions & 0 deletions cli/pkg/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2115,3 +2115,35 @@ func TestGetClusterHealth_Error(t *testing.T) {
require.Error(t, err)
assert.Nil(t, health)
}

// ---------- malformed / empty response body ----------

func TestClient_MalformedJSON(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`not json at all`))
}))
defer server.Close()

c := New(server.URL)
resp, err := c.ListStacks(nil)
require.Error(t, err)
assert.Nil(t, resp)
assert.Contains(t, err.Error(), "decoding response")
}

func TestClient_EmptyResponseBody(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
// Write nothing — empty body
}))
defer server.Close()

c := New(server.URL)
stack, err := c.GetStack(1)
require.Error(t, err)
assert.Nil(t, stack)
assert.Contains(t, err.Error(), "unexpected empty response body")
}
Loading
Loading