test: expand security regression coverage

This commit is contained in:
rcourtman 2026-02-04 10:28:41 +00:00
parent 4bebd2f576
commit b9eee668e5
16 changed files with 2644 additions and 0 deletions

View file

@ -0,0 +1,123 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
func TestExtractAndStoreAuthContext_APITokenHeader(t *testing.T) {
rawToken := "ctx-token-123.12345678"
record, err := config.NewAPITokenRecord(rawToken, "ctx-token", []string{config.ScopeMonitoringRead})
if err != nil {
t.Fatalf("new token record: %v", err)
}
cfg := &config.Config{
APITokens: []config.APITokenRecord{*record},
}
cfg.SortAPITokens()
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Header.Set("X-API-Token", rawToken)
req = extractAndStoreAuthContext(cfg, nil, req)
user := internalauth.GetUser(req.Context())
if user != "token:"+record.ID {
t.Fatalf("expected user token:%s, got %q", record.ID, user)
}
ctxRecord, ok := internalauth.GetAPIToken(req.Context()).(*config.APITokenRecord)
if !ok || ctxRecord == nil {
t.Fatalf("expected API token record in context")
}
if ctxRecord.ID != record.ID {
t.Fatalf("expected context token ID %q, got %q", record.ID, ctxRecord.ID)
}
}
func TestExtractAndStoreAuthContext_BearerToken(t *testing.T) {
rawToken := "ctx-bearer-123.12345678"
record, err := config.NewAPITokenRecord(rawToken, "ctx-token", []string{config.ScopeMonitoringRead})
if err != nil {
t.Fatalf("new token record: %v", err)
}
cfg := &config.Config{
APITokens: []config.APITokenRecord{*record},
}
cfg.SortAPITokens()
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Header.Set("Authorization", "Bearer "+rawToken)
req = extractAndStoreAuthContext(cfg, nil, req)
user := internalauth.GetUser(req.Context())
if user != "token:"+record.ID {
t.Fatalf("expected user token:%s, got %q", record.ID, user)
}
}
func TestExtractAndStoreAuthContext_QueryTokenRequiresWebSocketUpgrade(t *testing.T) {
rawToken := "ctx-query-123.12345678"
record, err := config.NewAPITokenRecord(rawToken, "ctx-token", []string{config.ScopeMonitoringRead})
if err != nil {
t.Fatalf("new token record: %v", err)
}
cfg := &config.Config{
APITokens: []config.APITokenRecord{*record},
}
cfg.SortAPITokens()
req := httptest.NewRequest(http.MethodGet, "/api/test?token="+rawToken, nil)
req = extractAndStoreAuthContext(cfg, nil, req)
if internalauth.GetUser(req.Context()) != "" {
t.Fatalf("expected no user context without WebSocket upgrade")
}
wsReq := httptest.NewRequest(http.MethodGet, "/api/test?token="+rawToken, nil)
wsReq.Header.Set("Upgrade", "websocket")
wsReq.Header.Set("Connection", "Upgrade")
wsReq = extractAndStoreAuthContext(cfg, nil, wsReq)
if internalauth.GetUser(wsReq.Context()) != "token:"+record.ID {
t.Fatalf("expected user context for WebSocket query token")
}
}
func TestExtractAndStoreAuthContext_SessionCookie(t *testing.T) {
dir := t.TempDir()
InitSessionStore(dir)
sessionToken := generateSessionToken()
GetSessionStore().CreateSession(sessionToken, time.Hour, "agent", "127.0.0.1", "alice")
cfg := &config.Config{}
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
req = extractAndStoreAuthContext(cfg, nil, req)
if internalauth.GetUser(req.Context()) != "alice" {
t.Fatalf("expected user alice, got %q", internalauth.GetUser(req.Context()))
}
}
func TestExtractAndStoreAuthContext_ProxyAuth(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "proxy-secret",
ProxyAuthUserHeader: "X-Proxy-User",
}
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Header.Set("X-Proxy-Secret", "proxy-secret")
req.Header.Set("X-Proxy-User", "proxyuser")
req = extractAndStoreAuthContext(cfg, nil, req)
if internalauth.GetUser(req.Context()) != "proxyuser" {
t.Fatalf("expected proxy user context, got %q", internalauth.GetUser(req.Context()))
}
}

View file

@ -0,0 +1,60 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
func TestExtractAndStoreAuthContext_UsesTenantConfigForToken(t *testing.T) {
t.Setenv("PULSE_MOCK_MODE", "true")
mock.SetEnabled(true)
t.Cleanup(func() { mock.SetEnabled(false) })
baseCfg := &config.Config{
DataPath: t.TempDir(),
ConfigPath: t.TempDir(),
}
mtPersistence := config.NewMultiTenantPersistence(t.TempDir())
mtm := monitoring.NewMultiTenantMonitor(baseCfg, mtPersistence, nil)
t.Cleanup(mtm.Stop)
tenantID := "org-1"
_, err := mtPersistence.GetPersistence(tenantID)
if err != nil {
t.Fatalf("tenant persistence: %v", err)
}
rawToken := "tenant-token-123.12345678"
record, err := config.NewAPITokenRecord(rawToken, "tenant", []string{config.ScopeMonitoringRead})
if err != nil {
t.Fatalf("new token record: %v", err)
}
baseCfg.APITokens = []config.APITokenRecord{*record}
baseCfg.SortAPITokens()
// Ensure tenant monitor/config is initialized
if _, err := mtm.GetMonitor(tenantID); err != nil {
t.Fatalf("get tenant monitor: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Header.Set("X-Pulse-Org-ID", tenantID)
req.Header.Set("X-API-Token", rawToken)
req = extractAndStoreAuthContext(baseCfg, mtm, req)
user := internalauth.GetUser(req.Context())
if user == "" {
t.Fatalf("expected user context to be set")
}
if internalauth.GetAPIToken(req.Context()) == nil {
t.Fatalf("expected API token record in context")
}
}

View file

@ -0,0 +1,69 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestRequireAdminProxyAuthRejectsNonAdmin(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "proxy-secret",
ProxyAuthUserHeader: "X-Proxy-User",
ProxyAuthRoleHeader: "X-Proxy-Roles",
ProxyAuthAdminRole: "admin",
}
handlerCalled := false
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusNoContent)
})
req := httptest.NewRequest(http.MethodGet, "/api/admin/test", nil)
req.Header.Set("X-Proxy-Secret", "proxy-secret")
req.Header.Set("X-Proxy-User", "alice")
req.Header.Set("X-Proxy-Roles", "viewer|user")
rec := httptest.NewRecorder()
handler(rec, req)
if handlerCalled {
t.Fatalf("handler should not be called for non-admin proxy user")
}
if rec.Code != http.StatusForbidden {
t.Fatalf("expected status %d, got %d (%s)", http.StatusForbidden, rec.Code, rec.Body.String())
}
}
func TestRequireAdminProxyAuthAllowsAdmin(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "proxy-secret",
ProxyAuthUserHeader: "X-Proxy-User",
ProxyAuthRoleHeader: "X-Proxy-Roles",
ProxyAuthAdminRole: "admin",
}
handlerCalled := false
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusNoContent)
})
req := httptest.NewRequest(http.MethodGet, "/api/admin/test", nil)
req.Header.Set("X-Proxy-Secret", "proxy-secret")
req.Header.Set("X-Proxy-User", "alice")
req.Header.Set("X-Proxy-Roles", "viewer|admin")
rec := httptest.NewRecorder()
handler(rec, req)
if !handlerCalled {
t.Fatalf("expected handler to be called for admin proxy user")
}
if rec.Code != http.StatusNoContent {
t.Fatalf("expected status %d, got %d (%s)", http.StatusNoContent, rec.Code, rec.Body.String())
}
}

