GroupVerify: Verification page, overrides and public Verified tags

Shown only where GroupVerify is: staff get the Verification page once its gv_* tables are in
the database, and the public site shows "Verified" only while it's on (enabled, with a group)
and not missing from css_plugins list, which the poller reads once a minute over RCON.

- Verification page: plugin state and version, Steam group, saved member list, overrides.
- Overrides (@zio/verify, not on yourself unless Root): give, change, remove; player drawer
  shows why a player is or isn't verified.
- Root sets the group and turns it on/off: saved to gv_settings, and sent as css_gv_* when the
  plugin is loaded so it applies at once.
- gv_* times use GroupVerify's own timezone setting.
- MySQL integration test (SAW_TEST_DSN), unit tests for plugin list parsing and the show rule.
This commit is contained in:
Astra 2026-10-02 17:59:26 +01:00
parent b2b4698776
commit 9e4bc63201
15 changed files with 1270 additions and 13 deletions

View file

@ -0,0 +1,222 @@
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")
// 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" {
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)
}
if got := verified("/api/public/players/"+sid, ""); got != want {
t.Errorf("public view of %s: verified %v, want %v", sid, got, want)
}
}
// 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/public/players/"+charlie, "") {
t.Error("still verified after removing the override")
}
// Turned off, nobody is verified, publicly or for staff.
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/public/players/"+member, "") || 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/public/players/"+charlie, "") {
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"])
}
if verified("/api/public/players/"+charlie, "") {
t.Error("publicly verified without the tables")
}
}