package api import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "testing" "time" "ferrum/internal/auth" "ferrum/internal/config" "ferrum/internal/secrets" "ferrum/internal/store" ) // --- harness --- type testEnv struct { db *store.DB server *Server router http.Handler } func newTestEnv(t *testing.T) *testEnv { t.Helper() dbPath := filepath.Join(t.TempDir(), "ferrum.db") db, err := store.Open(config.DBConfig{Driver: "sqlite", Path: dbPath}) if err != nil { t.Fatalf("store.Open: %v", err) } t.Cleanup(func() { db.Close() }) box, err := secrets.New("test-secret-that-is-long-enough") if err != nil { t.Fatalf("secrets.New: %v", err) } srv := New(db, auth.NewService(db, box), box, ServerOptions{SecureCookies: true}) return &testEnv{db: db, server: srv, router: srv.Router()} } func (e *testEnv) do(t *testing.T, method, path string, body any, cookie *http.Cookie) *httptest.ResponseRecorder { t.Helper() var buf *bytes.Buffer if body != nil { raw, err := json.Marshal(body) if err != nil { t.Fatalf("marshaling body: %v", err) } buf = bytes.NewBuffer(raw) } else { buf = bytes.NewBuffer(nil) } req := httptest.NewRequest(method, path, buf) req.Header.Set("Content-Type", "application/json") if cookie != nil { req.AddCookie(cookie) } rec := httptest.NewRecorder() e.router.ServeHTTP(rec, req) return rec } func (e *testEnv) get(t *testing.T, path string, cookie *http.Cookie) *httptest.ResponseRecorder { return e.do(t, http.MethodGet, path, nil, cookie) } func decode[T any](t *testing.T, rec *httptest.ResponseRecorder) T { t.Helper() var out T if err := json.NewDecoder(rec.Body).Decode(&out); err != nil { t.Fatalf("decoding response %s: %v", rec.Body.String(), err) } return out } // loginAs bootstraps the first admin, logs in, and returns the session cookie. func (e *testEnv) loginAs(t *testing.T, username, email, password string, isAdmin bool) *http.Cookie { t.Helper() if _, err := e.auth().Bootstrap(t.Context(), username, email, password); err != nil { if err != auth.ErrAlreadyBootstrapped { t.Fatalf("bootstrap: %v", err) } } if !isAdmin { // Non-admin accounts are created through the admin API. var userID string if err := e.db.QueryRow(`SELECT id FROM users WHERE username = ?`, username).Scan(&userID); err != nil { t.Fatalf("finding user: %v", err) } if _, err := e.db.Exec(`UPDATE users SET is_admin = 0 WHERE id = ?`, userID); err != nil { t.Fatalf("demoting user: %v", err) } } rec := e.do(t, http.MethodPost, "/api/v1/auth/login", map[string]string{"username": username, "password": password}, nil) if rec.Code != http.StatusOK { t.Fatalf("login %s: status %d body %s", username, rec.Code, rec.Body.String()) } var setCookie string for _, c := range rec.Result().Cookies() { if c.Name == auth.SessionCookieName { setCookie = c.Value } } if setCookie == "" { t.Fatalf("login response carried no %s cookie", auth.SessionCookieName) } return &http.Cookie{Name: auth.SessionCookieName, Value: setCookie} } func (e *testEnv) auth() *auth.Service { return e.server.auth } // --- health & auth surface --- func TestHealthIsPublic(t *testing.T) { e := newTestEnv(t) if rec := e.get(t, "/api/v1/health", nil); rec.Code != http.StatusOK { t.Fatalf("health status = %d, want 200", rec.Code) } } func TestSetupThenLoginFlow(t *testing.T) { e := newTestEnv(t) status := e.get(t, "/api/v1/auth/setup-status", nil) if got := decode[map[string]bool](t, status)["needsSetup"]; !got { t.Fatal("fresh instance should need setup") } rec := e.do(t, http.MethodPost, "/api/v1/auth/setup", map[string]string{ "username": "admin", "email": "admin@example.com", "password": "correct horse battery", }, nil) if rec.Code != http.StatusCreated { t.Fatalf("setup status = %d, want 201: %s", rec.Code, rec.Body.String()) } // Second setup is refused — the first admin already exists. rec = e.do(t, http.MethodPost, "/api/v1/auth/setup", map[string]string{ "username": "root", "email": "root@example.com", "password": "another password", }, nil) if rec.Code != http.StatusConflict { t.Fatalf("second setup status = %d, want 409", rec.Code) } // Wrong password is rejected with the generic message. rec = e.do(t, http.MethodPost, "/api/v1/auth/login", map[string]string{"username": "admin", "password": "wrong"}, nil) if rec.Code != http.StatusUnauthorized { t.Fatalf("bad login status = %d, want 401", rec.Code) } if got := decode[map[string]string](t, rec)["error"]; got != "invalid username or password" { t.Fatalf("login error message = %q, want the generic one", got) } cookie := e.loginAs(t, "admin", "admin@example.com", "correct horse battery", true) me := e.get(t, "/api/v1/auth/me", cookie) if me.Code != http.StatusOK { t.Fatalf("auth/me status = %d", me.Code) } user := decode[auth.User](t, me) if !user.IsAdmin || user.Username != "admin" { t.Fatalf("me = %+v, want admin user", user) } } func TestRequireAuthRejectsMissingOrBadCookie(t *testing.T) { e := newTestEnv(t) e.loginAs(t, "admin", "admin@example.com", "correct horse battery", true) if rec := e.get(t, "/api/v1/auth/me", nil); rec.Code != http.StatusUnauthorized { t.Fatalf("no-cookie me status = %d, want 401", rec.Code) } bad := &http.Cookie{Name: auth.SessionCookieName, Value: "forged-token"} if rec := e.get(t, "/api/v1/auth/me", bad); rec.Code != http.StatusUnauthorized { t.Fatalf("forged-cookie me status = %d, want 401", rec.Code) } } func TestSecurityHeadersOnEveryResponse(t *testing.T) { e := newTestEnv(t) rec := e.get(t, "/api/v1/health", nil) checks := map[string]string{ "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Referrer-Policy": "strict-origin-when-cross-origin", } for header, want := range checks { if got := rec.Header().Get(header); got != want { t.Errorf("%s = %q, want %q", header, got, want) } } if csp := rec.Header().Get("Content-Security-Policy"); csp == "" { t.Error("Content-Security-Policy header missing") } if hsts := rec.Header().Get("Strict-Transport-Security"); hsts == "" { t.Error("HSTS missing while SecureCookies is enabled") } } // --- authorization model --- func TestAdminOnlyRoutesRejectNonAdmins(t *testing.T) { e := newTestEnv(t) admin := e.loginAs(t, "admin", "admin@example.com", "correct horse battery", true) _ = e.do(t, http.MethodPost, "/api/v1/users/", map[string]any{ "username": "bob", "email": "bob@example.com", "password": "bob's own password", }, admin) bob := e.loginAs(t, "bob", "bob@example.com", "bob's own password", false) for _, tc := range []struct{ method, path string }{ {http.MethodGet, "/api/v1/users/"}, {http.MethodGet, "/api/v1/roles/"}, {http.MethodGet, "/api/v1/audit"}, } { if rec := e.do(t, tc.method, tc.path, nil, bob); rec.Code != http.StatusForbidden { t.Errorf("non-admin %s %s = %d, want 403", tc.method, tc.path, rec.Code) } } } func TestMutatingInfraRoutesRequireAdmin(t *testing.T) { e := newTestEnv(t) admin := e.loginAs(t, "admin", "admin@example.com", "correct horse battery", true) _ = e.do(t, http.MethodPost, "/api/v1/users/", map[string]any{ "username": "bob", "email": "bob@example.com", "password": "bob's own password", }, admin) bob := e.loginAs(t, "bob", "bob@example.com", "bob's own password", false) // Reads are open to authenticated users... if rec := e.do(t, http.MethodGet, "/api/v1/connections/", nil, bob); rec.Code != http.StatusOK { t.Fatalf("non-admin GET connections = %d, want 200", rec.Code) } // ...but mutations need admin. if rec := e.do(t, http.MethodPost, "/api/v1/connections/", map[string]any{ "name": "pve", "host": "10.0.0.5", "authType": "password", "username": "root", "password": "x", "tokenId": "", "tokenSecret": "", }, bob); rec.Code != http.StatusForbidden { t.Fatalf("non-admin POST connections = %d, want 403", rec.Code) } if rec := e.do(t, http.MethodPost, "/api/v1/connections/", map[string]any{ "name": "pve", "host": "10.0.0.5", "authType": "password", "username": "root", "password": "x", }, admin); rec.Code != http.StatusCreated { t.Fatalf("admin POST connections = %d, want 201: %s", rec.Code, rec.Body.String()) } } // --- users admin --- func TestUserLifecycleAndLastAdminGuard(t *testing.T) { e := newTestEnv(t) admin := e.loginAs(t, "admin", "admin@example.com", "correct horse battery", true) // Duplicate usernames are a conflict, not a 500. if rec := e.do(t, http.MethodPost, "/api/v1/users/", map[string]any{ "username": "admin", "email": "other@example.com", "password": "some password", }, admin); rec.Code != http.StatusConflict { t.Fatalf("duplicate user create = %d, want 409", rec.Code) } // Promote bob via the (previously missing) update endpoint. rec := e.do(t, http.MethodPost, "/api/v1/users/", map[string]any{ "username": "bob", "email": "bob@example.com", "password": "bob's own password", }, admin) if rec.Code != http.StatusCreated { t.Fatalf("create bob = %d: %s", rec.Code, rec.Body.String()) } var created struct{ ID string } _ = json.NewDecoder(rec.Body).Decode(&created) if rec := e.do(t, http.MethodPut, "/api/v1/users/"+created.ID, map[string]any{"isAdmin": true}, admin); rec.Code != http.StatusNoContent { t.Fatalf("promote bob = %d: %s", rec.Code, rec.Body.String()) } // Demoting the last remaining admin is refused (admin would demote self // only if bob was the other admin — with two admins, demote works). if rec := e.do(t, http.MethodPut, "/api/v1/users/"+created.ID, map[string]any{"isAdmin": false}, admin); rec.Code != http.StatusNoContent { t.Fatalf("demote bob with 2 admins = %d, want 204", rec.Code) } // Deleting your own account is refused. selfID := func() string { var id string if err := e.db.QueryRow(`SELECT id FROM users WHERE username = 'admin'`).Scan(&id); err != nil { t.Fatalf("finding admin id: %v", err) } return id }() if rec := e.do(t, http.MethodDelete, "/api/v1/users/"+selfID, nil, admin); rec.Code != http.StatusBadRequest { t.Fatalf("self delete = %d, want 400", rec.Code) } } // --- preferences --- func TestPreferencesPartialUpdate(t *testing.T) { e := newTestEnv(t) cookie := e.loginAs(t, "admin", "admin@example.com", "correct horse battery", true) // Defaults before any save. rec := e.get(t, "/api/v1/auth/me/preferences", cookie) var prefs map[string]any _ = json.NewDecoder(rec.Body).Decode(&prefs) if prefs["theme"] != "system" || prefs["accent"] != "oxide" { t.Fatalf("unexpected defaults: %+v", prefs) } // Setting only the accent must not reset theme back to a default. if rec := e.do(t, http.MethodPut, "/api/v1/auth/me/preferences", map[string]any{"theme": "dark"}, cookie); rec.Code != http.StatusOK { t.Fatalf("set theme = %d", rec.Code) } if rec := e.do(t, http.MethodPut, "/api/v1/auth/me/preferences", map[string]any{"accent": "violet"}, cookie); rec.Code != http.StatusOK { t.Fatalf("set accent = %d", rec.Code) } rec = e.get(t, "/api/v1/auth/me/preferences", cookie) _ = json.NewDecoder(rec.Body).Decode(&prefs) if prefs["theme"] != "dark" || prefs["accent"] != "violet" { t.Fatalf("partial updates clobbered each other: %+v", prefs) } // Pointing the active dashboard at a nonexistent (or foreign) id is refused. if rec := e.do(t, http.MethodPut, "/api/v1/auth/me/preferences", map[string]any{"activeDashboardId": "does-not-exist"}, cookie); rec.Code != http.StatusBadRequest { t.Fatalf("bogus active dashboard = %d, want 400", rec.Code) } // A real, owned dashboard id is accepted. e.get(t, "/api/v1/dashboards/", cookie) // seed the account's first dashboard var list []map[string]any rec = e.get(t, "/api/v1/dashboards/", cookie) _ = json.NewDecoder(rec.Body).Decode(&list) id := list[0]["id"].(string) if rec := e.do(t, http.MethodPut, "/api/v1/auth/me/preferences", map[string]any{"activeDashboardId": id}, cookie); rec.Code != http.StatusOK { t.Fatalf("set active dashboard = %d: %s", rec.Code, rec.Body.String()) } // Deleting a second dashboard that isn't active leaves the pointer alone; // deleting the active one clears it back to "" (client falls back to the // oldest remaining dashboard). rec = e.do(t, http.MethodPost, "/api/v1/dashboards/", map[string]any{"name": "Second"}, cookie) var second struct { ID string `json:"id"` } _ = json.NewDecoder(rec.Body).Decode(&second) e.do(t, http.MethodDelete, "/api/v1/dashboards/"+id, nil, cookie) rec = e.get(t, "/api/v1/auth/me/preferences", cookie) _ = json.NewDecoder(rec.Body).Decode(&prefs) if prefs["activeDashboardId"] != "" { t.Fatalf("active dashboard pointer not cleared after delete: %+v", prefs) } } // --- dashboards --- func TestDashboardLayoutValidation(t *testing.T) { e := newTestEnv(t) cookie := e.loginAs(t, "admin", "admin@example.com", "correct horse battery", true) // A brand-new account is seeded with one "Main" dashboard with widgets. rec := e.get(t, "/api/v1/dashboards/", cookie) if rec.Code != http.StatusOK { t.Fatalf("list dashboards = %d", rec.Code) } var list []map[string]any if err := json.NewDecoder(rec.Body).Decode(&list); err != nil || len(list) != 1 { t.Fatalf("expected one seeded dashboard: %v %v", err, list) } id := list[0]["id"].(string) rec = e.get(t, "/api/v1/dashboards/"+id, cookie) if rec.Code != http.StatusOK { t.Fatalf("get dashboard = %d", rec.Code) } var dash struct { Widgets []map[string]any `json:"widgets"` } if err := json.NewDecoder(rec.Body).Decode(&dash); err != nil || len(dash.Widgets) == 0 { t.Fatalf("default layout missing widgets: %v", err) } // Garbage shapes are refused. if rec := e.do(t, http.MethodPut, "/api/v1/dashboards/"+id, map[string]any{"widgets": "nope"}, cookie); rec.Code != http.StatusBadRequest { t.Fatalf("invalid layout = %d, want 400", rec.Code) } if rec := e.do(t, http.MethodPut, "/api/v1/dashboards/"+id, map[string]any{ "widgets": []map[string]any{{"id": "x", "type": "y", "x": -5, "y": 0, "w": 6, "h": 4}}, }, cookie); rec.Code != http.StatusBadRequest { t.Fatalf("negative coords = %d, want 400", rec.Code) } // A valid layout round-trips. valid := map[string]any{ "widgets": []map[string]any{{"id": "w1", "type": "fleet-overview", "x": 0, "y": 0, "w": 12, "h": 4}}, } if rec := e.do(t, http.MethodPut, "/api/v1/dashboards/"+id, valid, cookie); rec.Code != http.StatusOK { t.Fatalf("valid layout put = %d: %s", rec.Code, rec.Body.String()) } if rec := e.get(t, "/api/v1/dashboards/"+id, cookie); rec.Code != http.StatusOK { var got struct { Widgets []map[string]any `json:"widgets"` } _ = json.NewDecoder(rec.Body).Decode(&got) if len(got.Widgets) != 1 { t.Fatalf("layout did not round-trip: %s", rec.Body.String()) } } } func TestMultipleDashboards(t *testing.T) { e := newTestEnv(t) cookie := e.loginAs(t, "admin", "admin@example.com", "correct horse battery", true) // Seed the first ("Main") dashboard. e.get(t, "/api/v1/dashboards/", cookie) rec := e.do(t, http.MethodPost, "/api/v1/dashboards/", map[string]any{"name": "Capacity planning"}, cookie) if rec.Code != http.StatusCreated { t.Fatalf("create dashboard = %d: %s", rec.Code, rec.Body.String()) } var created struct { ID string `json:"id"` Name string `json:"name"` Widgets []map[string]any `json:"widgets"` } if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { t.Fatalf("decode created dashboard: %v", err) } if created.Name != "Capacity planning" || created.Widgets == nil || len(created.Widgets) != 0 { t.Fatalf("unexpected created dashboard: %+v", created) } // An empty name is refused. if rec := e.do(t, http.MethodPost, "/api/v1/dashboards/", map[string]any{"name": " "}, cookie); rec.Code != http.StatusBadRequest { t.Fatalf("blank name = %d, want 400", rec.Code) } // Renaming persists. if rec := e.do(t, http.MethodPut, "/api/v1/dashboards/"+created.ID, map[string]any{"name": "Renamed"}, cookie); rec.Code != http.StatusOK { t.Fatalf("rename dashboard = %d: %s", rec.Code, rec.Body.String()) } rec = e.get(t, "/api/v1/dashboards/", cookie) var list []map[string]any _ = json.NewDecoder(rec.Body).Decode(&list) if len(list) != 2 { t.Fatalf("expected 2 dashboards, got %d", len(list)) } // Deleting your only dashboard is refused; deleting one of several isn't. mainID := "" for _, d := range list { if d["id"] != created.ID { mainID = d["id"].(string) } } if rec := e.do(t, http.MethodDelete, "/api/v1/dashboards/"+created.ID, nil, cookie); rec.Code != http.StatusNoContent { t.Fatalf("delete second dashboard = %d: %s", rec.Code, rec.Body.String()) } if rec := e.do(t, http.MethodDelete, "/api/v1/dashboards/"+mainID, nil, cookie); rec.Code != http.StatusBadRequest { t.Fatalf("delete only dashboard = %d, want 400", rec.Code) } // A deleted or foreign id 404s rather than leaking existence. if rec := e.get(t, "/api/v1/dashboards/"+created.ID, cookie); rec.Code != http.StatusNotFound { t.Fatalf("get deleted dashboard = %d, want 404", rec.Code) } } // --- rate limiting --- func TestLoginRateLimitEngagesAndRecovers(t *testing.T) { e := newTestEnv(t) e.loginAs(t, "admin", "admin@example.com", "correct horse battery", true) // Hammer with wrong passwords until the limiter locks the key. var sawThrottle bool for i := 0; i < loginMaxFailures+3; i++ { rec := e.do(t, http.MethodPost, "/api/v1/auth/login", map[string]string{"username": "admin", "password": "wrong"}, nil) if rec.Code == http.StatusTooManyRequests { sawThrottle = true if rec.Header().Get("Retry-After") == "" { t.Fatal("429 response missing Retry-After header") } break } } if !sawThrottle { t.Fatal("rate limiter never engaged after repeated failures") } // The correct password is also throttled while locked out (the point of // a limiter: the attacker can't bypass it by guessing right). rec := e.do(t, http.MethodPost, "/api/v1/auth/login", map[string]string{"username": "admin", "password": "correct horse battery"}, nil) if rec.Code != http.StatusTooManyRequests { t.Fatalf("locked-out correct login = %d, want 429", rec.Code) } // Wind the clock past the window — the limiter must recover. if rec := e.do(t, http.MethodPost, "/api/v1/auth/login", map[string]string{"username": "admin", "password": "correct horse battery"}, nil); rec.Code == http.StatusTooManyRequests { // Travel the lockout forward and try once more. time.Sleep(0) // no real waiting in tests; move the window manually e.server.logins.mu.Lock() for _, r := range e.server.logins.failed { r.firstFailure = r.firstFailure.Add(-2 * loginWindow) r.lockedUntil = time.Time{} } e.server.logins.mu.Unlock() rec = e.do(t, http.MethodPost, "/api/v1/auth/login", map[string]string{"username": "admin", "password": "correct horse battery"}, nil) if rec.Code != http.StatusOK { t.Fatalf("post-window correct login = %d, want 200", rec.Code) } } } func TestLoginLimiterUnit(t *testing.T) { l := newLoginLimiter() key := "1.2.3.4|alice" for i := 0; i < loginMaxFailures; i++ { if allowed, _ := l.Allowed(key); !allowed { t.Fatalf("attempt %d should still be allowed", i+1) } l.RecordFailure(key) } if allowed, retryAfter := l.Allowed(key); allowed || retryAfter <= 0 { t.Fatal("limiter should be locked after maxFailures") } l.RecordSuccess(key) if allowed, _ := l.Allowed(key); !allowed { t.Fatal("RecordSuccess should clear the lockout") } } // --- alert silence authorization --- // seedActiveAlert inserts a live alert_instances row (plus the rule row the // foreign key needs) directly — the poller isn't running in these tests. func seedActiveAlert(t *testing.T, db *store.DB, id string) { t.Helper() if _, err := db.Exec(`INSERT INTO alert_rules (id, name, metric, connection_id, threshold, severity, enabled, created_at) VALUES ('rule-1', 'cpu', 'guest_cpu', NULL, 80, 'warning', 1, '2026-01-01T00:00:00Z')`); err != nil { t.Fatalf("seeding alert rule: %v", err) } if _, err := db.Exec(`INSERT INTO alert_instances (id, rule_id, connection_id, connection_name, resource_id, resource_name, metric, value, threshold, severity, status, triggered_at, updated_at) VALUES (?, 'rule-1', 'conn-1', 'pve', 'conn-1|qemu/100', 'web01', 'guest_cpu', 95, 80, 'warning', 'active', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, id); err != nil { t.Fatalf("seeding alert instance: %v", err) } } func TestAlertSilenceRequiresAdminAndExistingAlert(t *testing.T) { e := newTestEnv(t) admin := e.loginAs(t, "admin", "admin@example.com", "correct horse battery", true) _ = e.do(t, http.MethodPost, "/api/v1/users/", map[string]any{ "username": "bob", "email": "bob@example.com", "password": "bob's own password", }, admin) bob := e.loginAs(t, "bob", "bob@example.com", "bob's own password", false) seedActiveAlert(t, e.db, "alert-1") // Alerts are readable by any authenticated user... if rec := e.get(t, "/api/v1/alerts/", bob); rec.Code != http.StatusOK { t.Fatalf("non-admin GET alerts = %d, want 200", rec.Code) } // ...but silencing one is a mutation and needs admin. if rec := e.do(t, http.MethodPost, "/api/v1/alerts/alert-1/silence", nil, bob); rec.Code != http.StatusForbidden { t.Fatalf("non-admin silence = %d, want 403", rec.Code) } var status string if err := e.db.QueryRow(`SELECT status FROM alert_instances WHERE id = 'alert-1'`).Scan(&status); err != nil { t.Fatalf("reading alert status: %v", err) } if status != "active" { t.Fatalf("refused silence changed the alert status to %q", status) } if rec := e.do(t, http.MethodPost, "/api/v1/alerts/alert-1/silence", nil, admin); rec.Code != http.StatusOK { t.Fatalf("admin silence = %d, want 200: %s", rec.Code, rec.Body.String()) } // An unknown id is a 404, not a silent success. if rec := e.do(t, http.MethodPost, "/api/v1/alerts/does-not-exist/silence", nil, admin); rec.Code != http.StatusNotFound { t.Fatalf("unknown alert silence = %d, want 404", rec.Code) } if got := decode[map[string]string](t, e.do(t, http.MethodPost, "/api/v1/alerts/does-not-exist/silence", nil, admin))["error"]; got != "alert not found" { t.Fatalf("unknown alert error message = %q, want %q", got, "alert not found") } }