View file

@ -0,0 +1,163 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
func newRouterWithSession(t *testing.T) (*Router, string) {
t.Helper()
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
resetTrustedProxyConfig()
dir := t.TempDir()
InitSessionStore(dir)
InitCSRFStore(dir)
hashed, err := internalauth.HashPassword("Password!1")
if err != nil {
t.Fatalf("hash password: %v", err)
}
cfg := &config.Config{
AuthUser: "admin",
AuthPass: hashed,
DataPath: dir,
ConfigPath: dir,
}
router := &Router{
mux: http.NewServeMux(),
config: cfg,
}
token := generateSessionToken()
GetSessionStore().CreateSession(token, time.Hour, "test-agent", "127.0.0.1", "admin")
return router, token
}
func TestRouterCSRFEnforcedForSessionRequests(t *testing.T) {
router, sessionToken := newRouterWithSession(t)
router.mux.HandleFunc("/api/secure", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodPost, "/api/secure", nil)
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected status %d, got %d (%s)", http.StatusForbidden, rec.Code, rec.Body.String())
}
}
func TestRouterCSRFAllowsValidToken(t *testing.T) {
router, sessionToken := newRouterWithSession(t)
router.mux.HandleFunc("/api/secure", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
csrfToken := generateCSRFToken(sessionToken)
req := httptest.NewRequest(http.MethodPost, "/api/secure", nil)
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
req.Header.Set("X-CSRF-Token", csrfToken)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d (%s)", http.StatusOK, rec.Code, rec.Body.String())
}
}
func TestRouterCSRFSpecialCaseSkips(t *testing.T) {
router, sessionToken := newRouterWithSession(t)
skipPaths := []string{
"/api/login",
"/api/security/quick-setup",
"/api/security/validate-bootstrap-token",
"/api/setup-script-url",
}
for _, path := range skipPaths {
router.mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodPost, path, nil)
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("path %s: expected status %d, got %d (%s)", path, http.StatusOK, rec.Code, rec.Body.String())
}
}
}
func TestRouterIssuesCSRFCookieForGetWithSession(t *testing.T) {
router, sessionToken := newRouterWithSession(t)
router.mux.HandleFunc("/api/ping", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d (%s)", http.StatusOK, rec.Code, rec.Body.String())
}
var csrfCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == "pulse_csrf" {
csrfCookie = c
break
}
}
if csrfCookie == nil {
t.Fatalf("expected pulse_csrf cookie to be set")
}
if csrfCookie.Value == "" {
t.Fatalf("expected pulse_csrf cookie to have a value")
}
}
func TestRouterDoesNotIssueCSRFCookieWithoutSession(t *testing.T) {
router, _ := newRouterWithSession(t)
router.mux.HandleFunc("/api/ping", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d (%s)", http.StatusUnauthorized, rec.Code, rec.Body.String())
}
for _, c := range rec.Result().Cookies() {
if c.Name == "pulse_csrf" {
t.Fatalf("did not expect pulse_csrf cookie to be set")
}
}
}

View file

@ -0,0 +1,102 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
func TestCSRFSkippedForValidateBootstrapToken(t *testing.T) {
t.Setenv("PULSE_DATA_DIR", t.TempDir())
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
resetTrustedProxyConfig()
dataDir := t.TempDir()
cfg := &config.Config{
DataPath: dataDir,
ConfigPath: dataDir,
}
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
if router.configHandlers != nil {
router.configHandlers.SetConfig(cfg)
}
tokenPath := filepath.Join(cfg.DataPath, bootstrapTokenFilename)
content, err := os.ReadFile(tokenPath)
if err != nil {
t.Fatalf("read bootstrap token: %v", err)
}
token := strings.TrimSpace(string(content))
if token == "" {
t.Fatalf("bootstrap token should not be empty")
}
// Create a session to ensure CSRF would be required if not skipped.
sessionToken := generateSessionToken()
GetSessionStore().CreateSession(sessionToken, time.Hour, "agent", "127.0.0.1", "admin")
body := map[string]string{"token": token}
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", bytes.NewReader(payload))
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected status %d, got %d (%s)", http.StatusNoContent, rec.Code, rec.Body.String())
}
}
func TestCSRFSkippedForSetupScriptURL(t *testing.T) {
t.Setenv("PULSE_DATA_DIR", t.TempDir())
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
resetTrustedProxyConfig()
dataDir := t.TempDir()
hashed, err := internalauth.HashPassword("Password!1")
if err != nil {
t.Fatalf("hash password: %v", err)
}
cfg := &config.Config{
AuthUser: "admin",
AuthPass: hashed,
DataPath: dataDir,
ConfigPath: dataDir,
}
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
if router.configHandlers != nil {
router.configHandlers.SetConfig(cfg)
}
sessionToken := generateSessionToken()
GetSessionStore().CreateSession(sessionToken, time.Hour, "agent", "127.0.0.1", "admin")
body := `{"type":"pve"}`
req := httptest.NewRequest(http.MethodPost, "/api/setup-script-url", strings.NewReader(body))
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d (%s)", http.StatusOK, rec.Code, rec.Body.String())
}
}

View file

@ -0,0 +1,93 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func newProxyAuthRouter(t *testing.T) *Router {
t.Helper()
t.Setenv("PULSE_DATA_DIR", t.TempDir())
cfg := &config.Config{
ProxyAuthSecret: "proxy-secret",
ProxyAuthUserHeader: "X-Proxy-User",
ProxyAuthRoleHeader: "X-Proxy-Roles",
ProxyAuthAdminRole: "admin",
DataPath: t.TempDir(),
ConfigPath: t.TempDir(),
EnvOverrides: make(map[string]bool),
}
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
if router.configHandlers != nil {
router.configHandlers.SetConfig(cfg)
}
return router
}
func TestProxyAuthAdminRouteConfigSystem(t *testing.T) {
router := newProxyAuthRouter(t)
t.Run("non-admin forbidden", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/config/system", nil)
req.Header.Set("X-Proxy-Secret", "proxy-secret")
req.Header.Set("X-Proxy-User", "viewer")
req.Header.Set("X-Proxy-Roles", "viewer|user")
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected status %d, got %d (%s)", http.StatusForbidden, rec.Code, rec.Body.String())
}
})
t.Run("admin allowed", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/config/system", nil)
req.Header.Set("X-Proxy-Secret", "proxy-secret")
req.Header.Set("X-Proxy-User", "adminuser")
req.Header.Set("X-Proxy-Roles", "viewer|admin")
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d (%s)", http.StatusOK, rec.Code, rec.Body.String())
}
})
}
func TestProxyAuthAdminRouteLicenseStatus(t *testing.T) {
router := newProxyAuthRouter(t)
t.Run("non-admin forbidden", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/license/status", nil)
req.Header.Set("X-Proxy-Secret", "proxy-secret")
req.Header.Set("X-Proxy-User", "viewer")
req.Header.Set("X-Proxy-Roles", "viewer|user")
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected status %d, got %d (%s)", http.StatusForbidden, rec.Code, rec.Body.String())
}
})
t.Run("admin allowed", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/license/status", nil)
req.Header.Set("X-Proxy-Secret", "proxy-secret")
req.Header.Set("X-Proxy-User", "adminuser")
req.Header.Set("X-Proxy-Roles", "viewer|admin")
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d (%s)", http.StatusOK, rec.Code, rec.Body.String())
}
})
}

