Introduce a commercial license option alongside AGPL-3.0-only, require a CLA for contributors, and document the terms in COMMERCIAL.md and NOTICE. Add a script to stamp SPDX headers on Go files and apply it across the tree.
150 lines
5.8 KiB
Go
150 lines
5.8 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
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)
|
|
}
|
|
}
|