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.
321 lines
13 KiB
Go
321 lines
13 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server_test
|
|
|
|
// Tests for ExtendGrant's pool-ownership and multi-pool refusal guards
|
|
// (schema-hardening tasks 2.2 + 2.5, design D3): before conferring, the
|
|
// handler now refuses an organization that resolves to more than one active
|
|
// resource pool (mirroring IssueGrant's finding-#32 guard), and verifies the
|
|
// URL pool actually belongs to the URL org and is its default pool. Reuses
|
|
// the otc*/otcHarness fixtures from operator_org_type_change_test.go (same
|
|
// package).
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
)
|
|
|
|
// postOrgPool invokes an ExtendGrant-shaped handler with both {orgID} and
|
|
// {poolID} path values set, using the harness's authenticated-operator
|
|
// context. Returns status, body, and the HX-Trigger header (success/error
|
|
// toasts fire there, not in the body -- docs/operator-ux-conventions.md §3).
|
|
func (h *otcHarness) postOrgPool(handler http.HandlerFunc, orgID, poolID string, form url.Values) (int, string, string) {
|
|
h.t.Helper()
|
|
req := httptest.NewRequestWithContext(h.ctx, http.MethodPost, "/", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.SetPathValue("orgID", orgID)
|
|
req.SetPathValue("poolID", poolID)
|
|
rec := httptest.NewRecorder()
|
|
handler(rec, req)
|
|
return rec.Code, rec.Body.String(), rec.Header().Get("HX-Trigger")
|
|
}
|
|
|
|
// A pool that belongs to a different organization than the URL's org is
|
|
// refused: nothing is conferred or superseded (plan-enrollment-administration
|
|
// spec, "Extension against a pool the org does not own is refused").
|
|
func TestExtendGrant_RefusesPoolFromAnotherOrg(t *testing.T) {
|
|
database := testDB(t)
|
|
f := newOtcFixture(t, database)
|
|
h := newOtcHarness(t, database, f.operatorID)
|
|
|
|
orgA, poolA := otcOrg(t, database, f, "Extend Org A", true)
|
|
otcConferDefaultA(t, database, f, poolA)
|
|
_, poolB := otcOrg(t, database, f, "Extend Org B", true)
|
|
otcConferDefaultA(t, database, f, poolB)
|
|
|
|
beforeA := otcScalar(t, database, `SELECT count(*) FROM core.grants WHERE granted_to_org_id = $1`, orgA)
|
|
|
|
// orgA's URL, but poolB's pool (belongs to a different org).
|
|
code, body, _ := h.postOrgPool(h.handler.ExtendGrant, orgA, poolB, url.Values{"reason": {"cross-pool test"}})
|
|
if code != http.StatusConflict {
|
|
t.Fatalf("status=%d body=%s", code, body)
|
|
}
|
|
if !strings.Contains(body, "does not belong to this organization") {
|
|
t.Errorf("refusal copy missing; body:\n%s", body)
|
|
}
|
|
|
|
afterA := otcScalar(t, database, `SELECT count(*) FROM core.grants WHERE granted_to_org_id = $1`, orgA)
|
|
if afterA != beforeA {
|
|
t.Errorf("grants for orgA changed: %d -> %d, want no writes on refusal", beforeA, afterA)
|
|
}
|
|
}
|
|
|
|
// A pool that is the URL org's ONLY pool but isn't its default pool is
|
|
// refused too -- the ownership/default-type check, isolated from the
|
|
// multi-pool guard (single pool here, so that guard passes clean).
|
|
func TestExtendGrant_RefusesNonDefaultPool(t *testing.T) {
|
|
database := testDB(t)
|
|
f := newOtcFixture(t, database)
|
|
h := newOtcHarness(t, database, f.operatorID)
|
|
|
|
org, _ := otcOrg(t, database, f, "Extend NonDefault Org", false) // no pool yet
|
|
|
|
entQ := entitlements.New(database)
|
|
shared, err := entQ.CreateResourcePool(context.Background(), entitlements.CreateResourcePoolParams{
|
|
OrgID: org, Name: "Shared", PoolType: "shared", IsAutoManaged: false,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create shared pool: %v", err)
|
|
}
|
|
|
|
before := otcScalar(t, database, `SELECT count(*) FROM core.grants WHERE granted_to_org_id = $1`, org)
|
|
|
|
code, body, _ := h.postOrgPool(h.handler.ExtendGrant, org, shared.PoolID, url.Values{"reason": {"non-default test"}})
|
|
if code != http.StatusConflict {
|
|
t.Fatalf("status=%d body=%s", code, body)
|
|
}
|
|
if !strings.Contains(body, "does not belong to this organization") {
|
|
t.Errorf("non-default-pool refusal copy missing; body:\n%s", body)
|
|
}
|
|
|
|
after := otcScalar(t, database, `SELECT count(*) FROM core.grants WHERE granted_to_org_id = $1`, org)
|
|
if after != before {
|
|
t.Errorf("grants changed: %d -> %d, want no writes on refusal", before, after)
|
|
}
|
|
}
|
|
|
|
// An organization with more than one active resource pool is refused
|
|
// wholesale, mirroring IssueGrant's finding-#32 guard
|
|
// (plan-enrollment-administration spec, "Multi-pool organization is
|
|
// refused").
|
|
func TestExtendGrant_RefusesMultiPoolOrg(t *testing.T) {
|
|
database := testDB(t)
|
|
f := newOtcFixture(t, database)
|
|
h := newOtcHarness(t, database, f.operatorID)
|
|
|
|
org, pool := otcOrg(t, database, f, "Extend MultiPool Org", true)
|
|
otcConferDefaultA(t, database, f, pool)
|
|
|
|
entQ := entitlements.New(database)
|
|
if _, err := entQ.CreateResourcePool(context.Background(), entitlements.CreateResourcePoolParams{
|
|
OrgID: org, Name: "Second Pool", PoolType: "shared", IsAutoManaged: false,
|
|
}); err != nil {
|
|
t.Fatalf("create second pool: %v", err)
|
|
}
|
|
|
|
before := otcScalar(t, database, `SELECT count(*) FROM core.grants WHERE granted_to_org_id = $1`, org)
|
|
|
|
code, body, _ := h.postOrgPool(h.handler.ExtendGrant, org, pool, url.Values{"reason": {"multi-pool test"}})
|
|
if code != http.StatusConflict {
|
|
t.Fatalf("status=%d body=%s", code, body)
|
|
}
|
|
if !strings.Contains(body, "more than one resource pool") {
|
|
t.Errorf("multi-pool refusal copy missing; body:\n%s", body)
|
|
}
|
|
|
|
after := otcScalar(t, database, `SELECT count(*) FROM core.grants WHERE granted_to_org_id = $1`, org)
|
|
if after != before {
|
|
t.Errorf("grants changed: %d -> %d, want no writes on refusal", before, after)
|
|
}
|
|
}
|
|
|
|
// Control: a normal single-default-pool org's extension is not blocked by
|
|
// the new guards -- it proceeds to conferral as before.
|
|
func TestExtendGrant_SucceedsForSinglePoolOrg(t *testing.T) {
|
|
database := testDB(t)
|
|
f := newOtcFixture(t, database)
|
|
h := newOtcHarness(t, database, f.operatorID)
|
|
|
|
org, pool := otcOrg(t, database, f, "Extend Control Org", true)
|
|
otcConferDefaultA(t, database, f, pool)
|
|
|
|
before := otcScalar(t, database, `SELECT count(*) FROM core.grants WHERE granted_to_org_id = $1`, org)
|
|
|
|
code, body, trigger := h.postOrgPool(h.handler.ExtendGrant, org, pool, url.Values{
|
|
"reason": {"control test"},
|
|
"provision_id": {activeProvisionID(t, database, pool, f.prodA)},
|
|
})
|
|
if code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", code, body)
|
|
}
|
|
if !strings.Contains(trigger, "extended") {
|
|
t.Errorf("expected an extension-succeeded toast; HX-Trigger=%q", trigger)
|
|
}
|
|
|
|
after := otcScalar(t, database, `SELECT count(*) FROM core.grants WHERE granted_to_org_id = $1`, org)
|
|
if after != before+1 {
|
|
t.Errorf("grants for org: %d -> %d, want exactly one new grant", before, after)
|
|
}
|
|
// A default is system-authored; its extension is an operator's manual
|
|
// decree (chk_grants_default_iff_system_authored).
|
|
var gotReason string
|
|
if err := database.QueryRow(`SELECT grant_reason FROM core.grants WHERE granted_to_org_id = $1 ORDER BY created_at DESC LIMIT 1`, org).Scan(&gotReason); err != nil {
|
|
t.Fatalf("read newest grant reason: %v", err)
|
|
}
|
|
if gotReason != "manual" {
|
|
t.Errorf("extension of a default carries reason %q, want manual", gotReason)
|
|
}
|
|
}
|
|
|
|
// activeProvisionID resolves the active provision a pool holds for one
|
|
// product — the value the extend form carries in its hidden provision_id.
|
|
func activeProvisionID(t *testing.T, database *sql.DB, poolID, productID string) string {
|
|
t.Helper()
|
|
var id string
|
|
if err := database.QueryRowContext(context.Background(),
|
|
`SELECT provision_id FROM core.pool_provisions WHERE pool_id = $1 AND product_id = $2 AND status = 'active'`,
|
|
poolID, productID,
|
|
).Scan(&id); err != nil {
|
|
t.Fatalf("resolve active provision for product %s: %v", productID, err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
// TestExtendGrant_ExtendsExactlyTheNamedPosition covers the maintainer's
|
|
// 2026-08-23 finding: a pool can hold several active grant-backed
|
|
// provisions (positions on multiple ladders), and the old handler extended
|
|
// "the first grant-backed one" — an arbitrary pick that could disagree with
|
|
// the tier the button named. The handler now extends exactly the provision
|
|
// the form names, and refuses a stale or foreign provision id.
|
|
func TestExtendGrant_ExtendsExactlyTheNamedPosition(t *testing.T) {
|
|
database := testDB(t)
|
|
f := newOtcFixture(t, database)
|
|
h := newOtcHarness(t, database, f.operatorID)
|
|
|
|
org, pool := otcOrg(t, database, f, "Extend Scoping Org", true)
|
|
otcConferDefaultA(t, database, f, pool)
|
|
|
|
// Put a second grant-backed position (ladder B's product) on the same
|
|
// pool, mirroring what issuance does: grant, confer, materialize.
|
|
ctx := context.Background()
|
|
tx, err := entitlements.BeginMaterializing(ctx, database, nil)
|
|
if err != nil {
|
|
t.Fatalf("begin: %v", err)
|
|
}
|
|
defer tx.Rollback()
|
|
eq := entitlements.New(tx)
|
|
grantB, err := eq.CreateGrant(ctx, entitlements.CreateGrantParams{
|
|
ProductID: f.prodB,
|
|
GrantedToOrgID: uuid.NullUUID{UUID: uuid.MustParse(org), Valid: true},
|
|
GrantedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(f.operatorID), Valid: true},
|
|
GrantReason: "promotional",
|
|
Quantity: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("grant B: %v", err)
|
|
}
|
|
if _, _, err := eq.Confer(ctx, entitlements.ConferParams{
|
|
PoolID: pool, ProductID: f.prodB,
|
|
GrantID: uuid.NullUUID{UUID: uuid.MustParse(grantB.GrantID), Valid: true},
|
|
Quantity: 1,
|
|
ActorType: "operator",
|
|
ActorID: uuid.NullUUID{UUID: uuid.MustParse(f.operatorID), Valid: true},
|
|
}); err != nil {
|
|
t.Fatalf("confer B: %v", err)
|
|
}
|
|
if err := entitlements.MaterializePoolEntitlements(ctx, eq, pool); err != nil {
|
|
t.Fatalf("materialize: %v", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
t.Fatalf("commit B: %v", err)
|
|
}
|
|
|
|
provB := activeProvisionID(t, database, pool, f.prodB)
|
|
|
|
// Extending the NAMED position (B) must extend B's grant, product and
|
|
// lineage — not the default-A provision that sorts first.
|
|
code, body, _ := h.postOrgPool(h.handler.ExtendGrant, org, pool, url.Values{
|
|
"reason": {"scoped extend"},
|
|
"provision_id": {provB},
|
|
})
|
|
if code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", code, body)
|
|
}
|
|
var gotProduct, gotExtends string
|
|
if err := database.QueryRowContext(ctx,
|
|
`SELECT product_id, COALESCE(extends_grant_id::text, '') FROM core.grants
|
|
WHERE granted_to_org_id = $1 ORDER BY created_at DESC LIMIT 1`, org,
|
|
).Scan(&gotProduct, &gotExtends); err != nil {
|
|
t.Fatalf("read newest grant: %v", err)
|
|
}
|
|
if gotProduct != f.prodB {
|
|
t.Errorf("extend targeted product %s, want the named position's product %s", gotProduct, f.prodB)
|
|
}
|
|
if gotExtends != grantB.GrantID {
|
|
t.Errorf("extend lineage %s, want the named position's grant %s", gotExtends, grantB.GrantID)
|
|
}
|
|
// The extension carries the reason of the grant it extends and names
|
|
// its handoff (tier-changes-ledger D7): the ledger reads Extended by
|
|
// an operator, not Transferred under a manual reason.
|
|
var gotReason, gotTransition, gotTransitionType string
|
|
if err := database.QueryRowContext(ctx,
|
|
`SELECT grant_reason FROM core.grants WHERE granted_to_org_id = $1 ORDER BY created_at DESC LIMIT 1`, org,
|
|
).Scan(&gotReason); err != nil {
|
|
t.Fatalf("read newest grant reason: %v", err)
|
|
}
|
|
if gotReason != "promotional" {
|
|
t.Errorf("extension grant_reason %q, want the predecessor's %q", gotReason, "promotional")
|
|
}
|
|
if err := database.QueryRowContext(ctx,
|
|
`SELECT transition_type, COALESCE(reason, '') FROM core.pool_provision_transitions
|
|
WHERE pool_id = $1 AND transition_type <> 'end' ORDER BY effective_at DESC, transition_type LIMIT 1`, pool,
|
|
).Scan(&gotTransitionType, &gotTransition); err != nil {
|
|
t.Fatalf("read newest transition: %v", err)
|
|
}
|
|
if gotTransitionType != "transfer" || gotTransition != "operator_extension" {
|
|
t.Errorf("newest transition %s/%q, want transfer/operator_extension", gotTransitionType, gotTransition)
|
|
}
|
|
if !strings.Contains(body, "Extended by an operator") || !strings.Contains(body, ">Extended<") {
|
|
t.Errorf("ledger does not read the extension as Extended:\n%s", body)
|
|
}
|
|
if strings.Contains(body, "Transferred") {
|
|
t.Errorf("ledger still reads the extension as Transferred:\n%s", body)
|
|
}
|
|
|
|
// A stale/foreign provision id is refused with no new grant.
|
|
before := otcScalar(t, database, `SELECT count(*) FROM core.grants WHERE granted_to_org_id = $1`, org)
|
|
code, body, _ = h.postOrgPool(h.handler.ExtendGrant, org, pool, url.Values{
|
|
"reason": {"stale target"},
|
|
"provision_id": {uuid.NewString()},
|
|
})
|
|
if code != http.StatusOK {
|
|
t.Fatalf("stale-target render: status=%d body=%s", code, body)
|
|
}
|
|
if !strings.Contains(body, "no longer active") {
|
|
t.Errorf("expected the stale-position refusal, got:\n%s", body)
|
|
}
|
|
if after := otcScalar(t, database, `SELECT count(*) FROM core.grants WHERE granted_to_org_id = $1`, org); after != before {
|
|
t.Errorf("stale target must write nothing: grants %d -> %d", before, after)
|
|
}
|
|
|
|
// A missing provision id is refused too (the form always carries one;
|
|
// its absence means a stale page).
|
|
code, body, _ = h.postOrgPool(h.handler.ExtendGrant, org, pool, url.Values{"reason": {"no target"}})
|
|
if code != http.StatusOK {
|
|
t.Fatalf("missing-target render: status=%d body=%s", code, body)
|
|
}
|
|
if !strings.Contains(body, "did not name a position") {
|
|
t.Errorf("expected the missing-position refusal, got:\n%s", body)
|
|
}
|
|
}
|