fix(distribution,audit): propagate ErrSubscriptionNoPayment to handler + P0.12 closure date + E2E regression TODO

Self-review of the v1.0.6.2 hotfix surfaced that
distribution.checkEligibility silently swallowed
subscription.ErrSubscriptionNoPayment as "ineligible, no extra info",
so a user with a fantôme subscription trying to submit a distribution
got "Distribution requires Creator or Premium plan" — misleading, the
user has a plan but no payment. checkEligibility now propagates the
error so the handler can surface "Your subscription is not linked to
a payment. Complete payment to enable distribution."

Security is unchanged — the gate still refuses. This is a UX clarity
fix for honest-path users who landed in the fantôme state via a
broken payment flow.

Also:
- Closure timestamp added to axis-1 P0.12 ("closed 2026-04-17 in
  v1.0.6.2 (commit 9a8d2a4e7)") so future readers know the finding's
  lifecycle without re-grepping the CHANGELOG.
- Item G in v107-plan.md gains an explicit E2E Playwright @critical
  acceptance — the shell probe + Go unit tests validate the fix
  today but don't run on every commit, so a refactor of Subscribe or
  checkEligibility could silently re-open the bypass. The E2E test
  makes regression coverage automatic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
senke 2026-04-17 12:43:21 +02:00
parent 68a0d390e2
commit 26cb523334
4 changed files with 25 additions and 6 deletions

View file

@ -423,8 +423,8 @@ can_sell_on_marketplace` check on paid plans.
a mandatory `pending_payment` state + webhook-driven activation a mandatory `pending_payment` state + webhook-driven activation
(item G in the v1.0.7 plan). (item G in the v1.0.7 plan).
**Criticity** — **P0, closed in v1.0.6.2.** Item G in v1.0.7 hardens **Criticity** — **P0, closed 2026-04-17 in v1.0.6.2 (commit
the creation path end-to-end. d31f5733d).** Item G in v1.0.7 hardens the creation path end-to-end.
--- ---

View file

@ -255,6 +255,13 @@ Acceptance:
- Subscribe with provider misconfigured → 503, no row created. - Subscribe with provider misconfigured → 503, no row created.
- Migration of v1.0.6.2 voided rows — check `voided_subscriptions_20260417` - Migration of v1.0.6.2 voided rows — check `voided_subscriptions_20260417`
entries stay readable and not re-pickable by the new flow. entries stay readable and not re-pickable by the new flow.
- **E2E Playwright @critical**: `POST /subscribe` followed by
`POST /distribution/submit` asserts 403 with the "complete
payment" message until the payment webhook fires. Today's
regression coverage is the shell probe + Go unit tests —
neither runs on every commit. Wiring a Playwright @critical
test turns the probe into a gate so a refactor of `Subscribe`
or `checkEligibility` cannot silently re-open the bypass.
Independent of A/B/C/D/E/F. Can land at any point after D. Independent of A/B/C/D/E/F. Can land at any point after D.

View file

@ -244,12 +244,18 @@ func (s *Service) checkEligibility(ctx context.Context, userID uuid.UUID) (bool,
sub, err := s.subscriptionService.GetUserSubscription(ctx, userID) sub, err := s.subscriptionService.GetUserSubscription(ctx, userID)
if err != nil { if err != nil {
// v1.0.6.2: ErrSubscriptionNoPayment means a row exists in // No subscription row: ineligible with no extra signal — handler
// active/trialing but has no effective payment linkage. Treat as // surfaces the standard "Creator or Premium plan required" message.
// ineligible, same blast radius as no subscription at all. if errors.Is(err, subscription.ErrNoActiveSubscription) {
if errors.Is(err, subscription.ErrNoActiveSubscription) || errors.Is(err, subscription.ErrSubscriptionNoPayment) {
return false, nil return false, nil
} }
// v1.0.6.2: propagate ErrSubscriptionNoPayment so the handler can
// surface a distinct message ("complete payment") instead of the
// generic "upgrade your plan" — the user has a plan, just no
// effective payment linkage.
if errors.Is(err, subscription.ErrSubscriptionNoPayment) {
return false, err
}
return false, err return false, err
} }

View file

@ -7,6 +7,7 @@ import (
"time" "time"
"veza-backend-api/internal/core/distribution" "veza-backend-api/internal/core/distribution"
"veza-backend-api/internal/core/subscription"
apperrors "veza-backend-api/internal/errors" apperrors "veza-backend-api/internal/errors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -46,6 +47,11 @@ func (h *DistributionHandler) Submit(c *gin.Context) {
switch { switch {
case errors.Is(err, distribution.ErrNotEligible): case errors.Is(err, distribution.ErrNotEligible):
RespondWithAppError(c, apperrors.NewForbiddenError("Distribution requires Creator or Premium plan")) RespondWithAppError(c, apperrors.NewForbiddenError("Distribution requires Creator or Premium plan"))
case errors.Is(err, subscription.ErrSubscriptionNoPayment):
// v1.0.6.2: the user has a plan but no effective payment
// linkage. Distinct from ErrNotEligible so the UX can tell
// them to complete payment rather than upgrade.
RespondWithAppError(c, apperrors.NewForbiddenError("Your subscription is not linked to a payment. Complete payment to enable distribution."))
case errors.Is(err, distribution.ErrTrackNotPublic): case errors.Is(err, distribution.ErrTrackNotPublic):
RespondWithAppError(c, apperrors.NewValidationError("Track must be public and belong to you")) RespondWithAppError(c, apperrors.NewValidationError("Track must be public and belong to you"))
case errors.Is(err, distribution.ErrAlreadyDistributed): case errors.Is(err, distribution.ErrAlreadyDistributed):