Busiest hours and the heatmap count the different players on T or CT in each hour, open segments included, instead of averaging player-hours, so one player for part of an hour is 1, not 0.38. Their descriptions end in the span covered (Last 30 days, or Since <date>). Side-by-side chart panels match heights, and Players per day notes its busiest day. Dropped the page descriptions on Bans, Gags & mutes, Staff and Playtime, and the Verified badges on the staff Overview scoreboard and drawer header; the Verification page and the drawer's Verification section still show it. The GroupVerify DB test now checks the staff drawer, since the public site says nothing about verification.
234 lines
10 KiB
Go
234 lines
10 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "github.com/go-sql-driver/mysql"
|
|
|
|
"git.zio.sh/cs2/simpleadmin-web/internal/live"
|
|
"git.zio.sh/cs2/simpleadmin-web/internal/rcon"
|
|
"git.zio.sh/cs2/simpleadmin-web/internal/store"
|
|
)
|
|
|
|
// TestVerifyDB runs the GroupVerify endpoints against a real MySQL holding SimpleAdmin 1.8.2b's
|
|
// migrations and GroupVerify's Migrations/001_CreateTables.sql. It empties those tables first, so
|
|
// only point it at a scratch database:
|
|
//
|
|
// SAW_TEST_DSN='root:test@tcp(127.0.0.1:33061)/sa?parseTime=true&loc=UTC' go test ./internal/web -run VerifyDB
|
|
func TestVerifyDB(t *testing.T) {
|
|
dsn := os.Getenv("SAW_TEST_DSN")
|
|
if dsn == "" {
|
|
t.Skip("SAW_TEST_DSN not set")
|
|
}
|
|
db, err := sql.Open("mysql", dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
exec := func(q string, args ...any) {
|
|
t.Helper()
|
|
if _, err := db.Exec(q, args...); err != nil {
|
|
t.Fatalf("%s: %v", q, err)
|
|
}
|
|
}
|
|
|
|
const (
|
|
owner = "76561198000000001"
|
|
mod = "76561198000000002"
|
|
charlie = "76561198000000003"
|
|
member = "76561198000000004"
|
|
expired = "76561198000000005"
|
|
group = "103582791429521412"
|
|
)
|
|
for _, table := range []string{"gv_verified", "gv_settings", "sa_admins_flags", "sa_admins", "sa_groups_flags", "sa_groups_servers", "sa_groups", "sa_players_ips"} {
|
|
exec("DELETE FROM " + table)
|
|
}
|
|
exec("INSERT INTO sa_groups (id, name, immunity) VALUES (1, '#rank/owner', 9999), (2, '#rank/mod', 30)")
|
|
exec("INSERT INTO sa_groups_servers (group_id, server_id) VALUES (1, NULL), (2, NULL)")
|
|
exec("INSERT INTO sa_groups_flags (group_id, flag) VALUES (1, '@css/root'), (2, '@css/generic')")
|
|
exec("INSERT INTO sa_admins (id, player_name, player_steamid, immunity, server_id) VALUES (1, 'Owner', ?, 0, NULL), (2, 'Mod', ?, 0, NULL)", owner, mod)
|
|
exec("INSERT INTO sa_admins_flags (admin_id, flag) VALUES (1, '#rank/owner'), (2, '#rank/mod')")
|
|
exec("INSERT INTO sa_players_ips (steamid, name, address, used_at) VALUES (?, 'Charlie', INET_ATON('10.0.0.3'), NOW())", charlie)
|
|
|
|
st := store.New(db, time.UTC, 0, 1)
|
|
rc := rcon.New("127.0.0.1:1", "unused") // nothing listens: the plugin's state stays unknown
|
|
srv, err := New(Config{BaseURL: "http://panel.test", SessionSecret: []byte("secret")}, st, rc, live.NewPoller(rc, time.Hour), nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := srv.Handler()
|
|
|
|
call := func(method, path, as string, body any) (int, map[string]any) {
|
|
t.Helper()
|
|
var rd *bytes.Reader
|
|
if body != nil {
|
|
b, _ := json.Marshal(body)
|
|
rd = bytes.NewReader(b)
|
|
} else {
|
|
rd = bytes.NewReader(nil)
|
|
}
|
|
req := httptest.NewRequest(method, path, rd)
|
|
req.Header.Set(csrfHeader, csrfValue)
|
|
if as != "" {
|
|
rec := httptest.NewRecorder()
|
|
srv.setSession(rec, as)
|
|
for _, c := range rec.Result().Cookies() {
|
|
req.AddCookie(c)
|
|
}
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
var out map[string]any
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
|
return rec.Code, out
|
|
}
|
|
expect := func(want, got int, out map[string]any, what string) {
|
|
t.Helper()
|
|
if got != want {
|
|
t.Fatalf("%s: status %d, want %d (%v)", what, got, want, out)
|
|
}
|
|
}
|
|
verified := func(path, as string) bool {
|
|
t.Helper()
|
|
code, out := call("GET", path, as, nil)
|
|
expect(200, code, out, path)
|
|
if v, ok := out["verify"].(map[string]any); ok {
|
|
return v["verified"] == true
|
|
}
|
|
return out["verified"] == true
|
|
}
|
|
|
|
// The tables are found, and GroupVerify's defaults apply until something is set.
|
|
if _, out := call("GET", "/api/me", mod, nil); out["verify"] != true {
|
|
t.Fatalf("me.verify = %v, want true", out["verify"])
|
|
}
|
|
|
|
// Settings: Root only, and only real group IDs.
|
|
code, out := call("POST", "/api/admin/verify/settings", mod, map[string]any{"groupId": group})
|
|
expect(403, code, out, "mod sets the group")
|
|
code, out = call("POST", "/api/admin/verify/settings", owner, map[string]any{"groupId": "76561198000000003"})
|
|
expect(400, code, out, "a player's ID as the group")
|
|
code, out = call("POST", "/api/admin/verify/settings", owner, map[string]any{"groupId": group})
|
|
expect(200, code, out, "owner sets the group")
|
|
|
|
// The clan tag: Root only, at most 12 characters, nothing the console would mangle.
|
|
code, out = call("POST", "/api/admin/verify/settings", mod, map[string]any{"clanTag": "zio"})
|
|
expect(403, code, out, "mod sets the clan tag")
|
|
for _, bad := range []string{`a"b`, "a;b", "thirteen char"} {
|
|
code, out = call("POST", "/api/admin/verify/settings", owner, map[string]any{"clanTag": bad})
|
|
expect(400, code, out, "clan tag "+bad)
|
|
}
|
|
code, out = call("POST", "/api/admin/verify/settings", owner, map[string]any{"clanTag": " zio.sh "})
|
|
expect(200, code, out, "owner sets the clan tag")
|
|
var clanTag string
|
|
if err := db.QueryRow("SELECT value FROM gv_settings WHERE name = 'clan_tag'").Scan(&clanTag); err != nil || clanTag != "zio.sh" {
|
|
t.Fatalf("clan_tag = %q (%v), want zio.sh", clanTag, err)
|
|
}
|
|
|
|
// Overrides need @zio/verify.
|
|
code, out = call("POST", "/api/admin/verify/overrides", mod, map[string]any{"steamid": charlie, "days": 7})
|
|
expect(403, code, out, "mod without @zio/verify")
|
|
exec("INSERT INTO sa_groups_flags (group_id, flag) VALUES (2, '@zio/verify')")
|
|
srv.staff.invalidate()
|
|
code, out = call("POST", "/api/admin/verify/overrides", mod, map[string]any{"steamid": mod, "days": 7})
|
|
expect(403, code, out, "mod overrides themselves")
|
|
code, out = call("POST", "/api/admin/verify/overrides", mod, map[string]any{"steamid": charlie, "days": 7})
|
|
expect(200, code, out, "mod gives an override")
|
|
|
|
// A group member, and an override that has run out.
|
|
exec("INSERT INTO gv_verified (steamid64, source, `override`, added_by, created_at, updated_at) VALUES (?, 'group', 0, 'steam-group', NOW(), NOW())", member)
|
|
exec("INSERT INTO gv_verified (steamid64, source, `override`, expires_at, added_by, created_at, updated_at) VALUES (?, 'manual', 1, ?, 'x', NOW(), NOW())",
|
|
expired, time.Now().UTC().Add(-time.Hour).Format(time.DateTime))
|
|
|
|
code, out = call("GET", "/api/admin/verify", mod, nil)
|
|
expect(200, code, out, "verify page")
|
|
status := out["status"].(map[string]any)
|
|
if status["active"] != true || status["groupMembers"] != float64(1) || status["overrides"] != float64(1) || status["plugin"] != "unknown" || status["clanTag"] != "zio.sh" {
|
|
t.Fatalf("status %v", status)
|
|
}
|
|
items := out["list"].(map[string]any)["items"].([]any)
|
|
if len(items) != 1 || items[0].(map[string]any)["steamid"] != charlie || items[0].(map[string]any)["name"] != "Charlie" {
|
|
t.Fatalf("overrides %v", items)
|
|
}
|
|
ends, _ := time.Parse(time.RFC3339, items[0].(map[string]any)["expires"].(string))
|
|
if d := time.Until(ends); d < 7*24*time.Hour-time.Minute || d > 7*24*time.Hour+time.Minute {
|
|
t.Fatalf("override ends in %v, want 7 days", d)
|
|
}
|
|
code, out = call("GET", "/api/admin/verify?view=group&q="+member, mod, nil)
|
|
expect(200, code, out, "group view")
|
|
if items := out["list"].(map[string]any)["items"].([]any); len(items) != 1 || items[0].(map[string]any)["steamid"] != member {
|
|
t.Fatalf("group members %v", items)
|
|
}
|
|
code, out = call("GET", "/api/admin/verify?q=Charl", mod, nil)
|
|
expect(200, code, out, "search by name")
|
|
if items := out["list"].(map[string]any)["items"].([]any); len(items) != 1 {
|
|
t.Fatalf("name search %v", items)
|
|
}
|
|
|
|
for sid, want := range map[string]bool{charlie: true, member: true, expired: false, mod: false} {
|
|
if got := verified("/api/admin/players/"+sid, mod); got != want {
|
|
t.Errorf("staff view of %s: verified %v, want %v", sid, got, want)
|
|
}
|
|
// The public site never says whether anyone is verified.
|
|
if _, out := call("GET", "/api/public/players/"+sid, "", nil); out["verified"] != nil {
|
|
t.Errorf("public view of %s says verified %v", sid, out["verified"])
|
|
}
|
|
}
|
|
|
|
// Removing it, twice.
|
|
code, out = call("DELETE", "/api/admin/verify/overrides/"+charlie, mod, nil)
|
|
expect(200, code, out, "remove override")
|
|
code, out = call("DELETE", "/api/admin/verify/overrides/"+charlie, mod, nil)
|
|
expect(404, code, out, "remove it again")
|
|
if verified("/api/admin/players/"+charlie, mod) {
|
|
t.Error("still verified after removing the override")
|
|
}
|
|
|
|
// Turned off, nobody is verified.
|
|
code, out = call("POST", "/api/admin/verify/settings", owner, map[string]any{"enabled": false})
|
|
expect(200, code, out, "turn off")
|
|
if !strings.Contains(out["message"].(string), "off") {
|
|
t.Errorf("turn-off message %q", out["message"])
|
|
}
|
|
if verified("/api/admin/players/"+member, mod) {
|
|
t.Error("verified while GroupVerify is off")
|
|
}
|
|
|
|
// Override times are written in GroupVerify's timezone.
|
|
exec("UPDATE gv_settings SET value = '1' WHERE name = 'enabled'")
|
|
exec("INSERT INTO gv_settings (name, value) VALUES ('timezone', 'Asia/Tokyo') ON DUPLICATE KEY UPDATE value = 'Asia/Tokyo'")
|
|
srv.verify.invalidate()
|
|
code, out = call("POST", "/api/admin/verify/overrides", mod, map[string]any{"steamid": charlie, "days": 1})
|
|
expect(200, code, out, "override in Tokyo time")
|
|
var raw string
|
|
if err := db.QueryRow("SELECT DATE_FORMAT(expires_at, '%Y-%m-%d %H:%i') FROM gv_verified WHERE steamid64 = ? AND source = 'manual'", charlie).Scan(&raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tokyo, _ := time.LoadLocation("Asia/Tokyo")
|
|
if want := time.Now().Add(24 * time.Hour).In(tokyo).Format("2006-01-02 15:04"); raw != want && raw != time.Now().Add(24*time.Hour+time.Minute).In(tokyo).Format("2006-01-02 15:04") {
|
|
t.Errorf("expires_at %s, want Tokyo wall time %s", raw, want)
|
|
}
|
|
if !verified("/api/admin/players/"+charlie, mod) {
|
|
t.Error("override in Tokyo time isn't counted")
|
|
}
|
|
|
|
// Without GroupVerify's tables, there's no sign of it anywhere.
|
|
exec("RENAME TABLE gv_settings TO gv_settings_hidden")
|
|
defer db.Exec("RENAME TABLE gv_settings_hidden TO gv_settings")
|
|
srv.verify.invalidate()
|
|
if _, out := call("GET", "/api/me", mod, nil); out["verify"] != nil {
|
|
t.Errorf("me.verify = %v without the tables", out["verify"])
|
|
}
|
|
code, out = call("GET", "/api/admin/verify", mod, nil)
|
|
expect(404, code, out, "verify page without the tables")
|
|
if _, out := call("GET", "/api/admin/players/"+charlie, mod, nil); out["verify"] != nil {
|
|
t.Errorf("player drawer has verify %v without the tables", out["verify"])
|
|
}
|
|
}
|