package web import ( "bytes" "context" "crypto/ecdh" "crypto/rand" "database/sql" "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "os" "slices" "sync" "testing" "time" _ "github.com/go-sql-driver/mysql" "git.zio.sh/cs2/simpleadmin-web/internal/live" "git.zio.sh/cs2/simpleadmin-web/internal/push" "git.zio.sh/cs2/simpleadmin-web/internal/rcon" "git.zio.sh/cs2/simpleadmin-web/internal/store" ) // TestNotifyDB runs the notification endpoints and the watcher against a real MySQL holding // SimpleAdmin 1.8.2b's migrations, with a fake push service. It empties SimpleAdmin's admin, // group, ban and mute tables and the panel's push tables, 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 NotifyDB func TestNotifyDB(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() ctx := context.Background() exec := func(q string, args ...any) { t.Helper() if _, err := db.Exec(q, args...); err != nil { t.Fatalf("%s: %v", q, err) } } st := store.New(db, time.UTC, 0, 1) ddl, err := os.ReadFile("../../sql/push.sql") if err != nil { t.Fatal(err) } for range 2 { // creating the tables twice is fine if err := st.EnsurePush(ctx, string(ddl)); err != nil { t.Fatal(err) } } const ( owner = "76561198000000001" mod = "76561198000000002" former = "76561198000000003" // has a device, but is no longer staff player = "76561198000000009" ) for _, table := range []string{"saw_push_devices", "saw_push_prefs", "sa_unbans", "sa_unmutes", "sa_bans", "sa_mutes", "sa_admins_flags", "sa_admins", "sa_groups_flags", "sa_groups_servers", "sa_groups"} { 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')") // The fake push service records which device each message went to. /gone has unsubscribed. var mu sync.Mutex got := map[string]int{} fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() got[r.URL.Path]++ mu.Unlock() if r.URL.Path == "/gone" { w.WriteHeader(http.StatusGone) return } w.WriteHeader(http.StatusCreated) })) defer fake.Close() sent := func() map[string]int { mu.Lock() defer mu.Unlock() out := got got = map[string]int{} return out } keys, _ := push.GenerateKeys() rc := rcon.New("127.0.0.1:1", "unused") srv, err := New(Config{BaseURL: "http://panel.test", SessionSecret: []byte("secret"), Push: &push.Sender{Keys: keys, Subject: "http://panel.test", Client: fake.Client()}}, 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() b, _ := json.Marshal(body) req := httptest.NewRequest(method, path, bytes.NewReader(b)) req.Header.Set(csrfHeader, csrfValue) 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) } } browserKeys := func() (string, string) { k, _ := ecdh.P256().GenerateKey(rand.Reader) auth := make([]byte, 16) _, _ = rand.Read(auth) return base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()), base64.RawURLEncoding.EncodeToString(auth) } // The page's data: push is set up, defaults are every ready type, no devices yet. code, out := call("GET", "/api/admin/notifications", mod, nil) expect(200, code, out, "get notifications") if out["available"] != true || out["publicKey"] != keys.Public() || len(out["devices"].([]any)) != 0 { t.Fatalf("notifications: %v", out) } if n := len(out["prefs"].(map[string]any)["types"].([]any)); n != len(readyTypes()) { t.Errorf("default types: %d, want %d", n, len(readyTypes())) } // Subscribing: only known push services, with valid keys. p256dh, auth := browserKeys() sub := map[string]any{"endpoint": "https://fcm.googleapis.com/fcm/send/abc", "expirationTime": nil, "keys": map[string]string{"p256dh": p256dh, "auth": auth}, "userAgent": "test", "timezone": "Europe/London"} code, out = call("POST", "/api/admin/notifications/subscribe", mod, sub) expect(200, code, out, "subscribe") if p, ok, _ := st.PushPrefsFor(ctx, mod); !ok || p.Timezone != "Europe/London" || len(p.Types) != len(readyTypes()) { t.Errorf("prefs after subscribing: %+v %v", p, ok) } bad := map[string]any{"endpoint": "https://169.254.169.254/latest", "keys": map[string]string{"p256dh": p256dh, "auth": auth}} code, out = call("POST", "/api/admin/notifications/subscribe", mod, bad) expect(400, code, out, "subscribe to an unknown host") code, out = call("GET", "/api/admin/notifications", mod, nil) if len(out["devices"].([]any)) != 1 { t.Fatalf("devices after subscribing: %v", out["devices"]) } code, out = call("POST", "/api/admin/notifications/unsubscribe", mod, map[string]any{"endpoint": "https://fcm.googleapis.com/fcm/send/abc"}) expect(200, code, out, "unsubscribe") if d, _ := st.PushDevices(ctx, mod); len(d) != 0 { t.Fatalf("devices after unsubscribing: %v", d) } // Preferences: types that aren't ready, or unknown, are dropped. code, out = call("PUT", "/api/admin/notifications/prefs", mod, map[string]any{ "types": []string{"ban", "gag", "calladmin", "bogus", "ban"}, "quiet": map[string]any{"enabled": false, "start": "23:00", "end": "08:00"}, "timezone": "Mars/Base"}) expect(200, code, out, "save prefs") if p, _, _ := st.PushPrefsFor(ctx, mod); !slices.Equal(p.Types, []string{"ban", "gag"}) || p.Timezone != "UTC" { t.Errorf("saved prefs: %+v", p) } code, out = call("PUT", "/api/admin/notifications/prefs", mod, map[string]any{"types": []string{}, "quiet": map[string]any{"enabled": true, "start": "25:00", "end": "08:00"}, "timezone": "UTC"}) expect(400, code, out, "bad quiet hours") // Devices pointing at the fake push service. The owner is in quiet hours all day. addDevice := func(steamid, path string) { p, a := browserKeys() if err := st.SavePushDevice(ctx, steamid, fake.URL+path, p, a, "test"); err != nil { t.Fatal(err) } } addDevice(mod, "/mod") addDevice(mod, "/gone") addDevice(owner, "/owner") addDevice(former, "/former") now := time.Now().UTC() start := now.Hour()*60 + now.Minute() _ = st.SavePushPrefs(ctx, owner, store.PushPrefs{Types: readyTypes(), QuietEnabled: true, QuietStart: (start + 1380) % 1440, QuietEnd: (start + 60) % 1440, Timezone: "UTC"}) _ = st.SavePushPrefs(ctx, mod, store.PushPrefs{Types: []string{"ban", "unban", "gag"}, Timezone: "UTC"}) w := &watcher{s: srv, after: map[string]int64{}, seen: map[string]time.Time{}} for kind, table := range store.PenaltyEventTable { w.after[kind] = st.MaxID(ctx, table) } np := func(admin, name string, typ string) store.NewPenalty { return store.NewPenalty{SteamID: player, Name: "Victim", Type: typ, AdminSteamID: admin, AdminName: name, Reason: "test", Duration: 60} } if err := st.InsertBan(ctx, np(owner, "Owner", "")); err != nil { // mod wants bans: sent t.Fatal(err) } if err := st.InsertComm(ctx, np(owner, "Owner", "GAG")); err != nil { // mod wants gags: sent t.Fatal(err) } if err := st.InsertComm(ctx, np(owner, "Owner", "MUTE")); err != nil { // mod doesn't want mutes t.Fatal(err) } if _, err := st.LiftDirect(ctx, "sa_bans", player, "", 2, "appeal"); err != nil { // lifted by the mod themselves t.Fatal(err) } // Rows wait to settle before they're sent. w.penalties(ctx) if s := sent(); len(s) != 0 { t.Fatalf("sent before settling: %v", s) } if len(w.seen) != 4 { t.Fatalf("seen %d rows, want 4", len(w.seen)) } for k := range w.seen { w.seen[k] = time.Now().Add(-time.Minute) } w.penalties(ctx) s := sent() // /gone answers 410 to the first message and is deleted, so it misses the second. if s["/mod"] != 2 || s["/gone"] != 1 || s["/owner"] != 0 || s["/former"] != 0 { t.Errorf("sent %v, want 2 to /mod, 1 to /gone, none to /owner (quiet) or /former (not staff)", s) } if d, _ := st.PushDevices(ctx, mod); len(d) != 1 { t.Errorf("the gone device should be deleted, mod has %d", len(d)) } w.penalties(ctx) if s := sent(); len(s) != 0 { t.Errorf("sent again: %v", s) } // The test button reaches only your own devices, ignoring preferences. code, out = call("POST", "/api/admin/notifications/test", mod, map[string]any{}) expect(200, code, out, "test") if s := sent(); s["/mod"] != 1 || len(s) != 1 { t.Errorf("test sent %v", s) } code, out = call("POST", "/api/admin/notifications/test", owner, map[string]any{}) expect(200, code, out, "owner test in quiet hours") if s := sent(); s["/owner"] != 1 { t.Errorf("owner test sent %v", s) } // Turning off everywhere. code, out = call("POST", "/api/admin/notifications/unsubscribe", mod, map[string]any{"all": true}) expect(200, code, out, "unsubscribe all") code, out = call("POST", "/api/admin/notifications/test", mod, map[string]any{}) expect(400, code, out, "test with no devices") }