mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
8ea94d792b
Nothing in Pulse showed a paying customer what Patrol had done for them: about 164 runs a month per install, findings raised, investigations and fixes, and none of it summarised. GET /api/ai/patrol/digest rolls the last N days (default 7, max 30) up from records Pulse already keeps: run history, the findings store, Patrol-origin action audits, and the usage cost store. It adds no telemetry and persists nothing. The payload reports when the bounded run history no longer covers the window and when model pricing is unknown, rather than quietly under-counting. This is the first slice of the "Patrol weekly digest" named bet in the pulse-pro demand ledger; the in-app "This week" card follows once its browser pass is recorded. docs/PATROL_WEEKLY_DIGEST.md holds the design note and the honest limits of each line. status.json registers the patrol-value-visibility coverage gap, the candidate lane, and its work claim. It also drops the second, identical copy of the ai-provider-guided-setup coverage gap that landed with #1853; the duplicate id fails the status audit on main for every pull request.
5449 lines
211 KiB
Go
5449 lines
211 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/servicediscovery"
|
|
pulsews "github.com/rcourtman/pulse-go-rewrite/internal/websocket"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
)
|
|
|
|
type wsRawMessage struct {
|
|
Type agentexec.MessageType `json:"type"`
|
|
Payload json.RawMessage `json:"payload,omitempty"`
|
|
}
|
|
|
|
func TestSecurityImplicitAgentCredentialOmitsExecutionScope(t *testing.T) {
|
|
_, record, err := agenttokens.IssueAndPersist(&config.Config{}, nil, agenttokens.IssueOptions{
|
|
TokenName: "implicit-monitoring-agent",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IssueAndPersist: %v", err)
|
|
}
|
|
if !record.HasScope(config.ScopeAgentReport) || !record.HasScope(config.ScopeAgentConfigRead) {
|
|
t.Fatalf("implicit credential scopes = %v, want monitoring scopes", record.Scopes)
|
|
}
|
|
if record.HasScope(config.ScopeAgentExec) {
|
|
t.Fatalf("implicit credential scopes = %v, must not grant agent:exec", record.Scopes)
|
|
}
|
|
}
|
|
|
|
func TestSecurityRejectsCollectorRoleWithNonMonitoringScope(t *testing.T) {
|
|
rawToken := "security-over-scoped-collector.12345678"
|
|
record, err := config.NewAPITokenRecord(rawToken, "collector", []string{
|
|
config.ScopeAgentReport,
|
|
config.ScopeAgentConfigRead,
|
|
config.ScopeSettingsWrite,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
record.Metadata = map[string]string{
|
|
auth.RuntimeRoleMetadataKey: auth.RuntimeRoleMonitoringCollector,
|
|
}
|
|
cfg := &config.Config{APITokens: []config.APITokenRecord{*record}}
|
|
|
|
if admitted, ok := cfg.ValidateAPIToken(rawToken); ok || admitted != nil {
|
|
t.Fatalf("over-scoped collector authenticated: %#v", admitted)
|
|
}
|
|
}
|
|
|
|
func TestSecurityGenericExecTokenCannotApplyInstallCommandPolicy(t *testing.T) {
|
|
token := config.APITokenRecord{
|
|
ID: "generic-exec-token",
|
|
Scopes: []string{config.ScopeAgentReport, config.ScopeAgentExec},
|
|
}
|
|
cfg := &config.Config{DataPath: t.TempDir(), APITokens: []config.APITokenRecord{token}}
|
|
_, monitor := newUnifiedAgentHandlers(t, cfg)
|
|
|
|
disabled := false
|
|
if err := monitor.UpdateHostAgentConfig("machine-1", &disabled); err != nil {
|
|
t.Fatalf("seed disabled command policy: %v", err)
|
|
}
|
|
if err := reconcileInstallTokenCommandPolicy(monitor, &token, models.Host{
|
|
ID: "machine-1",
|
|
Hostname: "docker-1",
|
|
}); err != nil {
|
|
t.Fatalf("reconcile generic exec token: %v", err)
|
|
}
|
|
|
|
desired := monitor.GetHostAgentConfig("machine-1").CommandsEnabled
|
|
if desired == nil || *desired {
|
|
t.Fatalf("generic exec token changed desired command policy: %#v", desired)
|
|
}
|
|
if got := cfg.APITokens[0].Metadata[agenttokens.CommandPolicyAppliedAgentIDMetadataKey]; got != "" {
|
|
t.Fatalf("generic exec token gained install-policy marker %q", got)
|
|
}
|
|
}
|
|
|
|
func TestDeprovisionSessionRevocationIncludesPersistedUntrackedSessions(t *testing.T) {
|
|
resetSessionTracking()
|
|
resetSessionStoreForTests()
|
|
resetCSRFStoreForTests()
|
|
t.Cleanup(resetSessionStoreForTests)
|
|
t.Cleanup(resetCSRFStoreForTests)
|
|
|
|
dir := t.TempDir()
|
|
InitSessionStore(dir)
|
|
InitCSRFStore(dir)
|
|
token := generateSessionToken()
|
|
GetSessionStore().CreateSession(token, time.Hour, "agent", "127.0.0.1", "retired-user")
|
|
csrfToken := generateCSRFToken(token)
|
|
if !GetCSRFStore().ValidateCSRFToken(token, csrfToken) {
|
|
t.Fatal("expected CSRF token to be valid before revocation")
|
|
}
|
|
// Deliberately do not call TrackUserSession: this models a session loaded
|
|
// from persistent storage after process restart.
|
|
InvalidateUserSessions("retired-user")
|
|
|
|
if GetSessionStore().GetSession(token) != nil {
|
|
t.Fatal("persisted untracked session survived account-wide revocation")
|
|
}
|
|
if GetCSRFStore().ValidateCSRFToken(token, csrfToken) {
|
|
t.Fatal("CSRF state survived account-wide revocation")
|
|
}
|
|
}
|
|
|
|
func TestBrowserCookiePolicyWritesSecureCookiesForTLS(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "https://pulse.example/api/config", nil)
|
|
rec := httptest.NewRecorder()
|
|
getBrowserCookiePolicy(req).setClientReadable(rec, &http.Cookie{Name: CookieNameCSRF, Value: "token", Path: "/"})
|
|
|
|
cookies := rec.Result().Cookies()
|
|
if len(cookies) != 1 || !cookies[0].Secure {
|
|
t.Fatalf("TLS cookie policy = %+v, want one Secure cookie", cookies)
|
|
}
|
|
if cookies[0].SameSite != http.SameSiteLaxMode {
|
|
t.Fatalf("SameSite = %v, want Lax", cookies[0].SameSite)
|
|
}
|
|
}
|
|
|
|
type denyAuthorizer struct{}
|
|
|
|
func (d *denyAuthorizer) Authorize(_ context.Context, _ string, _ string) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
type adminOnlyAuthorizer struct{}
|
|
|
|
func (a *adminOnlyAuthorizer) Authorize(ctx context.Context, _ string, _ string) (bool, error) {
|
|
return auth.GetUser(ctx) == "admin", nil
|
|
}
|
|
|
|
func newTestConfigWithTokens(t *testing.T, records ...config.APITokenRecord) *config.Config {
|
|
t.Helper()
|
|
tempDir := t.TempDir()
|
|
return &config.Config{
|
|
DataPath: tempDir,
|
|
ConfigPath: tempDir,
|
|
APITokens: records,
|
|
}
|
|
}
|
|
|
|
func newTokenRecord(t *testing.T, raw string, scopes []string, metadata map[string]string) config.APITokenRecord {
|
|
t.Helper()
|
|
record, err := config.NewAPITokenRecord(raw, "test-token", scopes)
|
|
if err != nil {
|
|
t.Fatalf("NewAPITokenRecord: %v", err)
|
|
}
|
|
if metadata != nil {
|
|
record.Metadata = metadata
|
|
}
|
|
return *record
|
|
}
|
|
|
|
func TestSecurityRejectsWildcardTrustedProxyCIDR(t *testing.T) {
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "0.0.0.0/0")
|
|
resetTrustedProxyConfig()
|
|
|
|
if err := ValidateTrustedProxyCIDRsFromEnv(); err == nil || !strings.Contains(err.Error(), "wildcard trust range") {
|
|
t.Fatalf("expected wildcard trusted proxy configuration to be rejected, got %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://pulse.local/api/state", nil)
|
|
req.RemoteAddr = "198.51.100.42:8443"
|
|
req.Header.Set("X-Forwarded-For", "203.0.113.10")
|
|
|
|
if got := GetClientIP(req); got != "198.51.100.42" {
|
|
t.Fatalf("expected forwarded headers to fail closed, got %q", got)
|
|
}
|
|
}
|
|
|
|
func readRegisteredPayload(t *testing.T, conn *websocket.Conn) agentexec.RegisteredPayload {
|
|
t.Helper()
|
|
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
|
_, data, err := conn.ReadMessage()
|
|
if err != nil {
|
|
t.Fatalf("ReadMessage: %v", err)
|
|
}
|
|
|
|
var msg wsRawMessage
|
|
if err := json.Unmarshal(data, &msg); err != nil {
|
|
t.Fatalf("unmarshal message: %v", err)
|
|
}
|
|
if msg.Type != agentexec.MsgTypeRegistered {
|
|
t.Fatalf("message type = %q, want %q", msg.Type, agentexec.MsgTypeRegistered)
|
|
}
|
|
if msg.Payload == nil {
|
|
t.Fatalf("registered payload missing")
|
|
}
|
|
|
|
var payload agentexec.RegisteredPayload
|
|
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
|
|
t.Fatalf("unmarshal registered payload: %v", err)
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func TestSimpleStatsRequiresAuthInAPIMode(t *testing.T) {
|
|
rawToken := "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, "/simple-stats", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without token, got %d", rec.Code)
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/simple-stats", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec = httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 with token, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Simple Pulse Stats") {
|
|
t.Fatalf("expected stats page HTML, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSimpleStatsAllowsBearerToken(t *testing.T) {
|
|
rawToken := "stats-bearer-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, "/simple-stats", nil)
|
|
req.Header.Set("Authorization", "Bearer "+rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 with bearer token, got %d", rec.Code)
|
|
}
|
|
if rec.Header().Get("X-Auth-Method") != "api_token" {
|
|
t.Fatalf("expected X-Auth-Method api_token, got %q", rec.Header().Get("X-Auth-Method"))
|
|
}
|
|
}
|
|
|
|
func TestSimpleStatsRejectsInvalidBearerToken(t *testing.T) {
|
|
rawToken := "stats-bearer-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, "/simple-stats", nil)
|
|
req.Header.Set("Authorization", "Bearer invalid-token")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for invalid bearer token, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Invalid API token") {
|
|
t.Fatalf("expected invalid token response, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSchedulerHealthRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "sched-token-123.12345678", []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/monitoring/scheduler/health", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without token, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestChangePasswordRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "change-pass-token-123.12345678", []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/change-password", strings.NewReader(`{}`))
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestChangePasswordRejectsProxyNonAdmin(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/change-password", strings.NewReader(`{}`))
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy password change, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestResetLockoutRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "reset-lockout-token-123.12345678", []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/reset-lockout", strings.NewReader(`{}`))
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRequirePermissionDeniesProxyNonAdminUsers(t *testing.T) {
|
|
prevAuthorizer := auth.GetAuthorizer()
|
|
auth.SetAuthorizer(&adminOnlyAuthorizer{})
|
|
defer auth.SetAuthorizer(prevAuthorizer)
|
|
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/security/tokens", nil)
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy user, got %d", rec.Code)
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/api/security/tokens", nil)
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "admin")
|
|
req.Header.Set("X-Remote-Roles", "admin")
|
|
rec = httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for admin proxy user, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestLicenseFeaturesRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "license-token-123.12345678", []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/license/features", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without token, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestLicenseStatusRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "license-status-token-123.12345678", []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/license/status", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without token, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAIStatusRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "ai-status-token-123.12345678", []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/status", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without token, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAIStatusRequiresAIChatScope(t *testing.T) {
|
|
rawToken := "ai-status-scope-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/status", 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:chat scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeAIChat) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIChat, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestWebSocketRequiresMonitoringReadScope(t *testing.T) {
|
|
rawToken := "ws-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentReport}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/ws", 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, got %d", 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 TestWebSocketRequiresMonitoringReadScopeForUpgrade(t *testing.T) {
|
|
rawToken := "ws-scope-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentReport}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
|
|
hub := pulsews.NewHub(nil)
|
|
go hub.Run()
|
|
defer hub.Stop()
|
|
|
|
router := NewRouter(cfg, nil, nil, hub, nil, "1.0.0")
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws"
|
|
headers := http.Header{}
|
|
headers.Set("X-API-Token", rawToken)
|
|
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, headers)
|
|
if err == nil {
|
|
conn.Close()
|
|
t.Fatalf("expected websocket upgrade to be rejected without monitoring:read scope")
|
|
}
|
|
if resp == nil {
|
|
t.Fatalf("expected HTTP response for failed websocket upgrade")
|
|
}
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing scope, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestUnifiedAgentManagementRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "host-manage-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentManage}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
cases := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
}{
|
|
{name: "link", method: http.MethodPost, path: "/api/agents/agent/link"},
|
|
{name: "unlink", method: http.MethodPost, path: "/api/agents/agent/unlink"},
|
|
{name: "delete", method: http.MethodDelete, path: "/api/agents/agent/agent-1"},
|
|
{name: "host link alias", method: http.MethodPost, path: "/api/agents/host/link"},
|
|
{name: "host unlink alias", method: http.MethodPost, path: "/api/agents/host/unlink"},
|
|
{name: "host delete alias", method: http.MethodDelete, path: "/api/agents/host/agent-1"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(tc.method, tc.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: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 TestTestNotificationRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "notify-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/test-notification", 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: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 TestAIFindingsRequiresAIExecuteScope(t *testing.T) {
|
|
rawToken := "ai-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/findings/f-1/investigation", 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 TestNotificationsDLQRequiresSettingsReadScope(t *testing.T) {
|
|
rawToken := "dlq-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/dlq", 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 TestNotificationsDLQMutationsRequireSettingsWriteScope(t *testing.T) {
|
|
rawToken := "dlq-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")
|
|
|
|
cases := []string{
|
|
"/api/notifications/dlq/retry",
|
|
"/api/notifications/dlq/delete",
|
|
"/api/notifications/terminal-failures/retry",
|
|
"/api/notifications/terminal-failures/dismiss",
|
|
}
|
|
|
|
for _, path := range cases {
|
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader([]byte(`{"id":"test"}`)))
|
|
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 TestAgentExecTokenBindingEnforced(t *testing.T) {
|
|
rawToken := "agent-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, map[string]string{"bound_agent_id": "agent-1"})
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := wsURLForHTTP(ts.URL) + "/api/agent/ws"
|
|
|
|
// Mismatched agent ID should be rejected
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
regMsg, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
|
AgentID: "agent-2",
|
|
Hostname: "host-2",
|
|
Version: "1.0.0",
|
|
Platform: "linux",
|
|
Token: rawToken,
|
|
})
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatalf("NewMessage: %v", err)
|
|
}
|
|
if err := conn.WriteJSON(regMsg); err != nil {
|
|
conn.Close()
|
|
t.Fatalf("WriteJSON: %v", err)
|
|
}
|
|
reg := readRegisteredPayload(t, conn)
|
|
if reg.Success {
|
|
conn.Close()
|
|
t.Fatalf("expected registration to be rejected for mismatched bound agent")
|
|
}
|
|
conn.Close()
|
|
|
|
// Matching agent ID should succeed
|
|
conn, _, err = websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
regMsg, err = agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
|
AgentID: "agent-1",
|
|
Hostname: "host-1",
|
|
Version: "1.0.0",
|
|
Platform: "linux",
|
|
Token: rawToken,
|
|
})
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatalf("NewMessage: %v", err)
|
|
}
|
|
if err := conn.WriteJSON(regMsg); err != nil {
|
|
conn.Close()
|
|
t.Fatalf("WriteJSON: %v", err)
|
|
}
|
|
reg = readRegisteredPayload(t, conn)
|
|
if !reg.Success {
|
|
conn.Close()
|
|
t.Fatalf("expected registration to be accepted for matching bound agent, got %q", reg.Message)
|
|
}
|
|
conn.Close()
|
|
}
|
|
|
|
func TestActionRunnerCredentialAdmissionBindsTenantHostRoleAndCapability(t *testing.T) {
|
|
cfg := &config.Config{DataPath: t.TempDir()}
|
|
rawToken, record, err := agenttokens.IssueActionRunnerAndPersist(cfg, nil, agenttokens.ActionRunnerIssueOptions{
|
|
OrgID: "org-a", AgentID: "machine-a", Hostname: "node.example",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
router := &Router{config: cfg}
|
|
|
|
admission, ok := router.admitAgentExecToken(rawToken, "machine-a", "NODE")
|
|
if !ok {
|
|
t.Fatal("typed action runner credential was rejected")
|
|
}
|
|
if admission.OrganizationID != "org-a" || admission.TokenID != record.ID ||
|
|
admission.AgentID != "machine-a" || admission.RuntimeRole != agentexec.RuntimeRoleActionRunner ||
|
|
admission.ActionCapability != agentexec.ActionCapabilityTypedV1 {
|
|
t.Fatalf("action admission = %#v", admission)
|
|
}
|
|
if !router.validateAgentExecSession(admission) {
|
|
t.Fatal("fresh action runner admission failed live session validation")
|
|
}
|
|
|
|
config.Mu.Lock()
|
|
cfg.APITokens[0].Metadata[agenttokens.ActionCapabilityMetadataKey] = "shell.v1"
|
|
config.Mu.Unlock()
|
|
if router.validateAgentExecSession(admission) {
|
|
t.Fatal("session stayed valid after action capability changed")
|
|
}
|
|
}
|
|
|
|
func TestActionRunnerCredentialIssuanceRejectsIdentityOutsideSessionVocabulary(t *testing.T) {
|
|
for _, agentID := range []string{"bad agent", "-machine", strings.Repeat("a", 129)} {
|
|
t.Run(agentID, func(t *testing.T) {
|
|
rawToken, record, err := agenttokens.IssueActionRunnerAndPersist(&config.Config{}, nil, agenttokens.ActionRunnerIssueOptions{
|
|
OrgID: "org-a", AgentID: agentID, Hostname: "node.example",
|
|
})
|
|
if !errors.Is(err, agenttokens.ErrRecord) || rawToken != "" || record != nil {
|
|
t.Fatalf("action-runner issuance = (%q, %#v, %v), want closed identity rejection", rawToken, record, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestActionRunnerCredentialAdmissionRejectsWrongIdentityAndUnboundOrganization(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
agentID string
|
|
hostname string
|
|
clearOrg bool
|
|
}{
|
|
{name: "wrong agent", agentID: "other", hostname: "node.example"},
|
|
{name: "wrong host", agentID: "machine-a", hostname: "other.example"},
|
|
{name: "missing organization binding", agentID: "machine-a", hostname: "node.example", clearOrg: true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cfg := &config.Config{DataPath: t.TempDir()}
|
|
rawToken, _, err := agenttokens.IssueActionRunnerAndPersist(cfg, nil, agenttokens.ActionRunnerIssueOptions{
|
|
OrgID: "org-a", AgentID: "machine-a", Hostname: "node.example",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tc.clearOrg {
|
|
cfg.APITokens[0].OrgID = ""
|
|
}
|
|
if _, ok := (&Router{config: cfg}).admitAgentExecToken(rawToken, tc.agentID, tc.hostname); ok {
|
|
t.Fatal("mismatched action credential was admitted")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestActionRunnerCredentialRotationRevokesPreviousSecretOnlyAtActivation(t *testing.T) {
|
|
cfg := &config.Config{DataPath: t.TempDir()}
|
|
firstToken, firstRecord, err := agenttokens.IssueActionRunnerAndPersist(cfg, nil, agenttokens.ActionRunnerIssueOptions{
|
|
OrgID: "org-a", AgentID: "machine-a", Hostname: "node.example",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, _, err := agenttokens.ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), firstRecord.ID, "machine-a", "node.example"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
router := &Router{config: cfg}
|
|
if _, ok := router.admitAgentExecToken(firstToken, "machine-a", "node.example"); !ok {
|
|
t.Fatal("initial action runner credential was rejected")
|
|
}
|
|
|
|
secondToken, secondRecord, err := agenttokens.IssueActionRunnerAndPersist(cfg, nil, agenttokens.ActionRunnerIssueOptions{
|
|
OrgID: "org-a", AgentID: "machine-a", Hostname: "renamed.example",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if firstRecord.ID == secondRecord.ID || firstToken == secondToken || len(cfg.APITokens) != 2 {
|
|
t.Fatalf("rotation did not prepare beside the prior credential: %#v", cfg.APITokens)
|
|
}
|
|
if _, ok := router.admitAgentExecToken(firstToken, "machine-a", "node.example"); !ok {
|
|
t.Fatal("prepared rotation revoked the prior action runner credential")
|
|
}
|
|
if admission, ok := router.admitAgentExecToken(secondToken, "machine-a", "renamed.example"); !ok || !admission.ActivationPending {
|
|
t.Fatal("replacement action runner credential was rejected")
|
|
}
|
|
if _, revoked, changed, err := agenttokens.ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), secondRecord.ID, "machine-a", "renamed.example"); err != nil || !changed || len(revoked) != 1 || revoked[0].ID != firstRecord.ID {
|
|
t.Fatalf("rotation activation = revoked %#v, changed %v, error %v", revoked, changed, err)
|
|
}
|
|
if _, ok := router.admitAgentExecToken(firstToken, "machine-a", "node.example"); ok {
|
|
t.Fatal("activated rotation left prior action runner credential valid")
|
|
}
|
|
}
|
|
|
|
func TestSecurityActionRunnerActivationFencesPredecessorResultsAcrossPersistence(t *testing.T) {
|
|
testActionRunnerCredentialFencesPredecessorResultsAcrossPersistence(t)
|
|
}
|
|
|
|
func TestAgentExecTokenRejectsAmbiguousMultiOrganizationAuthority(t *testing.T) {
|
|
rawToken := "multi-org-agent-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, map[string]string{
|
|
"bound_agent_id": "agent-1",
|
|
"bound_hostname": "host-1",
|
|
agentExecBindingVersionKey: agentExecBindingVersion,
|
|
})
|
|
record.OrgIDs = []string{"org-a", "org-b"}
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
if _, ok := router.admitAgentExecToken(rawToken, "agent-1", "host-1"); ok {
|
|
t.Fatal("multi-organization exec token was admitted to an ambiguous command session")
|
|
}
|
|
}
|
|
|
|
func TestAgentExecTokenBindingFailsClosedAndRestoresMetadataWhenPersistenceFails(t *testing.T) {
|
|
rawToken := "persist-failure-agent-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, map[string]string{
|
|
"install_type": "docker",
|
|
"issued_via": agentInstallIssuedViaConfig,
|
|
})
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
|
|
notDirectory := filepath.Join(t.TempDir(), "not-a-directory")
|
|
persistence := config.NewConfigPersistence(notDirectory)
|
|
if err := os.RemoveAll(notDirectory); err != nil {
|
|
t.Fatalf("remove persistence directory: %v", err)
|
|
}
|
|
if err := os.WriteFile(notDirectory, []byte("blocked"), 0600); err != nil {
|
|
t.Fatalf("create persistence blocker: %v", err)
|
|
}
|
|
router := &Router{
|
|
config: cfg,
|
|
persistence: persistence,
|
|
}
|
|
|
|
if _, ok := router.admitAgentExecToken(rawToken, "agent-1", "host-1"); ok {
|
|
t.Fatal("first-use command admission succeeded without durable identity binding")
|
|
}
|
|
config.Mu.RLock()
|
|
defer config.Mu.RUnlock()
|
|
for _, key := range []string{
|
|
"bound_agent_id",
|
|
"bound_hostname",
|
|
"bound_at",
|
|
agentExecBindingVersionKey,
|
|
} {
|
|
if _, present := cfg.APITokens[0].Metadata[key]; present {
|
|
t.Fatalf("failed persistence left transient %q binding metadata behind", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAgentExecTokenBindingAcceptsHostnameMatch covers the deploy/enroll flow
|
|
// where the runtime token carries both bound_agent_id (server-canonical
|
|
// "agent-<hostname>" form) and bound_hostname, but the agent's runtime
|
|
// agent_id is derived locally from /etc/machine-id and does NOT match
|
|
// bound_agent_id. The hostname is the authoritative binding — the agent
|
|
// proves it is running on the bound host by registering with that hostname.
|
|
//
|
|
// Regression: prior to this test, the hardening commit 3ec2c0779 enforced
|
|
// strict bound_agent_id equality, which silently rejected every deploy-flow
|
|
// agent (they never produce "agent-<hostname>" as their runtime ID) and made
|
|
// the AI command tool report "No agents are currently connected" despite
|
|
// agents appearing online via HTTP reports.
|
|
func TestAgentExecTokenBindingAcceptsHostnameMatch(t *testing.T) {
|
|
rawToken := "agent-deploy-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, map[string]string{
|
|
"bound_agent_id": "agent-prox97",
|
|
"bound_hostname": "prox97",
|
|
})
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := wsURLForHTTP(ts.URL) + "/api/agent/ws"
|
|
|
|
// Agent registers with machine-id-style agent_id that does NOT match
|
|
// bound_agent_id, but hostname matches bound_hostname. Must succeed.
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
regMsg, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
|
AgentID: "f0c1b2a3e4d5f60718293a4b5c6d7e8f",
|
|
Hostname: "prox97",
|
|
Version: "1.0.0",
|
|
Platform: "linux",
|
|
Token: rawToken,
|
|
})
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatalf("NewMessage: %v", err)
|
|
}
|
|
if err := conn.WriteJSON(regMsg); err != nil {
|
|
conn.Close()
|
|
t.Fatalf("WriteJSON: %v", err)
|
|
}
|
|
reg := readRegisteredPayload(t, conn)
|
|
if !reg.Success {
|
|
conn.Close()
|
|
t.Fatalf("expected registration to be accepted when hostname matches bound_hostname, got %q", reg.Message)
|
|
}
|
|
conn.Close()
|
|
config.Mu.RLock()
|
|
if got := cfg.APITokens[0].Metadata["bound_agent_id"]; got != "f0c1b2a3e4d5f60718293a4b5c6d7e8f" {
|
|
config.Mu.RUnlock()
|
|
t.Fatalf("legacy hostname binding migrated agent id = %q", got)
|
|
}
|
|
if got := cfg.APITokens[0].Metadata[agentExecBindingVersionKey]; got != agentExecBindingVersion {
|
|
config.Mu.RUnlock()
|
|
t.Fatalf("legacy binding version = %q", got)
|
|
}
|
|
config.Mu.RUnlock()
|
|
|
|
// The hostname exception is a one-time upgrade migration. Once the runtime
|
|
// identity is persisted, replay with a second ID on the same host fails.
|
|
conn, _, err = websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
regMsg, err = agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
|
AgentID: "second-runtime-id",
|
|
Hostname: "prox97",
|
|
Version: "1.0.0",
|
|
Platform: "linux",
|
|
Token: rawToken,
|
|
})
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatalf("NewMessage: %v", err)
|
|
}
|
|
if err := conn.WriteJSON(regMsg); err != nil {
|
|
conn.Close()
|
|
t.Fatalf("WriteJSON: %v", err)
|
|
}
|
|
if replay := readRegisteredPayload(t, conn); replay.Success {
|
|
conn.Close()
|
|
t.Fatal("migrated token accepted replay under a second runtime identity")
|
|
}
|
|
conn.Close()
|
|
|
|
// Mismatched hostname AND mismatched agent_id must still be rejected —
|
|
// a leaked token cannot be used from a different host claiming a different
|
|
// agent_id.
|
|
conn, _, err = websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
regMsg, err = agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
|
AgentID: "attacker-id",
|
|
Hostname: "attacker-host",
|
|
Version: "1.0.0",
|
|
Platform: "linux",
|
|
Token: rawToken,
|
|
})
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatalf("NewMessage: %v", err)
|
|
}
|
|
if err := conn.WriteJSON(regMsg); err != nil {
|
|
conn.Close()
|
|
t.Fatalf("WriteJSON: %v", err)
|
|
}
|
|
reg = readRegisteredPayload(t, conn)
|
|
if reg.Success {
|
|
conn.Close()
|
|
t.Fatalf("expected registration to be rejected when neither hostname nor agent_id match the binding")
|
|
}
|
|
conn.Close()
|
|
}
|
|
|
|
func TestSecurityTokens_AgentExecRejectsUnboundToken(t *testing.T) {
|
|
rawToken := "agent-unbound-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := wsURLForHTTP(ts.URL) + "/api/agent/ws"
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
regMsg, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
|
AgentID: "agent-1",
|
|
Hostname: "host-1",
|
|
Version: "1.0.0",
|
|
Platform: "linux",
|
|
Token: rawToken,
|
|
})
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatalf("NewMessage: %v", err)
|
|
}
|
|
if err := conn.WriteJSON(regMsg); err != nil {
|
|
conn.Close()
|
|
t.Fatalf("WriteJSON: %v", err)
|
|
}
|
|
reg := readRegisteredPayload(t, conn)
|
|
if reg.Success {
|
|
conn.Close()
|
|
t.Fatalf("expected registration to be rejected for unbound token")
|
|
}
|
|
conn.Close()
|
|
}
|
|
|
|
func TestSecurityTokens_ProxmoxInstallExecTokenBindsOnFirstCommandRegistration(t *testing.T) {
|
|
rawToken := "agent-install-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec, config.ScopeAgentReport}, map[string]string{
|
|
"install_type": "pve",
|
|
"issued_via": agentInstallIssuedViaConfig,
|
|
})
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := wsURLForHTTP(ts.URL) + "/api/agent/ws"
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
regMsg, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
|
AgentID: "machine-id-agent",
|
|
Hostname: "delly",
|
|
Version: "1.0.0",
|
|
Platform: "linux",
|
|
Token: rawToken,
|
|
})
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatalf("NewMessage: %v", err)
|
|
}
|
|
if err := conn.WriteJSON(regMsg); err != nil {
|
|
conn.Close()
|
|
t.Fatalf("WriteJSON: %v", err)
|
|
}
|
|
reg := readRegisteredPayload(t, conn)
|
|
if !reg.Success {
|
|
conn.Close()
|
|
t.Fatalf("expected Pulse-minted Proxmox install token to bind on first command registration, got %q", reg.Message)
|
|
}
|
|
conn.Close()
|
|
|
|
config.Mu.Lock()
|
|
if got := cfg.APITokens[0].Metadata["bound_hostname"]; got != "delly" {
|
|
config.Mu.Unlock()
|
|
t.Fatalf("bound_hostname = %q, want delly", got)
|
|
}
|
|
if got := cfg.APITokens[0].Metadata["bound_agent_id"]; got != "machine-id-agent" {
|
|
config.Mu.Unlock()
|
|
t.Fatalf("bound_agent_id = %q, want machine-id-agent", got)
|
|
}
|
|
if got := cfg.APITokens[0].Metadata["bound_at"]; got == "" {
|
|
config.Mu.Unlock()
|
|
t.Fatalf("bound_at was not recorded")
|
|
}
|
|
config.Mu.Unlock()
|
|
|
|
persisted, err := config.NewConfigPersistence(cfg.DataPath).LoadAPITokens()
|
|
if err != nil {
|
|
t.Fatalf("LoadAPITokens: %v", err)
|
|
}
|
|
if len(persisted) != 1 {
|
|
t.Fatalf("persisted token count = %d, want 1", len(persisted))
|
|
}
|
|
if got := persisted[0].Metadata["bound_hostname"]; got != "delly" {
|
|
t.Fatalf("persisted bound_hostname = %q, want delly", got)
|
|
}
|
|
|
|
conn, _, err = websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial attacker: %v", err)
|
|
}
|
|
regMsg, err = agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
|
AgentID: "attacker-id",
|
|
Hostname: "other-host",
|
|
Version: "1.0.0",
|
|
Platform: "linux",
|
|
Token: rawToken,
|
|
})
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatalf("NewMessage attacker: %v", err)
|
|
}
|
|
if err := conn.WriteJSON(regMsg); err != nil {
|
|
conn.Close()
|
|
t.Fatalf("WriteJSON attacker: %v", err)
|
|
}
|
|
reg = readRegisteredPayload(t, conn)
|
|
if reg.Success {
|
|
conn.Close()
|
|
t.Fatalf("expected first-use-bound install token to reject a different host")
|
|
}
|
|
conn.Close()
|
|
}
|
|
|
|
func TestSecurityTokens_AgentExecRequiresAgentExecScope(t *testing.T) {
|
|
rawToken := "agent-scope-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")
|
|
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/agent/ws"
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
regMsg, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
|
AgentID: "agent-1",
|
|
Hostname: "host-1",
|
|
Version: "1.0.0",
|
|
Platform: "linux",
|
|
Token: rawToken,
|
|
})
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatalf("NewMessage: %v", err)
|
|
}
|
|
if err := conn.WriteJSON(regMsg); err != nil {
|
|
conn.Close()
|
|
t.Fatalf("WriteJSON: %v", err)
|
|
}
|
|
reg := readRegisteredPayload(t, conn)
|
|
if reg.Success {
|
|
conn.Close()
|
|
t.Fatalf("expected registration to be rejected without agent:exec scope")
|
|
}
|
|
conn.Close()
|
|
}
|
|
|
|
func TestSecurityTokens_AgentExecRejectsLegacySingleAPIToken(t *testing.T) {
|
|
rawToken := "legacy-agent-token-123.12345678"
|
|
cfg := &config.Config{
|
|
DataPath: t.TempDir(),
|
|
APIToken: rawToken,
|
|
APITokens: nil,
|
|
AllowedOrigins: "*",
|
|
TemperatureMonitoringEnabled: true,
|
|
}
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := wsURLForHTTP(ts.URL) + "/api/agent/ws"
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
regMsg, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
|
AgentID: "agent-legacy",
|
|
Hostname: "host-legacy",
|
|
Version: "1.0.0",
|
|
Platform: "linux",
|
|
Token: rawToken,
|
|
})
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatalf("NewMessage: %v", err)
|
|
}
|
|
if err := conn.WriteJSON(regMsg); err != nil {
|
|
conn.Close()
|
|
t.Fatalf("WriteJSON: %v", err)
|
|
}
|
|
reg := readRegisteredPayload(t, conn)
|
|
if reg.Success {
|
|
conn.Close()
|
|
t.Fatalf("expected registration to be rejected for legacy single API token")
|
|
}
|
|
conn.Close()
|
|
}
|
|
|
|
func TestWebSocketAllowsMonitoringReadScope(t *testing.T) {
|
|
rawToken := "ws-allow-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
|
|
hub := pulsews.NewHub(nil)
|
|
go hub.Run()
|
|
defer hub.Stop()
|
|
|
|
router := NewRouter(cfg, nil, nil, hub, nil, "1.0.0")
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?org_id=default"
|
|
headers := wsHeadersForHTTP(t, ts.URL)
|
|
headers.Set("X-API-Token", rawToken)
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, headers)
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
conn.Close()
|
|
}
|
|
|
|
func TestWebSocketAllowsBearerToken(t *testing.T) {
|
|
rawToken := "ws-bearer-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
|
|
hub := pulsews.NewHub(nil)
|
|
go hub.Run()
|
|
defer hub.Stop()
|
|
|
|
router := NewRouter(cfg, nil, nil, hub, nil, "1.0.0")
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?org_id=default"
|
|
headers := wsHeadersForHTTP(t, ts.URL)
|
|
headers.Set("Authorization", "Bearer "+rawToken)
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, headers)
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
conn.Close()
|
|
}
|
|
|
|
func TestWebSocketAllowsTokenQueryParam(t *testing.T) {
|
|
rawToken := "ws-query-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
|
|
hub := pulsews.NewHub(nil)
|
|
go hub.Run()
|
|
defer hub.Stop()
|
|
|
|
router := NewRouter(cfg, nil, nil, hub, nil, "1.0.0")
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?org_id=default&token=" + rawToken
|
|
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
if resp == nil || resp.StatusCode != http.StatusSwitchingProtocols {
|
|
conn.Close()
|
|
t.Fatalf("expected 101 switching protocols, got %v", resp)
|
|
}
|
|
conn.Close()
|
|
}
|
|
|
|
func TestWebSocketRejectsOrgQueryMismatchWithTenantContext(t *testing.T) {
|
|
rawToken := "ws-org-mismatch-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
|
|
hub := pulsews.NewHub(nil)
|
|
go hub.Run()
|
|
defer hub.Stop()
|
|
|
|
router := NewRouter(cfg, nil, nil, hub, nil, "1.0.0")
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?org_id=tenant-b&token=" + rawToken
|
|
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err == nil {
|
|
conn.Close()
|
|
t.Fatalf("expected websocket org mismatch rejection")
|
|
}
|
|
if resp == nil {
|
|
t.Fatalf("expected HTTP response for websocket org mismatch")
|
|
}
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for websocket org mismatch, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestWebSocketRejectsInvalidOrgQueryID(t *testing.T) {
|
|
rawToken := "ws-invalid-org-query-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
|
|
hub := pulsews.NewHub(nil)
|
|
go hub.Run()
|
|
defer hub.Stop()
|
|
|
|
router := NewRouter(cfg, nil, nil, hub, nil, "1.0.0")
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?org_id=../tenant-b&token=" + rawToken
|
|
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err == nil {
|
|
conn.Close()
|
|
t.Fatalf("expected websocket invalid org rejection")
|
|
}
|
|
if resp == nil {
|
|
t.Fatalf("expected HTTP response for websocket invalid org")
|
|
}
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for websocket invalid org, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestQueryTokenIgnoredForHTTPRequests(t *testing.T) {
|
|
rawToken := "query-token-ignored-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?token="+rawToken, nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 when token is only in query string, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
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 TestLogEndpointsRequireAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "log-auth-token-123.12345678", []string{config.ScopeSettingsRead}, 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)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth on %s, got %d", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 TestUpdateStatusRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "update-auth-token-123.12345678", []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/updates/status", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
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 TestSetupScriptURLRejectsSetupTokenAuthWhenPulseAuthIsConfigured(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
token := "0123456789abcdef0123456789abcdef"
|
|
tokenHash := auth.HashAPIToken(token)
|
|
router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{
|
|
ExpiresAt: time.Now().Add(time.Minute),
|
|
NodeType: "pve",
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/setup-script-url", strings.NewReader(`{"type":"pve","host":"pve.local"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Setup-Token", token)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected setup token auth on /api/setup-script-url to be rejected with 401, got %d (%s)", rec.Code, rec.Body.String())
|
|
}
|
|
if body := rec.Body.String(); body != `{"error":"Authentication required"}` {
|
|
t.Fatalf("body = %q, want authentication-required JSON", body)
|
|
}
|
|
}
|
|
|
|
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",
|
|
"/api/actions/plan",
|
|
"/api/actions/act_test/decision",
|
|
"/api/actions/act_test/execute",
|
|
}
|
|
|
|
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 TestAIRunCommandRequiresAIExecuteScope(t *testing.T) {
|
|
rawToken := "ai-run-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.MethodPost, "/api/ai/run-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 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 TestAIPatrolRunRequiresAIExecuteScope(t *testing.T) {
|
|
rawToken := "ai-patrol-run-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.MethodPost, "/api/ai/patrol/run", 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, 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 TestAIPatrolAutonomyRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "ai-patrol-autonomy-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.MethodGet, "/api/ai/patrol/autonomy", 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: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 TestAIPatrolAutonomyUpdateRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "ai-patrol-autonomy-update-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.MethodPut, "/api/ai/patrol/autonomy", 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 TestAIExecuteReadEndpointsRequireAIExecuteScope(t *testing.T) {
|
|
rawToken := "ai-exec-read-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/patrol/status",
|
|
"/api/ai/patrol/stream",
|
|
"/api/ai/patrol/findings",
|
|
"/api/ai/patrol/objectives",
|
|
"/api/ai/patrol/objectives/objective-1",
|
|
"/api/ai/patrol/history",
|
|
"/api/ai/patrol/runs",
|
|
"/api/ai/patrol/runs/run-1",
|
|
"/api/ai/patrol/digest",
|
|
"/api/ai/patrol/dismissed",
|
|
"/api/ai/patrol/suppressions",
|
|
"/api/ai/approvals",
|
|
"/api/ai/approvals/approval-1",
|
|
"/api/ai/intelligence",
|
|
"/api/ai/intelligence/patterns",
|
|
"/api/ai/intelligence/predictions",
|
|
"/api/ai/intelligence/correlations",
|
|
"/api/ai/intelligence/changes",
|
|
"/api/ai/intelligence/baselines",
|
|
"/api/ai/intelligence/remediations",
|
|
"/api/ai/intelligence/anomalies",
|
|
"/api/ai/intelligence/learning",
|
|
"/api/ai/unified/findings",
|
|
"/api/ai/forecast",
|
|
"/api/ai/forecasts/overview",
|
|
"/api/ai/learning/preferences",
|
|
"/api/ai/proxmox/events",
|
|
"/api/ai/proxmox/correlations",
|
|
"/api/ai/remediation/plans",
|
|
"/api/ai/remediation/plan",
|
|
"/api/ai/circuit/status",
|
|
"/api/ai/incidents",
|
|
"/api/ai/incidents/incident-1",
|
|
}
|
|
|
|
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 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 TestRelayMobileAccessScopeAllowsGovernedMobileRuntimeEndpoints(t *testing.T) {
|
|
rawToken := "relay-mobile-runtime-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeRelayMobileAccess}, 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.MethodGet, path: "/api/onboarding/qr", body: ""},
|
|
{method: http.MethodPost, path: "/api/onboarding/validate", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/onboarding/deep-link", body: ""},
|
|
{method: http.MethodGet, path: "/api/ai/patrol/findings", body: ""},
|
|
{method: http.MethodGet, path: "/api/ai/findings/finding-1/investigation", body: ""},
|
|
{method: http.MethodGet, path: "/api/ai/findings/finding-1/investigation/messages", body: ""},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/acknowledge", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/dismiss", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/snooze", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/ai/approvals", body: ""},
|
|
{method: http.MethodPost, path: "/api/ai/approvals/approval-1/approve", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/approvals/approval-1/deny", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/chat", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/ai/sessions", body: ""},
|
|
{method: http.MethodGet, path: "/api/ai/sessions/session-1/messages", body: ""},
|
|
{method: http.MethodPost, path: "/api/ai/sessions/session-1/abort", body: `{}`},
|
|
{method: http.MethodPatch, path: "/api/ai/sessions/session-1", body: `{"title":"Renamed session"}`},
|
|
{method: http.MethodDelete, path: "/api/ai/sessions/session-1", 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 && strings.Contains(rec.Body.String(), "missing_scope") {
|
|
t.Fatalf("expected relay mobile scope to pass scope gating on %s %s, got %d with body %q", tc.method, tc.path, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRelayMobileApprovalsListReturnsEmptyBeforeAIChatConfigured(t *testing.T) {
|
|
rawToken := "relay-mobile-approvals-empty-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeRelayMobileAccess}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
|
|
previousStore := approval.GetStore()
|
|
approval.SetStore(nil)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
t.Cleanup(func() {
|
|
router.shutdownBackgroundWorkers()
|
|
approval.SetStore(previousStore)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/ai/approvals", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("approvals list status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
var response struct {
|
|
Approvals []approval.ApprovalRequest `json:"approvals"`
|
|
Stats map[string]int `json:"stats"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("decode approvals list response: %v", err)
|
|
}
|
|
if response.Approvals == nil {
|
|
t.Fatal("approvals must be an empty array, not null")
|
|
}
|
|
if len(response.Approvals) != 0 {
|
|
t.Fatalf("approvals length = %d, want 0", len(response.Approvals))
|
|
}
|
|
if response.Stats["pending"] != 0 || response.Stats["approved"] != 0 || response.Stats["denied"] != 0 || response.Stats["expired"] != 0 || response.Stats["executions"] != 0 {
|
|
t.Fatalf("expected zero approval stats, got %#v", response.Stats)
|
|
}
|
|
}
|
|
|
|
func TestRelayMobileAccessScopeDeniesAdjacentAIRoutes(t *testing.T) {
|
|
rawToken := "relay-mobile-adjacent-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeRelayMobileAccess}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
want string
|
|
}{
|
|
{method: http.MethodGet, path: "/api/ai/models", body: "", want: config.ScopeAIChat},
|
|
{method: http.MethodGet, path: "/api/ai/status", body: "", want: config.ScopeAIChat},
|
|
{method: http.MethodGet, path: "/api/ai/assistant/surface-tools", body: "", want: config.ScopeAIChat},
|
|
{method: http.MethodPost, path: "/api/ai/workflow-prompts/render", body: `{}`, want: config.ScopeAIChat},
|
|
{method: http.MethodPost, path: "/api/ai/workflow-prompts/activity", body: `{}`, want: config.ScopeAIChat},
|
|
{method: http.MethodGet, path: "/api/ai/agents", body: "", want: config.ScopeAIExecute},
|
|
{method: http.MethodGet, path: "/api/ai/approvals/approval-1", body: "", want: config.ScopeAIExecute},
|
|
{method: http.MethodGet, path: "/api/ai/patrol/objectives", body: "", want: config.ScopeAIExecute},
|
|
{method: http.MethodDelete, path: "/api/ai/patrol/findings", body: "", want: config.ScopeAIExecute},
|
|
{method: http.MethodPost, path: "/api/ai/findings/finding-1/reinvestigate", body: `{}`, want: config.ScopeAIExecute},
|
|
{method: http.MethodPost, path: "/api/ai/findings/finding-1/reapprove", body: `{}`, want: config.ScopeAIExecute},
|
|
{method: http.MethodGet, path: "/api/ai/sessions/session-1", body: "", want: config.ScopeAIChat},
|
|
{method: http.MethodPost, path: "/api/ai/sessions/session-1/summarize", body: `{}`, want: config.ScopeAIChat},
|
|
{method: http.MethodGet, path: "/api/ai/sessions/session-1/diff", body: "", want: config.ScopeAIChat},
|
|
{method: http.MethodPost, path: "/api/ai/sessions/session-1/fork", body: `{}`, want: config.ScopeAIChat},
|
|
}
|
|
|
|
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 relay mobile token on %s %s, got %d", tc.method, tc.path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), tc.want) {
|
|
t.Fatalf("expected missing scope response to mention %q for %s %s, got %q", tc.want, tc.method, tc.path, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAIExecuteMutationEndpointsRequireAIExecuteScope(t *testing.T) {
|
|
rawToken := "ai-exec-mutate-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 := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodPost, path: "/api/ai/patrol/acknowledge", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/dismiss", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/findings/note", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/suppress", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/snooze", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/resolve", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/suppressions", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/ai/patrol/suppressions/rule-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/objectives", body: `{}`},
|
|
{method: http.MethodPatch, path: "/api/ai/patrol/objectives/objective-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/ai/patrol/objectives/objective-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/ai/remediation/approve", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/findings/f-1/reapprove", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/approvals/approval-1/approve", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/approvals/approval-1/deny", 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 ai:execute scope on %s %s, got %d", tc.method, tc.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 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/agent/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/snooze", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/alerts/unsnooze", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/alerts/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 TestAlertMutationEndpointsAllowMonitoringWriteWithoutReadScope(t *testing.T) {
|
|
rawToken := "alerts-write-only-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/alerts/acknowledge", 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 write-only token to reach alert mutation handler, got 403 body=%q", rec.Body.String())
|
|
}
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected validation failure (400) after passing scope checks, got %d body=%q", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
readReq := httptest.NewRequest(http.MethodGet, "/api/alerts/config", nil)
|
|
readReq.Header.Set("X-API-Token", rawToken)
|
|
readRec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(readRec, readReq)
|
|
if readRec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for read endpoint with write-only token, got %d", readRec.Code)
|
|
}
|
|
if !strings.Contains(readRec.Body.String(), config.ScopeMonitoringRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringRead, readRec.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 TestSystemSettingsUpdatePreservesAPIOnlyAuthError(t *testing.T) {
|
|
rawToken := "system-settings-api-only-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
handler := NewSystemSettingsHandler(
|
|
cfg,
|
|
config.NewConfigPersistence(cfg.DataPath),
|
|
nil,
|
|
nil,
|
|
nil,
|
|
func() {},
|
|
func() error { return nil },
|
|
)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/settings/update", strings.NewReader(`{}`))
|
|
rec := httptest.NewRecorder()
|
|
handler.HandleUpdateSystemSettings(rec, req)
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for missing API token, got %d", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "API token required") {
|
|
t.Fatalf("expected API-token-specific auth response, got %q", body)
|
|
}
|
|
if strings.Contains(body, `"unauthorized"`) {
|
|
t.Fatalf("expected route to preserve API-token-specific auth response, got generic body %q", body)
|
|
}
|
|
}
|
|
|
|
func TestSystemSettingsReportBrandingValidationRejectsUnsafePayload(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
raw map[string]interface{}
|
|
want string
|
|
}{
|
|
{
|
|
name: "non_object",
|
|
raw: map[string]interface{}{"reportBranding": "Client"},
|
|
want: "reportBranding must be an object",
|
|
},
|
|
{
|
|
name: "unsupported_key",
|
|
raw: map[string]interface{}{"reportBranding": map[string]interface{}{
|
|
"displayName": "Client",
|
|
"tenantID": "other-client",
|
|
}},
|
|
want: "reportBranding.tenantID is not supported",
|
|
},
|
|
{
|
|
name: "workspace_logo_path_unsupported",
|
|
raw: map[string]interface{}{"reportBranding": map[string]interface{}{
|
|
"logoPath": "/etc/pulse/secrets/handoff.key",
|
|
}},
|
|
want: "reportBranding.logoPath is not supported",
|
|
},
|
|
{
|
|
name: "newline",
|
|
raw: map[string]interface{}{"reportBranding": map[string]interface{}{
|
|
"displayName": "Client\nName",
|
|
}},
|
|
want: "reportBranding.displayName must not contain newlines",
|
|
},
|
|
{
|
|
name: "invalid_logo_base64",
|
|
raw: map[string]interface{}{"reportBranding": map[string]interface{}{
|
|
"logoBase64": "not base64!",
|
|
}},
|
|
want: "reportBranding.logoBase64 must be valid base64",
|
|
},
|
|
{
|
|
name: "invalid_logo_format",
|
|
raw: map[string]interface{}{"reportBranding": map[string]interface{}{
|
|
"logoFormat": "svg",
|
|
}},
|
|
want: "reportBranding.logoFormat must be png, jpg, jpeg, gif, or empty",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateSystemSettings(nil, tt.raw)
|
|
if err == nil {
|
|
t.Fatalf("expected validation error containing %q", tt.want)
|
|
}
|
|
if !strings.Contains(err.Error(), tt.want) {
|
|
t.Fatalf("unexpected validation error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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 TestConfigNodeMutationsRequireSettingsWriteScope(t *testing.T) {
|
|
rawToken := "config-node-mutate-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 {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodPost, path: "/api/config/nodes/test-config", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/config/nodes/test-connection", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/config/nodes/node-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/config/nodes/node-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/config/nodes/node-1/test", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/config/nodes/node-1/refresh-cluster", 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 settings:write scope on %s %s, got %d", tc.method, tc.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 TestTrueNASConnectionMutationsRequireSettingsWriteScope(t *testing.T) {
|
|
rawToken := "truenas-mutate-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 {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodPost, path: "/api/truenas/connections", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/truenas/connections/preview", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/truenas/connections/test", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/truenas/connections/conn-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/truenas/connections/conn-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/truenas/connections/conn-1/preview", body: ``},
|
|
{method: http.MethodPost, path: "/api/truenas/connections/conn-1/test", 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 settings:write scope on %s %s, got %d", tc.method, tc.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 TestVMwareConnectionMutationsRequireSettingsWriteScope(t *testing.T) {
|
|
rawToken := "vmware-mutate-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 {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodPost, path: "/api/vmware/connections", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/vmware/connections/preview", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/vmware/connections/test", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/vmware/connections/conn-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/vmware/connections/conn-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/vmware/connections/conn-1/preview", body: ``},
|
|
{method: http.MethodPost, path: "/api/vmware/connections/conn-1/test", 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 settings:write scope on %s %s, got %d", tc.method, tc.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 TestConfigExportRequiresSettingsReadScope(t *testing.T) {
|
|
rawToken := "config-export-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/config/export", strings.NewReader(`{}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
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 TestConfigImportRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "config-import-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/import", strings.NewReader(`{}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
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 TestConfigExportRejectsShortPassphrase(t *testing.T) {
|
|
rawToken := "config-export-pass-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/export", strings.NewReader(`{"passphrase":"short"}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for short passphrase, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Passphrase must be at least 12 characters") {
|
|
t.Fatalf("expected passphrase length error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestConfigExportRequiresPassphrase(t *testing.T) {
|
|
rawToken := "config-export-missing-pass-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/export", strings.NewReader(`{"passphrase":""}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for missing passphrase, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Passphrase is required") {
|
|
t.Fatalf("expected passphrase required error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestConfigImportRejectsMissingData(t *testing.T) {
|
|
rawToken := "config-import-data-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{"passphrase":"long-enough-passphrase","data":""}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for missing data, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Import data is required") {
|
|
t.Fatalf("expected import data error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestConfigImportRequiresPassphrase(t *testing.T) {
|
|
rawToken := "config-import-pass-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{"passphrase":"","data":"encrypted"}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for missing passphrase, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Passphrase is required") {
|
|
t.Fatalf("expected passphrase required error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestConfigExportRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "config-export-auth-token", []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/export", strings.NewReader(`{}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestConfigImportRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "config-import-auth-token", []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestConfigExportImportRejectsNonAdminSession(t *testing.T) {
|
|
hashed, err := auth.HashPassword("Password!1")
|
|
if err != nil {
|
|
t.Fatalf("HashPassword: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
DataPath: t.TempDir(),
|
|
ConfigPath: t.TempDir(),
|
|
AuthUser: "admin",
|
|
AuthPass: hashed,
|
|
}
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
sessionToken := "config-member-session-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
|
GetSessionStore().CreateSession(sessionToken, time.Hour, "agent", "127.0.0.1", "member")
|
|
csrfToken := generateCSRFToken(sessionToken)
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
body string
|
|
}{
|
|
{
|
|
name: "export",
|
|
path: "/api/config/export",
|
|
body: `{"passphrase":"long-enough-passphrase"}`,
|
|
},
|
|
{
|
|
name: "import",
|
|
path: "/api/config/import",
|
|
body: `{"passphrase":"long-enough-passphrase","data":"invalid"}`,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-CSRF-Token", csrfToken)
|
|
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
|
|
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin session on %s, got %d: %s", tc.path, rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Admin privileges required") {
|
|
t.Fatalf("expected admin privileges error on %s, got %q", tc.path, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConfigExportBlocksPublicNetworkWithoutAuth(t *testing.T) {
|
|
cfg := &config.Config{
|
|
DataPath: t.TempDir(),
|
|
ConfigPath: t.TempDir(),
|
|
}
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/export", strings.NewReader(`{}`))
|
|
req.RemoteAddr = "203.0.113.10:1234"
|
|
ResetRateLimitForIP("203.0.113.10")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for public network without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestConfigImportBlocksPublicNetworkWithoutAuth(t *testing.T) {
|
|
cfg := &config.Config{
|
|
DataPath: t.TempDir(),
|
|
ConfigPath: t.TempDir(),
|
|
}
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{}`))
|
|
req.RemoteAddr = "203.0.113.11:1234"
|
|
ResetRateLimitForIP("203.0.113.11")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for public network without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAutoRegisterRequiresAuth(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
router.configHandlers.SetConfig(cfg)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auto-register", strings.NewReader(`{}`))
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAutoRegisterRejectsAPITokenWithoutSetupToken(t *testing.T) {
|
|
rawToken := "auto-register-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")
|
|
router.configHandlers.SetConfig(cfg)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auto-register", strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 when API token is provided without a Pulse setup token, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Pulse setup token required") {
|
|
t.Fatalf("expected setup-token requirement guidance, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAutoRegisterRejectsAgentTokenWithoutSetupToken(t *testing.T) {
|
|
stubAutoRegisterNetworkDeps(t)
|
|
|
|
rawToken := "agent-register-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentReport}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
router.configHandlers.SetConfig(cfg)
|
|
|
|
// Agent-report tokens can authenticate auto-register but are restricted to
|
|
// updating existing nodes. Attempting to register a brand-new node must
|
|
// return 403, not succeed.
|
|
body := `{"type":"pve","host":"https://192.168.1.1:8006","tokenId":"pulse-monitor@pve!pulse-192-168-1-1","tokenValue":"secret","source":"agent","serverName":"newhost"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auto-register", strings.NewReader(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 when agent API token is used to register a new node, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "agent token auth permits token updates for existing nodes only") {
|
|
t.Fatalf("expected agent-token restriction message, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestConfigExportRequiresProxyAdmin(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/export", strings.NewReader(`{}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy user, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Admin privileges required") {
|
|
t.Fatalf("expected admin privilege error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestConfigImportRequiresProxyAdmin(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy user, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Admin privileges required") {
|
|
t.Fatalf("expected admin privilege error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestDiscoveryReadEndpointsRequireMonitoringReadScope(t *testing.T) {
|
|
rawToken := "discovery-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/discovery",
|
|
"/api/discovery/status",
|
|
"/api/discovery/info/host-1",
|
|
"/api/discovery/type/pve",
|
|
"/api/discovery/agent/host-1",
|
|
"/api/discovery/agent/host-1/resource-1",
|
|
"/api/discovery/agent/host-1/resource-1/progress",
|
|
"/api/discovery/agent/host-1",
|
|
"/api/discovery/agent/host-1/resource-1",
|
|
"/api/discovery/agent/host-1/resource-1/progress",
|
|
"/api/discovery/resource-1",
|
|
"/api/discovery/resource-1/progress",
|
|
}
|
|
|
|
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 TestDiscoveryMutationEndpointsRequireWriteScopes(t *testing.T) {
|
|
rawToken := "discovery-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
|
|
requiredScope string
|
|
}{
|
|
{method: http.MethodPost, path: "/api/discovery/run", body: `{}`, requiredScope: config.ScopeSettingsWrite},
|
|
{method: http.MethodPost, path: "/api/discovery/agent/host-1/resource-1", body: `{}`, requiredScope: config.ScopeSettingsWrite},
|
|
{method: http.MethodPut, path: "/api/discovery/agent/host-1/resource-1/notes", body: `{}`, requiredScope: config.ScopeMonitoringWrite},
|
|
{method: http.MethodPut, path: "/api/discovery/agent/host-1/resource-1/availability-proposal", body: `{}`, requiredScope: config.ScopeMonitoringWrite},
|
|
{method: http.MethodDelete, path: "/api/discovery/agent/host-1/resource-1", body: "", requiredScope: config.ScopeMonitoringWrite},
|
|
{method: http.MethodPost, path: "/api/discovery/agent/host-1/resource-1", body: `{}`, requiredScope: config.ScopeSettingsWrite},
|
|
{method: http.MethodPut, path: "/api/discovery/agent/host-1/resource-1/notes", body: `{}`, requiredScope: config.ScopeMonitoringWrite},
|
|
{method: http.MethodPut, path: "/api/discovery/agent/host-1/resource-1/availability-proposal", body: `{}`, requiredScope: config.ScopeMonitoringWrite},
|
|
{method: http.MethodDelete, path: "/api/discovery/agent/host-1/resource-1", body: "", requiredScope: config.ScopeMonitoringWrite},
|
|
{method: http.MethodPost, path: "/api/discovery/resource-1", body: `{}`, requiredScope: config.ScopeSettingsWrite},
|
|
{method: http.MethodPut, path: "/api/discovery/resource-1/notes", body: `{}`, requiredScope: config.ScopeMonitoringWrite},
|
|
{method: http.MethodDelete, path: "/api/discovery/resource-1", body: "", requiredScope: config.ScopeMonitoringWrite},
|
|
}
|
|
|
|
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 %s scope on %s %s, got %d", tc.requiredScope, tc.method, tc.path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), tc.requiredScope) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", tc.requiredScope, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDiscoverySettingsRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "discovery-settings-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/discovery/settings", 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 TestDiscoverySettingsRejectsProxyNonAdmin(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
service := servicediscovery.NewService(nil, nil, servicediscovery.DefaultConfig())
|
|
router.SetDiscoveryService(service)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/discovery/settings", strings.NewReader(`{"max_discovery_age_days":5}`))
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy discovery settings, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestDiscoveryNotesRejectsProxyUserSecrets(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
service := servicediscovery.NewService(nil, nil, servicediscovery.DefaultConfig())
|
|
router.SetDiscoveryService(service)
|
|
|
|
payload := `{"user_notes":"note","user_secrets":{"token":"abc"}}`
|
|
req := httptest.NewRequest(http.MethodPut, "/api/discovery/agent/host-1/resource-1/notes", strings.NewReader(payload))
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy discovery secrets, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "user_secrets") {
|
|
t.Fatalf("expected user_secrets error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestNotificationsRequireProxyAdmin(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/notifications/queue/stats", nil)
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy user, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Admin privileges required") {
|
|
t.Fatalf("expected admin privilege error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestProxyAuthNonAdminDeniedAdminEndpoints(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
router.aiSettingsHandler.defaultConfig = cfg
|
|
|
|
cases := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodGet, path: "/api/logs/stream", body: ""},
|
|
{method: http.MethodGet, path: "/api/logs/download", body: ""},
|
|
{method: http.MethodGet, path: "/api/logs/level", body: ""},
|
|
{method: http.MethodPost, path: "/api/logs/level", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/updates/check", body: ""},
|
|
{method: http.MethodPost, path: "/api/updates/apply", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/updates/status", body: ""},
|
|
{method: http.MethodGet, path: "/api/updates/stream", body: ""},
|
|
{method: http.MethodGet, path: "/api/updates/plan", body: ""},
|
|
{method: http.MethodGet, path: "/api/updates/history", body: ""},
|
|
{method: http.MethodGet, path: "/api/updates/history/entry", body: ""},
|
|
{method: http.MethodGet, path: "/api/diagnostics", body: ""},
|
|
{method: http.MethodPost, path: "/api/diagnostics/docker/prepare-token", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/config/system", body: ""},
|
|
{method: http.MethodPost, path: "/api/config/export", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/config/import", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/config/nodes", body: ""},
|
|
{method: http.MethodPost, path: "/api/config/nodes", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/config/nodes/test-config", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/config/nodes/test-connection", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/config/nodes/node-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/config/nodes/node-1", body: ``},
|
|
{method: http.MethodPost, path: "/api/config/nodes/node-1/test", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/config/nodes/node-1/refresh-cluster", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/system/settings", body: ""},
|
|
{method: http.MethodPost, path: "/api/system/settings/update", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/security/reset-lockout", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/security/apply-restart", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/security/tokens/relay-mobile", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/security/tokens", body: ``},
|
|
{method: http.MethodDelete, path: "/api/security/tokens/token-1", body: ``},
|
|
{method: http.MethodPost, path: "/api/security/regenerate-token", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/security/validate-token", body: `{"token":"abc"}`},
|
|
{method: http.MethodPost, path: "/api/system/verify-temperature-ssh", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/system/ssh-config", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/audit", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/roles", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/users", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/reports/generate", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/webhooks/audit", body: ""},
|
|
{method: http.MethodGet, path: "/api/settings/ai", body: ""},
|
|
{method: http.MethodGet, path: "/api/ai/debug/context", body: ""},
|
|
{method: http.MethodPost, path: "/api/ai/execute", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/execute/stream", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/kubernetes/analyze", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/investigate-alert", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/run-command", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/remediation/execute", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/remediation/rollback", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/run", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/ai/patrol/autonomy", body: ""},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/autonomy/acknowledgements", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/ai/patrol/autonomy/acknowledgements/ack-test", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/cost/reset", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/ai/cost/export", body: ""},
|
|
{method: http.MethodPost, path: "/api/ai/oauth/start", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/oauth/exchange", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/oauth/disconnect", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/test", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/test/openai", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/agents/docker/containers/update", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/agents/docker/runtimes/host-1/update-all", body: ``},
|
|
{method: http.MethodDelete, path: "/api/agents/docker/runtimes/host-1", body: ``},
|
|
{method: http.MethodDelete, path: "/api/agents/kubernetes/clusters/cluster-1", body: ``},
|
|
{method: http.MethodPost, path: "/api/agents/agent/link", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/agents/agent/unlink", body: `{}`},
|
|
{method: http.MethodPatch, path: "/api/agents/agent/host-1/config", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/agents/agent/agent-1", body: ``},
|
|
{method: http.MethodPost, path: "/api/agents/host/link", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/agents/host/unlink", body: `{}`},
|
|
{method: http.MethodPatch, path: "/api/agents/host/host-1/config", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/agents/host/agent-1", body: ``},
|
|
{method: http.MethodGet, path: "/api/admin/profiles/", body: ""},
|
|
{method: http.MethodPost, path: "/api/agent-install-command", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/setup-script-url", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/test-notification", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/discover", body: ""},
|
|
{method: http.MethodPost, path: "/api/license/activate", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/license/clear", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/license/status", body: ""},
|
|
{method: http.MethodGet, path: "/api/notifications/queue/stats", body: ""},
|
|
{method: http.MethodGet, path: "/api/notifications/", body: ""},
|
|
{method: http.MethodGet, path: "/api/notifications/dlq", body: ""},
|
|
{method: http.MethodPost, path: "/api/notifications/dlq/retry", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/notifications/dlq/delete", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/notifications/terminal-failures/retry", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/notifications/terminal-failures/dismiss", body: `{}`},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy user on %s %s, got %d", tc.method, tc.path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Admin privileges required") {
|
|
t.Fatalf("expected admin privilege error on %s %s, got %q", tc.method, tc.path, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDockerAgentEndpointsRequireDockerReportScope(t *testing.T) {
|
|
rawToken := "docker-report-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/agents/docker/report",
|
|
"/api/agents/docker/commands/command-1",
|
|
}
|
|
|
|
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 docker:report scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeDockerReport) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeDockerReport, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDockerManageEndpointsRequireDockerManageScope(t *testing.T) {
|
|
rawToken := "docker-manage-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeDockerReport}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/agents/docker/runtimes/host-1",
|
|
"/api/agents/docker/containers/update",
|
|
}
|
|
|
|
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 docker:manage scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeDockerManage) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeDockerManage, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestKubernetesAgentEndpointsRequireKubernetesReportScope(t *testing.T) {
|
|
rawToken := "kube-report-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/agents/kubernetes/report", 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 kubernetes:report scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeKubernetesReport) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeKubernetesReport, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestKubernetesManageEndpointsRequireKubernetesManageScope(t *testing.T) {
|
|
rawToken := "kube-manage-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeKubernetesReport}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/agents/kubernetes/clusters/cluster-1", 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 kubernetes:manage scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeKubernetesManage) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeKubernetesManage, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUnifiedAgentEndpointsRequireAgentReportScope(t *testing.T) {
|
|
rawToken := "host-report-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/agents/agent/report",
|
|
"/api/agents/agent/lookup",
|
|
"/api/agents/agent/uninstall",
|
|
"/api/agents/host/report",
|
|
"/api/agents/host/lookup",
|
|
"/api/agents/host/uninstall",
|
|
}
|
|
|
|
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 agent:report scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeAgentReport) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAgentReport, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUnifiedAgentEndpointsAcceptLegacyUnifiedAgentReportScopeAlias(t *testing.T) {
|
|
rawToken := "legacy-host-report-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{"host-agent:report"}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
for _, path := range []string{
|
|
"/api/agents/agent/report",
|
|
"/api/agents/host/report",
|
|
} {
|
|
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 legacy host-agent:report scope alias to pass scope gate on %s, got 403: %s", path, rec.Body.String())
|
|
}
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected request to reach handler and fail on invalid JSON for %s, got %d", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUnifiedAgentConfigPatchRequiresAgentManageScope(t *testing.T) {
|
|
rawToken := "host-config-manage-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentConfigRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
for _, path := range []string{"/api/agents/agent/host-1/config", "/api/agents/host/host-1/config"} {
|
|
req := httptest.NewRequest(http.MethodPatch, 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 agent:manage scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeAgentManage) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAgentManage, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMonitoringReadEndpointsRequireMonitoringReadScope(t *testing.T) {
|
|
rawToken := "monitoring-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/config",
|
|
"/api/runtime/branding",
|
|
"/api/storage/host-1",
|
|
"/api/storage-charts",
|
|
"/api/charts",
|
|
"/api/charts/workloads",
|
|
"/api/charts/storage-summary",
|
|
"/api/metrics-store/stats",
|
|
"/api/metrics-store/history",
|
|
"/api/availability-history",
|
|
"/api/guests/metadata",
|
|
"/api/guests/metadata/guest-1",
|
|
"/api/docker/metadata",
|
|
"/api/docker/metadata/container-1",
|
|
"/api/docker/runtimes/metadata",
|
|
"/api/docker/runtimes/metadata/host-1",
|
|
"/api/agents/metadata",
|
|
"/api/agents/metadata/host-1",
|
|
"/api/agents/metadata",
|
|
"/api/agents/metadata/host-1",
|
|
}
|
|
|
|
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 TestMetadataMutationEndpointsRequireMonitoringWriteScope(t *testing.T) {
|
|
rawToken := "metadata-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.MethodPost, path: "/api/guests/metadata/guest-1", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/guests/metadata/guest-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/guests/metadata/guest-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/docker/metadata/container-1", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/docker/metadata/container-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/docker/metadata/container-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/docker/runtimes/metadata/host-1", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/docker/runtimes/metadata/host-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/docker/runtimes/metadata/host-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/agents/metadata/host-1", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/agents/metadata/host-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/agents/metadata/host-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/agents/metadata/host-1", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/agents/metadata/host-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/agents/metadata/host-1", 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 TestAISettingsReadRequiresSettingsReadScope(t *testing.T) {
|
|
rawToken := "ai-settings-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/settings/ai", 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 TestAISettingsWriteRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "ai-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")
|
|
|
|
paths := []string{
|
|
"/api/settings/ai/update",
|
|
"/api/ai/test",
|
|
"/api/ai/test/openai",
|
|
}
|
|
|
|
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 TestAISettingsUpdateRejectsProxyNonAdmin(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
router.aiSettingsHandler.defaultConfig = cfg
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/settings/ai/update", strings.NewReader(`{}`))
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy AI settings update, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAITestConnectionRejectsProxyNonAdmin(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
router.aiSettingsHandler.defaultConfig = cfg
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/ai/test", strings.NewReader(`{}`))
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy AI test, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAITestProviderRejectsProxyNonAdmin(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
router.aiSettingsHandler.defaultConfig = cfg
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/ai/test/openai", strings.NewReader(`{}`))
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy AI provider test, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAITestConnectionRouteWithValidScope(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Set up a mock Ollama server for the AI connection test
|
|
ollama := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/api/version" {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"version": "0.1.0"})
|
|
return
|
|
}
|
|
if r.URL.Path == "/api/tags" {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"models": []map[string]any{
|
|
{"name": "llama3", "modified_at": "2026-04-03T09:00:00Z", "size": 1},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer ollama.Close()
|
|
|
|
router, rawToken := setupTestConnectionRouter(t, ollama.URL)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/ai/test", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for valid settings:write scope on /api/ai/test, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
Success bool `json:"success"`
|
|
Message string `json:"message"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if !resp.Success {
|
|
t.Fatalf("expected success=true, got %+v", resp)
|
|
}
|
|
}
|
|
|
|
func TestAITestProviderRouteWithValidScope(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Set up a mock Ollama server for the provider test
|
|
ollama := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/api/version" {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"version": "0.1.0"})
|
|
return
|
|
}
|
|
if r.URL.Path == "/api/tags" {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"models": []map[string]any{
|
|
{"name": "llama3", "modified_at": "2026-04-03T09:00:00Z", "size": 1},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer ollama.Close()
|
|
|
|
router, rawToken := setupTestProviderRouter(t, ollama.URL)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/ai/test/ollama", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for valid settings:write scope on /api/ai/test/ollama, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
Success bool `json:"success"`
|
|
Provider string `json:"provider"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if !resp.Success || resp.Provider != "ollama" {
|
|
t.Fatalf("expected success=true provider=ollama, got %+v", resp)
|
|
}
|
|
}
|
|
|
|
func TestAITestConnectionRouteRejectsWrongScope(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
router, rawToken := setupAIRouteRouter(t, newAIRouteTestOptions([]string{config.ScopeMonitoringRead}, "http://192.0.2.1:11434"))
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/ai/test", 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 wrong scope on /api/ai/test, 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 TestAITestProviderRouteRejectsWrongScope(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
router, rawToken := setupTestProviderRouterForScopes(t, "http://192.0.2.1:11434", []string{config.ScopeMonitoringRead})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/ai/test/openai", 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 wrong scope on /api/ai/test/openai, 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 TestAIChatEndpointsRequireAIChatScope(t *testing.T) {
|
|
rawToken := "ai-chat-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.MethodGet, path: "/api/ai/models", body: ""},
|
|
{method: http.MethodGet, path: "/api/ai/assistant/surface-tools", body: ""},
|
|
{method: http.MethodPost, path: "/api/ai/workflow-prompts/render", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/workflow-prompts/activity", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/chat", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/ai/sessions", body: ""},
|
|
{method: http.MethodGet, path: "/api/ai/sessions/session-1", body: ""},
|
|
{method: http.MethodGet, path: "/api/ai/question/q-1", body: ""},
|
|
{method: http.MethodGet, path: "/api/ai/knowledge", body: ""},
|
|
{method: http.MethodGet, path: "/api/ai/knowledge/export", 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 ai:chat scope on %s %s, got %d", tc.method, tc.path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeAIChat) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIChat, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAIKnowledgeMutationEndpointsRequireAIExecuteScope(t *testing.T) {
|
|
rawToken := "ai-chat-only-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")
|
|
|
|
for _, path := range []string{
|
|
"/api/ai/knowledge/save",
|
|
"/api/ai/knowledge/delete",
|
|
"/api/ai/knowledge/import",
|
|
"/api/ai/knowledge/clear",
|
|
} {
|
|
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 || !strings.Contains(rec.Body.String(), config.ScopeAIExecute) {
|
|
t.Fatalf("expected %s to require %q, got %d: %s", path, config.ScopeAIExecute, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAuditEndpointsRequireLicenseFeature(t *testing.T) {
|
|
rawToken := "audit-license-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead, config.ScopeAuditRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/audit", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusPaymentRequired {
|
|
t.Fatalf("expected 402 for missing audit logging license, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAuditVerifyRequiresLicenseFeature(t *testing.T) {
|
|
rawToken := "audit-verify-license-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead, config.ScopeAuditRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/audit/event-1/verify", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusPaymentRequired {
|
|
t.Fatalf("expected 402 for missing audit logging license, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestReportingCatalogDoesNotRequireLicenseFeature(t *testing.T) {
|
|
rawToken := "reporting-license-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.MethodGet, "/api/admin/reports/catalog", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for reporting catalog without reporting license, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestReportingExecutionEndpointsRequireLicenseFeature(t *testing.T) {
|
|
rawToken := "reporting-license-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")
|
|
|
|
cases := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodGet, path: "/api/admin/reports/generate", body: ""},
|
|
{method: http.MethodPost, path: "/api/admin/reports/generate-multi", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/admin/reports/inventory/vms/export", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/reports/schedules", body: ""},
|
|
{method: http.MethodPost, path: "/api/admin/reports/schedules", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/admin/reports/schedules/schedule-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/admin/reports/schedules/schedule-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/admin/reports/schedules/schedule-1/run", body: ""},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
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.StatusPaymentRequired {
|
|
t.Fatalf("expected 402 for missing reporting license on %s %s, got %d", tc.method, tc.path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRBACEndpointsRequireLicenseFeature(t *testing.T) {
|
|
rawToken := "rbac-license-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/admin/roles",
|
|
"/api/admin/users",
|
|
}
|
|
|
|
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.StatusPaymentRequired {
|
|
t.Fatalf("expected 402 for missing RBAC license on %s, got %d", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRBACMutationsRequireLicenseFeature(t *testing.T) {
|
|
rawToken := "rbac-license-mutation-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")
|
|
|
|
cases := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodPost, path: "/api/admin/roles", body: `{"id":"role-1","name":"Role 1"}`},
|
|
{method: http.MethodPut, path: "/api/admin/roles/role-1", body: `{"id":"role-1","name":"Role 1"}`},
|
|
{method: http.MethodDelete, path: "/api/admin/roles/role-1", body: ``},
|
|
{method: http.MethodPut, path: "/api/admin/users/alice/roles", body: `{"roleIds":["role-1"]}`},
|
|
{method: http.MethodPost, path: "/api/admin/users/alice/roles", body: `{"roleIds":["role-1"]}`},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
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.StatusPaymentRequired {
|
|
t.Fatalf("expected 402 for missing RBAC license on %s %s, got %d", tc.method, tc.path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAuditWebhookRequiresLicenseFeature(t *testing.T) {
|
|
rawToken := "audit-webhook-license-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.MethodGet, "/api/admin/webhooks/audit", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusPaymentRequired {
|
|
t.Fatalf("expected 402 for missing audit logging license, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSecurityTokensReadRequiresSettingsReadScope(t *testing.T) {
|
|
rawToken := "security-tokens-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/security/tokens", 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 TestSecurityTokensWriteRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "security-tokens-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/security/tokens/relay-mobile",
|
|
"/api/security/tokens",
|
|
"/api/security/tokens/token-1",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
|
|
if strings.Contains(path, "/token-") {
|
|
req = httptest.NewRequest(http.MethodDelete, 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: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 TestAgentProfilesRequireLicenseFeature(t *testing.T) {
|
|
rawToken := "profiles-license-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/profiles/", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusPaymentRequired {
|
|
t.Fatalf("expected 402 for missing agent profiles license, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAILicensedEndpointsRequireLicenseFeature(t *testing.T) {
|
|
rawToken := "ai-license-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAIExecute}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/ai/kubernetes/analyze",
|
|
"/api/ai/investigate-alert",
|
|
"/api/ai/findings/f-1/reinvestigate",
|
|
"/api/ai/findings/f-1/reapprove",
|
|
}
|
|
|
|
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.StatusPaymentRequired {
|
|
t.Fatalf("expected 402 for missing AI license on %s, got %d", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 TestAgentFleetDiagnosticsRequireSettingsReadScope(t *testing.T) {
|
|
rawToken := "agent-fleet-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/agents/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())
|
|
}
|
|
}
|
|
|
|
func TestPermissionProtectedEndpointsDenyWhenAuthorizerBlocks(t *testing.T) {
|
|
prevAuthorizer := auth.GetAuthorizer()
|
|
auth.SetAuthorizer(&denyAuthorizer{})
|
|
defer auth.SetAuthorizer(prevAuthorizer)
|
|
|
|
rawToken := "perm-deny-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead, config.ScopeAuditRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
cases := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodGet, path: "/api/audit", body: ""},
|
|
{method: http.MethodGet, path: "/api/audit/event-1/verify", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/roles", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/roles/", body: ""},
|
|
{method: http.MethodPost, path: "/api/admin/roles", body: `{"id":"role-1","name":"Role 1"}`},
|
|
{method: http.MethodPut, path: "/api/admin/roles/role-1", body: `{"id":"role-1","name":"Role 1"}`},
|
|
{method: http.MethodDelete, path: "/api/admin/roles/role-1", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/users", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/users/", body: ""},
|
|
{method: http.MethodPut, path: "/api/admin/users/alice/roles", body: `{"roleIds":["role-1"]}`},
|
|
{method: http.MethodPost, path: "/api/admin/users/alice/roles", body: `{"roleIds":["role-1"]}`},
|
|
{method: http.MethodGet, path: "/api/admin/users/alice/permissions", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/reports/catalog", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/reports/generate", body: ""},
|
|
{method: http.MethodPost, path: "/api/admin/reports/generate-multi", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/admin/reports/schedules", body: ""},
|
|
{method: http.MethodPost, path: "/api/admin/reports/schedules", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/admin/reports/schedules/schedule-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/admin/reports/schedules/schedule-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/admin/reports/schedules/schedule-1/run", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/webhooks/audit", body: ""},
|
|
{method: http.MethodPost, path: "/api/security/tokens/relay-mobile", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/security/tokens", body: ""},
|
|
{method: http.MethodDelete, path: "/api/security/tokens/token-1", body: ""},
|
|
{method: http.MethodGet, path: "/api/settings/ai", body: ""},
|
|
{method: http.MethodPost, path: "/api/settings/ai/update", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/test", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/test/openai", body: `{}`},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
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 permission denial on %s %s, got %d", tc.method, tc.path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPermissionEndpointsRejectProxyNonAdmin(t *testing.T) {
|
|
prevAuthorizer := auth.GetAuthorizer()
|
|
auth.SetAuthorizer(&auth.DefaultAuthorizer{})
|
|
defer auth.SetAuthorizer(prevAuthorizer)
|
|
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/security/tokens", nil)
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy on permissioned endpoint, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Admin privileges required") {
|
|
t.Fatalf("expected admin privilege error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestApplyRestartRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "apply-restart-token-123.12345678", []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/apply-restart", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestApplyRestartRequiresBootstrapTokenBeforeAuthentication(t *testing.T) {
|
|
t.Setenv("INVOCATION_ID", "")
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
|
resetTrustedProxyConfig()
|
|
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
bootstrapToken, _, _, err := loadOrCreateBootstrapToken(cfg.DataPath)
|
|
if err != nil {
|
|
t.Fatalf("load bootstrap token: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
token string
|
|
wantStatus int
|
|
}{
|
|
{name: "missing token", wantStatus: http.StatusUnauthorized},
|
|
{name: "invalid token", token: "invalid-token", wantStatus: http.StatusUnauthorized},
|
|
{name: "valid token", token: bootstrapToken, wantStatus: http.StatusOK},
|
|
}
|
|
|
|
for i, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/apply-restart", nil)
|
|
if tt.token == bootstrapToken {
|
|
req.RemoteAddr = "127.0.0.1:54321"
|
|
} else {
|
|
req.RemoteAddr = fmt.Sprintf("198.51.100.%d:54321", 101+i)
|
|
}
|
|
if tt.token != "" {
|
|
req.Header.Set(bootstrapTokenHeader, tt.token)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != tt.wantStatus {
|
|
t.Fatalf("status = %d, want %d (%s)", rec.Code, tt.wantStatus, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApplyRestartRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "apply-restart-scope-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/apply-restart", 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: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 TestApplyRestartRequiresProxyAdmin(t *testing.T) {
|
|
record := newTokenRecord(t, "apply-restart-proxy-token-123.12345678", []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/apply-restart", nil)
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy user, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Admin privileges required") {
|
|
t.Fatalf("expected admin privilege error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPrivilegedSecurityEndpointsRejectNonAdminSession(t *testing.T) {
|
|
hashed, err := auth.HashPassword("Password!1")
|
|
if err != nil {
|
|
t.Fatalf("HashPassword: %v", err)
|
|
}
|
|
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = hashed
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
sessionToken := "security-member-session-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
|
GetSessionStore().CreateSession(sessionToken, time.Hour, "agent", "127.0.0.1", "member")
|
|
tests := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodGet, path: "/api/system/settings", body: ``},
|
|
{method: http.MethodPost, path: "/api/system/settings/update", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/diagnostics", body: ``},
|
|
{method: http.MethodGet, path: "/api/diagnostics/docker/prepare-token", body: ``},
|
|
{method: http.MethodGet, path: "/api/license/status", body: ``},
|
|
{method: http.MethodGet, path: "/api/updates/check", body: ``},
|
|
{method: http.MethodGet, path: "/api/updates/status", body: ``},
|
|
{method: http.MethodGet, path: "/api/updates/plan", body: ``},
|
|
{method: http.MethodGet, path: "/api/updates/history", body: ``},
|
|
{method: http.MethodPost, path: "/api/updates/apply", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/logs/stream", body: ``},
|
|
{method: http.MethodGet, path: "/api/logs/download", body: ``},
|
|
{method: http.MethodGet, path: "/api/settings/relay", body: ``},
|
|
{method: http.MethodGet, path: "/api/settings/relay/status", body: ``},
|
|
{method: http.MethodPut, path: "/api/settings/relay", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/settings/ai", body: ``},
|
|
{method: http.MethodGet, path: "/api/ai/cost/summary", body: ``},
|
|
{method: http.MethodPost, path: "/api/security/tokens/relay-mobile", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/security/tokens", body: ``},
|
|
{method: http.MethodDelete, path: "/api/security/tokens/token-1", body: ``},
|
|
{method: http.MethodGet, path: "/api/security/sso/providers", body: ``},
|
|
{method: http.MethodPost, path: "/api/security/sso/providers", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/security/sso/providers/test", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/security/sso/providers/metadata/preview", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/admin/roles", body: ``},
|
|
{method: http.MethodGet, path: "/api/admin/users", body: ``},
|
|
{method: http.MethodGet, path: "/api/admin/rbac/integrity", body: ``},
|
|
{method: http.MethodPost, path: "/api/admin/rbac/reset-admin", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/admin/webhooks/audit", body: ``},
|
|
{method: http.MethodGet, path: "/api/onboarding/qr", body: ``},
|
|
{method: http.MethodGet, path: "/api/onboarding/deep-link", body: ``},
|
|
{method: http.MethodPost, path: "/api/onboarding/validate", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/security/apply-restart", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/security/regenerate-token", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/security/validate-token", body: `{"token":"abc"}`},
|
|
{method: http.MethodPost, path: "/api/settings/ai/update", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/test", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/test/openai", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/security/reset-lockout", body: `{"identifier":"admin"}`},
|
|
{method: http.MethodPost, path: "/api/system/verify-temperature-ssh", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/system/ssh-config", body: `{}`},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if tc.method != http.MethodGet && tc.method != http.MethodHead {
|
|
req.Header.Set("X-CSRF-Token", generateCSRFToken(sessionToken))
|
|
}
|
|
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
|
|
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin session on %s %s, got %d: %s", tc.method, tc.path, rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Admin privileges required") {
|
|
t.Fatalf("expected admin privilege error on %s %s, got %q", tc.method, tc.path, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVerifyTemperatureSSHRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "verify-ssh-token-123.12345678", []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh", strings.NewReader(`{}`))
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestVerifyTemperatureSSHRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "verify-ssh-scope-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/verify-temperature-ssh", 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 TestSSHConfigRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "ssh-config-token-123.12345678", []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/ssh-config", strings.NewReader(`{}`))
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSSHConfigRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "ssh-config-scope-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/ssh-config", 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 TestQuickSetupRequiresAuthWhenConfigured(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed-password"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.20")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(`{"username":"admin","password":"Password!1"}`))
|
|
req.RemoteAddr = "203.0.113.20:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestQuickSetupRejectsProxyNonAdmin(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed-password"
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.27")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(`{}`))
|
|
req.RemoteAddr = "203.0.113.27:1234"
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy quick setup, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRegenerateTokenRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "regen-token-123.12345678", []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.21")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/regenerate-token", nil)
|
|
req.RemoteAddr = "203.0.113.21:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRegenerateTokenRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "regen-scope-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")
|
|
|
|
ResetRateLimitForIP("203.0.113.22")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/regenerate-token", nil)
|
|
req.RemoteAddr = "203.0.113.22:1234"
|
|
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 TestRegenerateTokenRejectsProxyNonAdmin(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.25")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/regenerate-token", nil)
|
|
req.RemoteAddr = "203.0.113.25:1234"
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy regenerate-token, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestValidateTokenRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "validate-token-123.12345678", []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.23")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/validate-token", strings.NewReader(`{"token":"abc"}`))
|
|
req.RemoteAddr = "203.0.113.23:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestValidateTokenRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "validate-scope-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")
|
|
|
|
ResetRateLimitForIP("203.0.113.24")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/validate-token", strings.NewReader(`{"token":"abc"}`))
|
|
req.RemoteAddr = "203.0.113.24:1234"
|
|
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 TestValidateTokenRejectsProxyNonAdmin(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.26")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/validate-token", strings.NewReader(`{"token":"abc"}`))
|
|
req.RemoteAddr = "203.0.113.26:1234"
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy validate-token, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRecoveryEndpointRejectsRemoteWithoutToken(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.30")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/recovery", strings.NewReader(`{"action":"status"}`))
|
|
req.RemoteAddr = "203.0.113.30:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for remote recovery request, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHealthEndpointIsPublicEvenWhenAuthConfigured(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
monitor, err := monitoring.New(cfg)
|
|
if err != nil {
|
|
t.Fatalf("monitoring.New: %v", err)
|
|
}
|
|
defer monitor.Stop()
|
|
|
|
router := NewRouter(cfg, monitor, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.40")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
|
req.RemoteAddr = "203.0.113.40:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for public health endpoint, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestVersionEndpointIsPublicEvenWhenAuthConfigured(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.41")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
|
req.RemoteAddr = "203.0.113.41:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for public version endpoint, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAgentVersionEndpointIsPublicEvenWhenAuthConfigured(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.42")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/version", nil)
|
|
req.RemoteAddr = "203.0.113.42:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for public agent version endpoint, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestServerInfoEndpointIsPublicEvenWhenAuthConfigured(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.43")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/server/info", nil)
|
|
req.RemoteAddr = "203.0.113.43:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for public server info endpoint, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSecurityStatusIsPublicEvenWhenAuthConfigured(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.44")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil)
|
|
req.RemoteAddr = "203.0.113.44:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for public security status endpoint, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestValidateBootstrapTokenBypassesAuth(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.45")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{}`))
|
|
req.RemoteAddr = "203.0.113.45:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusConflict {
|
|
t.Fatalf("expected 409 when bootstrap token is unavailable, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSecurityStatusHidesBootstrapTokenWhenAuthConfigured(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.49")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil)
|
|
req.RemoteAddr = "203.0.113.49:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for security status, got %d", rec.Code)
|
|
}
|
|
|
|
var payload map[string]interface{}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if _, ok := payload["bootstrapTokenPath"]; ok {
|
|
t.Fatalf("expected bootstrapTokenPath to be omitted when auth is configured")
|
|
}
|
|
}
|
|
|
|
func TestSecurityStatusOmitsPrivilegedDetailsWhenUnauthenticated(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.50")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil)
|
|
req.RemoteAddr = "203.0.113.50:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for security status, got %d", rec.Code)
|
|
}
|
|
|
|
var payload map[string]interface{}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if got := payload["detailLevel"]; got != securityStatusDetailPublic {
|
|
t.Fatalf("detailLevel = %v, want %q", got, securityStatusDetailPublic)
|
|
}
|
|
if got, _ := payload["hasAuthentication"].(bool); got {
|
|
t.Fatalf("hasAuthentication = %v, want false", payload["hasAuthentication"])
|
|
}
|
|
privilegedKeys := []string{
|
|
"apiTokenConfigured",
|
|
"apiTokenHint",
|
|
"authLastModified",
|
|
"bootstrapTokenPath",
|
|
"clientIP",
|
|
"agentUrl",
|
|
"hasAuditLogging",
|
|
"isDocker",
|
|
"inContainer",
|
|
"lxcCtid",
|
|
"dockerContainerName",
|
|
"settingsCapabilities",
|
|
}
|
|
for _, key := range privilegedKeys {
|
|
if _, ok := payload[key]; ok {
|
|
t.Errorf("unauthenticated status exposed privileged field %q", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAuditRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "audit-auth-token-123.12345678", []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/audit", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSecurityStatusIgnoresTokenQueryParam(t *testing.T) {
|
|
rawToken := "status-query-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/security/status?token="+rawToken, nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for security status, got %d", rec.Code)
|
|
}
|
|
|
|
var payload map[string]interface{}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if hint, ok := payload["apiTokenHint"].(string); ok && hint != "" {
|
|
t.Fatalf("expected apiTokenHint to be empty when token passed via query param, got %q", hint)
|
|
}
|
|
if _, ok := payload["tokenScopes"]; ok {
|
|
t.Fatalf("expected tokenScopes to be omitted when unauthenticated")
|
|
}
|
|
}
|
|
|
|
func TestSecurityStatusAcceptsTokenHeader(t *testing.T) {
|
|
rawToken := "status-header-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/security/status", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for security status, got %d", rec.Code)
|
|
}
|
|
|
|
var payload map[string]interface{}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if _, ok := payload["apiTokenHint"]; ok {
|
|
t.Fatalf("scoped token status exposed apiTokenHint: %v", payload["apiTokenHint"])
|
|
}
|
|
if scopes, ok := payload["tokenScopes"].([]interface{}); !ok || len(scopes) == 0 {
|
|
t.Fatalf("expected tokenScopes to be present when authenticated via API token")
|
|
}
|
|
if got := payload["detailLevel"]; got != securityStatusDetailAuthenticated {
|
|
t.Fatalf("detailLevel = %v, want %q", got, securityStatusDetailAuthenticated)
|
|
}
|
|
if _, ok := payload["clientIP"]; ok {
|
|
t.Fatalf("scoped token status exposed privileged clientIP: %v", payload["clientIP"])
|
|
}
|
|
}
|
|
|
|
func TestSecurityStatusExposesPrivilegedDetailsToSettingsReadToken(t *testing.T) {
|
|
rawToken := "status-privileged-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.MethodGet, "/api/security/status", nil)
|
|
req.RemoteAddr = "198.51.100.63:54321"
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var payload map[string]interface{}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if got := payload["detailLevel"]; got != securityStatusDetailPrivileged {
|
|
t.Fatalf("detailLevel = %v, want %q", got, securityStatusDetailPrivileged)
|
|
}
|
|
if got, _ := payload["clientIP"].(string); got != "198.51.100.63" {
|
|
t.Fatalf("clientIP = %q, want %q", got, "198.51.100.63")
|
|
}
|
|
if _, ok := payload["apiTokenConfigured"]; !ok {
|
|
t.Fatal("privileged status omitted apiTokenConfigured")
|
|
}
|
|
if got, _ := payload["apiTokenHint"].(string); got != cfg.PrimaryAPITokenHint() {
|
|
t.Fatalf("apiTokenHint = %q, want %q", got, cfg.PrimaryAPITokenHint())
|
|
}
|
|
}
|
|
|
|
func TestAuditVerifyRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "audit-verify-auth-token-123.12345678", []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/audit/event-1/verify", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPathTraversalBlockedForAPIPaths(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/../api/security/status", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for path traversal on api, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPathTraversalBlockedForNonAPIPaths(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/../etc/passwd", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for path traversal on non-api, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPathTraversalBlockedForEncodedAPIPaths(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/%2e%2e/api/security/status", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for encoded path traversal on api, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPathTraversalBlockedForEncodedNonAPIPaths(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/%2e%2e/%2e%2e/etc/passwd", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for encoded path traversal on non-api, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSetupScriptIsPublicEvenWhenAuthConfigured(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/setup-script", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for missing params on public setup script, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPublicDownloadEndpointsBypassAuth(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/install.sh",
|
|
"/install.ps1",
|
|
"/download/pulse-agent",
|
|
"/download/pulse-agent-helper?arch=linux-amd64",
|
|
}
|
|
|
|
for idx, path := range paths {
|
|
ip := "203.0.113." + strconv.Itoa(70+idx)
|
|
ResetRateLimitForIP(ip)
|
|
req := httptest.NewRequest(http.MethodPost, path, nil)
|
|
req.RemoteAddr = ip + ":1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("expected 405 for public download endpoint %s, got %d", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPublicEndpointsBypassAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "public-api-token-123.12345678", []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
monitor, err := monitoring.New(cfg)
|
|
if err != nil {
|
|
t.Fatalf("monitoring.New: %v", err)
|
|
}
|
|
defer monitor.Stop()
|
|
|
|
router := NewRouter(cfg, monitor, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/health",
|
|
"/api/version",
|
|
"/api/agent/version",
|
|
"/api/server/info",
|
|
"/api/security/status",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for public endpoint %s, got %d", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSSHKeyGenerationBlockedInContainer(t *testing.T) {
|
|
t.Setenv("PULSE_DOCKER", "true")
|
|
t.Setenv("PULSE_DEV_ALLOW_CONTAINER_SSH", "")
|
|
homeDir := t.TempDir()
|
|
t.Setenv("HOME", homeDir)
|
|
|
|
handler := NewConfigHandlers(nil, nil, func() error { return nil }, nil, nil, func() {}, false)
|
|
keys := handler.GetOrGenerateSSHKeys()
|
|
if keys.SensorsPublicKey != "" {
|
|
t.Fatalf("expected empty key when container SSH generation is blocked")
|
|
}
|
|
|
|
pubKeyPath := filepath.Join(homeDir, ".ssh", "id_ed25519_sensors.pub")
|
|
if _, err := os.Stat(pubKeyPath); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("expected no key files to be written, got err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestSetupScriptRejectsInvalidSetupToken(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/setup-script?type=pve&host=https://example.com&pulse_url=https://pulse.example.com&setup_token=not-hex", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for invalid setup_token, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Invalid setup_token parameter") {
|
|
t.Fatalf("expected invalid setup_token error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSetupScriptRejectsInvalidHostURL(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/setup-script?type=pve&host=ftp://example.com&pulse_url=https://pulse.example.com", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for invalid host, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Invalid host parameter") {
|
|
t.Fatalf("expected invalid host error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSetupScriptRejectsInvalidPulseURL(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/setup-script?type=pve&host=https://example.com&pulse_url=ftp://pulse.example.com", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for invalid pulse_url, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Invalid pulse_url parameter") {
|
|
t.Fatalf("expected invalid pulse_url error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSSOOIDCLoginBypassesAuth(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.46")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/oidc/test-provider/login", nil)
|
|
req.RemoteAddr = "203.0.113.46:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404 for unknown OIDC provider on public endpoint, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSSOOIDCCallbackBypassesAuth(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.51")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/oidc/test-provider/callback", nil)
|
|
req.RemoteAddr = "203.0.113.51:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("expected 302 redirect for unknown OIDC provider callback on public endpoint, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestSSOOIDCLegacyCallbackBypassesAuthTokenOnly reproduces the v5->v6 upgrade
|
|
// bug from issue #1533. An upgraded OIDC provider keeps its v5 redirect URL
|
|
// (/api/oidc/callback, the 3-segment DefaultOIDCCallbackPath), so the IdP
|
|
// redirects the browser back to the legacy path with only code/state, no
|
|
// session cookie and no API token. In API-token-only mode
|
|
// (AuthUser=="" && AuthPass=="" && HasAPITokens()) the global auth middleware
|
|
// used to reject that inbound redirect with
|
|
// "API token required via Authorization header or X-API-Token header"
|
|
// because the public-path allowlist only recognised the 4-segment
|
|
// per-provider path. The legacy login and callback paths must bypass auth and
|
|
// reach handleSSOOIDC*, which map them to the migrated legacy provider.
|
|
func TestSSOOIDCLegacyCallbackBypassesAuthTokenOnly(t *testing.T) {
|
|
// Token-only mode: this is the exact config that emits the reported error.
|
|
record := newTokenRecord(t, "legacy-oidc-token-123.12345678", []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
if cfg.AuthUser != "" || cfg.AuthPass != "" || !cfg.HasAPITokens() {
|
|
t.Fatalf("test setup must reproduce token-only mode: authUser=%q authPass set=%v hasTokens=%v", cfg.AuthUser, cfg.AuthPass != "", cfg.HasAPITokens())
|
|
}
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
// No legacy provider is configured, so the callback handler redirects with a
|
|
// provider_not_found error (302) and the login handler returns 404. Either
|
|
// way the request reached the handler, proving auth was bypassed. Before the
|
|
// fix both returned 401 with the API-token error.
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
clientIP string
|
|
wantCode int
|
|
}{
|
|
{"legacy callback", "/api/oidc/callback?code=abc&state=xyz", "203.0.113.61", http.StatusFound},
|
|
{"legacy login", "/api/oidc/login", "203.0.113.62", http.StatusNotFound},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ResetRateLimitForIP(tc.clientIP)
|
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
|
req.RemoteAddr = tc.clientIP + ":1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code == http.StatusUnauthorized {
|
|
t.Fatalf("legacy OIDC path %q was rejected by API-token auth (401): %s", tc.path, strings.TrimSpace(rec.Body.String()))
|
|
}
|
|
if strings.Contains(rec.Body.String(), "API token required") {
|
|
t.Fatalf("legacy OIDC path %q returned the API-token error body: %s", tc.path, strings.TrimSpace(rec.Body.String()))
|
|
}
|
|
if rec.Code != tc.wantCode {
|
|
t.Fatalf("legacy OIDC path %q: expected status %d (auth bypassed, reached handler), got %d", tc.path, tc.wantCode, rec.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAIOAuthCallbackBypassesAuth(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.47")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/ai/oauth/callback", nil)
|
|
req.RemoteAddr = "203.0.113.47:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusTemporaryRedirect {
|
|
t.Fatalf("expected 307 redirect for OAuth callback, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestLoginEndpointBypassesAuth(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.48")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/login", nil)
|
|
req.RemoteAddr = "203.0.113.48:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("expected 405 for login GET, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestLegacyInstallScriptAliasesDoNotBypassAuth(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/install/install-docker.sh",
|
|
"/api/install/install.sh",
|
|
"/api/install/install.ps1",
|
|
}
|
|
|
|
for idx, path := range paths {
|
|
ip := "203.0.113." + strconv.Itoa(80+idx)
|
|
ResetRateLimitForIP(ip)
|
|
req := httptest.NewRequest(http.MethodPost, path, nil)
|
|
req.RemoteAddr = ip + ":1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for removed install alias %s, got %d", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLogoutRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "logout-token-123.12345678", []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/logout", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestStateRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "state-token-123.12345678", []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestStateRequiresMonitoringReadScope(t *testing.T) {
|
|
rawToken := "state-scope-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.MethodGet, "/api/state", 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, got %d", 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 TestStateSummaryRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "state-summary-token-123.12345678", []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/state/summary", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestStateSummaryRequiresMonitoringReadScope(t *testing.T) {
|
|
rawToken := "state-summary-scope-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.MethodGet, "/api/state/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 monitoring:read scope, got %d", 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 TestVerifyTemperatureSSHAllowsSetupToken(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
token := "0123456789abcdef0123456789abcdef"
|
|
tokenHash := auth.HashAPIToken(token)
|
|
router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh", strings.NewReader(`{"nodes":""}`))
|
|
req.Header.Set("X-Setup-Token", token)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 with setup token, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "No nodes to verify") {
|
|
t.Fatalf("expected verify response, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestVerifyTemperatureSSHRejectsSetupTokenOrgMismatch(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
token := "fedcba9876543210fedcba9876543210"
|
|
tokenHash := auth.HashAPIToken(token)
|
|
router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{
|
|
ExpiresAt: time.Now().Add(time.Minute),
|
|
OrgID: "org-a",
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh", strings.NewReader(`{"nodes":""}`))
|
|
req.Header.Set("X-Setup-Token", token)
|
|
req.Header.Set("X-Pulse-Org-ID", "org-b")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code == http.StatusOK {
|
|
t.Fatalf("expected setup token org mismatch to be rejected, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestVerifyTemperatureSSHRejectsSetupTokenOrgIDQueryBypass(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
token := "11223344556677889900aabbccddeeff"
|
|
tokenHash := auth.HashAPIToken(token)
|
|
router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{
|
|
ExpiresAt: time.Now().Add(time.Minute),
|
|
OrgID: "org-a",
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh?org_id=org-a", strings.NewReader(`{"nodes":""}`))
|
|
req.Header.Set("X-Setup-Token", token)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code == http.StatusOK {
|
|
t.Fatalf("expected org_id query bypass attempt to be rejected, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSSHConfigAllowsSetupToken(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
t.Setenv("HOME", t.TempDir())
|
|
|
|
token := "abcdef0123456789abcdef0123456789"
|
|
tokenHash := auth.HashAPIToken(token)
|
|
router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/ssh-config", strings.NewReader("Host example\nHostname example\n"))
|
|
req.Header.Set("X-Setup-Token", token)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 with setup token, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `"success":true`) {
|
|
t.Fatalf("expected success response, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSSHConfigRejectsSetupTokenOrgMismatch(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
t.Setenv("HOME", t.TempDir())
|
|
|
|
token := "00112233445566778899aabbccddeeff"
|
|
tokenHash := auth.HashAPIToken(token)
|
|
router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{
|
|
ExpiresAt: time.Now().Add(time.Minute),
|
|
OrgID: "org-a",
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/ssh-config", strings.NewReader("Host example\nHostname example\n"))
|
|
req.Header.Set("X-Setup-Token", token)
|
|
req.Header.Set("X-Pulse-Org-ID", "org-b")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code == http.StatusOK {
|
|
t.Fatalf("expected setup token org mismatch to be rejected, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestVerifyTemperatureSSHRejectsSetupTokenQueryParam(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t, newTokenRecord(t, "settings-write-token", []string{config.ScopeSettingsWrite}, nil))
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
token := "abcdefabcdefabcdefabcdefabcdefab"
|
|
tokenHash := auth.HashAPIToken(token)
|
|
router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh?auth_token="+token, strings.NewReader(`{"nodes":""}`))
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 when setup token is only provided in query string, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSSHConfigRejectsSetupTokenQueryParam(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t, newTokenRecord(t, "settings-write-token", []string{config.ScopeSettingsWrite}, nil))
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
t.Setenv("HOME", t.TempDir())
|
|
|
|
token := "deadbeefdeadbeefdeadbeefdeadbeef"
|
|
tokenHash := auth.HashAPIToken(token)
|
|
router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/ssh-config?auth_token="+token, strings.NewReader("Host example\nHostname example\n"))
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 when setup token is only provided in query string, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestCheckCSRF_HeaderDoesNotBypassWhenSessionCookiePresent regression-tests the
|
|
// fix for a CSRF bypass: CheckCSRF was skipping the entire CSRF check whenever
|
|
// the request carried Authorization: Basic, Authorization: Bearer, or
|
|
// X-API-Token, without validating the credential. A cross-origin attacker
|
|
// could fetch() with credentials: 'include' and an arbitrary Authorization
|
|
// header — the browser would still auto-attach the victim's session cookie
|
|
// and the server would skip CSRF, fully bypassing protection. The contract is
|
|
// now: a session cookie is the only signal for whether CSRF applies. If a
|
|
// session cookie is present, CSRF must be valid regardless of any other
|
|
// auth-style header.
|
|
func TestCheckCSRF_HeaderDoesNotBypassWhenSessionCookiePresent(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
setHeader func(*http.Request)
|
|
description string
|
|
}{
|
|
{
|
|
name: "x_api_token",
|
|
setHeader: func(r *http.Request) {
|
|
r.Header.Set("X-API-Token", "some-api-token")
|
|
},
|
|
description: "X-API-Token must not bypass CSRF when a session cookie is present",
|
|
},
|
|
{
|
|
name: "authorization_basic",
|
|
setHeader: func(r *http.Request) {
|
|
r.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
|
|
},
|
|
description: "Authorization: Basic must not bypass CSRF when a session cookie is present",
|
|
},
|
|
{
|
|
name: "authorization_bearer",
|
|
setHeader: func(r *http.Request) {
|
|
r.Header.Set("Authorization", "Bearer some-token")
|
|
},
|
|
description: "Authorization: Bearer must not bypass CSRF when a session cookie is present",
|
|
},
|
|
{
|
|
name: "authorization_bearer_mixed_case",
|
|
setHeader: func(r *http.Request) {
|
|
// Mixed-case scheme to ensure the old lower-case prefix check
|
|
// is not reintroduced as a guarded skip.
|
|
r.Header.Set("Authorization", "BeArEr some-token")
|
|
},
|
|
description: "Mixed-case Bearer must not bypass CSRF when a session cookie is present",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest("POST", "/api/test", nil)
|
|
tc.setHeader(req)
|
|
req.AddCookie(&http.Cookie{
|
|
Name: "pulse_session",
|
|
Value: "test-session-id-1234567890",
|
|
})
|
|
|
|
if CheckCSRF(w, req) {
|
|
t.Fatal(tc.description)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRequirePermissionUsesContextUsername regresses the X-Authenticated-User
|
|
// response-header-as-identity bug. RequirePermission previously read the
|
|
// authenticated username from w.Header().Get("X-Authenticated-User"). That
|
|
// header is mutable across the handler chain; any middleware sitting between
|
|
// checkAuth and RequirePermission that wrote to the response header could
|
|
// substitute an arbitrary identity, and the RBAC authorizer would make its
|
|
// decision against the substituted value. The contract is now: the identity
|
|
// comes from the REQUEST context (set by attachUserContext during auth).
|
|
// When the response header and the context disagree, the context wins.
|
|
func TestRequirePermissionUsesContextUsername(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
|
|
var observedSubject string
|
|
authorizer := &mockAuthorizerFn{fn: func(ctx context.Context, action string, resource string) (bool, error) {
|
|
observedSubject = auth.GetUser(ctx)
|
|
return true, nil
|
|
}}
|
|
|
|
handler := RequirePermission(cfg, authorizer, "read", "logs", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
req := newLoopbackRequest("GET", "/api/test", nil)
|
|
|
|
// Simulate a downstream middleware that has written a different value
|
|
// to the response header AFTER checkAuth ran. The header below is the
|
|
// would-be-attacker-supplied value; the context (set by attachUserContext)
|
|
// holds the real authenticated user.
|
|
rr := httptest.NewRecorder()
|
|
rr.Header().Set("X-Authenticated-User", "evil-spoofed-user")
|
|
|
|
ctxReq := req.WithContext(auth.WithUser(req.Context(), "real-authenticated-user"))
|
|
|
|
handler.ServeHTTP(rr, ctxReq)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rr.Code)
|
|
}
|
|
if observedSubject != "real-authenticated-user" {
|
|
t.Fatalf("RBAC subject must come from context, got %q (header was %q)", observedSubject, "evil-spoofed-user")
|
|
}
|
|
}
|
|
|
|
// Instance-wide webhook security settings must reach every tenant org's
|
|
// notification manager: the allowlist is stored globally, so applying it only
|
|
// to the request's org (or only to the default org on reload) leaves other
|
|
// tenants' webhook targets failing SSRF validation with no org-side remedy.
|
|
func TestSystemSettingsWebhookAllowlistPropagatesToAllTenantManagers(t *testing.T) {
|
|
t.Setenv("PULSE_MULTI_TENANT_ENABLED", "true")
|
|
defer SetMultiTenantEnabled(false)
|
|
SetMultiTenantEnabled(true)
|
|
|
|
dataDir := t.TempDir()
|
|
tokenVal := "allowlist-propagation-token-123.12345678"
|
|
cfg := &config.Config{
|
|
DataPath: dataDir,
|
|
APITokens: []config.APITokenRecord{
|
|
{ID: "tok-allowlist", Hash: auth.HashAPIToken(tokenVal), Name: "Allowlist Test Token"},
|
|
},
|
|
}
|
|
persistence := config.NewConfigPersistence(dataDir)
|
|
mtp := config.NewMultiTenantPersistence(dataDir)
|
|
mtm := monitoring.NewMultiTenantMonitor(cfg, mtp, nil)
|
|
t.Cleanup(mtm.Stop)
|
|
|
|
for _, org := range []struct{ id, name string }{
|
|
{"org-a", "Org A"},
|
|
{"org-b", "Org B"},
|
|
} {
|
|
if err := mtp.SaveOrganization(&models.Organization{ID: org.id, DisplayName: org.name}); err != nil {
|
|
t.Fatalf("SaveOrganization(%s): %v", org.id, err)
|
|
}
|
|
if _, err := mtm.GetMonitor(org.id); err != nil {
|
|
t.Fatalf("GetMonitor(%s): %v", org.id, err)
|
|
}
|
|
}
|
|
|
|
// Match the production router, which always supplies its already-created
|
|
// primary monitor. Passing nil here makes the constructor create and start a
|
|
// default monitor asynchronously against cfg just before this test mutates
|
|
// cfg, introducing a test-only race that production cannot take.
|
|
primaryMonitor, ok := mtm.PeekMonitor("org-a")
|
|
if !ok {
|
|
t.Fatal("org-a monitor was not initialized")
|
|
}
|
|
h := NewSystemSettingsHandler(cfg, persistence, nil, mtm, primaryMonitor, func() {}, nil)
|
|
|
|
// Both org managers must reject the private target before the update.
|
|
for _, orgID := range []string{"org-a", "org-b"} {
|
|
m, err := mtm.GetMonitor(orgID)
|
|
if err != nil {
|
|
t.Fatalf("GetMonitor(%s): %v", orgID, err)
|
|
}
|
|
if err := m.GetNotificationManager().ValidateWebhookURL("http://192.0.2.10:9999/hook"); err == nil {
|
|
t.Fatalf("%s: expected private webhook target to be rejected before allowlist update", orgID)
|
|
}
|
|
}
|
|
|
|
body := bytes.NewBufferString(`{"webhookAllowedPrivateCIDRs":"192.0.2.0/24"}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/settings/update", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-API-Token", tokenVal)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleUpdateSystemSettings(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("HandleUpdateSystemSettings responded %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// Every live tenant manager must observe the new allowlist, regardless of
|
|
// which org the admin's request context pointed at.
|
|
for _, orgID := range []string{"org-a", "org-b"} {
|
|
m, err := mtm.GetMonitor(orgID)
|
|
if err != nil {
|
|
t.Fatalf("GetMonitor(%s): %v", orgID, err)
|
|
}
|
|
if err := m.GetNotificationManager().ValidateWebhookURL("http://192.0.2.10:9999/hook"); err != nil {
|
|
t.Fatalf("%s: expected private webhook target to be allowed after allowlist update, got %v", orgID, err)
|
|
}
|
|
}
|
|
}
|