View file

@ -0,0 +1,107 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestProxyAuthNonAdminCannotResetLockout(t *testing.T) {
t.Setenv("PULSE_DATA_DIR", t.TempDir())
cfg := &config.Config{
ProxyAuthSecret: "proxy-secret",
ProxyAuthUserHeader: "X-Proxy-User",
ProxyAuthRoleHeader: "X-Proxy-Roles",
ProxyAuthAdminRole: "admin",
}
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
body, err := json.Marshal(map[string]string{"identifier": "user1"})
if err != nil {
t.Fatalf("marshal body: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/security/reset-lockout", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Proxy-Secret", "proxy-secret")
req.Header.Set("X-Proxy-User", "viewer")
req.Header.Set("X-Proxy-Roles", "viewer|user")
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected status %d, got %d (%s)", http.StatusForbidden, rec.Code, rec.Body.String())
}
}
func TestProxyAuthAdminCanResetLockoutWithSettingsScope(t *testing.T) {
t.Setenv("PULSE_DATA_DIR", t.TempDir())
rawToken := "proxy-admin-token-123.12345678"
record, err := config.NewAPITokenRecord(rawToken, "admin-token", []string{config.ScopeSettingsWrite})
if err != nil {
t.Fatalf("new token record: %v", err)
}
cfg := &config.Config{
ProxyAuthSecret: "proxy-secret",
ProxyAuthUserHeader: "X-Proxy-User",
ProxyAuthRoleHeader: "X-Proxy-Roles",
ProxyAuthAdminRole: "admin",
APITokens: []config.APITokenRecord{*record},
}
cfg.SortAPITokens()
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
body, err := json.Marshal(map[string]string{"identifier": "user1"})
if err != nil {
t.Fatalf("marshal body: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/security/reset-lockout", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Proxy-Secret", "proxy-secret")
req.Header.Set("X-Proxy-User", "adminuser")
req.Header.Set("X-Proxy-Roles", "viewer|admin")
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d (%s)", http.StatusOK, rec.Code, rec.Body.String())
}
}
func TestSecurityStatusAccessibleWithProxyAuth(t *testing.T) {
t.Setenv("PULSE_DATA_DIR", t.TempDir())
cfg := &config.Config{
ProxyAuthSecret: "proxy-secret",
ProxyAuthUserHeader: "X-Proxy-User",
ProxyAuthRoleHeader: "X-Proxy-Roles",
ProxyAuthAdminRole: "admin",
}
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil)
req.Header.Set("X-Proxy-Secret", "proxy-secret")
req.Header.Set("X-Proxy-User", "viewer")
req.Header.Set("X-Proxy-Roles", "viewer|user")
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d (%s)", http.StatusOK, rec.Code, rec.Body.String())
}
}

View file

@ -0,0 +1,59 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
func newLoginRouter(t *testing.T) *Router {
t.Helper()
hashed, err := auth.HashPassword("Password!1")
if err != nil {
t.Fatalf("hash password: %v", err)
}
cfg := &config.Config{
AuthUser: "admin",
AuthPass: hashed,
DataPath: t.TempDir(),
ConfigPath: t.TempDir(),
}
return NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
}
func TestLoginRateLimitEnforced(t *testing.T) {
router := newLoginRouter(t)
ip := "203.0.113.200"
authLimiter.Reset(ip)
defer authLimiter.Reset(ip)
body := `{"username":"admin","password":"Password!1","rememberMe":false}`
for i := 0; i < 10; i++ {
req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(body))
req.RemoteAddr = ip + ":12345"
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("attempt %d: expected %d, got %d (%s)", i+1, http.StatusOK, rec.Code, rec.Body.String())
}
}
req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(body))
req.RemoteAddr = ip + ":12345"
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected %d, got %d (%s)", http.StatusTooManyRequests, rec.Code, rec.Body.String())
}
}

View file

@ -0,0 +1,254 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
func newRecoveryRouter(t *testing.T) *Router {
t.Helper()
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
resetTrustedProxyConfig()
dir := t.TempDir()
hashed, err := internalauth.HashPassword("Password!1")
if err != nil {
t.Fatalf("hash password: %v", err)
}
cfg := &config.Config{
AuthUser: "admin",
AuthPass: hashed,
DataPath: dir,
ConfigPath: dir,
}
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
recoveryFile := filepath.Join(cfg.DataPath, ".auth_recovery")
if err := os.WriteFile(recoveryFile, []byte("recovery enabled"), 0600); err != nil {
t.Fatalf("write recovery file: %v", err)
}
return router
}
func TestAuthRecoveryAllowsDirectLoopback(t *testing.T) {
router := newRecoveryRouter(t)
router.mux.HandleFunc("/api/secure", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/api/secure", nil)
req.RemoteAddr = "127.0.0.1:12345"
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d (%s)", http.StatusOK, rec.Code, rec.Body.String())
}
if rec.Header().Get("X-Auth-Recovery") != "true" {
t.Fatalf("expected X-Auth-Recovery header to be set")
}
}
func TestAuthRecoveryRejectsForwardedLoopback(t *testing.T) {
router := newRecoveryRouter(t)
router.mux.HandleFunc("/api/secure", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/api/secure", nil)
req.RemoteAddr = "127.0.0.1:12345"
req.Header.Set("X-Forwarded-For", "127.0.0.1")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d (%s)", http.StatusUnauthorized, rec.Code, rec.Body.String())
}
}
func TestRecoveryEndpointDisableAuthRequiresLoopbackOrToken(t *testing.T) {
router := newRecoveryRouter(t)
req := httptest.NewRequest(http.MethodPost, "/api/security/recovery", strings.NewReader(`{"action":"disable_auth"}`))
req.RemoteAddr = "203.0.113.50:12345"
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected status %d, got %d (%s)", http.StatusForbidden, rec.Code, rec.Body.String())
}
}
func TestRecoveryEndpointDisableAuthAllowsLoopback(t *testing.T) {
router := newRecoveryRouter(t)
req := httptest.NewRequest(http.MethodPost, "/api/security/recovery", strings.NewReader(`{"action":"disable_auth"}`))
req.RemoteAddr = "127.0.0.1:12345"
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d (%s)", http.StatusOK, rec.Code, rec.Body.String())
}
}
func TestRecoveryEndpointDisableAuthAllowsValidToken(t *testing.T) {
router := newRecoveryRouter(t)
InitRecoveryTokenStore(router.config.DataPath)
token, err := GetRecoveryTokenStore().GenerateRecoveryToken(5 * time.Minute)
if err != nil {
t.Fatalf("generate recovery token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/security/recovery", strings.NewReader(`{"action":"disable_auth"}`))
req.RemoteAddr = "203.0.113.51:12345"
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Recovery-Token", token)
rec := httptest.NewRecorder()
router.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d (%s)", http.StatusOK, rec.Code, rec.Body.String())
}
}
func TestRecoveryEndpointEnableAuthRemovesFile(t *testing.T) {
router := newRecoveryRouter(t)
InitRecoveryTokenStore(router.config.DataPath)
token, err := GetRecoveryTokenStore().GenerateRecoveryToken(5 * time.Minute)
if err != nil {
t.Fatalf("generate recovery token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/security/recovery", strings.NewReader(`{"action":"enable_auth"}`))
req.RemoteAddr = "203.0.113.52:12345"
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Recovery-Token", token)
rec := httptest.NewRecorder()
router.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d (%s)", http.StatusOK, rec.Code, rec.Body.String())
}
recoveryFile := filepath.Join(router.config.DataPath, ".auth_recovery")
if _, err := os.Stat(recoveryFile); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected recovery file to be removed, got err=%v", err)
}
}
func TestRecoveryEndpointGenerateTokenRequiresLoopback(t *testing.T) {
router := newRecoveryRouter(t)
resetRecoveryStore()
InitRecoveryTokenStore(router.config.DataPath)
req := httptest.NewRequest(http.MethodPost, "/api/security/recovery", strings.NewReader(`{"action":"generate_token"}`))
req.RemoteAddr = "203.0.113.60:12345"
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected status %d, got %d (%s)", http.StatusForbidden, rec.Code, rec.Body.String())
}
}
func TestRecoveryEndpointGenerateTokenRejectsRemoteToken(t *testing.T) {
router := newRecoveryRouter(t)
resetRecoveryStore()
InitRecoveryTokenStore(router.config.DataPath)
token, err := GetRecoveryTokenStore().GenerateRecoveryToken(5 * time.Minute)
if err != nil {
t.Fatalf("generate recovery token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/security/recovery", strings.NewReader(`{"action":"generate_token"}`))
req.RemoteAddr = "203.0.113.61:12345"
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Recovery-Token", token)
rec := httptest.NewRecorder()
router.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected status %d, got %d (%s)", http.StatusForbidden, rec.Code, rec.Body.String())
}
}
func TestRecoveryEndpointGenerateTokenLoopbackSuccess(t *testing.T) {
router := newRecoveryRouter(t)
resetRecoveryStore()
InitRecoveryTokenStore(router.config.DataPath)
req := httptest.NewRequest(http.MethodPost, "/api/security/recovery", strings.NewReader(`{"action":"generate_token"}`))
req.RemoteAddr = "127.0.0.1:12345"
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d (%s)", http.StatusOK, rec.Code, rec.Body.String())
}
var payload map[string]interface{}
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if ok, _ := payload["success"].(bool); !ok {
t.Fatalf("expected success=true, got %#v", payload["success"])
}
if token, _ := payload["token"].(string); token == "" {
t.Fatalf("expected token in response, got %#v", payload["token"])
}
}
func TestRecoveryEndpointInvalidAction(t *testing.T) {
router := newRecoveryRouter(t)
req := httptest.NewRequest(http.MethodPost, "/api/security/recovery", strings.NewReader(`{"action":"not-valid"}`))
req.RemoteAddr = "127.0.0.1:12345"
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d (%s)", http.StatusOK, rec.Code, rec.Body.String())
}
var payload map[string]interface{}
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if ok, _ := payload["success"].(bool); ok {
t.Fatalf("expected success=false, got %#v", payload["success"])
}
if msg, _ := payload["message"].(string); !strings.Contains(msg, "Invalid action") {
t.Fatalf("unexpected message: %q", msg)
}
}

