diff --git a/internal/purchase/execution.go b/internal/purchase/execution.go index 8d45c7458..5106db205 100644 --- a/internal/purchase/execution.go +++ b/internal/purchase/execution.go @@ -363,7 +363,7 @@ func (m *Manager) executeForAccountLate(ctx context.Context, baseExec *config.Pu if err != nil { acctExec.Status = "failed" acctExec.Error = err.Error() - credErr := fmt.Errorf("credential resolution failed for account %s: %w", account.ID, err) + credErr := beforeSubmission(fmt.Errorf("credential resolution failed for account %s: %w", account.ID, err)) // Audit-trail save: the failed row is as much a part of the audit // trail as a success row, so a save failure must surface (AUDIT LOSS) // instead of being silently dropped. Extracted to a helper to keep @@ -499,6 +499,9 @@ func skippedAccountRows(scoped map[string]*config.PurchaseExecution, results []e // #1184 / COR-10). Extracted from executeForAccount so the new branch keeps // that function under the gocyclo:10 budget. func (m *Manager) persistFailedAccountExecution(ctx context.Context, acctExec *config.PurchaseExecution, account config.CloudAccount, cause error) error { + // Stamped before the save so the row that lands carries the proof; a failed + // save loses the row and strips the class below. + markRefusedAccountRow(acctExec, refusedBeforeSubmission(cause)) if saveErr := m.saveAccountRow(ctx, acctExec); saveErr != nil { // The cause is joined as text: a lost audit row means the outcome is // unknown, so a before-submission class on the cause must not survive diff --git a/internal/purchase/fanout_cred_pin_rows_test.go b/internal/purchase/fanout_cred_pin_rows_test.go new file mode 100644 index 000000000..9ce69fbd4 --- /dev/null +++ b/internal/purchase/fanout_cred_pin_rows_test.go @@ -0,0 +1,59 @@ +package purchase + +// #1844 S2: a credential or pin failure happens before any purchase call, so +// its account row records no_submission (releasing it from the ramp) and counts +// as proof for the root, unless the row was lost or another account may have +// submitted. + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExecuteMultiAccount_CredentialAndPinRows(t *testing.T) { + bothRefused := map[string]recBehavior{skipAcctA: recRefused, skipAcctB: recRefused} + cases := map[string]struct { + fx fanOutFixture + wantProof bool + wantRowA bool // account A's row exists + flaggedA bool + }{ + "a credential failure row is flagged and the root proven": { + fx: fanOutFixture{behaviors: bothRefused, credFail: map[string]bool{skipAcctA: true, skipAcctB: true}}, + wantProof: true, wantRowA: true, flaggedA: true, + }, + "a pin failure row is flagged and the root proven": { + fx: fanOutFixture{behaviors: bothRefused, pinFail: true}, + wantProof: true, wantRowA: true, flaggedA: true, + }, + "a credential failure next to a started plain failure stays unproven": { + fx: fanOutFixture{ + behaviors: map[string]recBehavior{skipAcctA: recRefused, skipAcctB: recPlainFail}, + credFail: map[string]bool{skipAcctA: true}, + }, + wantRowA: true, flaggedA: true, + }, + "a lost credential row stays unproven and unflagged": { + fx: fanOutFixture{behaviors: bothRefused, credFail: map[string]bool{skipAcctA: true}, saveFail: map[string]bool{skipAcctA: true}}, + wantProof: false, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + r := tc.fx.runRoot(t) + require.ErrorIs(t, r.err, errAllAccountsFailed) + row := savedAccountRow(r.saved, skipAcctA) + if !tc.wantRowA { + assert.Nil(t, row) + } else { + require.NotNil(t, row) + assert.Equal(t, "failed", row.Status) + assert.Equal(t, tc.flaggedA, row.NoSubmission) + } + r.saveTerminal(t) + assert.Equal(t, tc.wantProof, r.root.NoSubmission, "root no_submission") + }) + } +} diff --git a/internal/purchase/fanout_refusal_fixture_test.go b/internal/purchase/fanout_refusal_fixture_test.go index f2c86de04..9ec5d1632 100644 --- a/internal/purchase/fanout_refusal_fixture_test.go +++ b/internal/purchase/fanout_refusal_fixture_test.go @@ -181,7 +181,8 @@ func TestExecuteMultiAccount_FanOutFixtureHooks(t *testing.T) { "credential failure on A, B refused": { fx: fanOutFixture{behaviors: refusedBoth, credFail: map[string]bool{skipAcctA: true}}, errContain: "credential resolution failed for account " + skipAcctA, - a: rowWant{"failed", false}, // the unflagged row #1844 S2 will flag + wantProof: true, + a: rowWant{"failed", true}, b: rowWant{"failed", true}, }, "row save failure on A (AUDIT LOSS), B refused": { diff --git a/internal/purchase/fanout_root_proof_test.go b/internal/purchase/fanout_root_proof_test.go index 704a8fa4c..7d2324823 100644 --- a/internal/purchase/fanout_root_proof_test.go +++ b/internal/purchase/fanout_root_proof_test.go @@ -36,7 +36,7 @@ func TestExecuteMultiAccount_RootProof(t *testing.T) { "3 one unknown outcome, one refused": {fx: fanOutFixture{behaviors: withA(recUnknown)}}, "4 refused, the other never started": {fx: fanOutFixture{behaviors: bothRefused, cancelAfterSave: skipAcctA}, wantProof: true}, "5 all never started": {fx: fanOutFixture{behaviors: bothRefused, precancel: true}, wantProof: true}, - "5b started account cut off by the context": {fx: fanOutFixture{behaviors: bothRefused, cancelAtCred: skipAcctA}}, + "5b started account cut off by the context": {fx: fanOutFixture{behaviors: bothRefused, cancelAtCred: skipAcctA}, wantProof: true}, // cut off in credential load: nothing submitted (#1844 S2) "7 one panics after submitting, one refused": {fx: fanOutFixture{behaviors: withA(recPanics)}}, "8 refused wrapping unknown, one refused": {fx: fanOutFixture{behaviors: withA(recRefusedUnknown)}}, "9 audit loss on a refused account's row": {fx: fanOutFixture{behaviors: bothRefused, saveFail: map[string]bool{skipAcctA: true}}}, @@ -45,7 +45,7 @@ func TestExecuteMultiAccount_RootProof(t *testing.T) { "9c provider factory failure, one refused": {fx: fanOutFixture{behaviors: bothRefused, factoryFail: map[string]bool{skipAcctA: true}}}, "9c service client failure, one refused": {fx: fanOutFixture{behaviors: bothRefused, clientFail: map[string]bool{skipAcctA: true}}}, "9c success=false result, one refused": {fx: fanOutFixture{behaviors: withA(recSuccessFalse)}}, - "credential failure, one refused (until S2)": {fx: fanOutFixture{behaviors: bothRefused, credFail: map[string]bool{skipAcctA: true}}}, + "credential failure, one refused": {fx: fanOutFixture{behaviors: bothRefused, credFail: map[string]bool{skipAcctA: true}}, wantProof: true}, "plain purchase failure, one refused": {fx: fanOutFixture{behaviors: withA(recPlainFail)}}, "never started, the started one failed plain": {fx: fanOutFixture{behaviors: withA(recPlainFail), cancelAfterSave: skipAcctA}}, }