Files
member-console/internal/server/list_scale_test.go
T
cgalo5758 0b28a9dc29 Remediate security audit findings
- Replace gorilla/csrf with net/http CrossOriginProtection
- Require valkey-password and add TLS options for session store
- End session at /logout and revoke refresh tokens
- Re-derive identity and roles from provider every five minutes
- Process each Stripe webhook event in its own Temporal workflow
- Give each outbox entry its own workflow with Temporal retries
- Guard against stale Stripe events with provider timestamps
- Derive transport security from base-url scheme
2026-09-09 13:25:43 -05:00

246 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 51100 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</a>`,
`>All</a>`,
// 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"`,
`<div class="input-group">`,
`<span class="input-group-text"><svg`,
`class="btn btn-outline-secondary"><span class="spinner-border`,
} {
if !strings.Contains(out, want) {
t.Errorf("listControls missing %q in:\n%s", want, out)
}
}
for _, banned := range []string{"btn-primary", "app-form-box", "app-form-fields", "(optional)"} {
if strings.Contains(out, banned) {
t.Errorf("listControls rendered %q, which a search bar never carries:\n%s", banned, out)
}
}
// Clear rides in the same group as the commit, not on a row of its own.
if group := out[strings.Index(out, `<div class="input-group">`):]; !strings.Contains(group[:strings.Index(group, "</form>")], ">Clear</a>") {
t.Errorf("Clear must sit inside the input group beside the commit:\n%s", out)
}
if !strings.Contains(out, `active" href="/operator/grants?q=acme">All`) &&
!strings.Contains(out, `small active" href="/operator/grants?q=acme&amp;state=live">Live</a>`) {
t.Errorf("active facet pill not marked in:\n%s", out)
}
pager := renderListDefine(t, "listPager", nav)
for _, want := range []string{
"Showing 51100 of 120",
"Page 2 of 3",
">Previous</a>",
">Next</a>",
} {
if !strings.Contains(pager, want) {
t.Errorf("listPager missing %q in:\n%s", want, pager)
}
}
if pagerEmpty := renderListDefine(t, "listPager", ListNav{BasePath: "/x", Page: 1, Total: 0}); strings.Contains(pagerEmpty, "Showing") {
t.Errorf("empty list must render no Showing line, got:\n%s", pagerEmpty)
}
noMatch := renderListDefine(t, "listNoMatch", nav)
if !strings.Contains(noMatch, "No rows match") || !strings.Contains(noMatch, "acme") {
t.Errorf("listNoMatch must name the term, got:\n%s", noMatch)
}
if !strings.Contains(noMatch, `href="/operator/grants"`) {
t.Errorf("listNoMatch must link the bare list, got:\n%s", noMatch)
}
}