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.
147 lines
5.7 KiB
Go
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)
|
|
}
|
|
}
|