View file

@ -0,0 +1,195 @@
package api
import (
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/audit"
)
type auditCaptureLogger struct {
mu sync.Mutex
events []audit.Event
}
func (l *auditCaptureLogger) Log(event audit.Event) error {
l.mu.Lock()
defer l.mu.Unlock()
l.events = append(l.events, event)
return nil
}
func (l *auditCaptureLogger) Query(filter audit.QueryFilter) ([]audit.Event, error) {
l.mu.Lock()
defer l.mu.Unlock()
events := make([]audit.Event, len(l.events))
copy(events, l.events)
return events, nil
}
func (l *auditCaptureLogger) Count(filter audit.QueryFilter) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.events), nil
}
func (l *auditCaptureLogger) GetWebhookURLs() []string {
return nil
}
func (l *auditCaptureLogger) UpdateWebhookURLs(urls []string) error {
return nil
}
func (l *auditCaptureLogger) Close() error {
return nil
}
type auditErrorLogger struct{}
func (l *auditErrorLogger) Log(event audit.Event) error { return errors.New("log failed") }
func (l *auditErrorLogger) Query(filter audit.QueryFilter) ([]audit.Event, error) {
return nil, errors.New("query failed")
}
func (l *auditErrorLogger) Count(filter audit.QueryFilter) (int, error) {
return 0, errors.New("count failed")
}
func (l *auditErrorLogger) GetWebhookURLs() []string { return nil }
func (l *auditErrorLogger) UpdateWebhookURLs(urls []string) error { return errors.New("update failed") }
func (l *auditErrorLogger) Close() error { return nil }
type errorLoggerFactory struct{}
func (f *errorLoggerFactory) CreateLogger(dbPath string) (audit.Logger, error) {
return &auditErrorLogger{}, nil
}
func TestLogAuditEventForTenantFallsBackWhenManagerNil(t *testing.T) {
capture := &auditCaptureLogger{}
audit.SetLogger(capture)
SetTenantAuditManager(nil)
LogAuditEventForTenant("org-1", "event", "user", "1.2.3.4", "/path", false, "details")
if count, _ := capture.Count(audit.QueryFilter{}); count != 1 {
t.Fatalf("expected 1 audit event, got %d", count)
}
}
func TestLogAuditEventForTenantFallsBackOnError(t *testing.T) {
capture := &auditCaptureLogger{}
audit.SetLogger(capture)
manager := audit.NewTenantLoggerManager(t.TempDir(), &errorLoggerFactory{})
SetTenantAuditManager(manager)
LogAuditEventForTenant("org-2", "event", "user", "1.2.3.4", "/path", true, "details")
if count, _ := capture.Count(audit.QueryFilter{}); count != 1 {
t.Fatalf("expected fallback audit event, got %d", count)
}
SetTenantAuditManager(nil)
}
func TestInvalidateUserSessionsRemovesSessionsAndCSRF(t *testing.T) {
resetSessionTracking()
dir := t.TempDir()
InitSessionStore(dir)
InitCSRFStore(dir)
store := GetSessionStore()
sessionA := generateSessionToken()
sessionB := generateSessionToken()
store.CreateSession(sessionA, time.Hour, "agent", "127.0.0.1", "alice")
store.CreateSession(sessionB, time.Hour, "agent", "127.0.0.1", "alice")
TrackUserSession("alice", sessionA)
TrackUserSession("alice", sessionB)
tokenA := generateCSRFToken(sessionA)
tokenB := generateCSRFToken(sessionB)
if !GetCSRFStore().ValidateCSRFToken(sessionA, tokenA) || !GetCSRFStore().ValidateCSRFToken(sessionB, tokenB) {
t.Fatalf("expected CSRF tokens to be valid before invalidation")
}
InvalidateUserSessions("alice")
if store.GetSession(sessionA) != nil || store.GetSession(sessionB) != nil {
t.Fatalf("expected sessions to be removed from store")
}
if GetCSRFStore().ValidateCSRFToken(sessionA, tokenA) || GetCSRFStore().ValidateCSRFToken(sessionB, tokenB) {
t.Fatalf("expected CSRF tokens to be deleted")
}
if GetSessionUsername(sessionA) != "" || GetSessionUsername(sessionB) != "" {
t.Fatalf("expected session tracking to be cleared")
}
}
func TestUntrackUserSessionRemovesOnlyTarget(t *testing.T) {
resetSessionTracking()
TrackUserSession("bob", "sess-1")
TrackUserSession("bob", "sess-2")
UntrackUserSession("bob", "sess-1")
if GetSessionUsername("sess-1") != "" {
t.Fatalf("expected session sess-1 to be removed")
}
if GetSessionUsername("sess-2") != "bob" {
t.Fatalf("expected sess-2 to remain tracked")
}
}
func TestGetSessionUsernameFallsBackToSessionStore(t *testing.T) {
resetSessionTracking()
dir := t.TempDir()
InitSessionStore(dir)
store := GetSessionStore()
sessionToken := generateSessionToken()
store.CreateSession(sessionToken, time.Hour, "agent", "127.0.0.1", "carol")
if got := GetSessionUsername(sessionToken); got != "carol" {
t.Fatalf("expected fallback username 'carol', got %q", got)
}
if got := GetSessionUsername(sessionToken); got != "carol" {
t.Fatalf("expected cached username 'carol', got %q", got)
}
}
func TestLoadTrustedProxyCIDRsSkipsInvalidEntries(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "not-a-cidr,300.0.0.1,10.0.0.0/8")
resetTrustedProxyConfig()
if !isTrustedProxyIP("10.1.2.3") {
t.Fatalf("expected trusted proxy IP in valid CIDR to be accepted")
}
}
func TestCheckCSRFWithEmptySessionValueDoesNotIssueToken(t *testing.T) {
dir := t.TempDir()
InitCSRFStore(dir)
req := httptest.NewRequest(http.MethodPost, "/api/test", nil)
req.AddCookie(&http.Cookie{
Name: "pulse_session",
Value: "",
})
rr := httptest.NewRecorder()
if CheckCSRF(rr, req) {
t.Fatalf("expected CSRF check to fail with empty session value")
}
if rr.Header().Get("X-CSRF-Token") != "" {
t.Fatalf("expected no CSRF token to be issued for empty session value")
}
}

