- 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
395 lines
15 KiB
Go
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")
|
|
}
|
|
}
|