Watches any number of ARK:SE servers (RCON for players and chat, the query port for map, version and player cap), records joins, leaves, chat and an audit trail, and keeps a player database across servers. Control: save, broadcast, chat, whisper, kick, time of day, MOTD, wild dino wipe, a raw console with history and macros, restarts with timed warnings and a world save, optional start command and auto-start. Bans are kept by Megalodon: timed, cluster-wide and re-enforced when the player returns. Automation: schedules (every N or at times of day), Discord/ntfy/webhook notifications, welcome message, chat word filter. Access: viewer, moderator and admin roles, API tokens, Prometheus metrics. cmd/fakeark simulates a server for trying it out. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
224 lines
7.9 KiB
Go
224 lines
7.9 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.veillax.com/Veillax/megalodon/internal/config"
|
|
"git.veillax.com/Veillax/megalodon/internal/manager"
|
|
)
|
|
|
|
type env struct {
|
|
t *testing.T
|
|
ts *httptest.Server
|
|
cfg *config.Holder
|
|
tokens map[config.Role]string
|
|
}
|
|
|
|
func setup(t *testing.T) *env {
|
|
t.Helper()
|
|
h, err := config.Load(filepath.Join(t.TempDir(), "megalodon.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e := &env{t: t, cfg: h, tokens: map[config.Role]string{}}
|
|
hash, _ := config.HashPassword("hunter2hunter2")
|
|
err = h.Update(func(c *config.Config) error {
|
|
c.DataDir = t.TempDir()
|
|
c.Users = []config.User{{Name: "root", Role: config.Admin, Hash: hash}, {Name: "mod", Role: config.Moderator, Hash: hash}}
|
|
for _, r := range []config.Role{config.Viewer, config.Moderator, config.Admin} {
|
|
tok, th, _ := config.NewToken()
|
|
e.tokens[r] = tok
|
|
c.Tokens = append(c.Tokens, config.Token{Name: string(r), Role: r, Hash: th})
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m, err := manager.New(h)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
m.Start(ctx)
|
|
e.ts = httptest.NewServer(New(h, m, "test").Handler())
|
|
t.Cleanup(func() { e.ts.Close(); cancel(); m.Wait() })
|
|
return e
|
|
}
|
|
|
|
func (e *env) do(method, path, token string, body any, hdr ...string) *http.Response {
|
|
e.t.Helper()
|
|
var rd *bytes.Reader
|
|
if body != nil {
|
|
b, _ := json.Marshal(body)
|
|
rd = bytes.NewReader(b)
|
|
} else {
|
|
rd = bytes.NewReader(nil)
|
|
}
|
|
req, _ := http.NewRequest(method, e.ts.URL+path, rd)
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
for i := 0; i+1 < len(hdr); i += 2 {
|
|
req.Header.Set(hdr[i], hdr[i+1])
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
e.t.Fatal(err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func status(t *testing.T, resp *http.Response, want int) {
|
|
t.Helper()
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != want {
|
|
t.Fatalf("%s %s: got %d, want %d", resp.Request.Method, resp.Request.URL.Path, resp.StatusCode, want)
|
|
}
|
|
}
|
|
|
|
func TestRolesAreEnforced(t *testing.T) {
|
|
e := setup(t)
|
|
status(t, e.do("GET", "/api/servers", "", nil), 401)
|
|
status(t, e.do("GET", "/api/servers", "mgl_bogus", nil), 401)
|
|
status(t, e.do("GET", "/api/servers", e.tokens[config.Viewer], nil), 200)
|
|
|
|
status(t, e.do("GET", "/api/bans", e.tokens[config.Viewer], nil), 403)
|
|
status(t, e.do("GET", "/api/bans", e.tokens[config.Moderator], nil), 200)
|
|
|
|
status(t, e.do("GET", "/api/settings", e.tokens[config.Moderator], nil), 403)
|
|
status(t, e.do("GET", "/api/settings", e.tokens[config.Admin], nil), 200)
|
|
|
|
// The raw console is admin only; a moderator may kick but the check comes before any server is looked up.
|
|
status(t, e.do("POST", "/api/actions", e.tokens[config.Moderator], map[string]any{"action": "exec", "command": "DoExit"}), 403)
|
|
status(t, e.do("POST", "/api/actions", e.tokens[config.Viewer], map[string]any{"action": "broadcast", "message": "hi"}), 403)
|
|
status(t, e.do("POST", "/api/actions", e.tokens[config.Moderator], map[string]any{"action": "broadcast", "message": "hi"}), 400) // no servers configured
|
|
}
|
|
|
|
func TestLoginSessionAndCSRF(t *testing.T) {
|
|
e := setup(t)
|
|
login := func(pw string) *http.Response {
|
|
return e.do("POST", "/api/login", "", map[string]string{"username": "root", "password": pw}, "X-Megalodon", "1")
|
|
}
|
|
status(t, login("wrong"), 401)
|
|
status(t, e.do("POST", "/api/login", "", map[string]string{"username": "root", "password": "hunter2hunter2"}), 403) // no CSRF header
|
|
|
|
resp := login("hunter2hunter2")
|
|
var cookie *http.Cookie
|
|
for _, c := range resp.Cookies() {
|
|
if c.Name == cookieName {
|
|
cookie = c
|
|
}
|
|
}
|
|
status(t, resp, 200)
|
|
if cookie == nil || !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode {
|
|
t.Fatalf("session cookie is not locked down: %+v", cookie)
|
|
}
|
|
withCookie := func(method, path string, hdr ...string) *http.Response {
|
|
req, _ := http.NewRequest(method, e.ts.URL+path, strings.NewReader("{}"))
|
|
req.AddCookie(cookie)
|
|
for i := 0; i+1 < len(hdr); i += 2 {
|
|
req.Header.Set(hdr[i], hdr[i+1])
|
|
}
|
|
r, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return r
|
|
}
|
|
status(t, withCookie("GET", "/api/me"), 200)
|
|
status(t, withCookie("POST", "/api/actions"), 403) // cookie without the header: a cross-site form post
|
|
status(t, withCookie("POST", "/api/actions", "X-Megalodon", "1"), 400) // allowed through, fails only for lack of servers
|
|
status(t, withCookie("POST", "/api/logout", "X-Megalodon", "1"), 200)
|
|
status(t, withCookie("GET", "/api/me"), 401)
|
|
}
|
|
|
|
func TestLoginIsRateLimited(t *testing.T) {
|
|
e := setup(t)
|
|
var last int
|
|
for i := 0; i < maxLoginFails+2; i++ {
|
|
resp := e.do("POST", "/api/login", "", map[string]string{"username": "root", "password": "nope"}, "X-Megalodon", "1")
|
|
last = resp.StatusCode
|
|
resp.Body.Close()
|
|
}
|
|
if last != http.StatusTooManyRequests {
|
|
t.Fatalf("got %d after repeated failures", last)
|
|
}
|
|
// Even the right password is refused during the lockout.
|
|
status(t, e.do("POST", "/api/login", "", map[string]string{"username": "root", "password": "hunter2hunter2"}, "X-Megalodon", "1"), 429)
|
|
}
|
|
|
|
func TestSettingsAreValidated(t *testing.T) {
|
|
e := setup(t)
|
|
admin := e.tokens[config.Admin]
|
|
bad := map[string]any{"jobs": []map[string]any{{"name": "x", "enabled": true, "action": "save", "every": "5s"}}}
|
|
status(t, e.do("PUT", "/api/settings", admin, bad), 400)
|
|
|
|
good := map[string]any{
|
|
"jobs": []map[string]any{{"name": "Hourly save", "enabled": true, "action": "save", "every": "1h"}},
|
|
"macros": []map[string]any{{"name": "Day", "commands": []string{"SetTimeOfDay 12:00"}, "role": "moderator"}},
|
|
"chat_filter": map[string]any{"action": "warn"},
|
|
}
|
|
status(t, e.do("PUT", "/api/settings", admin, good), 200)
|
|
resp := e.do("GET", "/api/settings", admin, nil)
|
|
var got settings
|
|
json.NewDecoder(resp.Body).Decode(&got)
|
|
resp.Body.Close()
|
|
if len(got.Jobs) != 1 || got.Jobs[0].ID == "" {
|
|
t.Fatalf("job was not saved with an id: %+v", got.Jobs)
|
|
}
|
|
// Moderators see the macros they may run, admins' secrets stay out of /api/me.
|
|
resp = e.do("GET", "/api/me", e.tokens[config.Moderator], nil)
|
|
var me struct{ Macros []struct{ Name string } }
|
|
json.NewDecoder(resp.Body).Decode(&me)
|
|
resp.Body.Close()
|
|
if len(me.Macros) != 1 || me.Macros[0].Name != "Day" {
|
|
t.Fatalf("got %+v", me.Macros)
|
|
}
|
|
}
|
|
|
|
func TestUsersCannotLockEveryoneOut(t *testing.T) {
|
|
e := setup(t)
|
|
admin := e.tokens[config.Admin]
|
|
status(t, e.do("DELETE", "/api/users/root", admin, nil), 400) // the only admin
|
|
status(t, e.do("POST", "/api/users", admin, map[string]string{"name": "root", "role": "viewer"}), 400)
|
|
status(t, e.do("POST", "/api/users", admin, map[string]string{"name": "new", "role": "viewer", "password": "short"}), 400)
|
|
status(t, e.do("POST", "/api/users", admin, map[string]string{"name": "new", "role": "viewer", "password": "long enough pw"}), 200)
|
|
status(t, e.do("DELETE", "/api/users/new", admin, nil), 200)
|
|
}
|
|
|
|
func TestViewersDoNotSeeAuditEvents(t *testing.T) {
|
|
e := setup(t)
|
|
status(t, e.do("POST", "/api/users", e.tokens[config.Admin], map[string]string{"name": "n", "role": "viewer", "password": "long enough pw"}), 200)
|
|
count := func(token string) int {
|
|
resp := e.do("GET", "/api/events?type=audit", token, nil)
|
|
defer resp.Body.Close()
|
|
var evs []manager.Event
|
|
json.NewDecoder(resp.Body).Decode(&evs)
|
|
return len(evs)
|
|
}
|
|
if count(e.tokens[config.Moderator]) == 0 {
|
|
t.Fatal("moderators should see audit events")
|
|
}
|
|
if count(e.tokens[config.Viewer]) != 0 {
|
|
t.Fatal("viewers must not see audit events")
|
|
}
|
|
}
|
|
|
|
func TestSecurityHeadersAndStaticPage(t *testing.T) {
|
|
e := setup(t)
|
|
resp := e.do("GET", "/", "", nil)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 || !strings.Contains(resp.Header.Get("Content-Security-Policy"), "default-src 'none'") || resp.Header.Get("X-Frame-Options") != "DENY" {
|
|
t.Fatalf("status %d, headers %v", resp.StatusCode, resp.Header)
|
|
}
|
|
status(t, e.do("GET", "/healthz", "", nil), 200)
|
|
}
|