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") } }