Files
member-console/internal/entitlements/enclosure_test.go
T
cgalo5758 3727ff31d8 Add entitlement set rule change ledger and preview flow
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.
2026-09-15 03:53:28 -05:00

418 lines
15 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package entitlements_test
// The rule enclosure (migration 00018, design.md A16): core.commit_rule_change
// is the only write path to core.entitlement_set_rules, every rule write files
// an act row and one obligation per carrying pool, and core.settle_obligation
// records what each pool's recomputation did.
import (
"context"
"database/sql"
"encoding/json"
"strings"
"testing"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
)
// TestRuleChangeEnclosure walks one rule through its four kinds on a set a
// single pool carries, reading the act row, its obligation and its effect row
// back at each step.
func TestRuleChangeEnclosure(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
q := entitlements.New(tx)
to := setupTestOrg(t, ctx, tx)
product := createTestProduct(t, ctx, tx, "Enclosure", 5, false)
createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
// A key of this test's own, so the fixture's fedwiki_sites rule keeps the
// set's one active rule per key (uq_entitlement_set_rules_active_resource_key).
key := "enc.num-" + uuid.New().String()[:8]
if _, err := tx.ExecContext(ctx,
`INSERT INTO core.resource_keys (resource_key, display_name, unit) VALUES ($1, $1, 'items')`, key,
); err != nil {
t.Fatalf("create resource key: %v", err)
}
// The set is carried by exactly one pool now, so an added rule enumerates
// that pool and nothing else.
added, err := entitlements.CommitRuleChangeTx(ctx, q, entitlements.CommitRuleChangeInput{
SetID: product.setID,
ChangeKind: entitlements.ChangeKindRuleAdded,
ActorType: entitlements.ActorTypeSystem,
Rule: entitlements.RuleFields{
RuleType: "limit",
ResourceKey: sql.NullString{String: key, Valid: true},
ResourceValue: sql.NullInt64{Int64: 100, 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)
}
if added.SyncPath != "atomic" {
t.Errorf("sync_path = %q, want atomic", added.SyncPath)
}
if added.PoolCount != 1 || added.OrgCount != 1 {
t.Errorf("counts = %d pools / %d orgs, want 1 / 1", added.PoolCount, added.OrgCount)
}
act := readAct(t, ctx, tx, added.ChangeID)
if act.kind != entitlements.ChangeKindRuleAdded {
t.Errorf("change_kind = %q, want rule_added", act.kind)
}
if act.ruleBefore.Valid {
t.Errorf("rule_before = %q, want NULL on an add", act.ruleBefore.String)
}
var after map[string]any
if err := json.Unmarshal([]byte(act.ruleAfter), &after); err != nil {
t.Fatalf("rule_after is not a JSON object: %q", act.ruleAfter)
}
if v, _ := after["resource_value"].(float64); v != 100 || after["is_active"] != true || after["rule_type"] != "limit" {
t.Errorf("rule_after = %v, want resource_value 100, is_active true, rule_type limit", after)
}
for _, excluded := range []string{"rule_id", "set_id", "description", "created_at", "updated_at"} {
if _, present := after[excluded]; present {
t.Errorf("rule_after carries %s, which the snapshot excludes", excluded)
}
}
if !act.valueAfter.Valid || act.valueAfter.Int64 != 100 {
t.Errorf("value_after = %+v, want 100", act.valueAfter)
}
if act.valueBefore.Valid {
t.Errorf("value_before = %+v, want NULL on an add", act.valueBefore)
}
if got := obligationStatus(t, ctx, tx, added.ChangeID, to.pool.PoolID); got != "pending" {
t.Errorf("obligation status = %q, want pending", got)
}
// An edit projects both sides of the value.
modified, err := entitlements.CommitRuleChangeTx(ctx, q, entitlements.CommitRuleChangeInput{
SetID: product.setID,
ChangeKind: entitlements.ChangeKindRuleModified,
RuleID: added.RuleID,
ActorType: entitlements.ActorTypeSystem,
Rule: entitlements.RuleFields{
RuleType: "limit",
ResourceKey: sql.NullString{String: key, Valid: true},
ResourceValue: sql.NullInt64{Int64: 250, Valid: true},
ResourcePerUnit: sql.NullBool{Bool: false, Valid: true},
StackingPolicy: sql.NullString{String: "additive", Valid: true},
},
})
if err != nil {
t.Fatalf("modify rule: %v", err)
}
editAct := readAct(t, ctx, tx, modified.ChangeID)
if !editAct.valueBefore.Valid || editAct.valueBefore.Int64 != 100 {
t.Errorf("value_before = %+v, want 100", editAct.valueBefore)
}
if !editAct.valueAfter.Valid || editAct.valueAfter.Int64 != 250 {
t.Errorf("value_after = %+v, want 250", editAct.valueAfter)
}
// Resubmitting the same edit changes nothing, so the function refuses it.
expectMismatch(t, ctx, tx, q, entitlements.CommitRuleChangeInput{
SetID: product.setID,
ChangeKind: entitlements.ChangeKindRuleModified,
RuleID: added.RuleID,
ActorType: entitlements.ActorTypeSystem,
Rule: entitlements.RuleFields{
RuleType: "limit",
ResourceKey: sql.NullString{String: key, Valid: true},
ResourceValue: sql.NullInt64{Int64: 250, Valid: true},
ResourcePerUnit: sql.NullBool{Bool: false, Valid: true},
StackingPolicy: sql.NullString{String: "additive", Valid: true},
},
}, "an identical edit")
// Deactivation and reactivation both file acts and keep the rule row.
deactivated, err := entitlements.CommitRuleChangeTx(ctx, q, entitlements.CommitRuleChangeInput{
SetID: product.setID,
ChangeKind: entitlements.ChangeKindRuleDeactivated,
RuleID: added.RuleID,
ActorType: entitlements.ActorTypeSystem,
})
if err != nil {
t.Fatalf("deactivate rule: %v", err)
}
if rule, err := q.GetEntitlementSetRule(ctx, added.RuleID); err != nil {
t.Fatalf("read deactivated rule: %v", err)
} else if rule.IsActive {
t.Error("rule is still active after a deactivation")
}
if readAct(t, ctx, tx, deactivated.ChangeID).kind != entitlements.ChangeKindRuleDeactivated {
t.Error("deactivation filed the wrong kind")
}
// A second deactivation of an inactive rule is already applied.
expectMismatch(t, ctx, tx, q, entitlements.CommitRuleChangeInput{
SetID: product.setID,
ChangeKind: entitlements.ChangeKindRuleDeactivated,
RuleID: added.RuleID,
ActorType: entitlements.ActorTypeSystem,
}, "a second deactivation")
reactivated, err := entitlements.CommitRuleChangeTx(ctx, q, entitlements.CommitRuleChangeInput{
SetID: product.setID,
ChangeKind: entitlements.ChangeKindRuleReactivated,
RuleID: added.RuleID,
ActorType: entitlements.ActorTypeSystem,
})
if err != nil {
t.Fatalf("reactivate rule: %v", err)
}
if rule, err := q.GetEntitlementSetRule(ctx, added.RuleID); err != nil {
t.Fatalf("read reactivated rule: %v", err)
} else if !rule.IsActive {
t.Error("rule is not active after a reactivation")
}
if act := readAct(t, ctx, tx, reactivated.ChangeID); act.kind != entitlements.ChangeKindRuleReactivated {
t.Errorf("reactivation change_kind = %q, want rule_reactivated", act.kind)
} else if !act.ruleBefore.Valid {
t.Error("reactivation act has no rule_before snapshot")
}
// Settling the first obligation writes its effect rows and marks it.
effects, err := json.Marshal([]map[string]any{{
"resource_key": key,
"limit_before": 0,
"limit_after": 100,
"usage_at_effect": 0,
"over_usage": false,
"was_over_before": false,
"reduction_policy": "defer",
}})
if err != nil {
t.Fatal(err)
}
written, err := q.SettleObligation(ctx, entitlements.SettleObligationParams{
ChangeID: added.ChangeID,
PoolID: to.pool.PoolID,
Effects: effects,
})
if err != nil {
t.Fatalf("settle obligation: %v", err)
}
if written != 1 {
t.Errorf("effects written = %d, want 1", written)
}
if got := obligationStatus(t, ctx, tx, added.ChangeID, to.pool.PoolID); got != "settled" {
t.Errorf("obligation status = %q, want settled", got)
}
rows, err := q.ListEntitlementSetChangeEffects(ctx, entitlements.ListEntitlementSetChangeEffectsParams{
ChangeID: added.ChangeID,
Limit: 10,
})
if err != nil {
t.Fatalf("list effects: %v", err)
}
if len(rows) != 1 {
t.Fatalf("effect rows = %d, want 1", len(rows))
}
e := rows[0]
if e.ResourceKey != key {
t.Errorf("effect resource_key = %q", e.ResourceKey)
}
if !e.LimitAfter.Valid || e.LimitAfter.Int64 != 100 {
t.Errorf("effect limit_after = %+v, want 100", e.LimitAfter)
}
if e.OrgID != to.org.OrgID {
t.Errorf("effect org_id = %q, want %q", e.OrgID, to.org.OrgID)
}
// Re-settling an already settled obligation writes nothing.
if written, err := q.SettleObligation(ctx, entitlements.SettleObligationParams{
ChangeID: added.ChangeID,
PoolID: to.pool.PoolID,
Effects: effects,
}); err != nil {
t.Fatalf("re-settle: %v", err)
} else if written != 0 {
t.Errorf("re-settle wrote %d effects, want 0", written)
}
}
// TestRuleTableDMLRevoked pins the enclosure itself: the application role has
// no INSERT on core.entitlement_set_rules, so no code path can write a rule
// without its act row. SET LOCAL ROLE drops to core_writer because the test
// stack's login role is a cluster superuser, which bypasses every privilege
// check (migration 00001 grants core_writer to member_console).
func TestRuleTableDMLRevoked(t *testing.T) {
database := testDB(t)
ctx := context.Background()
statements := map[string]string{
"insert": `INSERT INTO core.entitlement_set_rules (set_id, rule_type, resource_key, resource_value, stacking_policy)
SELECT set_id, 'limit', 'fedwiki_sites', 1, 'additive' FROM core.entitlement_sets LIMIT 1`,
"update": `UPDATE core.entitlement_set_rules SET resource_value = 1 WHERE rule_type = 'limit'`,
"delete": `DELETE FROM core.entitlement_set_rules WHERE rule_type = 'limit'`,
}
for name, statement := range statements {
t.Run(name, func(t *testing.T) {
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
// The test login is a superuser, which no revoke binds; the app
// role is what the enclosure guards.
if _, err := tx.ExecContext(ctx, "SET LOCAL ROLE core_writer"); err != nil {
t.Fatalf("set role core_writer: %v", err)
}
_, err = tx.ExecContext(ctx, statement)
if err == nil {
t.Fatalf("core_writer ran a direct %s on rules, want a permission error", name)
}
if !strings.Contains(strings.ToLower(err.Error()), "permission denied") {
t.Fatalf("%s error = %v, want permission denied", name, err)
}
})
}
}
// Scenario: the commit function refuses a transaction that holds no exclusive
// rendezvous, before it touches a row.
func TestCommitRuleChangeWithoutRendezvousIsRefused(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil) // deliberately not BeginRuleChange
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
q := entitlements.New(tx)
set, err := q.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{Name: "no rendezvous", IsActive: true})
if err != nil {
t.Fatalf("create set: %v", err)
}
_, err = entitlements.CommitRuleChangeTx(ctx, q, entitlements.CommitRuleChangeInput{
SetID: set.SetID,
ChangeKind: entitlements.ChangeKindRuleAdded,
Rule: entitlements.RuleFields{
RuleType: "limit",
ResourceKey: sql.NullString{String: "fedwiki_sites", Valid: true},
ResourceValue: sql.NullInt64{Int64: 1, Valid: true},
ResourcePerUnit: sql.NullBool{Bool: false, Valid: true},
StackingPolicy: sql.NullString{String: "additive", Valid: true},
},
ActorType: entitlements.ActorTypeSystem,
})
if !entitlements.IsRendezvousMissing(err) {
t.Fatalf("expected materialization_rendezvous_missing, got: %v", err)
}
}
// Scenario: a failed settlement below the attempt budget keeps the obligation
// pending with the attempt recorded; at the budget it is failed.
func TestSettleObligationAttemptBudget(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
q := entitlements.New(tx)
to := setupTestOrg(t, ctx, tx)
product := createTestProduct(t, ctx, tx, "BudgetProduct", 5, false)
createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
rule := firstActiveRule(t, ctx, q, product.setID)
row, err := entitlements.CommitRuleChangeTx(ctx, q, entitlements.CommitRuleChangeInput{
SetID: product.setID,
ChangeKind: entitlements.ChangeKindRuleModified,
RuleID: rule.RuleID,
Rule: entitlements.RuleFields{ResourceValue: sql.NullInt64{Int64: 7, Valid: true}},
ActorType: entitlements.ActorTypeSystem,
})
if err != nil {
t.Fatalf("commit: %v", err)
}
for attempt, want := range []string{"pending", "failed"} {
if err := q.MarkObligationFailed(ctx, entitlements.MarkObligationFailedParams{
ChangeID: row.ChangeID,
PoolID: to.pool.PoolID,
Failure: "simulated",
MaxAttempts: 2,
}); err != nil {
t.Fatalf("mark failed (attempt %d): %v", attempt+1, err)
}
var status string
var attempts int
if err := tx.QueryRowContext(ctx,
`SELECT status, attempts FROM core.entitlement_set_change_obligations WHERE change_id = $1 AND pool_id = $2`,
row.ChangeID, to.pool.PoolID).Scan(&status, &attempts); err != nil {
t.Fatalf("read obligation: %v", err)
}
if status != want || attempts != attempt+1 {
t.Errorf("after attempt %d: status=%s attempts=%d, want %s / %d", attempt+1, status, attempts, want, attempt+1)
}
}
}
// expectMismatch runs one commit that must be refused, inside a savepoint: the
// refusal aborts the transaction, and the savepoint is what lets the test go on
// reading in it.
func expectMismatch(t *testing.T, ctx context.Context, tx *sql.Tx, q *entitlements.Queries, in entitlements.CommitRuleChangeInput, what string) {
t.Helper()
if _, err := tx.ExecContext(ctx, "SAVEPOINT rule_refusal"); err != nil {
t.Fatalf("savepoint: %v", err)
}
_, err := entitlements.CommitRuleChangeTx(ctx, q, in)
if _, rbErr := tx.ExecContext(ctx, "ROLLBACK TO SAVEPOINT rule_refusal"); rbErr != nil {
t.Fatalf("rollback to savepoint: %v", rbErr)
}
if err == nil {
t.Fatalf("%s was accepted, want rule_change_kind_mismatch", what)
}
if !entitlements.IsRuleChangeKindMismatch(err) {
t.Fatalf("%s error = %v, want rule_change_kind_mismatch", what, err)
}
}
type actRow struct {
kind string
ruleBefore sql.NullString
ruleAfter string
valueBefore sql.NullInt64
valueAfter sql.NullInt64
}
func readAct(t *testing.T, ctx context.Context, tx *sql.Tx, changeID string) actRow {
t.Helper()
var a actRow
if err := tx.QueryRowContext(ctx,
`SELECT change_kind, rule_before::text, rule_after::text, value_before, value_after
FROM core.entitlement_set_changes WHERE change_id = $1`, changeID,
).Scan(&a.kind, &a.ruleBefore, &a.ruleAfter, &a.valueBefore, &a.valueAfter); err != nil {
t.Fatalf("read act %s: %v", changeID, err)
}
return a
}
func obligationStatus(t *testing.T, ctx context.Context, tx *sql.Tx, changeID, poolID string) string {
t.Helper()
var status string
if err := tx.QueryRowContext(ctx,
`SELECT status FROM core.entitlement_set_change_obligations
WHERE change_id = $1 AND pool_id = $2`, changeID, poolID,
).Scan(&status); err != nil {
t.Fatalf("read obligation (%s, %s): %v", changeID, poolID, err)
}
return status
}