Merge pull request 'fix(admin): keep the filter query intact in console pager and export links' (#49) from feature/admin-pager-filter-encoding into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 16s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 57s

This commit was merged in pull request #49.
This commit is contained in:
2026-06-12 09:41:37 +00:00
3 changed files with 63 additions and 6 deletions
@@ -2,6 +2,7 @@ package adminconsole
import ( import (
"bytes" "bytes"
"html/template"
"io/fs" "io/fs"
"strings" "strings"
"testing" "testing"
@@ -55,6 +56,56 @@ func TestRendererRendersEveryPage(t *testing.T) {
} }
} }
// TestPagerLinksPreserveFilterQuery guards the paginated lists whose links carry a
// pre-encoded filter query (url.Values.Encode) past the page number. The query fragment
// must reach the link verbatim: the contextual escaper would otherwise re-encode its
// structural "=" and "&" (turning "kind=robots" into the broken "kind%3drobots"), dropping
// the active filter on every page step. FilterQuery is typed template.URL to prevent that.
func TestPagerLinksPreserveFilterQuery(t *testing.T) {
r := MustNewRenderer()
t.Run("users", func(t *testing.T) {
var buf bytes.Buffer
view := UsersView{Robots: true, FilterQuery: template.URL("kind=robots"), Pager: NewPager(2, 50, 200)}
if err := r.Render(&buf, "users", PageData{Title: "Users", Data: view}); err != nil {
t.Fatalf("render users: %v", err)
}
out := buf.String()
for _, want := range []string{
`href="/_gm/users?kind=robots&page=1"`,
`href="/_gm/users?kind=robots&page=3"`,
} {
if !strings.Contains(out, want) {
t.Errorf("users pager: missing %q in:\n%s", want, out)
}
}
if strings.Contains(out, "kind%3drobots") {
t.Error("users pager: filter query was double-encoded (kind%3drobots)")
}
})
t.Run("messages", func(t *testing.T) {
var buf bytes.Buffer
view := MessagesView{FilterQuery: template.URL("game=abc&user=def"), Pager: NewPager(2, 50, 200)}
if err := r.Render(&buf, "messages", PageData{Title: "Messages", Data: view}); err != nil {
t.Fatalf("render messages: %v", err)
}
out := buf.String()
for _, want := range []string{
`href="/_gm/messages.csv?game=abc&user=def"`,
`href="/_gm/messages?game=abc&user=def&page=1"`,
`href="/_gm/messages?game=abc&user=def&page=3"`,
} {
if !strings.Contains(out, want) {
t.Errorf("messages pager: missing %q in:\n%s", want, out)
}
}
if strings.Contains(out, "%3d") || strings.Contains(out, "%26") {
t.Error("messages pager: filter query was double-encoded (%3d / %26)")
}
})
}
// TestRendererUnknownPage reports an error for a page that does not exist. // TestRendererUnknownPage reports an error for a page that does not exist.
func TestRendererUnknownPage(t *testing.T) { func TestRendererUnknownPage(t *testing.T) {
r := MustNewRenderer() r := MustNewRenderer()
+9 -4
View File
@@ -51,11 +51,14 @@ type UsersView struct {
Items []UserRow Items []UserRow
Pager Pager Pager Pager
// Robots is the active people/robots toggle; NameMask/ExternalIDMask are the current // Robots is the active people/robots toggle; NameMask/ExternalIDMask are the current
// glob filters; FilterQuery is those encoded for pager/toggle links. // glob filters; FilterQuery is those URL-encoded for the pager links. It is an
// already-escaped query fragment (url.Values.Encode), so it is typed template.URL to
// be emitted verbatim — interpolated as a plain string it would have its "=" and "&"
// percent-encoded again by the contextual escaper.
Robots bool Robots bool
NameMask string NameMask string
ExternalIDMask string ExternalIDMask string
FilterQuery string FilterQuery template.URL
} }
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's // UserRow is one account row in the list. MoveMin/Avg/Max are the account's
@@ -77,7 +80,9 @@ type UserRow struct {
// MessagesView is the paginated chat-message moderation list. NameMask/ExtMask are the // MessagesView is the paginated chat-message moderation list. NameMask/ExtMask are the
// current sender glob filters; GameID/UserID pin the list to one game / sender (set from a // current sender glob filters; GameID/UserID pin the list to one game / sender (set from a
// game or user card); FilterQuery is the active filters encoded for the pager links. // game or user card); FilterQuery is the active filters URL-encoded for the pager and CSV
// links — an already-escaped query fragment, hence template.URL so it is not re-encoded
// inside the link (see UsersView.FilterQuery).
type MessagesView struct { type MessagesView struct {
Items []MessageRow Items []MessageRow
Pager Pager Pager Pager
@@ -85,7 +90,7 @@ type MessagesView struct {
ExtMask string ExtMask string
GameID string GameID string
UserID string UserID string
FilterQuery string FilterQuery template.URL
} }
// MessageRow is one chat message in the moderation list: its sender (linked to the user // MessageRow is one chat message in the moderation list: its sender (linked to the user
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"encoding/csv" "encoding/csv"
"fmt" "fmt"
"html/template"
"net/http" "net/http"
"net/url" "net/url"
"path/filepath" "path/filepath"
@@ -108,7 +109,7 @@ func (s *Server) consoleUsers(c *gin.Context) {
view := adminconsole.UsersView{ view := adminconsole.UsersView{
Pager: adminconsole.NewPager(page, adminPageSize, total), Pager: adminconsole.NewPager(page, adminPageSize, total),
Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask, Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
FilterQuery: q.Encode(), FilterQuery: template.URL(q.Encode()),
} }
ids := make([]uuid.UUID, 0, len(items)) ids := make([]uuid.UUID, 0, len(items))
for _, it := range items { for _, it := range items {
@@ -174,7 +175,7 @@ func (s *Server) consoleMessages(c *gin.Context) {
Pager: adminconsole.NewPager(page, adminPageSize, total), Pager: adminconsole.NewPager(page, adminPageSize, total),
NameMask: filter.NameMask, NameMask: filter.NameMask,
ExtMask: filter.ExtMask, ExtMask: filter.ExtMask,
FilterQuery: q.Encode(), FilterQuery: template.URL(q.Encode()),
} }
if filter.GameID != uuid.Nil { if filter.GameID != uuid.Nil {
view.GameID = filter.GameID.String() view.GameID = filter.GameID.String()