mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
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>
197 lines
6.4 KiB
Go
197 lines
6.4 KiB
Go
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)
|
|
}
|
|
}
|