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:
Tomas DvorakandDevin committed 2026-09-22 12:49:44 +02:00
1 parent 8973be3b41
commit f3143af351
33 files changed
+2772 -126

No files matched your search

+196
View File
@@ -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)
}
}