mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 02:55:51 +00:00
99129d0c09
Remove local upgrade-metrics API registration, settings payload wiring, startup store migration, and backend conversion recorder hooks from the normal product runtime. Delete the retired conversion/funnel and metering packages from compiled licensing code, and extend diagnostics boundary audits and governance contracts so maintainer commercial analytics cannot return through Settings or diagnostics.
4509 lines
175 KiB
Go
4509 lines
175 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"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"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"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"`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// newLegacyAIServiceForTest creates an *ai.Service with loaded config for route-level tests.
|
|
func newLegacyAIServiceForTest(persistence *config.ConfigPersistence) *ai.Service {
|
|
svc := ai.NewService(persistence, nil)
|
|
_ = svc.LoadConfig()
|
|
return svc
|
|
}
|
|
|
|
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",
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
// 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()
|
|
|
|
// 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_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.codeMutex.Lock()
|
|
router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{
|
|
ExpiresAt: time.Now().Add(time.Minute),
|
|
NodeType: "pve",
|
|
}
|
|
router.configHandlers.codeMutex.Unlock()
|
|
|
|
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",
|
|
}
|
|
|
|
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/history",
|
|
"/api/ai/patrol/runs",
|
|
"/api/ai/patrol/runs/run-1",
|
|
"/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.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 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/agents", body: "", want: config.ScopeAIExecute},
|
|
{method: http.MethodGet, path: "/api/ai/approvals/approval-1", 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/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/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 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 TestDiscoveryMutationEndpointsRequireMonitoringWriteScope(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
|
|
}{
|
|
{method: http.MethodPost, path: "/api/discovery/agent/host-1/resource-1", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/discovery/agent/host-1/resource-1/notes", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/discovery/agent/host-1/resource-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/discovery/agent/host-1/resource-1", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/discovery/agent/host-1/resource-1/notes", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/discovery/agent/host-1/resource-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/discovery/resource-1", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/discovery/resource-1/notes", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/discovery/resource-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 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/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: `{}`},
|
|
}
|
|
|
|
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/storage/host-1",
|
|
"/api/storage-charts",
|
|
"/api/charts",
|
|
"/api/charts/workloads",
|
|
"/api/charts/storage-summary",
|
|
"/api/metrics-store/stats",
|
|
"/api/metrics-store/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.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.MethodPost, path: "/api/ai/knowledge/save", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/knowledge/delete", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/ai/knowledge/export", body: ""},
|
|
{method: http.MethodPost, path: "/api/ai/knowledge/import", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/knowledge/clear", 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 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")
|
|
|
|
paths := []string{
|
|
"/api/admin/reports/generate",
|
|
"/api/admin/reports/generate-multi",
|
|
"/api/admin/reports/inventory/vms/export",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
if path == "/api/admin/reports/generate-multi" {
|
|
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 reporting license on %s, got %d", 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 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/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 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 TestSecurityStatusIncludesBootstrapTokenWhenUnauthenticated(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)
|
|
}
|
|
path, ok := payload["bootstrapTokenPath"].(string)
|
|
if !ok || path == "" {
|
|
t.Fatalf("expected bootstrapTokenPath to be present for unauthenticated setup")
|
|
}
|
|
}
|
|
|
|
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 hint, ok := payload["apiTokenHint"].(string); !ok || hint != cfg.PrimaryAPITokenHint() {
|
|
t.Fatalf("expected apiTokenHint %q, got %v", cfg.PrimaryAPITokenHint(), payload["apiTokenHint"])
|
|
}
|
|
if scopes, ok := payload["tokenScopes"].([]interface{}); !ok || len(scopes) == 0 {
|
|
t.Fatalf("expected tokenScopes to be present when authenticated via API token")
|
|
}
|
|
}
|
|
|
|
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",
|
|
}
|
|
|
|
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() {})
|
|
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)
|
|
}
|
|
}
|
|
|
|
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 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.codeMutex.Lock()
|
|
router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)}
|
|
router.configHandlers.codeMutex.Unlock()
|
|
|
|
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.codeMutex.Lock()
|
|
router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{
|
|
ExpiresAt: time.Now().Add(time.Minute),
|
|
OrgID: "org-a",
|
|
}
|
|
router.configHandlers.codeMutex.Unlock()
|
|
|
|
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.codeMutex.Lock()
|
|
router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{
|
|
ExpiresAt: time.Now().Add(time.Minute),
|
|
OrgID: "org-a",
|
|
}
|
|
router.configHandlers.codeMutex.Unlock()
|
|
|
|
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.codeMutex.Lock()
|
|
router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)}
|
|
router.configHandlers.codeMutex.Unlock()
|
|
|
|
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.codeMutex.Lock()
|
|
router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{
|
|
ExpiresAt: time.Now().Add(time.Minute),
|
|
OrgID: "org-a",
|
|
}
|
|
router.configHandlers.codeMutex.Unlock()
|
|
|
|
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.codeMutex.Lock()
|
|
router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)}
|
|
router.configHandlers.codeMutex.Unlock()
|
|
|
|
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.codeMutex.Lock()
|
|
router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)}
|
|
router.configHandlers.codeMutex.Unlock()
|
|
|
|
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)
|
|
}
|
|
}
|