Notifications: staff turn them on per device from a new Notifications page, pick each type (bans, gags, mutes and silences added or lifted, server down and back up) and quiet hours. A watcher polls SimpleAdmin's tables, so in-game penalties are sent too. Web Push with VAPID and RFC 8291 encryption in internal/push, standard library only. Tables in sql/push.sql, created at startup; the VAPID key is made on first start and kept in saw_settings. Penalty notifications link to the public list searching for the player (#bans?q=<steamid64>). The Android app delegates notifications (versionCode 3). Caching: pages, scripts and styles are served with Cache-Control: no-cache and an ETag, so a deploy shows up at once instead of after Cloudflare's default 4-hour browser cache.
257 lines
9.7 KiB
Go
257 lines
9.7 KiB
Go
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")
|
|
}
|