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
16 changes: 12 additions & 4 deletions pkg/exchange/auto.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,9 @@ func processRecommendation(ctx context.Context, params RunAutoExchangeParams, re
return
}

// A nil PaymentDueUSD documents a zero-cost exchange (no payment due),
// distinct from a parse failure: processAutoExchange fails closed on any
// string that does not parse as a decimal.
paymentDueStr := "0"
if quote.PaymentDueUSD != nil {
paymentDueStr = quote.PaymentDueUSD.FloatString(6)
Expand Down Expand Up @@ -444,12 +447,17 @@ func processAutoExchange(ctx context.Context, params RunAutoExchangeParams, rec
return outcome
}

// paymentDueStr is always a decimal here: processRecommendation sets it to
// "0" when the quote has no PaymentDueUSD (the documented nil-means-zero
// case) and to FloatString(6) otherwise. A parse failure therefore means a
// caller passed garbage; fail closed instead of counting $0 toward the
// daily cap, mirroring the dailySpent branch above.
paymentDue, err := ParseDecimalRat(paymentDueStr)
if err != nil {
logging.Warnf("failed to parse payment due %q: %v, using zero", paymentDueStr, err)
}
if paymentDue == nil {
paymentDue = new(big.Rat)
logging.Errorf("failed to parse payment due %q for %s: %v", paymentDueStr, rec.SourceRIID, err)
outcome.Error = fmt.Sprintf("failed to parse payment due %q: %v", paymentDueStr, err)
saveFailedRecord(ctx, params, rec, offeringID, paymentDueStr, outcome.Error, ExchangeModeAuto)
return outcome
}

newTotal := new(big.Rat).Add(dailySpent, paymentDue)
Expand Down
43 changes: 43 additions & 0 deletions pkg/exchange/auto_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package exchange
import (
"context"
"fmt"
"math/big"
"testing"
"time"

Expand Down Expand Up @@ -112,13 +113,15 @@ type mockExchangeClient struct {
quoteErr error
executeResult string
executeErr error
executeCalls int
}

func (m *mockExchangeClient) GetQuote(_ context.Context, _ ExchangeQuoteRequest) (*ExchangeQuoteSummary, error) {
return m.quoteResult, m.quoteErr
}

func (m *mockExchangeClient) Execute(_ context.Context, _ ExchangeExecuteRequest) (string, *ExchangeQuoteSummary, error) {
m.executeCalls++
return m.executeResult, m.quoteResult, m.executeErr
}

Expand Down Expand Up @@ -315,6 +318,46 @@ func TestRunAutoExchange_AutoMode_DailySpendDBError_FailsClosed(t *testing.T) {
assert.Contains(t, result.Failed[0].Error, "daily cap check failed")
}

// TestProcessAutoExchange_UnparseablePaymentDue_FailsClosed is the regression
// test for COR-04: an unparseable or empty paymentDueStr must abort the
// exchange and persist a failed record, never count as $0 toward the
// MaxPaymentDailyUSD cap. RunAutoExchange always builds a parseable string
// today, so processAutoExchange is exercised directly with the bad inputs.
func TestProcessAutoExchange_UnparseablePaymentDue_FailsClosed(t *testing.T) {
t.Parallel()
for _, paymentDueStr := range []string{"not-a-number", ""} {
t.Run(fmt.Sprintf("paymentDue=%q", paymentDueStr), func(t *testing.T) {
t.Parallel()
store := &mockExchangeStore{dailySpend: "0"}
client := &mockExchangeClient{executeResult: "exch-should-not-happen"}
params := defaultParams(store, client)
params.Config.Mode = "auto"

rec := ReshapeRecommendation{
SourceRIID: "ri-001",
SourceInstanceType: "m5.xlarge",
TargetInstanceType: "m5.large",
SourceCount: 1,
TargetCount: 2,
UtilizationPercent: 50.0,
}
perExchangeCap := new(big.Rat).SetFloat64(params.Config.MaxPaymentPerExchangeUSD)

outcome := processAutoExchange(context.Background(), params, rec, "offering-123", paymentDueStr, perExchangeCap)

assert.Contains(t, outcome.Error, "failed to parse payment due")
assert.Empty(t, outcome.ExchangeID)
assert.Zero(t, client.executeCalls, "exchange must not execute on unparseable payment due")

// A failed record must be persisted for the audit trail,
// mirroring the dailySpent parse-failure branch.
require.Len(t, store.savedRecords, 1)
assert.Equal(t, "failed", store.savedRecords[0].Status)
assert.Contains(t, store.savedRecords[0].Error, "failed to parse payment due")
})
}
}

func TestRunAutoExchange_AutoMode_ExecutionFails(t *testing.T) {
t.Parallel()
store := &mockExchangeStore{dailySpend: "0"}
Expand Down
Loading