test(subscriptions): close Stripe webhook + checkout edge/failure gaps - #367
Conversation
Adds intelligent behavior/edge/failure tests for the Stripe webhook and checkout-session handlers, plus one minimal source fix. HandleWebhookCommand (source): reject malformed/empty webhook bodies gracefully. EventUtility.ConstructEvent throws System.Text.Json.JsonException (not StripeException) on unparseable payloads, so those previously escaped unhandled (HTTP 500 -> Stripe retries a body that can never parse). Now caught at the trust boundary and returned as an InvalidWebhookSignature failure. HandleWebhookCommandHandlerTests: subscription-created safe no-op (setup is owned by checkout.session.completed); invoice.payment_failed does NOT downgrade a Pro user (Stripe dunning drives the eventual subscription.updated/deleted); Stripe API error during subscription fetch surfaces WebhookStripeApiError; OperationCanceledException propagates instead of being swallowed as a generic failure; malformed and empty bodies rejected; non-subscription data object safe no-op; GetPeriodEnd fallback with no subscription items; unusual interval defaults to Monthly while keeping the item period end; replayed event with a stale signature timestamp rejected by tolerance. CreateCheckoutCommandHandlerTests: Stripe customer-creation failure returns a graceful failure without persisting partial state; HTTP 500 / network timeout / malformed response during checkout all fail gracefully with the correct Result. Refs thomasluizon/orbit-ui-mobile#243 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
There was a problem hiding this comment.
Review Complete
Scope: PR #367 in thomasluizon/orbit-api — test(subscriptions): close Stripe webhook + checkout edge/failure gaps
Recommendation: APPROVE
| Severity | Count |
|---|---|
| Critical (incl. |
0 |
| High | 0 |
| Medium | 0 |
| Low / Info | 1 |
Summary
This PR closes test gaps around the Stripe webhook and checkout-session handlers and ships one minimal, correctly-scoped source fix: HandleWebhookCommand now catches System.Text.Json.JsonException (thrown by EventUtility.ConstructEvent on malformed/empty payloads, per Stripe.net 52.1.0 behavior) alongside the existing StripeException catch, returning the same InvalidWebhookSignature failure instead of letting the exception escape as an unhandled 500. The remaining ~269 added lines are test code (HandleWebhookCommandHandlerTests.cs, CreateCheckoutCommandHandlerTests.cs) that assert on resulting state/Result, not just mock invocation. Verified: the new catch block's scoping (tightly wraps only EventUtility.ConstructEvent, cannot mask later JSON errors), the reused LogWebhookSignatureVerificationFailed log call, the BillingProviderException constructor used by the new checkout tests, ErrorMessages strings against test assertions, and the invoice.payment_failed no-op path (confirmed genuinely absent from the handler's switch, so the "safe default" test asserts real behavior, not a tautology).
Findings
Critical
None.
High
None.
Medium
None.
Low / Info
- [Info] The new
catch (System.Text.Json.JsonException ex)block is scoped tightly around only theEventUtility.ConstructEventcall (src/Orbit.Application/Subscriptions/Commands/HandleWebhookCommand.cs:38-56), so it cannot mask an unrelated JSON error later in event processing. Good scoping, no fix needed.
Subagents
| Agent | Verdict |
|---|---|
| security-reviewer | PASS (no findings) — reviewed HandleWebhookCommand.cs; signature verification (EventUtility.ConstructEvent) is unchanged and still runs before any state mutation, the new catch only reclassifies an exception type into the existing rejection path, no new attack surface |
| contract-aligner | N/A — diff touches no DTO, Controller route, or packages/shared type; no cross-repo contract surface changed |
Validation
| Check | Result |
|---|---|
| Build (dotnet) | N/A in this pass — CI's Build required check covers it |
| Tests (dotnet) | N/A in this pass — CI's Unit Tests required check covers it; PR body reports dotnet test → 2749 passed, 0 failed locally |
Deferred — N/A dimensions & files not verdicted
- Dimension 8 (DESIGN.md/AI-slop) — N/A, no
apps/*files in diff. - Dimension 9 (Parity web↔mobile) — N/A, backend-only diff.
- Dimension 10 (i18n) — N/A, backend-only diff.
- Dimension 11 (Contract drift + backward-compat) — N/A, no DTO/route/shared-type change.
- Dimension 14 (FEATURES.md parity) — N/A, no user-facing feature surface changed (internal robustness fix + tests only).
- All three changed files (
HandleWebhookCommand.cs,HandleWebhookCommandHandlerTests.cs,CreateCheckoutCommandHandlerTests.cs) were read in full diff context and given a verdict. - Phase 6 adversarial pass /
/second-opinion: no Critical/High finding survived to require it — that step is scoped to Critical/High only, and none exist here.
What's good
- Genuine root-cause fix (catches the exception type Stripe.net actually throws), not a defensive band-aid, and empirically verified per the PR description.
- New tests assert on resulting
Result/state (user.IsPro,SaveChangesAsyncnot called, specific error substrings) rather than just verifying a mock was invoked. - The
invoice.payment_failed"no downgrade" test locks a deliberate, well-reasoned safe default (avoid downgrading a paying user on a transient decline; let dunning +customer.subscription.updated/deletedown the lifecycle). - Good edge-case coverage: no-items subscriptions, unusual billing intervals, stale-signature-timestamp replay rejection,
OperationCanceledExceptionpropagation (not swallowed), Stripe API errors during subscription fetch. - No bare
//narration comments introduced (verified via grep) — clean underORBIT0001.
Recommendation
Approve as-is. No Critical/High findings; the diff is test-focused with one minimal, well-scoped, empirically-verified source fix.



Summary
Closes the
/prod-readinesstests-audit gaps around the Stripe webhook and checkout-session handlers with intelligent behavior/edge/failure tests, plus one minimal source fix. Every test asserts resulting state /Result, not just that a mock was invoked, so each can fail if the behavior breaks.Source change (minimal, required to be correct)
HandleWebhookCommand— reject malformed/empty webhook bodies gracefully. Verified empirically:EventUtility.ConstructEvent(Stripe.net 52.1.0) throwsSystem.Text.Json.JsonException(aJsonReaderException) — not aStripeException— on unparseable/empty payloads. Those previously escaped the handler unhandled → HTTP 500 → Stripe retries a body that can never parse. Now caught at the trust boundary and returned as anInvalidWebhookSignaturefailure, mirroring the existing signature-failure path.Payment-failure decision (no source handler — intentional safe default)
invoice.payment_failedis intentionally not given a downgrade handler. Immediately downgrading on a transient card decline would wrongly kick a paying user off Pro; the correct Stripe pattern is to let dunning run and downgrade via the eventualcustomer.subscription.updated(unpaid/canceled) orcustomer.subscription.deletedevents, which are already handled. A test now locks this safe default (a Pro user is not downgraded, noSaveChanges).New tests
HandleWebhookCommandHandlerTestscustomer.subscription.created→ safe no-op (setup is owned bycheckout.session.completed; repo not queried, no save)invoice.payment_failed→ does not downgrade a Pro user (dunning owns the lifecycle)WebhookStripeApiErrorfailure, no saveOperationCanceledExceptionduring fetch → propagates, not swallowed as a generic failurecustomer.subscription.updatedwith a non-subscription data object → safe no-op (null-cast guard)GetPeriodEndfalls back to Monthly + 1 monthweek) → defaults to Monthly while keeping the item's period endCreateCheckoutCommandHandlerTestsResult(no unhandled throw)Verification
dotnet buildclean;dotnet test→ 2749 passed, 0 failed.//comments (ORBIT0001).Refs thomasluizon/orbit-ui-mobile#243