Files
member-console/internal/server/billing_checkout_gate_test.go
T
cgalo5758 efe3f1528d Restrict member surfaces to published products
Add lifecycle_status = 'published' to the public-catalog queries
(plans and add-ons listings) and reject checkout before any Stripe
call unless the product behind the price clears the shared member
gate (published + active + public). The currently-enrolled ladder
rung stays renderable even if its product is later drafted or
retired, fetched directly so members keep seeing what they are on.

Introduce a single evaluateMemberGate definition shared by the
catalog paths and the operator readiness panel so the surfaces
cannot disagree about what is publishable for members.
2026-08-22 12:58:05 -05:00

147 lines
5.7 KiB
Go

package server
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"github.com/alexedwards/scs/v2"
)
// fakeCheckoutQuerier stubs only GetPrice and GetProductByID: the checkout
// gate must reject an unpublished product before touching any other
// billing.Querier method (ladder lookup, billing account, Stripe mapping) or
// making a Stripe API call. The embedded nil interface panics loudly if
// HandleCheckout ever reaches further than that, same convention as
// fakePricesQuerier in product_readiness_default_price_test.go.
type fakeCheckoutQuerier struct {
billing.Querier
price billing.Price
product billing.Product
}
func (f fakeCheckoutQuerier) GetPrice(_ context.Context, _ string) (billing.Price, error) {
return f.price, nil
}
func (f fakeCheckoutQuerier) GetProductByID(_ context.Context, _ string) (billing.Product, error) {
return f.product, nil
}
// controlCheckoutQuerier extends fakeCheckoutQuerier with just enough more
// stubbing to clear the already-subscribed ladder guard, so the request can
// reach (and be stopped by) the next real precondition -- proving the member
// gate itself opened for a published product rather than refusing everything.
type controlCheckoutQuerier struct {
fakeCheckoutQuerier
}
func (f controlCheckoutQuerier) ListLaddersByProduct(_ context.Context, _ string) ([]billing.ListLaddersByProductRow, error) {
return nil, nil
}
func (f controlCheckoutQuerier) ListBillingAccountsByOrgID(_ context.Context, _ string) ([]billing.Account, error) {
return nil, nil
}
// checkoutSession builds an authenticated request context the way the member
// checkout form would send it: an org_id in session, price_id in the form body.
func checkoutSession(t *testing.T, orgID string) (context.Context, *auth.Config) {
t.Helper()
sm := scs.New()
sctx, err := sm.Load(context.Background(), "")
if err != nil {
t.Fatalf("load session: %v", err)
}
sm.Put(sctx, "authenticated", true)
sm.Put(sctx, "org_id", orgID)
return sctx, &auth.Config{SessionManager: sm}
}
// TestHandleCheckoutRejectsUnpublishedProduct pins the stripe-subscription-creation
// "Checkout rejects unpublished product" scenario: a request for a price whose
// product fails the shared member gate (draft, retired, inactive, or
// non-public) is rejected with 400 before any further billing lookup or Stripe
// call is attempted (purchase-path-blockers task 1.3).
func TestHandleCheckoutRejectsUnpublishedProduct(t *testing.T) {
cases := []struct {
name string
product billing.Product
}{
{"draft product", billing.Product{ProductID: "prod-draft", LifecycleStatus: "draft", IsActive: true, IsPublic: true}},
{"retired product", billing.Product{ProductID: "prod-retired", LifecycleStatus: "retired", IsActive: true, IsPublic: true}},
{"inactive product", billing.Product{ProductID: "prod-inactive", LifecycleStatus: "published", IsActive: false, IsPublic: true}},
{"non-public product", billing.Product{ProductID: "prod-internal", LifecycleStatus: "published", IsActive: true, IsPublic: false}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
q := fakeCheckoutQuerier{
price: billing.Price{PriceID: "price-1", ProductID: tc.product.ProductID, IsActive: true},
product: tc.product,
}
sctx, authCfg := checkoutSession(t, "org-1")
h := &BillingCheckoutHandler{
BillingQ: q,
AuthConfig: authCfg,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
BaseURL: "https://example.test",
}
form := url.Values{"price_id": {"price-1"}}
req := httptest.NewRequest(http.MethodPost, "/billing/checkout", strings.NewReader(form.Encode())).WithContext(sctx)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCheckout(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if em := rec.Body.String(); strings.Contains(em, "—") {
t.Errorf("rejection message contains an em dash: %q", em)
}
})
}
}
// TestHandleCheckoutAllowsPublishedProduct is the control for the gate above:
// a published, active, public product's price is not rejected by the member
// gate. It stops one precondition later instead ("no billing account found"),
// which proves the gate itself opened rather than the whole handler
// accidentally refusing every request with the same message.
func TestHandleCheckoutAllowsPublishedProduct(t *testing.T) {
q := controlCheckoutQuerier{fakeCheckoutQuerier{
price: billing.Price{PriceID: "price-1", ProductID: "prod-1", IsActive: true},
product: billing.Product{ProductID: "prod-1", LifecycleStatus: "published", IsActive: true, IsPublic: true},
}}
sctx, authCfg := checkoutSession(t, "org-1")
h := &BillingCheckoutHandler{
BillingQ: q,
AuthConfig: authCfg,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
BaseURL: "https://example.test",
}
form := url.Values{"price_id": {"price-1"}}
req := httptest.NewRequest(http.MethodPost, "/billing/checkout", strings.NewReader(form.Encode())).WithContext(sctx)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCheckout(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if body := rec.Body.String(); !strings.Contains(body, "no billing account found") {
t.Errorf("body = %q, want the no-billing-account rejection (proves the member gate did not block this published product)", body)
}
}