1402 lines
54 KiB
Go
1402 lines
54 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server_test
|
|
|
|
// The Rules table as the form (spec entitlement-set-management "The Rules
|
|
// table is the rule form", "A staged change lives in the page until it is
|
|
// applied", "One tray under the table carries the batch and its commit",
|
|
// "The reduction policy is a field of the rule", "Rules are grouped by kind",
|
|
// "Focus follows the act", "The Rules table is a record table on fixed
|
|
// columns and stacks on narrow screens"). Every
|
|
// test here drives the row-action route, which writes nothing; the commit is
|
|
// operator_entitlement_set_rule_change_test.go's.
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"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"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// entitlementSetRulesFormName mirrors the unexported constant of the same
|
|
// name in package server (operator_entitlement_set_forms.go): the rules
|
|
// declaration's registry name, needed here because this file lives in the
|
|
// external server_test package.
|
|
const entitlementSetRulesFormName = "operator.entitlement-set.rules"
|
|
|
|
// newRulesHandler builds the operator partials handler for the rule surfaces.
|
|
// database is the handler's own write path: a rule change opens its own
|
|
// transaction through BeginRuleChange, because core.commit_rule_change asserts
|
|
// the exclusive materialization rendezvous. Tests that only read pass nil.
|
|
func newRulesHandler(t *testing.T, eq entitlements.Querier, database *sql.DB) *server.OperatorPartialsHandler {
|
|
t.Helper()
|
|
h, err := server.NewOperatorPartialsHandler(server.OperatorPartialsConfig{
|
|
EntitlementsQ: eq,
|
|
Database: database,
|
|
Logger: slog.Default(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("construct operator partials handler: %v", err)
|
|
}
|
|
return h
|
|
}
|
|
|
|
// ruleAction drives one row action of the rules form: the act, the row it was
|
|
// pressed on, and the whole page state the form would have posted with it.
|
|
func ruleAction(t *testing.T, h *server.OperatorPartialsHandler, setID string, form url.Values) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
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.PostEntitlementSetRules(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// applyRules drives the commit: the staged batch as the form carries it.
|
|
func applyRules(t *testing.T, h *server.OperatorPartialsHandler, setID string, form url.Values) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest("POST", "/partials/operator/entitlement-sets/"+setID+"/rules/apply", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.SetPathValue("setID", setID)
|
|
rec := httptest.NewRecorder()
|
|
h.ApplyEntitlementSetRules(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// commitRuleChange applies one delta through the rules form's commit, from
|
|
// the fields the retired one-rule form used to carry. The rule-authoring
|
|
// tests below and the History tests drive the write path through it, so one
|
|
// helper states how a single change reaches the engine now.
|
|
func commitRuleChange(t *testing.T, h *server.OperatorPartialsHandler, setID string, form url.Values) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
verb := form.Get("verb")
|
|
if verb == "" {
|
|
verb = "add"
|
|
}
|
|
key := form.Get("rule_id")
|
|
if verb == "add" {
|
|
key = "new:" + form.Get("resource_key")
|
|
}
|
|
// The hidden kind marker the rendered page would carry: the handler
|
|
// re-derives the real one from the database, so a numeric marker on a
|
|
// boolean key is exactly the stale submission the derivation covers.
|
|
kind := "boolean"
|
|
if form.Get("resource_value") != "" {
|
|
kind = "numeric"
|
|
}
|
|
staged := url.Values{
|
|
"staged.0.verb": {verb},
|
|
"staged.0.key": {key},
|
|
"staged.0.resource_key": {form.Get("resource_key")},
|
|
"staged.0.resource_key_kind": {kind},
|
|
}
|
|
for name, staged_name := range map[string]string{
|
|
"resource_value": "staged.0.resource_value",
|
|
"resource_per_unit": "staged.0.resource_per_unit",
|
|
"tier_reduction_policy": "staged.0.tier_reduction_policy",
|
|
} {
|
|
if v := form.Get(name); v != "" {
|
|
staged.Set(staged_name, v)
|
|
}
|
|
}
|
|
if v := form.Get("note"); v != "" {
|
|
staged.Set("note", v)
|
|
}
|
|
if v := form.Get("stacking_policy"); v != "" {
|
|
staged.Set("stacking_policy", v)
|
|
}
|
|
return applyRules(t, h, setID, staged)
|
|
}
|
|
|
|
// rulesFixture is one set carrying the rules a test names, with no pool: the
|
|
// row actions write nothing, so the population is beside the point and the
|
|
// fixture stays cheap. A key with no rule on the set is what a new row may
|
|
// choose, so a test that adds a rule asks for a set without that key's rule.
|
|
//
|
|
// The two keys are the catalog's own: fedwiki_sites is numeric and
|
|
// discourse_posting is boolean, and a test database seeds no others.
|
|
type rulesFixture struct {
|
|
setID string
|
|
numericID string
|
|
booleanID string
|
|
}
|
|
|
|
// newRulesFixture creates the set and the rules named: "numeric" for the
|
|
// numeric key's limit rule, "boolean" for the boolean key's.
|
|
func newRulesFixture(t *testing.T, database *sql.DB, limit int64, policy string, rules ...string) rulesFixture {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
tx, err := entitlements.BeginRuleChange(ctx, database)
|
|
if err != nil {
|
|
t.Fatalf("begin rule change: %v", err)
|
|
}
|
|
defer tx.Rollback()
|
|
q := entitlements.New(tx)
|
|
|
|
set, err := q.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
|
|
Name: "Rules Form Set " + uuid.New().String()[:8],
|
|
IsActive: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create set: %v", err)
|
|
}
|
|
fx := rulesFixture{setID: set.SetID}
|
|
want := map[string]bool{}
|
|
for _, r := range rules {
|
|
want[r] = true
|
|
}
|
|
|
|
if want["numeric"] {
|
|
numeric, err := entitlements.CommitRuleChangeTx(ctx, q, entitlements.CommitRuleChangeInput{
|
|
SetID: set.SetID,
|
|
ChangeKind: entitlements.ChangeKindRuleAdded,
|
|
ActorType: entitlements.ActorTypeSystem,
|
|
Rule: entitlements.RuleFields{
|
|
RuleType: "limit",
|
|
ResourceKey: sql.NullString{String: "fedwiki_sites", Valid: true},
|
|
ResourceValue: sql.NullInt64{Int64: limit, Valid: true},
|
|
ResourcePerUnit: sql.NullBool{Bool: false, Valid: true},
|
|
StackingPolicy: sql.NullString{String: "additive", Valid: true},
|
|
TierReductionPolicy: policy,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("add numeric rule: %v", err)
|
|
}
|
|
fx.numericID = numeric.RuleID
|
|
}
|
|
|
|
if want["boolean"] {
|
|
boolean, err := entitlements.CommitRuleChangeTx(ctx, q, entitlements.CommitRuleChangeInput{
|
|
SetID: set.SetID,
|
|
ChangeKind: entitlements.ChangeKindRuleAdded,
|
|
ActorType: entitlements.ActorTypeSystem,
|
|
Rule: entitlements.RuleFields{
|
|
RuleType: "boolean",
|
|
ResourceKey: sql.NullString{String: "discourse_posting", Valid: true},
|
|
TierReductionPolicy: "clamp",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("add boolean rule: %v", err)
|
|
}
|
|
fx.booleanID = boolean.RuleID
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
t.Fatalf("commit fixture: %v", err)
|
|
}
|
|
return fx
|
|
}
|
|
|
|
// The tables at rest: one group per kind, each with only its own columns
|
|
// (design D10); the record-table treatment and a right-aligned Actions column
|
|
// on both; Edit and Remove on a limit rule, Remove alone on an on/off one;
|
|
// the reduction policy stated in the Rules table's own column.
|
|
func TestEntitlementSetRulesTableAtRest(t *testing.T) {
|
|
database := testDB(t)
|
|
fx := newRulesFixture(t, database, 5, "force_reduce", "numeric", "boolean")
|
|
h := newRulesHandler(t, entitlements.New(database), database)
|
|
|
|
rec := ruleAction(t, h, fx.setID, url.Values{})
|
|
if rec.Code != 200 {
|
|
t.Fatalf("rest render = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range []string{
|
|
`id="entitlement-set-rules"`,
|
|
`data-form="` + entitlementSetRulesFormName + `"`,
|
|
`class="table table-hover table-sm table-record align-middle app-rules-table"`,
|
|
`<col class="app-col-limit">`,
|
|
`<tr class="app-rules-group"><th scope="rowgroup" colspan="5" class="small fw-semibold text-body-secondary">Limit</th></tr>`,
|
|
`<tr class="app-rules-group"><th scope="rowgroup" colspan="5" class="small fw-semibold text-body-secondary">Boolean</th></tr>`,
|
|
`<th scope="col">Reduction policy</th>`,
|
|
`<td>Force reduce</td>`,
|
|
`<th scope="col" class="text-end"><span class="visually-hidden">Actions</span></th>`,
|
|
`<td class="text-end">`,
|
|
`>Edit</button>`,
|
|
`>Remove</button>`,
|
|
">Add rule</button>",
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("the rules section is missing %q, got:\n%s", want, body)
|
|
}
|
|
}
|
|
for _, reject := range []string{
|
|
"Apply changes", "Discard all", "Nothing has been changed.",
|
|
`id="addRulePanel"`, `id="rule-change-panel"`, "Kept in this set's History.",
|
|
`name="tier_reduction_policy"`, "—", "<caption",
|
|
} {
|
|
if strings.Contains(body, reject) {
|
|
t.Errorf("a table at rest must not carry %q, got:\n%s", reject, body)
|
|
}
|
|
}
|
|
// One table: the Limit group comes first, and the boolean rule's name
|
|
// spans the value columns so its row carries no empty cell.
|
|
if strings.Count(body, "<table") != 1 {
|
|
t.Errorf("the rules render as one table, got %d", strings.Count(body, "<table"))
|
|
}
|
|
if strings.Index(body, ">Limit</th></tr>") > strings.Index(body, ">Boolean</th></tr>") {
|
|
t.Error("the Limit group renders before the Boolean group")
|
|
}
|
|
booleanRow := rowMarkup(t, body, fx.booleanID)
|
|
if strings.Count(booleanRow, "<td") != 1 || !strings.Contains(booleanRow, `<th scope="row" colspan="4">`) {
|
|
t.Errorf("a boolean row spans the value columns and carries its Actions cell alone, got:\n%s", booleanRow)
|
|
}
|
|
// The on/off row carries Remove and no Edit: it has no field to edit.
|
|
if strings.Contains(booleanRow, `"edit"`) {
|
|
t.Errorf("a boolean row must carry no Edit, got:\n%s", booleanRow)
|
|
}
|
|
if !strings.Contains(booleanRow, `"remove"`) {
|
|
t.Errorf("a boolean row must carry Remove, got:\n%s", booleanRow)
|
|
}
|
|
}
|
|
|
|
// rowMarkup carves out the markup around one rule id, which is how a row's
|
|
// own actions are read apart from its neighbours'.
|
|
func rowMarkup(t *testing.T, body, ruleID string) string {
|
|
t.Helper()
|
|
// The row's own action buttons name the rule in their hx-vals; the
|
|
// hidden batch inputs above the table name it too, so look for the
|
|
// button's form first.
|
|
i := strings.Index(body, `"key":"`+ruleID+`"`)
|
|
if i < 0 {
|
|
i = strings.Index(body, ruleID)
|
|
}
|
|
if i < 0 {
|
|
t.Fatalf("no row for %s in:\n%s", ruleID, body)
|
|
}
|
|
start := strings.LastIndex(body[:i], "<tr")
|
|
end := strings.Index(body[i:], "</tr>")
|
|
if start < 0 || end < 0 {
|
|
t.Fatalf("no row markup around %s", ruleID)
|
|
}
|
|
return body[start : i+end]
|
|
}
|
|
|
|
// Edit turns the row's Limit, Per unit and Reduction policy cells into the
|
|
// declaration's controls, in their own columns, with Stage change and Cancel
|
|
// as the row's actions and the Limit carrying focus.
|
|
func TestEntitlementSetRulesEditOpensTheRow(t *testing.T) {
|
|
database := testDB(t)
|
|
fx := newRulesFixture(t, database, 5, "clamp", "numeric", "boolean")
|
|
h := newRulesHandler(t, entitlements.New(database), database)
|
|
|
|
rec := ruleAction(t, h, fx.setID, url.Values{"act": {"edit"}, "key": {fx.numericID}})
|
|
if rec.Code != 200 {
|
|
t.Fatalf("edit = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range []string{
|
|
`name="rows.` + fx.numericID + `.resource_value"`,
|
|
`name="rows.` + fx.numericID + `.resource_per_unit"`,
|
|
`name="rows.` + fx.numericID + `.tier_reduction_policy"`,
|
|
`value="5"`,
|
|
`value="clamp" selected`,
|
|
">Stage change</button>",
|
|
">Cancel</button>",
|
|
`class="d-block d-lg-table-cell`,
|
|
`d-lg-none`,
|
|
`id="form-` + entitlementSetRulesFormName + `-resource_value-` + fx.numericID + `"`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("the editing row is missing %q, got:\n%s", want, body)
|
|
}
|
|
}
|
|
if n := strings.Count(body, "autofocus"); n != 1 {
|
|
t.Errorf("exactly one control takes focus, got %d in:\n%s", n, body)
|
|
}
|
|
if !strings.Contains(limitControl(t, body, fx.numericID), "autofocus") {
|
|
t.Errorf("Edit focuses the row's Limit, got:\n%s", body)
|
|
}
|
|
|
|
// Cancel closes that editor and nothing else.
|
|
rec = ruleAction(t, h, fx.setID, url.Values{
|
|
"act": {"cancel"},
|
|
"key": {fx.numericID},
|
|
"open": {fx.numericID},
|
|
"rows." + fx.numericID + ".resource_key_kind": {"numeric"},
|
|
"rows." + fx.numericID + ".resource_value": {"9"},
|
|
})
|
|
if rec.Code != 200 {
|
|
t.Fatalf("cancel = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body = rec.Body.String()
|
|
if strings.Contains(body, `name="rows.`+fx.numericID+`.resource_value"`) {
|
|
t.Errorf("Cancel closes the editor, got:\n%s", body)
|
|
}
|
|
// Focus returns to the control that opened the editor.
|
|
if !strings.Contains(rowMarkup(t, body, fx.numericID), `"act":"edit"`) {
|
|
t.Fatalf("the cancelled row renders its Edit again, got:\n%s", body)
|
|
}
|
|
if !strings.Contains(editButton(t, body, fx.numericID), "autofocus") {
|
|
t.Errorf("Cancel focuses the row's Edit, got:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// editButton carves out one row's Edit control.
|
|
func editButton(t *testing.T, body, ruleID string) string {
|
|
t.Helper()
|
|
row := rowMarkup(t, body, ruleID)
|
|
i := strings.Index(row, `"act":"edit"`)
|
|
if i < 0 {
|
|
t.Fatalf("no Edit in the row for %s:\n%s", ruleID, row)
|
|
}
|
|
start := strings.LastIndex(row[:i], "<button")
|
|
end := strings.Index(row[i:], ">")
|
|
return row[start : i+end]
|
|
}
|
|
|
|
// limitControl carves out one row's Limit input.
|
|
func limitControl(t *testing.T, body, instance string) string {
|
|
t.Helper()
|
|
name := `name="rows.` + instance + `.resource_value"`
|
|
i := strings.Index(body, name)
|
|
if i < 0 {
|
|
t.Fatalf("no Limit control for %s in:\n%s", instance, body)
|
|
}
|
|
end := strings.Index(body[i:], ">")
|
|
return body[i : i+end]
|
|
}
|
|
|
|
// Add rule opens the add form above the table and outside it: the key
|
|
// select alone until a key is chosen, then that kind's fields beside it in
|
|
// one dense row (design D8). The form never sits in the table; a staged
|
|
// addition does.
|
|
func TestEntitlementSetRulesAddRuleRow(t *testing.T) {
|
|
database := testDB(t)
|
|
fx := newRulesFixture(t, database, 5, "clamp", "numeric")
|
|
h := newRulesHandler(t, entitlements.New(database), database)
|
|
|
|
rec := ruleAction(t, h, fx.setID, url.Values{"act": {"add"}})
|
|
if rec.Code != 200 {
|
|
t.Fatalf("add = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range []string{
|
|
`id="entitlement-set-rule-add"`,
|
|
`name="rows.new.resource_key"`,
|
|
`>Resource</label>`,
|
|
"Choose a resource key",
|
|
`value="discourse_posting"`,
|
|
">Stage change</button>",
|
|
">Cancel</button>",
|
|
`<tr class="app-rules-group"><th scope="rowgroup" colspan="5" class="small fw-semibold text-body-secondary">Limit</th></tr>`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("the add form is missing %q, got:\n%s", want, body)
|
|
}
|
|
}
|
|
for _, reject := range []string{`name="rows.new.resource_value"`, `name="rows.new.resource_per_unit"`, `name="rows.new.tier_reduction_policy"`, `id="addRulePanel"`} {
|
|
if strings.Contains(body, reject) {
|
|
t.Errorf("an add form with no key chosen must not carry %q", reject)
|
|
}
|
|
}
|
|
if strings.Index(body, `id="entitlement-set-rule-add"`) > strings.Index(body, "<table") {
|
|
t.Errorf("the add form renders above the table, got:\n%s", body)
|
|
}
|
|
if strings.Contains(tableMarkup(t, body), `name="rows.new.`) {
|
|
t.Errorf("the add form's controls must not sit in the table, got:\n%s", body)
|
|
}
|
|
if !strings.Contains(keySelect(t, body), "autofocus") {
|
|
t.Errorf("Add rule focuses the resource-key select, got:\n%s", body)
|
|
}
|
|
|
|
// The add form renders on a set with no rule too, with no table at all.
|
|
empty := newRulesFixture(t, database, 5, "clamp")
|
|
rec = ruleAction(t, h, empty.setID, url.Values{"act": {"add"}})
|
|
if rec.Code != 200 {
|
|
t.Fatalf("add on an empty set = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if b := rec.Body.String(); !strings.Contains(b, `name="rows.new.resource_key"`) || strings.Contains(b, "<table") {
|
|
t.Errorf("an empty set's add form renders alone, got:\n%s", b)
|
|
}
|
|
fx = empty
|
|
|
|
// Choosing a numeric key reveals the numeric fields beside the select,
|
|
// each under its own label; the select's change posts the whole form, so
|
|
// the act is empty and the state is the page's.
|
|
numeric := ruleAction(t, h, fx.setID, url.Values{
|
|
"open": {"new"},
|
|
"rows.new.resource_key": {"fedwiki_sites"},
|
|
})
|
|
if numeric.Code != 200 {
|
|
t.Fatalf("key change = %d, body: %s", numeric.Code, numeric.Body.String())
|
|
}
|
|
body = numeric.Body.String()
|
|
for _, want := range []string{
|
|
`id="entitlement-set-rule-add"`,
|
|
`name="rows.new.resource_value"`, `name="rows.new.resource_per_unit"`, `name="rows.new.tier_reduction_policy"`,
|
|
`>Limit</label>`, `>Per unit</label>`, `>Reduction policy</label>`, `value="clamp" selected`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("a numeric key reveals %q, got:\n%s", want, body)
|
|
}
|
|
}
|
|
if strings.Contains(body, "<table") {
|
|
t.Errorf("a chosen key does not put the add form in a table, got:\n%s", body)
|
|
}
|
|
if !strings.Contains(limitControl(t, body, "new"), "autofocus") {
|
|
t.Errorf("a numeric key moves focus to the Limit, got:\n%s", body)
|
|
}
|
|
|
|
boolean := ruleAction(t, h, fx.setID, url.Values{
|
|
"open": {"new"},
|
|
"rows.new.resource_key": {"discourse_posting"},
|
|
})
|
|
if boolean.Code != 200 {
|
|
t.Fatalf("boolean key change = %d, body: %s", boolean.Code, boolean.Body.String())
|
|
}
|
|
body = boolean.Body.String()
|
|
for _, reject := range []string{`name="rows.new.resource_value"`, `name="rows.new.resource_per_unit"`, `name="rows.new.tier_reduction_policy"`, "<table"} {
|
|
if strings.Contains(body, reject) {
|
|
t.Errorf("a boolean key must not reveal %q, got:\n%s", reject, body)
|
|
}
|
|
}
|
|
if !strings.Contains(body, `id="entitlement-set-rule-add"`) || !strings.Contains(keySelect(t, body), "autofocus") {
|
|
t.Errorf("a boolean key keeps the add form open with focus on the select, got:\n%s", body)
|
|
}
|
|
|
|
// A key that already carries an active rule on the set is not offered,
|
|
// since choosing it could only be refused (finding #21).
|
|
ruled := newRulesFixture(t, database, 5, "clamp", "numeric")
|
|
rec = ruleAction(t, h, ruled.setID, url.Values{"act": {"add"}})
|
|
if rec.Code != 200 {
|
|
t.Fatalf("add on a ruled set = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if body := keySelect(t, rec.Body.String()); strings.Contains(body, `value="fedwiki_sites"`) {
|
|
t.Errorf("a key with an active rule on this set must not be offered, got:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// tableMarkup carves out the Rules table.
|
|
func tableMarkup(t *testing.T, body string) string {
|
|
t.Helper()
|
|
i := strings.Index(body, "<table")
|
|
if i < 0 {
|
|
t.Fatalf("no table in:\n%s", body)
|
|
}
|
|
end := strings.Index(body[i:], "</table>")
|
|
if end < 0 {
|
|
t.Fatalf("unterminated table")
|
|
}
|
|
return body[i : i+end]
|
|
}
|
|
|
|
// keySelect carves out the new row's resource-key select.
|
|
func keySelect(t *testing.T, body string) string {
|
|
t.Helper()
|
|
i := strings.Index(body, `name="rows.new.resource_key"`)
|
|
if i < 0 {
|
|
t.Fatalf("no resource-key select in:\n%s", body)
|
|
}
|
|
end := strings.Index(body[i:], "</select>")
|
|
if end < 0 {
|
|
t.Fatalf("unterminated resource-key select")
|
|
}
|
|
return body[i : i+end]
|
|
}
|
|
|
|
// Stage change tints the row, strikes the old values beside the new, leaves
|
|
// Undo alone in its Actions cell, lists the delta in the tray with the
|
|
// population, and puts focus on the tray's heading. The tray carries no
|
|
// control of its own: the policy rides as a hidden field of the delta.
|
|
func TestEntitlementSetRulesStageChange(t *testing.T) {
|
|
database := testDB(t)
|
|
fx := newRulesFixture(t, database, 5, "clamp", "numeric", "boolean")
|
|
h := newRulesHandler(t, entitlements.New(database), database)
|
|
|
|
rec := ruleAction(t, h, fx.setID, url.Values{
|
|
"act": {"stage"},
|
|
"key": {fx.numericID},
|
|
"open": {fx.numericID},
|
|
"rows." + fx.numericID + ".resource_key_kind": {"numeric"},
|
|
"rows." + fx.numericID + ".resource_value": {"8"},
|
|
"rows." + fx.numericID + ".tier_reduction_policy": {"force_reduce"},
|
|
})
|
|
if rec.Code != 200 {
|
|
t.Fatalf("stage = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range []string{
|
|
`<tr class="table-light">`,
|
|
`<span class="text-decoration-line-through text-muted">5</span> 8`,
|
|
`<span class="text-decoration-line-through text-muted">Clamp</span> Force reduce`,
|
|
">Undo</button>",
|
|
"Nothing has been changed.",
|
|
"Applying to no pools:",
|
|
"FedWiki Sites: limit 5 becomes limit 8. FedWiki Sites: reduction policy clamp becomes force reduce.",
|
|
`name="staged.0.verb" value="edit"`,
|
|
`name="staged.0.key" value="` + fx.numericID + `"`,
|
|
`name="staged.0.resource_value" value="8"`,
|
|
`type="hidden" name="staged.0.tier_reduction_policy" value="force_reduce"`,
|
|
">Apply changes</button>",
|
|
">Discard all</button>",
|
|
// The commit swaps what #operator-body holds, never the container
|
|
// itself: an outerHTML swap would leave the next request no target.
|
|
`hx-target="#operator-body" hx-swap="innerHTML"`,
|
|
">Note <span class=\"text-muted\">(optional)</span></label>",
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("the staged render is missing %q, got:\n%s", want, body)
|
|
}
|
|
}
|
|
for _, reject := range []string{"Kept in this set's History.", "badge", ">Edit</button>"} {
|
|
if strings.Contains(body, reject) {
|
|
t.Errorf("the staged render must not carry %q, got:\n%s", reject, body)
|
|
}
|
|
}
|
|
if tray := trayMarkup(t, body); strings.Contains(tray, "<select") || strings.Contains(tray, ">Reduction policy</label>") {
|
|
t.Errorf("the tray carries no control of its own, got:\n%s", tray)
|
|
}
|
|
if !strings.Contains(body, `tabindex="-1" autofocus`) {
|
|
t.Errorf("Stage change focuses the tray's heading, got:\n%s", body)
|
|
}
|
|
if n := strings.Count(body, "autofocus"); n != 1 {
|
|
t.Errorf("exactly one region takes focus, got %d", n)
|
|
}
|
|
|
|
// Nothing was written: the rule still reads 5.
|
|
rules, err := entitlements.New(database).GetActiveRulesBySetID(context.Background(), fx.setID)
|
|
if err != nil {
|
|
t.Fatalf("list rules: %v", err)
|
|
}
|
|
for _, rule := range rules {
|
|
if rule.RuleID == fx.numericID && rule.ResourceValue.Int64 != 5 {
|
|
t.Errorf("staging wrote the rule: value = %d, want 5", rule.ResourceValue.Int64)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove stages a withdrawal in one press, with no modal: the row is tinted,
|
|
// its resource name struck, and its line reads the ledger's own sentence.
|
|
func TestEntitlementSetRulesRemoveStagesInOnePress(t *testing.T) {
|
|
database := testDB(t)
|
|
fx := newRulesFixture(t, database, 5, "clamp", "numeric", "boolean")
|
|
h := newRulesHandler(t, entitlements.New(database), database)
|
|
|
|
rec := ruleAction(t, h, fx.setID, url.Values{"act": {"remove"}, "key": {fx.numericID}})
|
|
if rec.Code != 200 {
|
|
t.Fatalf("remove = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range []string{
|
|
`<tr class="table-light">`,
|
|
`<span class="text-decoration-line-through text-muted">FedWiki Sites`,
|
|
">Undo</button>",
|
|
"FedWiki Sites: limit 5 is withdrawn from this set.",
|
|
`name="staged.0.verb" value="remove"`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("the staged removal is missing %q, got:\n%s", want, body)
|
|
}
|
|
}
|
|
for _, reject := range []string{"Kept in this set's History.", "data-bs-toggle=\"modal\""} {
|
|
if strings.Contains(body, reject) {
|
|
t.Errorf("a staged removal must not carry %q", reject)
|
|
}
|
|
}
|
|
// The struck row keeps its values as they stand, the policy included.
|
|
if row := rowMarkup(t, body, fx.numericID); !strings.Contains(row, "<td>Clamp</td>") {
|
|
t.Errorf("a staged removal states the rule as it stands, got:\n%s", row)
|
|
}
|
|
if strings.Contains(trayMarkup(t, body), "<select") {
|
|
t.Errorf("the tray carries no control of its own, got:\n%s", body)
|
|
}
|
|
|
|
// A boolean removal's line states the provision.
|
|
rec = ruleAction(t, h, fx.setID, url.Values{"act": {"remove"}, "key": {fx.booleanID}})
|
|
if rec.Code != 200 {
|
|
t.Fatalf("boolean remove = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body = rec.Body.String()
|
|
if !strings.Contains(body, "Forum Posting: no longer provided by this set.") {
|
|
t.Errorf("the boolean removal's line is missing, got:\n%s", body)
|
|
}
|
|
// The delta's policy rides as a hidden input, so the batch survives the
|
|
// request, but no control offers one.
|
|
if strings.Contains(trayMarkup(t, body), "<select") {
|
|
t.Errorf("a boolean delta's line carries no control, got:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, `type="hidden" name="staged.0.tier_reduction_policy"`) {
|
|
t.Errorf("the delta still carries its stored policy, got:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// trayMarkup carves out the tray's box.
|
|
func trayMarkup(t *testing.T, body string) string {
|
|
t.Helper()
|
|
i := strings.Index(body, "app-form-box-wide")
|
|
if i < 0 {
|
|
t.Fatalf("no tray in:\n%s", body)
|
|
}
|
|
end := strings.Index(body[i:], "app-form-commit")
|
|
if end < 0 {
|
|
t.Fatalf("unterminated tray")
|
|
}
|
|
return body[i : i+end]
|
|
}
|
|
|
|
// Undo drops one delta and leaves every other and every open editor alone;
|
|
// Discard all drops the batch and closes every editor.
|
|
func TestEntitlementSetRulesUndoAndDiscard(t *testing.T) {
|
|
database := testDB(t)
|
|
fx := newRulesFixture(t, database, 5, "clamp", "numeric", "boolean")
|
|
h := newRulesHandler(t, entitlements.New(database), database)
|
|
|
|
state := url.Values{
|
|
"staged.0.verb": {"edit"},
|
|
"staged.0.key": {fx.numericID},
|
|
"staged.0.resource_key": {"fedwiki_sites"},
|
|
"staged.0.resource_key_kind": {"numeric"},
|
|
"staged.0.resource_value": {"8"},
|
|
"staged.0.tier_reduction_policy": {"clamp"},
|
|
"staged.1.verb": {"remove"},
|
|
"staged.1.key": {fx.booleanID},
|
|
"staged.1.resource_key": {"discourse_posting"},
|
|
"staged.1.resource_key_kind": {"boolean"},
|
|
"staged.1.tier_reduction_policy": {"clamp"},
|
|
// One editor open beside the batch: Undo must leave it alone.
|
|
"open": {"new"},
|
|
"rows.new.resource_key": {""},
|
|
"note": {"Trimming"},
|
|
}
|
|
undo := url.Values{"act": {"undo"}, "key": {fx.numericID}}
|
|
for k, v := range state {
|
|
undo[k] = v
|
|
}
|
|
rec := ruleAction(t, h, fx.setID, undo)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("undo = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
if strings.Contains(body, "FedWiki Sites: limit 5 becomes limit 8.") {
|
|
t.Errorf("Undo drops that delta, got:\n%s", body)
|
|
}
|
|
for _, want := range []string{
|
|
"Forum Posting: no longer provided by this set.",
|
|
`name="rows.new.resource_key"`,
|
|
`value="Trimming"`,
|
|
">Edit</button>",
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("Undo leaves the rest alone, missing %q, got:\n%s", want, body)
|
|
}
|
|
}
|
|
if !strings.Contains(body, `tabindex="-1" autofocus`) {
|
|
t.Error("Undo focuses the tray's heading while the batch has a line")
|
|
}
|
|
|
|
discard := url.Values{"act": {"discard"}}
|
|
for k, v := range state {
|
|
discard[k] = v
|
|
}
|
|
rec = ruleAction(t, h, fx.setID, discard)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("discard = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body = rec.Body.String()
|
|
for _, reject := range []string{
|
|
"Nothing has been changed.", ">Apply changes</button>",
|
|
`name="rows.new.resource_key"`, `value="Trimming"`,
|
|
} {
|
|
if strings.Contains(body, reject) {
|
|
t.Errorf("Discard all clears the page, still carries %q", reject)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A refused staging answers 422 with the refusal on that row, the other
|
|
// editor still open with its typed values, and the staged delta intact.
|
|
func TestEntitlementSetRulesRefusedStagingKeepsEverything(t *testing.T) {
|
|
database := testDB(t)
|
|
fx := newRulesFixture(t, database, 5, "clamp", "numeric", "boolean")
|
|
h := newRulesHandler(t, entitlements.New(database), database)
|
|
|
|
rec := ruleAction(t, h, fx.setID, url.Values{
|
|
"act": {"stage"},
|
|
"key": {"new"},
|
|
"open": {"new", fx.numericID},
|
|
"rows.new.resource_key": {"fedwiki_sites"},
|
|
"rows.new.resource_key_kind": {"numeric"},
|
|
"rows.new.resource_value": {"2"},
|
|
"staged.0.verb": {"remove"},
|
|
"staged.0.key": {fx.booleanID},
|
|
"staged.0.resource_key": {"discourse_posting"},
|
|
"staged.0.resource_key_kind": {"boolean"},
|
|
"staged.0.tier_reduction_policy": {"clamp"},
|
|
"rows." + fx.numericID + ".resource_key_kind": {"numeric"},
|
|
"rows." + fx.numericID + ".resource_value": {"7"},
|
|
})
|
|
if rec.Code != 422 {
|
|
t.Fatalf("staging a key that already has a rule = %d, want 422, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range []string{
|
|
"This resource key already has an active rule. Edit it instead.",
|
|
`name="rows.new.resource_key"`,
|
|
`name="rows.` + fx.numericID + `.resource_value"`,
|
|
`value="7"`,
|
|
"Forum Posting: no longer provided by this set.",
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("the refusal is missing %q, got:\n%s", want, body)
|
|
}
|
|
}
|
|
// The refusal takes the focus from whatever the act would have named.
|
|
if !strings.Contains(keySelect(t, body), "autofocus") {
|
|
t.Errorf("a refusal focuses the first invalid control, got:\n%s", body)
|
|
}
|
|
if strings.Contains(body, `tabindex="-1" autofocus`) {
|
|
t.Error("a refusal wins the focus from the tray's heading")
|
|
}
|
|
}
|
|
|
|
// A new row for a key that already carries an active rule is refused on the
|
|
// row; so is a staging whose limit, per-unit flag and policy all equal the
|
|
// stored rule's, under the row's Limit; a row whose only change is its
|
|
// policy stages with the policy sentence on its line.
|
|
func TestEntitlementSetRulesDuplicateAndUnchangedRefused(t *testing.T) {
|
|
database := testDB(t)
|
|
fx := newRulesFixture(t, database, 5, "clamp", "numeric", "boolean")
|
|
h := newRulesHandler(t, entitlements.New(database), database)
|
|
|
|
rec := ruleAction(t, h, fx.setID, url.Values{
|
|
"act": {"stage"},
|
|
"key": {"new"},
|
|
"open": {"new"},
|
|
"rows.new.resource_key": {"fedwiki_sites"},
|
|
"rows.new.resource_key_kind": {"numeric"},
|
|
"rows.new.resource_value": {"3"},
|
|
})
|
|
if rec.Code != 422 {
|
|
t.Fatalf("duplicate key = %d, want 422, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if body := rec.Body.String(); !strings.Contains(body, "This resource key already has an active rule. Edit it instead.") {
|
|
t.Errorf("the duplicate-key refusal is missing, got:\n%s", body)
|
|
}
|
|
|
|
rec = ruleAction(t, h, fx.setID, url.Values{
|
|
"act": {"stage"},
|
|
"key": {fx.numericID},
|
|
"open": {fx.numericID},
|
|
"rows." + fx.numericID + ".resource_key_kind": {"numeric"},
|
|
"rows." + fx.numericID + ".resource_value": {"5"},
|
|
"rows." + fx.numericID + ".tier_reduction_policy": {"clamp"},
|
|
})
|
|
if rec.Code != 422 {
|
|
t.Fatalf("an unchanged staging = %d, want 422, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "Nothing changed.") {
|
|
t.Errorf("an unchanged staging is refused under the row's Limit, got:\n%s", body)
|
|
}
|
|
if strings.Contains(body, "Nothing has been changed.") {
|
|
t.Error("an unchanged staging stages nothing")
|
|
}
|
|
if !strings.Contains(limitControl(t, body, fx.numericID), "autofocus") {
|
|
t.Errorf("the refusal focuses the row's Limit, got:\n%s", body)
|
|
}
|
|
|
|
// A policy-only edit is a change: the row stages with its own sentence.
|
|
rec = ruleAction(t, h, fx.setID, url.Values{
|
|
"act": {"stage"},
|
|
"key": {fx.numericID},
|
|
"open": {fx.numericID},
|
|
"rows." + fx.numericID + ".resource_key_kind": {"numeric"},
|
|
"rows." + fx.numericID + ".resource_value": {"5"},
|
|
"rows." + fx.numericID + ".tier_reduction_policy": {"force_reduce"},
|
|
})
|
|
if rec.Code != 200 {
|
|
t.Fatalf("policy change = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body = rec.Body.String()
|
|
if !strings.Contains(body, "FedWiki Sites: reduction policy clamp becomes force reduce.") {
|
|
t.Errorf("a moved policy gives the line its sentence, got:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, `<span class="text-decoration-line-through text-muted">Clamp</span> Force reduce`) {
|
|
t.Errorf("the staged row strikes the old policy beside the new, got:\n%s", body)
|
|
}
|
|
if strings.Contains(body, "unchanged.") {
|
|
t.Error("a delta that moves the policy is not unchanged")
|
|
}
|
|
}
|
|
|
|
// A delta naming a rule that is no longer active is refused on its own line,
|
|
// with the rest of the batch intact.
|
|
func TestEntitlementSetRulesStaleDeltaOnItsLine(t *testing.T) {
|
|
database := testDB(t)
|
|
fx := newRulesFixture(t, database, 5, "clamp", "numeric", "boolean")
|
|
h := newRulesHandler(t, entitlements.New(database), database)
|
|
|
|
rec := ruleAction(t, h, fx.setID, url.Values{
|
|
"staged.0.verb": {"edit"},
|
|
"staged.0.key": {uuid.New().String()},
|
|
"staged.0.resource_key": {"fedwiki_sites"},
|
|
"staged.0.resource_key_kind": {"numeric"},
|
|
"staged.0.resource_value": {"8"},
|
|
"staged.0.tier_reduction_policy": {"clamp"},
|
|
"staged.1.verb": {"remove"},
|
|
"staged.1.key": {fx.booleanID},
|
|
"staged.1.resource_key": {"discourse_posting"},
|
|
"staged.1.resource_key_kind": {"boolean"},
|
|
"staged.1.tier_reduction_policy": {"clamp"},
|
|
})
|
|
if rec.Code != 422 {
|
|
t.Fatalf("a stale delta = %d, want 422, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "That rule is no longer active.") {
|
|
t.Errorf("the stale delta's line is missing its refusal, got:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, "Forum Posting: no longer provided by this set.") {
|
|
t.Errorf("the rest of the batch must stay staged, got:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, ">Apply changes</button>") {
|
|
t.Error("the commit stays in the tray; the operator undoes the stale delta")
|
|
}
|
|
}
|
|
|
|
// Each stored policy decides the options its row's select offers when the
|
|
// row is edited: the two acting values always, and the stored value too when
|
|
// it is one of the dormant two, selected, so nothing is rewritten unless the
|
|
// operator picks. At rest the column states the stored value's label.
|
|
func TestEntitlementSetRulesPolicyOptionsPerStoredValue(t *testing.T) {
|
|
database := testDB(t)
|
|
h := newRulesHandler(t, entitlements.New(database), database)
|
|
|
|
cases := []struct {
|
|
stored string
|
|
label string
|
|
want []string
|
|
reject []string
|
|
}{
|
|
{stored: "clamp", label: "Clamp", want: []string{`value="clamp" selected`, `value="force_reduce"`}, reject: []string{`value="defer"`, `value="block"`}},
|
|
{stored: "force_reduce", label: "Force reduce", want: []string{`value="force_reduce" selected`, `value="clamp"`}, reject: []string{`value="defer"`, `value="block"`}},
|
|
{stored: "defer", label: "Defer", want: []string{`value="defer" selected`, `value="clamp"`, `value="force_reduce"`}, reject: []string{`value="block"`}},
|
|
{stored: "block", label: "Block", want: []string{`value="block" selected`, `value="clamp"`, `value="force_reduce"`}, reject: []string{`value="defer"`}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.stored, func(t *testing.T) {
|
|
fx := newRulesFixture(t, database, 5, tc.stored, "numeric", "boolean")
|
|
rest := ruleAction(t, h, fx.setID, url.Values{})
|
|
if row := rowMarkup(t, rest.Body.String(), fx.numericID); !strings.Contains(row, "<td>"+tc.label+"</td>") {
|
|
t.Errorf("stored %s: the column at rest reads %q, got:\n%s", tc.stored, tc.label, row)
|
|
}
|
|
rec := ruleAction(t, h, fx.setID, url.Values{"act": {"edit"}, "key": {fx.numericID}})
|
|
if rec.Code != 200 {
|
|
t.Fatalf("edit = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := policySelect(t, rec.Body.String(), fx.numericID)
|
|
for _, want := range tc.want {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("stored %s: the select is missing %q, got:\n%s", tc.stored, want, body)
|
|
}
|
|
}
|
|
for _, reject := range tc.reject {
|
|
if strings.Contains(body, reject) {
|
|
t.Errorf("stored %s: the select must not offer %q, got:\n%s", tc.stored, reject, body)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// A new rule's select opens on Clamp, the value the console writes, and
|
|
// a value no row offered falls back to it when the row is staged.
|
|
fx := newRulesFixture(t, database, 5, "clamp", "boolean")
|
|
rec := ruleAction(t, h, fx.setID, url.Values{
|
|
"open": {"new"},
|
|
"rows.new.resource_key": {"fedwiki_sites"},
|
|
})
|
|
if rec.Code != 200 {
|
|
t.Fatalf("key change = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := policySelect(t, rec.Body.String(), "new")
|
|
if !strings.Contains(body, `value="clamp" selected`) {
|
|
t.Errorf("a new rule's select opens on Clamp, got:\n%s", body)
|
|
}
|
|
for _, reject := range []string{`value="defer"`, `value="block"`} {
|
|
if strings.Contains(body, reject) {
|
|
t.Errorf("a new rule is not offered %q", reject)
|
|
}
|
|
}
|
|
rec = ruleAction(t, h, fx.setID, url.Values{
|
|
"act": {"stage"},
|
|
"key": {"new"},
|
|
"open": {"new"},
|
|
"rows.new.resource_key": {"fedwiki_sites"},
|
|
"rows.new.resource_key_kind": {"numeric"},
|
|
"rows.new.resource_value": {"3"},
|
|
"rows.new.tier_reduction_policy": {"defer"},
|
|
})
|
|
if rec.Code != 200 {
|
|
t.Fatalf("stage a new rule = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `type="hidden" name="staged.0.tier_reduction_policy" value="clamp"`) {
|
|
t.Errorf("a value the row never offered falls back to Clamp, got:\n%s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// policySelect carves out one row's reduction-policy select.
|
|
func policySelect(t *testing.T, body, instance string) string {
|
|
t.Helper()
|
|
i := strings.Index(body, `name="rows.`+instance+`.tier_reduction_policy"`)
|
|
if i < 0 {
|
|
t.Fatalf("no policy select for %s in:\n%s", instance, body)
|
|
}
|
|
end := strings.Index(body[i:], "</select>")
|
|
if end < 0 {
|
|
t.Fatalf("unterminated policy select")
|
|
}
|
|
return body[i : i+end]
|
|
}
|
|
|
|
// The tray's own lines: the population once with its singulars, the
|
|
// above-cap statement, and the rule type that reaches no pool. Rendered
|
|
// straight, because a population above the cap is not a fixture.
|
|
func TestEntitlementSetRulesTrayLines(t *testing.T) {
|
|
h := newRulesHandler(t, nil, nil)
|
|
cases := []struct {
|
|
name string
|
|
view server.EntitlementSetRuleTrayView
|
|
want []string
|
|
reject []string
|
|
}{
|
|
{
|
|
name: "no pool",
|
|
view: server.EntitlementSetRuleTrayView{},
|
|
want: []string{"Nothing has been changed.", "Applying to no pools:"},
|
|
},
|
|
{
|
|
name: "one pool in one organization",
|
|
view: server.EntitlementSetRuleTrayView{PoolCount: 1, OrgCount: 1, Pools: "1", Orgs: "1"},
|
|
want: []string{"Applying to 1 pool in 1 organization:"},
|
|
},
|
|
{
|
|
name: "many",
|
|
view: server.EntitlementSetRuleTrayView{PoolCount: 13, OrgCount: 12, Pools: "13", Orgs: "12"},
|
|
want: []string{"Applying to 13 pools in 12 organizations:"},
|
|
},
|
|
{
|
|
name: "above the cap",
|
|
view: server.EntitlementSetRuleTrayView{PoolCount: 400, OrgCount: 300, Pools: "400", Orgs: "300", AboveCap: true},
|
|
want: []string{"Recomputation continues after the change is applied.", "Applying to 400 pools in 300 organizations:"},
|
|
},
|
|
{
|
|
name: "reaches no pool",
|
|
view: server.EntitlementSetRuleTrayView{ReachesNoPool: true},
|
|
want: []string{"This rule type reaches no pool."},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
h.Templates.Render(rec, "operator_entitlement_set_rules_tray.html", tc.view)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("render tray = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range tc.want {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("the tray is missing %q, got:\n%s", want, body)
|
|
}
|
|
}
|
|
for _, reject := range append(tc.reject, "Kept in this set's History.", "badge") {
|
|
if strings.Contains(body, reject) {
|
|
t.Errorf("the tray must not carry %q, got:\n%s", reject, body)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// One delta's line reads the ledger's narrative, and no more of it.
|
|
func TestEntitlementSetRuleLineNarrative(t *testing.T) {
|
|
h := newRulesHandler(t, nil, nil)
|
|
cases := []struct {
|
|
name string
|
|
view server.EntitlementSetRuleLineView
|
|
want string
|
|
}{
|
|
{"add numeric", server.EntitlementSetRuleLineView{Verb: "add", Label: "Seats", LimitAfter: "5"}, "Seats: no rule becomes limit 5."},
|
|
{"add per unit", server.EntitlementSetRuleLineView{Verb: "add", Label: "Seats", LimitAfter: "5", PerUnit: true}, "Seats: no rule becomes limit 5 for each unit."},
|
|
{"add boolean", server.EntitlementSetRuleLineView{Verb: "add", Label: "Posting", Boolean: true}, "Posting: provided by this set."},
|
|
{"edit limit", server.EntitlementSetRuleLineView{Verb: "edit", Label: "Seats", LimitBefore: "5", LimitAfter: "8", LimitChanged: true}, "Seats: limit 5 becomes limit 8."},
|
|
{"edit per unit", server.EntitlementSetRuleLineView{Verb: "edit", Label: "Seats", PerUnitChanged: true, PerUnit: true}, "Seats: per unit no becomes yes."},
|
|
{"edit policy", server.EntitlementSetRuleLineView{Verb: "edit", Label: "Seats", PolicyBefore: "clamp", PolicyAfter: "force reduce", PolicyChanged: true}, "Seats: reduction policy clamp becomes force reduce."},
|
|
{"edit unchanged", server.EntitlementSetRuleLineView{Verb: "edit", Label: "Seats", Unchanged: true}, "Seats: unchanged."},
|
|
{"remove numeric", server.EntitlementSetRuleLineView{Verb: "remove", Label: "Seats", LimitBefore: "5"}, "Seats: limit 5 is withdrawn from this set."},
|
|
{"remove boolean", server.EntitlementSetRuleLineView{Verb: "remove", Label: "Posting", Boolean: true}, "Posting: no longer provided by this set."},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
h.Templates.Render(rec, "entitlementSetRuleLine", tc.view)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("render line = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := strings.TrimSpace(rec.Body.String())
|
|
if !strings.Contains(body, tc.want) {
|
|
t.Errorf("the line reads %q, want %q", body, tc.want)
|
|
}
|
|
if strings.Contains(body, "Kept in this set's History.") {
|
|
t.Error("the tray does not repeat what History keeps")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
|
|
// The handler writes rules through its own transaction, so the fixtures
|
|
// are committed rather than held open in one this test rolls back.
|
|
eq := entitlements.New(database)
|
|
h := newRulesHandler(t, eq, database)
|
|
|
|
// 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 " + uuid.New().String()[:8],
|
|
IsActive: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create set: %v", err)
|
|
}
|
|
|
|
postRule := func(form url.Values) *httptest.ResponseRecorder {
|
|
return commitRuleChange(t, h, set.SetID, form)
|
|
}
|
|
|
|
// 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 " + uuid.New().String()[:8],
|
|
IsActive: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create set2: %v", err)
|
|
}
|
|
rec = commitRuleChange(t, h, set2.SetID, url.Values{"resource_key": {"fedwiki_sites"}})
|
|
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.
|
|
rec = commitRuleChange(t, h, set2.SetID, url.Values{"resource_key": {"nope_missing"}})
|
|
if rec.Code != 422 {
|
|
t.Errorf("unknown key create = %d, want 422", rec.Code)
|
|
}
|
|
}
|
|
|
|
// A ticked Per unit box stores resource_per_unit = true (design D1): the
|
|
// defect was forms.Values.SetBool filling only the raw half of a bound
|
|
// value set while the rule handler read the typed half, which only Parse
|
|
// filled, so every rule was stored with resource_per_unit = false regardless
|
|
// of what the operator ticked.
|
|
func TestCreateEntitlementSetRuleResourcePerUnitStored(t *testing.T) {
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
eq := entitlements.New(database)
|
|
h := newRulesHandler(t, eq, database)
|
|
|
|
postRule := func(setID string, form url.Values) *httptest.ResponseRecorder {
|
|
return commitRuleChange(t, h, setID, form)
|
|
}
|
|
|
|
tickedSet, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
|
|
Name: "Per Unit Ticked Test Set " + uuid.New().String()[:8],
|
|
IsActive: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create ticked set: %v", err)
|
|
}
|
|
rec := postRule(tickedSet.SetID, url.Values{
|
|
"resource_key": {"fedwiki_sites"},
|
|
"resource_value": {"5"},
|
|
"stacking_policy": {"additive"},
|
|
"resource_per_unit": {"true"},
|
|
})
|
|
if rec.Code != 200 {
|
|
t.Fatalf("ticked create = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
tickedRules, err := eq.GetActiveRulesBySetID(ctx, tickedSet.SetID)
|
|
if err != nil {
|
|
t.Fatalf("list ticked rules: %v", err)
|
|
}
|
|
if len(tickedRules) != 1 {
|
|
t.Fatalf("ticked rules = %d, want 1", len(tickedRules))
|
|
}
|
|
if !tickedRules[0].ResourcePerUnit.Valid || !tickedRules[0].ResourcePerUnit.Bool {
|
|
t.Errorf("ticked Per unit stored ResourcePerUnit = %+v, want {Bool: true, Valid: true}", tickedRules[0].ResourcePerUnit)
|
|
}
|
|
|
|
untickedSet, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
|
|
Name: "Per Unit Unticked Test Set " + uuid.New().String()[:8],
|
|
IsActive: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create unticked set: %v", err)
|
|
}
|
|
rec = postRule(untickedSet.SetID, url.Values{
|
|
"resource_key": {"fedwiki_sites"},
|
|
"resource_value": {"5"},
|
|
"stacking_policy": {"additive"},
|
|
})
|
|
if rec.Code != 200 {
|
|
t.Fatalf("unticked create = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
untickedRules, err := eq.GetActiveRulesBySetID(ctx, untickedSet.SetID)
|
|
if err != nil {
|
|
t.Fatalf("list unticked rules: %v", err)
|
|
}
|
|
if len(untickedRules) != 1 {
|
|
t.Fatalf("unticked rules = %d, want 1", len(untickedRules))
|
|
}
|
|
if !untickedRules[0].ResourcePerUnit.Valid || untickedRules[0].ResourcePerUnit.Bool {
|
|
t.Errorf("unticked Per unit stored ResourcePerUnit = %+v, want {Bool: false, Valid: true}", untickedRules[0].ResourcePerUnit)
|
|
}
|
|
}
|
|
|
|
// The per-unit checkbox carries its submitted state back on a refusal, so a
|
|
// restaging writes the rule the operator intended and not its opposite
|
|
// (finding FA-23).
|
|
func TestEntitlementSetRulesPerUnitCarriesBackOnRefusal(t *testing.T) {
|
|
database := testDB(t)
|
|
fx := newRulesFixture(t, database, 5, "clamp", "boolean")
|
|
h := newRulesHandler(t, entitlements.New(database), database)
|
|
|
|
rec := ruleAction(t, h, fx.setID, url.Values{
|
|
"act": {"stage"},
|
|
"key": {"new"},
|
|
"open": {"new"},
|
|
"rows.new.resource_key": {"fedwiki_sites"},
|
|
"rows.new.resource_key_kind": {"numeric"},
|
|
"rows.new.resource_value": {""},
|
|
})
|
|
if rec.Code != 422 {
|
|
t.Fatalf("staging with no limit = %d, want 422", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "Enter a limit.") {
|
|
t.Errorf("a numeric row staged with no limit is refused on its Limit, got:\n%s", body)
|
|
}
|
|
i := strings.Index(body, `name="rows.new.resource_per_unit"`)
|
|
if i < 0 {
|
|
t.Fatalf("the Per unit checkbox is missing from the refused row:\n%s", body)
|
|
}
|
|
if strings.Contains(body[i:i+strings.Index(body[i:], ">")], "checked") {
|
|
t.Errorf("an unticked box renders unticked, got:\n%s", body[i:i+120])
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
|
|
eq := entitlements.New(database)
|
|
h := newRulesHandler(t, eq, database)
|
|
|
|
postRule := func(setID string, form url.Values) *httptest.ResponseRecorder {
|
|
return commitRuleChange(t, h, setID, form)
|
|
}
|
|
|
|
set1, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
|
|
Name: "Additive Default Test Set " + uuid.New().String()[:8],
|
|
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)
|
|
}
|
|
|
|
set2, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
|
|
Name: "Smuggled Stacking Test Set " + uuid.New().String()[:8],
|
|
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())
|
|
}
|
|
if body := rec.Body.String(); !strings.Contains(body, "Stacking policy must be additive.") {
|
|
t.Errorf("expected the additive-only refusal, got:\n%s", body)
|
|
}
|
|
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 refused smuggled stacking policy must write nothing)", len(rules2))
|
|
}
|
|
}
|
|
|
|
// TestCreateEntitlementSet_CreateThenLand exercises CreateEntitlementSet end
|
|
// to end against a real DB: design D20's create-then-land convention. An
|
|
// htmx submission (HX-Request: true) answers 200 with HX-Redirect to the new
|
|
// set's own page carrying ?flash=created and an empty body; a non-htmx
|
|
// submission (a native form fallback, no HX-Request header) answers 303
|
|
// straight to the same URL. The set is created active regardless (design
|
|
// D21: the create form has no Active control).
|
|
func TestCreateEntitlementSet_CreateThenLand(t *testing.T) {
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
tx, err := entitlements.BeginRuleChange(ctx, database)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer tx.Rollback()
|
|
eq := entitlements.New(tx)
|
|
h := newRulesHandler(t, eq, nil)
|
|
|
|
post := func(name string, htmx bool) *httptest.ResponseRecorder {
|
|
form := url.Values{"name": {name}}
|
|
req := httptest.NewRequest("POST", "/partials/operator/entitlement-sets", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
if htmx {
|
|
req.Header.Set("HX-Request", "true")
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.CreateEntitlementSet(rec, req)
|
|
return rec
|
|
}
|
|
|
|
t.Run("htmx submit gets HX-Redirect with an empty body", func(t *testing.T) {
|
|
rec := post("Create Then Land Set Htmx "+uuid.New().String()[:8], true)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("code = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
loc := rec.Header().Get("HX-Redirect")
|
|
if !strings.HasPrefix(loc, "/operator/entitlement-sets/") || !strings.HasSuffix(loc, "?flash=created") {
|
|
t.Errorf("HX-Redirect = %q, want /operator/entitlement-sets/{id}?flash=created", loc)
|
|
}
|
|
if rec.Body.Len() != 0 {
|
|
t.Errorf("body = %q, want empty", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("non-htmx submit gets a 303 to the same URL", func(t *testing.T) {
|
|
rec := post("Create Then Land Set Native "+uuid.New().String()[:8], false)
|
|
if rec.Code != 303 {
|
|
t.Fatalf("code = %d, want 303, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
loc := rec.Header().Get("Location")
|
|
if !strings.HasPrefix(loc, "/operator/entitlement-sets/") || !strings.HasSuffix(loc, "?flash=created") {
|
|
t.Errorf("Location = %q, want /operator/entitlement-sets/{id}?flash=created", loc)
|
|
}
|
|
})
|
|
|
|
t.Run("the created set is active", func(t *testing.T) {
|
|
rec := post("Create Then Land Set Active Check "+uuid.New().String()[:8], true)
|
|
loc := rec.Header().Get("HX-Redirect")
|
|
id := strings.TrimSuffix(strings.TrimPrefix(loc, "/operator/entitlement-sets/"), "?flash=created")
|
|
set, err := eq.GetEntitlementSetByID(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("get entitlement set: %v", err)
|
|
}
|
|
if !set.IsActive {
|
|
t.Error("a newly created set must be active")
|
|
}
|
|
})
|
|
}
|