Add an append-only ledger of entitlement set rule changes with per-pool effect rows, a preview-and-commit rule change flow, and an automatic drain that settles deferred recomputations. Rules gain a tier reduction policy, resource keys declare over-limit behavior, and the materializer now lowers limits when a rule stops applying. Add entitlement set rule change ledger and preview flow Add an append-only ledger of entitlement set rule changes with a preview-and-commit operator flow. Rule writes now go through an enclosed `core.commit_rule_change` function that files an act row and one obligation per carrying pool, with a drain workflow settling deferred recomputations. The preview dry-runs the materializer with a rule overlay and renders per-pool buckets, reduction-policy disclosures, and provider over-limit consequences. Materializing transactions take a shared advisory rendezvous that rule changes hold exclusively, enforced by a possession assertion. Add History and Entitlement changes surfaces, a rule-less warning on five product-selection surfaces, and a `tier_reduction_policy` column that gates FedWiki parking.
146 lines
5.3 KiB
Go
146 lines
5.3 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"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"
|
|
"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"
|
|
)
|
|
|
|
// TestSwitchRefusesADraftTierThroughTheHandler drives POST
|
|
// /partials/member/plans/switch end to end against the database: a draft
|
|
// tier that is otherwise fully purchasable (active, public, priced,
|
|
// Stripe-mapped) is refused with the unavailable-plan message before any
|
|
// subscription is read (2026-09 security audit, finding 12). The control
|
|
// posts for a published tier and is refused one precondition later, by
|
|
// SwitchPlan's own subscription check, which proves the gate opened.
|
|
func TestSwitchRefusesADraftTierThroughTheHandler(t *testing.T) {
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
tx, err := entitlements.BeginMaterializing(ctx, database, 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: "switch-gate-set-" + sfx})
|
|
if err != nil {
|
|
t.Fatalf("create entitlement set: %v", err)
|
|
}
|
|
esID := uuid.NullUUID{UUID: uuid.MustParse(es.SetID), Valid: true}
|
|
|
|
ladder, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{Name: "Switch Gate Ladder " + sfx, IsActive: true})
|
|
if err != nil {
|
|
t.Fatalf("create ladder: %v", err)
|
|
}
|
|
|
|
tier := func(name, lifecycle string) billing.Price {
|
|
t.Helper()
|
|
product, err := bq.CreateProduct(ctx, billing.CreateProductParams{
|
|
Name: name + " " + sfx, IsActive: true, IsPublic: true, EntitlementSetID: esID, LifecycleStatus: lifecycle,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create %s: %v", name, err)
|
|
}
|
|
price, err := bq.CreatePrice(ctx, billing.CreatePriceParams{
|
|
ProductID: product.ProductID, Currency: "usd", UnitAmount: 1000,
|
|
RecurringInterval: sql.NullString{String: "month", Valid: true},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create %s price: %v", name, err)
|
|
}
|
|
if _, err := sq.UpsertPriceMapping(ctx, internalstripe.UpsertPriceMappingParams{
|
|
PriceID: price.PriceID, StripePriceID: sql.NullString{String: "price_stripe_" + sfx + "_" + lifecycle, Valid: true}, SyncStatus: "synced",
|
|
}); err != nil {
|
|
t.Fatalf("%s price mapping: %v", name, err)
|
|
}
|
|
if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{PlanLadderID: ladder.PlanLadderID, ProductID: product.ProductID}); err != nil {
|
|
t.Fatalf("create %s tier: %v", name, err)
|
|
}
|
|
return price
|
|
}
|
|
draft := tier("Hidden Tier", "draft")
|
|
published := tier("Open Tier", "published")
|
|
|
|
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: "Switch Gate Member",
|
|
PrimaryEmail: "switch-gate-" + sfx + "@example.com", PrimaryEmailVerified: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create person: %v", err)
|
|
}
|
|
org, err := oq.CreateOrganization(ctx, organization.CreateOrganizationParams{Name: "Switch Gate Org", 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(), Database: database,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new handler: %v", err)
|
|
}
|
|
|
|
post := func(priceID string) string {
|
|
t.Helper()
|
|
form := url.Values{"ladder_id": {ladder.PlanLadderID}, "price_id": {priceID}}
|
|
req := httptest.NewRequest(http.MethodPost, "/partials/member/plans/switch", strings.NewReader(form.Encode())).WithContext(sctx)
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rec := httptest.NewRecorder()
|
|
h.PostSwitch(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("switch to %s: status %d: %s", priceID, rec.Code, rec.Body.String())
|
|
}
|
|
return rec.Body.String()
|
|
}
|
|
|
|
const unavailable = "That plan is no longer available."
|
|
if body := post(draft.PriceID); !strings.Contains(body, unavailable) {
|
|
t.Errorf("a switch onto a draft tier must be refused as unavailable; body:\n%s", body)
|
|
}
|
|
body := post(published.PriceID)
|
|
if strings.Contains(body, unavailable) {
|
|
t.Errorf("a published tier must pass the gate; body:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, "no active subscription to change on this plan.") {
|
|
t.Errorf("a published tier must be refused by SwitchPlan's subscription check, one step past the gate; body:\n%s", body)
|
|
}
|
|
}
|