Skip to content

Commit d78f295

Browse files
committed
fix(exchange): enforce daily-cap integrity on manual path, actual-paid ledger, and headroom (adversarial-review follow-ups)
H1: checkDailyCap fails closed on unparseable paymentDue (was treating as $0). H2: executeApprovedExchange and processAutoExchange bound Execute MaxPaymentDueUSD by min(perExchangeCap, dailyCap-dailySpent) so re-quotes cannot breach headroom. H3: Add CompleteRIExchangeWithPayment to persist the accepted (not pre-execution) amount; both auto and manual paths use the fresh quote from Execute. H4: Retry ledger write 3x after money moves; halt auto run / return 500 on persistent failure to prevent cap bypass via missing ledger rows. M5: GetRIExchangeDailySpend includes processing rows (COALESCE on completed_at/ updated_at) to close concurrent-approval TOCTOU window. Each defect has a regression test that fails pre-fix and passes post-fix.
1 parent 8f9347e commit d78f295

13 files changed

Lines changed: 773 additions & 47 deletions

‎internal/analytics/collector_test.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,9 @@ func (m *mockConfigStore) TransitionRIExchangeStatus(ctx context.Context, id str
313313
func (m *mockConfigStore) CompleteRIExchange(ctx context.Context, id string, exchangeID string) error {
314314
return nil
315315
}
316+
func (m *mockConfigStore) CompleteRIExchangeWithPayment(_ context.Context, _, _, _ string) error {
317+
return nil
318+
}
316319
func (m *mockConfigStore) FailRIExchange(ctx context.Context, id string, errorMsg string) error {
317320
return nil
318321
}

‎internal/api/exchange_helpers_test.go‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@ func TestCheckDailyCap_InvalidDailySpend(t *testing.T) {
3636
}
3737

3838
func TestCheckDailyCap_InvalidPaymentDue(t *testing.T) {
39-
// Unparseable payment due → treated as $0, within cap
39+
// H1 fix: an unparseable payment-due string must fail closed (return a
40+
// blocking reason) instead of being treated as $0. Proceeding as $0 would
41+
// allow an exchange of unknown cost through the daily cap check.
4042
reason := checkDailyCap("100.00", "not-a-number", 500.0)
41-
// $100 + $0 = $100 < $500 → allowed
42-
assert.Equal(t, "", reason)
43+
assert.NotEmpty(t, reason, "unparseable payment due must block the exchange (fail closed)")
44+
assert.Contains(t, reason, "could not parse payment due")
4345
}
4446

4547
func TestCheckDailyCap_ExactlyAtCap(t *testing.T) {

‎internal/api/handler.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/LeanerCloud/CUDly/internal/email"
1717
"github.com/LeanerCloud/CUDly/internal/oidc"
1818
"github.com/LeanerCloud/CUDly/internal/runtime"
19+
"github.com/LeanerCloud/CUDly/pkg/exchange"
1920
"github.com/LeanerCloud/CUDly/pkg/logging"
2021
"github.com/aws/aws-lambda-go/events"
2122
"github.com/aws/aws-sdk-go-v2/aws"
@@ -120,6 +121,12 @@ type Handler struct {
120121
// validation no-ops, deferring to the frontend's hardcoded rules.
121122
commitmentOpts CommitmentOptsInterface
122123

124+
// executeExchangeFn is the RI exchange execution function injected by tests.
125+
// When nil (the production default), executeApprovedExchange calls
126+
// exchange.ExecuteExchange directly. Tests inject a stub so the handler
127+
// can be exercised end-to-end without live AWS credentials or a real RI.
128+
executeExchangeFn func(ctx context.Context, req exchange.ExchangeExecuteRequest) (string, *exchange.ExchangeQuoteSummary, error)
129+
123130
// encryptionKeySource is the env var name that resolved the credential
124131
// encryption key. Empty when no credStore is configured. Used by the
125132
// /health endpoint only — never logged outside that one place.

‎internal/api/handler_ri_exchange.go‎

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1268,6 +1268,52 @@ func (h *Handler) failExchange(ctx context.Context, id, reason string) (any, err
12681268
return map[string]any{"status": "failed", "reason": reason}, nil
12691269
}
12701270

1271+
// retryCompleteWithPayment calls CompleteRIExchangeWithPayment up to
1272+
// maxLedgerAttempts times, logging retries. Returns a non-nil error if all
1273+
// attempts fail. Extracted from executeApprovedExchange to reduce its
1274+
// cyclomatic complexity (H4 fix).
1275+
func (h *Handler) retryCompleteWithPayment(ctx context.Context, id, exchangeID, acceptedPaymentDue string) error {
1276+
const maxAttempts = 3
1277+
var err error
1278+
for attempt := 1; attempt <= maxAttempts; attempt++ {
1279+
err = h.config.CompleteRIExchangeWithPayment(ctx, id, exchangeID, acceptedPaymentDue)
1280+
if err == nil {
1281+
return nil
1282+
}
1283+
if attempt < maxAttempts {
1284+
logging.Warnf("ledger write retry %d/%d for exchange %s after money moved: %v",
1285+
attempt, maxAttempts, id, err)
1286+
}
1287+
}
1288+
return err
1289+
}
1290+
1291+
// handlerChooseEffectiveCap returns the smaller of perExchangeCap and daily
1292+
// headroom (dailyCap - dailySpent), bounding Execute's MaxPaymentDueUSD so a
1293+
// fresh re-quote cannot exceed the remaining daily budget (H2 fix).
1294+
func handlerChooseEffectiveCap(dailyCap, dailySpent, perExchangeCap *big.Rat) *big.Rat {
1295+
remaining := new(big.Rat).Sub(dailyCap, dailySpent)
1296+
if remaining.Cmp(perExchangeCap) < 0 {
1297+
return remaining
1298+
}
1299+
return perExchangeCap
1300+
}
1301+
1302+
// handlerAcceptedAmount extracts the confirmed payment amount from a fresh
1303+
// Execute quote, falling back to fallback when freshQ is nil or empty (H3 fix).
1304+
func handlerAcceptedAmount(freshQ *exchange.ExchangeQuoteSummary, fallback string) string {
1305+
if freshQ == nil {
1306+
return fallback
1307+
}
1308+
if freshQ.PaymentDueUSDStr != "" {
1309+
return freshQ.PaymentDueUSDStr
1310+
}
1311+
// Zero-cost exchange: PaymentDueRaw was empty (AWS returned nil) so
1312+
// PaymentDueUSDStr is also empty. Use "0" to avoid a NULL payment_due in
1313+
// the DB that would silently distort GetRIExchangeDailySpend's SUM.
1314+
return "0"
1315+
}
1316+
12711317
// executeApprovedExchange checks caps and executes the exchange after approval.
12721318
func (h *Handler) executeApprovedExchange(ctx context.Context, id string, record *config.RIExchangeRecord) (any, error) {
12731319
dailySpendStr, err := h.config.GetRIExchangeDailySpend(ctx, time.Now())
@@ -1299,27 +1345,49 @@ func (h *Handler) executeApprovedExchange(ctx context.Context, id string, record
12991345
return h.failExchange(ctx, id, "per-exchange spending cap is not configured (RIExchangeMaxPerExchangeUSD is 0)")
13001346
}
13011347

1348+
// H2: bound Execute's MaxPaymentDueUSD by remaining daily headroom so a
1349+
// fresh re-quote inside Execute cannot accept an amount that would breach
1350+
// the daily cap. dailySpend was verified parseable by checkDailyCap above.
1351+
dailySpent, _ := exchange.ParseDecimalRat(dailySpendStr)
1352+
dailyCap := new(big.Rat).SetFloat64(globalCfg.RIExchangeMaxDailyUSD)
13021353
perExchangeCap := new(big.Rat).SetFloat64(globalCfg.RIExchangeMaxPerExchangeUSD)
1303-
exchangeID, _, execErr := exchange.ExecuteExchange(ctx, exchange.ExchangeExecuteRequest{
1354+
effectiveCap := handlerChooseEffectiveCap(dailyCap, dailySpent, perExchangeCap)
1355+
1356+
execFn := exchange.ExecuteExchange
1357+
if h.executeExchangeFn != nil {
1358+
execFn = h.executeExchangeFn
1359+
}
1360+
exchangeID, freshQ, execErr := execFn(ctx, exchange.ExchangeExecuteRequest{
13041361
Region: region,
13051362
ReservedIDs: record.SourceRIIDs,
13061363
TargetOfferingID: record.TargetOfferingID,
13071364
TargetCount: int32(record.TargetCount), // #nosec G115 -- RI quantity stored from validated API request; AWS limits RI counts well below math.MaxInt32
1308-
MaxPaymentDueUSD: perExchangeCap,
1365+
MaxPaymentDueUSD: effectiveCap,
13091366
})
13101367
if execErr != nil {
13111368
return h.failExchange(ctx, id, execErr.Error())
13121369
}
13131370

1314-
if err := h.config.CompleteRIExchange(ctx, id, exchangeID); err != nil {
1315-
logging.Errorf("failed to mark exchange %s as completed: %v", id, err)
1371+
// H3: persist the amount AWS actually accepted, not the stale pre-execution
1372+
// quote stored in record.PaymentDue.
1373+
acceptedPaymentDue := handlerAcceptedAmount(freshQ, record.PaymentDue)
1374+
1375+
// H4: retry the ledger write via retryCompleteWithPayment; persistent
1376+
// failure is returned as an error (HTTP 500) so the caller knows money
1377+
// moved but the record was not updated.
1378+
if completeErr := h.retryCompleteWithPayment(ctx, id, exchangeID, acceptedPaymentDue); completeErr != nil {
1379+
logging.Errorf("all ledger write attempts failed for exchange %s after money moved: %v",
1380+
id, completeErr)
1381+
return nil, fmt.Errorf("exchange executed (id=%s) but ledger update failed: %w",
1382+
exchangeID, completeErr)
13161383
}
13171384

13181385
return map[string]any{"status": "completed", "exchange_id": exchangeID}, nil
13191386
}
13201387

13211388
// checkDailyCap verifies the exchange payment won't exceed the daily spending cap.
1322-
// Returns an empty string if within cap, or a reason string if exceeded.
1389+
// Returns an empty string if within cap, or a reason string if exceeded or if
1390+
// either input cannot be parsed (fail closed on parse errors).
13231391
func checkDailyCap(dailySpendStr, paymentDueStr string, maxDailyUSD float64) string {
13241392
dailyCap := new(big.Rat).SetFloat64(maxDailyUSD)
13251393
dailySpent, err := exchange.ParseDecimalRat(dailySpendStr)
@@ -1331,8 +1399,11 @@ func checkDailyCap(dailySpendStr, paymentDueStr string, maxDailyUSD float64) str
13311399
}
13321400
paymentDue, err := exchange.ParseDecimalRat(paymentDueStr)
13331401
if err != nil || paymentDue == nil {
1334-
logging.Warnf("checkDailyCap: failed to parse payment due string %q: %v; treating as $0", paymentDueStr, err)
1335-
paymentDue = new(big.Rat)
1402+
// H1 fix: fail closed on an unparseable payment-due string instead of
1403+
// treating it as $0. An unparseable value means we cannot determine the
1404+
// true cost of this exchange, so proceeding risks exceeding the cap.
1405+
logging.Warnf("checkDailyCap: failed to parse payment due string %q: %v; blocking exchange to avoid cap bypass", paymentDueStr, err)
1406+
return fmt.Sprintf("daily spend check failed: could not parse payment due value %q", paymentDueStr)
13361407
}
13371408

13381409
newTotal := new(big.Rat).Add(dailySpent, paymentDue)

‎internal/api/handler_ri_exchange_test.go‎

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ package api
33
import (
44
"context"
55
"fmt"
6+
"math/big"
67
"testing"
78
"time"
89

910
"github.com/LeanerCloud/CUDly/internal/auth"
1011
"github.com/LeanerCloud/CUDly/internal/config"
12+
"github.com/LeanerCloud/CUDly/pkg/exchange"
1113
"github.com/LeanerCloud/CUDly/providers/aws/recommendations"
1214
ec2svc "github.com/LeanerCloud/CUDly/providers/aws/services/ec2"
1315
azurecompute "github.com/LeanerCloud/CUDly/providers/azure/services/compute"
@@ -1582,3 +1584,190 @@ func TestRejectRIExchange_TokenPathActorIsNil(t *testing.T) {
15821584
_, err := (&Handler{config: mockStore}).rejectRIExchange(ctx, id, "tok")
15831585
require.NoError(t, err)
15841586
}
1587+
1588+
// ─── H1: checkDailyCap fails closed on unparseable paymentDue ─────────────────
1589+
1590+
// TestCheckDailyCap_UnparseablePaymentDue_FailsClosed is the regression test
1591+
// for H1: checkDailyCap must return a blocking reason when paymentDueStr cannot
1592+
// be parsed, never treat it as $0 (which would allow an unknown-cost exchange
1593+
// to proceed when the daily cap might be exceeded).
1594+
func TestCheckDailyCap_UnparseablePaymentDue_FailsClosed(t *testing.T) {
1595+
t.Parallel()
1596+
cases := []struct {
1597+
name string
1598+
paymentDueStr string
1599+
}{
1600+
{"not-a-number", "not-a-number"},
1601+
{"empty string", ""},
1602+
}
1603+
for _, tc := range cases {
1604+
tc := tc
1605+
t.Run(tc.name, func(t *testing.T) {
1606+
t.Parallel()
1607+
reason := checkDailyCap("100.00", tc.paymentDueStr, 500.0)
1608+
assert.NotEmpty(t, reason,
1609+
"checkDailyCap must fail closed when paymentDueStr=%q cannot be parsed", tc.paymentDueStr)
1610+
assert.Contains(t, reason, "could not parse payment due",
1611+
"reason must reference the payment-due parsing failure, not treat it as $0")
1612+
})
1613+
}
1614+
}
1615+
1616+
// TestCheckDailyCap_ValidPaymentDue_WithinCap_Passes verifies the happy path:
1617+
// valid parseable amounts within the daily cap must return an empty reason.
1618+
func TestCheckDailyCap_ValidPaymentDue_WithinCap_Passes(t *testing.T) {
1619+
t.Parallel()
1620+
reason := checkDailyCap("100.00", "50.00", 500.0)
1621+
assert.Empty(t, reason, "within-cap exchange must not be blocked")
1622+
}
1623+
1624+
// ─── H2: effectiveCap bounded by daily headroom ───────────────────────────────
1625+
1626+
// TestExecuteApprovedExchange_EffectiveCap_BoundedByDailyHeadroom is the
1627+
// regression test for H2: when dailyCap-dailySpent < perExchangeCap, the cap
1628+
// passed to Execute (MaxPaymentDueUSD) must be the daily headroom, not the full
1629+
// perExchangeCap. This prevents a fresh re-quote from accepting an amount that
1630+
// exceeds the daily budget.
1631+
func TestExecuteApprovedExchange_EffectiveCap_BoundedByDailyHeadroom(t *testing.T) {
1632+
ctx := context.Background()
1633+
const id = "550e8400-e29b-41d4-a716-000000000012"
1634+
1635+
mockStore := new(MockConfigStore)
1636+
t.Cleanup(func() { mockStore.AssertExpectations(t) })
1637+
1638+
// dailySpent=$450, dailyCap=$500, perExchangeCap=$100 -> headroom=$50
1639+
mockStore.On("GetRIExchangeDailySpend", mock.Anything, mock.Anything).Return("450.00", nil)
1640+
mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{
1641+
RIExchangeMaxDailyUSD: 500,
1642+
RIExchangeMaxPerExchangeUSD: 100,
1643+
}, nil)
1644+
1645+
var capturedReq exchange.ExchangeExecuteRequest
1646+
h := &Handler{
1647+
config: mockStore,
1648+
executeExchangeFn: func(_ context.Context, req exchange.ExchangeExecuteRequest) (string, *exchange.ExchangeQuoteSummary, error) {
1649+
capturedReq = req
1650+
return "exch-h2-handler", &exchange.ExchangeQuoteSummary{PaymentDueUSDStr: "40.000000"}, nil
1651+
},
1652+
}
1653+
1654+
mockStore.On("CompleteRIExchangeWithPayment", ctx, id, "exch-h2-handler", "40.000000").Return(nil)
1655+
1656+
record := &config.RIExchangeRecord{
1657+
ID: id,
1658+
Region: "us-east-1",
1659+
SourceRIIDs: []string{"ri-1"},
1660+
TargetOfferingID: "offering-1",
1661+
TargetCount: 1,
1662+
PaymentDue: "40.00",
1663+
}
1664+
1665+
_, err := h.executeApprovedExchange(ctx, id, record)
1666+
require.NoError(t, err)
1667+
1668+
// MaxPaymentDueUSD must equal headroom ($50), not perExchangeCap ($100).
1669+
require.NotNil(t, capturedReq.MaxPaymentDueUSD,
1670+
"MaxPaymentDueUSD must be set on the Execute request")
1671+
expectedCap := new(big.Rat).SetFrac64(50, 1)
1672+
assert.Equal(t, 0, capturedReq.MaxPaymentDueUSD.Cmp(expectedCap),
1673+
"MaxPaymentDueUSD must be daily headroom ($50), got $%s",
1674+
capturedReq.MaxPaymentDueUSD.FloatString(2))
1675+
}
1676+
1677+
// ─── H3: ledger records fresh accepted amount ─────────────────────────────────
1678+
1679+
// TestExecuteApprovedExchange_AcceptedAmountFromFreshQuote is the regression
1680+
// test for H3: CompleteRIExchangeWithPayment must be called with the amount
1681+
// AWS confirmed during Execute (freshQ.PaymentDueUSDStr), not the stale
1682+
// record.PaymentDue stored from the pre-execution quote.
1683+
func TestExecuteApprovedExchange_AcceptedAmountFromFreshQuote(t *testing.T) {
1684+
ctx := context.Background()
1685+
const id = "550e8400-e29b-41d4-a716-000000000013"
1686+
1687+
mockStore := new(MockConfigStore)
1688+
t.Cleanup(func() { mockStore.AssertExpectations(t) })
1689+
1690+
mockStore.On("GetRIExchangeDailySpend", mock.Anything, mock.Anything).Return("0", nil)
1691+
mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{
1692+
RIExchangeMaxDailyUSD: 1000,
1693+
RIExchangeMaxPerExchangeUSD: 500,
1694+
}, nil)
1695+
1696+
// Execute returns a fresh quote ($35.50) different from pre-execution ($30.00).
1697+
h := &Handler{
1698+
config: mockStore,
1699+
executeExchangeFn: func(_ context.Context, _ exchange.ExchangeExecuteRequest) (string, *exchange.ExchangeQuoteSummary, error) {
1700+
return "exch-h3-fresh", &exchange.ExchangeQuoteSummary{
1701+
PaymentDueUSDStr: "35.500000",
1702+
}, nil
1703+
},
1704+
}
1705+
1706+
// The FRESH amount "35.500000" must reach CompleteRIExchangeWithPayment,
1707+
// not the stale "30.00" from record.PaymentDue.
1708+
mockStore.On("CompleteRIExchangeWithPayment", ctx, id, "exch-h3-fresh", "35.500000").Return(nil)
1709+
1710+
record := &config.RIExchangeRecord{
1711+
ID: id,
1712+
Region: "us-east-1",
1713+
SourceRIIDs: []string{"ri-1"},
1714+
TargetOfferingID: "offering-1",
1715+
TargetCount: 1,
1716+
PaymentDue: "30.00", // stale pre-execution quote
1717+
}
1718+
1719+
resp, err := h.executeApprovedExchange(ctx, id, record)
1720+
require.NoError(t, err)
1721+
respMap, ok := resp.(map[string]any)
1722+
require.True(t, ok)
1723+
assert.Equal(t, "completed", respMap["status"])
1724+
}
1725+
1726+
// ─── H4: persistent ledger failure returns error ──────────────────────────────
1727+
1728+
// TestExecuteApprovedExchange_LedgerWriteFailure_ReturnsError is the regression
1729+
// test for H4: when CompleteRIExchangeWithPayment fails persistently (all retry
1730+
// attempts exhausted) after money has already moved, executeApprovedExchange must
1731+
// return an error rather than silently logging and returning a success response.
1732+
// The caller (approveRIExchange) must see this as an error so it can return
1733+
// HTTP 500 and the operator knows the ledger is inconsistent.
1734+
func TestExecuteApprovedExchange_LedgerWriteFailure_ReturnsError(t *testing.T) {
1735+
ctx := context.Background()
1736+
const id = "550e8400-e29b-41d4-a716-000000000014"
1737+
1738+
mockStore := new(MockConfigStore)
1739+
t.Cleanup(func() { mockStore.AssertExpectations(t) })
1740+
1741+
mockStore.On("GetRIExchangeDailySpend", mock.Anything, mock.Anything).Return("0", nil)
1742+
mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{
1743+
RIExchangeMaxDailyUSD: 1000,
1744+
RIExchangeMaxPerExchangeUSD: 500,
1745+
}, nil)
1746+
1747+
h := &Handler{
1748+
config: mockStore,
1749+
executeExchangeFn: func(_ context.Context, _ exchange.ExchangeExecuteRequest) (string, *exchange.ExchangeQuoteSummary, error) {
1750+
return "exch-h4-test", &exchange.ExchangeQuoteSummary{PaymentDueUSDStr: "0"}, nil
1751+
},
1752+
}
1753+
1754+
// Fail all three retry attempts.
1755+
mockStore.On("CompleteRIExchangeWithPayment", ctx, id, "exch-h4-test", "0").
1756+
Return(fmt.Errorf("DB write failed")).Times(3)
1757+
1758+
record := &config.RIExchangeRecord{
1759+
ID: id,
1760+
Region: "us-east-1",
1761+
SourceRIIDs: []string{"ri-1"},
1762+
TargetOfferingID: "offering-1",
1763+
TargetCount: 1,
1764+
PaymentDue: "0",
1765+
}
1766+
1767+
_, err := h.executeApprovedExchange(ctx, id, record)
1768+
require.Error(t, err, "persistent ledger failure must propagate as a non-nil error")
1769+
assert.Contains(t, err.Error(), "ledger update failed",
1770+
"error must describe the ledger failure for operator triage")
1771+
assert.Contains(t, err.Error(), "exch-h4-test",
1772+
"error must include the exchange ID so operators can correlate with AWS")
1773+
}

‎internal/config/interfaces.go‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,9 +246,15 @@ type StoreInterface interface {
246246
// actor is the UUID of the user performing the transition (nil for system-initiated paths).
247247
TransitionRIExchangeStatus(ctx context.Context, id string, fromStatus string, toStatus string, actor *string) (*RIExchangeRecord, error)
248248
CompleteRIExchange(ctx context.Context, id string, exchangeID string) error
249+
// CompleteRIExchangeWithPayment marks an RI exchange as completed and
250+
// updates payment_due to the amount AWS actually accepted. Use this instead
251+
// of CompleteRIExchange on the manual-approval path so the daily-spend
252+
// ledger (GetRIExchangeDailySpend) reflects the real accepted amount rather
253+
// than the stale pre-execution quote (H3 fix).
254+
CompleteRIExchangeWithPayment(ctx context.Context, id string, exchangeID string, acceptedPaymentDue string) error
249255
// StampRIExchangeApprovedBy sets the approved_by column on a completed
250-
// exchange row (issue #300). Called after CompleteRIExchange when the
251-
// approval came from a session-authed user rather than an email token.
256+
// exchange row (issue #300). Called after CompleteRIExchangeWithPayment when
257+
// the approval came from a session-authed user rather than an email token.
252258
StampRIExchangeApprovedBy(ctx context.Context, id string, approverEmail string) error
253259
FailRIExchange(ctx context.Context, id string, errorMsg string) error
254260
GetRIExchangeDailySpend(ctx context.Context, date time.Time) (string, error)

0 commit comments

Comments
 (0)