View file

@ -441,3 +441,688 @@ func TestWebSocketAllowsMonitoringReadScope(t *testing.T) {
}
conn.Close()
}
func TestLogEndpointsRequireSettingsReadScope(t *testing.T) {
rawToken := "logs-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
paths := []string{
"/api/logs/stream",
"/api/logs/download",
}
for _, path := range paths {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:read scope on %s, got %d", path, rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
}
}
}
func TestLogLevelReadRequiresSettingsReadScope(t *testing.T) {
rawToken := "log-level-read-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodGet, "/api/logs/level", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
}
}
func TestLogLevelUpdateRequiresSettingsWriteScope(t *testing.T) {
rawToken := "log-level-write-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodPost, "/api/logs/level", strings.NewReader(`{"level":"info"}`))
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
}
}
func TestUpdateReadEndpointsRequireSettingsReadScope(t *testing.T) {
rawToken := "updates-read-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
paths := []string{
"/api/updates/check",
"/api/updates/status",
"/api/updates/plan",
}
for _, path := range paths {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:read scope on %s, got %d", path, rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
}
}
}
func TestUpdateApplyRequiresSettingsWriteScope(t *testing.T) {
rawToken := "updates-write-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodPost, "/api/updates/apply", strings.NewReader(`{}`))
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
}
}
func TestLicenseMutationsRequireSettingsWriteScope(t *testing.T) {
rawToken := "license-write-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
paths := []string{
"/api/license/activate",
"/api/license/clear",
}
for _, path := range paths {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:write scope on %s, got %d", path, rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
}
}
}
func TestSetupScriptURLRequiresSettingsWriteScope(t *testing.T) {
rawToken := "setup-script-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodPost, "/api/setup-script-url", strings.NewReader(`{}`))
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
}
}
func TestAgentInstallCommandRequiresSettingsWriteScope(t *testing.T) {
rawToken := "agent-install-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodPost, "/api/agent-install-command", strings.NewReader(`{}`))
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
}
}
func TestDiscoverRequiresSettingsWriteScope(t *testing.T) {
rawToken := "discover-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
paths := []struct {
name string
method string
body string
}{
{name: "get", method: http.MethodGet, body: ""},
{name: "post", method: http.MethodPost, body: `{}`},
}
for _, tc := range paths {
req := httptest.NewRequest(tc.method, "/api/discover", strings.NewReader(tc.body))
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:write scope on %s, got %d", tc.name, rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
}
}
}
func TestAIOAuthEndpointsRequireSettingsWriteScope(t *testing.T) {
rawToken := "ai-oauth-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
paths := []string{
"/api/ai/oauth/start",
"/api/ai/oauth/exchange",
"/api/ai/oauth/disconnect",
}
for _, path := range paths {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:write scope on %s, got %d", path, rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
}
}
}
func TestAIExecuteEndpointsRequireAIExecuteScope(t *testing.T) {
rawToken := "ai-exec-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
paths := []string{
"/api/ai/execute",
"/api/ai/execute/stream",
}
for _, path := range paths {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing ai:execute scope on %s, got %d", path, rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIExecute, rec.Body.String())
}
}
}
func TestAIRemediationMutationsRequireAIExecuteScope(t *testing.T) {
rawToken := "ai-remediate-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
paths := []string{
"/api/ai/remediation/execute",
"/api/ai/remediation/rollback",
}
for _, path := range paths {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing ai:execute scope on %s, got %d", path, rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIExecute, rec.Body.String())
}
}
}
func TestAIAgentsRequiresAIExecuteScope(t *testing.T) {
rawToken := "ai-agents-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodGet, "/api/ai/agents", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing ai:execute scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIExecute, rec.Body.String())
}
}
func TestAICostEndpointsRequireSettingsScopes(t *testing.T) {
rawToken := "ai-cost-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
// Summary requires settings:read
req := httptest.NewRequest(http.MethodGet, "/api/ai/cost/summary", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
}
// Reset requires settings:write
req = httptest.NewRequest(http.MethodPost, "/api/ai/cost/reset", strings.NewReader(`{}`))
req.Header.Set("X-API-Token", rawToken)
rec = httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
}
// Export requires settings:read
req = httptest.NewRequest(http.MethodGet, "/api/ai/cost/export", nil)
req.Header.Set("X-API-Token", rawToken)
rec = httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
}
}
func TestAIDebugContextRequiresSettingsReadScope(t *testing.T) {
rawToken := "ai-debug-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodGet, "/api/ai/debug/context", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
}
}
func TestInfraUpdateReadEndpointsRequireMonitoringReadScope(t *testing.T) {
rawToken := "infra-read-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
paths := []string{
"/api/infra-updates",
"/api/infra-updates/summary",
"/api/infra-updates/host/host-1",
"/api/infra-updates/docker:host-1/c1",
}
for _, path := range paths {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing monitoring:read scope on %s, got %d", path, rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeMonitoringRead) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringRead, rec.Body.String())
}
}
}
func TestInfraUpdateCheckRequiresMonitoringWriteScope(t *testing.T) {
rawToken := "infra-write-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodPost, "/api/infra-updates/check", strings.NewReader(`{}`))
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing monitoring:write scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeMonitoringWrite) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringWrite, rec.Body.String())
}
}
func TestAlertReadEndpointsRequireMonitoringReadScope(t *testing.T) {
rawToken := "alerts-read-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
paths := []string{
"/api/alerts/config",
"/api/alerts/active",
"/api/alerts/history",
"/api/alerts/incidents",
}
for _, path := range paths {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing monitoring:read scope on %s, got %d", path, rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeMonitoringRead) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringRead, rec.Body.String())
}
}
}
func TestAlertMutationEndpointsRequireMonitoringWriteScope(t *testing.T) {
rawToken := "alerts-write-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
paths := []struct {
method string
path string
body string
}{
{method: http.MethodPut, path: "/api/alerts/config", body: `{}`},
{method: http.MethodPost, path: "/api/alerts/activate", body: `{}`},
{method: http.MethodDelete, path: "/api/alerts/history", body: ""},
{method: http.MethodPost, path: "/api/alerts/bulk/acknowledge", body: `{}`},
{method: http.MethodPost, path: "/api/alerts/bulk/clear", body: `{}`},
{method: http.MethodPost, path: "/api/alerts/acknowledge", body: `{}`},
{method: http.MethodPost, path: "/api/alerts/unacknowledge", body: `{}`},
{method: http.MethodPost, path: "/api/alerts/clear", body: `{}`},
{method: http.MethodPost, path: "/api/alerts/alert-1/acknowledge", body: `{}`},
{method: http.MethodPost, path: "/api/alerts/alert-1/unacknowledge", body: `{}`},
{method: http.MethodPost, path: "/api/alerts/alert-1/clear", body: `{}`},
{method: http.MethodPost, path: "/api/alerts/incidents/note", body: `{}`},
}
for _, tc := range paths {
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing monitoring:write scope on %s %s, got %d", tc.method, tc.path, rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeMonitoringWrite) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringWrite, rec.Body.String())
}
}
}
func TestNotificationQueueStatsRequireSettingsReadScope(t *testing.T) {
rawToken := "queue-stats-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodGet, "/api/notifications/queue/stats", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
}
}
func TestConfigSystemRequiresSettingsReadScope(t *testing.T) {
rawToken := "config-system-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodGet, "/api/config/system", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
}
}
func TestSystemSettingsRequiresSettingsReadScope(t *testing.T) {
rawToken := "system-settings-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodGet, "/api/system/settings", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
}
}
func TestSystemSettingsUpdateRequiresSettingsWriteScope(t *testing.T) {
rawToken := "system-settings-write-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodPost, "/api/system/settings/update", strings.NewReader(`{}`))
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
}
}
func TestMockModeReadRequiresSettingsReadScope(t *testing.T) {
rawToken := "mock-mode-read-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodGet, "/api/system/mock-mode", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
}
}
func TestMockModeWriteRequiresSettingsWriteScope(t *testing.T) {
rawToken := "mock-mode-write-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodPost, "/api/system/mock-mode", strings.NewReader(`{}`))
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
}
}
func TestConfigNodesReadRequiresSettingsReadScope(t *testing.T) {
rawToken := "config-nodes-read-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodGet, "/api/config/nodes", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
}
}
func TestConfigNodesWriteRequiresSettingsWriteScope(t *testing.T) {
rawToken := "config-nodes-write-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodPost, "/api/config/nodes", strings.NewReader(`{}`))
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
}
}
func TestSecurityOIDCRequiresSettingsWriteScope(t *testing.T) {
rawToken := "security-oidc-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodPost, "/api/security/oidc", strings.NewReader(`{}`))
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
}
}
func TestUpdateHistoryEndpointsRequireSettingsReadScope(t *testing.T) {
rawToken := "updates-history-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
paths := []string{
"/api/updates/history",
"/api/updates/history/entry",
"/api/updates/stream",
}
for _, path := range paths {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:read scope on %s, got %d", path, rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
}
}
}
func TestDiagnosticsRequireSettingsReadScope(t *testing.T) {
rawToken := "diag-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodGet, "/api/diagnostics", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
}
}
func TestDiagnosticsPrepareTokenRequiresSettingsWriteScope(t *testing.T) {
rawToken := "diag-write-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodPost, "/api/diagnostics/docker/prepare-token", strings.NewReader(`{}`))
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
}
}

