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.
367 lines
13 KiB
Go
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")
|
|
}
|
|
}
|