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.
418 lines
15 KiB
Go
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
|
|
}
|