View file

@ -2,8 +2,11 @@ package api
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
@ -215,3 +218,247 @@ func TestHandleValidateAPIToken_ValidToken(t *testing.T) {
t.Fatalf("unexpected message: %#v", payload["message"])
}
}
func TestQuickSecuritySetupSkipsWhenAuthConfigured(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
resetTrustedProxyConfig()
dataDir := t.TempDir()
hashed, err := internalauth.HashPassword("ExistingPassword!1")
if err != nil {
t.Fatalf("hash password: %v", err)
}
rawToken := strings.Repeat("ab", 32)
record, err := config.NewAPITokenRecord(rawToken, "admin-token", []string{config.ScopeSettingsWrite})
if err != nil {
t.Fatalf("new token record: %v", err)
}
cfg := &config.Config{
AuthUser: "admin",
AuthPass: hashed,
DataPath: dataDir,
ConfigPath: dataDir,
APITokens: []config.APITokenRecord{*record},
}
cfg.SortAPITokens()
router := &Router{
config: cfg,
persistence: config.NewConfigPersistence(cfg.DataPath),
}
handler := handleQuickSecuritySetupFixed(router)
authLimiter.Reset("198.51.100.15")
payload := `{"username":"newadmin","password":"NewPassword!1","apiToken":"` + strings.Repeat("cd", 32) + `"}`
req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(payload))
req.RemoteAddr = "198.51.100.15:54321"
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d (%s)", http.StatusOK, rec.Code, rec.Body.String())
}
var response map[string]interface{}
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response["skipped"] != true {
t.Fatalf("expected skipped=true, got %#v", response["skipped"])
}
if cfg.AuthUser != "admin" {
t.Fatalf("expected AuthUser to remain admin, got %q", cfg.AuthUser)
}
if !internalauth.CheckPasswordHash("ExistingPassword!1", cfg.AuthPass) {
t.Fatalf("expected password hash to remain unchanged")
}
if len(cfg.APITokens) != 1 {
t.Fatalf("expected 1 API token, got %d", len(cfg.APITokens))
}
if cfg.APITokens[0].Hash != record.Hash {
t.Fatalf("expected API token hash to remain unchanged")
}
}
func TestQuickSecuritySetupBootstrapTokenUnavailable(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
resetTrustedProxyConfig()
dataDir := t.TempDir()
cfg := &config.Config{
DataPath: dataDir,
ConfigPath: dataDir,
}
router := &Router{
config: cfg,
persistence: config.NewConfigPersistence(cfg.DataPath),
}
handler := handleQuickSecuritySetupFixed(router)
authLimiter.Reset("198.51.100.16")
payload := `{"username":"bootstrap","password":"StrongPass!1","apiToken":"` + strings.Repeat("aa", 32) + `"}`
req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(payload))
req.RemoteAddr = "198.51.100.16:54321"
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected status %d, got %d (%s)", http.StatusServiceUnavailable, rec.Code, rec.Body.String())
}
}
func TestQuickSecuritySetupAcceptsSetupTokenInBody(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
resetTrustedProxyConfig()
dataDir := t.TempDir()
cfg := &config.Config{
DataPath: dataDir,
ConfigPath: dataDir,
}
router := &Router{
config: cfg,
persistence: config.NewConfigPersistence(cfg.DataPath),
}
router.initializeBootstrapToken()
tokenPath := filepath.Join(cfg.DataPath, bootstrapTokenFilename)
content, err := os.ReadFile(tokenPath)
if err != nil {
t.Fatalf("read bootstrap token: %v", err)
}
bootstrapToken := strings.TrimSpace(string(content))
if bootstrapToken == "" {
t.Fatalf("bootstrap token is empty")
}
handler := handleQuickSecuritySetupFixed(router)
authLimiter.Reset("198.51.100.17")
payload := `{"username":"bootstrap","password":"StrongPass!1","apiToken":"` + strings.Repeat("aa", 32) + `","setupToken":"` + bootstrapToken + `"}` //nolint:lll
req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(payload))
req.RemoteAddr = "198.51.100.17:54321"
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d (%s)", http.StatusOK, rec.Code, rec.Body.String())
}
if _, err := os.Stat(tokenPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected bootstrap token file to be removed after successful setup, got err=%v", err)
}
if router.bootstrapTokenHash != "" {
t.Fatalf("expected bootstrap token hash to be cleared after successful setup")
}
}
func TestQuickSecuritySetupRotatesWithBasicAuth(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
resetTrustedProxyConfig()
dataDir := t.TempDir()
hashed, err := internalauth.HashPassword("OldPassword!1")
if err != nil {
t.Fatalf("hash password: %v", err)
}
cfg := &config.Config{
AuthUser: "admin",
AuthPass: hashed,
DataPath: dataDir,
ConfigPath: dataDir,
}
router := &Router{
config: cfg,
persistence: config.NewConfigPersistence(cfg.DataPath),
}
handler := handleQuickSecuritySetupFixed(router)
authLimiter.Reset("198.51.100.18")
payload := `{"username":"newadmin","password":"NewPassword!1","apiToken":"` + strings.Repeat("bb", 32) + `","force":true}`
req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(payload))
req.RemoteAddr = "198.51.100.18:54321"
req.SetBasicAuth("admin", "OldPassword!1")
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d (%s)", http.StatusOK, rec.Code, rec.Body.String())
}
if cfg.AuthUser != "newadmin" {
t.Fatalf("expected AuthUser to be rotated, got %q", cfg.AuthUser)
}
if !internalauth.CheckPasswordHash("NewPassword!1", cfg.AuthPass) {
t.Fatalf("stored password hash does not match new password")
}
if len(cfg.APITokens) != 1 {
t.Fatalf("expected one API token, got %d", len(cfg.APITokens))
}
}
func TestQuickSecuritySetupRateLimitEnforced(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
resetTrustedProxyConfig()
dataDir := t.TempDir()
hashed, err := internalauth.HashPassword("OldPassword!1")
if err != nil {
t.Fatalf("hash password: %v", err)
}
cfg := &config.Config{
AuthUser: "admin",
AuthPass: hashed,
DataPath: dataDir,
ConfigPath: dataDir,
}
router := &Router{
config: cfg,
persistence: config.NewConfigPersistence(cfg.DataPath),
}
handler := handleQuickSecuritySetupFixed(router)
ip := "203.0.113.210"
authLimiter.Reset(ip)
defer authLimiter.Reset(ip)
payload := `{"username":"newadmin","password":"NewPassword!1","apiToken":"` + strings.Repeat("bb", 32) + `"}`
for i := 0; i < 10; i++ {
req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(payload))
req.RemoteAddr = ip + ":1234"
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("attempt %d: expected %d, got %d (%s)", i+1, http.StatusUnauthorized, rec.Code, rec.Body.String())
}
}
req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(payload))
req.RemoteAddr = ip + ":1234"
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected %d, got %d (%s)", http.StatusTooManyRequests, rec.Code, rec.Body.String())
}
}

