Skip to content

fix(test): make 78 unfailable MockConfigStore assertions able to fail - #1735

Merged
cristim merged 3 commits into
mainfrom
fix/1595-mockconfigstore-vacuous-assertions
Aug 8, 2026
Merged

cristim merged 3 commits into
mainfrom
fix/1595-mockconfigstore-vacuous-assertions

Conversation

@cristim

@cristim cristim commented Aug 7, 2026 •

Copy link
Copy Markdown
Member

Closes #1595

Scope

This PR covers MockConfigStore only. Cause (b) below is a property of testify itself, not of this mock: the repo has 27 further name-only assertion sites on other mock types (MockAuthService, MockPurchaseManager, MockAnalyticsClient, mockAzureExchangeOpsClient and others) that are still unfailable the same way. They are out of scope here and filed separately. "78, not 20" is a count for MockConfigStore, not a claim of class-wide completeness.

The defect

MockConfigStore serves many methods from a hardcoded default without reaching mock.Called: the isExpected short-circuit when no expectation is registered, and the Fn override fields. testify appends to mock.Mock.Calls from inside mock.Called, so those invocations never landed there. Any assertion reading that slice was structurally incapable of failing: AssertNotCalled walked a slice the call could not have reached.

A second, independent cause compounds it. testify diffs an empty expectation against the real arguments and counts each one as a difference, so the name-only form AssertNotCalled(t, "WithTx") never matches a method that takes parameters. 18 sites were unfailable both ways, so recording alone would not have made them bite.

A third cause, found by adversarial review after the first two were repaired: a matcher count that is non-zero but does not equal the method's real arity. Diff pads the shorter side with (Missing) and scores the padded position as a difference, so diffs is unconditionally non-zero and the assertion matches nothing. The original matching() special-cased only len(expected) == 0, so a wrong count fell straight through.

The real count: at least 78, not 20

The issue estimated 20. Causes (a) and (b) were enumerated empirically by instrumenting AssertNotCalled / AssertNumberOfCalls to log every execution that could not fail, then running the whole suite:

cause unique sites
(a) isExpected short-circuit only 28
(a) and (b) 18
(b) empty-expectation diff only 31
(c) matcher count != real arity 1
total 78

Spread across internal/api, internal/purchase and internal/scheduler; the cause-(c) site is in internal/purchase. (The earlier revision of this section gave a per-package split of 49/17/7, which sums to 73 rather than 77; the per-package figures were not re-derived here, so they are omitted rather than restated.)

The instrumentation that found (a) and (b) could not have found (c) — it keyed on the two known bypass shapes — so 78 is a floor, not a proven ceiling for causes nobody has thought of yet. The arity dimension specifically is now closed: a static sweep over all 206 MockConfigStore assertion sites in the repo, comparing every non-zero matcher count against the arity the method records, finds exactly one mismatch (the site fixed here) and no other. The armed runtime check agrees — the full suite stays green.

The fix

Every MockConfigStore method records its own invocation in a callLog before branching, and AssertCalled / AssertNotCalled / AssertNumberOfCalls shadow the promoted testify methods to read that log. Naming a method with no arguments now means "not called at all", which is the only thing that form can usefully assert. Argument matchers, mock.Anything, registered expectations and AssertExpectations are unchanged, and a call that reaches mock.Called is counted once, not twice.

For cause (c), matching() treats a matcher count that differs from the recorded arity as a bug in the test and reports it via Errorf + FailNow, naming the method, its real arity and the count given. len(expected) == 0 keeps its meaning. The check reads the arity off a recorded call, so it is inert when the method was never called — the case where any matcher count yields the same correct verdict anyway.

Proof that each repaired assertion bites

Every site was mutation-tested: the production code was changed so the path under test performs the action the assertion forbids, and the test had to fail with the message from the shadowed helper. All mutations were reverted.

What the mutations actually demonstrate differs by bucket, and the distinction matters:

  • The 46 sites with cause (a) (28 pure, 18 combined) reach a hardcoded default without touching mock.Called. Pre-fix, the forbidden call happened and nothing observed it: the test shipped green with the bug live. These are the sites where the fix converts a missed bug into a caught one.
  • The 31 pure cause-(b) sites are on methods that reach m.Called unconditionally. Pre-fix, a forbidden call with no registered expectation hit testify's unknown-call path and panicked — the test failed, loudly but at stores.go and with a message about an unexpected call rather than about the invariant. The gain here is a legible failure attributed to the assertion that owns the invariant, not a bug that would otherwise have shipped. Describing these as "would have shipped green" overstates the fix, which is the same defect class this PR exists to correct.
  • The 1 cause-(c) site (scheduled_fire_test.go:52) is a genuine false pass at the assertion level: AssertNotCalled returned success with the forbidden call recorded. It sat on TransitionExecutionStatus, an unconditional-m.Called method, so the test was still backstopped by the panic — but the assertion itself guarded nothing, and would have gone silent the moment that method gained an isExpected guard or Fn escape like its siblings.

Mutations were the bug class each site exists to catch, for example:

Three sites needed repair beyond the mock, all included here:

  • scheduled_fire_test.go:52 AssertNotCalled(t, "TransitionExecutionStatus", ...) passed four matchers to a five-argument method (cause (c) above). Now named without matchers.
  • coverage_extra_test.go:643 AssertNotCalled(t, "GetCloudAccount") was mechanically failable but semantically inert: the exec-bad-token fixture carried no recommendation with a CloudAccountID, so gatherApproverContactEmails iterated an empty slice and the guarded ordering bug could not produce the call. The fixture now mirrors its sibling; hoisting approver resolution above the token check fails the test, and did not before.
  • t.Helper() marks its own caller, so routing it through a shared markHelper reported every failure at assertions.go instead of the failing assertion. Inlined into each shadowed method; failures now point at the test line.

internal/mocks/assertions_test.go pins all three causes, including a test asserting that testify's promoted AssertNotCalled still passes on the same call - the defect this replaces. The two cause-(c) regression tests were verified to fail with the arity check disabled and pass with it armed.

Note on the escape hatches: SavePurchaseExecution is the one method served by an Fn override without an isExpected guard (stores.go:235-242); the other twelve Fn fields sit on methods that also short-circuit via isExpected.

Gates

go build ./..., go vet ./..., go test -race -count=1 ./... (6692 passed, 43 packages), gocyclo -over 10 -ignore "_test\.go" ., golangci-lint run at the CI-pinned v2.10.1: all clean.

Follow-ups filed separately, not fixed here

  • Cause (b) is testify-wide. 27 name-only assertion sites on non-MockConfigStore mocks remain unfailable; this PR does not touch them.
  • scheduler_test.go:972 asserts MarkCollectionStarted is not called, but that method has no caller on the scheduler's background-refresh path (only handler_recommendations_refresh.go:77). The faithful mutation - making RecommendationsCacheStaleHours == 0 no longer disable the refresh, the PR feat(settings): configurable recommendations cache-staleness threshold + provider lookback window (closes #301) #308 regression - leaves the test green. The sentinel-disable behavior is unguarded.
  • GetPurchaseHistory has no production callers at all, so the two AssertNotCalled sites naming it are tripwires for re-introducing the legacy single-column API rather than guards on current behavior.

