mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 15:51:27 +00:00
feat(api): phase 5 — alerts, agent depth, subdomains, auth, boards
P5b: notifier transport registry (webhook/slack/discord/telegram/ gotify/ntfy/smtp via net/smtp), per-monitor+domain alert rules (failsBeforeDown, latencyWarn, certDays, mute). P5c: per-container cpu/mem, optional SMART/ZFS/GPU exec-or-skip collectors, crt.sh subdomain discovery with daily sweep. P5d: opt-in auth (bcrypt, sqlite sessions, 7d rotating cookie, login rate limit, public-route bypass) and multiple boards (boards table, sections.board_id, /api/boards CRUD, slug routing). Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
8973be3b41
commit
f3143af351
33 files changed
+2772
-126
No files matched your search
@@ -0,0 +1,196 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tdvorak/dash/internal/db"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// authServer returns the bare server (no httptest) so tests can plant
|
||||
// sessions and inspect cookies directly.
|
||||
func authServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
d, err := db.Open(filepath.Join(t.TempDir(), "dash.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
gin.SetMode(gin.TestMode)
|
||||
// Login rate limits are keyed by IP in a package-global map — reset so
|
||||
// tests don't count each other's attempts.
|
||||
loginMu.Lock()
|
||||
loginHits = map[string][]time.Time{}
|
||||
loginMu.Unlock()
|
||||
return newServer(zap.NewNop(), d, t.TempDir())
|
||||
}
|
||||
|
||||
func req(t *testing.T, s *Server, method, path, body, cookie string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if cookie != "" {
|
||||
r.Header.Set("Cookie", sessionCookie+"="+cookie)
|
||||
}
|
||||
s.routes().ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func sessionCookieOf(w *httptest.ResponseRecorder) string {
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == sessionCookie {
|
||||
return c.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func enableAuth(t *testing.T, s *Server) {
|
||||
t.Helper()
|
||||
if w := req(t, s, "PUT", "/api/settings", `{"auth_enabled":true}`, ""); w.Code != 200 {
|
||||
t.Fatalf("enable auth: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthDisabledByDefault(t *testing.T) {
|
||||
s := authServer(t)
|
||||
if w := req(t, s, "GET", "/api/sections", "", ""); w.Code != 200 {
|
||||
t.Fatalf("sections open when auth off: %d", w.Code)
|
||||
}
|
||||
var out map[string]any
|
||||
w := req(t, s, "GET", "/api/auth/session", "", "")
|
||||
_ = json.NewDecoder(w.Body).Decode(&out)
|
||||
if out["enabled"] != false {
|
||||
t.Fatalf("auth should report disabled: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLifecycle(t *testing.T) {
|
||||
s := authServer(t)
|
||||
enableAuth(t, s)
|
||||
|
||||
// Everything gates now.
|
||||
if w := req(t, s, "GET", "/api/sections", "", ""); w.Code != 401 {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
// Session endpoint stays public and reports setup mode.
|
||||
var sess map[string]any
|
||||
w := req(t, s, "GET", "/api/auth/session", "", "")
|
||||
_ = json.NewDecoder(w.Body).Decode(&sess)
|
||||
if sess["enabled"] != true || sess["setup"] != true || sess["authed"] != false {
|
||||
t.Fatalf("bad session payload: %v", sess)
|
||||
}
|
||||
// Public surfaces stay open even gated.
|
||||
for _, p := range []string{"/api/healthz", "/api/metrics", "/api/badge/uptime/x.svg"} {
|
||||
if w := req(t, s, "GET", p, "", ""); w.Code == 401 {
|
||||
t.Fatalf("public path %s gated", p)
|
||||
}
|
||||
}
|
||||
|
||||
// Setup creates the first admin and returns a session cookie.
|
||||
w = req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
|
||||
if w.Code != 201 {
|
||||
t.Fatalf("setup: %d body=%s", w.Code, w.Body)
|
||||
}
|
||||
cookie := sessionCookieOf(w)
|
||||
if cookie == "" {
|
||||
t.Fatal("setup did not set a session cookie")
|
||||
}
|
||||
// Second setup blocked.
|
||||
if w := req(t, s, "POST", "/api/auth/setup", `{"username":"x","password":"another-pass"}`, ""); w.Code != 409 {
|
||||
t.Fatalf("second setup should 409, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Authed request with the cookie.
|
||||
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 200 {
|
||||
t.Fatalf("authed request: %d", w.Code)
|
||||
}
|
||||
|
||||
// Logout kills the session.
|
||||
req(t, s, "POST", "/api/auth/logout", "", cookie)
|
||||
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 401 {
|
||||
t.Fatalf("expected 401 after logout, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Login wrong password -> 401; right -> 200 + fresh cookie.
|
||||
if w := req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"wrong-password"}`, ""); w.Code != 401 {
|
||||
t.Fatalf("bad login should 401, got %d", w.Code)
|
||||
}
|
||||
w = req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"correct-horse"}`, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("login: %d", w.Code)
|
||||
}
|
||||
if sessionCookieOf(w) == "" {
|
||||
t.Fatal("login set no cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRateLimit(t *testing.T) {
|
||||
s := authServer(t)
|
||||
enableAuth(t, s)
|
||||
req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
|
||||
req(t, s, "POST", "/api/auth/logout", "", "")
|
||||
for i := 0; i < 5; i++ {
|
||||
req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"nope-nope"}`, "")
|
||||
}
|
||||
if w := req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"correct-horse"}`, ""); w.Code != 429 {
|
||||
t.Fatalf("6th login should be rate-limited, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSessionExpiryAndRotation(t *testing.T) {
|
||||
s := authServer(t)
|
||||
enableAuth(t, s)
|
||||
w := req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
|
||||
cookie := sessionCookieOf(w)
|
||||
|
||||
// Expired session -> 401.
|
||||
_, _ = s.db.Exec(`UPDATE sessions SET expires_at = ? WHERE token = ?`,
|
||||
time.Now().Add(-time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"), cookie)
|
||||
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 401 {
|
||||
t.Fatalf("expired session should 401, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Near-expiry session rotates: response carries a new cookie.
|
||||
fresh := req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"correct-horse"}`, "")
|
||||
cookie = sessionCookieOf(fresh)
|
||||
_, _ = s.db.Exec(`UPDATE sessions SET expires_at = ? WHERE token = ?`,
|
||||
time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"), cookie)
|
||||
w = req(t, s, "GET", "/api/sections", "", cookie)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("rotating request should pass, got %d", w.Code)
|
||||
}
|
||||
newCookie := sessionCookieOf(w)
|
||||
if newCookie == "" || newCookie == cookie {
|
||||
t.Fatal("expected session rotation to issue a new token")
|
||||
}
|
||||
// Old token is dead.
|
||||
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 401 {
|
||||
t.Fatalf("old token should be invalid after rotation, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthDisable(t *testing.T) {
|
||||
s := authServer(t)
|
||||
enableAuth(t, s)
|
||||
w := req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
|
||||
cookie := sessionCookieOf(w)
|
||||
|
||||
// Unauthenticated disable is refused.
|
||||
if w := req(t, s, "POST", "/api/auth/disable", "", ""); w.Code != 401 {
|
||||
t.Fatalf("disable without session should 401, got %d", w.Code)
|
||||
}
|
||||
if w := req(t, s, "POST", "/api/auth/disable", "", cookie); w.Code != 204 {
|
||||
t.Fatalf("disable: %d", w.Code)
|
||||
}
|
||||
if w := req(t, s, "GET", "/api/sections", "", ""); w.Code != 200 {
|
||||
t.Fatalf("sections should reopen after disable, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user