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") } }