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.
442 lines
17 KiB
Go
442 lines
17 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server
|
|
|
|
// The rule-less warning (task 10.5): all five surfaces render the identical
|
|
// sentence, read from core.product_shape's active_rule_count, and every
|
|
// commit stays enabled.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
|
|
)
|
|
|
|
// theRuleLessSentence is the one wording, spelled out here rather than built
|
|
// from the helper, so a drift in the helper fails this test.
|
|
const theRuleLessSentence = `Community Plan provides nothing: its entitlement set <a href="/operator/entitlement-sets/SETID">Community Baseline</a> has no active rule.`
|
|
|
|
// renderPartial executes one operator partial (or one shared define) against
|
|
// the real template set.
|
|
func renderPartial(t *testing.T, name string, data any) string {
|
|
t.Helper()
|
|
partialsSub, err := fs.Sub(embeds.Templates, "templates/partials")
|
|
if err != nil {
|
|
t.Fatalf("fs.Sub partials: %v", err)
|
|
}
|
|
tmpl := template.New("operator").Funcs(template.FuncMap{
|
|
"renderBody": func(string, any) (template.HTML, error) { return "", nil },
|
|
"routeURL": web.RouteURL,
|
|
"fieldErr": func(string, string, string, string, web.FieldErrors, string) string { return "" },
|
|
"stripeEntityURL": func(string, string) string { return "" },
|
|
"helpIcon": helpIcon,
|
|
})
|
|
tmpl, err = web.ParseUIPartials(template.Must(tmpl.ParseFS(partialsSub, "operator_*.html")))
|
|
if err != nil {
|
|
t.Fatalf("ParseFS: %v", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
|
|
t.Fatalf("ExecuteTemplate %s: %v", name, err)
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
// TestRuleLessWarningIsOneSentenceOnFiveSurfaces renders the sentence
|
|
// through each of the five surfaces' own markup and asserts every one of
|
|
// them carries it byte for byte, with no disabled control anywhere.
|
|
func TestRuleLessWarningIsOneSentenceOnFiveSurfaces(t *testing.T) {
|
|
setID := uuid.New().String()
|
|
sentence := string(ruleLessWarningSentence("Community Plan", setID, "Community Baseline"))
|
|
if want := strings.Replace(theRuleLessSentence, "SETID", setID, 1); sentence != want {
|
|
t.Fatalf("sentence =\n%s\nwant\n%s", sentence, want)
|
|
}
|
|
warning := template.HTML(sentence)
|
|
|
|
products := []ProductOption{{ProductID: "p1", Name: "Community Plan"}}
|
|
issuance := []IssuanceProductOption{{ProductID: "p1", Name: "Community Plan"}}
|
|
|
|
surfaces := []struct {
|
|
name string
|
|
render func() string
|
|
}{
|
|
{"org type default change", func() string {
|
|
return renderPartial(t, "operator_org_type_preview.html",
|
|
&OrgTypeChangePreview{CandidateLadderID: "l1", RuleLessWarning: warning})
|
|
}},
|
|
{"tier add", func() string {
|
|
return renderPartial(t, "form",
|
|
planLadderTierAddFormView("l1", forms.NewValues(), nil, products, warning))
|
|
}},
|
|
{"tier removal", func() string {
|
|
return renderPartial(t, "operator_plan_ladder_tier_removal_message.html",
|
|
&PendingTierRemoval{ProductName: "Old Plan", PromotedName: "Community Plan", RuleLessWarning: warning})
|
|
}},
|
|
{"tier reorder", func() string {
|
|
return renderPartial(t, "operator_plan_ladder_tier_reorder_message.html",
|
|
&PendingTierReorder{RankZeroChanged: true, OldRankZeroName: "Old Plan",
|
|
NewRankZeroName: "Community Plan", RuleLessWarning: warning})
|
|
}},
|
|
{"issue grant", func() string {
|
|
return renderPartial(t, "form",
|
|
issueGrantFormView("org-1", issuance, forms.ModeRecord, forms.NewValues(), nil, warning))
|
|
}},
|
|
}
|
|
for _, s := range surfaces {
|
|
t.Run(s.name, func(t *testing.T) {
|
|
body := s.render()
|
|
if !strings.Contains(body, sentence) {
|
|
t.Errorf("%s does not render the sentence, got:\n%s", s.name, body)
|
|
}
|
|
// A8, M1: no disabled control is added on any of them. The
|
|
// shared disabled-control idiom is the wrapper the console
|
|
// uses everywhere a commit is blocked; a select's own
|
|
// placeholder option is not a disabled control.
|
|
if strings.Contains(body, "disabled-control") {
|
|
t.Errorf("%s renders a disabled control:\n%s", s.name, body)
|
|
}
|
|
if i := strings.Index(body, `<button type="submit"`); i >= 0 {
|
|
commit := body[i : i+strings.Index(body[i:], "</button>")]
|
|
if strings.Contains(commit, "disabled") {
|
|
t.Errorf("%s disables its commit: %s", s.name, commit)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The two form surfaces render no region at all when the selected product's
|
|
// set carries a rule, which is what an empty slot means.
|
|
func TestRuleLessWarningAbsentWhenTheSetCarriesARule(t *testing.T) {
|
|
products := []ProductOption{{ProductID: "p1", Name: "Community Plan"}}
|
|
body := renderPartial(t, "form", planLadderTierAddFormView("l1", forms.NewValues(), nil, products, ""))
|
|
if strings.Contains(body, "provides nothing") {
|
|
t.Errorf("tier add renders the sentence with no warning bound:\n%s", body)
|
|
}
|
|
issuance := []IssuanceProductOption{{ProductID: "p1", Name: "Community Plan"}}
|
|
body = renderPartial(t, "form", issueGrantFormView("org-1", issuance, forms.ModeRecord, forms.NewValues(), nil, ""))
|
|
if strings.Contains(body, "provides nothing") {
|
|
t.Errorf("issue grant renders the sentence with no warning bound:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// ruleLessFixture is one scratch catalog for the DB-backed cases: a product
|
|
// whose set carries no active rule, and one whose set carries a limit rule.
|
|
// The rules are seeded through core.commit_rule_change, the only write path
|
|
// to core.entitlement_set_rules, which is why the transaction comes from
|
|
// BeginRuleChange.
|
|
type ruleLessFixture struct {
|
|
tx *sql.Tx
|
|
h *OperatorPartialsHandler
|
|
emptySetID string
|
|
emptyProductID string
|
|
ruledSetID string
|
|
ruledProductID string
|
|
ruledRuleID string
|
|
personID string
|
|
sfx string
|
|
billingQ *billing.Queries
|
|
entitlementsQ *entitlements.Queries
|
|
organizationQ *organization.Queries
|
|
newSetForProduct func(name string) string
|
|
}
|
|
|
|
func newRuleLessFixture(t *testing.T, database *sql.DB, ctx context.Context) *ruleLessFixture {
|
|
t.Helper()
|
|
tx, err := entitlements.BeginRuleChange(ctx, database)
|
|
if err != nil {
|
|
t.Fatalf("begin rule change: %v", err)
|
|
}
|
|
t.Cleanup(func() { tx.Rollback() })
|
|
|
|
bq, eq, oq, iq := billing.New(tx), entitlements.New(tx), organization.New(tx), identity.New(tx)
|
|
h, err := NewOperatorPartialsHandler(OperatorPartialsConfig{
|
|
BillingQ: bq,
|
|
EntitlementsQ: eq,
|
|
OrgQ: oq,
|
|
Database: database,
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewOperatorPartialsHandler: %v", err)
|
|
}
|
|
|
|
sfx := uuid.New().String()[:8]
|
|
f := &ruleLessFixture{tx: tx, h: h, sfx: sfx, billingQ: bq, entitlementsQ: eq, organizationQ: oq}
|
|
f.newSetForProduct = func(name string) string {
|
|
set, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
|
|
Name: name + " " + sfx, IsActive: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create set %s: %v", name, err)
|
|
}
|
|
return set.SetID
|
|
}
|
|
|
|
f.emptySetID = f.newSetForProduct("Community Baseline")
|
|
f.emptyProductID = f.newProduct(t, ctx, "Community Plan", f.emptySetID)
|
|
f.ruledSetID = f.newSetForProduct("Ruled Baseline")
|
|
f.ruledProductID = f.newProduct(t, ctx, "Community Plus", f.ruledSetID)
|
|
f.ruledRuleID = f.addRule(t, ctx, f.ruledSetID)
|
|
|
|
user, err := iq.CreateUser(ctx, "rlw-"+uuid.NewString())
|
|
if err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
person, err := iq.CreatePerson(ctx, identity.CreatePersonParams{
|
|
UserID: user.UserID,
|
|
DisplayName: "Rule Less Operator",
|
|
PrimaryEmail: fmt.Sprintf("rlw-%s@example.com", sfx),
|
|
PrimaryEmailVerified: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create person: %v", err)
|
|
}
|
|
f.personID = person.PersonID
|
|
return f
|
|
}
|
|
|
|
func (f *ruleLessFixture) newProduct(t *testing.T, ctx context.Context, name, setID string) string {
|
|
t.Helper()
|
|
prod, err := f.billingQ.CreateProduct(ctx, billing.CreateProductParams{
|
|
Name: name, IsActive: true, IsPublic: true, LifecycleStatus: "published",
|
|
EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(setID), Valid: true},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create product %s: %v", name, err)
|
|
}
|
|
return prod.ProductID
|
|
}
|
|
|
|
// addRule seeds one active limit rule through core.commit_rule_change.
|
|
func (f *ruleLessFixture) addRule(t *testing.T, ctx context.Context, setID string) string {
|
|
t.Helper()
|
|
row, err := entitlements.CommitRuleChangeTx(ctx, f.entitlementsQ, entitlements.CommitRuleChangeInput{
|
|
SetID: setID,
|
|
ChangeKind: entitlements.ChangeKindRuleAdded,
|
|
ActorType: entitlements.ActorTypeSystem,
|
|
Rule: entitlements.RuleFields{
|
|
RuleType: "limit",
|
|
ResourceKey: sql.NullString{String: "fedwiki_sites", Valid: true},
|
|
ResourceValue: sql.NullInt64{Int64: 5, Valid: true},
|
|
ResourcePerUnit: sql.NullBool{Bool: false, Valid: true},
|
|
StackingPolicy: sql.NullString{String: "additive", Valid: true},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("add rule: %v", err)
|
|
}
|
|
return row.RuleID
|
|
}
|
|
|
|
// TestRuleLessWarningReadsActiveRuleCount is the source test: the sentence
|
|
// comes from core.product_shape's active_rule_count and from nothing else,
|
|
// including on the org-type default-change preview.
|
|
func TestRuleLessWarningReadsActiveRuleCount(t *testing.T) {
|
|
database := topoTestDB(t)
|
|
ctx := context.Background()
|
|
f := newRuleLessFixture(t, database, ctx)
|
|
|
|
got := string(f.h.ruleLessWarning(ctx, f.emptyProductID))
|
|
if !strings.Contains(got, "provides nothing: its entitlement set") ||
|
|
!strings.Contains(got, `/operator/entitlement-sets/`+f.emptySetID) {
|
|
t.Errorf("rule-less product warning = %q", got)
|
|
}
|
|
if got := f.h.ruleLessWarning(ctx, f.ruledProductID); got != "" {
|
|
t.Errorf("a set with an active rule must render no sentence, got %q", got)
|
|
}
|
|
|
|
// Deactivating the only rule flips active_rule_count to 0, and the
|
|
// sentence follows it without any other read changing.
|
|
if _, err := entitlements.CommitRuleChangeTx(ctx, f.entitlementsQ, entitlements.CommitRuleChangeInput{
|
|
SetID: f.ruledSetID,
|
|
ChangeKind: entitlements.ChangeKindRuleDeactivated,
|
|
RuleID: f.ruledRuleID,
|
|
ActorType: entitlements.ActorTypeSystem,
|
|
}); err != nil {
|
|
t.Fatalf("deactivate rule: %v", err)
|
|
}
|
|
if got := f.h.ruleLessWarning(ctx, f.ruledProductID); got == "" {
|
|
t.Error("a set whose rules are all inactive must render the sentence")
|
|
}
|
|
|
|
// The org-type default-change preview reads it through the same helper.
|
|
preview, err := f.h.buildOrgTypeChangePreview(ctx, "personal", uuid.New().String(), f.emptyProductID)
|
|
if err != nil {
|
|
t.Fatalf("buildOrgTypeChangePreview: %v", err)
|
|
}
|
|
if preview.RuleLessWarning == "" {
|
|
t.Error("the default-change preview must carry the sentence for a rule-less rank-0 candidate")
|
|
}
|
|
body := renderPartial(t, "operator_org_type_preview.html", preview)
|
|
if !strings.Contains(body, "provides nothing: its entitlement set") {
|
|
t.Errorf("the default-change preview does not render the sentence:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// TestRuleLessWarningComputedOnTierSurfaces drives the two ladder previews
|
|
// against the real catalog rather than binding a sentence into their view
|
|
// models: the removal preview's promoted rank 0 and the reorder preview's
|
|
// new rank 0 each read active_rule_count for themselves.
|
|
func TestRuleLessWarningComputedOnTierSurfaces(t *testing.T) {
|
|
database := topoTestDB(t)
|
|
ctx := context.Background()
|
|
f := newRuleLessFixture(t, database, ctx)
|
|
|
|
for _, c := range []struct {
|
|
name string
|
|
candidateID string
|
|
wantWarning bool
|
|
}{
|
|
{"rule-less candidate", f.emptyProductID, true},
|
|
{"ruled candidate", f.ruledProductID, false},
|
|
} {
|
|
t.Run("tier removal, "+c.name, func(t *testing.T) {
|
|
ladderID, removedID := f.newLadder(t, ctx, "Removal "+c.name, c.candidateID)
|
|
f.holdTier(t, ctx, removedID, "Removal Holder "+c.name)
|
|
pending, errMsg := f.h.buildTierRemovalPreview(ctx, ladderID, removedID)
|
|
if errMsg != "" || pending == nil {
|
|
t.Fatalf("buildTierRemovalPreview: pending=%v err=%q", pending, errMsg)
|
|
}
|
|
assertWarning(t, string(pending.RuleLessWarning), c.wantWarning)
|
|
})
|
|
t.Run("tier reorder, "+c.name, func(t *testing.T) {
|
|
ladderID, otherID := f.newLadder(t, ctx, "Reorder "+c.name, c.candidateID)
|
|
pending, errMsg := f.h.buildPendingTierReorder(ctx, ladderID, []string{c.candidateID, otherID})
|
|
if errMsg != "" || pending == nil {
|
|
t.Fatalf("buildPendingTierReorder: pending=%v err=%q", pending, errMsg)
|
|
}
|
|
if !pending.RankZeroChanged {
|
|
t.Fatal("the submitted order must move rank 0")
|
|
}
|
|
assertWarning(t, string(pending.RuleLessWarning), c.wantWarning)
|
|
})
|
|
}
|
|
}
|
|
|
|
func assertWarning(t *testing.T, got string, want bool) {
|
|
t.Helper()
|
|
if want && !strings.Contains(got, "provides nothing: its entitlement set") {
|
|
t.Errorf("the surface must compute the sentence from active_rule_count, got %q", got)
|
|
}
|
|
if !want && got != "" {
|
|
t.Errorf("a ruled candidate must carry no sentence, got %q", got)
|
|
}
|
|
}
|
|
|
|
// newLadder builds a two-tier ladder whose rank 0 is a fresh product and
|
|
// whose rank 1 is candidateID, and returns the ladder with its rank-0
|
|
// product: the one the removal preview removes and the reorder preview
|
|
// displaces.
|
|
func (f *ruleLessFixture) newLadder(t *testing.T, ctx context.Context, name, candidateID string) (string, string) {
|
|
t.Helper()
|
|
rankZeroID := f.newProduct(t, ctx, name+" Top "+uuid.New().String()[:8], f.newSetForProduct(name+" Top Baseline"))
|
|
ladder, err := f.billingQ.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{
|
|
Name: name + " " + uuid.New().String()[:8], IsActive: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create ladder: %v", err)
|
|
}
|
|
for _, productID := range []string{rankZeroID, candidateID} {
|
|
if _, err := f.billingQ.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
|
|
PlanLadderID: ladder.PlanLadderID, ProductID: productID,
|
|
}); err != nil {
|
|
t.Fatalf("create tier: %v", err)
|
|
}
|
|
}
|
|
return ladder.PlanLadderID, rankZeroID
|
|
}
|
|
|
|
// holdTier puts one live holder on the product, which core.confer attaches to
|
|
// the ladder the product is a tier of: what the removal preview needs before
|
|
// it classifies anything.
|
|
func (f *ruleLessFixture) holdTier(t *testing.T, ctx context.Context, productID, orgName string) {
|
|
t.Helper()
|
|
org, err := f.organizationQ.CreateOrganization(ctx, organization.CreateOrganizationParams{
|
|
Name: orgName, OrgType: "personal", OwnerPersonID: f.personID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create org: %v", err)
|
|
}
|
|
pool, err := f.entitlementsQ.CreateResourcePool(ctx, entitlements.CreateResourcePoolParams{
|
|
OrgID: org.OrgID, Name: "Default", PoolType: "default", IsAutoManaged: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create pool: %v", err)
|
|
}
|
|
grant, err := f.entitlementsQ.CreateGrant(ctx, entitlements.CreateGrantParams{
|
|
ProductID: productID,
|
|
GrantedToOrgID: uuid.NullUUID{UUID: uuid.MustParse(org.OrgID), Valid: true},
|
|
GrantedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(f.personID), Valid: true},
|
|
GrantReason: "manual",
|
|
Quantity: 1,
|
|
ValidFrom: time.Now(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create grant: %v", err)
|
|
}
|
|
if _, outcome, err := f.entitlementsQ.Confer(ctx, entitlements.ConferParams{
|
|
PoolID: pool.PoolID,
|
|
ProductID: productID,
|
|
GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant.GrantID), Valid: true},
|
|
Quantity: 1,
|
|
}); err != nil || outcome != "created" {
|
|
t.Fatalf("confer: outcome=%q err=%v", outcome, err)
|
|
}
|
|
}
|
|
|
|
// TestGetProductRuleCheckAnswersTheLiveRegion drives the route the tier-add
|
|
// panel and the Issue grant panel fire on change: the sentence for a
|
|
// rule-less product, nothing for a ruled one, and nothing for a product_id
|
|
// that names no product or names none at all.
|
|
func TestGetProductRuleCheckAnswersTheLiveRegion(t *testing.T) {
|
|
database := topoTestDB(t)
|
|
ctx := context.Background()
|
|
f := newRuleLessFixture(t, database, ctx)
|
|
|
|
call := func(productID string) string {
|
|
req := httptest.NewRequestWithContext(ctx, "GET",
|
|
"/partials/operator/products/rule-check?product_id="+productID, nil)
|
|
rec := httptest.NewRecorder()
|
|
f.h.GetProductRuleCheck(rec, req)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("rule-check = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
return rec.Body.String()
|
|
}
|
|
|
|
if body := call(f.emptyProductID); !strings.Contains(body, "provides nothing: its entitlement set") ||
|
|
!strings.Contains(body, "/operator/entitlement-sets/"+f.emptySetID) {
|
|
t.Errorf("a rule-less product must answer with the sentence, got:\n%s", body)
|
|
}
|
|
for _, c := range []struct{ name, productID string }{
|
|
{"ruled product", f.ruledProductID},
|
|
{"unknown product", uuid.New().String()},
|
|
{"no product", ""},
|
|
} {
|
|
if body := call(c.productID); strings.TrimSpace(body) != "" {
|
|
t.Errorf("%s must answer with an empty region, got:\n%s", c.name, body)
|
|
}
|
|
}
|
|
}
|