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.
107 lines
3.3 KiB
Go
107 lines
3.3 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package entitlements_test
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Scenario: a transaction that materializes without holding the rendezvous
|
|
// is refused by the assertion, and the error names the pool.
|
|
func TestMaterializeWithoutRendezvousIsRefused(t *testing.T) {
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
tx, err := database.BeginTx(ctx, nil) // deliberately not BeginMaterializing
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
poolID := uuid.New().String()
|
|
err = entitlements.MaterializePoolEntitlements(ctx, entitlements.New(tx), poolID)
|
|
if err == nil {
|
|
t.Fatal("expected the assertion to refuse a materialization without the rendezvous")
|
|
}
|
|
if !entitlements.IsRendezvousMissing(err) {
|
|
t.Fatalf("expected materialization_rendezvous_missing, got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), poolID) {
|
|
t.Errorf("expected the error to name pool %s, got: %v", poolID, err)
|
|
}
|
|
}
|
|
|
|
// Scenario: the shared rendezvous is granted while no rule change holds it.
|
|
func TestMaterializingTransactionHoldsRendezvous(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()
|
|
if _, err := tx.ExecContext(ctx, "SELECT core.assert_rendezvous(false)"); err != nil {
|
|
t.Fatalf("expected the shared rendezvous to satisfy the assertion: %v", err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, "SELECT core.assert_rendezvous(true)"); err == nil || !entitlements.IsRendezvousMissing(err) {
|
|
t.Fatalf("expected the shared rendezvous to fail the exclusive assertion, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// Scenario: a rule change holds the exclusive rendezvous; a materializer
|
|
// opened meanwhile waits and is granted only once the change is over.
|
|
func TestRuleChangeBlocksMaterializersUntilItEnds(t *testing.T) {
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
holder, err := entitlements.BeginRuleChange(ctx, database)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer holder.Rollback() // a fatal below must not strand the exclusive rendezvous
|
|
if _, err := holder.ExecContext(ctx, "SELECT core.assert_rendezvous(true)"); err != nil {
|
|
t.Fatalf("expected the rule change to hold the exclusive rendezvous: %v", err)
|
|
}
|
|
|
|
const hold = 300 * time.Millisecond
|
|
waited := make(chan time.Duration, 1)
|
|
failed := make(chan error, 1)
|
|
start := time.Now()
|
|
go func() {
|
|
tx, err := entitlements.BeginMaterializing(ctx, database, nil)
|
|
if err != nil {
|
|
failed <- err
|
|
return
|
|
}
|
|
waited <- time.Since(start)
|
|
_ = tx.Rollback()
|
|
}()
|
|
|
|
time.Sleep(hold)
|
|
select {
|
|
case d := <-waited:
|
|
t.Fatalf("materializer was granted the rendezvous after %v while a rule change held it", d)
|
|
case err := <-failed:
|
|
t.Fatalf("materializer failed instead of waiting: %v", err)
|
|
default:
|
|
}
|
|
if err := holder.Rollback(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case d := <-waited:
|
|
if d < hold {
|
|
t.Errorf("materializer proceeded after %v, before the rule change ended", d)
|
|
}
|
|
case err := <-failed:
|
|
t.Fatalf("materializer failed after the rule change ended: %v", err)
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("materializer never proceeded after the rule change ended")
|
|
}
|
|
}
|