Files
Felis/internal/api/handlers_access_test.go
T

600 lines
25 KiB
Go

package api
import (
"encoding/json"
"net/http"
"testing"
)
// mkAccess builds an API whose "survival" server is owned by owner1 and Ready,
// with a fresh fakeConsole wired — the shared fixture for the §7 access tests.
func mkAccess(t *testing.T) (*API, *fakeRepo, *fakeCluster, *fakeConsole) {
t.Helper()
repo := newFakeRepo()
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
cl := newFakeCluster()
cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: "Running", Ready: true}
console := &fakeConsole{reply: "ok"}
api := newTestAPI(repo, cl)
api.Console = console
return api, repo, cl, console
}
var accessOwner = &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
// TestAccessTranslation pins the structured-field → RCON-command translation for
// every §7 action. The exact gotCommand is the contract LuckPerms / vanilla will
// receive, so each variant that hides a bug is checked: a permission grant with
// an OMITTED value must become "... true" (not the *bool zero "... false", which
// would be a silent DENY), an explicit false must stay false, and an optional
// world must append " world=<w>" only when present.
func TestAccessTranslation(t *testing.T) {
cases := []struct {
name string
path string
body string
wantCmd string
wantLbl string // audit action label
}{
{"whitelist add", "/api/v1/servers/survival/access/whitelist",
`{"action":"add","player":"Steve"}`, "whitelist add Steve", "access.whitelist.add"},
{"whitelist remove", "/api/v1/servers/survival/access/whitelist",
`{"action":"remove","player":"Steve"}`, "whitelist remove Steve", "access.whitelist.remove"},
{"ban", "/api/v1/servers/survival/access/ban",
`{"action":"ban","player":"Griefer_99"}`, "ban Griefer_99", "access.ban.ban"},
{"pardon", "/api/v1/servers/survival/access/ban",
`{"action":"pardon","player":"Griefer_99"}`, "pardon Griefer_99", "access.ban.pardon"},
// kick has no action field — a single verb — so its label is "access.kick".
{"kick", "/api/v1/servers/survival/access/kick",
`{"player":"Griefer_99"}`, "kick Griefer_99", "access.kick"},
// The *bool trap: omitted value defaults to true (grant), NOT false (deny).
{"permission set default grant", "/api/v1/servers/survival/access/permission",
`{"action":"set","player":"Steve","node":"essentials.fly"}`,
"lp user Steve permission set essentials.fly true", "access.permission.set"},
{"permission set explicit false", "/api/v1/servers/survival/access/permission",
`{"action":"set","player":"Steve","node":"essentials.fly","value":false}`,
"lp user Steve permission set essentials.fly false", "access.permission.set"},
{"permission set with world", "/api/v1/servers/survival/access/permission",
`{"action":"set","player":"Steve","node":"essentials.fly","world":"nether","value":true}`,
"lp user Steve permission set essentials.fly true world=nether", "access.permission.set"},
{"permission unset", "/api/v1/servers/survival/access/permission",
`{"action":"unset","player":"Steve","node":"worldedit.*"}`,
"lp user Steve permission unset worldedit.*", "access.permission.unset"},
{"permission unset with world", "/api/v1/servers/survival/access/permission",
`{"action":"unset","player":"Steve","node":"essentials.fly","world":"nether"}`,
"lp user Steve permission unset essentials.fly world=nether", "access.permission.unset"},
{"group add", "/api/v1/servers/survival/access/group",
`{"action":"add","player":"Steve","group":"vip"}`, "lp user Steve parent add vip", "access.group.add"},
{"group remove", "/api/v1/servers/survival/access/group",
`{"action":"remove","player":"Steve","group":"vip"}`, "lp user Steve parent remove vip", "access.group.remove"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
api, repo, _, console := mkAccess(t)
api.External = staticExternal{p: accessOwner}
w := do(api.ExternalHandler(), "POST", tc.path, tc.body, nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
if console.calls != 1 {
t.Fatalf("console calls = %d, want 1", console.calls)
}
if console.gotName != "survival" || console.gotCommand != tc.wantCmd {
t.Fatalf("console saw (%q,%q), want (survival,%q)", console.gotName, console.gotCommand, tc.wantCmd)
}
if len(repo.audits) != 1 || repo.audits[0].Action != tc.wantLbl || repo.audits[0].Actor != "[email protected]" {
t.Fatalf("audit = %+v, want action %q", repo.audits, tc.wantLbl)
}
})
}
}
// TestAccessInjectionRejected is the security verification: a space, newline, or
// other off-charset character in ANY structured field must be rejected with 400
// AND must never reach RCON. Without per-field validation each of these inputs
// would splice a second command into the assembled line — so this matrix, not the
// happy-path gotCommand assert, is what proves the injection defence.
func TestAccessInjectionRejected(t *testing.T) {
cases := []struct {
name, path, body string
}{
// player field, across the routes that take one.
{"whitelist player space", "/api/v1/servers/survival/access/whitelist", `{"action":"add","player":"ev il"}`},
{"whitelist player newline", "/api/v1/servers/survival/access/whitelist", `{"action":"add","player":"ev\nop x"}`},
{"ban player semicolon", "/api/v1/servers/survival/access/ban", `{"action":"ban","player":"ev;il"}`},
{"ban player space", "/api/v1/servers/survival/access/ban", `{"action":"ban","player":"ev il"}`},
{"kick player space", "/api/v1/servers/survival/access/kick", `{"player":"ev il"}`},
{"kick player newline", "/api/v1/servers/survival/access/kick", `{"player":"ev\nop x"}`},
{"permission player space", "/api/v1/servers/survival/access/permission",
`{"action":"set","player":"ev il","node":"essentials.fly"}`},
{"group player newline", "/api/v1/servers/survival/access/group",
`{"action":"add","player":"ev\nx","group":"vip"}`},
// node field.
{"permission node space", "/api/v1/servers/survival/access/permission",
`{"action":"set","player":"Steve","node":"essentials fly"}`},
{"permission node newline", "/api/v1/servers/survival/access/permission",
`{"action":"set","player":"Steve","node":"essentials.fly\nop x"}`},
{"permission node equals", "/api/v1/servers/survival/access/permission",
`{"action":"set","player":"Steve","node":"a=b"}`},
// world field (optional — but when present, still validated).
{"permission world space", "/api/v1/servers/survival/access/permission",
`{"action":"set","player":"Steve","node":"essentials.fly","world":"ne ther"}`},
{"permission world newline", "/api/v1/servers/survival/access/permission",
`{"action":"set","player":"Steve","node":"essentials.fly","world":"a\nb"}`},
// group field.
{"group group space", "/api/v1/servers/survival/access/group",
`{"action":"add","player":"Steve","group":"vi p"}`},
{"group group newline", "/api/v1/servers/survival/access/group",
`{"action":"add","player":"Steve","group":"a\nb"}`},
// Trailing / embedded control characters: the anchored regex uses Go's `$`
// (= \z, absolute end), so even a SINGLE trailing newline after an otherwise
// valid name is rejected — a Perl `$` (\Z) would have let "Steve\n" through.
{"whitelist player trailing newline", "/api/v1/servers/survival/access/whitelist",
`{"action":"add","player":"Steve\n"}`},
{"whitelist player carriage return", "/api/v1/servers/survival/access/whitelist",
`{"action":"add","player":"Steve\r"}`},
{"whitelist player tab", "/api/v1/servers/survival/access/whitelist",
`{"action":"add","player":"Ste\tve"}`},
// Length bounds: a name past 16 chars / node past 64 chars must be rejected,
// not truncated, so an over-long field can never carry a smuggled tail.
{"whitelist player over 16 chars", "/api/v1/servers/survival/access/whitelist",
`{"action":"add","player":"AAAAAAAAAAAAAAAAA"}`},
{"permission node over 64 chars", "/api/v1/servers/survival/access/permission",
`{"action":"set","player":"Steve","node":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
api, _, _, console := mkAccess(t)
api.External = staticExternal{p: accessOwner}
w := do(api.ExternalHandler(), "POST", tc.path, tc.body, nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("code = %d body %s, want 400", w.Code, w.Body.String())
}
if console.calls != 0 {
t.Fatalf("an off-charset field must not reach RCON (calls = %d)", console.calls)
}
})
}
}
// TestAccessUnknownAction rejects an action enum value no handler understands,
// before any RCON contact.
func TestAccessUnknownAction(t *testing.T) {
cases := []struct{ name, path, body string }{
{"whitelist", "/api/v1/servers/survival/access/whitelist", `{"action":"frobnicate","player":"Steve"}`},
{"ban", "/api/v1/servers/survival/access/ban", `{"action":"frobnicate","player":"Steve"}`},
{"permission", "/api/v1/servers/survival/access/permission",
`{"action":"frobnicate","player":"Steve","node":"essentials.fly"}`},
{"group", "/api/v1/servers/survival/access/group", `{"action":"frobnicate","player":"Steve","group":"vip"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
api, _, _, console := mkAccess(t)
api.External = staticExternal{p: accessOwner}
w := do(api.ExternalHandler(), "POST", tc.path, tc.body, nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400", w.Code)
}
if console.calls != 0 {
t.Fatalf("an unknown action must not reach RCON (calls = %d)", console.calls)
}
})
}
}
// TestAccessUnknownField proves decodeJSON's strict mode locks the body shape for
// every mutating access route — no extra knob can ride in.
func TestAccessUnknownField(t *testing.T) {
api, _, _, console := mkAccess(t)
api.External = staticExternal{p: accessOwner}
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/access/whitelist",
`{"action":"add","player":"Steve","extra":1}`, nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400", w.Code)
}
if console.calls != 0 {
t.Fatal("a body with an unknown field must not reach RCON")
}
}
// TestAccessOwnerGate proves the shared issueAccessCommand gate: an owner and an
// admin pass (on their own / on any node), a non-owner non-admin is rejected with
// 403 and never reaches RCON. The gate is the same one handleCommand uses, so one
// route exercises it for all five.
func TestAccessOwnerGate(t *testing.T) {
body := `{"action":"add","player":"Steve"}`
path := "/api/v1/servers/survival/access/whitelist"
t.Run("owner -> 200", func(t *testing.T) {
api, _, _, console := mkAccess(t)
api.External = staticExternal{p: accessOwner}
w := do(api.ExternalHandler(), "POST", path, body, nil)
if w.Code != http.StatusOK || console.calls != 1 {
t.Fatalf("code = %d calls = %d", w.Code, console.calls)
}
})
t.Run("admin on another's node -> 200", func(t *testing.T) {
api, _, _, console := mkAccess(t)
api.External = staticExternal{p: &Principal{UserID: "admin1", Email: "[email protected]",
Role: "admin", ViaAdminAccess: true}}
w := do(api.ExternalHandler(), "POST", path, body, nil)
if w.Code != http.StatusOK || console.calls != 1 {
t.Fatalf("code = %d calls = %d", w.Code, console.calls)
}
})
t.Run("non-owner -> 403, no RCON call", func(t *testing.T) {
api, _, _, console := mkAccess(t)
api.External = staticExternal{p: &Principal{UserID: "stranger", Role: "user"}}
w := do(api.ExternalHandler(), "POST", path, body, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403", w.Code)
}
if console.calls != 0 {
t.Fatal("a forbidden caller must not reach RCON")
}
})
}
// TestAccessReadinessAndFailures covers the non-happy lifecycle states the shared
// helper maps: unknown server → 404, stopped server → 409 (no RCON), unreachable
// console → 503 (no audit), nil console → 503.
func TestAccessReadinessAndFailures(t *testing.T) {
body := `{"action":"add","player":"Steve"}`
path := "/api/v1/servers/survival/access/whitelist"
t.Run("unknown server -> 404", func(t *testing.T) {
api, _, _, _ := mkAccess(t)
api.External = staticExternal{p: accessOwner}
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/missing/access/whitelist", body, nil)
if w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", w.Code)
}
})
t.Run("not running -> 409 not_running, no RCON call", func(t *testing.T) {
api, _, cl, console := mkAccess(t)
cl.byName["survival"].Ready = false
cl.byName["survival"].Phase = "Stopped"
api.External = staticExternal{p: accessOwner}
w := do(api.ExternalHandler(), "POST", path, body, nil)
if w.Code != http.StatusConflict || decodeErr(t, w) != "not_running" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
if console.calls != 0 {
t.Fatal("a non-ready server must not reach RCON")
}
})
t.Run("console unreachable -> 503, no audit", func(t *testing.T) {
api, repo, _, console := mkAccess(t)
console.err = ErrConsoleUnavailable
api.External = staticExternal{p: accessOwner}
w := do(api.ExternalHandler(), "POST", path, body, nil)
if w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "console_unavailable" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
if len(repo.audits) != 0 {
t.Fatalf("a failed command must not audit: %+v", repo.audits)
}
})
t.Run("nil Console -> 503", func(t *testing.T) {
api, _, _, _ := mkAccess(t)
api.Console = nil
api.External = staticExternal{p: accessOwner}
w := do(api.ExternalHandler(), "POST", path, body, nil)
if w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "console_unavailable" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
})
}
// TestAccessWhitelistList exercises the read projector: GET returns the parsed
// player list plus the raw reply, and is owner-gated like the writes.
func TestAccessWhitelistList(t *testing.T) {
t.Run("parses players and returns raw output", func(t *testing.T) {
api, repo, _, console := mkAccess(t)
console.reply = "There are 2 whitelisted player(s): Steve, Alex"
api.External = staticExternal{p: accessOwner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/whitelist", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
var resp struct {
Name string `json:"name"`
Players []string `json:"players"`
Output string `json:"output"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
}
if resp.Name != "survival" || resp.Output != console.reply {
t.Fatalf("unexpected response %+v", resp)
}
if len(resp.Players) != 2 || resp.Players[0] != "Steve" || resp.Players[1] != "Alex" {
t.Fatalf("players = %#v, want [Steve Alex]", resp.Players)
}
if console.gotCommand != "whitelist list" {
t.Fatalf("console got %q, want %q", console.gotCommand, "whitelist list")
}
// A read must never write the audit trail — only the mutating routes audit.
if len(repo.audits) != 0 {
t.Fatalf("GET whitelist must not audit: %+v", repo.audits)
}
})
t.Run("empty whitelist -> [] not null", func(t *testing.T) {
api, repo, _, console := mkAccess(t)
console.reply = "There are no whitelisted players"
api.External = staticExternal{p: accessOwner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/whitelist", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
// players must render as [] (non-nil) so the client never sees null.
var raw map[string]json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
t.Fatalf("body not JSON: %v", err)
}
if string(raw["players"]) != "[]" {
t.Fatalf("players = %s, want []", raw["players"])
}
if len(repo.audits) != 0 {
t.Fatalf("GET whitelist must not audit: %+v", repo.audits)
}
})
t.Run("non-owner -> 403, no RCON call", func(t *testing.T) {
api, _, _, console := mkAccess(t)
api.External = staticExternal{p: &Principal{UserID: "stranger", Role: "user"}}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/whitelist", "", nil)
if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403", w.Code)
}
if console.calls != 0 {
t.Fatal("a forbidden caller must not reach RCON")
}
})
}
// TestParseWhitelistOutput unit-tests the vanilla parser directly, including the
// formats issueAccessCommand never produces but a real server might.
func TestParseWhitelistOutput(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"There are 2 whitelisted player(s): Steve, Alex", []string{"Steve", "Alex"}},
{"There are 1 whitelisted player(s): Steve", []string{"Steve"}},
{"There are no whitelisted players", []string{}},
{"", []string{}},
{"There are 0 whitelisted player(s):", []string{}},
{"Names: a, b ,c", []string{"a", "b", "c"}},
}
for _, tc := range cases {
got := parseWhitelistOutput(tc.in)
if got == nil {
t.Fatalf("parseWhitelistOutput(%q) = nil, want non-nil slice", tc.in)
}
if len(got) != len(tc.want) {
t.Fatalf("parseWhitelistOutput(%q) = %#v, want %#v", tc.in, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("parseWhitelistOutput(%q)[%d] = %q, want %q", tc.in, i, got[i], tc.want[i])
}
}
}
}
// TestAccessPlayers exercises the online-roster read projector: GET runs "list"
// and returns the online/max tally, the parsed names, and the raw reply, owner-
// gated like the writes and never auditing.
func TestAccessPlayers(t *testing.T) {
t.Run("parses tally, names and raw output", func(t *testing.T) {
api, repo, _, console := mkAccess(t)
console.reply = "There are 3 of a max of 20 players online: alice, bob, carol"
api.External = staticExternal{p: accessOwner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/players", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
var resp struct {
Name string `json:"name"`
Online int `json:"online"`
Max int `json:"max"`
Players []string `json:"players"`
Output string `json:"output"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
}
if resp.Name != "survival" || resp.Online != 3 || resp.Max != 20 || resp.Output != console.reply {
t.Fatalf("unexpected response %+v", resp)
}
if len(resp.Players) != 3 || resp.Players[0] != "alice" || resp.Players[2] != "carol" {
t.Fatalf("players = %#v, want [alice bob carol]", resp.Players)
}
if console.gotCommand != "list" {
t.Fatalf("console got %q, want %q", console.gotCommand, "list")
}
if len(repo.audits) != 0 {
t.Fatalf("GET players must not audit: %+v", repo.audits)
}
})
t.Run("empty server -> [] not null", func(t *testing.T) {
api, _, _, console := mkAccess(t)
console.reply = "There are 0 of a max of 20 players online:"
api.External = staticExternal{p: accessOwner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/players", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
t.Fatalf("body not JSON: %v", err)
}
if string(raw["players"]) != "[]" {
t.Fatalf("players = %s, want []", raw["players"])
}
})
t.Run("non-owner -> 403, no RCON call", func(t *testing.T) {
api, _, _, console := mkAccess(t)
api.External = staticExternal{p: &Principal{UserID: "stranger", Role: "user"}}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/players", "", nil)
if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403", w.Code)
}
if console.calls != 0 {
t.Fatal("a forbidden caller must not reach RCON")
}
})
}
// TestParseListOutput unit-tests the "list" parser directly, including formats
// issueAccessCommand never produces but a real server might. Names parse from the
// colon tail independently of the count line, so both are pinned separately.
func TestParseListOutput(t *testing.T) {
cases := []struct {
in string
online, max int
want []string
}{
{"There are 3 of a max of 20 players online: alice, bob, carol", 3, 20, []string{"alice", "bob", "carol"}},
{"There are 1 of a max of 20 players online: Steve", 1, 20, []string{"Steve"}},
{"There are 0 of a max of 20 players online:", 0, 20, []string{}},
{"There are 0 of a max of 20 players online", 0, 20, []string{}},
{"", 0, 0, []string{}},
// A colon tail with no recognised count line still yields names, tally 0.
{"Online: a, b ,c", 0, 0, []string{"a", "b", "c"}},
}
for _, tc := range cases {
online, max, got := parseListOutput(tc.in)
if got == nil {
t.Fatalf("parseListOutput(%q) names = nil, want non-nil slice", tc.in)
}
if online != tc.online || max != tc.max {
t.Fatalf("parseListOutput(%q) = (%d,%d), want (%d,%d)", tc.in, online, max, tc.online, tc.max)
}
if len(got) != len(tc.want) {
t.Fatalf("parseListOutput(%q) names = %#v, want %#v", tc.in, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("parseListOutput(%q)[%d] = %q, want %q", tc.in, i, got[i], tc.want[i])
}
}
}
}
// TestAccessBanList exercises the ban-list read projector: GET runs "banlist",
// returns the parsed names plus the raw reply, is owner-gated, and never audits.
func TestAccessBanList(t *testing.T) {
t.Run("parses players and returns raw output", func(t *testing.T) {
api, repo, _, console := mkAccess(t)
console.reply = "There are 2 ban(s):\nSteve was banned by Server: Griefing\nAlex was banned by Server: Spam"
api.External = staticExternal{p: accessOwner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/ban", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
var resp struct {
Name string `json:"name"`
Players []string `json:"players"`
Output string `json:"output"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
}
if resp.Name != "survival" || resp.Output != console.reply {
t.Fatalf("unexpected response %+v", resp)
}
if len(resp.Players) != 2 || resp.Players[0] != "Steve" || resp.Players[1] != "Alex" {
t.Fatalf("players = %#v, want [Steve Alex]", resp.Players)
}
if console.gotCommand != "banlist" {
t.Fatalf("console got %q, want %q", console.gotCommand, "banlist")
}
if len(repo.audits) != 0 {
t.Fatalf("GET ban must not audit: %+v", repo.audits)
}
})
t.Run("empty ban list -> [] not null", func(t *testing.T) {
api, _, _, console := mkAccess(t)
console.reply = "There are no bans."
api.External = staticExternal{p: accessOwner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/ban", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
t.Fatalf("body not JSON: %v", err)
}
if string(raw["players"]) != "[]" {
t.Fatalf("players = %s, want []", raw["players"])
}
})
t.Run("non-owner -> 403, no RCON call", func(t *testing.T) {
api, _, _, console := mkAccess(t)
api.External = staticExternal{p: &Principal{UserID: "stranger", Role: "user"}}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/ban", "", nil)
if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403", w.Code)
}
if console.calls != 0 {
t.Fatal("a forbidden caller must not reach RCON")
}
})
}
// TestParseBanlistOutput unit-tests the ban parser directly. The critical cases are
// the SEPARATOR variants: "banlist" emits one feedback message per ban and RCON's
// concatenation separator is version-dependent, so the parser must return the same
// names whether entries are newline-, space-, or non-separated — and must never let
// the header or a reason's own colon/spaces leak into a name (a corrupt name would
// arm an off-charset pardon).
func TestParseBanlistOutput(t *testing.T) {
cases := []struct {
name string
in string
want []string
}{
{
"newline-separated",
"There are 2 ban(s):\nSteve was banned by Server: Griefing\nAlex was banned by Server: Spam",
[]string{"Steve", "Alex"},
},
{
// The separator the wire format might actually use: header + entries
// concatenated with spaces, reasons carrying spaces of their own.
"space-concatenated with spaced reasons",
"There are 2 ban(s): Steve was banned by Server: griefing spawn Alex was banned by Server: spam",
[]string{"Steve", "Alex"},
},
{"single ban", "There are 1 ban(s):\nNotch was banned by Console: rude", []string{"Notch"}},
{"no bans", "There are no bans.", []string{}},
{"empty", "", []string{}},
}
for _, tc := range cases {
got := parseBanlistOutput(tc.in)
if got == nil {
t.Fatalf("%s: parseBanlistOutput(%q) = nil, want non-nil slice", tc.name, tc.in)
}
if len(got) != len(tc.want) {
t.Fatalf("%s: parseBanlistOutput(%q) = %#v, want %#v", tc.name, tc.in, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("%s: parseBanlistOutput(%q)[%d] = %q, want %q", tc.name, tc.in, i, got[i], tc.want[i])
}
}
}
}