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