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
4 changes: 4 additions & 0 deletions internal/analytics/collector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,10 @@ func (m *mockConfigStore) SaveRIExchangeRecord(ctx context.Context, record *conf
func (m *mockConfigStore) ReserveRIExchange(ctx context.Context, record *config.RIExchangeRecord, dailyCapUSD, perExchangeCapUSD string) (string, error) {
return "", errors.New("RI exchange reservation not configured in analytics test store")
}

func (m *mockConfigStore) ReserveApprovedRIExchange(ctx context.Context, id, dailyCapUSD, perExchangeCapUSD string) (string, error) {
return "", errors.New("approved RI exchange reservation not configured in test store")
}
func (m *mockConfigStore) GetRIExchangeRecord(ctx context.Context, id string) (*config.RIExchangeRecord, error) {
return nil, nil
}
Expand Down
46 changes: 1 addition & 45 deletions internal/api/exchange_helpers_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package api

// exchange_helpers_test.go — tests for checkDailyCap and federation IaC helpers.
// exchange_helpers_test.go — tests for federation IaC helpers.

import (
"context"
Expand All @@ -15,50 +15,6 @@ import (
// checkDailyCap
// ---------------------------------------------------------------------------

func TestCheckDailyCap_WithinCap(t *testing.T) {
// $100 daily spend + $50 payment = $150, cap is $200 → allowed
reason := checkDailyCap("100.00", "50.00", 200.0)
assert.Equal(t, "", reason, "expected no reason when within cap")
}

func TestCheckDailyCap_ExceedsCap(t *testing.T) {
// $150 spent + $100 payment = $250 > $200 cap → blocked
reason := checkDailyCap("150.00", "100.00", 200.0)
assert.NotEmpty(t, reason)
assert.Contains(t, reason, "daily cap exceeded")
}

func TestCheckDailyCap_InvalidDailySpend(t *testing.T) {
// Unparseable daily spend → fail-safe block
reason := checkDailyCap("not-a-number", "50.00", 200.0)
assert.NotEmpty(t, reason)
assert.Contains(t, reason, "daily spend check failed")
}

func TestCheckDailyCap_InvalidPaymentDue(t *testing.T) {
// H1 fix: an unparseable payment-due string must fail closed (return a
// blocking reason) instead of being treated as $0. Proceeding as $0 would
// allow an exchange of unknown cost through the daily cap check.
reason := checkDailyCap("100.00", "not-a-number", 500.0)
assert.NotEmpty(t, reason, "unparseable payment due must block the exchange (fail closed)")
assert.Contains(t, reason, "could not parse payment due")
}

func TestCheckDailyCap_ExactlyAtCap(t *testing.T) {
// $100 spent + $100 payment = $200 == $200 cap → allowed (not strictly greater)
reason := checkDailyCap("100.00", "100.00", 200.0)
assert.Equal(t, "", reason)
}

func TestCheckDailyCap_ZeroSpend(t *testing.T) {
reason := checkDailyCap("0.00", "50.00", 200.0)
assert.Equal(t, "", reason)
}

// ---------------------------------------------------------------------------
// getFederationIaC — accessible validation paths (no DB required)
// ---------------------------------------------------------------------------

func TestHandler_getFederationIaC_MissingTarget(t *testing.T) {
ctx := context.Background()
h := federationHandler()
Expand Down
98 changes: 28 additions & 70 deletions internal/api/handler_ri_exchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -2593,17 +2593,6 @@ func (h *Handler) retryCompleteWithPayment(ctx context.Context, id, exchangeID,
return err
}

// handlerChooseEffectiveCap returns the smaller of perExchangeCap and daily
// headroom (dailyCap - dailySpent), bounding Execute's MaxPaymentDueUSD so a
// fresh re-quote cannot exceed the remaining daily budget (H2 fix).
func handlerChooseEffectiveCap(dailyCap, dailySpent, perExchangeCap *big.Rat) *big.Rat {
remaining := new(big.Rat).Sub(dailyCap, dailySpent)
if remaining.Cmp(perExchangeCap) < 0 {
return remaining
}
return perExchangeCap
}

// handlerAcceptedAmount extracts the confirmed payment amount from a fresh
// Execute quote, falling back to fallback when freshQ is nil or empty (H3 fix).
//
Expand All @@ -2620,39 +2609,26 @@ func handlerAcceptedAmount(freshQ *exchange.ExchangeQuoteSummary, fallback strin
return fallback
}

// checkCapsAndComputeHeadroom validates the spending-cap configuration, runs the
// daily-cap check, and computes the effective MaxPaymentDueUSD that Execute must
// not exceed (H2: remaining daily headroom vs per-exchange cap, whichever is
// smaller). Returns a non-empty reason string on any failure so the caller can
// forward it to failExchange.
func checkCapsAndComputeHeadroom(dailySpendStr, paymentDue string, cfg *config.GlobalConfig) (effectiveCap *big.Rat, reason string) {
// exchangeCapStrings validates the spending-cap configuration and returns the
// daily and per-exchange caps as decimal strings for the atomic reservation.
// A non-empty reason means the caller must forward it to failExchange.
func exchangeCapStrings(cfg *config.GlobalConfig) (daily, perExchange, reason string) {
if cfg.RIExchangeMaxDailyUSD == 0 {
return nil, "daily spending cap is not configured (RIExchangeMaxDailyUSD is 0)"
}
if reason := checkDailyCap(dailySpendStr, paymentDue, cfg.RIExchangeMaxDailyUSD); reason != "" {
return nil, reason
return "", "", "daily spending cap is not configured (RIExchangeMaxDailyUSD is 0)"
}
if cfg.RIExchangeMaxPerExchangeUSD == 0 {
return nil, "per-exchange spending cap is not configured (RIExchangeMaxPerExchangeUSD is 0)"
}
// checkDailyCap already verified dailySpendStr is parseable; a second failure
// is an internal error - fail closed to avoid executing with wrong headroom.
dailySpent, err := exchange.ParseDecimalRat(dailySpendStr)
if err != nil || dailySpent == nil {
return nil, fmt.Sprintf("daily spend re-parse failed (internal error): %v", err)
return "", "", "per-exchange spending cap is not configured (RIExchangeMaxPerExchangeUSD is 0)"
}
dailyCap := new(big.Rat).SetFloat64(cfg.RIExchangeMaxDailyUSD)
perExchangeCap := new(big.Rat).SetFloat64(cfg.RIExchangeMaxPerExchangeUSD)
return handlerChooseEffectiveCap(dailyCap, dailySpent, perExchangeCap), ""
daily = new(big.Rat).SetFloat64(cfg.RIExchangeMaxDailyUSD).FloatString(6)
perExchange = new(big.Rat).SetFloat64(cfg.RIExchangeMaxPerExchangeUSD).FloatString(6)
return daily, perExchange, ""
}

// executeApprovedExchange checks caps and executes the exchange after approval.
// executeApprovedExchange reserves the execution ceiling atomically under the
// shared daily-cap lock, then executes the exchange within that ceiling. The
// ceiling is the full permitted amount (min of per-exchange cap and daily
// headroom), so a fresh re-quote cannot push the day over the cap.
func (h *Handler) executeApprovedExchange(ctx context.Context, id string, record *config.RIExchangeRecord) (any, error) {
dailySpendStr, err := h.config.GetRIExchangeDailySpend(ctx, time.Now())
if err != nil {
return h.failExchange(ctx, id, "daily spending cap check failed")
}

globalCfg, err := h.config.GetGlobalConfig(ctx)
if err != nil {
return h.failExchange(ctx, id, "config load failed")
Expand All @@ -2666,10 +2642,22 @@ func (h *Handler) executeApprovedExchange(ctx context.Context, id string, record
return h.failExchange(ctx, id, "exchange record has no region; cannot execute safely")
}

effectiveCap, reason := checkCapsAndComputeHeadroom(dailySpendStr, record.PaymentDue, globalCfg)
dailyCapUSD, perExchangeCapUSD, reason := exchangeCapStrings(globalCfg)
if reason != "" {
return h.failExchange(ctx, id, reason)
}
ceiling, err := h.config.ReserveApprovedRIExchange(ctx, id, dailyCapUSD, perExchangeCapUSD)
if err != nil {
logging.Warnf("RI exchange %s reservation refused: %v", id, err)
if errors.Is(err, config.ErrRIExchangeDailyCapExceeded) {
return h.failExchange(ctx, id, err.Error())
}
return h.failExchange(ctx, id, "daily spending cap check failed")
}
effectiveCap, ok := new(big.Rat).SetString(ceiling)
if !ok {
return h.failExchange(ctx, id, fmt.Sprintf("reserved ceiling %q is not a decimal", ceiling))
}

execFn := exchange.ExecuteExchange
if h.executeExchangeFn != nil {
Expand All @@ -2686,9 +2674,8 @@ func (h *Handler) executeApprovedExchange(ctx context.Context, id string, record
return h.failExchange(ctx, id, execErr.Error())
}

// H3: persist the amount AWS actually accepted, not the stale pre-execution
// quote stored in record.PaymentDue.
acceptedPaymentDue := handlerAcceptedAmount(freshQ, record.PaymentDue)
// H3: persist the amount AWS actually accepted, not the reserved ceiling.
acceptedPaymentDue := handlerAcceptedAmount(freshQ, ceiling)

// H4: retry the ledger write via retryCompleteWithPayment; persistent
// failure is returned as an error (HTTP 500) so the caller knows money
Expand All @@ -2703,35 +2690,6 @@ func (h *Handler) executeApprovedExchange(ctx context.Context, id string, record
return map[string]any{"status": "completed", "exchange_id": exchangeID}, nil
}

// checkDailyCap verifies the exchange payment won't exceed the daily spending cap.
// Returns an empty string if within cap, or a reason string if exceeded or if
// either input cannot be parsed (fail closed on parse errors).
func checkDailyCap(dailySpendStr, paymentDueStr string, maxDailyUSD float64) string {
dailyCap := new(big.Rat).SetFloat64(maxDailyUSD)
dailySpent, err := exchange.ParseDecimalRat(dailySpendStr)
if err != nil || dailySpent == nil {
// A parse failure means we cannot determine today's spend; treat as a cap
// check failure to avoid under-counting spend (fail-safe).
logging.Warnf("checkDailyCap: failed to parse daily spend string %q: %v; blocking exchange to avoid exceeding cap", dailySpendStr, err)
return fmt.Sprintf("daily spend check failed: could not parse today's spend value %q", dailySpendStr)
}
paymentDue, err := exchange.ParseDecimalRat(paymentDueStr)
if err != nil || paymentDue == nil {
// H1 fix: fail closed on an unparseable payment-due string instead of
// treating it as $0. An unparseable value means we cannot determine the
// true cost of this exchange, so proceeding risks exceeding the cap.
logging.Warnf("checkDailyCap: failed to parse payment due string %q: %v; blocking exchange to avoid cap bypass", paymentDueStr, err)
return fmt.Sprintf("daily spend check failed: could not parse payment due value %q", paymentDueStr)
}

newTotal := new(big.Rat).Add(dailySpent, paymentDue)
if newTotal.Cmp(dailyCap) > 0 {
return fmt.Sprintf("daily cap exceeded: spent $%s + payment $%s > cap $%.2f",
dailySpent.FloatString(2), paymentDue.FloatString(2), maxDailyUSD)
}
return ""
}

// rejectRIExchange handles rejection of a pending RI exchange via token.
func (h *Handler) rejectRIExchange(ctx context.Context, id, token string) (any, error) {
if err := validateUUID(id); err != nil {
Expand Down
Loading
Loading