// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package server import ( "bytes" "errors" "html/template" "io/fs" "math" "net/http/httptest" "strings" "testing" "git.coopcloud.tech/wiki-cafe/member-console/internal/config" "git.coopcloud.tech/wiki-cafe/member-console/internal/embeds" "git.coopcloud.tech/wiki-cafe/member-console/internal/web" ) func TestParseListParamsClamping(t *testing.T) { cases := []struct { url string wantQ string wantF string wantPage int }{ {"/operator/grants", "", "", 1}, {"/operator/grants?q=acme&state=live&page=3", "acme", "live", 3}, {"/operator/grants?page=abc", "", "", 1}, {"/operator/grants?page=-4", "", "", 1}, {"/operator/grants?page=0", "", "", 1}, } for _, tc := range cases { r := httptest.NewRequest("GET", tc.url, nil) p := ParseListParams(r, "state") if p.Q != tc.wantQ || p.Facet != tc.wantF || p.Page != tc.wantPage { t.Errorf("%s: got %+v, want q=%q facet=%q page=%d", tc.url, p, tc.wantQ, tc.wantF, tc.wantPage) } } // A list with no facet parameter never reads one. r := httptest.NewRequest("GET", "/operator/persons?state=live", nil) if p := ParseListParams(r, ""); p.Facet != "" { t.Errorf("facet must stay empty when the list declares no facet param, got %q", p.Facet) } } // A page number large enough to overflow the int32 offset used to wrap // negative, and Postgres refuses a negative OFFSET with an error the reader // saw as a 500 (2026-09 audit candidate). The offset is capped instead, and // the capped page is past any real list, which FetchPage already clamps. func TestOffsetNeverOverflows(t *testing.T) { for _, page := range []int{1 << 31, 1 << 40, int(^uint(0) >> 1)} { p := ListParams{Page: page} if off := p.Offset(); off < 0 || off != math.MaxInt32 { t.Errorf("page %d: offset = %d, want the int32 cap", page, off) } } if off := (ListParams{Page: 3}).Offset(); off != 100 { t.Errorf("page 3 at the default size: offset = %d, want 100", off) } if off := (ListParams{Page: 0}).Offset(); off != 0 { t.Errorf("page 0: offset = %d, want 0", off) } } func TestFetchPageClampsPastTheEnd(t *testing.T) { calls := []int32{} load := func(limit, offset int32) ([]string, int64, error) { calls = append(calls, offset) if offset >= 60 { return nil, 0, nil // past the end: no rows, and no total knowledge } return []string{"row"}, 60, nil } p := &ListParams{Page: 9} rows, total, err := FetchPage(p, load) if err != nil || len(rows) != 1 || total != 60 { t.Fatalf("clamped fetch: rows=%d total=%d err=%v", len(rows), total, err) } if p.Page != 1 { t.Errorf("page must clamp to 1 after an out-of-range fetch, got %d", p.Page) } if len(calls) != 2 || calls[1] != 0 { t.Errorf("expected a re-run at offset 0, calls=%v", calls) } wantErr := errors.New("boom") _, _, err = FetchPage(&ListParams{Page: 1}, func(_, _ int32) ([]string, int64, error) { return nil, 0, wantErr }) if !errors.Is(err, wantErr) { t.Errorf("load errors must surface, got %v", err) } } func TestListNavMathAndURLs(t *testing.T) { n := ListNav{BasePath: "/operator/grants", FacetParam: "state", Q: "a b", Facet: "live", Page: 2, Total: 120} if n.Pages() != 3 || n.From() != 51 || n.To() != 100 { t.Errorf("math: pages=%d from=%d to=%d", n.Pages(), n.From(), n.To()) } if got := n.Showing(); got != "Showing 51–100 of 120" { t.Errorf("Showing() = %q", got) } if !n.HasPrev() || !n.HasNext() { t.Error("page 2 of 3 must have both prev and next") } // URLs preserve the rest of the state and escape the term. if got := n.NextURL(); got != "/operator/grants?page=3&q=a+b&state=live" { t.Errorf("NextURL = %q", got) } if got := n.FacetURL("inactive"); got != "/operator/grants?q=a+b&state=inactive" { t.Errorf("FacetURL = %q", got) } if got := n.FacetURL(""); got != "/operator/grants?q=a+b" { t.Errorf("FacetURL clear = %q", got) } if got := n.ClearSearchURL(); got != "/operator/grants?state=live" { t.Errorf("ClearSearchURL = %q", got) } empty := ListNav{BasePath: "/operator/persons", Total: 0, Page: 1} if empty.Pages() != 1 || empty.From() != 0 || empty.To() != 0 || empty.Showing() != "" { t.Errorf("empty list math: pages=%d from=%d to=%d showing=%q", empty.Pages(), empty.From(), empty.To(), empty.Showing()) } if empty.Filtered() { t.Error("empty unfiltered list must not report Filtered") } exact := ListNav{BasePath: "/x", Page: 2, Total: 100} if exact.Pages() != 2 || exact.To() != 100 || exact.HasNext() { t.Errorf("exact-multiple math: pages=%d to=%d hasNext=%v", exact.Pages(), exact.To(), exact.HasNext()) } } func TestValidFacet(t *testing.T) { opts := []FacetOption{{Value: "live", Label: "Live"}, {Value: "inactive", Label: "Inactive"}} if got := ValidFacet("live", opts); got != "live" { t.Errorf("valid value rejected: %q", got) } if got := ValidFacet("bogus", opts); got != "" { t.Errorf("unknown facet value must be ignored, got %q", got) } } // listControlsTemplates builds the operator partial set the same way the // smoke test does, so the shared list-controls defines render exactly as // they will in handlers. func listControlsTemplates(t *testing.T) *template.Template { t.Helper() partialsSub, err := fs.Sub(embeds.Templates, "templates/partials") if err != nil { t.Fatalf("fs.Sub partials: %v", err) } tmpl := template.New("t").Funcs(template.FuncMap{ "renderBody": func(string, any) (template.HTML, error) { return "", nil }, "routeURL": web.RouteURL, "fieldErr": func(_, _, _, _ string, _, _ any) string { return "" }, "stripeEntityURL": func(string, string) string { return "" }, "deploymentName": config.DeploymentName, "helpIcon": helpIcon, }) tmpl, err = web.ParseUIPartials(template.Must(tmpl.ParseFS(partialsSub, "operator_*.html"))) if err != nil { t.Fatalf("ParseFS partials: %v", err) } return tmpl } func renderListDefine(t *testing.T, name string, nav ListNav) string { t.Helper() var buf bytes.Buffer if err := listControlsTemplates(t).ExecuteTemplate(&buf, name, nav); err != nil { t.Fatalf("ExecuteTemplate %s: %v", name, err) } return buf.String() } func TestListControlsRender(t *testing.T) { nav := ListNav{ BasePath: "/operator/grants", SearchPlaceholder: "Search organization or product", FacetParam: "state", FacetOptions: []FacetOption{{Value: "live", Label: "Live"}, {Value: "inactive", Label: "Inactive"}}, Q: "acme", Facet: "live", Page: 2, Total: 120, } out := renderListDefine(t, "listControls", nav) for _, want := range []string{ `action="/operator/grants"`, `name="q" value="acme"`, `name="state" value="live"`, // hidden input keeps the facet through a search `>Clear`, `>All`, // A governed list's search is a bar: one input group with the // magnifier, the control, the outline button and, with a query // active, Clear in the same group (design D20). Round 3 rendered it // stacked, so the button wrapped onto a line of its own on every // list page (maintainer, 2026-09-04). `class="app-form app-form-bar"`, `