From accda66cd22c1d118f964ad8e199cc96a8c1c7b5 Mon Sep 17 00:00:00 2001 From: Aadarsh Sankar Date: Mon, 22 Jun 2026 10:42:33 -0500 Subject: [PATCH 1/2] Add cloud integration disabled integration test. Assert /cloudCost endpoints return HTTP 200 with empty data when billing integration is not configured, and skip when integrations are present. Fixes #82 Signed-off-by: Aadarsh Sankar Co-authored-by: Cursor --- .../config/cloud_integration_disabled_test.go | 156 ++++++++++++++++++ test/integration/api/config/test.bats | 0 2 files changed, 156 insertions(+) create mode 100644 test/integration/api/config/cloud_integration_disabled_test.go create mode 100644 test/integration/api/config/test.bats diff --git a/test/integration/api/config/cloud_integration_disabled_test.go b/test/integration/api/config/cloud_integration_disabled_test.go new file mode 100644 index 0000000..d7a51a1 --- /dev/null +++ b/test/integration/api/config/cloud_integration_disabled_test.go @@ -0,0 +1,156 @@ +package config + +// Integration test for OpenCost deployments without cloud billing integration configured. +// +// Documented behavior when cloud billing integration is NOT configured: +// - GET /cloudCost/status → HTTP 200, protocol code 200, data: [] +// - GET /cloudCost → HTTP 200, protocol code 200, empty cloudCosts in all sets +// - GET /cloudCost/autocomplete → HTTP 200, protocol code 200, data.data: [] +// +// These endpoints must never return HTTP 500 when integration is disabled. +// +// The shared CI demo environment has cloud billing enabled, so this test skips when +// /cloudCost/status reports one or more integrations. Run against a local OpenCost +// instance without cloud-integration.json to exercise the assertions: +// +// export OPENCOST_URL='http://localhost:9003' +// go test -v ./test/integration/api/config/cloud_integration_disabled_test.go + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/opencost/opencost-integration-tests/pkg/api" + "github.com/opencost/opencost-integration-tests/pkg/env" +) + +const defaultWindow = "24h" + +type protocolResponse[T any] struct { + Code int `json:"code"` + Data T `json:"data"` +} + +type cloudCostQueryData struct { + Sets []struct { + CloudCosts map[string]json.RawMessage `json:"cloudCosts"` + } `json:"sets"` +} + +type cloudCostStatusEntry struct { + Key string `json:"key"` + Provider string `json:"provider"` + Active bool `json:"active"` +} + +func TestCloudCostWhenIntegrationDisabled(t *testing.T) { + apiClient := api.NewAPI() + t.Logf("OPENCOST_URL=%s", env.GetDefaultURL()) + + requireCloudIntegrationDisabled(t, apiClient) + + t.Run("cloudCost query", func(t *testing.T) { + body := getCloudCostEndpoint(t, apiClient, "/cloudCost", api.AutocompleteRequest{Window: defaultWindow}) + + var resp protocolResponse[cloudCostQueryData] + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("decode /cloudCost: %v", err) + } + if resp.Code != http.StatusOK { + t.Fatalf("/cloudCost protocol code %d, want 200", resp.Code) + } + for i, set := range resp.Data.Sets { + if len(set.CloudCosts) > 0 { + t.Errorf("set %d: expected empty cloudCosts, got %d entries", i, len(set.CloudCosts)) + } + } + }) + + t.Run("cloudCost autocomplete", func(t *testing.T) { + body := getCloudCostEndpoint(t, apiClient, "/cloudCost/autocomplete", api.AutocompleteRequest{ + Window: defaultWindow, + Field: "service", + Limit: 1, + }) + + var resp protocolResponse[api.AutocompletePayload] + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("decode /cloudCost/autocomplete: %v", err) + } + if resp.Code != http.StatusOK { + t.Fatalf("/cloudCost/autocomplete protocol code %d, want 200", resp.Code) + } + if len(resp.Data.Data) > 0 { + t.Errorf("expected empty autocomplete data, got %v", resp.Data.Data) + } + }) + + t.Run("cloudCost status", func(t *testing.T) { + body := getCloudCostEndpoint(t, apiClient, "/cloudCost/status", api.AutocompleteRequest{}) + + var resp protocolResponse[[]cloudCostStatusEntry] + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("decode /cloudCost/status: %v", err) + } + if resp.Code != http.StatusOK { + t.Fatalf("/cloudCost/status protocol code %d, want 200", resp.Code) + } + if len(resp.Data) != 0 { + t.Errorf("expected no cloud cost integrations, got %d", len(resp.Data)) + } + }) +} + +func requireCloudIntegrationDisabled(t *testing.T, apiClient *api.API) { + t.Helper() + + status, body, err := apiClient.GetAutocompleteStatus("/cloudCost/status", api.AutocompleteRequest{}) + if err != nil { + t.Fatalf("GET /cloudCost/status: %v", err) + } + if status == http.StatusNotFound { + t.Skipf( + "/cloudCost/status is not deployed on %s (HTTP 404)", + env.GetDefaultURL(), + ) + } + if status == http.StatusInternalServerError { + t.Fatalf("/cloudCost/status returned HTTP 500: %s", strings.TrimSpace(string(body))) + } + if status != http.StatusOK { + t.Fatalf("/cloudCost/status returned HTTP %d: %s", status, strings.TrimSpace(string(body))) + } + + var resp protocolResponse[[]cloudCostStatusEntry] + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("decode /cloudCost/status: %v", err) + } + if resp.Code != http.StatusOK { + t.Fatalf("/cloudCost/status protocol code %d, want 200", resp.Code) + } + if len(resp.Data) > 0 { + t.Skipf( + "cloud billing integration is configured on %s (%d integration(s)); need instance without cloud integration", + env.GetDefaultURL(), + len(resp.Data), + ) + } +} + +func getCloudCostEndpoint(t *testing.T, apiClient *api.API, path string, req api.AutocompleteRequest) []byte { + t.Helper() + + status, body, err := apiClient.GetAutocompleteStatus(path, req) + if err != nil { + t.Fatalf("GET %s: %v", path, err) + } + if status == http.StatusInternalServerError { + t.Fatalf("%s returned HTTP 500: %s", path, strings.TrimSpace(string(body))) + } + if status != http.StatusOK { + t.Fatalf("%s returned HTTP %d: %s", path, status, strings.TrimSpace(string(body))) + } + return body +} diff --git a/test/integration/api/config/test.bats b/test/integration/api/config/test.bats new file mode 100644 index 0000000..e69de29 From 143a64cd5ea65496dc6c5341c913f17ca5130743 Mon Sep 17 00:00:00 2001 From: Aadarsh Sankar Date: Mon, 22 Jun 2026 10:45:52 -0500 Subject: [PATCH 2/2] Add inline comments to cloud integration disabled test. Document test helpers and assertions, and restore the bats runner for the config integration test. Signed-off-by: Aadarsh Sankar --- .../config/cloud_integration_disabled_test.go | 96 ++++++++----------- test/integration/api/config/test.bats | 12 +++ 2 files changed, 52 insertions(+), 56 deletions(-) diff --git a/test/integration/api/config/cloud_integration_disabled_test.go b/test/integration/api/config/cloud_integration_disabled_test.go index d7a51a1..0ae20d9 100644 --- a/test/integration/api/config/cloud_integration_disabled_test.go +++ b/test/integration/api/config/cloud_integration_disabled_test.go @@ -1,21 +1,5 @@ package config -// Integration test for OpenCost deployments without cloud billing integration configured. -// -// Documented behavior when cloud billing integration is NOT configured: -// - GET /cloudCost/status → HTTP 200, protocol code 200, data: [] -// - GET /cloudCost → HTTP 200, protocol code 200, empty cloudCosts in all sets -// - GET /cloudCost/autocomplete → HTTP 200, protocol code 200, data.data: [] -// -// These endpoints must never return HTTP 500 when integration is disabled. -// -// The shared CI demo environment has cloud billing enabled, so this test skips when -// /cloudCost/status reports one or more integrations. Run against a local OpenCost -// instance without cloud-integration.json to exercise the assertions: -// -// export OPENCOST_URL='http://localhost:9003' -// go test -v ./test/integration/api/config/cloud_integration_disabled_test.go - import ( "encoding/json" "net/http" @@ -26,79 +10,79 @@ import ( "github.com/opencost/opencost-integration-tests/pkg/env" ) -const defaultWindow = "24h" +const defaultWindow = "24h" //test the last 24 hours of data type protocolResponse[T any] struct { - Code int `json:"code"` - Data T `json:"data"` + Code int `json:"code"` //the HTTP status code of the response + Data T `json:"data"` //the data of the response } type cloudCostQueryData struct { - Sets []struct { - CloudCosts map[string]json.RawMessage `json:"cloudCosts"` + Sets []struct { //the sets of the response + CloudCosts map[string]json.RawMessage `json:"cloudCosts"` //the cloud costs of the response } `json:"sets"` } type cloudCostStatusEntry struct { - Key string `json:"key"` - Provider string `json:"provider"` - Active bool `json:"active"` + Key string `json:"key"` //the key of the response + Provider string `json:"provider"` //the provider of the response + Active bool `json:"active"` //the active status of the response } func TestCloudCostWhenIntegrationDisabled(t *testing.T) { - apiClient := api.NewAPI() + apiClient := api.NewAPI() //create a new API client t.Logf("OPENCOST_URL=%s", env.GetDefaultURL()) - requireCloudIntegrationDisabled(t, apiClient) + requireCloudIntegrationDisabled(t, apiClient) //check if the cloud integration is disabled t.Run("cloudCost query", func(t *testing.T) { - body := getCloudCostEndpoint(t, apiClient, "/cloudCost", api.AutocompleteRequest{Window: defaultWindow}) + body := getCloudCostEndpoint(t, apiClient, "/cloudCost", api.AutocompleteRequest{Window: defaultWindow}) //get the cloud cost endpoint - var resp protocolResponse[cloudCostQueryData] + var resp protocolResponse[cloudCostQueryData] //decode the response if err := json.Unmarshal(body, &resp); err != nil { - t.Fatalf("decode /cloudCost: %v", err) + t.Fatalf("decode /cloudCost: %v", err) //if there is an error, fail the test } if resp.Code != http.StatusOK { - t.Fatalf("/cloudCost protocol code %d, want 200", resp.Code) + t.Fatalf("/cloudCost protocol code %d, want 200", resp.Code) //if the response code is not 200, fail the test } for i, set := range resp.Data.Sets { if len(set.CloudCosts) > 0 { - t.Errorf("set %d: expected empty cloudCosts, got %d entries", i, len(set.CloudCosts)) + t.Errorf("set %d: expected empty cloudCosts, got %d entries", i, len(set.CloudCosts)) //if the cloud costs are not empty, fail the test } } }) t.Run("cloudCost autocomplete", func(t *testing.T) { - body := getCloudCostEndpoint(t, apiClient, "/cloudCost/autocomplete", api.AutocompleteRequest{ + body := getCloudCostEndpoint(t, apiClient, "/cloudCost/autocomplete", api.AutocompleteRequest{ //get the cloud cost autocomplete endpoint Window: defaultWindow, Field: "service", Limit: 1, - }) + }) //get the cloud cost autocomplete endpoint - var resp protocolResponse[api.AutocompletePayload] + var resp protocolResponse[api.AutocompletePayload] //decode the response if err := json.Unmarshal(body, &resp); err != nil { - t.Fatalf("decode /cloudCost/autocomplete: %v", err) + t.Fatalf("decode /cloudCost/autocomplete: %v", err) //if there is an error, fail the test } if resp.Code != http.StatusOK { - t.Fatalf("/cloudCost/autocomplete protocol code %d, want 200", resp.Code) + t.Fatalf("/cloudCost/autocomplete protocol code %d, want 200", resp.Code) //if the response code is not 200, fail the test } if len(resp.Data.Data) > 0 { - t.Errorf("expected empty autocomplete data, got %v", resp.Data.Data) + t.Errorf("expected empty autocomplete data, got %v", resp.Data.Data) //if the autocomplete data is not empty, fail the test } }) t.Run("cloudCost status", func(t *testing.T) { - body := getCloudCostEndpoint(t, apiClient, "/cloudCost/status", api.AutocompleteRequest{}) + body := getCloudCostEndpoint(t, apiClient, "/cloudCost/status", api.AutocompleteRequest{}) //get the cloud cost status endpoint - var resp protocolResponse[[]cloudCostStatusEntry] + var resp protocolResponse[[]cloudCostStatusEntry] //decode the response if err := json.Unmarshal(body, &resp); err != nil { - t.Fatalf("decode /cloudCost/status: %v", err) + t.Fatalf("decode /cloudCost/status: %v", err) //if there is an error, fail the test } if resp.Code != http.StatusOK { - t.Fatalf("/cloudCost/status protocol code %d, want 200", resp.Code) + t.Fatalf("/cloudCost/status protocol code %d, want 200", resp.Code) //if the response code is not 200, fail the test } if len(resp.Data) != 0 { - t.Errorf("expected no cloud cost integrations, got %d", len(resp.Data)) + t.Errorf("expected no cloud cost integrations, got %d", len(resp.Data)) //if the cloud cost integrations are not empty, fail the test } }) } @@ -106,51 +90,51 @@ func TestCloudCostWhenIntegrationDisabled(t *testing.T) { func requireCloudIntegrationDisabled(t *testing.T, apiClient *api.API) { t.Helper() - status, body, err := apiClient.GetAutocompleteStatus("/cloudCost/status", api.AutocompleteRequest{}) + status, body, err := apiClient.GetAutocompleteStatus("/cloudCost/status", api.AutocompleteRequest{}) //get the cloud cost status endpoint if err != nil { - t.Fatalf("GET /cloudCost/status: %v", err) + t.Fatalf("GET /cloudCost/status: %v", err) //if there is an error, fail the test } if status == http.StatusNotFound { t.Skipf( - "/cloudCost/status is not deployed on %s (HTTP 404)", + "/cloudCost/status is not deployed on %s (HTTP 404)", //if the cloud cost status endpoint is not deployed, skip the test env.GetDefaultURL(), ) } if status == http.StatusInternalServerError { - t.Fatalf("/cloudCost/status returned HTTP 500: %s", strings.TrimSpace(string(body))) + t.Fatalf("/cloudCost/status returned HTTP 500: %s", strings.TrimSpace(string(body))) //if the cloud cost status endpoint returned HTTP 500, fail the test } if status != http.StatusOK { - t.Fatalf("/cloudCost/status returned HTTP %d: %s", status, strings.TrimSpace(string(body))) + t.Fatalf("/cloudCost/status returned HTTP %d: %s", status, strings.TrimSpace(string(body))) //if the cloud cost status endpoint returned HTTP not 200, fail the test } - var resp protocolResponse[[]cloudCostStatusEntry] + var resp protocolResponse[[]cloudCostStatusEntry] //decode the response if err := json.Unmarshal(body, &resp); err != nil { - t.Fatalf("decode /cloudCost/status: %v", err) + t.Fatalf("decode /cloudCost/status: %v", err) //if there is an error, fail the test } if resp.Code != http.StatusOK { - t.Fatalf("/cloudCost/status protocol code %d, want 200", resp.Code) + t.Fatalf("/cloudCost/status protocol code %d, want 200", resp.Code) //if the response code is not 200, fail the test } if len(resp.Data) > 0 { t.Skipf( - "cloud billing integration is configured on %s (%d integration(s)); need instance without cloud integration", + "cloud billing integration is configured on %s (%d integration(s)); need instance without cloud integration", //if the cloud billing integration is configured, skip the test env.GetDefaultURL(), len(resp.Data), - ) + ) //if the cloud billing integration is configured, skip the test } } -func getCloudCostEndpoint(t *testing.T, apiClient *api.API, path string, req api.AutocompleteRequest) []byte { +func getCloudCostEndpoint(t *testing.T, apiClient *api.API, path string, req api.AutocompleteRequest) []byte { //get the cloud cost endpoint t.Helper() status, body, err := apiClient.GetAutocompleteStatus(path, req) if err != nil { - t.Fatalf("GET %s: %v", path, err) + t.Fatalf("GET %s: %v", path, err) //if there is an error, fail the test } if status == http.StatusInternalServerError { - t.Fatalf("%s returned HTTP 500: %s", path, strings.TrimSpace(string(body))) + t.Fatalf("%s returned HTTP 500: %s", path, strings.TrimSpace(string(body))) //if the cloud cost endpoint returned HTTP 500, fail the test } if status != http.StatusOK { - t.Fatalf("%s returned HTTP %d: %s", path, status, strings.TrimSpace(string(body))) + t.Fatalf("%s returned HTTP %d: %s", path, status, strings.TrimSpace(string(body))) //if the cloud cost endpoint returned HTTP not 200, fail the test } return body } diff --git a/test/integration/api/config/test.bats b/test/integration/api/config/test.bats index e69de29..7816cee 100644 --- a/test/integration/api/config/test.bats +++ b/test/integration/api/config/test.bats @@ -0,0 +1,12 @@ +setup() { + DIR="$( cd "$( dirname "$BATS_TEST_FILENAME" )" >/dev/null 2>&1 && pwd )" + cd "$DIR" +} + +teardown() { + : # nothing to tear down +} + +@test "config: cloud integration disabled" { + go test -count=1 cloud_integration_disabled_test.go +} \ No newline at end of file