cristim added 2 commits August 8, 2026 00:26
MockConfigStore serves many methods from a hardcoded default without
reaching mock.Called: the isExpected short-circuit when no expectation is
registered, and the Fn-override fields. testify appends to mock.Mock.Calls
from inside mock.Called, so those invocations never landed there, and any
assertion reading that slice was structurally incapable of failing.
AssertNotCalled walked a slice the call could not have reached and
reported success no matter what the code under test did.

A second, independent cause compounds it: testify diffs an empty
expectation against the real arguments and counts each one as a
difference, so the name-only form AssertNotCalled(t, "WithTx") never
matches a method that takes parameters. 18 of the affected sites were
unfailable both ways, so recording alone would not have made them bite.

Every MockConfigStore method now records its own invocation in a callLog
before branching, and AssertCalled / AssertNotCalled / AssertNumberOfCalls
shadow the promoted testify methods to read that log. Naming a method with
no arguments now means "not called at all", which is the only thing that
form can usefully assert. Argument matchers, mock.Anything, registered
expectations and AssertExpectations are unchanged.

This repairs 77 assertion sites across internal/api, internal/purchase and
internal/scheduler that could not fail: 46 blocked by the short-circuit,
31 by the empty-expectation diff.

Closes #1595
…t fixture

Two findings from mutation-testing the 77 repaired assertion sites.

t.Helper() marks its own caller, so routing it through a shared markHelper
function marked markHelper as the helper and reported every failure at
assertions.go rather than at the failing assertion. With 25 assertion
sites in one test file, the output could not say which one fired. The
type-assertion is now inlined in each of the three shadowed methods.

TestProcessMessage_ApproveRejectsTokenMismatch asserts GetCloudAccount is
not called, guarding that token validation precedes approver resolution.
Its fixture carries no recommendation with a CloudAccountID, so
gatherApproverContactEmails iterates an empty slice and the guarded bug
could not produce the call: the assertion was mechanically failable but
semantically inert. The fixture now mirrors its sibling at
TestProcessMessage_ApproveRejectsNonMatchingActor. Hoisting approver
resolution above the token check in messages.go now fails the test;
without the fixture change it stayed green.

Refs #1595
@cristim cristim added triaged Item has been triaged priority/p1 Next up; this sprint severity/critical Major harm when it happens urgency/now Drop other things impact/internal Team-internal only effort/m Days type/bug Defect labels Aug 7, 2026
@coderabbitai

coderabbitai Bot commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 15 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 00382e6f-e844-489f-b278-5bb334ba201b

📥 Commits

Reviewing files that changed from the base of the PR and between 9102e1c and c8dfb46.

📒 Files selected for processing (5)
  • internal/mocks/assertions.go
  • internal/mocks/assertions_test.go
  • internal/mocks/stores.go
  • internal/purchase/coverage_extra_test.go
  • internal/purchase/scheduled_fire_test.go

Comment @coderabbitai help to get the list of available commands.

@cristim

cristim commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Adversarial review, head 46ac437b4

Independent reviewer. Everything below was re-derived from the committed diff in a clean worktree at the PR head; nothing was taken from the PR description on trust. Findings are split into execution-verified and reading-derived.

Gates, run locally at the PR head

gate result
go build ./... clean
go vet ./... clean
go test -race -count=1 ./... exit 0, 32 packages ok, 0 FAIL
gocyclo -over 10 -ignore "_test\.go" . clean
golangci-lint run --timeout=10m at the CI-pinned v2.10.1 (installed fresh; the local 2.11.4 is not authoritative) 0 issues.

Diff is 417 insertions, 0 deletions across 4 files. No assertion was deleted, weakened, or relocated to make it honest. Proportionality is fine: no new mocking framework, no test-suite rewrite.


F1 (blocking, execution-verified) internal/purchase/scheduled_fire_test.go:52 is still unfailable

This is a MockConfigStore AssertNotCalled site inside the class the PR claims to have completed, and it is not repaired.

store.AssertNotCalled(t, "TransitionExecutionStatus",
    mock.Anything, mock.Anything, mock.Anything, mock.Anything)   // 4 matchers

internal/mocks/stores.go:246 records five arguments:

m.record("TransitionExecutionStatus", ctx, executionID, fromStatuses, toStatus, actor)

matching() special-cases only len(expected) == 0. With a non-zero but wrong matcher count it falls through to mock.Arguments(expected).Diff(c.args), and testify's Diff sets maxArgCount = max(len(expected), len(actual)) and scores position 5 as (Missing). diffs >= 1 always, so the assertion can never match a recorded call. This is a third structural vacuity mode, distinct from the two the PR describes, and the PR's fix does not cover it.

Verified by mutation, not by reading: with FireScheduledDelayedPurchases mutated to issue a CAS on the nothing-is-due branch (the exact bug the assertion states it guards, "No CAS attempted when nothing is due"), the call demonstrably happens and dispatches through the mock, and the test still passes:

=== RUN   TestFireScheduledDelayedPurchases_NoDueRows
MUTATION: about to call TransitionExecutionStatus with nothing due
MUTATION: TransitionExecutionStatus returned
--- PASS: TestFireScheduledDelayedPurchases_NoDueRows (0.00s)

Correcting only the assertion (either 5 matchers, or the name-only form) makes it bite, with the mutation unchanged:

scheduled_fire_test.go: mock: TransitionExecutionStatus was called 1 time(s) matching [], expected none. Recorded calls:
    TransitionExecutionStatus([context.Background mutation-injected-exec [scheduled] approved <nil>])
--- FAIL

One-line fix. Consequence for the PR's headline claim: this site was unfailable before the PR and is unfailable after it, so the true in-scope count is at least 78, not 77, and 77/77 FAILS-AS-EXPECTED does not hold for it. Whatever enumeration produced 77 did not model the arity mode.

Suggested hardening beyond the one-line fix, since the class is now known: have matching() treat a non-zero matcher count that differs from the recorded arity as a hard error rather than a silent non-match. A supplied-vs-recorded arity mismatch is never a meaningful assertion; failing loudly there closes the mode permanently instead of relying on nobody miscounting again.

Sweep for siblings: exactly one

Two independent methods agree that scheduled_fire_test.go:52 is the complete set:

  • Static. Method-to-arity map built from all 109 m.record(...) calls, resolved against all 197 MockConfigStore AssertCalled/AssertNotCalled sites (including the type MockConfigStore = mocks.MockConfigStore aliases in internal/api/mocks_test.go:19 and internal/purchase/mocks_test.go:18). The (supplied, recorded) distribution is entirely on the diagonal, (0,*)x51, (1,1)x14, (2,2)x66, (3,3)x19, (4,4)x9, (5,5)x37, with one off-diagonal cell.
  • Runtime. An arity probe wired into the shadowed helpers, run across the whole ./internal/... suite, emitted exactly one distinct hit, the same line, with zero unresolved records.

What holds up (execution-verified)

Cause (b) is real and correctly diagnosed. Confirmed against the testify v1.11.1 source in go.mod (mock.go:955-985): with an empty args, maxArgCount comes from the actual arguments and each position becomes (Missing), counting as a difference. A name-only AssertNotCalled(t, "WithTx") therefore cannot match any method taking parameters. Reproduced by execution.