View file

@ -0,0 +1,151 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gorilla/websocket"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
pulsews "github.com/rcourtman/pulse-go-rewrite/internal/websocket"
)
func newWebSocketRouter(t *testing.T, allowedOrigins []string, tokenRecord config.APITokenRecord) (*httptest.Server, func()) {
t.Helper()
cfg := newTestConfigWithTokens(t, tokenRecord)
hub := pulsews.NewHub(nil)
hub.SetAllowedOrigins(allowedOrigins)
go hub.Run()
router := NewRouter(cfg, nil, nil, hub, nil, "1.0.0")
server := httptest.NewServer(router.Handler())
cleanup := func() {
server.Close()
hub.Stop()
}
return server, cleanup
}
func TestWebSocketOriginRejectedWhenNotAllowed(t *testing.T) {
rawToken := "ws-origin-reject-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
server, cleanup := newWebSocketRouter(t, []string{"https://allowed.example.com"}, record)
defer cleanup()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws"
headers := http.Header{}
headers.Set("X-API-Token", rawToken)
headers.Set("Origin", "https://evil.example.com")
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, headers)
if err == nil {
conn.Close()
t.Fatalf("expected websocket origin rejection")
}
if resp == nil {
t.Fatalf("expected HTTP response for rejected origin")
}
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected status %d, got %d", http.StatusForbidden, resp.StatusCode)
}
}
func TestWebSocketOriginAllowedWhenConfigured(t *testing.T) {
rawToken := "ws-origin-allow-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
server, cleanup := newWebSocketRouter(t, []string{"https://allowed.example.com"}, record)
defer cleanup()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws"
headers := http.Header{}
headers.Set("X-API-Token", rawToken)
headers.Set("Origin", "https://allowed.example.com")
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, headers)
if err != nil {
t.Fatalf("expected websocket connection, got %v", err)
}
if resp == nil || resp.StatusCode != http.StatusSwitchingProtocols {
t.Fatalf("expected 101 switching protocols, got %v", resp)
}
conn.Close()
}
func TestSocketIOWebSocketOriginRejected(t *testing.T) {
rawToken := "socket-origin-reject-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
server, cleanup := newWebSocketRouter(t, []string{"https://allowed.example.com"}, record)
defer cleanup()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/socket.io/?transport=websocket"
headers := http.Header{}
headers.Set("X-API-Token", rawToken)
headers.Set("Origin", "https://evil.example.com")
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, headers)
if err == nil {
conn.Close()
t.Fatalf("expected websocket origin rejection for socket.io")
}
if resp == nil {
t.Fatalf("expected HTTP response for rejected socket.io origin")
}
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected status %d, got %d", http.StatusForbidden, resp.StatusCode)
}
}
func TestWebSocketOriginRejectedWhenNoAllowedOriginsAndPublicOrigin(t *testing.T) {
rawToken := "ws-origin-default-reject-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
server, cleanup := newWebSocketRouter(t, []string{}, record)
defer cleanup()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws"
headers := http.Header{}
headers.Set("X-API-Token", rawToken)
headers.Set("Origin", "https://evil.example.com")
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, headers)
if err == nil {
conn.Close()
t.Fatalf("expected websocket origin rejection with empty allowed origins")
}
if resp == nil {
t.Fatalf("expected HTTP response for rejected origin")
}
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected status %d, got %d", http.StatusForbidden, resp.StatusCode)
}
}
func TestWebSocketOriginAllowsPrivateWhenNoAllowedOrigins(t *testing.T) {
rawToken := "ws-origin-default-allow-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
server, cleanup := newWebSocketRouter(t, []string{}, record)
defer cleanup()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws"
headers := http.Header{}
headers.Set("X-API-Token", rawToken)
headers.Set("Origin", "http://localhost:3000")
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, headers)
if err != nil {
t.Fatalf("expected websocket connection, got %v", err)
}
if resp == nil || resp.StatusCode != http.StatusSwitchingProtocols {
t.Fatalf("expected 101 switching protocols, got %v", resp)
}
conn.Close()
}

View file

