The Rules section is one record table grouped by kind, Limit then Boolean, on fixed columns, edited in place: Edit opens a row's controls in their columns, Add rule opens a dense row above the table, and every change is staged into a tray that lists the deltas with Undo and applies them as one rule-change act. The reduction policy is a column of the rule beside its limit. History shows counts only. Group rows are a quiet heading rather than a divider, the maintainer's pick from four rounds of outside-model ideation. Dense rows align to the top and render each error under its control in every form family (design D16), replacing the below-row error block; the forms library gains the batch form (rows plus one tray) and the RowField dense and label-hidden options. Migration 00019 records the governing reduction policy on effect rows. Archive staged-rule-changes with its spec updates (entitlement-set- management, entitlement-set-history, entitlements, form-library, form-conventions, ui-quality-gate). Screens accepted 2026-09-19.
653 lines
25 KiB
Go
653 lines
25 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package entitlements_test
|
|
|
|
// A staged batch applied as one commit (entitlement-set-management, "A rule
|
|
// change commit reaches every carrying pool"): one act per rule, one
|
|
// recomputation per pool, each key's effect under the act that changed it and
|
|
// a movement on a key no delta names under the batch's first act, on both the
|
|
// atomic and the deferred path. The batch dry run is checked against the same
|
|
// fixture, so the two answer one question.
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
)
|
|
|
|
// batchFixture is a pool funded by two sets. The batch's set carries all three
|
|
// keys; the other set funds A and C too, so A's governing policy is not the
|
|
// changed rule's own and C stacks across both sets.
|
|
type batchFixture struct {
|
|
keyA, keyB, keyC string
|
|
other testProduct
|
|
batched testProduct
|
|
ruleA, ruleB string
|
|
orgs []testOrg
|
|
}
|
|
|
|
// newBatchFixture seeds pools pools carrying both sets and returns the keys
|
|
// and the rules the batch edits. Every pool starts at A = 3, B = 3, C = 9.
|
|
func newBatchFixture(t *testing.T, ctx context.Context, database *sql.DB, pools int) batchFixture {
|
|
t.Helper()
|
|
tx, err := entitlements.BeginRuleChange(ctx, database)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
q := entitlements.New(tx)
|
|
|
|
suffix := uuid.New().String()[:8]
|
|
fx := batchFixture{
|
|
keyA: "alpha.a-" + suffix,
|
|
keyB: "alpha.b-" + suffix,
|
|
keyC: "alpha.c-" + suffix,
|
|
}
|
|
for _, key := range []string{fx.keyA, fx.keyB, fx.keyC} {
|
|
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 %s: %v", key, err)
|
|
}
|
|
}
|
|
|
|
fx.other = createRuledProduct(t, ctx, tx, "Other "+suffix, []entitlements.RuleFields{
|
|
limitRule(fx.keyA, 2, "force_reduce"),
|
|
limitRule(fx.keyC, 5, "clamp"),
|
|
})
|
|
fx.batched = createRuledProduct(t, ctx, tx, "Batched "+suffix, []entitlements.RuleFields{
|
|
limitRule(fx.keyA, 1, "clamp"),
|
|
limitRule(fx.keyB, 3, "defer"),
|
|
limitRule(fx.keyC, 4, "clamp"),
|
|
})
|
|
fx.ruleA = ruleForKey(t, ctx, q, fx.batched.setID, fx.keyA)
|
|
fx.ruleB = ruleForKey(t, ctx, q, fx.batched.setID, fx.keyB)
|
|
|
|
for i := 0; i < pools; i++ {
|
|
org := setupTestOrg(t, ctx, tx)
|
|
createGrantAndProvision(t, ctx, tx, q, org, fx.other.productID, 1)
|
|
createGrantAndProvision(t, ctx, tx, q, org, fx.batched.productID, 1)
|
|
// C's stored limit is put out of step with its rules, so the batch's
|
|
// one recomputation moves a key no delta names.
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE core.numeric_entitlements SET resource_limit = 99 WHERE pool_id = $1 AND resource_key = $2`,
|
|
org.pool.PoolID, fx.keyC,
|
|
); err != nil {
|
|
t.Fatalf("make key C stale: %v", err)
|
|
}
|
|
fx.orgs = append(fx.orgs, org)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
t.Fatalf("commit fixture: %v", err)
|
|
}
|
|
return fx
|
|
}
|
|
|
|
// edits is the batch both paths apply: A to 6 on the pool, B to 1.
|
|
func (fx batchFixture) edits(syncCap int32) []entitlements.RuleChangeInput {
|
|
return []entitlements.RuleChangeInput{
|
|
{
|
|
SetID: fx.batched.setID,
|
|
ChangeKind: entitlements.ChangeKindRuleModified,
|
|
RuleID: fx.ruleB,
|
|
Rule: entitlements.RuleFields{ResourceValue: sql.NullInt64{Int64: 1, Valid: true}},
|
|
ActorType: entitlements.ActorTypeSystem,
|
|
SyncCap: syncCap,
|
|
},
|
|
{
|
|
SetID: fx.batched.setID,
|
|
ChangeKind: entitlements.ChangeKindRuleModified,
|
|
RuleID: fx.ruleA,
|
|
Rule: entitlements.RuleFields{ResourceValue: sql.NullInt64{Int64: 4, Valid: true}},
|
|
ActorType: entitlements.ActorTypeSystem,
|
|
SyncCap: syncCap,
|
|
},
|
|
}
|
|
}
|
|
|
|
func limitRule(resourceKey string, value int64, policy string) entitlements.RuleFields {
|
|
return entitlements.RuleFields{
|
|
RuleType: "limit",
|
|
ResourceKey: sql.NullString{String: resourceKey, Valid: true},
|
|
ResourceValue: sql.NullInt64{Int64: value, Valid: true},
|
|
ResourcePerUnit: sql.NullBool{Bool: false, Valid: true},
|
|
StackingPolicy: sql.NullString{String: "additive", Valid: true},
|
|
TierReductionPolicy: policy,
|
|
}
|
|
}
|
|
|
|
// createRuledProduct is createTestProduct with the caller's rules instead of
|
|
// the one fedwiki_sites rule, so a fixture can state each rule's key and
|
|
// reduction policy.
|
|
func createRuledProduct(t *testing.T, ctx context.Context, tx *sql.Tx, name string, rules []entitlements.RuleFields) testProduct {
|
|
t.Helper()
|
|
q := entitlements.New(tx)
|
|
set, err := q.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{Name: name + " Set", IsActive: true})
|
|
if err != nil {
|
|
t.Fatalf("create entitlement set for %s: %v", name, err)
|
|
}
|
|
for _, rule := range rules {
|
|
addSetRule(t, ctx, q, set.SetID, rule)
|
|
}
|
|
var productID string
|
|
if err := tx.QueryRowContext(ctx,
|
|
`INSERT INTO core.products (name, is_active, is_public, lifecycle_status, entitlement_set_id)
|
|
VALUES ($1, TRUE, TRUE, 'published', $2) RETURNING product_id`, name, set.SetID,
|
|
).Scan(&productID); err != nil {
|
|
t.Fatalf("create product %s: %v", name, err)
|
|
}
|
|
return testProduct{productID: productID, setID: set.SetID}
|
|
}
|
|
|
|
func ruleForKey(t *testing.T, ctx context.Context, q *entitlements.Queries, setID, resourceKey string) string {
|
|
t.Helper()
|
|
rules, err := q.GetActiveRulesBySetID(ctx, setID)
|
|
if err != nil {
|
|
t.Fatalf("active rules of set %s: %v", setID, err)
|
|
}
|
|
for _, rule := range rules {
|
|
if rule.ResourceKey.String == resourceKey {
|
|
return rule.RuleID
|
|
}
|
|
}
|
|
t.Fatalf("set %s has no active rule for %s", setID, resourceKey)
|
|
return ""
|
|
}
|
|
|
|
func poolLimitFor(t *testing.T, ctx context.Context, database *sql.DB, poolID, resourceKey string) int64 {
|
|
t.Helper()
|
|
var limit int64
|
|
if err := database.QueryRowContext(ctx,
|
|
`SELECT resource_limit FROM core.numeric_entitlements WHERE pool_id = $1 AND resource_key = $2`,
|
|
poolID, resourceKey,
|
|
).Scan(&limit); err != nil {
|
|
t.Fatalf("read limit of pool %s for %s: %v", poolID, resourceKey, err)
|
|
}
|
|
return limit
|
|
}
|
|
|
|
// assertEffect checks one effect row's movement and its two policies: the
|
|
// policy the changed rule held and the one governing the pool and key.
|
|
func assertEffect(t *testing.T, where string, effect effectRow, before, after int64, rulePolicy, governing string) {
|
|
t.Helper()
|
|
if effect.limitBefore.Int64 != before || effect.limitAfter.Int64 != after {
|
|
t.Errorf("%s: limit %d to %d, want %d to %d", where, effect.limitBefore.Int64, effect.limitAfter.Int64, before, after)
|
|
}
|
|
if effect.reductionPolicy.String != rulePolicy {
|
|
t.Errorf("%s: reduction_policy %q, want %q", where, effect.reductionPolicy.String, rulePolicy)
|
|
}
|
|
if effect.governingPolicy.String != governing {
|
|
t.Errorf("%s: governing_policy %q, want %q", where, effect.governingPolicy.String, governing)
|
|
}
|
|
}
|
|
|
|
// Scenario: a batch of two deltas, on keys A and B, applied below the cap to a
|
|
// pool that also moves on key C. Each pool is recomputed once, A's effect
|
|
// hangs off A's act, B's off B's act and C's off the batch's first act, no
|
|
// effect is written twice, and every effect carries the governing policy,
|
|
// which on A is the other set's force_reduce rather than the changed rule's
|
|
// own clamp. The batch dry run of the same inputs said the same numbers.
|
|
func TestRuleChangeBatchCommitRoutesEffectsByKey(t *testing.T) {
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
fx := newBatchFixture(t, ctx, database, 1)
|
|
pool := fx.orgs[0].pool.PoolID
|
|
in := fx.edits(0)
|
|
|
|
preview, err := entitlements.RuleChangeBatchPreview(ctx, entitlements.New(database), in)
|
|
if err != nil {
|
|
t.Fatalf("batch preview: %v", err)
|
|
}
|
|
if !preview.Exact || preview.PoolCount != 1 {
|
|
t.Fatalf("preview: exact=%v over %d pools, want exact over 1", preview.Exact, preview.PoolCount)
|
|
}
|
|
if len(preview.Keys) != 2 || preview.Keys[0].ResourceKey != fx.keyA {
|
|
t.Fatalf("preview keys = %v, want A first", previewKeys(preview))
|
|
}
|
|
previewA, previewB := preview.ForKey(fx.keyA), preview.ForKey(fx.keyB)
|
|
if previewA.Buckets.Up.Count != 1 || previewA.Buckets.Up.Rows[0].LimitAfter != 6 {
|
|
t.Errorf("preview of A = %+v, want one pool up to 6", previewA.Buckets.Up)
|
|
}
|
|
if previewB.Buckets.Down.Count != 1 || previewB.Buckets.Down.Rows[0].LimitAfter != 1 {
|
|
t.Errorf("preview of B = %+v, want one pool down to 1", previewB.Buckets.Down)
|
|
}
|
|
if previewA.Buckets.AlsoOther.Count != 1 {
|
|
t.Fatalf("preview of A named %d pools moving on another key, want 1", previewA.Buckets.AlsoOther.Count)
|
|
}
|
|
if others := previewA.Buckets.AlsoOther.Rows[0].OtherKeys; len(others) != 1 || others[0].ResourceKey != fx.keyC {
|
|
t.Errorf("the other keys moving = %+v, want C alone", others)
|
|
}
|
|
|
|
result, err := entitlements.RuleChangeBatchCommit(ctx, database, in)
|
|
if err != nil {
|
|
t.Fatalf("batch commit: %v", err)
|
|
}
|
|
if result.SyncPath != "atomic" || result.RulesChanged != 2 || len(result.ChangeIDs) != 2 {
|
|
t.Fatalf("result = %s path, %d rules, %d acts, want atomic / 2 / 2",
|
|
result.SyncPath, result.RulesChanged, len(result.ChangeIDs))
|
|
}
|
|
if result.Recomputed != 1 || result.Changed != 1 {
|
|
t.Errorf("result = %d recomputed / %d changed, want 1 / 1", result.Recomputed, result.Changed)
|
|
}
|
|
if want := "2 rules changed. 1 pool recomputed, 1 changed."; result.Summary() != want {
|
|
t.Errorf("summary = %q, want %q", result.Summary(), want)
|
|
}
|
|
for key, want := range map[string]int64{fx.keyA: 6, fx.keyB: 1, fx.keyC: 9} {
|
|
if got := poolLimitFor(t, ctx, database, pool, key); got != want {
|
|
t.Errorf("committed %s at %d, want %d", key, got, want)
|
|
}
|
|
}
|
|
|
|
// The acts are filed in resource-key order, so the first is A's.
|
|
actA, actB := result.ChangeIDs[0], result.ChangeIDs[1]
|
|
effectsA, effectsB := readEffects(t, ctx, database, actA), readEffects(t, ctx, database, actB)
|
|
if len(effectsA[pool]) != 2 {
|
|
t.Errorf("A's act wrote %d effects for the pool, want A and C", len(effectsA[pool]))
|
|
}
|
|
if len(effectsB[pool]) != 1 {
|
|
t.Errorf("B's act wrote %d effects for the pool, want B alone", len(effectsB[pool]))
|
|
}
|
|
assertEffect(t, "A under A's act", effectsA[pool][fx.keyA], 3, 6, "clamp", "force_reduce")
|
|
assertEffect(t, "C under A's act", effectsA[pool][fx.keyC], 99, 9, "", "clamp")
|
|
assertEffect(t, "B under B's act", effectsB[pool][fx.keyB], 3, 1, "defer", "defer")
|
|
if _, written := effectsB[pool][fx.keyC]; written {
|
|
t.Errorf("C's effect was written under both acts of the batch")
|
|
}
|
|
|
|
// Both obligations for the pool were settled from the one recomputation.
|
|
var pending int
|
|
if err := database.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM core.entitlement_set_change_obligations
|
|
WHERE change_id = ANY($1::uuid[]) AND status <> 'settled'`,
|
|
uuidArray(result.ChangeIDs),
|
|
).Scan(&pending); err != nil {
|
|
t.Fatalf("count obligations: %v", err)
|
|
}
|
|
if pending != 0 {
|
|
t.Errorf("unsettled obligations = %d, want 0", pending)
|
|
}
|
|
|
|
// The acts of one batch are related by one request identifier and by
|
|
// nothing else; the ledger keeps one row per rule.
|
|
var requests int
|
|
if err := database.QueryRowContext(ctx,
|
|
`SELECT COUNT(DISTINCT request_id) FROM core.entitlement_set_changes WHERE change_id = ANY($1::uuid[])`,
|
|
uuidArray(result.ChangeIDs),
|
|
).Scan(&requests); err != nil {
|
|
t.Fatalf("count request ids: %v", err)
|
|
}
|
|
if requests != 1 {
|
|
t.Errorf("the batch's acts carry %d request ids, want 1", requests)
|
|
}
|
|
}
|
|
|
|
func previewKeys(preview *entitlements.BatchPreviewResult) []string {
|
|
keys := make([]string, 0, len(preview.Keys))
|
|
for _, result := range preview.Keys {
|
|
keys = append(keys, result.ResourceKey)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// uuidArray renders change ids for a Postgres uuid[] parameter.
|
|
func uuidArray(ids []string) string {
|
|
out := "{"
|
|
for i, id := range ids {
|
|
if i > 0 {
|
|
out += ","
|
|
}
|
|
out += id
|
|
}
|
|
return out + "}"
|
|
}
|
|
|
|
// Scenario: the same batch above the cap. The rules, both acts and every
|
|
// obligation commit together; the request's own drain then settles each pool
|
|
// once under both acts, with the same routing, and writes each key's effect
|
|
// exactly once.
|
|
func TestRuleChangeBatchDeferredDrainsOncePerPool(t *testing.T) {
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
fx := newBatchFixture(t, ctx, database, 2)
|
|
|
|
result, err := entitlements.RuleChangeBatchCommit(ctx, database, fx.edits(1))
|
|
if err != nil {
|
|
t.Fatalf("batch commit: %v", err)
|
|
}
|
|
if result.SyncPath != "deferred" {
|
|
t.Fatalf("sync_path = %q, want deferred", result.SyncPath)
|
|
}
|
|
if result.Recomputed != 2 {
|
|
t.Errorf("drained %d pools, want 2: a pool owed under both acts is recomputed once", result.Recomputed)
|
|
}
|
|
if want := "2 rules changed. 2 of 2 pools recomputed."; result.Summary() != want {
|
|
t.Errorf("summary = %q, want %q", result.Summary(), want)
|
|
}
|
|
|
|
actA, actB := result.ChangeIDs[0], result.ChangeIDs[1]
|
|
effectsA, effectsB := readEffects(t, ctx, database, actA), readEffects(t, ctx, database, actB)
|
|
for _, org := range fx.orgs {
|
|
pool := org.pool.PoolID
|
|
assertEffect(t, "A under A's act", effectsA[pool][fx.keyA], 3, 6, "clamp", "force_reduce")
|
|
assertEffect(t, "C under A's act", effectsA[pool][fx.keyC], 99, 9, "", "clamp")
|
|
assertEffect(t, "B under B's act", effectsB[pool][fx.keyB], 3, 1, "defer", "defer")
|
|
if len(effectsA[pool]) != 2 || len(effectsB[pool]) != 1 {
|
|
t.Errorf("pool %s: %d effects under A and %d under B, want 2 and 1", pool, len(effectsA[pool]), len(effectsB[pool]))
|
|
}
|
|
// One recomputation writes every effect of one pool in one
|
|
// transaction, so they share the transaction's clock; a second
|
|
// recomputation per act would stamp two moments.
|
|
if effectsA[pool][fx.keyA].createdAt != effectsB[pool][fx.keyB].createdAt {
|
|
t.Errorf("pool %s was settled in two transactions: %s and %s",
|
|
pool, effectsA[pool][fx.keyA].createdAt, effectsB[pool][fx.keyB].createdAt)
|
|
}
|
|
}
|
|
|
|
var pending int
|
|
if err := database.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM core.entitlement_set_change_obligations
|
|
WHERE change_id = ANY($1::uuid[]) AND status <> 'settled'`, uuidArray(result.ChangeIDs),
|
|
).Scan(&pending); err != nil {
|
|
t.Fatalf("count obligations: %v", err)
|
|
}
|
|
if pending != 0 {
|
|
t.Errorf("unsettled obligations = %d after the request's drain, want 0", pending)
|
|
}
|
|
}
|
|
|
|
// Scenario: the refusals of a batch. A pool that cannot be recomputed rolls
|
|
// back every delta; a delta naming a rule another operator deactivated
|
|
// refuses the whole batch on that delta's line; and applying the same batch
|
|
// twice reports Already applied. on the delta that is already there.
|
|
func TestRuleChangeBatchRefusalsRollBackEverything(t *testing.T) {
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
fx := newBatchFixture(t, ctx, database, 2)
|
|
|
|
t.Run("a pool that cannot be recomputed", func(t *testing.T) {
|
|
in := fx.edits(0)
|
|
in[0].TestOnlyFailOnPool = 2
|
|
in[1].TestOnlyFailOnPool = 2
|
|
_, err := entitlements.RuleChangeBatchCommit(ctx, database, in)
|
|
if err == nil {
|
|
t.Fatal("a batch with an injected failure succeeded, want a refusal")
|
|
}
|
|
refusal, ok := entitlements.AsRefusal(err)
|
|
if !ok || refusal.Kind != entitlements.RefusalPoolRecompute {
|
|
t.Fatalf("error = %v, want a pool-recompute refusal", err)
|
|
}
|
|
assertBatchUnapplied(t, ctx, database, fx)
|
|
})
|
|
|
|
t.Run("a rule that is no longer active", func(t *testing.T) {
|
|
deactivateRule(t, ctx, database, fx.batched.setID, fx.ruleB)
|
|
in := fx.edits(0)
|
|
_, err := entitlements.RuleChangeBatchCommit(ctx, database, in)
|
|
refusal, ok := entitlements.AsRefusal(err)
|
|
if !ok {
|
|
t.Fatalf("error = %v, want a refusal", err)
|
|
}
|
|
if refusal.Message != "That rule is no longer active." || refusal.Kind != entitlements.RefusalInactiveRule {
|
|
t.Errorf("refusal = %q (%s), want the inactive-rule sentence", refusal.Message, refusal.Kind)
|
|
}
|
|
if refusal.DeltaIndex != 0 || refusal.ResourceKey != fx.keyB {
|
|
t.Errorf("refusal names delta %d for %q, want delta 0 for B", refusal.DeltaIndex, refusal.ResourceKey)
|
|
}
|
|
// A's rule is untouched: the batch is one transaction.
|
|
if got := currentRuleValue(t, ctx, database, fx.ruleA); got != 1 {
|
|
t.Errorf("A's rule = %d after a refused batch, want 1", got)
|
|
}
|
|
})
|
|
|
|
t.Run("already applied", func(t *testing.T) {
|
|
only := []entitlements.RuleChangeInput{fx.edits(0)[1]}
|
|
if _, err := entitlements.RuleChangeBatchCommit(ctx, database, only); err != nil {
|
|
t.Fatalf("apply A alone: %v", err)
|
|
}
|
|
_, err := entitlements.RuleChangeBatchCommit(ctx, database, only)
|
|
refusal, ok := entitlements.AsRefusal(err)
|
|
if !ok {
|
|
t.Fatalf("error = %v, want a refusal", err)
|
|
}
|
|
if refusal.Message != "Already applied." || refusal.DeltaIndex != 0 {
|
|
t.Errorf("refusal = %q on delta %d, want Already applied. on delta 0", refusal.Message, refusal.DeltaIndex)
|
|
}
|
|
})
|
|
}
|
|
|
|
// assertBatchUnapplied is the atomic promise: neither rule moved and the
|
|
// ledger holds no act for the refused batch.
|
|
func assertBatchUnapplied(t *testing.T, ctx context.Context, database *sql.DB, fx batchFixture) {
|
|
t.Helper()
|
|
if got := currentRuleValue(t, ctx, database, fx.ruleA); got != 1 {
|
|
t.Errorf("A's rule = %d after a refused batch, want 1", got)
|
|
}
|
|
if got := currentRuleValue(t, ctx, database, fx.ruleB); got != 3 {
|
|
t.Errorf("B's rule = %d after a refused batch, want 3", got)
|
|
}
|
|
var acts int
|
|
if err := database.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM core.entitlement_set_changes WHERE set_id = $1 AND change_kind = 'rule_modified'`,
|
|
fx.batched.setID,
|
|
).Scan(&acts); err != nil {
|
|
t.Fatalf("count acts: %v", err)
|
|
}
|
|
if acts != 0 {
|
|
t.Errorf("rule_modified acts = %d after a refused batch, want 0", acts)
|
|
}
|
|
}
|
|
|
|
func currentRuleValue(t *testing.T, ctx context.Context, database *sql.DB, ruleID string) int64 {
|
|
t.Helper()
|
|
var value int64
|
|
if err := database.QueryRowContext(ctx,
|
|
`SELECT resource_value FROM core.entitlement_set_rules WHERE rule_id = $1`, ruleID).Scan(&value); err != nil {
|
|
t.Fatalf("read rule %s: %v", ruleID, err)
|
|
}
|
|
return value
|
|
}
|
|
|
|
// deactivateRule takes a rule out from under a staged delta, the way another
|
|
// operator would, through the enclosed function that is the only writer.
|
|
func deactivateRule(t *testing.T, ctx context.Context, database *sql.DB, setID, ruleID string) {
|
|
t.Helper()
|
|
if _, err := entitlements.RuleChangeCommit(ctx, database, entitlements.RuleChangeInput{
|
|
SetID: setID,
|
|
ChangeKind: entitlements.ChangeKindRuleDeactivated,
|
|
RuleID: ruleID,
|
|
ActorType: entitlements.ActorTypeSystem,
|
|
}); err != nil {
|
|
t.Fatalf("deactivate rule %s: %v", ruleID, err)
|
|
}
|
|
}
|
|
|
|
// Scenario: the effect row's audit projection carries both policies
|
|
// (entitlement-set-history, "The change row and every effect row project onto
|
|
// the audit view shape"). The organization's trail reads the pool as the
|
|
// resource, the act's actor and occurrence through the foreign key, and a
|
|
// payload naming the rule's policy and the governing one.
|
|
func TestEffectAuditProjectionCarriesBothPolicies(t *testing.T) {
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
fx := newBatchFixture(t, ctx, database, 1)
|
|
|
|
result, err := entitlements.RuleChangeBatchCommit(ctx, database, fx.edits(0))
|
|
if err != nil {
|
|
t.Fatalf("batch commit: %v", err)
|
|
}
|
|
rows, err := entitlements.New(database).ListEntitlementSetChangeEffectsByOrg(ctx,
|
|
entitlements.ListEntitlementSetChangeEffectsByOrgParams{
|
|
OrgID: fx.orgs[0].org.OrgID,
|
|
Limit: 50,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("project the org's effects: %v", err)
|
|
}
|
|
projected := map[string]entitlements.ListEntitlementSetChangeEffectsByOrgRow{}
|
|
for _, row := range rows {
|
|
projected[row.ResourceKey] = row
|
|
}
|
|
if len(projected) != 3 {
|
|
t.Fatalf("the org's trail names %d keys, want A, B and C", len(projected))
|
|
}
|
|
for key, want := range map[string][2]string{
|
|
fx.keyA: {"clamp", "force_reduce"},
|
|
fx.keyB: {"defer", "defer"},
|
|
fx.keyC: {"", "clamp"},
|
|
} {
|
|
row := projected[key]
|
|
if row.ReductionPolicy.String != want[0] || row.GoverningPolicy.String != want[1] {
|
|
t.Errorf("%s projects %q / %q, want %q / %q", key,
|
|
row.ReductionPolicy.String, row.GoverningPolicy.String, want[0], want[1])
|
|
}
|
|
if row.PoolID != fx.orgs[0].pool.PoolID || row.ActorType != entitlements.ActorTypeSystem {
|
|
t.Errorf("%s projects pool %s and actor %s, want the batch's pool and actor", key, row.PoolID, row.ActorType)
|
|
}
|
|
}
|
|
if len(result.ChangeIDs) != 2 {
|
|
t.Errorf("acts = %d, want 2", len(result.ChangeIDs))
|
|
}
|
|
}
|
|
|
|
// Scenario: one batch dry run agrees with the batch commit on every key and
|
|
// every pool, which is what lets the tray promise what Apply changes then
|
|
// does.
|
|
func TestRuleChangeBatchPreviewEqualsBatchCommit(t *testing.T) {
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
fx := newBatchFixture(t, ctx, database, 2)
|
|
in := fx.edits(0)
|
|
|
|
preview, err := entitlements.RuleChangeBatchPreview(ctx, entitlements.New(database), in)
|
|
if err != nil {
|
|
t.Fatalf("batch preview: %v", err)
|
|
}
|
|
promised := map[string]map[string]int64{}
|
|
for _, result := range preview.Keys {
|
|
promised[result.ResourceKey] = map[string]int64{}
|
|
for _, bucket := range []entitlements.PreviewBucket{result.Buckets.Up, result.Buckets.Down, result.Buckets.Unchanged} {
|
|
for _, row := range bucket.Rows {
|
|
promised[result.ResourceKey][row.PoolID] = row.LimitAfter
|
|
}
|
|
}
|
|
}
|
|
|
|
result, err := entitlements.RuleChangeBatchCommit(ctx, database, in)
|
|
if err != nil {
|
|
t.Fatalf("batch commit: %v", err)
|
|
}
|
|
if result.Recomputed != 2 {
|
|
t.Fatalf("recomputed %d pools, want 2", result.Recomputed)
|
|
}
|
|
for _, key := range []string{fx.keyA, fx.keyB} {
|
|
for _, org := range fx.orgs {
|
|
want, ok := promised[key][org.pool.PoolID]
|
|
if !ok {
|
|
t.Fatalf("the dry run put pool %s in no bucket for %s", org.pool.PoolID, key)
|
|
}
|
|
if got := poolLimitFor(t, ctx, database, org.pool.PoolID, key); got != want {
|
|
t.Errorf("%s on pool %s: committed %d, the dry run said %d", key, org.pool.PoolID, got, want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Scenario: two independent commits arrive carrying one caller request id,
|
|
// which an inbound X-Request-ID header can make happen. The engine mints the
|
|
// identifier that relates a batch's acts, so the two acts are related by
|
|
// nothing and neither drain can settle the other's obligations from its own
|
|
// recomputation.
|
|
func TestSeparateCommitsAreNotOneBatch(t *testing.T) {
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
fx := newBatchFixture(t, ctx, database, 1)
|
|
in := fx.edits(0)
|
|
const shared = "x-request-id-sent-by-the-caller"
|
|
in[0].RequestID, in[1].RequestID = shared, shared
|
|
|
|
first, err := entitlements.RuleChangeBatchCommit(ctx, database, []entitlements.RuleChangeInput{in[0]})
|
|
if err != nil {
|
|
t.Fatalf("first commit: %v", err)
|
|
}
|
|
second, err := entitlements.RuleChangeBatchCommit(ctx, database, []entitlements.RuleChangeInput{in[1]})
|
|
if err != nil {
|
|
t.Fatalf("second commit: %v", err)
|
|
}
|
|
|
|
ids := map[string]string{}
|
|
for _, changeID := range []string{first.ChangeID, second.ChangeID} {
|
|
var requestID sql.NullString
|
|
if err := database.QueryRowContext(ctx,
|
|
`SELECT request_id FROM core.entitlement_set_changes WHERE change_id = $1`, changeID,
|
|
).Scan(&requestID); err != nil {
|
|
t.Fatalf("read request id of %s: %v", changeID, err)
|
|
}
|
|
if requestID.String == shared {
|
|
t.Errorf("act %s stored the caller's request id", changeID)
|
|
}
|
|
ids[changeID] = requestID.String
|
|
}
|
|
if ids[first.ChangeID] == ids[second.ChangeID] {
|
|
t.Errorf("two separate commits share the request id %q, so a drain would read them as one batch", ids[first.ChangeID])
|
|
}
|
|
|
|
// Each act settled its own key alone, which is what the separation buys.
|
|
firstEffects := readEffects(t, ctx, database, first.ChangeID)
|
|
if _, crossed := firstEffects[fx.orgs[0].pool.PoolID][fx.keyA]; crossed {
|
|
t.Errorf("B's act wrote an effect for A's key")
|
|
}
|
|
}
|
|
|
|
// Scenario: two deltas on one resource key. The tray stages at most one per
|
|
// key; a second is a defect, and the engine refuses it rather than filing two
|
|
// acts whose effects would contend for one key.
|
|
func TestRuleChangeBatchRefusesTwoDeltasOnOneKey(t *testing.T) {
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
fx := newBatchFixture(t, ctx, database, 1)
|
|
in := fx.edits(0)
|
|
|
|
_, err := entitlements.RuleChangeBatchCommit(ctx, database, []entitlements.RuleChangeInput{in[1], in[1]})
|
|
if err == nil {
|
|
t.Fatal("a batch with two deltas on one key succeeded")
|
|
}
|
|
if !strings.Contains(err.Error(), "one delta per resource key") {
|
|
t.Errorf("error = %v, want the one-delta-per-key refusal", err)
|
|
}
|
|
if got := currentRuleValue(t, ctx, database, fx.ruleA); got != 1 {
|
|
t.Errorf("A's rule = %d after the refusal, want 1", got)
|
|
}
|
|
}
|
|
|
|
// Scenario: a dry run of two overlays on one resource key. The fold answers
|
|
// per key, so a second overlay for one key has no meaning and is refused
|
|
// rather than silently applied in list order.
|
|
func TestDryRunRefusesTwoOverlaysOnOneKey(t *testing.T) {
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
fx := newBatchFixture(t, ctx, database, 1)
|
|
|
|
overlay := entitlements.RuleOverlay{
|
|
SetID: fx.batched.setID,
|
|
RuleType: "limit",
|
|
ResourceKey: sql.NullString{String: fx.keyA, Valid: true},
|
|
ResourceValue: sql.NullInt64{Int64: 4, Valid: true},
|
|
ResourcePerUnit: sql.NullBool{Bool: false, Valid: true},
|
|
StackingPolicy: sql.NullString{String: "additive", Valid: true},
|
|
IsActive: true,
|
|
}
|
|
_, err := entitlements.DryRunPoolEntitlements(ctx, entitlements.New(database), fx.orgs[0].pool.PoolID,
|
|
[]entitlements.RuleOverlay{overlay, overlay})
|
|
if err == nil {
|
|
t.Fatal("a dry run with two overlays for one key succeeded")
|
|
}
|
|
if !strings.Contains(err.Error(), "two overlays for resource key") {
|
|
t.Errorf("error = %v, want the two-overlays refusal", err)
|
|
}
|
|
}
|