Files
Megalodon/internal/manager/manager_test.go
T
VeillaxandClaude Sonnet 5.5 0d2cb31abf
CI / test (push) Successful in 2m26s
Release / release (push) Successful in 2m36s
Megalodon: manage ARK servers over RCON from a web dashboard
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>
2026-10-30 23:36:13 -06:00

286 lines
7.8 KiB
Go

package manager
import (
"context"
"encoding/binary"
"fmt"
"io"
"net"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.veillax.com/Veillax/megalodon/internal/config"
)
// fakeArk is a minimal ARK RCON server whose player list the test controls.
type fakeArk struct {
ln net.Listener
mu sync.Mutex
players []string // "Name, steamid" lines
got []string
}
func startFake(t *testing.T) *fakeArk {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
f := &fakeArk{ln: ln}
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go f.handle(c)
}
}()
return f
}
func rawPacket(w io.Writer, id, typ int32, body string) {
buf := make([]byte, 12+len(body)+2)
binary.LittleEndian.PutUint32(buf[0:], uint32(10+len(body)))
binary.LittleEndian.PutUint32(buf[4:], uint32(id))
binary.LittleEndian.PutUint32(buf[8:], uint32(typ))
copy(buf[12:], body)
w.Write(buf)
}
func rawRead(r io.Reader) (id, typ int32, body string, err error) {
var h [4]byte
if _, err = io.ReadFull(r, h[:]); err != nil {
return
}
buf := make([]byte, binary.LittleEndian.Uint32(h[:]))
if _, err = io.ReadFull(r, buf); err != nil {
return
}
return int32(binary.LittleEndian.Uint32(buf)), int32(binary.LittleEndian.Uint32(buf[4:])), string(buf[8 : len(buf)-2]), nil
}
func (f *fakeArk) handle(c net.Conn) {
defer c.Close()
id, _, _, err := rawRead(c)
if err != nil {
return
}
rawPacket(c, id, 0, "")
rawPacket(c, id, 2, "")
for {
id, _, cmd, err := rawRead(c)
if err != nil {
return
}
f.mu.Lock()
f.got = append(f.got, cmd)
reply := "ok"
switch cmd {
case "ListPlayers":
reply = "No Players Connected"
if len(f.players) > 0 {
var lines []string
for n, p := range f.players {
lines = append(lines, fmt.Sprintf("%d. %s", n, p))
}
reply = strings.Join(lines, "\n") + "\n"
}
case "GetChat":
reply = "Server received, But no response!!"
}
f.mu.Unlock()
rawPacket(c, id, 0, reply)
}
}
func (f *fakeArk) setPlayers(p ...string) { f.mu.Lock(); f.players = p; f.mu.Unlock() }
func (f *fakeArk) received(cmd string) int {
f.mu.Lock()
defer f.mu.Unlock()
n := 0
for _, c := range f.got {
if c == cmd {
n++
}
}
return n
}
func (f *fakeArk) port() int { return f.ln.Addr().(*net.TCPAddr).Port }
func newTestManager(t *testing.T, f *fakeArk) *Manager {
t.Helper()
h, err := config.Load(filepath.Join(t.TempDir(), "megalodon.json"))
if err != nil {
t.Fatal(err)
}
err = h.Update(func(c *config.Config) error {
c.DataDir = t.TempDir()
c.PollSeconds = 1
c.Servers = []config.Server{{ID: "island", Name: "The Island", Host: "127.0.0.1", RCONPort: f.port(), Password: "pw"}}
return nil
})
if err != nil {
t.Fatal(err)
}
m, err := New(h)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
m.Start(ctx)
t.Cleanup(func() { cancel(); m.Wait() })
return m
}
func waitFor(t *testing.T, what string, ok func() bool) {
t.Helper()
deadline := time.Now().Add(8 * time.Second)
for time.Now().Before(deadline) {
if ok() {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
func hasEvent(m *Manager, typ, contains string) bool {
for _, e := range m.hub.Recent(Filter{Types: []string{typ}}) {
if strings.Contains(e.Message, contains) {
return true
}
}
return false
}
const (
alex = "76561198000000001"
bo = "76561198000000002"
)
func TestJoinLeaveAndBanEnforcement(t *testing.T) {
f := startFake(t)
f.setPlayers("Alex, " + alex)
m := newTestManager(t, f)
waitFor(t, "server online", func() bool { return m.Views()[0].Online })
if hasEvent(m, "join", "Alex") {
t.Fatal("someone already online at startup should not raise a join event")
}
f.setPlayers("Alex, "+alex, "Bo, "+bo)
waitFor(t, "join event", func() bool { return hasEvent(m, "join", "Bo joined") })
if v := m.Views()[0]; len(v.Players) != 2 {
t.Fatalf("expected two players, got %+v", v.Players)
}
f.setPlayers("Bo, " + bo)
waitFor(t, "leave event", func() bool { return hasEvent(m, "leave", "Alex left") })
// A ban is sent to the server, and enforced again when the player shows up.
if _, err := m.Do("tester", Action{Name: "ban", SteamID: alex, Reason: "griefing", Minutes: 60}); err != nil {
t.Fatal(err)
}
if f.received("BanPlayer "+alex) != 1 {
t.Fatal("the ban was not sent")
}
f.setPlayers("Alex, "+alex, "Bo, "+bo)
waitFor(t, "ban enforcement", func() bool { return f.received("BanPlayer "+alex) >= 2 })
waitFor(t, "kick event", func() bool { return hasEvent(m, "kick", "Removed banned player Alex") })
if _, err := m.Do("tester", Action{Name: "unban", SteamID: alex}); err != nil {
t.Fatal(err)
}
if f.received("UnbanPlayer "+alex) != 1 || len(m.Bans()) != 0 {
t.Fatal("the unban did not go through")
}
}
func TestActionsBuildSafeCommands(t *testing.T) {
f := startFake(t)
m := newTestManager(t, f)
waitFor(t, "server online", func() bool { return m.Views()[0].Online })
res, err := m.Do("tester", Action{Name: "broadcast", Message: "hello\nDoExit"})
if err != nil || len(res) != 1 || !res[0].OK {
t.Fatalf("broadcast: %+v, %v", res, err)
}
if f.received("Broadcast hello DoExit") != 1 || f.received("DoExit") != 0 {
t.Fatalf("commands received: %v", f.got)
}
res, _ = m.Do("tester", Action{Name: "kick", SteamID: "1; DoExit"})
if res[0].OK || f.received("DoExit") != 0 {
t.Fatal("an invalid Steam ID got through")
}
if _, err := m.Do("tester", Action{Name: "save", Servers: []string{"nope"}}); err == nil {
t.Fatal("unknown server accepted")
}
if !hasEvent(m, "audit", "broadcast") {
t.Fatal("the action was not audited")
}
}
func TestRestartCountdownCanBeCancelled(t *testing.T) {
f := startFake(t)
m := newTestManager(t, f)
waitFor(t, "server online", func() bool { return m.Views()[0].Online })
if _, err := m.Do("tester", Action{Name: "restart", Minutes: 10, Reason: "updates"}); err != nil {
t.Fatal(err)
}
if m.Views()[0].Restart == nil {
t.Fatal("no restart shown")
}
waitFor(t, "first warning", func() bool { return f.received("Broadcast Server restarting in 10 minutes. updates") == 1 })
if res, _ := m.Do("tester", Action{Name: "restart", Minutes: 5}); res[0].OK {
t.Fatal("a second restart was accepted")
}
m.Do("tester", Action{Name: "cancel_restart"})
if m.Views()[0].Restart != nil {
t.Fatal("restart still shown after cancelling")
}
if f.received("DoExit") != 0 {
t.Fatal("the server was stopped despite the cancel")
}
}
func TestSchedule(t *testing.T) {
m := &Manager{lastRun: map[string]time.Time{}, lastFire: map[string]string{}}
now := time.Date(2026, 3, 2, 4, 0, 10, 0, time.Local) // a Monday
every := config.Job{ID: "e", Every: "30m"}
if m.due(every, now) {
t.Fatal("an interval job must not fire the moment it is first seen")
}
if m.due(every, now.Add(29*time.Minute)) || !m.due(every, now.Add(31*time.Minute)) {
t.Fatal("interval job timing is wrong")
}
daily := config.Job{ID: "d", At: []string{"04:00"}, Days: []string{"mon"}}
if !m.due(daily, now) || m.due(daily, now.Add(5*time.Second)) {
t.Fatal("a timed job should fire once in its minute")
}
if m.due(config.Job{ID: "t", At: []string{"04:00"}, Days: []string{"tue"}}, now) {
t.Fatal("job fired on the wrong day")
}
}
func TestNotificationFilter(t *testing.T) {
join := Event{Type: "join", Server: "island"}
chat := Event{Type: "chat", Server: "island"}
if !wants(config.Notify{}, join) || wants(config.Notify{}, chat) {
t.Fatal("default event set is wrong")
}
if !wants(config.Notify{Events: []string{"chat"}}, chat) || wants(config.Notify{Events: []string{"chat"}}, join) {
t.Fatal("explicit event list ignored")
}
if wants(config.Notify{Servers: []string{"other"}}, join) || !wants(config.Notify{Servers: []string{"island"}}, join) {
t.Fatal("server filter is wrong")
}
}