@ -0,0 +1,100 @@
package notifications
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
)
func TestSendResolvedApprise_NoAlerts(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
err := nm.sendResolvedApprise(AppriseConfig{Enabled: true}, nil, time.Now())
if err == nil {
t.Fatalf("expected error for empty alerts list")
}
}
func TestSendResolvedApprise_DisabledConfig(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
alertList := []*alerts.Alert{{ID: "a1", ResourceName: "db"}}
err := nm.sendResolvedApprise(AppriseConfig{Enabled: false}, alertList, time.Now())
if err == nil {
t.Fatalf("expected error for disabled config")
}
}
func TestSendResolvedApprise_InvalidPayload(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
alertList := []*alerts.Alert{nil}
err := nm.sendResolvedApprise(AppriseConfig{Enabled: true, Targets: []string{"discord://token"}}, alertList, time.Now())
if err == nil {
t.Fatalf("expected error for invalid payload")
}
}
func TestSendResolvedApprise_HTTP(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
t.Fatalf("allowlist: %v", err)
}
alertList := []*alerts.Alert{{
ID: "a1",
ResourceName: "db-1",
Level: alerts.AlertLevelWarning,
Message: "ok",
}}
err := nm.sendResolvedApprise(AppriseConfig{
Enabled: true,
Mode: AppriseModeHTTP,
ServerURL: server.URL,
TimeoutSeconds: 2,
}, alertList, time.Now())
if err != nil {
t.Fatalf("expected resolved apprise HTTP to succeed, got %v", err)
}
}
func TestSendResolvedApprise_CLI(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
nm.appriseExec = func(ctx context.Context, path string, args []string) ([]byte, error) {
return nil, nil
}
alertList := []*alerts.Alert{{
ID: "a1",
ResourceName: "db-1",
Level: alerts.AlertLevelWarning,
Message: "ok",
}}
err := nm.sendResolvedApprise(AppriseConfig{
Enabled: true,
Mode: AppriseModeCLI,
Targets: []string{"discord://token"},
TimeoutSeconds: 2,
}, alertList, time.Now())
if err != nil {
t.Fatalf("expected resolved apprise CLI to succeed, got %v", err)
}
}

View file

@ -0,0 +1,84 @@
package notifications
import (
"context"
"errors"
"testing"
"time"
)
func TestSendAppriseViaCLINoTargets(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
err := nm.sendAppriseViaCLI(AppriseConfig{
CLIPath: "apprise",
TimeoutSeconds: 1,
}, "title", "body")
if err == nil {
t.Fatalf("expected error when no targets configured")
}
}
func TestSendAppriseViaCLIExecError(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
nm.appriseExec = func(ctx context.Context, path string, args []string) ([]byte, error) {
return []byte("boom"), errors.New("exec failed")
}
err := nm.sendAppriseViaCLI(AppriseConfig{
CLIPath: "apprise",
TimeoutSeconds: 1,
Targets: []string{"discord://token"},
}, "title", "body")
if err == nil {
t.Fatalf("expected exec error")
}
}
func TestSendAppriseViaCLISuccess(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
nm.appriseExec = func(ctx context.Context, path string, args []string) ([]byte, error) {
return []byte("ok"), nil
}
err := nm.sendAppriseViaCLI(AppriseConfig{
CLIPath: "apprise",
TimeoutSeconds: 1,
Targets: []string{"discord://token"},
}, "title", "body")
if err != nil {
t.Fatalf("expected success, got %v", err)
}
}
func TestSendAppriseViaCLISuccessNoOutput(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
nm.appriseExec = func(ctx context.Context, path string, args []string) ([]byte, error) {
return nil, nil
}
err := nm.sendAppriseViaCLI(AppriseConfig{
CLIPath: "apprise",
TimeoutSeconds: 1,
Targets: []string{"discord://token"},
}, "title", "body")
if err != nil {
t.Fatalf("expected success, got %v", err)
}
}
func TestDefaultAppriseExecRunsCommand(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if _, err := defaultAppriseExec(ctx, "true", nil); err != nil {
t.Fatalf("expected defaultAppriseExec to run, got %v", err)
}
}

View file

@ -0,0 +1,152 @@
package notifications
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSendAppriseViaHTTPEmptyServerURL(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
err := nm.sendAppriseViaHTTP(AppriseConfig{TimeoutSeconds: 1}, "title", "body", "info")
if err == nil || !strings.Contains(err.Error(), "server URL is not configured") {
t.Fatalf("expected missing server URL error, got %v", err)
}
}
func TestSendAppriseViaHTTPInvalidScheme(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
err := nm.sendAppriseViaHTTP(AppriseConfig{ServerURL: "ftp://example.com", TimeoutSeconds: 1}, "title", "body", "info")
if err == nil || !strings.Contains(err.Error(), "must start with http or https") {
t.Fatalf("expected scheme validation error, got %v", err)
}
}
func TestSendAppriseViaHTTPSkipTLSVerifyAndDefaultHeader(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-API-KEY"); got != "secret" {
t.Fatalf("expected default API key header, got %q", got)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
t.Fatalf("allowlist: %v", err)
}
err := nm.sendAppriseViaHTTP(AppriseConfig{
ServerURL: server.URL,
SkipTLSVerify: true,
APIKey: "secret",
TimeoutSeconds: 2,
}, "title", "body", "info")
if err != nil {
t.Fatalf("expected HTTPS request to succeed, got %v", err)
}
}
func TestSendAppriseViaHTTPIncludesTargetsAndCustomHeader(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
type payload struct {
Body string `json:"body"`
Title string `json:"title"`
Type string `json:"type"`
URLs []string `json:"urls"`
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Test-Key"); got != "secret" {
t.Fatalf("expected custom API key header, got %q", got)
}
if !strings.Contains(r.URL.Path, "/notify/") {
t.Fatalf("expected notify path, got %q", r.URL.Path)
}
var p payload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
t.Fatalf("decode payload: %v", err)
}
if len(p.URLs) != 1 || p.URLs[0] != "discord://token" {
t.Fatalf("expected urls in payload, got %#v", p.URLs)
}
if p.Type != "warning" {
t.Fatalf("expected notify type warning, got %q", p.Type)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
t.Fatalf("allowlist: %v", err)
}
err := nm.sendAppriseViaHTTP(AppriseConfig{
ServerURL: server.URL,
ConfigKey: "config key",
APIKey: "secret",
APIKeyHeader: "X-Test-Key",
Targets: []string{"discord://token"},
TimeoutSeconds: 2,
}, "title", "body", "warning")
if err != nil {
t.Fatalf("expected HTTP request to succeed, got %v", err)
}
}
func TestSendAppriseViaHTTPStatusErrorWithBody(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("boom"))
}))
defer server.Close()
if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
t.Fatalf("allowlist: %v", err)
}
err := nm.sendAppriseViaHTTP(AppriseConfig{
ServerURL: server.URL,
TimeoutSeconds: 2,
}, "title", "body", "info")
if err == nil || !strings.Contains(err.Error(), "HTTP 500") {
t.Fatalf("expected status error with body, got %v", err)
}
}
func TestSendAppriseViaHTTPStatusErrorWithoutBody(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
t.Fatalf("allowlist: %v", err)
}
err := nm.sendAppriseViaHTTP(AppriseConfig{
ServerURL: server.URL,
TimeoutSeconds: 2,
}, "title", "body", "info")
if err == nil || !strings.Contains(err.Error(), "HTTP 500") {
t.Fatalf("expected status error, got %v", err)
}
}