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