All 109 *MockConfigStore methods record before branching. Zero methods missing an m.record(...). The instrumentation itself is complete.

Mutation spot-checks: 4 sites across 3 shapes, all bite, all correctly attributed. For each: mutate production code so the forbidden call happens, confirm the failure comes from the shadowed helper (not a testify panic or an unrelated assertion), then re-run with the three shadowed methods renamed so testify's promoted versions take over, confirming the site was genuinely unfailable pre-fix.

site class mutated behaviour result
handler_purchases_test.go:2622 WithTx both causes deny path opens a tx before the 403 (#1516 shape) fails; control passes
handler_purchases_test.go:2623 SavePurchaseExecution name-only + Fn escape deny path stamps the execution row before the 403 fails; control passes
handler_inventory_test.go:177 GetAllPurchaseHistory cause (b) handler falls back to the unscoped read (#701/#498/#866 shape) fails; control passes
coverage_extra_test.go:651 GetCloudAccount repaired fixture approver resolution hoisted above the token check fails; control passes

t.Helper() attribution is correct. Every failure is attributed to the assertion's own line in the test file, never to assertions.go. Confirmed independently at four different call sites. The inlining fix in the second commit does what it claims.

The coverage_extra_test.go:643 fixture repair is load-bearing, not cosmetic. The decisive evidence is the pair against the same mutation: with the new Recommendations: [{CloudAccountID: &accountID}] fixture the test goes red; with only the fixture reverted it goes green, because gatherApproverContactEmails iterates a nil slice and never reaches GetCloudAccount. The two commits are independently necessary, one fixes visibility and the other reachability.

The shadowing does not break assertions that already worked. AssertNumberOfCalls reads the same log and does not double-count:

  • handler_config_test.go:1089 (SaveServiceConfig, a method that both records and reaches m.Called, so it is the direct no-double-count proof): one extra call gives expected 2 call(s) ... got 3. Exactly +1.
  • handler_history_test.go:1969 and :2031 (TransitionExecutionStatus): firing twice gives expected 1 ... got 2. Exactly +1, not +2.
  • Registered expectations, argument matchers, .Maybe() and AssertExpectations are untouched; they still read mock.Mock.Calls. internal/api 2041 passed, internal/purchase + internal/mocks + internal/scheduler 432 passed.

Also checked: there are zero name-only AssertCalled sites in the repo, so the loosened name-only semantics cannot silently convert a previously-failing AssertCalled into a passing one. The two structs that embed MockConfigStore (mockSuppressionStore, mockOverrideStore in internal/scheduler) override methods that never reached m.Called either, so their assertion behaviour is unchanged.


F2 (non-blocking, scope) The PR does not state that the fix is MockConfigStore-only

Cause (b) is a testify-wide defect, not a MockConfigStore one. Every name-only AssertNotCalled on any testify mock in this repo is vacuous, and the shadowing here only covers *MockConfigStore.

Repo-wide there are 82 name-only sites, of which roughly 30 sit on other mock types and remain unfailable after this PR, for example:

  • internal/api/handler_auth_test.go:968 mockAuth.AssertNotCalled(t, "UpdateUserProfile")
  • internal/api/handler_purchases_test.go:156,207,331,681,744,745 mockPurchase (MockPurchaseManager), ApproveExecution / ApproveAndExecute
  • internal/purchase/execution_test.go:663,729,1822 mockFactory (MockProviderFactory), CreateAndValidateProvider
  • internal/email/mute_test.go:60,149 ses (MockSESClient), SendEmail
  • internal/api/handler_analytics_test.go:204,259,414,639,662, internal/api/handler_ri_exchange_test.go:971, providers/aws/services/savingsplans/client_test.go:390,976,1032,1535, cmd/multi_service_test.go:1287,1349,1424

Several of these guard authorization and money paths of the same shape the PR is fixing. This is legitimately out of scope for #1595, but the PR body presents "The real count: 77, not 20" without saying the count is scoped to one mock type, which reads as completeness for the whole defect class. Worth a scope sentence in the body and a follow-up issue.

F3 (non-blocking, accuracy) The severity framing does not hold uniformly across the 77

The premise is "an assertion that cannot fail is a place where a real defect ships green". That is exactly right for the short-circuit and Fn-override classes, where the mock serves a default and the run stays green.

It is not right for the pure cause-(b) class. For methods that reach m.Called unconditionally (TransitionExecutionStatus, GetAllPurchaseHistory, SaveServiceConfig, ...), a forbidden call with no registered expectation makes testify panic: mock: I don't know what to return because the method call was unexpected, aborting the test before the assertion runs. Reaching the assertion at those sites required arming an inert .On(...) in the fixture. So for that subset the pre-fix state was a loud, if misattributed, failure rather than a silent pass, and the PR's real gain is a legible correctly-attributed assertion instead of an incidental panic. That is still worth having; it just is not the same risk. Since the 31 "empty-expectation diff only" sites are the largest single bucket in the table, the distinction is material to how the 77 should be read.

Related minor inaccuracy: the body attributes the SavePurchaseExecution sites to the isExpected short-circuit, but stores.go:235-242 has no isExpected on that method, only the SavePurchaseExecutionFn override. The count is unaffected; the 28/18/31 split labels are imprecise.

F4 (non-blocking, latent) The call log makes mock-internal delegation observable

record() fires once per method entry and outer/inner names differ, so nothing is ever double-counted. But four methods fall through to a sibling, and the fallback now shows up in the log where testify structurally could not see it:

delegation new callLog testify mock.Mock.Calls
SavePurchaseExecutionTx -> SavePurchaseExecution (Fn set) Tx=1, inner=1 (neither)
UpdatePurchasePlanTx -> UpdatePurchasePlan Tx=1, inner=1 [UpdatePurchasePlan]
GetPendingExecutionsTx -> GetPendingExecutions Tx=1, inner=1 [GetPendingExecutions]
UpdateGlobalConfigAtomic -> GetGlobalConfig + SaveGlobalConfig Atomic=1, Get=1, Save=1 [SaveGlobalConfig]

Divergence is monotone and only ever 0 -> 1. AssertNotCalled(t, "SavePurchaseExecution") now means "neither the plain writer nor the Tx variant ran", which is strictly stronger for a "no write happened" guard, and a false positive only for a test whose intent is specifically "the Tx variant was used, not the plain one". Nothing trips it today (the Tx delegators have production callers only in internal/api, and those AssertNotCalled sites all sit on 403-before-store paths). A short comment at the four fallbacks in stores.go would stop a future author reading AssertNotCalled(inner) as "the non-Tx path was not used".


Declared out-of-scope items: both confirmed, correctly excluded


Verdict

The core mechanism is sound, the diagnosis of both named causes is correct, the fix is proportionate and purely additive, attribution is right, and the spot-checked sites genuinely bite with pre-fix controls confirming they could not. The fixture repair is load-bearing. All gates pass at the CI-pinned versions.

One blocking item: F1. It is a one-line assertion fix, but it matters more than its size, because it is a site inside the claimed-complete set that the enumeration did not model, and the PR's central claim is completeness. I would also take the matching() arity guard so the mode cannot recur. F2 and F3 are corrections to the PR description plus a follow-up issue; F4 is a comment.

Reviewer notes: F1, the four mutation spot-checks, the pre-fix controls, the no-double-count checks, the arity sweep and all five gates are execution-verified. F2's site list is static-analysis-derived (receiver types resolved by declaration, not by full type-checking). F3's panic behaviour is execution-verified; its implication for the 31-site bucket is inferred. I did not independently reproduce the exact 28/18/31 partition, so I neither endorse nor dispute those three sub-counts; what I did establish is that the in-scope total is at least 78 and that one member of it is unrepaired.

A third structural cause of unfailable assertions, distinct from the two
this PR already repaired and not covered by either fix.

scheduled_fire_test.go asserted TransitionExecutionStatus was not called
using four matchers. The method takes five arguments. matching() special
-cased only len(expected)==0, so a non-zero but wrong count fell through
to testify's Diff, which pads the shorter side with "(Missing)" and
scores the padded position as a difference. diffs was then non-zero for
every recorded call, the assertion matched nothing, and it could not
fire whatever the code under test did. Mutating FireScheduledDelayedPurchases
to issue a CAS on the nothing-is-due branch -- exactly what the assertion
guards -- leaves AssertNotCalled reporting success.

The assertion now names the method with no matchers, which is how this
mock spells "never called at all, whatever the arguments", and bites on
that mutation.

matching() now treats a matcher count that differs from the recorded
arity as a test bug and reports it through Errorf + FailNow, naming the
method, its real arity and the count given. len(expected)==0 keeps its
meaning. The check reads the arity off a recorded call, so it is inert
when the method was never called -- the case where any matcher count
yields the same correct verdict anyway. The helpers also surface it as a
failed assertion, so a TestingT whose FailNow returns cannot turn a
miscount back into a silent pass.

An arity sweep over all 206 MockConfigStore assertion sites in the repo,
comparing each non-zero matcher count against the arity the method
records, finds this one site and no other. The full suite confirms it:
6692 pass with the check armed.

Both new regression tests fail with the check disabled and pass with it
armed. The mock-internal fallback delegations gain a note that the
delegate records itself, so one Tx call appears in the log under both
names.

Refs #1595
@cristim cristim changed the title fix(test): make 77 unfailable MockConfigStore assertions able to fail fix(test): make 78 unfailable MockConfigStore assertions able to fail Aug 8, 2026
@cristim

cristim commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Merging without a CodeRabbit verdict, deliberately, with the reasoning recorded.

CodeRabbit has posted nothing on this PR at any head; its quota is per-developer, adaptive, and shared across the open PRs. What stands in its place is a full independent adversarial review, a blocking finding from that review, the fix for it, and an independent sweep confirming the fix is complete.

The review found a third structural vacuity mode the PR did not model — a non-zero but wrong matcher count. matching() special-cased only len(expected)==0, so a wrong arity fell through to Diff, where the missing position always scores as a difference and the assertion can never fire. That is now fixed at the mechanism rather than the instance: matching() fails loudly on an arity mismatch, naming the method, its real arity and the count given.

The completeness claim was re-derived independently. An AST tool with full type info built a method-to-arity map from the m.record(...) calls and checked every assertion site repo-wide: 306 total, 206 on MockConfigStore, exactly one arity mismatch. Its first run under-reported (16 of 206) because three packages alias the type and types.Unalias was needed — caught and corrected before the number was published.

Three claims in the original body were overstated and are now corrected:

  • The count is a floor of 78, not 77 — and framed as a floor, because the instrumentation that found causes (a) and (b) keyed on those two shapes and could not have found (c). The arity dimension specifically is closed by the 206-site sweep.
  • The "ships green" framing was wrong for the 31 pure cause-(b) sites: those methods reach m.Called unconditionally, so a forbidden call panicked rather than silently passing. The gain there is a legible attributed failure, not a caught-versus-missed bug.
  • SavePurchaseExecution is attributed to the Fn escape, not isExpected — verified rather than assumed: of the 13 methods with an Fn field, it is the only one without an isExpected guard.

Two things the implementer caught in its own work, both worth recording:

It declined to claim the in-situ mutation left the test passing. It does not — the test fails via a testify panic rather than via the assertion, because that method reaches m.Called unconditionally. The assertion guards nothing and would go silent the moment that method gained an isExpected guard, but the test was separately backstopped. Saying otherwise would have repeated the exact overstatement the review had just flagged.

And its own first regression test passed without the hardening enabled — vacuous in precisely the way this PR exists to fix. It extended the test double to capture the error message so the test now asserts the diagnosis rather than the outcome.

It also found the old body's per-package split summed to 73 against a stated 77, could not re-derive it without repeating the original instrumentation run, and removed the figures rather than patching a broken sum.

Scope is stated explicitly: #1595 covers MockConfigStore only. 27 name-only sites on other mock types remain vacuous, tracked in #1740 — including three guarding "a dry run must not purchase" on MockServiceClient, which is a money-path guard.

Gates at CI-pinned versions: build, vet, go test -race -count=1 ./... (6692 passed, 43 packages), gocyclo clean, golangci-lint v2.10.1 with the exit code captured separately from stdout. Nothing was loosened to get green.

@cristim
cristim merged commit 1a1e595 into main Aug 8, 2026
20 checks passed
cristim added a commit that referenced this pull request Aug 8, 2026
… them

Review finding F1. collectMocks is per-directory, and MockConfigStore is
declared in internal/mocks and aliased into internal/api, internal/purchase
and internal/scheduler -- the repo's only cross-package mock. In those
packages it was not recognized as a mock at all, so every site fell into
the "resolved to a non-mock" branch and was silently continued: absent from
the checked count, absent from the report.

Harmless while MockConfigStore's shadowed helpers apply, but the guard's
own comment claimed the opt-out was detected rather than hardcoded, and for
that one mock the detection never ran. Removing those helpers took the old
guard from 6 findings to 6; it should have been 56. A check that cannot fire
inside the check that exists to stop checks from not firing.

The suggested cheap fix -- report the skip instead of swallowing it -- was
measured first and rejected: it produces 183 findings, all MockConfigStore,
which would leave the guard permanently red and therefore ignored.

Instead the mocks are indexed repo-wide by type name and consulted when a
receiver's type is not declared in the using package. Type names are not
unique here (MockHTTPClient has five unrelated definitions, 24 names are
multi-package), so a single candidate is used directly, multiple candidates
only when they agree about the method in question, and anything else is
reported rather than guessed. Guessing would attribute one mock's arity to
another, which is the failure direction that gets a guard deleted.

With both shadowed helpers renamed away the guard now reports 56 across
checked 323 -- 6 in-package as before plus the 50 cross-package sites in
internal/api and internal/purchase that were invisible.

Also from the same review:

F2: shadowing is now recorded per helper rather than per type. A mock that
defines only AssertNotCalled left AssertCalled going through testify, where
the name-only form is still unfailable, while the whole type was exempt.
Renaming only MockConfigStore.AssertCalled now surfaces a name-only
AssertCalled site that the old logic exempted.

F3: a method that never calls m.Called itself has no derivable arity, and
assertions naming it were dropped without a word. They are reported now,
for the same reason checked == 0 is fatal.

F4: an unresolved receiver is reported whatever its matcher count. A
non-zero count that does not match the real arity is the third vacuity mode
from #1735, so restricting the report to name-only sites left that mode out.

F3 and F4 were both measured at zero sites before being changed, so neither
adds noise today.

Refs #1740
cristim added a commit that referenced this pull request Aug 8, 2026
…predicate

The three existing carve-out tests call requirePermission directly. That
proves adminCarvedOuts refuses the pair; it does not prove the endpoint
refuses, and those are different claims. #1757 is the standing example of the
gap in this same file: a test named "...MUST be required" passes while
calling a predicate the real dispatch never consults.

Adds coverage that exercises executeExchange itself, the handler behind
POST /api/ri-exchange/execute, with a plain admin:* principal. mockStore is
stubbed with no expectations so a regression reaches the store and panics
rather than quietly returning 200, and the AssertNotCalled passes two
matchers because SaveRIExchangeRecord hands two arguments to m.Called
(a name-only form could never fail: #1595/#1740).

The request body is the real ExchangeExecuteRequestBody shape. The first
draft used invented field names, and the mutation run exposed it: with the
carve-out removed the handler stopped at "ri_ids is required" rather than
proceeding, so the no-state-change assertion was satisfied by body validation
instead of by the guard under test -- the semantically-inert fixture shape
from #1735. With a valid body the mutation now carries execution past the
gate and into the handler proper, so the assertion discriminates.

Refs #1644
cristim added a commit that referenced this pull request Aug 8, 2026
…predicate

The three existing carve-out tests call requirePermission directly. That
proves adminCarvedOuts refuses the pair; it does not prove the endpoint
refuses, and those are different claims. #1757 is the standing example of the
gap in this same file: a test named "...MUST be required" passes while
calling a predicate the real dispatch never consults.

Adds coverage that exercises executeExchange itself, the handler behind
POST /api/ri-exchange/execute, with a plain admin:* principal. mockStore is
stubbed with no expectations so a regression reaches the store and panics
rather than quietly returning 200, and the AssertNotCalled passes two
matchers because SaveRIExchangeRecord hands two arguments to m.Called
(a name-only form could never fail: #1595/#1740).

The request body is the real ExchangeExecuteRequestBody shape. The first
draft used invented field names, and the mutation run exposed it: with the
carve-out removed the handler stopped at "ri_ids is required" rather than
proceeding, so the no-state-change assertion was satisfied by body validation
instead of by the guard under test -- the semantically-inert fixture shape
from #1735. With a valid body the mutation now carries execution past the
gate and into the handler proper, so the assertion discriminates.

Refs #1644
cristim added a commit that referenced this pull request Aug 8, 2026
…ting group (#1758)

* sec(auth): carve execute:ri-exchange out of admin:* and seed the granting group

execute:ri-exchange was absent from adminCarvedOuts, so permissionsAllow
short-circuited on the admin:* wildcard and returned true unconditionally.
Both routed execute endpoints, POST /api/ri-exchange/execute and POST
/api/ri-exchange/azure-instances/exchange, were reachable by any admin with
no explicit grant, and the provider/region/MaxPurchaseAmount dimensions were
skipped along with the verb. An exchange consumes existing commitments and
buys replacements with no rollback path, which is the same rationale that put
execute:purchases behind separation of duties in #923.

The carve-out alone would have been an outage rather than a partial fix.
PR #1737's grant ceiling refuses to add a carved-out verb to any group
through the API, and no migration seeded one, so the verb would have been
grantable to nobody and both endpoints would 403 for every principal.
execute:purchases survives its own carve-out only because 000059/000064 seed
Purchaser and backfill admins into it. Migration 000096 does the same here:
it seeds a system-managed RI Exchanger group at the next free namespace UUID
and backfills every Administrators member, so no admin loses the capability
on upgrade.

The seeded grant is deliberately unconstrained, because a migration cannot
know an operator's accounts, regions or spend ceiling and an over-narrow seed
would refuse legitimate exchanges. Verified by execution against
permissionsAllow: an unconstrained grant still short-circuits the constraint
dimensions, so backfilled members bypass them exactly as admin:* does today.
What the carve-out does buy is that the verb can no longer be granted through
the API, that new principals need a deliberate grant instead of inheriting it
from the wildcard, and that membership is revocable and auditable
independently of the admin role. Constraint enforcement for the seeded
population remains open on #1644.

Coverage runs both directions, because a refusal-only test passes equally
well against a handler that refuses everyone: a plain admin:* principal is
refused, a holder of an explicit execute:ri-exchange grant is allowed, and a
scope control pins that admin:* still grants the neighbouring non-carved
verbs including view:ri-exchange. Mutation-verified per test, run alone:
removing the pair from adminCarvedOuts fails the refusal test and leaves the
other two passing, which is the correct dependency shape.

The frontend ADMIN_CARVED_OUTS mirror is updated in the same change; drift
there would offer an action in the UI that the backend then refuses.

Closes #1644

* test(api): drive the routed execute handler, not just the permission predicate

The three existing carve-out tests call requirePermission directly. That
proves adminCarvedOuts refuses the pair; it does not prove the endpoint
refuses, and those are different claims. #1757 is the standing example of the
gap in this same file: a test named "...MUST be required" passes while
calling a predicate the real dispatch never consults.

Adds coverage that exercises executeExchange itself, the handler behind
POST /api/ri-exchange/execute, with a plain admin:* principal. mockStore is
stubbed with no expectations so a regression reaches the store and panics
rather than quietly returning 200, and the AssertNotCalled passes two
matchers because SaveRIExchangeRecord hands two arguments to m.Called
(a name-only form could never fail: #1595/#1740).

The request body is the real ExchangeExecuteRequestBody shape. The first
draft used invented field names, and the mutation run exposed it: with the
carve-out removed the handler stopped at "ri_ids is required" rather than
proceeding, so the no-state-change assertion was satisfied by body validation
instead of by the guard under test -- the semantically-inert fixture shape
from #1735. With a valid body the mutation now carries execution past the
gate and into the handler proper, so the assertion discriminates.

Refs #1644

* fix(frontend): route each carved-out verb through the group that grants it back

canAccess()'s loading-race fallback (effectivePermissions not yet loaded)
hardcoded every carved-out verb to isPurchaser(), so it agreed with the
backend for the three money-spending verbs but not for execute:ri-exchange
(issue #1644): admin+Purchaser (not RI Exchanger) wrongly passed, and
admin+RI-Exchanger (not Purchaser) wrongly failed.

Adds isRIExchanger(), mirroring isPurchaser()'s shape, and routes both
through a CARVE_OUT_FALLBACK_CHECK map keyed by carved-out verb instead of
a single hardcoded predicate. Also fixes isPurchaser() itself, which
iterated the full ADMIN_CARVED_OUTS set (now including execute:ri-exchange)
instead of the three money-spending verbs it's actually meant to gate --
holding execute:ri-exchange alone would have wrongly satisfied the "can
spend money" predicate the no-Purchaser first-run prompt keys off.

Fixes the stale permissions.test.ts assertion that still expected
execute:ri-exchange to pass admin:* during the fallback (pre-carve-out
behaviour), and adds both-direction coverage for the fix plus regression
tests pinning the two carve-outs' groups as disjoint.

UX-only gate; the backend enforces on every request regardless of this
fallback's answer.

* test(migrations): make the 000096 idempotency subtest actually re-run the migration

The "backfill is idempotent" subtest called migrations.RunMigrations a
second time expecting it to re-exercise the DO block's guards. It does
not: m.Up() returns migrate.ErrNoChange once the database is already at
the latest version, so the migration body never runs again and the
subtest passed regardless of whether the guards worked. Proved by mutation:
stripping both idempotency devices from the up migration (the WHERE NOT
guard and the DISTINCT dedup on the backfill UPDATE) still left the old
test green.

Rewrites the subtest to read 000096_seed_ri_exchanger_group.up.sql and
execute its SQL directly a second time, the same pattern
000095_purchase_history_account_id_width_test.go already uses for its
re-run assertion. Re-verified by the same mutation: with the fix, stripping
the guards now fails the subtest (duplicate group_ids entry), and restoring
them passes it again.

The migration itself was already correctly idempotent; only the test
coverage was empty.

* test(api): assert the 403 identity, not error-string tokens, in the execute:ri-exchange handler test

TestExecuteExchange_PlainAdminIsRefused discriminated a dropped carve-out by
checking that the error message did NOT contain "execute"/"ri-exchange".
Under mutation the error actually returned is an AWS credential/STS failure
from exchange.ExecuteExchange (reached only once the carve-out stops
refusing), whose wording has nothing to do with permissions -- the test
caught the regression by accident, because that unrelated error string
happened not to contain those two tokens. If the wording of that AWS error
ever changed, the test would stop discriminating while staying green.

Replaces the substring assertions with an identity check: the carve-out
denial from requirePermission is a *clientError with code 403
(requireSessionPermission in handler.go), returned unwrapped by
executeExchange, so asserting IsClientError + code 403 fails for the actual
reason -- refused at the gate vs. failed downstream for something else.
Verified by mutation, run in a hard-sandboxed AWS environment (nulled
credentials/config files, disabled profile, IMDS disabled) so the mutated
test cannot reach live AWS: with adminCarvedOuts stripped of the
execute:ri-exchange pair, the new 403-identity assertion is the one that
fails (0.00s, no AWS contact); restoring the pair passes again.

Also corrects the docstring, which claimed the unstubbed mockStore backstop
would panic if the carve-out regressed. It cannot: executeExchange's success
path never calls SaveRIExchangeRecord at all (only the scheduled
auto-exchange path in pkg/exchange/auto.go does), so AssertNotCalled holds
unconditionally here and does not discriminate this test today. Kept as
defense-in-depth for a future change that routes this handler through the
store, documented as such.

Adds t.Setenv("AWS_EC2_METADATA_DISABLED", "true") so the credential
resolution failure this test depends on under mutation is fast and
deterministic regardless of what AWS credentials happen to be configured in
whichever environment re-runs it later, rather than depending on IMDS being
unreachable by chance. executeExchange builds its AWS clients from ambient
credentials with no injected seam (unlike internal/server's
riExchangeClients) -- that structural gap is tracked separately as #1760;
this is containment for the test, not a fix to the seam.
cristim added a commit that referenced this pull request Aug 8, 2026
…1750)

* fix(test): repair 30 unfailable mock assertions and guard the class

testify diffs the matchers an assertion is given against the arguments a
mock recorded, and diffs an empty matcher list too, counting each real
argument as a difference. A name-only AssertNotCalled(t, "Method") can
therefore never match a method that passes arguments to m.Called: it
reports success whatever the code under test did. PR #1735 fixed this for
MockConfigStore by shadowing the helpers; every other mock still had it.

Repaired 30 sites across 11 mock types by passing each method's real
m.Called arity as matchers. The three highest-stakes are the dry-run
guards in cmd/multi_service_test.go, which assert that --dry-run does not
reach PurchaseCommitment; they could not fail.

Every site was mutation-verified in both directions: with the forbidden
call recorded on the mock, the name-only form passes (23+4 of 27) and the
repaired form fails (27 of 27). The three dry-run guards were verified
against a real production mutation making processPurchaseLoop purchase on
the dry-run branch: the assertion fires only once it carries three
matchers.

Of the 30, exactly one had a registered expectation and so silently
passed; the other 29 were backstopped by testify's unexpected-call panic,
which fails the test loudly but at the mock rather than at the invariant.
That backstop is accidental: it disappears the moment anyone registers an
expectation for the method.

TestNoUnfailableMockAssertions closes the class for code not yet written.
It parses every package, derives each mock method's m.Called arity, and
rejects any AssertCalled / AssertNotCalled whose matcher count cannot
match. Mocks opt out by defining their own helpers, which is detected
rather than hardcoded, so MockConfigStore's callLog shadows keep their
name-only meaning and a future mock adopting the pattern is covered
automatically. Sites whose receiver cannot be resolved are reported, not
skipped, so a blind spot cannot hide behind a pass.

TestVacuousAssertionGuardDetects exercises that analysis on synthetic
source: name-only, wrong count, correct count, zero-arity method, a
shadowed type, a non-mock, an unresolvable receiver and a variadic spread
whose length is not knowable. Without it the guard could break into
reporting nothing and the repo-wide run would still look green, which is
the failure mode this whole class is about.

The four providers/aws sites were found only by the guard: the repo is a
Go workspace, and a type-checked sweep of the root module cannot see the
other five modules.

Closes #1740

* fix(test): make the guard see cross-package mocks instead of skipping them

Review finding F1. collectMocks is per-directory, and MockConfigStore is
declared in internal/mocks and aliased into internal/api, internal/purchase
and internal/scheduler -- the repo's only cross-package mock. In those
packages it was not recognized as a mock at all, so every site fell into
the "resolved to a non-mock" branch and was silently continued: absent from
the checked count, absent from the report.

Harmless while MockConfigStore's shadowed helpers apply, but the guard's
own comment claimed the opt-out was detected rather than hardcoded, and for
that one mock the detection never ran. Removing those helpers took the old
guard from 6 findings to 6; it should have been 56. A check that cannot fire
inside the check that exists to stop checks from not firing.

The suggested cheap fix -- report the skip instead of swallowing it -- was
measured first and rejected: it produces 183 findings, all MockConfigStore,
which would leave the guard permanently red and therefore ignored.

Instead the mocks are indexed repo-wide by type name and consulted when a
receiver's type is not declared in the using package. Type names are not
unique here (MockHTTPClient has five unrelated definitions, 24 names are
multi-package), so a single candidate is used directly, multiple candidates
only when they agree about the method in question, and anything else is
reported rather than guessed. Guessing would attribute one mock's arity to
another, which is the failure direction that gets a guard deleted.

With both shadowed helpers renamed away the guard now reports 56 across
checked 323 -- 6 in-package as before plus the 50 cross-package sites in
internal/api and internal/purchase that were invisible.

Also from the same review:

F2: shadowing is now recorded per helper rather than per type. A mock that
defines only AssertNotCalled left AssertCalled going through testify, where
the name-only form is still unfailable, while the whole type was exempt.
Renaming only MockConfigStore.AssertCalled now surfaces a name-only
AssertCalled site that the old logic exempted.

F3: a method that never calls m.Called itself has no derivable arity, and
assertions naming it were dropped without a word. They are reported now,
for the same reason checked == 0 is fatal.

F4: an unresolved receiver is reported whatever its matcher count. A
non-zero count that does not match the real arity is the third vacuity mode
from #1735, so restricting the report to name-only sites left that mode out.

F3 and F4 were both measured at zero sites before being changed, so neither
adds noise today.

Refs #1740

* fix(test): repair an assertion that ran before its call, and scope the guard

Three CodeRabbit findings, all Major, all reproduced before fixing.

An assertion positioned before the call under test is a FOURTH vacuity mode.
savingsplans/client_test.go asserted DescribeSavingsPlansOfferings was not
called, above the findOfferingID call it guards. The mock had recorded
nothing at that point, so it passed for any implementation regardless of
matcher count -- inside the PR that removes unfailable assertions.

Proved by mutating the production short-circuit so the CE-provided offering
ID no longer suppresses the lookup, with a permissive expectation registered
so the assertion rather than testify's panic decides: the original ordering
passes, the moved assertion fails at its own line.

The verification method used for the other 29 sites was blind to this by
construction -- it injected a recorded call immediately BEFORE the assertion,
which is the ordering under test. Swept all 30 sites with an AST detector for
the shape; this was the only genuine instance. The others it flagged are
t.Cleanup bodies, which run after the test returns, and calls appearing as
arguments to sibling assertions.

Detecting the mode generally is not cheap: the prototype produced 10 findings
with 9 false positives, because there is no syntactic notion of "the call
under test". A check at that ratio gets deleted rather than fixed, so it is
filed as #1761 and this PR's claim is narrowed to the matcher-count modes.

resolveRecvType searched the whole file and kept the last matching
assignment, so two tests in one file both naming a variable mockStore would
resolve every site to whichever type appeared last, comparing matcher counts
against the wrong arity. It now walks the enclosing function scopes outward
the way Go's lexical scoping does -- which also keeps a t.Cleanup closure
referencing an outer mock resolvable -- and returns unresolved when one scope
binds a name to two different types, rather than guessing. Wrong attribution
is the failure direction that gets a guard deleted. No file in the repo
currently binds one name to two mock types, so nothing was mis-attributed;
the fix closes a latent hazard.

Three assertion shapes were dropped without a word, contradicting the
guard's own stated rule that what it cannot place is reported: a receiver
that is not a plain identifier, a method name that is not a string literal,
and a malformed call. All three now reach the skipped report. Unquoting uses
strconv.Unquote rather than strings.Trim, which mishandles escapes and
silently accepts a raw-string literal.

The multi-package mock figure in the PR body was wrong: it counted every
func (m *T) receiver name rather than only types dispatching through
m.Called. Restated with its counting method, since three methods give three
answers and the safety argument rests instead on MockConfigStore having
exactly one declaring package.

Refs #1740

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(test): route unsupported shapes through the path that actually runs

site.unsupported was written in five places and read in exactly one -- inside
TestVacuousAssertionGuardDetects. The repo-wide run went straight from
collectAssertSites to resolveRecvType and never consulted it, so no reason
string ever reached the skipped report on the path that runs.

The self-test passed because it re-implemented the main loop's dispatch, and
its copy had the unsupported branch the real one lacked. A test asserting on
its own private copy of the logic is the defect this guard exists to catch,
one level up.

Verified on an isolated module -- scratch go.mod, one package, so repoRoot()
resolves to it -- with the guard otherwise unmodified. Before:

  variadic spread        FALSE FINDING ("passes no matchers, but Two hands 2")
  selector receiver      silently dropped, neither checked nor reported
  non-literal method     reported with the wrong reason

A false finding on correct code is the worst of the three: that is what gets a
guard deleted rather than fixed.

Both halves are fixed, because either alone leaves the blind spot. The
per-site dispatch is now one function, classifySite, called by the repo-wide
run and by the self-test, so the self-test can no longer pass against a branch
the production path does not have. All three shapes now report their own
reason, and the spread produces no finding.

The fourth shape, fewer than two arguments, cannot occur in code that
compiles: testify's signature requires the method name, and the reviewer's
probe failed to build until it was removed. It stays as a defensive branch,
exercised only from the parse-only synthetic fixture, and is now marked as
such.

Routing the reports through the real path surfaced one live site the dead code
had been swallowing: assertions_test.go called testify's promoted
implementation as m.Mock.AssertNotCalled(...), a selector receiver the guard
cannot resolve. Hoisted to a local so the deliberate call into the broken
implementation stays, without tripping the report forever.

Also from the same review:

The outward scope walk over-reached. A scope binding a name from a non-literal
(mockStore := newStore()) yielded no type, so the walk continued outward and
could adopt an unrelated outer type that the code would never use. The
innermost scope that binds the name now wins whether or not its type can be
determined, matching what shadowing does at run time; an undeterminable
binding returns unresolved.

"the way Go's own lexical scoping works" overstated it. The walk approximates
Go's scoping for the shapes mock assertions are written in; it does not
implement it. Wording corrected.

Refs #1740

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
cristim added a commit that referenced this pull request Sep 27, 2026
…1750)

* fix(test): repair 30 unfailable mock assertions and guard the class

testify diffs the matchers an assertion is given against the arguments a
mock recorded, and diffs an empty matcher list too, counting each real
argument as a difference. A name-only AssertNotCalled(t, "Method") can
therefore never match a method that passes arguments to m.Called: it
reports success whatever the code under test did. PR #1735 fixed this for
MockConfigStore by shadowing the helpers; every other mock still had it.

Repaired 30 sites across 11 mock types by passing each method's real
m.Called arity as matchers. The three highest-stakes are the dry-run
guards in cmd/multi_service_test.go, which assert that --dry-run does not
reach PurchaseCommitment; they could not fail.

Every site was mutation-verified in both directions: with the forbidden
call recorded on the mock, the name-only form passes (23+4 of 27) and the
repaired form fails (27 of 27). The three dry-run guards were verified
against a real production mutation making processPurchaseLoop purchase on
the dry-run branch: the assertion fires only once it carries three
matchers.

Of the 30, exactly one had a registered expectation and so silently
passed; the other 29 were backstopped by testify's unexpected-call panic,
which fails the test loudly but at the mock rather than at the invariant.
That backstop is accidental: it disappears the moment anyone registers an
expectation for the method.

TestNoUnfailableMockAssertions closes the class for code not yet written.
It parses every package, derives each mock method's m.Called arity, and
rejects any AssertCalled / AssertNotCalled whose matcher count cannot
match. Mocks opt out by defining their own helpers, which is detected
rather than hardcoded, so MockConfigStore's callLog shadows keep their
name-only meaning and a future mock adopting the pattern is covered
automatically. Sites whose receiver cannot be resolved are reported, not
skipped, so a blind spot cannot hide behind a pass.

TestVacuousAssertionGuardDetects exercises that analysis on synthetic
source: name-only, wrong count, correct count, zero-arity method, a
shadowed type, a non-mock, an unresolvable receiver and a variadic spread
whose length is not knowable. Without it the guard could break into
reporting nothing and the repo-wide run would still look green, which is
the failure mode this whole class is about.

The four providers/aws sites were found only by the guard: the repo is a
Go workspace, and a type-checked sweep of the root module cannot see the
other five modules.

Closes #1740

* fix(test): make the guard see cross-package mocks instead of skipping them

Review finding F1. collectMocks is per-directory, and MockConfigStore is
declared in internal/mocks and aliased into internal/api, internal/purchase
and internal/scheduler -- the repo's only cross-package mock. In those
packages it was not recognized as a mock at all, so every site fell into
the "resolved to a non-mock" branch and was silently continued: absent from
the checked count, absent from the report.

Harmless while MockConfigStore's shadowed helpers apply, but the guard's
own comment claimed the opt-out was detected rather than hardcoded, and for
that one mock the detection never ran. Removing those helpers took the old
guard from 6 findings to 6; it should have been 56. A check that cannot fire
inside the check that exists to stop checks from not firing.

The suggested cheap fix -- report the skip instead of swallowing it -- was
measured first and rejected: it produces 183 findings, all MockConfigStore,
which would leave the guard permanently red and therefore ignored.

Instead the mocks are indexed repo-wide by type name and consulted when a
receiver's type is not declared in the using package. Type names are not
unique here (MockHTTPClient has five unrelated definitions, 24 names are
multi-package), so a single candidate is used directly, multiple candidates
only when they agree about the method in question, and anything else is
reported rather than guessed. Guessing would attribute one mock's arity to
another, which is the failure direction that gets a guard deleted.

With both shadowed helpers renamed away the guard now reports 56 across
checked 323 -- 6 in-package as before plus the 50 cross-package sites in
internal/api and internal/purchase that were invisible.

Also from the same review:

F2: shadowing is now recorded per helper rather than per type. A mock that
defines only AssertNotCalled left AssertCalled going through testify, where
the name-only form is still unfailable, while the whole type was exempt.
Renaming only MockConfigStore.AssertCalled now surfaces a name-only
AssertCalled site that the old logic exempted.

F3: a method that never calls m.Called itself has no derivable arity, and
assertions naming it were dropped without a word. They are reported now,
for the same reason checked == 0 is fatal.

F4: an unresolved receiver is reported whatever its matcher count. A
non-zero count that does not match the real arity is the third vacuity mode
from #1735, so restricting the report to name-only sites left that mode out.

F3 and F4 were both measured at zero sites before being changed, so neither
adds noise today.

Refs #1740

* fix(test): repair an assertion that ran before its call, and scope the guard

Three CodeRabbit findings, all Major, all reproduced before fixing.

An assertion positioned before the call under test is a FOURTH vacuity mode.
savingsplans/client_test.go asserted DescribeSavingsPlansOfferings was not
called, above the findOfferingID call it guards. The mock had recorded
nothing at that point, so it passed for any implementation regardless of
matcher count -- inside the PR that removes unfailable assertions.

Proved by mutating the production short-circuit so the CE-provided offering
ID no longer suppresses the lookup, with a permissive expectation registered
so the assertion rather than testify's panic decides: the original ordering
passes, the moved assertion fails at its own line.

The verification method used for the other 29 sites was blind to this by
construction -- it injected a recorded call immediately BEFORE the assertion,
which is the ordering under test. Swept all 30 sites with an AST detector for
the shape; this was the only genuine instance. The others it flagged are
t.Cleanup bodies, which run after the test returns, and calls appearing as
arguments to sibling assertions.

Detecting the mode generally is not cheap: the prototype produced 10 findings
with 9 false positives, because there is no syntactic notion of "the call
under test". A check at that ratio gets deleted rather than fixed, so it is
filed as #1761 and this PR's claim is narrowed to the matcher-count modes.

resolveRecvType searched the whole file and kept the last matching
assignment, so two tests in one file both naming a variable mockStore would
resolve every site to whichever type appeared last, comparing matcher counts
against the wrong arity. It now walks the enclosing function scopes outward
the way Go's lexical scoping does -- which also keeps a t.Cleanup closure
referencing an outer mock resolvable -- and returns unresolved when one scope
binds a name to two different types, rather than guessing. Wrong attribution
is the failure direction that gets a guard deleted. No file in the repo
currently binds one name to two mock types, so nothing was mis-attributed;
the fix closes a latent hazard.

Three assertion shapes were dropped without a word, contradicting the
guard's own stated rule that what it cannot place is reported: a receiver
that is not a plain identifier, a method name that is not a string literal,
and a malformed call. All three now reach the skipped report. Unquoting uses
strconv.Unquote rather than strings.Trim, which mishandles escapes and
silently accepts a raw-string literal.

The multi-package mock figure in the PR body was wrong: it counted every
func (m *T) receiver name rather than only types dispatching through
m.Called. Restated with its counting method, since three methods give three
answers and the safety argument rests instead on MockConfigStore having
exactly one declaring package.

Refs #1740

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(test): route unsupported shapes through the path that actually runs

site.unsupported was written in five places and read in exactly one -- inside
TestVacuousAssertionGuardDetects. The repo-wide run went straight from
collectAssertSites to resolveRecvType and never consulted it, so no reason
string ever reached the skipped report on the path that runs.

The self-test passed because it re-implemented the main loop's dispatch, and
its copy had the unsupported branch the real one lacked. A test asserting on
its own private copy of the logic is the defect this guard exists to catch,
one level up.

Verified on an isolated module -- scratch go.mod, one package, so repoRoot()
resolves to it -- with the guard otherwise unmodified. Before:

  variadic spread        FALSE FINDING ("passes no matchers, but Two hands 2")
  selector receiver      silently dropped, neither checked nor reported
  non-literal method     reported with the wrong reason

A false finding on correct code is the worst of the three: that is what gets a
guard deleted rather than fixed.

Both halves are fixed, because either alone leaves the blind spot. The
per-site dispatch is now one function, classifySite, called by the repo-wide
run and by the self-test, so the self-test can no longer pass against a branch
the production path does not have. All three shapes now report their own
reason, and the spread produces no finding.

The fourth shape, fewer than two arguments, cannot occur in code that
compiles: testify's signature requires the method name, and the reviewer's
probe failed to build until it was removed. It stays as a defensive branch,
exercised only from the parse-only synthetic fixture, and is now marked as
such.

Routing the reports through the real path surfaced one live site the dead code
had been swallowing: assertions_test.go called testify's promoted
implementation as m.Mock.AssertNotCalled(...), a selector receiver the guard
cannot resolve. Hoisted to a local so the deliberate call into the broken
implementation stays, without tripping the report forever.

Also from the same review:

The outward scope walk over-reached. A scope binding a name from a non-literal
(mockStore := newStore()) yielded no type, so the walk continued outward and
could adopt an unrelated outer type that the code would never use. The
innermost scope that binds the name now wins whether or not its type can be
determined, matching what shadowing does at run time; an undeterminable
binding returns unresolved.

"the way Go's own lexical scoping works" overstated it. The walk approximates
Go's scoping for the shapes mock assertions are written in; it does not
implement it. Wording corrected.

Refs #1740

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/internal Team-internal only priority/p1 Next up; this sprint severity/critical Major harm when it happens triaged Item has been triaged type/bug Defect urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(test): MockConfigStore isExpected short-circuit makes 20 AssertNotCalled assertions unfailable

1 participant