Files
member-console/internal/server/member_lifecycle_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

367 lines
13 KiB
Go

package server_test
import (
"context"
"database/sql"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"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/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
internalstripe "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/server"
"github.com/alexedwards/scs/v2"
"github.com/google/uuid"
)
// TestDraftProductHiddenFromMemberCatalog covers member-product-discovery's
// "Draft product hidden from member catalog" scenario: a draft product that is
// otherwise fully purchasable (active, public, priced, and Stripe-mapped) must
// not appear in the plans catalog or the add-ons listing, because
// ListPublicProducts/ListPublicPlanProducts now filter lifecycle_status =
// 'published' (purchase-path-blockers task 1.1).
func TestDraftProductHiddenFromMemberCatalog(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
bq := billing.New(tx)
eq := entitlements.New(tx)
iq := identity.New(tx)
oq := organization.New(tx)
sq := internalstripe.New(tx)
sfx := uuid.New().String()[:8]
es, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "draft-gate-set-" + sfx,
})
if err != nil {
t.Fatalf("create entitlement set: %v", err)
}
esID := uuid.NullUUID{UUID: uuid.MustParse(es.SetID), Valid: true}
// A draft add-on: active, public, priced, and Stripe-mapped -- every axis
// green except lifecycle_status.
addon, err := bq.CreateProduct(ctx, billing.CreateProductParams{
Name: "Hidden Addon " + sfx,
DisplayCategory: sql.NullString{String: "addon", Valid: true},
IsActive: true,
IsPublic: true,
EntitlementSetID: esID,
LifecycleStatus: "draft",
})
if err != nil {
t.Fatalf("create addon product: %v", err)
}
addonPrice, err := bq.CreatePrice(ctx, billing.CreatePriceParams{
ProductID: addon.ProductID, Currency: "usd", UnitAmount: 500,
})
if err != nil {
t.Fatalf("create addon price: %v", err)
}
if _, err := sq.UpsertPriceMapping(ctx, internalstripe.UpsertPriceMappingParams{
PriceID: addonPrice.PriceID,
StripePriceID: sql.NullString{String: "price_stripe_addon_" + sfx, Valid: true},
SyncStatus: "synced",
}); err != nil {
t.Fatalf("addon price mapping: %v", err)
}
// A draft plan product tiered onto a ladder nobody is enrolled on: active,
// public, priced, and Stripe-mapped.
plan, err := bq.CreateProduct(ctx, billing.CreateProductParams{
Name: "Hidden Plan " + sfx,
IsActive: true,
IsPublic: true,
EntitlementSetID: esID,
LifecycleStatus: "draft",
})
if err != nil {
t.Fatalf("create plan product: %v", err)
}
planPrice, err := bq.CreatePrice(ctx, billing.CreatePriceParams{
ProductID: plan.ProductID, Currency: "usd", UnitAmount: 1000, RecurringInterval: sql.NullString{String: "month", Valid: true},
})
if err != nil {
t.Fatalf("create plan price: %v", err)
}
if _, err := sq.UpsertPriceMapping(ctx, internalstripe.UpsertPriceMappingParams{
PriceID: planPrice.PriceID,
StripePriceID: sql.NullString{String: "price_stripe_plan_" + sfx, Valid: true},
SyncStatus: "synced",
}); err != nil {
t.Fatalf("plan price mapping: %v", err)
}
ladder, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{
LadderKey: "draft-gate-" + sfx, Name: "Draft Gate Ladder", IsActive: true,
})
if err != nil {
t.Fatalf("create ladder: %v", err)
}
if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
PlanLadderID: ladder.PlanLadderID, ProductID: plan.ProductID,
}); err != nil {
t.Fatalf("create tier: %v", err)
}
// A member org with no ladder attachment at all -- neither product is its
// current rung, so both must simply be invisible.
user, err := iq.CreateUser(ctx, "u-"+uuid.New().String())
if err != nil {
t.Fatalf("create user: %v", err)
}
person, err := iq.CreatePerson(ctx, identity.CreatePersonParams{
UserID: user.UserID,
DisplayName: "Draft Gate Member",
PrimaryEmail: "draft-gate-" + sfx + "@example.com",
PrimaryEmailVerified: true,
})
if err != nil {
t.Fatalf("create person: %v", err)
}
org, err := oq.CreateOrganization(ctx, organization.CreateOrganizationParams{
Name: "Draft Gate Org",
Slug: "draft-gate-org-" + sfx,
OrgType: "personal",
OwnerPersonID: person.PersonID,
})
if err != nil {
t.Fatalf("create org: %v", err)
}
sm := scs.New()
sctx, err := sm.Load(ctx, "")
if err != nil {
t.Fatalf("load session: %v", err)
}
sm.Put(sctx, "authenticated", true)
sm.Put(sctx, "org_id", org.OrgID)
h, err := server.NewMemberProductsHandler(server.MemberProductsConfig{
EntitlementsQ: eq,
BillingQ: bq,
AuthConfig: &auth.Config{SessionManager: sm},
Logger: discardLogger(),
})
if err != nil {
t.Fatalf("new handler: %v", err)
}
plansReq := httptest.NewRequest(http.MethodGet, "/partials/member/plans", nil).WithContext(sctx)
plansRec := httptest.NewRecorder()
h.GetPlans(plansRec, plansReq)
if plansRec.Code != 200 {
t.Fatalf("plans: expected 200, got %d: %s", plansRec.Code, plansRec.Body.String())
}
if strings.Contains(plansRec.Body.String(), "Hidden Plan "+sfx) {
t.Errorf("draft plan product must not appear in the plans listing")
}
addonsReq := httptest.NewRequest(http.MethodGet, "/partials/member/addons", nil).WithContext(sctx)
addonsRec := httptest.NewRecorder()
h.GetAddons(addonsRec, addonsReq)
if addonsRec.Code != 200 {
t.Fatalf("addons: expected 200, got %d: %s", addonsRec.Code, addonsRec.Body.String())
}
if strings.Contains(addonsRec.Body.String(), "Hidden Addon "+sfx) {
t.Errorf("draft add-on product must not appear in the add-ons listing")
}
}
// TestCurrentRungSurvivesRetirement covers member-product-discovery's
// "Enrolled rung on an unpublished product still renders" and "Retired
// product hidden from prospective buyers" scenarios together: an org enrolled
// on a tier before it was retired keeps seeing that tier as its current plan
// (name, description, current marker, no move control), while an org that was
// never enrolled on it does not see the tier at all.
func TestCurrentRungSurvivesRetirement(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
bq := billing.New(tx)
eq := entitlements.New(tx)
iq := identity.New(tx)
oq := organization.New(tx)
sfx := uuid.New().String()[:8]
es, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "retire-set-" + sfx,
})
if err != nil {
t.Fatalf("create entitlement set: %v", err)
}
esID := uuid.NullUUID{UUID: uuid.MustParse(es.SetID), Valid: true}
// Created (and enrolled) while published; retired afterward, mirroring an
// operator retiring a product a member is already subscribed to.
tier, err := bq.CreateProduct(ctx, billing.CreateProductParams{
Name: "Legacy Tier " + sfx,
Description: sql.NullString{String: "The tier this org is grandfathered on.", Valid: true},
IsActive: true,
IsPublic: true,
EntitlementSetID: esID,
LifecycleStatus: "published",
})
if err != nil {
t.Fatalf("create tier product: %v", err)
}
ladder, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{
LadderKey: "retire-ladder-" + sfx, Name: "Retire Ladder", IsActive: true,
})
if err != nil {
t.Fatalf("create ladder: %v", err)
}
if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
PlanLadderID: ladder.PlanLadderID, ProductID: tier.ProductID,
}); err != nil {
t.Fatalf("create tier row: %v", err)
}
// Enrolled org: person + org + pool, then a grant conferred onto the pool
// while the product is still published (core.confer rejects draft/retired
// products outright -- this is intake, done before the retirement below).
mkPersonOrgPool := func(label string) (person identity.Person, org organization.Organization, pool entitlements.ResourcePool) {
user, err := iq.CreateUser(ctx, "u-"+uuid.New().String())
if err != nil {
t.Fatalf("create user: %v", err)
}
person, err = iq.CreatePerson(ctx, identity.CreatePersonParams{
UserID: user.UserID,
DisplayName: label,
PrimaryEmail: label + "-" + sfx + "@example.com",
PrimaryEmailVerified: true,
})
if err != nil {
t.Fatalf("create person: %v", err)
}
org, err = oq.CreateOrganization(ctx, organization.CreateOrganizationParams{
Name: label + " Org",
Slug: strings.ToLower(label) + "-org-" + sfx,
OrgType: "personal",
OwnerPersonID: person.PersonID,
})
if err != nil {
t.Fatalf("create org: %v", err)
}
pool, err = eq.CreateResourcePool(ctx, entitlements.CreateResourcePoolParams{
OrgID: org.OrgID, Name: "default", Slug: "default", PoolType: "default", IsAutoManaged: true,
})
if err != nil {
t.Fatalf("create pool: %v", err)
}
return person, org, pool
}
enrolledPerson, enrolledOrg, enrolledPool := mkPersonOrgPool("Enrolled")
_, nonEnrolledOrg, _ := mkPersonOrgPool("NonEnrolled")
grant, err := eq.CreateGrant(ctx, entitlements.CreateGrantParams{
ProductID: tier.ProductID,
GrantedToOrgID: uuid.NullUUID{UUID: uuid.MustParse(enrolledOrg.OrgID), Valid: true},
GrantedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(enrolledPerson.PersonID), Valid: true},
GrantReason: "promotional",
Quantity: 1,
ValidFrom: time.Now(),
})
if err != nil {
t.Fatalf("create grant: %v", err)
}
_, outcome, err := eq.Confer(ctx, entitlements.ConferParams{
PoolID: enrolledPool.PoolID,
ProductID: tier.ProductID,
GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant.GrantID), Valid: true},
Quantity: 1,
})
if err != nil {
t.Fatalf("confer: %v", err)
}
if outcome != "created" {
t.Fatalf("confer outcome = %q, want created", outcome)
}
// Retire the product now that the org is enrolled on it -- the scenario
// under test.
if _, err := tx.ExecContext(ctx,
`UPDATE core.products SET lifecycle_status = 'retired' WHERE product_id = $1`, tier.ProductID,
); err != nil {
t.Fatalf("retire product: %v", err)
}
h, err := server.NewMemberProductsHandler(server.MemberProductsConfig{
EntitlementsQ: eq,
BillingQ: bq,
Logger: discardLogger(),
})
if err != nil {
t.Fatalf("new handler: %v", err)
}
// scs binds session data to a context key scoped to the SessionManager
// instance that created it, so each org's request needs its own manager
// wired onto the (shared) handler before that request runs.
sessionFor := func(orgID string) context.Context {
sm := scs.New()
sctx, err := sm.Load(ctx, "")
if err != nil {
t.Fatalf("load session: %v", err)
}
sm.Put(sctx, "authenticated", true)
sm.Put(sctx, "org_id", orgID)
h.AuthConfig = &auth.Config{SessionManager: sm}
return sctx
}
// Enrolled org still sees its current rung.
enrolledCtx := sessionFor(enrolledOrg.OrgID)
req := httptest.NewRequest(http.MethodGet, "/partials/member/plans", nil).WithContext(enrolledCtx)
rec := httptest.NewRecorder()
h.GetPlans(rec, req)
if rec.Code != 200 {
t.Fatalf("enrolled: expected 200, got %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Legacy Tier "+sfx) {
t.Errorf("enrolled org: expected the retired current-rung product's name to render")
}
if !strings.Contains(body, "The tier this org is grandfathered on.") {
t.Errorf("enrolled org: expected the retired current-rung product's description to render")
}
if !strings.Contains(body, "Current plan") {
t.Errorf("enrolled org: expected a 'Current plan' badge on the retired rung")
}
if strings.Contains(body, `hx-post="/billing/checkout"`) || strings.Contains(body, `hx-get="/partials/member/plans/switch/preview"`) {
t.Errorf("enrolled org: the retired current rung must not offer a move control")
}
// Never-enrolled org does not see the tier at all.
otherCtx := sessionFor(nonEnrolledOrg.OrgID)
req2 := httptest.NewRequest(http.MethodGet, "/partials/member/plans", nil).WithContext(otherCtx)
rec2 := httptest.NewRecorder()
h.GetPlans(rec2, req2)
if rec2.Code != 200 {
t.Fatalf("non-enrolled: expected 200, got %d: %s", rec2.Code, rec2.Body.String())
}
if strings.Contains(rec2.Body.String(), "Legacy Tier "+sfx) {
t.Errorf("non-enrolled org must not see the retired tier")
}
}