Root sets GroupVerify's clan_tag (the scoreboard tag CS2-Tags gives verified players without a rank) next to the Steam group. Validated like the plugin (12 UTF-16 units, no quotes, ; or \), saved to gv_settings and sent as css_gv_clantag when the plugin is loaded. Only changed fields are sent. GroupVerify 0.3.0 checks players with the Steam Web API as they join, so the group list is now "Confirmed group members" with first/last confirmed dates.
236 lines
10 KiB
Go
236 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)
|
|
}
|
|
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")
|
|
}
|
|
}
|