Files
cgalo5758 29520a8120 Set the Stripe key once at boot and read the boot mode everywhere
First section of #141. start assigns the stripe-go key through UseKey,
directly after it derives the key's mode and fingerprint and before any
goroutine starts; the Stripe integration's Startup no longer assigns it,
and nothing else does. UseKey assigns the trimmed key, so the SDK sends
the key whose mode and fingerprint boot derived, and an empty key leaves
the SDK's key as it was.

Every surface that asked a function for the key mode now reads the mode
boot derived: Checkout, the plan moves and the eager reconcile in
internal/server, the fulfillment entry points (SwitchPlan,
KeepCurrentPlan, CancelSubscription, the two reconciles and the
scheduled-change sweep), and the worker's billing activities. The Stripe
workflows' activities still read the SDK's key, which start set before
the worker existed. SDKKeyMode, Config.StripeKeyMode and
WorkerConfig.StripeKeyMode are gone. stripe-api-key is a secret, which
an operator override cannot replace, so the value boot reads is the one
Startup used to assign.

One behaviour changes until the next section lands: with no
temporal-host, the SDK now holds the key, so checkout sessions and plan
moves reach Stripe while the webhooks they cause are stored and never
processed. The next section refuses that configuration; the two are not
deployed apart.

The tests pass the boot mode, stripetest.KeyMode(), where they passed
the function. TestCheckoutThroughTheRouter lost its count of how often
the mode function was asked, since the function no longer exists, and
gained a row that posts a price mapped in live mode under the test key
and wants 422, which fails if the router hands Checkout an empty mode.
The eager reconcile's wiring is covered by no break.

Race baseline (go test -race -p 1 -count=1 over cmd, auth, server,
workflows and the three integrations, on bdd042bc after
reset-test-db.sh): 19 packages pass, no data race. An earlier run
without -p 1 failed two desiredstate relay tests on counts, because the
packages ran in parallel over one test database; the race gates now run
with -p 1 after reset-test-db.sh, as make test does.

Full suite on reset databases: 4373 pass, 15 skip, none failed. make lint passes. Breaks,
each caught:
- UseKey assigns an empty key
- Startup assigns stripego.Key from the configuration again
- the checkout handler compares against an empty mode
- SwitchPlan reads an empty mode
- SweepDueScheduledChanges passes an empty mode to the change it fires
- UseKey assigns the untrimmed key
- the router hands Checkout an empty mode
2026-10-23 13:39:27 -04:00

446 lines
18 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
// The router NewHandler builds, on the test database: every page and partial
// a signed-in person can GET, Checkout's configuration, and the facts the
// member pages compute before they render (new_handler_test.go holds the
// answers that need no database).
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"testing"
"time"
stripe "github.com/stripe/stripe-go/v81"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
"git.coopcloud.tech/wiki-cafe/member-console/internal/instance"
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/stripetest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/authtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/golden"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
)
// onDatabase points every querier of the configured profile at db.
func onDatabase(db *sql.DB) func(*Config) {
return func(c *Config) {
c.Database = db
c.IdentityQ, c.OrgQ, c.EntitlementsQ = identity.New(db), organization.New(db), entitlements.New(db)
c.BillingQ, c.StripeQ, c.DomainsQ = billing.New(db), stripedb.New(db), domains.New(db)
}
}
// signIn returns the cookie of a session signed in as s.
func (e *routeEnv) signIn(t *testing.T, s auth.SignIn) *http.Cookie {
t.Helper()
return authtest.Cookie(t, e.sm, authtest.SignIn(t, e.sm, s))
}
// get runs a GET of path through the router as the cookie's session; htmx
// says whether it asks as htmx does.
func (e *routeEnv) get(cookie *http.Cookie, path string, htmx bool) *httptest.ResponseRecorder {
return e.serve(probe(http.MethodGet, path, func(r *http.Request) {
r.AddCookie(cookie)
if htmx {
r.Header.Set("HX-Request", "true")
}
}))
}
// member signs in the owner of a new organization of orgType with one
// workspace.
func member(t *testing.T, w *world.World, e *routeEnv, orgType, name string) (world.Org, world.Workspace, *http.Cookie) {
t.Helper()
owner := w.Person(name)
o := w.Org(name, orgType, owner)
ws := w.Workspace(o, "Main")
s := owner.SignIn()
s.OrgID, s.WorkspaceID = o.ID, ws.ID
return o, ws, e.signIn(t, s)
}
// catalogListRoutes answer with a list of the database's active ladders or
// add-on products, so the body depends on what other tests have left there;
// the golden records their status and location only.
var catalogListRoutes = map[string]bool{
"GET /partials/member/plans": true,
"GET /partials/member/addons": true,
}
// pageAnswer is a signed-in GET's status, where it redirects, and the page's
// heading, or for a fragment its first element's opening tag; with
// statusOnly, neither.
func pageAnswer(t *testing.T, n *golden.Normalizer, rec *httptest.ResponseRecorder, statusOnly bool) string {
t.Helper()
out := fmt.Sprintf("%d", rec.Code)
if loc := rec.Header().Get("Location"); loc != "" {
out += " loc=" + loc
}
if statusOnly || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") || rec.Body.Len() == 0 {
return n.Apply(out)
}
doc := htmltest.Parse(t, rec.Body.String())
if h := doc.Texts("main h1"); len(h) > 0 {
return n.Apply(out + " h1=" + h[0])
}
if top := doc.All(":root"); len(top) > 0 {
tag, _, _ := strings.Cut(top[0].HTML(), ">")
out += " first=" + tag + ">"
}
return n.Apply(out)
}
// TestNewHandlerPages holds every GET route outside the sign-in flow to
// testdata/route_pages.golden, as a member for the member surface and as an
// operator for the operator surface. A detail route gets the zero UUID, so
// its not-found answer is what it records. No Stripe call is answered.
func TestNewHandlerPages(t *testing.T) {
db := dbtest.Open(t)
w := world.New(t, db)
t.Cleanup(stripetest.Install(&stripetest.MockBackend{Err: errors.New("stripetest: the routing test answers no Stripe call")}))
env := newRouteEnv(t, onDatabase(db))
n := newGoldenNormalizer(context.Background(), t, w)
_, _, asMember := member(t, w, env, w.OrgType("routes"), "Member")
op := w.Person("Operator").SignIn()
op.Roles = []string{OperatorRole}
asOperator := env.signIn(t, op)
var b strings.Builder
for _, r := range routeRows(t) {
if r.method != http.MethodGet || r.auth || r.key == "GET /hooks/panic" {
continue
}
cookie := asMember
if strings.HasPrefix(r.probe, "/operator") || strings.HasPrefix(r.probe, "/partials/operator") {
cookie = asOperator
}
fmt.Fprintf(&b, "%s %s\n", r.key, pageAnswer(t, n, env.get(cookie, r.probe, false), catalogListRoutes[r.key]))
}
ask := env.get(asMember, "/domains/ask?domain=unknown-"+w.Sfx+".example", false)
fmt.Fprintf(&b, "ask: an unknown domain %d %s\n", ask.Code, strings.TrimSpace(ask.Body.String()))
golden.Compare(t, "testdata/route_pages.golden", b.String())
}
// TestCheckoutThroughTheRouter: Checkout, built from the server's
// configuration, checks the posted price against the boot mode and sends
// Stripe the configured base URL to return to.
func TestCheckoutThroughTheRouter(t *testing.T) {
const sessionURL = "https://checkout.example.test/c/pay/cs_test_routes"
db := dbtest.Open(t)
w := world.New(t, db)
plan := w.Product("Plan")
price := w.Price(plan, 1500)
w.MapPrice(price)
w.Ladder("Checkout", plan)
livePlan := w.Product("Live plan")
livePrice := w.Price(livePlan, 2500)
w.MapPriceIn(livePrice, true)
env := newRouteEnv(t, onDatabase(db))
o, _, cookie := member(t, w, env, w.OrgType("cart"), "Buyer")
w.MapAccountIn(w.BillingAccount(o, "Primary"), false)
mock := &stripetest.MockBackend{Handle: func(c stripetest.Call) (any, error, bool) {
if c.Method == http.MethodPost && c.Path == "/v1/checkout/sessions" {
return &stripe.CheckoutSession{ID: "cs_test_routes", URL: sessionURL}, nil, true
}
return nil, nil, false
}}
t.Cleanup(stripetest.Install(mock))
req := httptest.NewRequest(http.MethodPost, "/billing/checkout", strings.NewReader(url.Values{"price_id": {price.ID}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := env.serve(req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != sessionURL {
t.Fatalf("checkout = %d to %q, want 303 to the session", rec.Code, rec.Header().Get("Location"))
}
live := httptest.NewRequest(http.MethodPost, "/billing/checkout", strings.NewReader(url.Values{"price_id": {livePrice.ID}}.Encode()))
live.Header.Set("Content-Type", "application/x-www-form-urlencoded")
live.AddCookie(cookie)
if rec := env.serve(live); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "That product is no longer available") {
t.Errorf("checkout of a live-mode price under the test key = %d %q, want 422 containing %q", rec.Code, strings.TrimSpace(rec.Body.String()), "That product is no longer available")
}
sessions := mock.CallsTo(http.MethodPost, "/v1/checkout/sessions")
if len(sessions) != 1 || sessions[0].Form.Get("success_url") != routesBaseURL+"/?checkout=success" {
t.Errorf("session requests %v, want one returning to %s", sessions, routesBaseURL)
}
}
// memberPagesEnv is the member pages' fixture: a router on the test database
// and a Stripe mock that records every call.
type memberPagesEnv struct {
db *sql.DB
orgType string
w *world.World
env *routeEnv
mock *stripetest.MockBackend
}
// TestMemberPagesThroughTheRouter holds what the dashboard, Products and
// Billing pages compute before they render, through the router.
func TestMemberPagesThroughTheRouter(t *testing.T) {
db := dbtest.Open(t)
f := &memberPagesEnv{db: db, w: world.New(t, db), mock: &stripetest.MockBackend{}}
t.Cleanup(stripetest.Install(f.mock))
f.env = newRouteEnv(t, onDatabase(db))
f.orgType = f.w.OrgType("pages")
t.Run("the workspace region shows from two workspaces", f.workspaceRegion)
t.Run("each region is what its partial answers", f.regions)
t.Run("only pending claims are listed", f.pendingClaims)
t.Run("a return from checkout shows its banner", f.checkoutBanner)
t.Run("a successful return reconciles", f.reconciles)
t.Run("a successful return shows what the reconcile conferred", f.conferred)
t.Run("no return reconciles without a Stripe key", f.noKeyNoReconcile)
t.Run("a successful return repeats the billing facts", f.billingFacts)
t.Run("the empty state follows the organization's holdings", f.emptyState)
}
func (f *memberPagesEnv) dashboard(t *testing.T, e *routeEnv, cookie *http.Cookie, query string) *htmltest.Node {
t.Helper()
rec := e.get(cookie, "/"+query, false)
if rec.Code != http.StatusOK {
t.Fatalf("GET /%s = %d", query, rec.Code)
}
return htmltest.Parse(t, rec.Body.String())
}
func (f *memberPagesEnv) workspaceRegion(t *testing.T) {
_, _, one := member(t, f.w, f.env, f.orgType, "One")
f.dashboard(t, f.env, one, "").None(t, "#workspace-management")
o, _, two := member(t, f.w, f.env, f.orgType, "Two")
f.w.Workspace(o, "Second")
f.dashboard(t, f.env, two, "").One(t, "#workspace-management")
}
// regions: each composed region holds exactly what its partial route answers
// the same session through the router, and no region failed to load.
func (f *memberPagesEnv) regions(t *testing.T) {
o, _, cookie := member(t, f.w, f.env, f.orgType, "Regions")
f.w.Workspace(o, "Second")
for page, partials := range map[string][]string{
"/": {"/partials/workspaces", "/hooks/card"},
"/products": {"/partials/member/entitlements", "/partials/member/plans", "/partials/member/addons"},
"/billing": {"/partials/member/invoices"},
} {
body := f.env.get(cookie, page, false).Body.String()
if strings.Contains(body, "Could not load") {
t.Errorf("%s has a region that could not load", page)
}
for _, p := range partials {
if part := f.env.get(cookie, p, true).Body.String(); part == "" || !strings.Contains(body, part) {
t.Errorf("%s does not hold what %s answers", page, p)
}
}
}
}
func (f *memberPagesEnv) pendingClaims(t *testing.T) {
ctx := context.Background()
_, ws, cookie := member(t, f.w, f.env, f.orgType, "Claims")
reg := domains.NewRegistry(f.db)
pending := "pending-" + f.w.Sfx + ".example"
if _, err := reg.ClaimExternal(ctx, ws.ID, pending); err != nil {
t.Fatalf("pending claim: %v", err)
}
if _, err := reg.GrandfatherExternalClaim(ctx, ws.ID, "active-"+f.w.Sfx+".example"); err != nil {
t.Fatalf("active claim: %v", err)
}
got := f.dashboard(t, f.env, cookie, "").Texts(`#member-main [role="status"] code`)
if len(got) != 1 || got[0] != pending {
t.Errorf("pending claims shown = %q, want only %s", got, pending)
}
}
func (f *memberPagesEnv) checkoutBanner(t *testing.T) {
_, _, cookie := member(t, f.w, f.env, f.orgType, "Returner")
for query, want := range map[string]string{
"?checkout=success": "Thanks!",
"?checkout=cancel": "Checkout canceled.",
"?checkout=other": "",
} {
got := f.dashboard(t, f.env, cookie, query).Texts(`#member-main [role="alert"]`)
switch {
case want == "" && len(got) != 0:
t.Errorf("%s shows %q, want no banner", query, got)
case want != "" && (len(got) != 1 || !strings.HasPrefix(got[0], want)):
t.Errorf("%s shows %q, want one banner starting %q", query, got, want)
}
}
}
// subscriptionReads is how many times Stripe was asked for the customer's
// subscriptions.
func (f *memberPagesEnv) subscriptionReads(customer string) int {
n := 0
for _, c := range f.mock.CallsTo(http.MethodGet, "/v1/subscriptions") {
if c.Form.Get("customer") == customer {
n++
}
}
return n
}
// customer gives o a billing account mapped to a Stripe customer and returns
// the customer's id.
func (f *memberPagesEnv) customer(t *testing.T, o world.Org) string {
t.Helper()
a := f.w.BillingAccount(o, "Primary")
f.w.MapAccountIn(a, false)
m, err := stripedb.New(f.db).GetCustomerMappingByBillingAccountID(context.Background(), a.ID)
if err != nil {
t.Fatalf("customer mapping: %v", err)
}
return m.StripeCustomerID.String
}
func (f *memberPagesEnv) reconciles(t *testing.T) {
o, _, cookie := member(t, f.w, f.env, f.orgType, "Reconciled")
cus := f.customer(t, o)
f.dashboard(t, f.env, cookie, "?checkout=cancel")
if n := f.subscriptionReads(cus); n != 0 {
t.Errorf("a canceled return read the subscriptions %d times, want 0", n)
}
f.dashboard(t, f.env, cookie, "?checkout=success")
if n := f.subscriptionReads(cus); n != 1 {
t.Errorf("a successful return read the subscriptions %d times, want 1", n)
}
}
// conferred: a successful return runs the reconcile before the page reads
// what the organization holds and before the page includes the regions that
// show it. One organization is read with no dashboard card, where a member
// whose customer has an active subscription to a ladder's only tier no longer
// sees the empty state; another is read with the entitlements partial as the
// only card, where the card body names the ladder only after the return.
func (f *memberPagesEnv) conferred(t *testing.T) {
cardless := newRouteEnv(t, func(c *Config) { onDatabase(f.db)(c); c.DashboardCards = nil })
carded := newRouteEnv(t, func(c *Config) {
onDatabase(f.db)(c)
c.DashboardCards = []DashboardCard{{Title: "Holdings", PartialPath: "/partials/member/entitlements"}}
})
plan := f.w.Product("Conferred plan")
price := f.w.Price(plan, 1500)
f.w.MapPrice(price)
ladder := f.w.Ladder("Conferred", plan)
o, _, cookie := member(t, f.w, cardless, f.orgType, "Conferred")
f.subscribe(t, f.customer(t, o), price)
if got := f.dashboard(t, cardless, cookie, "?checkout=cancel").Texts("#member-main p"); !showsEmptyState(got) {
t.Fatalf("a canceled return shows %q, want %q", got, emptyStateHeadline)
}
if got := f.dashboard(t, cardless, cookie, "?checkout=success").Texts("#member-main p"); showsEmptyState(got) {
t.Errorf("a successful return still shows the empty state, though the reconcile conferred a rung")
}
o, _, cookie = member(t, f.w, carded, f.orgType, "Conferred card")
f.subscribe(t, f.customer(t, o), price)
if body := f.dashboard(t, carded, cookie, "?checkout=cancel").One(t, "#member-main").Text(); strings.Contains(body, ladder.Name) {
t.Fatalf("a canceled return shows the ladder in the holdings card: %q", body)
}
if body := f.dashboard(t, carded, cookie, "?checkout=success").One(t, "#member-main").Text(); !strings.Contains(body, ladder.Name) {
t.Errorf("a successful return shows no ladder in the holdings card, though the reconcile conferred a rung: %q", body)
}
}
// subscribe makes Stripe answer with one active subscription of the customer
// to the price, until the test ends.
func (f *memberPagesEnv) subscribe(t *testing.T, customer string, price world.Price) {
t.Helper()
now := time.Now().Unix()
f.mock.Sub = &stripe.Subscription{
ID: "sub_" + customer,
Status: stripe.SubscriptionStatusActive,
Customer: &stripe.Customer{ID: customer},
CurrentPeriodStart: now,
CurrentPeriodEnd: now + 2592000,
Items: &stripe.SubscriptionItemList{Data: []*stripe.SubscriptionItem{
{ID: "si_" + customer, Quantity: 1, Price: &stripe.Price{ID: "price_" + price.ID}},
}},
}
f.mock.List = []*stripe.Subscription{f.mock.Sub}
t.Cleanup(func() { f.mock.Sub, f.mock.List = nil, nil })
}
func (f *memberPagesEnv) noKeyNoReconcile(t *testing.T) {
bare := newRouteEnv(t, func(c *Config) { onDatabase(f.db)(c); c.StripeAPIKey = "" })
o, _, cookie := member(t, f.w, bare, f.orgType, "Keyless")
cus := f.customer(t, o)
f.dashboard(t, bare, cookie, "?checkout=success")
if n := f.subscriptionReads(cus); n != 0 {
t.Errorf("a return without a Stripe key read the subscriptions %d times, want 0", n)
}
}
// billingFacts stores a public-details record for the router's key and puts
// back whatever the database held before.
func (f *memberPagesEnv) billingFacts(t *testing.T) {
ctx := context.Background()
store := instance.NewStore(f.db)
prev, found, err := store.GetJSON(ctx, instance.StripePublicDetails)
if err != nil {
t.Fatalf("read the public details: %v", err)
}
t.Cleanup(func() {
if found {
_ = store.SetJSON(ctx, instance.StripePublicDetails, prev, "")
return
}
_, _ = f.db.ExecContext(ctx, `DELETE FROM core.instance_settings WHERE key = $1`, string(instance.StripePublicDetails))
})
now := time.Now()
name := "Riverside Makers " + f.w.Sfx
if err := store.SetJSON(ctx, instance.StripePublicDetails, stripedb.PublicDetailsRecord{
KeyFingerprint: f.env.cfg.StripeKeyFingerprint, ReadAt: &now,
Name: stripedb.PublicDetail{State: stripedb.DetailRead, Value: name},
}, ""); err != nil {
t.Fatalf("store the public details: %v", err)
}
_, _, cookie := member(t, f.w, f.env, f.orgType, "Billed")
got := f.dashboard(t, f.env, cookie, "?checkout=success").Texts(`#member-main [role="alert"] div`)
if len(got) != 1 || got[0] != "Billed by "+name+"." {
t.Errorf("billing facts = %q, want Billed by %s.", got, name)
}
}
func (f *memberPagesEnv) emptyState(t *testing.T) {
cardless := newRouteEnv(t, func(c *Config) { onDatabase(f.db)(c); c.DashboardCards = nil })
_, _, empty := member(t, f.w, cardless, f.orgType, "Empty")
if got := f.dashboard(t, cardless, empty, "").Texts("#member-main p"); !showsEmptyState(got) {
t.Errorf("an organization holding nothing shows %q, want %q", got, emptyStateHeadline)
}
o, _, holding := member(t, f.w, cardless, f.orgType, "Holding")
p := f.w.Product("Held")
f.w.Ladder("Held", p)
f.w.Confer(o, p)
if got := f.dashboard(t, cardless, holding, "").Texts("#member-main p"); showsEmptyState(got) {
t.Errorf("an organization holding a rung shows the empty state")
}
}
// emptyStateHeadline is what the dashboard says to an organization that holds
// nothing.
const emptyStateHeadline = "Nothing to show yet."
// showsEmptyState reports whether the dashboard's paragraphs hold the empty
// state's headline.
func showsEmptyState(texts []string) bool {
return slices.Contains(texts, emptyStateHeadline)
}