mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
test: expand security regression coverage
This commit is contained in:
parent
4bebd2f576
commit
b9eee668e5
16 changed files with 2644 additions and 0 deletions
123
internal/api/auth_context_middleware_test.go
Normal file
123
internal/api/auth_context_middleware_test.go
Normal 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()))
|
||||
}
|
||||
}
|
||||
60
internal/api/auth_context_tenant_test.go
Normal file
60
internal/api/auth_context_tenant_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
69
internal/api/require_admin_proxy_test.go
Normal file
69
internal/api/require_admin_proxy_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
163
internal/api/router_csrf_middleware_test.go
Normal file
163
internal/api/router_csrf_middleware_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
102
internal/api/router_csrf_skip_routes_test.go
Normal file
102
internal/api/router_csrf_skip_routes_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
93
internal/api/router_proxy_auth_admin_routes_test.go
Normal file
93
internal/api/router_proxy_auth_admin_routes_test.go
Normal 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
107
internal/api/router_proxy_auth_security_test.go
Normal file
107
internal/api/router_proxy_auth_security_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
59
internal/api/router_rate_limit_security_test.go
Normal file
59
internal/api/router_rate_limit_security_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
254
internal/api/router_recovery_test.go
Normal file
254
internal/api/router_recovery_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
195
internal/api/security_additional_coverage_test.go
Normal file
195
internal/api/security_additional_coverage_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
151
internal/api/websocket_origin_security_test.go
Normal file
151
internal/api/websocket_origin_security_test.go
Normal 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()
|
||||
}
|
||||
100
internal/notifications/notifications_resolved_apprise_test.go
Normal file
100
internal/notifications/notifications_resolved_apprise_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
84
internal/notifications/notifications_security_cli_test.go
Normal file
84
internal/notifications/notifications_security_cli_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
152
internal/notifications/notifications_security_http_test.go
Normal file
152
internal/notifications/notifications_security_http_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue