Files
member-console/internal/server/operator_entitlement_set_rules_test.go
T
cgalo5758 8e3c68c6be Make UI surfaces honestly reflect system state
- Add deployment-name branding to titles, mastheads, and OG tags
- Share one grant delivery-state query with lineage across grants
  surfaces
- Show pool status/usage, org owners, and config readiness
- Make billing views projection-aware with recency and sync vocabulary
- Guard FedWiki creation without domains and render route-aware 404s
2026-08-23 01:45:52 -05:00

395 lines
15 KiB
Go

package server_test
import (
"context"
"log/slog"
"net/http/httptest"
"net/url"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
)
func newRulesHandler(t *testing.T, eq entitlements.Querier) *server.OperatorPartialsHandler {
t.Helper()
h, err := server.NewOperatorPartialsHandler(server.OperatorPartialsConfig{
EntitlementsQ: eq,
Logger: slog.Default(),
})
if err != nil {
t.Fatalf("construct operator partials handler: %v", err)
}
return h
}
func renderRuleFields(t *testing.T, data server.RuleFieldsData) string {
t.Helper()
h := newRulesHandler(t, nil)
rec := httptest.NewRecorder()
h.Templates.Render(rec, "operator_entitlement_set_rule_fields.html", data)
if rec.Code != 200 {
t.Fatalf("render rule fields = %d, body: %s", rec.Code, rec.Body.String())
}
return rec.Body.String()
}
// The fields region branches on the selected key's kind: numeric keys get
// value/multiply-by-quantity (no stacking-policy control, per 2026-08-22
// design D9 — rule authoring is additive-only), boolean keys get none of
// them, and no selection renders the hint state.
func TestRuleFieldsPartialBranches(t *testing.T) {
numeric := renderRuleFields(t, server.RuleFieldsData{SelectedKey: "alpha_widgets", SelectedKind: "numeric"})
for _, want := range []string{
`name="resource_value"`,
"Multiply by purchased quantity", "flat total regardless of quantity",
} {
if !strings.Contains(numeric, want) {
t.Errorf("numeric fields missing %q", want)
}
}
if strings.Contains(numeric, `name="stacking_policy"`) {
t.Error("numeric fields must not render a stacking-policy control (additive-only authoring, design D9)")
}
boolean := renderRuleFields(t, server.RuleFieldsData{SelectedKey: "alpha_flag", SelectedKind: "boolean"})
for _, reject := range []string{`name="resource_value"`, `name="stacking_policy"`, `name="resource_per_unit"`} {
if strings.Contains(boolean, reject) {
t.Errorf("boolean fields must not render %q", reject)
}
}
if !strings.Contains(boolean, "On/off capability") {
t.Error("boolean fields missing the on/off explanation")
}
hint := renderRuleFields(t, server.RuleFieldsData{})
if !strings.Contains(hint, "Select a resource key to configure the rule.") {
t.Error("empty selection missing the hint state")
}
}
// The add-rule form is key-first: retitled "Add Rule", the key select swaps
// the fields region via hx-get, and a re-render (e.g. after validation errors)
// restores the submitted key's selection and branch.
func TestRulesFormKeyFirst(t *testing.T) {
h := newRulesHandler(t, nil)
rec := httptest.NewRecorder()
h.Templates.Render(rec, "operator_entitlement_set_rules.html", server.EntitlementSetRulesData{
EntitlementSet: server.EntitlementSetViewModel{SetID: "sid-1", Name: "Test Set"},
ResourceKeys: []server.ResourceKeyOption{
{ResourceKey: "alpha_widgets", DisplayName: "Widgets"},
{ResourceKey: "alpha_flag", DisplayName: "Flag"},
},
RuleFields: server.RuleFieldsData{SelectedKey: "alpha_flag", SelectedKind: "boolean"},
Rules: []server.RuleViewModel{
{RuleID: "r1", RuleType: "boolean", ResourceKey: "alpha_flag", IsActive: true},
{RuleID: "r2", RuleType: "limit", ResourceKey: "alpha_widgets", ResourceValue: 5, StackingPolicy: "additive", IsActive: true},
},
})
if rec.Code != 200 {
t.Fatalf("render rules partial = %d, body: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if strings.Contains(body, "Add Limit Rule") {
t.Error("form still titled Add Limit Rule")
}
for _, want := range []string{
"/partials/operator/entitlement-sets/sid-1/rules/fields", `hx-target="#ruleFields"`,
`value="alpha_flag" selected`,
"On/off capability", // fields region restored to the boolean branch
"Included (on/off capability)", // boolean row in the rules table
} {
if !strings.Contains(body, want) {
t.Errorf("rules partial missing %q", want)
}
}
}
// TestEntitlementSetRulesEffectTimingDisclosure covers UX-10
// (ux-honest-surfaces 5.4): the rules panel must state, visibly and
// adjacent to the Add Rule and Delete controls, that rule changes take
// effect at a pool's next conferral rather than immediately -- and a rule
// currently backing pools' materialized entitlements must carry inline
// consequence copy beside its Delete control, while a rule with no live
// backing must not.
func TestEntitlementSetRulesEffectTimingDisclosure(t *testing.T) {
h := newRulesHandler(t, nil)
rec := httptest.NewRecorder()
h.Templates.Render(rec, "operator_entitlement_set_rules.html", server.EntitlementSetRulesData{
EntitlementSet: server.EntitlementSetViewModel{SetID: "sid-1", Name: "Test Set"},
Rules: []server.RuleViewModel{
{RuleID: "r-live", RuleType: "limit", ResourceKey: "alpha_widgets", ResourceValue: 5, StackingPolicy: "additive", IsActive: true, IsLiveBacking: true},
{RuleID: "r-idle", RuleType: "limit", ResourceKey: "beta_widgets", ResourceValue: 1, StackingPolicy: "additive", IsActive: true, IsLiveBacking: false},
},
})
if rec.Code != 200 {
t.Fatalf("render rules partial = %d, body: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "take effect at a pool's next conferral, not immediately") {
t.Errorf("expected the always-visible effect-timing disclosure, got: %s", body)
}
// Anchor on the resource key, which renders at the start of each row --
// before the conditionally-rendered consequence copy in the Actions
// cell -- so slicing from it captures the whole row.
liveIdx := strings.Index(body, "alpha_widgets")
idleIdx := strings.Index(body, "beta_widgets")
if liveIdx == -1 || idleIdx == -1 {
t.Fatal("expected both rule rows to render")
}
liveSection, idleSection := body[liveIdx:idleIdx], body[idleIdx:]
if !strings.Contains(liveSection, "Backs entitlements pools currently hold") {
t.Errorf("expected inline consequence copy beside the live-backing rule's Delete control, got: %s", liveSection)
}
if strings.Contains(idleSection, "Backs entitlements pools currently hold") {
t.Errorf("a rule with no live backing must not render the live-use consequence copy")
}
}
// rule_type is derived server-side from the key's kind: a boolean key yields a
// boolean rule with the NULLs chk_entitlement_set_rules_type requires — even
// when the client submits numeric fields — and a numeric key yields a limit
// rule. Also asserts the migration backfill kinds (core 00008 + discourse
// 00002 + fedwiki default).
func TestCreateEntitlementSetRuleKindDerived(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
eq := entitlements.New(tx)
h := newRulesHandler(t, eq)
// Migration assertions: discourse stream corrects its key to boolean;
// platform keys ride the 'numeric' default.
if rk, err := eq.GetResourceKey(ctx, "discourse_posting"); err != nil {
t.Fatalf("get discourse_posting: %v", err)
} else if rk.Kind != "boolean" {
t.Fatalf("discourse_posting kind = %q, want boolean", rk.Kind)
}
if rk, err := eq.GetResourceKey(ctx, "fedwiki_sites"); err != nil {
t.Fatalf("get fedwiki_sites: %v", err)
} else if rk.Kind != "numeric" {
t.Fatalf("fedwiki_sites kind = %q, want numeric", rk.Kind)
}
set, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "Rule Authoring Test Set",
IsActive: true,
})
if err != nil {
t.Fatalf("create set: %v", err)
}
postRule := func(form url.Values) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", "/partials/operator/entitlement-sets/"+set.SetID+"/rules", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("setID", set.SetID)
rec := httptest.NewRecorder()
h.CreateEntitlementSetRule(rec, req)
return rec
}
// Boolean key with stale numeric fields: the extras are ignored, the rule
// is boolean, and the insert passes the CHECK (explicit NULL stacking).
rec := postRule(url.Values{
"resource_key": {"discourse_posting"},
"resource_value": {"7"},
"stacking_policy": {"additive"},
"resource_per_unit": {"true"},
})
if rec.Code != 200 {
t.Fatalf("boolean create = %d, body: %s", rec.Code, rec.Body.String())
}
rec = postRule(url.Values{
"resource_key": {"fedwiki_sites"},
"resource_value": {"5"},
"stacking_policy": {"additive"},
})
if rec.Code != 200 {
t.Fatalf("limit create = %d, body: %s", rec.Code, rec.Body.String())
}
rules, err := eq.GetActiveRulesBySetID(ctx, set.SetID)
if err != nil {
t.Fatalf("list rules: %v", err)
}
if len(rules) != 2 {
t.Fatalf("rules = %d, want 2", len(rules))
}
// Ordered by resource_key ASC: discourse_posting, fedwiki_sites.
boolRule, limitRule := rules[0], rules[1]
if boolRule.RuleType != "boolean" {
t.Errorf("discourse_posting rule_type = %q, want boolean", boolRule.RuleType)
}
if boolRule.ResourceValue.Valid || boolRule.StackingPolicy.Valid || boolRule.ResourcePerUnit.Valid {
t.Errorf("boolean rule must have NULL value/stacking/per_unit, got %+v", boolRule)
}
if limitRule.RuleType != "limit" {
t.Errorf("fedwiki_sites rule_type = %q, want limit", limitRule.RuleType)
}
if !limitRule.ResourceValue.Valid || limitRule.ResourceValue.Int64 != 5 {
t.Errorf("limit rule value = %+v, want 5", limitRule.ResourceValue)
}
if limitRule.StackingPolicy.String != "additive" {
t.Errorf("limit rule stacking = %q, want additive", limitRule.StackingPolicy.String)
}
// Numeric key without its required fields still 422s.
set2, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "Rule Authoring Test Set 2",
IsActive: true,
})
if err != nil {
t.Fatalf("create set2: %v", err)
}
req := httptest.NewRequest("POST", "/partials/operator/entitlement-sets/"+set2.SetID+"/rules", strings.NewReader(url.Values{"resource_key": {"fedwiki_sites"}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("setID", set2.SetID)
rec = httptest.NewRecorder()
h.CreateEntitlementSetRule(rec, req)
if rec.Code != 422 {
t.Errorf("limit create without fields = %d, want 422", rec.Code)
}
// Unknown key 422s with a field error rather than 500ing.
req = httptest.NewRequest("POST", "/partials/operator/entitlement-sets/"+set2.SetID+"/rules", strings.NewReader(url.Values{"resource_key": {"nope_missing"}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("setID", set2.SetID)
rec = httptest.NewRecorder()
h.CreateEntitlementSetRule(rec, req)
if rec.Code != 422 {
t.Errorf("unknown key create = %d, want 422", rec.Code)
}
}
// Rule authoring is additive-only as of 2026-08-22 (maintainer decision,
// design D9): the form no longer submits a stacking_policy field, so a
// normal create defaults to "additive"; a non-additive value smuggled past
// the removed control (stale tab or crafted POST) is rejected with a
// validation error and writes nothing.
func TestCreateEntitlementSetRuleAdditiveOnly(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
eq := entitlements.New(tx)
h := newRulesHandler(t, eq)
postRule := func(setID string, form url.Values) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", "/partials/operator/entitlement-sets/"+setID+"/rules", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("setID", setID)
rec := httptest.NewRecorder()
h.CreateEntitlementSetRule(rec, req)
return rec
}
// No stacking_policy field submitted (what the form now sends) stores
// "additive".
set1, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "Additive Default Test Set",
IsActive: true,
})
if err != nil {
t.Fatalf("create set1: %v", err)
}
rec := postRule(set1.SetID, url.Values{
"resource_key": {"fedwiki_sites"},
"resource_value": {"5"},
})
if rec.Code != 200 {
t.Fatalf("create without stacking_policy = %d, body: %s", rec.Code, rec.Body.String())
}
rules, err := eq.GetActiveRulesBySetID(ctx, set1.SetID)
if err != nil {
t.Fatalf("list rules for set1: %v", err)
}
if len(rules) != 1 {
t.Fatalf("set1 rules = %d, want 1", len(rules))
}
if !rules[0].StackingPolicy.Valid || rules[0].StackingPolicy.String != "additive" {
t.Errorf("stacking policy = %+v, want additive", rules[0].StackingPolicy)
}
// A smuggled non-additive stacking_policy is rejected and writes nothing.
set2, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "Smuggled Stacking Test Set",
IsActive: true,
})
if err != nil {
t.Fatalf("create set2: %v", err)
}
rec = postRule(set2.SetID, url.Values{
"resource_key": {"fedwiki_sites"},
"resource_value": {"5"},
"stacking_policy": {"maximum"},
})
if rec.Code != 422 {
t.Fatalf("smuggled maximum create = %d, want 422, body: %s", rec.Code, rec.Body.String())
}
// The stacking-policy control no longer exists in the rendered form (design
// D9), so there is no field left to display the message against — the 422
// status plus the write-nothing guarantee below is the contract this
// scenario makes (spec: "Non-additive stacking policy is rejected").
rules2, err := eq.GetActiveRulesBySetID(ctx, set2.SetID)
if err != nil {
t.Fatalf("list rules for set2: %v", err)
}
if len(rules2) != 0 {
t.Fatalf("set2 rules = %d, want 0 (a rejected smuggled stacking policy must write nothing)", len(rules2))
}
}
// The fields endpoint resolves kind from the database and renders the matching
// branch.
func TestGetEntitlementSetRuleFieldsEndpoint(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
h := newRulesHandler(t, entitlements.New(tx))
get := func(key string) string {
target := "/partials/operator/entitlement-sets/sid-1/rules/fields"
if key != "" {
target += "?resource_key=" + url.QueryEscape(key)
}
req := httptest.NewRequest("GET", target, nil)
req.SetPathValue("setID", "sid-1")
rec := httptest.NewRecorder()
h.GetEntitlementSetRuleFields(rec, req)
if rec.Code != 200 {
t.Fatalf("fields endpoint (%q) = %d", key, rec.Code)
}
return rec.Body.String()
}
if body := get("discourse_posting"); !strings.Contains(body, "On/off capability") {
t.Error("boolean key did not render the boolean branch")
}
if body := get("fedwiki_sites"); !strings.Contains(body, "Multiply by purchased quantity") {
t.Error("numeric key did not render the numeric branch")
}
if body := get(""); !strings.Contains(body, "Select a resource key") {
t.Error("no key did not render the hint state")
}
}