Skip to content

Commit 18de8ea

Browse files
committed
fix(purchase): gate scheduler/SQS on typed web source constant (follow-up to #1456)
executableByScheduler gated on the bare literal exec.Source == "web", but web-originated executions are persisted with Source = common.PurchaseSourceWeb ("cudly-web"). The literal never matched, so a pending/notified web row on a plan with AutoPurchase=true could be auto-executed by the cron sweep and the SQS execute_purchase path WITHOUT the required token-link human approval -- a fail-open on a money path (CodeRabbit CRITICAL thread on #1456, left unaddressed). Fix: - manager.go: compare against the typed common.PurchaseSourceWeb constant, not a stringly-typed literal (repo enum convention). - manager_test.go / messages_test.go: the prior regression tests set Source: "web" (matching the buggy gate) and relied on AssertNotCalled("GetPurchasePlan"); the mock's default GetPurchasePlan stub does not record the call, so those tests could never detect the bypass. Use the real persisted value and assert the invariant via a GetPurchasePlanFn sentinel: web rows must short-circuit before the AutoPurchase plan fetch. Verified the tests FAIL on the buggy literal and PASS on the fix.
1 parent 5c267c5 commit 18de8ea

3 files changed

Lines changed: 43 additions & 14 deletions

File tree

‎internal/purchase/manager.go‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/LeanerCloud/CUDly/internal/credentials"
1212
"github.com/LeanerCloud/CUDly/internal/email"
1313
"github.com/LeanerCloud/CUDly/internal/oidc"
14+
"github.com/LeanerCloud/CUDly/pkg/common"
1415
"github.com/LeanerCloud/CUDly/pkg/logging"
1516
"github.com/LeanerCloud/CUDly/pkg/provider"
1617
"github.com/aws/aws-sdk-go-v2/aws"
@@ -494,16 +495,21 @@ func (m *Manager) RecoverStrandedApprovals(ctx context.Context) (int, error) {
494495
// an explicit human approval action (fail closed on money paths).
495496
//
496497
// Rules:
497-
// - source="web" rows must wait for the token-link approval path; the
498-
// scheduler and SQS paths must never bypass that gate.
498+
// - web-submitted rows (Source == common.PurchaseSourceWeb, "cudly-web") must
499+
// wait for the token-link approval path; the scheduler and SQS paths must
500+
// never bypass that gate.
499501
// - All other pending/notified rows require the owning plan to have
500502
// AutoPurchase=true. A plan-fetch error is propagated so the caller can
501503
// fail closed rather than defaulting to "execute".
502504
//
503505
// "approved" rows are handled by the session/token approval paths and
504506
// RecoverStrandedApprovals; this helper is only called for pending/notified.
505507
func (m *Manager) executableByScheduler(ctx context.Context, exec *config.PurchaseExecution) (bool, error) {
506-
if exec.Source == "web" {
508+
// Compare against the typed source constant, not a bare "web" literal: the
509+
// persisted value is "cudly-web" (common.PurchaseSourceWeb), so the old
510+
// literal never matched and web rows could be auto-executed without the
511+
// token-link approval when AutoPurchase=true (fail-open on a money path).
512+
if exec.Source == common.PurchaseSourceWeb {
507513
return false, nil
508514
}
509515
plan, err := m.config.GetPurchasePlan(ctx, exec.PlanID)

‎internal/purchase/manager_test.go‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -386,9 +386,12 @@ func TestManager_ProcessScheduledPurchases_PendingAutoFalseSkipped(t *testing.T)
386386
}
387387

388388
// TestManager_ProcessScheduledPurchases_WebSourceSkipped verifies that a
389-
// pending execution with Source="web" is never auto-executed by the cron
390-
// sweep, even when the plan has AutoPurchase=true. Web-submitted rows must
391-
// wait for the token-link approval path.
389+
// pending execution with the canonical web source (common.PurchaseSourceWeb,
390+
// "cudly-web") is never auto-executed by the cron sweep, even when the plan
391+
// has AutoPurchase=true. Web-submitted rows must wait for the token-link
392+
// approval path. Uses the real persisted value, not a bare "web" literal, so
393+
// the test reproduces the production data shape (regression guard for the
394+
// stringly-typed gate that let "cudly-web" rows through).
392395
func TestManager_ProcessScheduledPurchases_WebSourceSkipped(t *testing.T) {
393396
ctx := context.Background()
394397
mockStore := new(MockConfigStore)
@@ -400,15 +403,24 @@ func TestManager_ProcessScheduledPurchases_WebSourceSkipped(t *testing.T) {
400403
ExecutionID: "exec-web",
401404
PlanID: "plan-web",
402405
Status: "pending",
403-
Source: "web",
406+
Source: common.PurchaseSourceWeb,
404407
ScheduledDate: pastDate,
405408
},
406409
}
407410

408411
mockStore.On("GetStaleApprovedExecutions", ctx, mock.Anything).Return([]config.PurchaseExecution{}, nil)
409412
mockStore.On("GetPendingExecutions", ctx).Return(executions, nil)
410-
// executableByScheduler short-circuits on Source="web" — GetPurchasePlan must NOT be called.
411-
// TransitionExecutionStatus must NOT be called.
413+
414+
// Sentinel: web rows must short-circuit in executableByScheduler BEFORE the
415+
// AutoPurchase plan fetch. GetPurchasePlanFn returns AutoPurchase=true (the
416+
// dangerous case: if the gate is bypassed the row would execute), and flips
417+
// planFetched. The mock's default GetPurchasePlan stub does not record the
418+
// call, so AssertNotCalled alone cannot detect the bypass — hence this flag.
419+
planFetched := false
420+
mockStore.GetPurchasePlanFn = func(_ context.Context, planID string) (*config.PurchasePlan, error) {
421+
planFetched = true
422+
return &config.PurchasePlan{ID: planID, AutoPurchase: true}, nil
423+
}
412424

413425
manager := &Manager{
414426
config: mockStore,
@@ -421,9 +433,11 @@ func TestManager_ProcessScheduledPurchases_WebSourceSkipped(t *testing.T) {
421433
assert.Equal(t, 0, result.Processed, "web-sourced rows must not be auto-executed")
422434
assert.Equal(t, 0, result.Executed)
423435
assert.Equal(t, 0, result.Failed)
436+
assert.False(t, planFetched,
437+
"web rows must short-circuit before the AutoPurchase plan fetch; a bare \"web\" "+
438+
"literal gate lets the persisted \"cudly-web\" value through to execution")
424439

425440
mockStore.AssertExpectations(t)
426-
mockStore.AssertNotCalled(t, "GetPurchasePlan", mock.Anything, mock.Anything)
427441
mockStore.AssertNotCalled(t, "TransitionExecutionStatus",
428442
mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
429443
}

‎internal/purchase/messages_test.go‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"testing"
77

88
"github.com/LeanerCloud/CUDly/internal/config"
9+
"github.com/LeanerCloud/CUDly/pkg/common"
910
"github.com/stretchr/testify/assert"
1011
"github.com/stretchr/testify/mock"
1112
)
@@ -118,16 +119,24 @@ func TestManager_ProcessMessage(t *testing.T) {
118119
ExecutionID: "exec-web",
119120
PlanID: "plan-1",
120121
Status: "pending",
121-
Source: "web",
122+
Source: common.PurchaseSourceWeb,
122123
}
123124
mockStore.On("GetExecutionByID", ctx, "exec-web").Return(execution, nil)
124-
// GetPurchasePlan must NOT be called (Source=web short-circuits)
125-
// TransitionExecutionStatus must NOT be called
125+
// Sentinel: web rows must short-circuit BEFORE the AutoPurchase plan
126+
// fetch. Return AutoPurchase=true (the dangerous case) and flag the
127+
// fetch; the default GetPurchasePlan stub doesn't record the call, so
128+
// AssertNotCalled alone can't catch a bypass of the "cudly-web" gate.
129+
planFetched := false
130+
mockStore.GetPurchasePlanFn = func(_ context.Context, planID string) (*config.PurchasePlan, error) {
131+
planFetched = true
132+
return &config.PurchasePlan{ID: planID, AutoPurchase: true}, nil
133+
}
126134

127135
err := manager.ProcessMessage(ctx, `{"type": "execute_purchase", "execution_id": "exec-web"}`)
128136
assert.Error(t, err)
129137
assert.Contains(t, err.Error(), "not eligible for auto-execution")
130-
mockStore.AssertNotCalled(t, "GetPurchasePlan", mock.Anything, mock.Anything)
138+
assert.False(t, planFetched,
139+
"web rows must short-circuit before the AutoPurchase plan fetch on the SQS path")
131140
mockStore.AssertNotCalled(t, "TransitionExecutionStatus",
132141
mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
133142
})

0 commit comments

Comments
 (0)