Skip to content
Draft
3 changes: 3 additions & 0 deletions internal/analytics/collector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,9 @@ func (m *mockConfigStore) UpdateAccountRegistration(_ context.Context, _ *config
func (m *mockConfigStore) TransitionRegistrationStatus(_ context.Context, _ *config.AccountRegistration, _ string, _ *string) error {
return nil
}
func (m *mockConfigStore) ApproveAccountRegistration(_ context.Context, _ *config.AccountRegistration, _ *config.CloudAccount, _ *string) error {
return nil
}
func (m *mockConfigStore) DeleteAccountRegistration(_ context.Context, _ string) error {
return nil
}
Expand Down
9 changes: 9 additions & 0 deletions internal/api/handler_accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,15 @@ func (h *Handler) updateAccount(ctx context.Context, httpReq *events.LambdaFunct
return nil, err
}

// The provider is fixed when the account is created: UpdateCloudAccount
// does not write it, and credentials, service overrides and plan
// memberships all depend on it. Refuse a change rather than echo back a
// provider that was never saved.
if req.Provider != existing.Provider {
return nil, NewClientError(400, fmt.Sprintf(
"provider cannot be changed (this account is %s); delete the account and add it again under the new provider", existing.Provider))
}

account := cloudAccountFromRequest(req)
account.ID = id
account.CreatedAt = existing.CreatedAt
Expand Down
12 changes: 5 additions & 7 deletions internal/api/handler_accounts_hostcheck_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,28 +173,26 @@ func TestApproveRegistration_EmptyRoleARNRequiresHostAccount(t *testing.T) {
Return(&config.AccountRegistration{ID: hostCheckRegID, Status: "pending", Provider: "aws", ExternalID: tc.externalID}, nil)
// Registered so a regression fails on the assertions below
// instead of panicking on an unexpected call.
store.On("TransitionRegistrationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(errors.New("registration must not be transitioned"))
store.On("ApproveAccountRegistration", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(errors.New("registration must not be approved"))
h := hostCheckHandler(store, tc.fake, tc.cfgErr)
req := &events.LambdaFunctionURLRequest{Body: roleARNBody(tc.externalID, "")}
result, err := h.approveRegistration(ctx, req, hostCheckRegID)
assert.Nil(t, result)
requireNotHost400(t, err)
store.AssertNotCalled(t, "TransitionRegistrationStatus")
store.AssertNotCalled(t, "CreateCloudAccount")
store.AssertNotCalled(t, "ApproveAccountRegistration")
})
}

t.Run("host account is accepted", func(t *testing.T) {
store := new(MockConfigStore)
store.On("GetAccountRegistration", mock.Anything, hostCheckRegID).
Return(&config.AccountRegistration{ID: hostCheckRegID, Status: "pending", Provider: "aws", ExternalID: probeHostAcct}, nil)
store.On("TransitionRegistrationStatus", mock.Anything, mock.Anything, "pending", mock.Anything).Return(nil)
store.On("UpdateAccountRegistration", mock.Anything, mock.Anything).Return(nil)
store.On("ApproveAccountRegistration", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
h := hostCheckHandler(store, &probeSTS{}, nil)
req := &events.LambdaFunctionURLRequest{Body: roleARNBody(probeHostAcct, "")}
_, err := h.approveRegistration(ctx, req, hostCheckRegID)
require.NoError(t, err)
store.AssertCalled(t, "CreateCloudAccount")
store.AssertCalled(t, "ApproveAccountRegistration", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
})
}
74 changes: 74 additions & 0 deletions internal/api/handler_accounts_provider_immutable_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package api

import (
"context"
"testing"

"github.com/aws/aws-lambda-go/events"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"

"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
)

// Regression tests for issue #1102 item 3: PUT /api/accounts/{id} validated
// the request's provider and echoed it back, but UpdateCloudAccount never
// writes the provider column, so the change was silently dropped. A provider
// change is now refused with a 400 before anything is saved.

func updateAccountRequest(body, id string) *events.LambdaFunctionURLRequest {
req := adminRequest(body)
req.RequestContext.HTTP.Method = "PUT"
req.RequestContext.HTTP.Path = "/api/accounts/" + id
return req
}

func TestUpdateAccount_ProviderChangeIsRejected(t *testing.T) {
ctx := context.Background()
mockAuth := new(MockAuthService)
setupAdminAuth(ctx, mockAuth)

existing := sampleAccount() // provider aws
store := new(MockConfigStore)
store.On("GetCloudAccount", ctx, existing.ID).Return(&existing, nil)
// Registered so the pre-fix handler reaches the save and returns the
// echoed account instead of panicking on an unexpected call.
store.On("UpdateCloudAccount", ctx, mock.Anything).Return(nil).Maybe()
handler := &Handler{auth: mockAuth, config: store}

body := `{"name":"Test Account","provider":"gcp","external_id":"123456789012","gcp_auth_mode":"application_default"}`
result, err := handler.updateAccount(ctx, updateAccountRequest(body, existing.ID), existing.ID)

assert.Nil(t, result, "a provider that is never saved must not be echoed back")
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok, "want a client error, got %v", err)
assert.Equal(t, 400, ce.code)
assert.Contains(t, ce.message, "provider cannot be changed")
assert.Contains(t, ce.message, "aws")
store.AssertNotCalled(t, "UpdateCloudAccount", mock.Anything, mock.Anything)
}

func TestUpdateAccount_SameProviderIsSaved(t *testing.T) {
ctx := context.Background()
mockAuth := new(MockAuthService)
setupAdminAuth(ctx, mockAuth)

existing := sampleAccount()
store := new(MockConfigStore)
store.On("GetCloudAccount", ctx, existing.ID).Return(&existing, nil)
store.On("UpdateCloudAccount", ctx, mock.MatchedBy(func(a *config.CloudAccount) bool {
return a.ID == existing.ID && a.Provider == "aws" && a.Name == "Renamed"
})).Return(nil).Once()
handler := &Handler{auth: mockAuth, config: store}

body := `{"name":"Renamed","provider":"aws","external_id":"123456789012"}`
result, err := handler.updateAccount(ctx, updateAccountRequest(body, existing.ID), existing.ID)
require.NoError(t, err)
saved, ok := result.(*config.CloudAccount)
require.True(t, ok)
assert.Equal(t, "aws", saved.Provider)
assert.Equal(t, "Renamed", saved.Name)
store.AssertExpectations(t)
}
38 changes: 32 additions & 6 deletions internal/api/handler_analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,9 @@ func (h *Handler) getHistoryAnalytics(ctx context.Context, req *events.LambdaFun
}

// Parse parameters
interval := params["interval"]
if interval == "" {
interval = "hourly"
interval, err := analyticsInterval(params["interval"])
if err != nil {
return nil, err
}

// Normalise + validate the global-filter provider chip, mirroring
Expand Down Expand Up @@ -190,9 +190,9 @@ func (h *Handler) getHistoryBreakdown(ctx context.Context, req *events.LambdaFun
return nil, NewClientError(503, "analytics not configured")
}

dimension := params["dimension"]
if dimension == "" {
dimension = "service"
dimension, err := analyticsDimension(params["dimension"])
if err != nil {
return nil, err
}

// Scope check plus account -> dual-column inputs; see getHistoryAnalytics.
Expand Down Expand Up @@ -220,6 +220,32 @@ func (h *Handler) getHistoryBreakdown(ctx context.Context, req *events.LambdaFun
}, nil
}

// analyticsInterval returns the requested bucket interval, defaulting to
// hourly. A value the store cannot bucket by is a 400 here; without this check
// it reached intervalToTruncUnit inside QueryHistory and came back as a 500.
func analyticsInterval(raw string) (string, error) {
if raw == "" {
return "hourly", nil
}
if _, err := intervalToTruncUnit(raw); err != nil {
return "", NewClientError(400, "invalid interval: must be one of hourly, daily, weekly, monthly")
}
return raw, nil
}

// analyticsDimension returns the requested breakdown dimension, defaulting to
// service. A value the store cannot group by is a 400 here; without this check
// it reached dimensionToColumn inside QueryBreakdown and came back as a 500.
func analyticsDimension(raw string) (string, error) {
if raw == "" {
return "service", nil
}
if _, err := dimensionToColumn(raw); err != nil {
return "", NewClientError(400, "invalid dimension: must be one of service, provider, region, account")
}
return raw, nil
}

// resolveAnalyticsAccountFilter enforces the session's allowed_accounts scope
// on the requested account_id and returns the dual-column filter the analytics
// queries run with. Scoped users must name an account that their scope allows;
Expand Down
102 changes: 102 additions & 0 deletions internal/api/handler_analytics_invalid_params_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package api

import (
"context"
"fmt"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

// Regression tests for issue #1102 item 1: an unsupported analytics interval
// or dimension used to reach the Postgres client, fail its allowlist and come
// back as a 500. It is a bad request, so the handler now answers 400 before
// any query runs.

func TestHandler_getHistoryAnalytics_UnsupportedIntervalIs400(t *testing.T) {
for _, interval := range []string{"yearly", "minute", "HOURLY", "hour"} {
t.Run(interval, func(t *testing.T) {
ctx := context.Background()
mockClient := new(MockAnalyticsClient)
// What the Postgres client answers for an interval outside its
// allowlist, so the pre-fix handler fails this test with a 500
// instead of panicking on an unexpected call.
mockClient.On("QueryHistory", ctx, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, interval).
Return(nil, nil, fmt.Errorf("unsupported interval %q", interval)).Maybe()
mockAuth, req := adminAnalyticsReq(ctx)
handler := &Handler{auth: mockAuth, analyticsClient: mockClient, config: new(MockConfigStore)}

_, err := handler.getHistoryAnalytics(ctx, req, map[string]string{"interval": interval})
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok, "want a client error, got %v", err)
assert.Equal(t, 400, ce.code)
assert.Contains(t, ce.message, "invalid interval")
mockClient.AssertNotCalled(t, "QueryHistory", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
})
}
}

func TestHandler_getHistoryAnalytics_SupportedIntervalsReachStore(t *testing.T) {
for _, interval := range []string{"hourly", "daily", "weekly", "monthly"} {
t.Run(interval, func(t *testing.T) {
ctx := context.Background()
mockClient := new(MockAnalyticsClient)
mockClient.On("QueryHistory", ctx, []string(nil), map[string][]string(nil), "", mock.Anything, mock.Anything, interval).
Return([]HistoryDataPoint{}, (*HistorySummary)(nil), nil)
mockAuth, req := adminAnalyticsReq(ctx)
handler := &Handler{auth: mockAuth, analyticsClient: mockClient, config: new(MockConfigStore)}

result, err := handler.getHistoryAnalytics(ctx, req, map[string]string{"interval": interval})
require.NoError(t, err)
resp, ok := result.(*AnalyticsResponse)
require.True(t, ok)
assert.Equal(t, interval, resp.Interval)
mockClient.AssertExpectations(t)
})
}
}

func TestHandler_getHistoryBreakdown_UnsupportedDimensionIs400(t *testing.T) {
for _, dimension := range []string{"tenant", "account_id", "SERVICE", "service_type"} {
t.Run(dimension, func(t *testing.T) {
ctx := context.Background()
mockClient := new(MockAnalyticsClient)
// The Postgres client's answer for an unknown dimension; see above.
mockClient.On("QueryBreakdown", ctx, mock.Anything, mock.Anything, mock.Anything, mock.Anything, dimension).
Return(nil, fmt.Errorf("unsupported dimension %q", dimension)).Maybe()
mockAuth, req := adminAnalyticsReq(ctx)
handler := &Handler{auth: mockAuth, analyticsClient: mockClient, config: new(MockConfigStore)}

_, err := handler.getHistoryBreakdown(ctx, req, map[string]string{"dimension": dimension})
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok, "want a client error, got %v", err)
assert.Equal(t, 400, ce.code)
assert.Contains(t, ce.message, "invalid dimension")
mockClient.AssertNotCalled(t, "QueryBreakdown", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
})
}
}

func TestHandler_getHistoryBreakdown_SupportedDimensionsReachStore(t *testing.T) {
for _, dimension := range []string{"service", "provider", "region", "account"} {
t.Run(dimension, func(t *testing.T) {
ctx := context.Background()
mockClient := new(MockAnalyticsClient)
mockClient.On("QueryBreakdown", ctx, []string(nil), map[string][]string(nil), mock.Anything, mock.Anything, dimension).
Return(map[string]BreakdownValue{}, nil)
mockAuth, req := adminAnalyticsReq(ctx)
handler := &Handler{auth: mockAuth, analyticsClient: mockClient, config: new(MockConfigStore)}

result, err := handler.getHistoryBreakdown(ctx, req, map[string]string{"dimension": dimension})
require.NoError(t, err)
resp, ok := result.(*BreakdownResponse)
require.True(t, ok)
assert.Equal(t, dimension, resp.Dimension)
mockClient.AssertExpectations(t)
})
}
}
80 changes: 80 additions & 0 deletions internal/api/handler_purchases_revoke_azure_id_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//go:build integration

package api

import (
"context"
"testing"
"time"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/aws/aws-lambda-go/events"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
)

// TestRevokePurchase_AzureARMPurchaseIDOnRealPostgres is the regression test
// for issue #1102 item 5. revokePurchase first asks GetExecutionByID whether
// the ID is a scheduled execution. An Azure purchase ID is an ARM path, which
// Postgres refused to cast to the uuid execution_id column (SQLSTATE 22P02), so
// every Azure revoke failed with a 500 before reaching purchase_history. The
// unit tests mocked that lookup as ErrNotFound and never saw it.
//
// The store and the purchase_history row are real; only the two Azure SDK
// clients are stubs, so no cloud call is made.
func TestRevokePurchase_AzureARMPurchaseIDOnRealPostgres(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
t.Cleanup(cancel)
f := newCreateConcurrencyFixture(ctx, t)

var acctID string
require.NoError(t, f.pool.QueryRow(ctx,
"SELECT id::text FROM cloud_accounts WHERE external_id = 'fixture-subscription-a'").Scan(&acctID))

const purchaseID = "/subscriptions/00000000-0000-0000-0000-00000000000a/providers/Microsoft.Capacity/reservationOrders/order-1102/reservations/res-1102"
purchasedAt := time.Now().UTC().Add(-24 * time.Hour)
require.NoError(t, f.store.SavePurchaseHistory(ctx, &config.PurchaseHistoryRecord{
AccountID: "fixture-subscription-a", CloudAccountID: &acctID, PurchaseID: purchaseID,
Timestamp: purchasedAt, Provider: "azure", Service: "compute", Region: "eastus",
ResourceType: "Standard_D2s_v3", Count: 1, Term: 1, Payment: "monthly",
RevocationWindowClosesAt: config.RevocationWindowClosesAtFor("azure", purchasedAt),
}))

returnClient := &stubReturnClient{}
f.handler.azureRevokeFactory = &azureRevokeClientFactory{
newCredential: func() (azcore.TokenCredential, error) { return nil, nil },
newCalculateRefundClient: func(azcore.TokenCredential) (azureCalculateRefundClient, error) {
return &stubCalcRefundClientWithAmount{amount: 42.5, currency: "USD", sessID: "sess-1102"}, nil
},
newReturnClient: func(azcore.TokenCredential) (azureReturnClient, error) { return returnClient, nil },
}
req := func(body string) *events.LambdaFunctionURLRequest {
return &events.LambdaFunctionURLRequest{Headers: map[string]string{"Authorization": "Bearer admin-token"}, Body: body}
}

t.Run("unknown ARM id is a 404, not a 500", func(t *testing.T) {
_, err := f.handler.revokePurchase(ctx, req(""), "/providers/Microsoft.Capacity/reservationOrders/no-such-order/reservations/none")
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok, "want a client error, got %v", err)
assert.Equal(t, 404, ce.code)
assert.Zero(t, returnClient.calls)
})

t.Run("Azure reservation is returned and recorded", func(t *testing.T) {
result, err := f.handler.revokePurchase(ctx,
req(`{"expected_refund_amount":42.5,"expected_refund_currency":"USD"}`), purchaseID)
require.NoError(t, err)
res, ok := result.(*revokePurchaseResult)
require.True(t, ok, "got %T", result)
assert.Equal(t, "revoked", res.Status)
assert.Equal(t, 1, returnClient.calls)

var revokedAt *time.Time
require.NoError(t, f.pool.QueryRow(ctx,
"SELECT revoked_at FROM purchase_history WHERE purchase_id = $1", purchaseID).Scan(&revokedAt))
assert.NotNil(t, revokedAt, "the revoke must be persisted on the purchase_history row")
})
}
Loading
Loading