diff --git a/internal/api/auth_context_middleware_test.go b/internal/api/auth_context_middleware_test.go new file mode 100644 index 000000000..8a0343f0b --- /dev/null +++ b/internal/api/auth_context_middleware_test.go @@ -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())) + } +} diff --git a/internal/api/auth_context_tenant_test.go b/internal/api/auth_context_tenant_test.go new file mode 100644 index 000000000..c53d29301 --- /dev/null +++ b/internal/api/auth_context_tenant_test.go @@ -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") + } +} diff --git a/internal/api/require_admin_proxy_test.go b/internal/api/require_admin_proxy_test.go new file mode 100644 index 000000000..d3dfccdee --- /dev/null +++ b/internal/api/require_admin_proxy_test.go @@ -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()) + } +} diff --git a/internal/api/router_csrf_middleware_test.go b/internal/api/router_csrf_middleware_test.go new file mode 100644 index 000000000..5de403492 --- /dev/null +++ b/internal/api/router_csrf_middleware_test.go @@ -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") + } + } +} diff --git a/internal/api/router_csrf_skip_routes_test.go b/internal/api/router_csrf_skip_routes_test.go new file mode 100644 index 000000000..17d8c2735 --- /dev/null +++ b/internal/api/router_csrf_skip_routes_test.go @@ -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()) + } +} diff --git a/internal/api/router_proxy_auth_admin_routes_test.go b/internal/api/router_proxy_auth_admin_routes_test.go new file mode 100644 index 000000000..d095f66dc --- /dev/null +++ b/internal/api/router_proxy_auth_admin_routes_test.go @@ -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()) + } + }) +} diff --git a/internal/api/router_proxy_auth_security_test.go b/internal/api/router_proxy_auth_security_test.go new file mode 100644 index 000000000..9af37048c --- /dev/null +++ b/internal/api/router_proxy_auth_security_test.go @@ -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()) + } +} diff --git a/internal/api/router_rate_limit_security_test.go b/internal/api/router_rate_limit_security_test.go new file mode 100644 index 000000000..3da6a0a70 --- /dev/null +++ b/internal/api/router_rate_limit_security_test.go @@ -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()) + } +} diff --git a/internal/api/router_recovery_test.go b/internal/api/router_recovery_test.go new file mode 100644 index 000000000..e926fb35a --- /dev/null +++ b/internal/api/router_recovery_test.go @@ -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) + } +} diff --git a/internal/api/security_additional_coverage_test.go b/internal/api/security_additional_coverage_test.go new file mode 100644 index 000000000..501d27c21 --- /dev/null +++ b/internal/api/security_additional_coverage_test.go @@ -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") + } +} diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index 2f6f3b0d2..2752a2ec7 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -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()) + } +} diff --git a/internal/api/security_setup_fix_additional_test.go b/internal/api/security_setup_fix_additional_test.go index 07a4608a3..ea847b4b0 100644 --- a/internal/api/security_setup_fix_additional_test.go +++ b/internal/api/security_setup_fix_additional_test.go @@ -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()) + } +} diff --git a/internal/api/websocket_origin_security_test.go b/internal/api/websocket_origin_security_test.go new file mode 100644 index 000000000..62ed557c4 --- /dev/null +++ b/internal/api/websocket_origin_security_test.go @@ -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() +} diff --git a/internal/notifications/notifications_resolved_apprise_test.go b/internal/notifications/notifications_resolved_apprise_test.go new file mode 100644 index 000000000..986eacc69 --- /dev/null +++ b/internal/notifications/notifications_resolved_apprise_test.go @@ -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) + } +} diff --git a/internal/notifications/notifications_security_cli_test.go b/internal/notifications/notifications_security_cli_test.go new file mode 100644 index 000000000..5abf09093 --- /dev/null +++ b/internal/notifications/notifications_security_cli_test.go @@ -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) + } +} diff --git a/internal/notifications/notifications_security_http_test.go b/internal/notifications/notifications_security_http_test.go new file mode 100644 index 000000000..eb7bb2747 --- /dev/null +++ b/internal/notifications/notifications_security_http_test.go @@ -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) + } +}