mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 13:39:47 +00:00
dcbfd2d86d
The in-app Assistant chat window was retired in #3126. Several backend routes existed only for it, and Pulse Mobile, the remaining chat client, never called them: assistant surface-tools, workflow-prompt render, question answers, and the session summarize, fork, undo, redo, steer, diff, revert and unrevert sub-resources. This removes them along with the handlers, service and session-store methods, the persisted redo stack, session compaction, mid-turn steering and the question-answer wait behind them. The question tool needed more than a route removal. Interactive chat still offered pulse_question, and Pulse Mobile has no way to answer it, so a Mobile turn that called it waited until the request ended. No profile offers it now. The agentic loop answers any call with a refusal that tells the model to ask in its reply text, the path Patrol runs already took, and the chat prompt drops its clarification-tool advice. The capabilities manifest's Assistant surface stops declaring interactive questions, so the generated docs and fallback governance names follow it. /api/ai/chat and the session list, create, rename, delete, messages and abort routes Pulse Mobile uses are unchanged, as is /api/ai/status. Old session files load as before and drop the redo stack and steered flag on their next write. docs/API.md lists the remaining chat routes, and the subsystem contracts record the removal.
6069 lines
238 KiB
Go
6069 lines
238 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/api/agentbinding"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
|
pulsews "github.com/rcourtman/pulse-go-rewrite/internal/websocket"
|
|
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
)
|
|
|
|
func TestSecurityContainerDiagnosticsRejectsUnitInjectionBeforeTokenIssuance(t *testing.T) {
|
|
for _, url := range []string{"https://pulse.example\nEnvironment=INJECTED=true", "https://pulse.example/\rpath", "https://pulse.example/\x00"} {
|
|
t.Run(strconv.Quote(url), func(t *testing.T) {
|
|
monitor, state, _ := newTestMonitor(t)
|
|
state.DockerHosts = []models.DockerHost{{ID: "host-1"}}
|
|
cfg := &config.Config{PublicURL: url}
|
|
router := &Router{monitor: monitor, config: cfg}
|
|
req := httptest.NewRequest(http.MethodPost, "/api/diagnostics/docker/prepare-token", strings.NewReader(`{"agentId":"host-1"}`))
|
|
rec := httptest.NewRecorder()
|
|
router.handleDiagnosticsDockerPrepareToken(rec, req)
|
|
if rec.Code != http.StatusServiceUnavailable || len(cfg.APITokens) != 0 {
|
|
t.Fatal("unsafe diagnostic unit target minted a token or produced a usable response")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecurityContainerDiagnosticsServiceReferenceUsesPrivateState(t *testing.T) {
|
|
for _, enableHost := range []bool{true, false} {
|
|
for _, baseURL := range []string{"https://pulse.example/base", "http://pulse.example:7655", `https://pulse.example/path%20space/%n/$VALUE/"quoted"`} {
|
|
snippet, err := buildContainerRuntimeAgentServiceSnippet(baseURL+"/", enableHost)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
urlLine := "Environment=" + strconv.Quote("PULSE_URL="+strings.ReplaceAll(baseURL, "%", "%%"))
|
|
if !strings.Contains(snippet, urlLine) || !strings.Contains(snippet, "--url ${PULSE_URL}") || !strings.Contains(snippet, "--token-file /var/lib/pulse-agent/token") {
|
|
t.Fatal("service reference changed URL data or lost the private credential path")
|
|
}
|
|
if strings.Contains(snippet, "PULSE_TOKEN=") || strings.Contains(snippet, "--token ") || strings.Contains(snippet, "--enable-commands") {
|
|
t.Fatal("service reference exposes a credential or broadens command authority")
|
|
}
|
|
if !strings.Contains(snippet, containerRuntimeAgentHostFlag(enableHost)+" --interval") {
|
|
t.Fatal("service reference lost its explicit host-monitoring mode")
|
|
}
|
|
if strings.Contains(snippet, "--insecure") != strings.HasPrefix(baseURL, "http://") {
|
|
t.Fatal("service reference changed the transport trust policy")
|
|
}
|
|
// Validate real systemd unit grammar, without installing a service or
|
|
// pretending the diagnostic reference is the complete governed unit.
|
|
unit := filepath.Join(t.TempDir(), "container-reference.service")
|
|
body := strings.Replace(snippet, "/usr/local/bin/pulse-agent", "/bin/true", 1)
|
|
if err := os.WriteFile(unit, []byte(body+"\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := exec.Command("systemd-analyze", "verify", "--man=no", unit).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("systemd reference syntax: %v\n%s", err, out)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
type wsRawMessage struct {
|
|
Type agentexec.MessageType `json:"type"`
|
|
Payload json.RawMessage `json:"payload,omitempty"`
|
|
}
|
|
|
|
func TestSecurityImplicitAgentCredentialOmitsExecutionScope(t *testing.T) {
|
|
_, record, err := agenttokens.IssueAndPersist(&config.Config{}, nil, agenttokens.IssueOptions{
|
|
TokenName: "implicit-monitoring-agent",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IssueAndPersist: %v", err)
|
|
}
|
|
if !record.HasScope(config.ScopeAgentReport) || !record.HasScope(config.ScopeAgentConfigRead) {
|
|
t.Fatalf("implicit credential scopes = %v, want monitoring scopes", record.Scopes)
|
|
}
|
|
if record.HasScope(config.ScopeAgentExec) {
|
|
t.Fatalf("implicit credential scopes = %v, must not grant agent:exec", record.Scopes)
|
|
}
|
|
}
|
|
|
|
func TestSecurityRejectsCollectorRoleWithNonMonitoringScope(t *testing.T) {
|
|
rawToken := "security-over-scoped-collector.12345678"
|
|
record, err := config.NewAPITokenRecord(rawToken, "collector", []string{
|
|
config.ScopeAgentReport,
|
|
config.ScopeAgentConfigRead,
|
|
config.ScopeSettingsWrite,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
record.Metadata = map[string]string{
|
|
auth.RuntimeRoleMetadataKey: auth.RuntimeRoleMonitoringCollector,
|
|
}
|
|
cfg := &config.Config{APITokens: []config.APITokenRecord{*record}}
|
|
|
|
if admitted, ok := cfg.ValidateAPIToken(rawToken); ok || admitted != nil {
|
|
t.Fatalf("over-scoped collector authenticated: %#v", admitted)
|
|
}
|
|
}
|
|
|
|
func TestSecurityGenericExecTokenCannotApplyInstallCommandPolicy(t *testing.T) {
|
|
token := config.APITokenRecord{
|
|
ID: "generic-exec-token",
|
|
Scopes: []string{config.ScopeAgentReport, config.ScopeAgentExec},
|
|
}
|
|
cfg := &config.Config{DataPath: t.TempDir(), APITokens: []config.APITokenRecord{token}}
|
|
_, monitor := newUnifiedAgentHandlers(t, cfg)
|
|
|
|
disabled := false
|
|
if err := monitor.UpdateHostAgentConfig("machine-1", &disabled); err != nil {
|
|
t.Fatalf("seed disabled command policy: %v", err)
|
|
}
|
|
if err := reconcileInstallTokenCommandPolicy(monitor, &token, models.Host{
|
|
ID: "machine-1",
|
|
Hostname: "docker-1",
|
|
}); err != nil {
|
|
t.Fatalf("reconcile generic exec token: %v", err)
|
|
}
|
|
|
|
desired := monitor.GetHostAgentConfig("machine-1").CommandsEnabled
|
|
if desired == nil || *desired {
|
|
t.Fatalf("generic exec token changed desired command policy: %#v", desired)
|
|
}
|
|
if got := cfg.APITokens[0].Metadata[agenttokens.CommandPolicyAppliedAgentIDMetadataKey]; got != "" {
|
|
t.Fatalf("generic exec token gained install-policy marker %q", got)
|
|
}
|
|
}
|
|
|
|
func TestDeprovisionSessionRevocationIncludesPersistedUntrackedSessions(t *testing.T) {
|
|
resetSessionTracking()
|
|
resetSessionStoreForTests()
|
|
resetCSRFStoreForTests()
|
|
t.Cleanup(resetSessionStoreForTests)
|
|
t.Cleanup(resetCSRFStoreForTests)
|
|
|
|
dir := t.TempDir()
|
|
InitSessionStore(dir)
|
|
InitCSRFStore(dir)
|
|
token := generateSessionToken()
|
|
GetSessionStore().CreateSession(token, time.Hour, "agent", "127.0.0.1", "retired-user")
|
|
csrfToken := generateCSRFToken(token)
|
|
if !GetCSRFStore().ValidateCSRFToken(token, csrfToken) {
|
|
t.Fatal("expected CSRF token to be valid before revocation")
|
|
}
|
|
// Deliberately do not call TrackUserSession: this models a session loaded
|
|
// from persistent storage after process restart.
|
|
InvalidateUserSessions("retired-user")
|
|
|
|
if GetSessionStore().GetSession(token) != nil {
|
|
t.Fatal("persisted untracked session survived account-wide revocation")
|
|
}
|
|
if GetCSRFStore().ValidateCSRFToken(token, csrfToken) {
|
|
t.Fatal("CSRF state survived account-wide revocation")
|
|
}
|
|
}
|
|
|
|
func TestBrowserCookiePolicyWritesSecureCookiesForTLS(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "https://pulse.example/api/config", nil)
|
|
rec := httptest.NewRecorder()
|
|
getBrowserCookiePolicy(req).setClientReadable(rec, &http.Cookie{Name: CookieNameCSRF, Value: "token", Path: "/"})
|
|
|
|
cookies := rec.Result().Cookies()
|
|
if len(cookies) != 1 || !cookies[0].Secure {
|
|
t.Fatalf("TLS cookie policy = %+v, want one Secure cookie", cookies)
|
|
}
|
|
if cookies[0].SameSite != http.SameSiteLaxMode {
|
|
t.Fatalf("SameSite = %v, want Lax", cookies[0].SameSite)
|
|
}
|
|
}
|
|
|
|
type denyAuthorizer struct{}
|
|
|
|
func (d *denyAuthorizer) Authorize(_ context.Context, _ string, _ string) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
type adminOnlyAuthorizer struct{}
|
|
|
|
func (a *adminOnlyAuthorizer) Authorize(ctx context.Context, _ string, _ string) (bool, error) {
|
|
return auth.GetUser(ctx) == "admin", nil
|
|
}
|
|
|
|
func newTestConfigWithTokens(t *testing.T, records ...config.APITokenRecord) *config.Config {
|
|
t.Helper()
|
|
tempDir := t.TempDir()
|
|
return &config.Config{
|
|
DataPath: tempDir,
|
|
ConfigPath: tempDir,
|
|
APITokens: records,
|
|
}
|
|
}
|
|
|
|
func newTokenRecord(t *testing.T, raw string, scopes []string, metadata map[string]string) config.APITokenRecord {
|
|
t.Helper()
|
|
record, err := config.NewAPITokenRecord(raw, "test-token", scopes)
|
|
if err != nil {
|
|
t.Fatalf("NewAPITokenRecord: %v", err)
|
|
}
|
|
if metadata != nil {
|
|
record.Metadata = metadata
|
|
}
|
|
return *record
|
|
}
|
|
|
|
func TestSecurityRejectsWildcardTrustedProxyCIDR(t *testing.T) {
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "0.0.0.0/0")
|
|
resetTrustedProxyConfig()
|
|
|
|
if err := ValidateTrustedProxyCIDRsFromEnv(); err == nil || !strings.Contains(err.Error(), "wildcard trust range") {
|
|
t.Fatalf("expected wildcard trusted proxy configuration to be rejected, got %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://pulse.local/api/state", nil)
|
|
req.RemoteAddr = "198.51.100.42:8443"
|
|
req.Header.Set("X-Forwarded-For", "203.0.113.10")
|
|
|
|
if got := GetClientIP(req); got != "198.51.100.42" {
|
|
t.Fatalf("expected forwarded headers to fail closed, got %q", got)
|
|
}
|
|
}
|
|
|
|
func readRegisteredPayload(t *testing.T, conn *websocket.Conn) agentexec.RegisteredPayload {
|
|
t.Helper()
|
|
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
|
_, data, err := conn.ReadMessage()
|
|
if err != nil {
|
|
t.Fatalf("ReadMessage: %v", err)
|
|
}
|
|
|
|
var msg wsRawMessage
|
|
if err := json.Unmarshal(data, &msg); err != nil {
|
|
t.Fatalf("unmarshal message: %v", err)
|
|
}
|
|
if msg.Type != agentexec.MsgTypeRegistered {
|
|
t.Fatalf("message type = %q, want %q", msg.Type, agentexec.MsgTypeRegistered)
|
|
}
|
|
if msg.Payload == nil {
|
|
t.Fatalf("registered payload missing")
|
|
}
|
|
|
|
var payload agentexec.RegisteredPayload
|
|
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
|
|
t.Fatalf("unmarshal registered payload: %v", err)
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func TestSimpleStatsRequiresAuthInAPIMode(t *testing.T) {
|
|
rawToken := "stats-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/simple-stats", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without token, got %d", rec.Code)
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/simple-stats", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec = httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 with token, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Simple Pulse Stats") {
|
|
t.Fatalf("expected stats page HTML, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSimpleStatsAllowsBearerToken(t *testing.T) {
|
|
rawToken := "stats-bearer-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/simple-stats", nil)
|
|
req.Header.Set("Authorization", "Bearer "+rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 with bearer token, got %d", rec.Code)
|
|
}
|
|
if rec.Header().Get("X-Auth-Method") != "api_token" {
|
|
t.Fatalf("expected X-Auth-Method api_token, got %q", rec.Header().Get("X-Auth-Method"))
|
|
}
|
|
}
|
|
|
|
func TestSimpleStatsRejectsInvalidBearerToken(t *testing.T) {
|
|
rawToken := "stats-bearer-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/simple-stats", nil)
|
|
req.Header.Set("Authorization", "Bearer invalid-token")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for invalid bearer token, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Invalid API token") {
|
|
t.Fatalf("expected invalid token response, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSchedulerHealthRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "sched-token-123.12345678", []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/monitoring/scheduler/health", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without token, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestChangePasswordRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "change-pass-token-123.12345678", []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/change-password", strings.NewReader(`{}`))
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestChangePasswordRejectsProxyNonAdmin(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/change-password", strings.NewReader(`{}`))
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy password change, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestResetLockoutRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "reset-lockout-token-123.12345678", []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/reset-lockout", strings.NewReader(`{}`))
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRequirePermissionDeniesProxyNonAdminUsers(t *testing.T) {
|
|
prevAuthorizer := auth.GetAuthorizer()
|
|
auth.SetAuthorizer(&adminOnlyAuthorizer{})
|
|
defer auth.SetAuthorizer(prevAuthorizer)
|
|
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/security/tokens", nil)
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy user, got %d", rec.Code)
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/api/security/tokens", nil)
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "admin")
|
|
req.Header.Set("X-Remote-Roles", "admin")
|
|
rec = httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for admin proxy user, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestLicenseFeaturesRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "license-token-123.12345678", []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/license/features", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without token, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestLicenseStatusRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "license-status-token-123.12345678", []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/license/status", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without token, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAIStatusRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "ai-status-token-123.12345678", []string{config.ScopeAIChat}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/ai/status", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without token, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAIStatusRequiresAIChatScope(t *testing.T) {
|
|
rawToken := "ai-status-scope-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/ai/status", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing ai:chat scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeAIChat) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIChat, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestWebSocketRequiresMonitoringReadScope(t *testing.T) {
|
|
rawToken := "ws-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentReport}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/ws", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing monitoring:read scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeMonitoringRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringRead, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestWebSocketRequiresMonitoringReadScopeForUpgrade(t *testing.T) {
|
|
rawToken := "ws-scope-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentReport}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
|
|
hub := pulsews.NewHub(nil)
|
|
go hub.Run()
|
|
defer hub.Stop()
|
|
|
|
router := NewRouter(cfg, nil, nil, hub, nil, "1.0.0")
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws"
|
|
headers := http.Header{}
|
|
headers.Set("X-API-Token", rawToken)
|
|
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, headers)
|
|
if err == nil {
|
|
conn.Close()
|
|
t.Fatalf("expected websocket upgrade to be rejected without monitoring:read scope")
|
|
}
|
|
if resp == nil {
|
|
t.Fatalf("expected HTTP response for failed websocket upgrade")
|
|
}
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing scope, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestUnifiedAgentManagementRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "host-manage-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentManage}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
cases := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
}{
|
|
{name: "link", method: http.MethodPost, path: "/api/agents/agent/link"},
|
|
{name: "unlink", method: http.MethodPost, path: "/api/agents/agent/unlink"},
|
|
{name: "delete", method: http.MethodDelete, path: "/api/agents/agent/agent-1"},
|
|
{name: "host link alias", method: http.MethodPost, path: "/api/agents/host/link"},
|
|
{name: "host unlink alias", method: http.MethodPost, path: "/api/agents/host/unlink"},
|
|
{name: "host delete alias", method: http.MethodDelete, path: "/api/agents/host/agent-1"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(tc.method, tc.path, nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTestNotificationRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "notify-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/test-notification", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAIFindingsRequiresAIExecuteScope(t *testing.T) {
|
|
rawToken := "ai-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/ai/findings/f-1/investigation", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing ai:execute scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIExecute, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestNotificationsDLQRequiresSettingsReadScope(t *testing.T) {
|
|
rawToken := "dlq-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/notifications/dlq", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestNotificationsDLQMutationsRequireSettingsWriteScope(t *testing.T) {
|
|
rawToken := "dlq-write-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
cases := []string{
|
|
"/api/notifications/dlq/retry",
|
|
"/api/notifications/dlq/delete",
|
|
"/api/notifications/terminal-failures/retry",
|
|
"/api/notifications/terminal-failures/dismiss",
|
|
}
|
|
|
|
for _, path := range cases {
|
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader([]byte(`{"id":"test"}`)))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAgentExecTokenBindingEnforced(t *testing.T) {
|
|
rawToken := "agent-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, map[string]string{"bound_agent_id": "agent-1"})
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := wsURLForHTTP(ts.URL) + "/api/agent/ws"
|
|
|
|
// Mismatched agent ID should be rejected
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
regMsg, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
|
AgentID: "agent-2",
|
|
Hostname: "host-2",
|
|
Version: "1.0.0",
|
|
Platform: "linux",
|
|
Token: rawToken,
|
|
})
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatalf("NewMessage: %v", err)
|
|
}
|
|
if err := conn.WriteJSON(regMsg); err != nil {
|
|
conn.Close()
|
|
t.Fatalf("WriteJSON: %v", err)
|
|
}
|
|
reg := readRegisteredPayload(t, conn)
|
|
if reg.Success {
|
|
conn.Close()
|
|
t.Fatalf("expected registration to be rejected for mismatched bound agent")
|
|
}
|
|
conn.Close()
|
|
|
|
// Matching agent ID should succeed
|
|
conn, _, err = websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
regMsg, err = agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
|
AgentID: "agent-1",
|
|
Hostname: "host-1",
|
|
Version: "1.0.0",
|
|
Platform: "linux",
|
|
Token: rawToken,
|
|
})
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatalf("NewMessage: %v", err)
|
|
}
|
|
if err := conn.WriteJSON(regMsg); err != nil {
|
|
conn.Close()
|
|
t.Fatalf("WriteJSON: %v", err)
|
|
}
|
|
reg = readRegisteredPayload(t, conn)
|
|
if !reg.Success {
|
|
conn.Close()
|
|
t.Fatalf("expected registration to be accepted for matching bound agent, got %q", reg.Message)
|
|
}
|
|
conn.Close()
|
|
}
|
|
|
|
func TestActionRunnerCredentialAdmissionBindsTenantHostRoleAndCapability(t *testing.T) {
|
|
cfg := &config.Config{DataPath: t.TempDir()}
|
|
rawToken, record, err := agenttokens.IssueActionRunnerAndPersist(cfg, nil, agenttokens.ActionRunnerIssueOptions{
|
|
OrgID: "org-a", AgentID: "machine-a", Hostname: "node.example",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
router := &Router{config: cfg}
|
|
|
|
admission, ok := router.admitAgentExecToken(rawToken, "machine-a", "NODE")
|
|
if !ok {
|
|
t.Fatal("typed action runner credential was rejected")
|
|
}
|
|
if admission.OrganizationID != "org-a" || admission.TokenID != record.ID ||
|
|
admission.AgentID != "machine-a" || admission.RuntimeRole != agentexec.RuntimeRoleActionRunner ||
|
|
admission.ActionCapability != agentexec.ActionCapabilityTypedV1 {
|
|
t.Fatalf("action admission = %#v", admission)
|
|
}
|
|
if !router.validateAgentExecSession(admission) {
|
|
t.Fatal("fresh action runner admission failed live session validation")
|
|
}
|
|
|
|
config.Mu.Lock()
|
|
cfg.APITokens[0].Metadata[agenttokens.ActionCapabilityMetadataKey] = "shell.v1"
|
|
config.Mu.Unlock()
|
|
if router.validateAgentExecSession(admission) {
|
|
t.Fatal("session stayed valid after action capability changed")
|
|
}
|
|
}
|
|
|
|
func TestActionRunnerCredentialIssuanceRejectsIdentityOutsideSessionVocabulary(t *testing.T) {
|
|
for _, agentID := range []string{"bad agent", "-machine", strings.Repeat("a", 129)} {
|
|
t.Run(agentID, func(t *testing.T) {
|
|
rawToken, record, err := agenttokens.IssueActionRunnerAndPersist(&config.Config{}, nil, agenttokens.ActionRunnerIssueOptions{
|
|
OrgID: "org-a", AgentID: agentID, Hostname: "node.example",
|
|
})
|
|
if !errors.Is(err, agenttokens.ErrRecord) || rawToken != "" || record != nil {
|
|
t.Fatalf("action-runner issuance = (%q, %#v, %v), want closed identity rejection", rawToken, record, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestActionRunnerCredentialAdmissionRejectsWrongIdentityAndUnboundOrganization(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
agentID string
|
|
hostname string
|
|
clearOrg bool
|
|
}{
|
|
{name: "wrong agent", agentID: "other", hostname: "node.example"},
|
|
{name: "wrong host", agentID: "machine-a", hostname: "other.example"},
|
|
{name: "missing organization binding", agentID: "machine-a", hostname: "node.example", clearOrg: true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cfg := &config.Config{DataPath: t.TempDir()}
|
|
rawToken, _, err := agenttokens.IssueActionRunnerAndPersist(cfg, nil, agenttokens.ActionRunnerIssueOptions{
|
|
OrgID: "org-a", AgentID: "machine-a", Hostname: "node.example",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tc.clearOrg {
|
|
cfg.APITokens[0].OrgID = ""
|
|
}
|
|
if _, ok := (&Router{config: cfg}).admitAgentExecToken(rawToken, tc.agentID, tc.hostname); ok {
|
|
t.Fatal("mismatched action credential was admitted")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestActionRunnerCredentialRotationRevokesPreviousSecretOnlyAtActivation(t *testing.T) {
|
|
cfg := &config.Config{DataPath: t.TempDir()}
|
|
firstToken, firstRecord, err := agenttokens.IssueActionRunnerAndPersist(cfg, nil, agenttokens.ActionRunnerIssueOptions{
|
|
OrgID: "org-a", AgentID: "machine-a", Hostname: "node.example",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, _, err := agenttokens.ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), firstRecord.ID, "machine-a", "node.example"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
router := &Router{config: cfg}
|
|
if _, ok := router.admitAgentExecToken(firstToken, "machine-a", "node.example"); !ok {
|
|
t.Fatal("initial action runner credential was rejected")
|
|
}
|
|
|
|
secondToken, secondRecord, err := agenttokens.IssueActionRunnerAndPersist(cfg, nil, agenttokens.ActionRunnerIssueOptions{
|
|
OrgID: "org-a", AgentID: "machine-a", Hostname: "renamed.example",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if firstRecord.ID == secondRecord.ID || firstToken == secondToken || len(cfg.APITokens) != 2 {
|
|
t.Fatalf("rotation did not prepare beside the prior credential: %#v", cfg.APITokens)
|
|
}
|
|
if _, ok := router.admitAgentExecToken(firstToken, "machine-a", "node.example"); !ok {
|
|
t.Fatal("prepared rotation revoked the prior action runner credential")
|
|
}
|
|
if admission, ok := router.admitAgentExecToken(secondToken, "machine-a", "renamed.example"); !ok || !admission.ActivationPending {
|
|
t.Fatal("replacement action runner credential was rejected")
|
|
}
|
|
if _, revoked, changed, err := agenttokens.ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), secondRecord.ID, "machine-a", "renamed.example"); err != nil || !changed || len(revoked) != 1 || revoked[0].ID != firstRecord.ID {
|
|
t.Fatalf("rotation activation = revoked %#v, changed %v, error %v", revoked, changed, err)
|
|
}
|
|
if _, ok := router.admitAgentExecToken(firstToken, "machine-a", "node.example"); ok {
|
|
t.Fatal("activated rotation left prior action runner credential valid")
|
|
}
|
|
}
|
|
|
|
func TestSecurityActionRunnerActivationFencesPredecessorResultsAcrossPersistence(t *testing.T) {
|
|
testActionRunnerCredentialFencesPredecessorResultsAcrossPersistence(t)
|
|
}
|
|
|
|
func TestAgentExecTokenRejectsAmbiguousMultiOrganizationAuthority(t *testing.T) {
|
|
rawToken := "multi-org-agent-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, map[string]string{
|
|
"bound_agent_id": "agent-1",
|
|
"bound_hostname": "host-1",
|
|
agentExecBindingVersionKey: agentExecBindingVersion,
|
|
})
|
|
record.OrgIDs = []string{"org-a", "org-b"}
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
if _, ok := router.admitAgentExecToken(rawToken, "agent-1", "host-1"); ok {
|
|
t.Fatal("multi-organization exec token was admitted to an ambiguous command session")
|
|
}
|
|
}
|
|
|
|
func TestAgentExecTokenBindingFailsClosedAndRestoresMetadataWhenPersistenceFails(t *testing.T) {
|
|
rawToken := "persist-failure-agent-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, map[string]string{
|
|
"install_type": "docker",
|
|
"issued_via": agentInstallIssuedViaConfig,
|
|
})
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
|
|
notDirectory := filepath.Join(t.TempDir(), "not-a-directory")
|
|
persistence := config.NewConfigPersistence(notDirectory)
|
|
if err := os.RemoveAll(notDirectory); err != nil {
|
|
t.Fatalf("remove persistence directory: %v", err)
|
|
}
|
|
if err := os.WriteFile(notDirectory, []byte("blocked"), 0600); err != nil {
|
|
t.Fatalf("create persistence blocker: %v", err)
|
|
}
|
|
router := &Router{
|
|
config: cfg,
|
|
persistence: persistence,
|
|
}
|
|
|
|
if _, ok := router.admitAgentExecToken(rawToken, "agent-1", "host-1"); ok {
|
|
t.Fatal("first-use command admission succeeded without durable identity binding")
|
|
}
|
|
config.Mu.RLock()
|
|
defer config.Mu.RUnlock()
|
|
for _, key := range []string{
|
|
"bound_agent_id",
|
|
"bound_hostname",
|
|
"bound_at",
|
|
agentExecBindingVersionKey,
|
|
} {
|
|
if _, present := cfg.APITokens[0].Metadata[key]; present {
|
|
t.Fatalf("failed persistence left transient %q binding metadata behind", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAgentExecTokenBindingAcceptsHostnameMatch covers the deploy/enroll flow
|
|
// where the runtime token carries both bound_agent_id (server-canonical
|
|
// "agent-<hostname>" form) and bound_hostname, but the agent's runtime
|
|
// agent_id is derived locally from /etc/machine-id and does NOT match
|
|
// bound_agent_id. The hostname is the authoritative binding — the agent
|
|
// proves it is running on the bound host by registering with that hostname.
|
|
//
|
|
// Regression: prior to this test, the hardening commit 3ec2c0779 enforced
|
|
// strict bound_agent_id equality, which silently rejected every deploy-flow
|
|
// agent (they never produce "agent-<hostname>" as their runtime ID) and made
|
|
// the AI command tool report "No agents are currently connected" despite
|
|
// agents appearing online via HTTP reports.
|
|
func TestAgentExecTokenBindingAcceptsHostnameMatch(t *testing.T) {
|
|
rawToken := "agent-deploy-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, map[string]string{
|
|
"bound_agent_id": "agent-prox97",
|
|
"bound_hostname": "prox97",
|
|
})
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := wsURLForHTTP(ts.URL) + "/api/agent/ws"
|
|
|
|
// Agent registers with machine-id-style agent_id that does NOT match
|
|
// bound_agent_id, but hostname matches bound_hostname. Must succeed.
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
regMsg, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
|
AgentID: "f0c1b2a3e4d5f60718293a4b5c6d7e8f",
|
|
Hostname: "prox97",
|
|
Version: "1.0.0",
|
|
Platform: "linux",
|
|
Token: rawToken,
|
|
})
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatalf("NewMessage: %v", err)
|
|
}
|
|
if err := conn.WriteJSON(regMsg); err != nil {
|
|
conn.Close()
|
|
t.Fatalf("WriteJSON: %v", err)
|
|
}
|
|
reg := readRegisteredPayload(t, conn)
|
|
if !reg.Success {
|
|
conn.Close()
|
|
t.Fatalf("expected registration to be accepted when hostname matches bound_hostname, got %q", reg.Message)
|
|
}
|
|
conn.Close()
|
|
config.Mu.RLock()
|
|
if got := cfg.APITokens[0].Metadata["bound_agent_id"]; got != "f0c1b2a3e4d5f60718293a4b5c6d7e8f" {
|
|
config.Mu.RUnlock()
|
|
t.Fatalf("legacy hostname binding migrated agent id = %q", got)
|
|
}
|
|
if got := cfg.APITokens[0].Metadata[agentExecBindingVersionKey]; got != agentExecBindingVersion {
|
|
config.Mu.RUnlock()
|
|
t.Fatalf("legacy binding version = %q", got)
|
|
}
|
|
config.Mu.RUnlock()
|
|
|
|
// The hostname exception is a one-time upgrade migration. Once the runtime
|
|
// identity is persisted, replay with a second ID on the same host fails.
|
|
conn, _, err = websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
regMsg, err = agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
|
AgentID: "second-runtime-id",
|
|
Hostname: "prox97",
|
|
Version: "1.0.0",
|
|
Platform: "linux",
|
|
Token: rawToken,
|
|
})
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatalf("NewMessage: %v", err)
|
|
}
|
|
if err := conn.WriteJSON(regMsg); err != nil {
|
|
conn.Close()
|
|
t.Fatalf("WriteJSON: %v", err)
|
|
}
|
|
if replay := readRegisteredPayload(t, conn); replay.Success {
|
|
conn.Close()
|
|
t.Fatal("migrated token accepted replay under a second runtime identity")
|
|
}
|
|
conn.Close()
|
|
|
|
// Mismatched hostname AND mismatched agent_id must still be rejected —
|
|
// a leaked token cannot be used from a different host claiming a different
|
|
// agent_id.
|
|
conn, _, err = websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
regMsg, err = agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
|
AgentID: "attacker-id",
|
|
Hostname: "attacker-host",
|
|
Version: "1.0.0",
|
|
Platform: "linux",
|
|
Token: rawToken,
|
|
})
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatalf("NewMessage: %v", err)
|
|
}
|
|
if err := conn.WriteJSON(regMsg); err != nil {
|
|
conn.Close()
|
|
t.Fatalf("WriteJSON: %v", err)
|
|
}
|
|
reg = readRegisteredPayload(t, conn)
|
|
if reg.Success {
|
|
conn.Close()
|
|
t.Fatalf("expected registration to be rejected when neither hostname nor agent_id match the binding")
|
|
}
|
|
conn.Close()
|
|
}
|
|
|
|
func TestSecurityTokens_AgentExecRejectsUnboundToken(t *testing.T) {
|
|
rawToken := "agent-unbound-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := wsURLForHTTP(ts.URL) + "/api/agent/ws"
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
regMsg, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
|
AgentID: "agent-1",
|
|
Hostname: "host-1",
|
|
Version: "1.0.0",
|
|
Platform: "linux",
|
|
Token: rawToken,
|
|
})
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatalf("NewMessage: %v", err)
|
|
}
|
|
if err := conn.WriteJSON(regMsg); err != nil {
|
|
conn.Close()
|
|
t.Fatalf("WriteJSON: %v", err)
|
|
}
|
|
reg := readRegisteredPayload(t, conn)
|
|
if reg.Success {
|
|
conn.Close()
|
|
t.Fatalf("expected registration to be rejected for unbound token")
|
|
}
|
|
conn.Close()
|
|
}
|
|
|
|
func TestSecurityTokens_ProxmoxInstallExecTokenBindsOnFirstCommandRegistration(t *testing.T) {
|
|
rawToken := "agent-install-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec, config.ScopeAgentReport}, map[string]string{
|
|
"install_type": "pve",
|
|
"issued_via": agentInstallIssuedViaConfig,
|
|
})
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := wsURLForHTTP(ts.URL) + "/api/agent/ws"
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
regMsg, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
|
AgentID: "machine-id-agent",
|
|
Hostname: "delly",
|
|
Version: "1.0.0",
|
|
Platform: "linux",
|
|
Token: rawToken,
|
|
})
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatalf("NewMessage: %v", err)
|
|
}
|
|
if err := conn.WriteJSON(regMsg); err != nil {
|
|
conn.Close()
|
|
t.Fatalf("WriteJSON: %v", err)
|
|
}
|
|
reg := readRegisteredPayload(t, conn)
|
|
if !reg.Success {
|
|
conn.Close()
|
|
t.Fatalf("expected Pulse-minted Proxmox install token to bind on first command registration, got %q", reg.Message)
|
|
}
|
|
conn.Close()
|
|
|
|
config.Mu.Lock()
|
|
if got := cfg.APITokens[0].Metadata["bound_hostname"]; got != "delly" {
|
|
config.Mu.Unlock()
|
|
t.Fatalf("bound_hostname = %q, want delly", got)
|
|
}
|
|
if got := cfg.APITokens[0].Metadata["bound_agent_id"]; got != "machine-id-agent" {
|
|
config.Mu.Unlock()
|
|
t.Fatalf("bound_agent_id = %q, want machine-id-agent", got)
|
|
}
|
|
if got := cfg.APITokens[0].Metadata["bound_at"]; got == "" {
|
|
config.Mu.Unlock()
|
|
t.Fatalf("bound_at was not recorded")
|
|
}
|
|
config.Mu.Unlock()
|
|
|
|
persisted, err := config.NewConfigPersistence(cfg.DataPath).LoadAPITokens()
|
|
if err != nil {
|
|
t.Fatalf("LoadAPITokens: %v", err)
|
|
}
|
|
if len(persisted) != 1 {
|
|
t.Fatalf("persisted token count = %d, want 1", len(persisted))
|
|
}
|
|
if got := persisted[0].Metadata["bound_hostname"]; got != "delly" {
|
|
t.Fatalf("persisted bound_hostname = %q, want delly", got)
|
|
}
|
|
|
|
conn, _, err = websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial attacker: %v", err)
|
|
}
|
|
regMsg, err = agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
|
AgentID: "attacker-id",
|
|
Hostname: "other-host",
|
|
Version: "1.0.0",
|
|
Platform: "linux",
|
|
Token: rawToken,
|
|
})
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatalf("NewMessage attacker: %v", err)
|
|
}
|
|
if err := conn.WriteJSON(regMsg); err != nil {
|
|
conn.Close()
|
|
t.Fatalf("WriteJSON attacker: %v", err)
|
|
}
|
|
reg = readRegisteredPayload(t, conn)
|
|
if reg.Success {
|
|
conn.Close()
|
|
t.Fatalf("expected first-use-bound install token to reject a different host")
|
|
}
|
|
conn.Close()
|
|
}
|
|
|
|
func TestSecurityTokens_AgentExecRequiresAgentExecScope(t *testing.T) {
|
|
rawToken := "agent-scope-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/agent/ws"
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
regMsg, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
|
AgentID: "agent-1",
|
|
Hostname: "host-1",
|
|
Version: "1.0.0",
|
|
Platform: "linux",
|
|
Token: rawToken,
|
|
})
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatalf("NewMessage: %v", err)
|
|
}
|
|
if err := conn.WriteJSON(regMsg); err != nil {
|
|
conn.Close()
|
|
t.Fatalf("WriteJSON: %v", err)
|
|
}
|
|
reg := readRegisteredPayload(t, conn)
|
|
if reg.Success {
|
|
conn.Close()
|
|
t.Fatalf("expected registration to be rejected without agent:exec scope")
|
|
}
|
|
conn.Close()
|
|
}
|
|
|
|
func TestSecurityTokens_AgentExecRejectsLegacySingleAPIToken(t *testing.T) {
|
|
rawToken := "legacy-agent-token-123.12345678"
|
|
cfg := &config.Config{
|
|
DataPath: t.TempDir(),
|
|
APIToken: rawToken,
|
|
APITokens: nil,
|
|
AllowedOrigins: "*",
|
|
TemperatureMonitoringEnabled: true,
|
|
}
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := wsURLForHTTP(ts.URL) + "/api/agent/ws"
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
regMsg, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
|
AgentID: "agent-legacy",
|
|
Hostname: "host-legacy",
|
|
Version: "1.0.0",
|
|
Platform: "linux",
|
|
Token: rawToken,
|
|
})
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatalf("NewMessage: %v", err)
|
|
}
|
|
if err := conn.WriteJSON(regMsg); err != nil {
|
|
conn.Close()
|
|
t.Fatalf("WriteJSON: %v", err)
|
|
}
|
|
reg := readRegisteredPayload(t, conn)
|
|
if reg.Success {
|
|
conn.Close()
|
|
t.Fatalf("expected registration to be rejected for legacy single API token")
|
|
}
|
|
conn.Close()
|
|
}
|
|
|
|
func TestWebSocketAllowsMonitoringReadScope(t *testing.T) {
|
|
rawToken := "ws-allow-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
|
|
hub := pulsews.NewHub(nil)
|
|
go hub.Run()
|
|
defer hub.Stop()
|
|
|
|
router := NewRouter(cfg, nil, nil, hub, nil, "1.0.0")
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?org_id=default"
|
|
headers := wsHeadersForHTTP(t, ts.URL)
|
|
headers.Set("X-API-Token", rawToken)
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, headers)
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
conn.Close()
|
|
}
|
|
|
|
func TestWebSocketAllowsBearerToken(t *testing.T) {
|
|
rawToken := "ws-bearer-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
|
|
hub := pulsews.NewHub(nil)
|
|
go hub.Run()
|
|
defer hub.Stop()
|
|
|
|
router := NewRouter(cfg, nil, nil, hub, nil, "1.0.0")
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?org_id=default"
|
|
headers := wsHeadersForHTTP(t, ts.URL)
|
|
headers.Set("Authorization", "Bearer "+rawToken)
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, headers)
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
conn.Close()
|
|
}
|
|
|
|
func TestWebSocketAllowsTokenQueryParam(t *testing.T) {
|
|
rawToken := "ws-query-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
|
|
hub := pulsews.NewHub(nil)
|
|
go hub.Run()
|
|
defer hub.Stop()
|
|
|
|
router := NewRouter(cfg, nil, nil, hub, nil, "1.0.0")
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?org_id=default&token=" + rawToken
|
|
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
if resp == nil || resp.StatusCode != http.StatusSwitchingProtocols {
|
|
conn.Close()
|
|
t.Fatalf("expected 101 switching protocols, got %v", resp)
|
|
}
|
|
conn.Close()
|
|
}
|
|
|
|
func TestWebSocketRejectsOrgQueryMismatchWithTenantContext(t *testing.T) {
|
|
rawToken := "ws-org-mismatch-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
|
|
hub := pulsews.NewHub(nil)
|
|
go hub.Run()
|
|
defer hub.Stop()
|
|
|
|
router := NewRouter(cfg, nil, nil, hub, nil, "1.0.0")
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?org_id=tenant-b&token=" + rawToken
|
|
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err == nil {
|
|
conn.Close()
|
|
t.Fatalf("expected websocket org mismatch rejection")
|
|
}
|
|
if resp == nil {
|
|
t.Fatalf("expected HTTP response for websocket org mismatch")
|
|
}
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for websocket org mismatch, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestWebSocketRejectsInvalidOrgQueryID(t *testing.T) {
|
|
rawToken := "ws-invalid-org-query-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
|
|
hub := pulsews.NewHub(nil)
|
|
go hub.Run()
|
|
defer hub.Stop()
|
|
|
|
router := NewRouter(cfg, nil, nil, hub, nil, "1.0.0")
|
|
ts := newIPv4HTTPServer(t, router.Handler())
|
|
defer ts.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?org_id=../tenant-b&token=" + rawToken
|
|
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
|
if err == nil {
|
|
conn.Close()
|
|
t.Fatalf("expected websocket invalid org rejection")
|
|
}
|
|
if resp == nil {
|
|
t.Fatalf("expected HTTP response for websocket invalid org")
|
|
}
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for websocket invalid org, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestQueryTokenIgnoredForHTTPRequests(t *testing.T) {
|
|
rawToken := "query-token-ignored-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config?token="+rawToken, nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 when token is only in query string, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestLogEndpointsRequireSettingsReadScope(t *testing.T) {
|
|
rawToken := "logs-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/logs/stream",
|
|
"/api/logs/download",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:read scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLogEndpointsRequireAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "log-auth-token-123.12345678", []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/logs/stream",
|
|
"/api/logs/download",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth on %s, got %d", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLogLevelReadRequiresSettingsReadScope(t *testing.T) {
|
|
rawToken := "log-level-read-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/logs/level", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestLogLevelUpdateRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "log-level-write-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/logs/level", strings.NewReader(`{"level":"info"}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUpdateReadEndpointsRequireSettingsReadScope(t *testing.T) {
|
|
rawToken := "updates-read-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/updates/check",
|
|
"/api/updates/status",
|
|
"/api/updates/plan",
|
|
"/api/updates/pro-runtime",
|
|
}
|
|
|
|
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")
|
|
|
|
for _, path := range []string{"/api/updates/apply", "/api/updates/pro-runtime/apply"} {
|
|
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("%s: expected 403 for missing settings:write scope, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("%s: expected missing scope response to mention %q, got %q", path, config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLicenseMutationsRequireSettingsWriteScope(t *testing.T) {
|
|
rawToken := "license-write-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/license/activate",
|
|
"/api/license/clear",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetupScriptURLRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "setup-script-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/setup-script-url", strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSetupScriptURLRejectsSetupTokenAuthWhenPulseAuthIsConfigured(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
token := "0123456789abcdef0123456789abcdef"
|
|
tokenHash := auth.HashAPIToken(token)
|
|
router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{
|
|
ExpiresAt: time.Now().Add(time.Minute),
|
|
NodeType: "pve",
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/setup-script-url", strings.NewReader(`{"type":"pve","host":"pve.local"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Setup-Token", token)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected setup token auth on /api/setup-script-url to be rejected with 401, got %d (%s)", rec.Code, rec.Body.String())
|
|
}
|
|
if body := rec.Body.String(); body != `{"error":"Authentication required"}` {
|
|
t.Fatalf("body = %q, want authentication-required JSON", body)
|
|
}
|
|
}
|
|
|
|
func TestAgentInstallCommandRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "agent-install-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/agent-install-command", strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestDiscoverRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "discover-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []struct {
|
|
name string
|
|
method string
|
|
body string
|
|
}{
|
|
{name: "get", method: http.MethodGet, body: ""},
|
|
{name: "post", method: http.MethodPost, body: `{}`},
|
|
}
|
|
|
|
for _, tc := range paths {
|
|
req := httptest.NewRequest(tc.method, "/api/discover", strings.NewReader(tc.body))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope on %s, got %d", tc.name, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAIOAuthEndpointsRequireSettingsWriteScope(t *testing.T) {
|
|
rawToken := "ai-oauth-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/ai/oauth/start",
|
|
"/api/ai/oauth/exchange",
|
|
"/api/ai/oauth/disconnect",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAIExecuteEndpointsRequireAIExecuteScope(t *testing.T) {
|
|
rawToken := "ai-exec-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/ai/execute",
|
|
"/api/ai/execute/stream",
|
|
"/api/actions/plan",
|
|
"/api/actions/act_test/decision",
|
|
"/api/actions/act_test/execute",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing ai:execute scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIExecute, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAIRemediationMutationsRequireAIExecuteScope(t *testing.T) {
|
|
rawToken := "ai-remediate-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/ai/remediation/execute",
|
|
"/api/ai/remediation/rollback",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing ai:execute scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIExecute, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAIAgentsRequiresAIExecuteScope(t *testing.T) {
|
|
rawToken := "ai-agents-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/ai/agents", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing ai:execute scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIExecute, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAICostEndpointsRequireSettingsScopes(t *testing.T) {
|
|
rawToken := "ai-cost-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
// Summary requires settings:read
|
|
req := httptest.NewRequest(http.MethodGet, "/api/ai/cost/summary", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
|
|
}
|
|
|
|
// Reset requires settings:write
|
|
req = httptest.NewRequest(http.MethodPost, "/api/ai/cost/reset", strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec = httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
|
|
// Export requires settings:read
|
|
req = httptest.NewRequest(http.MethodGet, "/api/ai/cost/export", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec = httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAIDebugContextRequiresSettingsReadScope(t *testing.T) {
|
|
rawToken := "ai-debug-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/ai/debug/context", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAIRunCommandRequiresAIExecuteScope(t *testing.T) {
|
|
rawToken := "ai-run-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/ai/run-command", strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing ai:execute scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIExecute, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAIPatrolRunRequiresAIExecuteScope(t *testing.T) {
|
|
rawToken := "ai-patrol-run-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/run", strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing ai:execute scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIExecute, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAIPatrolAutonomyRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "ai-patrol-autonomy-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/autonomy", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAIPatrolAutonomyUpdateRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "ai-patrol-autonomy-update-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/api/ai/patrol/autonomy", strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAIExecuteReadEndpointsRequireAIExecuteScope(t *testing.T) {
|
|
rawToken := "ai-exec-read-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/ai/patrol/status",
|
|
"/api/ai/patrol/stream",
|
|
"/api/ai/patrol/findings",
|
|
"/api/ai/patrol/objectives",
|
|
"/api/ai/patrol/objectives/objective-1",
|
|
"/api/ai/patrol/history",
|
|
"/api/ai/patrol/runs",
|
|
"/api/ai/patrol/runs/run-1",
|
|
"/api/ai/patrol/digest",
|
|
"/api/ai/patrol/dismissed",
|
|
"/api/ai/patrol/suppressions",
|
|
"/api/ai/approvals",
|
|
"/api/ai/approvals/approval-1",
|
|
"/api/ai/intelligence",
|
|
"/api/ai/intelligence/patterns",
|
|
"/api/ai/intelligence/predictions",
|
|
"/api/ai/intelligence/correlations",
|
|
"/api/ai/intelligence/changes",
|
|
"/api/ai/intelligence/baselines",
|
|
"/api/ai/intelligence/remediations",
|
|
"/api/ai/intelligence/anomalies",
|
|
"/api/ai/intelligence/learning",
|
|
"/api/ai/unified/findings",
|
|
"/api/ai/forecast",
|
|
"/api/ai/forecasts/overview",
|
|
"/api/ai/learning/preferences",
|
|
"/api/ai/proxmox/events",
|
|
"/api/ai/proxmox/correlations",
|
|
"/api/ai/remediation/plans",
|
|
"/api/ai/remediation/plan",
|
|
"/api/ai/circuit/status",
|
|
"/api/ai/incidents",
|
|
"/api/ai/incidents/incident-1",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing ai:execute scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIExecute, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRelayMobileAccessScopeAllowsGovernedMobileRuntimeEndpoints(t *testing.T) {
|
|
rawToken := "relay-mobile-runtime-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeRelayMobileAccess}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodGet, path: "/api/onboarding/qr", body: ""},
|
|
{method: http.MethodPost, path: "/api/onboarding/validate", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/onboarding/deep-link", body: ""},
|
|
{method: http.MethodGet, path: "/api/ai/patrol/findings", body: ""},
|
|
{method: http.MethodGet, path: "/api/ai/findings/finding-1/investigation", body: ""},
|
|
{method: http.MethodGet, path: "/api/ai/findings/finding-1/investigation/messages", body: ""},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/acknowledge", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/dismiss", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/snooze", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/ai/approvals", body: ""},
|
|
{method: http.MethodPost, path: "/api/ai/approvals/approval-1/approve", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/approvals/approval-1/deny", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/chat", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/ai/sessions", body: ""},
|
|
{method: http.MethodGet, path: "/api/ai/sessions/session-1/messages", body: ""},
|
|
{method: http.MethodPost, path: "/api/ai/sessions/session-1/abort", body: `{}`},
|
|
{method: http.MethodPatch, path: "/api/ai/sessions/session-1", body: `{"title":"Renamed session"}`},
|
|
{method: http.MethodDelete, path: "/api/ai/sessions/session-1", body: ""},
|
|
}
|
|
|
|
for _, tc := range paths {
|
|
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code == http.StatusForbidden && strings.Contains(rec.Body.String(), "missing_scope") {
|
|
t.Fatalf("expected relay mobile scope to pass scope gating on %s %s, got %d with body %q", tc.method, tc.path, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRelayMobileApprovalsListReturnsEmptyBeforeAIChatConfigured(t *testing.T) {
|
|
rawToken := "relay-mobile-approvals-empty-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeRelayMobileAccess}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
|
|
previousStore := approval.GetStore()
|
|
approval.SetStore(nil)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
t.Cleanup(func() {
|
|
router.shutdownBackgroundWorkers()
|
|
approval.SetStore(previousStore)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/ai/approvals", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("approvals list status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
var response struct {
|
|
Approvals []approval.ApprovalRequest `json:"approvals"`
|
|
Stats map[string]int `json:"stats"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("decode approvals list response: %v", err)
|
|
}
|
|
if response.Approvals == nil {
|
|
t.Fatal("approvals must be an empty array, not null")
|
|
}
|
|
if len(response.Approvals) != 0 {
|
|
t.Fatalf("approvals length = %d, want 0", len(response.Approvals))
|
|
}
|
|
if response.Stats["pending"] != 0 || response.Stats["approved"] != 0 || response.Stats["denied"] != 0 || response.Stats["expired"] != 0 || response.Stats["executions"] != 0 {
|
|
t.Fatalf("expected zero approval stats, got %#v", response.Stats)
|
|
}
|
|
}
|
|
|
|
func TestRelayMobileAccessScopeDeniesAdjacentAIRoutes(t *testing.T) {
|
|
rawToken := "relay-mobile-adjacent-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeRelayMobileAccess}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
want string
|
|
}{
|
|
{method: http.MethodGet, path: "/api/ai/models", body: "", want: config.ScopeAIChat},
|
|
{method: http.MethodGet, path: "/api/ai/status", body: "", want: config.ScopeAIChat},
|
|
{method: http.MethodPost, path: "/api/ai/workflow-prompts/activity", body: `{}`, want: config.ScopeAIChat},
|
|
{method: http.MethodGet, path: "/api/ai/agents", body: "", want: config.ScopeAIExecute},
|
|
{method: http.MethodGet, path: "/api/ai/approvals/approval-1", body: "", want: config.ScopeAIExecute},
|
|
{method: http.MethodGet, path: "/api/ai/patrol/objectives", body: "", want: config.ScopeAIExecute},
|
|
{method: http.MethodDelete, path: "/api/ai/patrol/findings", body: "", want: config.ScopeAIExecute},
|
|
{method: http.MethodPost, path: "/api/ai/findings/finding-1/reinvestigate", body: `{}`, want: config.ScopeAIExecute},
|
|
{method: http.MethodPost, path: "/api/ai/findings/finding-1/reapprove", body: `{}`, want: config.ScopeAIExecute},
|
|
{method: http.MethodGet, path: "/api/ai/sessions/session-1", body: "", want: config.ScopeAIChat},
|
|
// Retired web chat sub-resources fall to the session router default,
|
|
// which still refuses tokens without ai:chat before answering 404.
|
|
{method: http.MethodPost, path: "/api/ai/sessions/session-1/summarize", body: `{}`, want: config.ScopeAIChat},
|
|
{method: http.MethodGet, path: "/api/ai/sessions/session-1/diff", body: "", want: config.ScopeAIChat},
|
|
{method: http.MethodPost, path: "/api/ai/sessions/session-1/fork", body: `{}`, want: config.ScopeAIChat},
|
|
}
|
|
|
|
for _, tc := range paths {
|
|
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for relay mobile token on %s %s, got %d", tc.method, tc.path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), tc.want) {
|
|
t.Fatalf("expected missing scope response to mention %q for %s %s, got %q", tc.want, tc.method, tc.path, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAIExecuteMutationEndpointsRequireAIExecuteScope(t *testing.T) {
|
|
rawToken := "ai-exec-mutate-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodPost, path: "/api/ai/patrol/acknowledge", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/dismiss", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/findings/note", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/suppress", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/snooze", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/resolve", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/suppressions", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/ai/patrol/suppressions/rule-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/objectives", body: `{}`},
|
|
{method: http.MethodPatch, path: "/api/ai/patrol/objectives/objective-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/ai/patrol/objectives/objective-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/ai/remediation/approve", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/findings/f-1/reapprove", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/approvals/approval-1/approve", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/approvals/approval-1/deny", body: `{}`},
|
|
}
|
|
|
|
for _, tc := range paths {
|
|
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing ai:execute scope on %s %s, got %d", tc.method, tc.path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIExecute, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInfraUpdateReadEndpointsRequireMonitoringReadScope(t *testing.T) {
|
|
rawToken := "infra-read-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/infra-updates",
|
|
"/api/infra-updates/summary",
|
|
"/api/infra-updates/agent/host-1",
|
|
"/api/infra-updates/docker:host-1/c1",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing monitoring:read scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeMonitoringRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringRead, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInfraUpdateCheckRequiresMonitoringWriteScope(t *testing.T) {
|
|
rawToken := "infra-write-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/infra-updates/check", strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing monitoring:write scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeMonitoringWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringWrite, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAlertReadEndpointsRequireMonitoringReadScope(t *testing.T) {
|
|
rawToken := "alerts-read-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/alerts/config",
|
|
"/api/alerts/active",
|
|
"/api/alerts/history",
|
|
"/api/alerts/incidents",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing monitoring:read scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeMonitoringRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringRead, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAlertMutationEndpointsRequireMonitoringWriteScope(t *testing.T) {
|
|
rawToken := "alerts-write-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodPut, path: "/api/alerts/config", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/alerts/activate", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/alerts/history", body: ""},
|
|
{method: http.MethodPost, path: "/api/alerts/bulk/acknowledge", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/alerts/bulk/clear", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/alerts/acknowledge", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/alerts/unacknowledge", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/alerts/snooze", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/alerts/unsnooze", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/alerts/clear", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/alerts/incidents/note", body: `{}`},
|
|
}
|
|
|
|
for _, tc := range paths {
|
|
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing monitoring:write scope on %s %s, got %d", tc.method, tc.path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeMonitoringWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringWrite, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAlertMutationEndpointsAllowMonitoringWriteWithoutReadScope(t *testing.T) {
|
|
rawToken := "alerts-write-only-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/alerts/acknowledge", strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code == http.StatusForbidden {
|
|
t.Fatalf("expected write-only token to reach alert mutation handler, got 403 body=%q", rec.Body.String())
|
|
}
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected validation failure (400) after passing scope checks, got %d body=%q", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
readReq := httptest.NewRequest(http.MethodGet, "/api/alerts/config", nil)
|
|
readReq.Header.Set("X-API-Token", rawToken)
|
|
readRec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(readRec, readReq)
|
|
if readRec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for read endpoint with write-only token, got %d", readRec.Code)
|
|
}
|
|
if !strings.Contains(readRec.Body.String(), config.ScopeMonitoringRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringRead, readRec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestNotificationQueueStatsRequireSettingsReadScope(t *testing.T) {
|
|
rawToken := "queue-stats-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/notifications/queue/stats", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestConfigSystemRequiresSettingsReadScope(t *testing.T) {
|
|
rawToken := "config-system-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config/system", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSystemSettingsRequiresSettingsReadScope(t *testing.T) {
|
|
rawToken := "system-settings-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/system/settings", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSystemSettingsUpdateRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "system-settings-write-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/settings/update", strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSystemSettingsUpdatePreservesAPIOnlyAuthError(t *testing.T) {
|
|
rawToken := "system-settings-api-only-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
handler := NewSystemSettingsHandler(
|
|
cfg,
|
|
config.NewConfigPersistence(cfg.DataPath),
|
|
nil,
|
|
nil,
|
|
nil,
|
|
func() {},
|
|
func() error { return nil },
|
|
)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/settings/update", strings.NewReader(`{}`))
|
|
rec := httptest.NewRecorder()
|
|
handler.HandleUpdateSystemSettings(rec, req)
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for missing API token, got %d", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "API token required") {
|
|
t.Fatalf("expected API-token-specific auth response, got %q", body)
|
|
}
|
|
if strings.Contains(body, `"unauthorized"`) {
|
|
t.Fatalf("expected route to preserve API-token-specific auth response, got generic body %q", body)
|
|
}
|
|
}
|
|
|
|
func TestSystemSettingsReportBrandingValidationRejectsUnsafePayload(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
raw map[string]interface{}
|
|
want string
|
|
}{
|
|
{
|
|
name: "non_object",
|
|
raw: map[string]interface{}{"reportBranding": "Client"},
|
|
want: "reportBranding must be an object",
|
|
},
|
|
{
|
|
name: "unsupported_key",
|
|
raw: map[string]interface{}{"reportBranding": map[string]interface{}{
|
|
"displayName": "Client",
|
|
"tenantID": "other-client",
|
|
}},
|
|
want: "reportBranding.tenantID is not supported",
|
|
},
|
|
{
|
|
name: "workspace_logo_path_unsupported",
|
|
raw: map[string]interface{}{"reportBranding": map[string]interface{}{
|
|
"logoPath": "/etc/pulse/secrets/handoff.key",
|
|
}},
|
|
want: "reportBranding.logoPath is not supported",
|
|
},
|
|
{
|
|
name: "newline",
|
|
raw: map[string]interface{}{"reportBranding": map[string]interface{}{
|
|
"displayName": "Client\nName",
|
|
}},
|
|
want: "reportBranding.displayName must not contain newlines",
|
|
},
|
|
{
|
|
name: "invalid_logo_base64",
|
|
raw: map[string]interface{}{"reportBranding": map[string]interface{}{
|
|
"logoBase64": "not base64!",
|
|
}},
|
|
want: "reportBranding.logoBase64 must be valid base64",
|
|
},
|
|
{
|
|
name: "invalid_logo_format",
|
|
raw: map[string]interface{}{"reportBranding": map[string]interface{}{
|
|
"logoFormat": "svg",
|
|
}},
|
|
want: "reportBranding.logoFormat must be png, jpg, jpeg, gif, or empty",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateSystemSettings(nil, tt.raw)
|
|
if err == nil {
|
|
t.Fatalf("expected validation error containing %q", tt.want)
|
|
}
|
|
if !strings.Contains(err.Error(), tt.want) {
|
|
t.Fatalf("unexpected validation error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMockModeReadRequiresSettingsReadScope(t *testing.T) {
|
|
rawToken := "mock-mode-read-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/system/mock-mode", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestMockModeWriteRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "mock-mode-write-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/mock-mode", strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestConfigNodesReadRequiresSettingsReadScope(t *testing.T) {
|
|
rawToken := "config-nodes-read-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config/nodes", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestConfigNodesWriteRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "config-nodes-write-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/nodes", strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestConfigNodeMutationsRequireSettingsWriteScope(t *testing.T) {
|
|
rawToken := "config-node-mutate-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodPost, path: "/api/config/nodes/test-config", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/config/nodes/test-connection", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/config/nodes/node-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/config/nodes/node-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/config/nodes/node-1/test", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/config/nodes/node-1/refresh-cluster", body: `{}`},
|
|
}
|
|
|
|
for _, tc := range paths {
|
|
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope on %s %s, got %d", tc.method, tc.path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTrueNASConnectionMutationsRequireSettingsWriteScope(t *testing.T) {
|
|
rawToken := "truenas-mutate-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodPost, path: "/api/truenas/connections", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/truenas/connections/preview", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/truenas/connections/test", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/truenas/connections/conn-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/truenas/connections/conn-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/truenas/connections/conn-1/preview", body: ``},
|
|
{method: http.MethodPost, path: "/api/truenas/connections/conn-1/test", body: ""},
|
|
}
|
|
|
|
for _, tc := range paths {
|
|
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope on %s %s, got %d", tc.method, tc.path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVMwareConnectionMutationsRequireSettingsWriteScope(t *testing.T) {
|
|
rawToken := "vmware-mutate-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodPost, path: "/api/vmware/connections", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/vmware/connections/preview", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/vmware/connections/test", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/vmware/connections/conn-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/vmware/connections/conn-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/vmware/connections/conn-1/preview", body: ``},
|
|
{method: http.MethodPost, path: "/api/vmware/connections/conn-1/test", body: ""},
|
|
}
|
|
|
|
for _, tc := range paths {
|
|
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope on %s %s, got %d", tc.method, tc.path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConfigExportRequiresSettingsReadScope(t *testing.T) {
|
|
rawToken := "config-export-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/export", strings.NewReader(`{}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestConfigImportRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "config-import-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestConfigExportRejectsShortPassphrase(t *testing.T) {
|
|
rawToken := "config-export-pass-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/export", strings.NewReader(`{"passphrase":"short"}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for short passphrase, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Passphrase must be at least 12 characters") {
|
|
t.Fatalf("expected passphrase length error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestConfigExportRequiresPassphrase(t *testing.T) {
|
|
rawToken := "config-export-missing-pass-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/export", strings.NewReader(`{"passphrase":""}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for missing passphrase, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Passphrase is required") {
|
|
t.Fatalf("expected passphrase required error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestConfigImportRejectsMissingData(t *testing.T) {
|
|
rawToken := "config-import-data-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{"passphrase":"long-enough-passphrase","data":""}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for missing data, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Import data is required") {
|
|
t.Fatalf("expected import data error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestConfigImportRequiresPassphrase(t *testing.T) {
|
|
rawToken := "config-import-pass-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{"passphrase":"","data":"encrypted"}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for missing passphrase, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Passphrase is required") {
|
|
t.Fatalf("expected passphrase required error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestConfigExportRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "config-export-auth-token", []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/export", strings.NewReader(`{}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestConfigImportRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "config-import-auth-token", []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestConfigExportImportRejectsNonAdminSession(t *testing.T) {
|
|
hashed, err := auth.HashPassword("Password!1")
|
|
if err != nil {
|
|
t.Fatalf("HashPassword: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
DataPath: t.TempDir(),
|
|
ConfigPath: t.TempDir(),
|
|
AuthUser: "admin",
|
|
AuthPass: hashed,
|
|
}
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
sessionToken := "config-member-session-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
|
GetSessionStore().CreateSession(sessionToken, time.Hour, "agent", "127.0.0.1", "member")
|
|
csrfToken := generateCSRFToken(sessionToken)
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
body string
|
|
}{
|
|
{
|
|
name: "export",
|
|
path: "/api/config/export",
|
|
body: `{"passphrase":"long-enough-passphrase"}`,
|
|
},
|
|
{
|
|
name: "import",
|
|
path: "/api/config/import",
|
|
body: `{"passphrase":"long-enough-passphrase","data":"invalid"}`,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-CSRF-Token", csrfToken)
|
|
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
|
|
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin session on %s, got %d: %s", tc.path, rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Admin privileges required") {
|
|
t.Fatalf("expected admin privileges error on %s, got %q", tc.path, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConfigExportBlocksPublicNetworkWithoutAuth(t *testing.T) {
|
|
cfg := &config.Config{
|
|
DataPath: t.TempDir(),
|
|
ConfigPath: t.TempDir(),
|
|
}
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/export", strings.NewReader(`{}`))
|
|
req.RemoteAddr = "203.0.113.10:1234"
|
|
ResetRateLimitForIP("203.0.113.10")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for public network without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestConfigImportBlocksPublicNetworkWithoutAuth(t *testing.T) {
|
|
cfg := &config.Config{
|
|
DataPath: t.TempDir(),
|
|
ConfigPath: t.TempDir(),
|
|
}
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{}`))
|
|
req.RemoteAddr = "203.0.113.11:1234"
|
|
ResetRateLimitForIP("203.0.113.11")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for public network without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAutoRegisterRequiresAuth(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
router.configHandlers.SetConfig(cfg)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auto-register", strings.NewReader(`{}`))
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAutoRegisterRejectsAPITokenWithoutSetupToken(t *testing.T) {
|
|
rawToken := "auto-register-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
router.configHandlers.SetConfig(cfg)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auto-register", strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 when API token is provided without a Pulse setup token, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Pulse setup token required") {
|
|
t.Fatalf("expected setup-token requirement guidance, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAutoRegisterRejectsAgentTokenWithoutSetupToken(t *testing.T) {
|
|
stubAutoRegisterNetworkDeps(t)
|
|
|
|
rawToken := "agent-register-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentReport}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
router.configHandlers.SetConfig(cfg)
|
|
|
|
// Agent-report tokens can authenticate auto-register but are restricted to
|
|
// updating existing nodes. Attempting to register a brand-new node must
|
|
// return 403, not succeed.
|
|
body := `{"type":"pve","host":"https://192.168.1.1:8006","tokenId":"pulse-monitor@pve!pulse-192-168-1-1","tokenValue":"secret","source":"agent","serverName":"newhost"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auto-register", strings.NewReader(body))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 when agent API token is used to register a new node, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "agent token auth permits token updates for existing nodes only") {
|
|
t.Fatalf("expected agent-token restriction message, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestConfigExportRequiresProxyAdmin(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/export", strings.NewReader(`{}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy user, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Admin privileges required") {
|
|
t.Fatalf("expected admin privilege error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestConfigImportRequiresProxyAdmin(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{}`))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy user, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Admin privileges required") {
|
|
t.Fatalf("expected admin privilege error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func 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/pro-runtime", body: ""},
|
|
{method: http.MethodPost, path: "/api/updates/pro-runtime/apply", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/updates/status", body: ""},
|
|
{method: http.MethodGet, path: "/api/updates/stream", body: ""},
|
|
{method: http.MethodGet, path: "/api/updates/plan", body: ""},
|
|
{method: http.MethodGet, path: "/api/updates/history", body: ""},
|
|
{method: http.MethodGet, path: "/api/updates/history/entry", body: ""},
|
|
{method: http.MethodGet, path: "/api/diagnostics", body: ""},
|
|
{method: http.MethodPost, path: "/api/diagnostics/docker/prepare-token", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/config/system", body: ""},
|
|
{method: http.MethodPost, path: "/api/config/export", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/config/import", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/config/nodes", body: ""},
|
|
{method: http.MethodPost, path: "/api/config/nodes", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/config/nodes/test-config", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/config/nodes/test-connection", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/config/nodes/node-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/config/nodes/node-1", body: ``},
|
|
{method: http.MethodPost, path: "/api/config/nodes/node-1/test", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/config/nodes/node-1/refresh-cluster", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/system/settings", body: ""},
|
|
{method: http.MethodPost, path: "/api/system/settings/update", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/security/reset-lockout", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/security/apply-restart", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/security/tokens/relay-mobile", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/security/tokens", body: ``},
|
|
{method: http.MethodDelete, path: "/api/security/tokens/token-1", body: ``},
|
|
{method: http.MethodPost, path: "/api/security/regenerate-token", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/security/validate-token", body: `{"token":"abc"}`},
|
|
{method: http.MethodPost, path: "/api/system/verify-temperature-ssh", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/system/ssh-config", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/audit", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/roles", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/users", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/reports/generate", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/webhooks/audit", body: ""},
|
|
{method: http.MethodGet, path: "/api/settings/ai", body: ""},
|
|
{method: http.MethodGet, path: "/api/ai/debug/context", body: ""},
|
|
{method: http.MethodPost, path: "/api/ai/execute", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/execute/stream", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/kubernetes/analyze", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/investigate-alert", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/run-command", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/remediation/execute", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/remediation/rollback", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/run", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/ai/patrol/autonomy", body: ""},
|
|
{method: http.MethodPost, path: "/api/ai/patrol/autonomy/acknowledgements", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/ai/patrol/autonomy/acknowledgements/ack-test", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/cost/reset", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/ai/cost/export", body: ""},
|
|
{method: http.MethodPost, path: "/api/ai/oauth/start", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/oauth/exchange", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/oauth/disconnect", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/test", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/test/openai", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/agents/docker/containers/update", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/agents/docker/runtimes/host-1/update-all", body: ``},
|
|
{method: http.MethodDelete, path: "/api/agents/docker/runtimes/host-1", body: ``},
|
|
{method: http.MethodDelete, path: "/api/agents/kubernetes/clusters/cluster-1", body: ``},
|
|
{method: http.MethodPost, path: "/api/agents/agent/link", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/agents/agent/unlink", body: `{}`},
|
|
{method: http.MethodPatch, path: "/api/agents/agent/host-1/config", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/agents/agent/agent-1", body: ``},
|
|
{method: http.MethodPost, path: "/api/agents/host/link", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/agents/host/unlink", body: `{}`},
|
|
{method: http.MethodPatch, path: "/api/agents/host/host-1/config", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/agents/host/agent-1", body: ``},
|
|
{method: http.MethodGet, path: "/api/admin/profiles/", body: ""},
|
|
{method: http.MethodPost, path: "/api/agent-install-command", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/setup-script-url", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/test-notification", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/discover", body: ""},
|
|
{method: http.MethodPost, path: "/api/license/activate", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/license/clear", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/license/status", body: ""},
|
|
{method: http.MethodGet, path: "/api/notifications/queue/stats", body: ""},
|
|
{method: http.MethodGet, path: "/api/notifications/", body: ""},
|
|
{method: http.MethodGet, path: "/api/notifications/dlq", body: ""},
|
|
{method: http.MethodPost, path: "/api/notifications/dlq/retry", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/notifications/dlq/delete", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/notifications/terminal-failures/retry", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/notifications/terminal-failures/dismiss", body: `{}`},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy user on %s %s, got %d", tc.method, tc.path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Admin privileges required") {
|
|
t.Fatalf("expected admin privilege error on %s %s, got %q", tc.method, tc.path, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDockerAgentEndpointsRequireDockerReportScope(t *testing.T) {
|
|
rawToken := "docker-report-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/agents/docker/report",
|
|
"/api/agents/docker/commands/command-1",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing docker:report scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeDockerReport) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeDockerReport, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDockerManageEndpointsRequireDockerManageScope(t *testing.T) {
|
|
rawToken := "docker-manage-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeDockerReport}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/agents/docker/runtimes/host-1",
|
|
"/api/agents/docker/containers/update",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing docker:manage scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeDockerManage) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeDockerManage, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestKubernetesAgentEndpointsRequireKubernetesReportScope(t *testing.T) {
|
|
rawToken := "kube-report-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/agents/kubernetes/report", strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing kubernetes:report scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeKubernetesReport) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeKubernetesReport, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestKubernetesManageEndpointsRequireKubernetesManageScope(t *testing.T) {
|
|
rawToken := "kube-manage-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeKubernetesReport}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/agents/kubernetes/clusters/cluster-1", strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing kubernetes:manage scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeKubernetesManage) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeKubernetesManage, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUnifiedAgentEndpointsRequireAgentReportScope(t *testing.T) {
|
|
rawToken := "host-report-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/agents/agent/report",
|
|
"/api/agents/agent/lookup",
|
|
"/api/agents/agent/uninstall",
|
|
"/api/agents/host/report",
|
|
"/api/agents/host/lookup",
|
|
"/api/agents/host/uninstall",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing agent:report scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeAgentReport) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAgentReport, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUnifiedAgentEndpointsAcceptLegacyUnifiedAgentReportScopeAlias(t *testing.T) {
|
|
rawToken := "legacy-host-report-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{"host-agent:report"}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
for _, path := range []string{
|
|
"/api/agents/agent/report",
|
|
"/api/agents/host/report",
|
|
} {
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code == http.StatusForbidden {
|
|
t.Fatalf("expected legacy host-agent:report scope alias to pass scope gate on %s, got 403: %s", path, rec.Body.String())
|
|
}
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected request to reach handler and fail on invalid JSON for %s, got %d", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUnifiedAgentConfigPatchRequiresAgentManageScope(t *testing.T) {
|
|
rawToken := "host-config-manage-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentConfigRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
for _, path := range []string{"/api/agents/agent/host-1/config", "/api/agents/host/host-1/config"} {
|
|
req := httptest.NewRequest(http.MethodPatch, path, strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing agent:manage scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeAgentManage) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAgentManage, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMonitoringReadEndpointsRequireMonitoringReadScope(t *testing.T) {
|
|
rawToken := "monitoring-read-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/config",
|
|
"/api/runtime/branding",
|
|
"/api/storage/host-1",
|
|
"/api/storage-charts",
|
|
"/api/charts",
|
|
"/api/charts/workloads",
|
|
"/api/metrics-store/stats",
|
|
"/api/metrics-store/history",
|
|
"/api/availability-history",
|
|
"/api/guests/metadata",
|
|
"/api/guests/metadata/guest-1",
|
|
"/api/docker/metadata",
|
|
"/api/docker/metadata/container-1",
|
|
"/api/docker/runtimes/metadata",
|
|
"/api/docker/runtimes/metadata/host-1",
|
|
"/api/agents/metadata",
|
|
"/api/agents/metadata/host-1",
|
|
"/api/agents/metadata",
|
|
"/api/agents/metadata/host-1",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing monitoring:read scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeMonitoringRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringRead, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMetadataMutationEndpointsRequireMonitoringWriteScope(t *testing.T) {
|
|
rawToken := "metadata-write-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodPost, path: "/api/guests/metadata/guest-1", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/guests/metadata/guest-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/guests/metadata/guest-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/docker/metadata/container-1", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/docker/metadata/container-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/docker/metadata/container-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/docker/runtimes/metadata/host-1", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/docker/runtimes/metadata/host-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/docker/runtimes/metadata/host-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/agents/metadata/host-1", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/agents/metadata/host-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/agents/metadata/host-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/agents/metadata/host-1", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/agents/metadata/host-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/agents/metadata/host-1", body: ""},
|
|
}
|
|
|
|
for _, tc := range paths {
|
|
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing monitoring:write scope on %s %s, got %d", tc.method, tc.path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeMonitoringWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringWrite, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAISettingsReadRequiresSettingsReadScope(t *testing.T) {
|
|
rawToken := "ai-settings-read-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/settings/ai", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAISettingsWriteRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "ai-settings-write-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/settings/ai/update",
|
|
"/api/ai/test",
|
|
"/api/ai/test/openai",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAISettingsUpdateRejectsProxyNonAdmin(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
router.aiSettingsHandler.defaultConfig = cfg
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/settings/ai/update", strings.NewReader(`{}`))
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy AI settings update, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAITestConnectionRejectsProxyNonAdmin(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
router.aiSettingsHandler.defaultConfig = cfg
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/ai/test", strings.NewReader(`{}`))
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy AI test, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAITestProviderRejectsProxyNonAdmin(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
router.aiSettingsHandler.defaultConfig = cfg
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/ai/test/openai", strings.NewReader(`{}`))
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy AI provider test, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAITestConnectionRouteWithValidScope(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Set up a mock Ollama server for the AI connection test
|
|
ollama := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/api/version" {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"version": "0.1.0"})
|
|
return
|
|
}
|
|
if r.URL.Path == "/api/tags" {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"models": []map[string]any{
|
|
{"name": "llama3", "modified_at": "2026-04-03T09:00:00Z", "size": 1},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer ollama.Close()
|
|
|
|
router, rawToken := setupTestConnectionRouter(t, ollama.URL)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/ai/test", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for valid settings:write scope on /api/ai/test, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
Success bool `json:"success"`
|
|
Message string `json:"message"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if !resp.Success {
|
|
t.Fatalf("expected success=true, got %+v", resp)
|
|
}
|
|
}
|
|
|
|
func TestAITestProviderRouteWithValidScope(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Set up a mock Ollama server for the provider test
|
|
ollama := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/api/version" {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"version": "0.1.0"})
|
|
return
|
|
}
|
|
if r.URL.Path == "/api/tags" {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"models": []map[string]any{
|
|
{"name": "llama3", "modified_at": "2026-04-03T09:00:00Z", "size": 1},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer ollama.Close()
|
|
|
|
router, rawToken := setupTestProviderRouter(t, ollama.URL)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/ai/test/ollama", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for valid settings:write scope on /api/ai/test/ollama, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
Success bool `json:"success"`
|
|
Provider string `json:"provider"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if !resp.Success || resp.Provider != "ollama" {
|
|
t.Fatalf("expected success=true provider=ollama, got %+v", resp)
|
|
}
|
|
}
|
|
|
|
func TestAITestConnectionRouteRejectsWrongScope(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
router, rawToken := setupAIRouteRouter(t, newAIRouteTestOptions([]string{config.ScopeMonitoringRead}, "http://192.0.2.1:11434"))
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/ai/test", strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for wrong scope on /api/ai/test, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAITestProviderRouteRejectsWrongScope(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
router, rawToken := setupTestProviderRouterForScopes(t, "http://192.0.2.1:11434", []string{config.ScopeMonitoringRead})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/ai/test/openai", strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for wrong scope on /api/ai/test/openai, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAIChatEndpointsRequireAIChatScope(t *testing.T) {
|
|
rawToken := "ai-chat-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodGet, path: "/api/ai/models", body: ""},
|
|
{method: http.MethodPost, path: "/api/ai/workflow-prompts/activity", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/chat", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/ai/sessions", body: ""},
|
|
{method: http.MethodGet, path: "/api/ai/sessions/session-1", body: ""},
|
|
{method: http.MethodGet, path: "/api/ai/knowledge", body: ""},
|
|
{method: http.MethodGet, path: "/api/ai/knowledge/export", body: ""},
|
|
}
|
|
|
|
for _, tc := range paths {
|
|
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing ai:chat scope on %s %s, got %d", tc.method, tc.path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeAIChat) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIChat, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAIKnowledgeMutationEndpointsRequireAIExecuteScope(t *testing.T) {
|
|
rawToken := "ai-chat-only-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
for _, path := range []string{
|
|
"/api/ai/knowledge/save",
|
|
"/api/ai/knowledge/delete",
|
|
"/api/ai/knowledge/import",
|
|
"/api/ai/knowledge/clear",
|
|
} {
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), config.ScopeAIExecute) {
|
|
t.Fatalf("expected %s to require %q, got %d: %s", path, config.ScopeAIExecute, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAuditEndpointsRequireLicenseFeature(t *testing.T) {
|
|
rawToken := "audit-license-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead, config.ScopeAuditRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/audit", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusPaymentRequired {
|
|
t.Fatalf("expected 402 for missing audit logging license, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAuditVerifyRequiresLicenseFeature(t *testing.T) {
|
|
rawToken := "audit-verify-license-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead, config.ScopeAuditRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/audit/event-1/verify", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusPaymentRequired {
|
|
t.Fatalf("expected 402 for missing audit logging license, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestReportingCatalogDoesNotRequireLicenseFeature(t *testing.T) {
|
|
rawToken := "reporting-license-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/catalog", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for reporting catalog without reporting license, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestReportingExecutionEndpointsRequireLicenseFeature(t *testing.T) {
|
|
rawToken := "reporting-license-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
cases := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodGet, path: "/api/admin/reports/generate", body: ""},
|
|
{method: http.MethodPost, path: "/api/admin/reports/generate-multi", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/admin/reports/inventory/vms/export", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/reports/schedules", body: ""},
|
|
{method: http.MethodPost, path: "/api/admin/reports/schedules", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/admin/reports/schedules/schedule-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/admin/reports/schedules/schedule-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/admin/reports/schedules/schedule-1/run", body: ""},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusPaymentRequired {
|
|
t.Fatalf("expected 402 for missing reporting license on %s %s, got %d", tc.method, tc.path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRBACEndpointsRequireLicenseFeature(t *testing.T) {
|
|
rawToken := "rbac-license-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/admin/roles",
|
|
"/api/admin/users",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusPaymentRequired {
|
|
t.Fatalf("expected 402 for missing RBAC license on %s, got %d", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRBACMutationsRequireLicenseFeature(t *testing.T) {
|
|
rawToken := "rbac-license-mutation-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
cases := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodPost, path: "/api/admin/roles", body: `{"id":"role-1","name":"Role 1"}`},
|
|
{method: http.MethodPut, path: "/api/admin/roles/role-1", body: `{"id":"role-1","name":"Role 1"}`},
|
|
{method: http.MethodDelete, path: "/api/admin/roles/role-1", body: ``},
|
|
{method: http.MethodPut, path: "/api/admin/users/alice/roles", body: `{"roleIds":["role-1"]}`},
|
|
{method: http.MethodPost, path: "/api/admin/users/alice/roles", body: `{"roleIds":["role-1"]}`},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusPaymentRequired {
|
|
t.Fatalf("expected 402 for missing RBAC license on %s %s, got %d", tc.method, tc.path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAuditWebhookRequiresLicenseFeature(t *testing.T) {
|
|
rawToken := "audit-webhook-license-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/webhooks/audit", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusPaymentRequired {
|
|
t.Fatalf("expected 402 for missing audit logging license, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSecurityTokensReadRequiresSettingsReadScope(t *testing.T) {
|
|
rawToken := "security-tokens-read-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/security/tokens", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSecurityTokensWriteRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "security-tokens-write-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/security/tokens/relay-mobile",
|
|
"/api/security/tokens",
|
|
"/api/security/tokens/token-1",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
|
|
if strings.Contains(path, "/token-") {
|
|
req = httptest.NewRequest(http.MethodDelete, path, nil)
|
|
}
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAgentProfilesRequireLicenseFeature(t *testing.T) {
|
|
rawToken := "profiles-license-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/profiles/", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusPaymentRequired {
|
|
t.Fatalf("expected 402 for missing agent profiles license, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAILicensedEndpointsRequireLicenseFeature(t *testing.T) {
|
|
rawToken := "ai-license-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeAIExecute}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/ai/kubernetes/analyze",
|
|
"/api/ai/investigate-alert",
|
|
"/api/ai/findings/f-1/reinvestigate",
|
|
"/api/ai/findings/f-1/reapprove",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusPaymentRequired {
|
|
t.Fatalf("expected 402 for missing AI license on %s, got %d", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUpdateHistoryEndpointsRequireSettingsReadScope(t *testing.T) {
|
|
rawToken := "updates-history-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/updates/history",
|
|
"/api/updates/history/entry",
|
|
"/api/updates/stream",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:read scope on %s, got %d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDiagnosticsRequireSettingsReadScope(t *testing.T) {
|
|
rawToken := "diag-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/diagnostics", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAgentFleetDiagnosticsRequireSettingsReadScope(t *testing.T) {
|
|
rawToken := "agent-fleet-diag-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agents/diagnostics", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestDiagnosticsPrepareTokenRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "diag-write-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/diagnostics/docker/prepare-token", strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPermissionProtectedEndpointsDenyWhenAuthorizerBlocks(t *testing.T) {
|
|
prevAuthorizer := auth.GetAuthorizer()
|
|
auth.SetAuthorizer(&denyAuthorizer{})
|
|
defer auth.SetAuthorizer(prevAuthorizer)
|
|
|
|
rawToken := "perm-deny-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead, config.ScopeAuditRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
cases := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodGet, path: "/api/audit", body: ""},
|
|
{method: http.MethodGet, path: "/api/audit/event-1/verify", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/roles", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/roles/", body: ""},
|
|
{method: http.MethodPost, path: "/api/admin/roles", body: `{"id":"role-1","name":"Role 1"}`},
|
|
{method: http.MethodPut, path: "/api/admin/roles/role-1", body: `{"id":"role-1","name":"Role 1"}`},
|
|
{method: http.MethodDelete, path: "/api/admin/roles/role-1", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/users", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/users/", body: ""},
|
|
{method: http.MethodPut, path: "/api/admin/users/alice/roles", body: `{"roleIds":["role-1"]}`},
|
|
{method: http.MethodPost, path: "/api/admin/users/alice/roles", body: `{"roleIds":["role-1"]}`},
|
|
{method: http.MethodGet, path: "/api/admin/users/alice/permissions", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/reports/catalog", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/reports/generate", body: ""},
|
|
{method: http.MethodPost, path: "/api/admin/reports/generate-multi", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/admin/reports/schedules", body: ""},
|
|
{method: http.MethodPost, path: "/api/admin/reports/schedules", body: `{}`},
|
|
{method: http.MethodPut, path: "/api/admin/reports/schedules/schedule-1", body: `{}`},
|
|
{method: http.MethodDelete, path: "/api/admin/reports/schedules/schedule-1", body: ""},
|
|
{method: http.MethodPost, path: "/api/admin/reports/schedules/schedule-1/run", body: ""},
|
|
{method: http.MethodGet, path: "/api/admin/webhooks/audit", body: ""},
|
|
{method: http.MethodPost, path: "/api/security/tokens/relay-mobile", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/security/tokens", body: ""},
|
|
{method: http.MethodDelete, path: "/api/security/tokens/token-1", body: ""},
|
|
{method: http.MethodGet, path: "/api/settings/ai", body: ""},
|
|
{method: http.MethodPost, path: "/api/settings/ai/update", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/test", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/test/openai", body: `{}`},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for permission denial on %s %s, got %d", tc.method, tc.path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPermissionEndpointsRejectProxyNonAdmin(t *testing.T) {
|
|
prevAuthorizer := auth.GetAuthorizer()
|
|
auth.SetAuthorizer(&auth.DefaultAuthorizer{})
|
|
defer auth.SetAuthorizer(prevAuthorizer)
|
|
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/security/tokens", nil)
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy on permissioned endpoint, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Admin privileges required") {
|
|
t.Fatalf("expected admin privilege error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestApplyRestartRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "apply-restart-token-123.12345678", []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/apply-restart", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestApplyRestartRequiresBootstrapTokenBeforeAuthentication(t *testing.T) {
|
|
t.Setenv("INVOCATION_ID", "")
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
|
resetTrustedProxyConfig()
|
|
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
bootstrapToken, _, _, err := loadOrCreateBootstrapToken(cfg.DataPath)
|
|
if err != nil {
|
|
t.Fatalf("load bootstrap token: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
token string
|
|
wantStatus int
|
|
}{
|
|
{name: "missing token", wantStatus: http.StatusUnauthorized},
|
|
{name: "invalid token", token: "invalid-token", wantStatus: http.StatusUnauthorized},
|
|
{name: "valid token", token: bootstrapToken, wantStatus: http.StatusOK},
|
|
}
|
|
|
|
for i, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/apply-restart", nil)
|
|
if tt.token == bootstrapToken {
|
|
req.RemoteAddr = "127.0.0.1:54321"
|
|
} else {
|
|
req.RemoteAddr = fmt.Sprintf("198.51.100.%d:54321", 101+i)
|
|
}
|
|
if tt.token != "" {
|
|
req.Header.Set(bootstrapTokenHeader, tt.token)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != tt.wantStatus {
|
|
t.Fatalf("status = %d, want %d (%s)", rec.Code, tt.wantStatus, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApplyRestartRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "apply-restart-scope-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/apply-restart", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestApplyRestartRequiresProxyAdmin(t *testing.T) {
|
|
record := newTokenRecord(t, "apply-restart-proxy-token-123.12345678", []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/apply-restart", nil)
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy user, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Admin privileges required") {
|
|
t.Fatalf("expected admin privilege error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPrivilegedSecurityEndpointsRejectNonAdminSession(t *testing.T) {
|
|
hashed, err := auth.HashPassword("Password!1")
|
|
if err != nil {
|
|
t.Fatalf("HashPassword: %v", err)
|
|
}
|
|
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = hashed
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
sessionToken := "security-member-session-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
|
GetSessionStore().CreateSession(sessionToken, time.Hour, "agent", "127.0.0.1", "member")
|
|
tests := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodGet, path: "/api/system/settings", body: ``},
|
|
{method: http.MethodPost, path: "/api/system/settings/update", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/diagnostics", body: ``},
|
|
{method: http.MethodGet, path: "/api/diagnostics/docker/prepare-token", body: ``},
|
|
{method: http.MethodGet, path: "/api/license/status", body: ``},
|
|
{method: http.MethodGet, path: "/api/updates/check", body: ``},
|
|
{method: http.MethodGet, path: "/api/updates/status", body: ``},
|
|
{method: http.MethodGet, path: "/api/updates/plan", body: ``},
|
|
{method: http.MethodGet, path: "/api/updates/history", body: ``},
|
|
{method: http.MethodPost, path: "/api/updates/apply", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/updates/pro-runtime", body: ``},
|
|
{method: http.MethodPost, path: "/api/updates/pro-runtime/apply", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/logs/stream", body: ``},
|
|
{method: http.MethodGet, path: "/api/logs/download", body: ``},
|
|
{method: http.MethodGet, path: "/api/settings/relay", body: ``},
|
|
{method: http.MethodGet, path: "/api/settings/relay/status", body: ``},
|
|
{method: http.MethodPut, path: "/api/settings/relay", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/settings/ai", body: ``},
|
|
{method: http.MethodGet, path: "/api/ai/cost/summary", body: ``},
|
|
{method: http.MethodPost, path: "/api/security/tokens/relay-mobile", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/security/tokens", body: ``},
|
|
{method: http.MethodDelete, path: "/api/security/tokens/token-1", body: ``},
|
|
{method: http.MethodGet, path: "/api/security/sso/providers", body: ``},
|
|
{method: http.MethodPost, path: "/api/security/sso/providers", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/security/sso/providers/test", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/security/sso/providers/metadata/preview", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/admin/roles", body: ``},
|
|
{method: http.MethodGet, path: "/api/admin/users", body: ``},
|
|
{method: http.MethodGet, path: "/api/admin/rbac/integrity", body: ``},
|
|
{method: http.MethodPost, path: "/api/admin/rbac/reset-admin", body: `{}`},
|
|
{method: http.MethodGet, path: "/api/admin/webhooks/audit", body: ``},
|
|
{method: http.MethodGet, path: "/api/onboarding/qr", body: ``},
|
|
{method: http.MethodGet, path: "/api/onboarding/deep-link", body: ``},
|
|
{method: http.MethodPost, path: "/api/onboarding/validate", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/security/apply-restart", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/security/regenerate-token", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/security/validate-token", body: `{"token":"abc"}`},
|
|
{method: http.MethodPost, path: "/api/settings/ai/update", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/test", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/ai/test/openai", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/security/reset-lockout", body: `{"identifier":"admin"}`},
|
|
{method: http.MethodPost, path: "/api/system/verify-temperature-ssh", body: `{}`},
|
|
{method: http.MethodPost, path: "/api/system/ssh-config", body: `{}`},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if tc.method != http.MethodGet && tc.method != http.MethodHead {
|
|
req.Header.Set("X-CSRF-Token", generateCSRFToken(sessionToken))
|
|
}
|
|
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
|
|
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin session on %s %s, got %d: %s", tc.method, tc.path, rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Admin privileges required") {
|
|
t.Fatalf("expected admin privilege error on %s %s, got %q", tc.method, tc.path, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVerifyTemperatureSSHRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "verify-ssh-token-123.12345678", []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh", strings.NewReader(`{}`))
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestVerifyTemperatureSSHRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "verify-ssh-scope-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh", strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSSHConfigRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "ssh-config-token-123.12345678", []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/ssh-config", strings.NewReader(`{}`))
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSSHConfigRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "ssh-config-scope-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/ssh-config", strings.NewReader(`{}`))
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestQuickSetupRequiresAuthWhenConfigured(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed-password"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.20")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(`{"username":"admin","password":"Password!1"}`))
|
|
req.RemoteAddr = "203.0.113.20:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestQuickSetupRejectsProxyNonAdmin(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed-password"
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.27")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(`{}`))
|
|
req.RemoteAddr = "203.0.113.27:1234"
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy quick setup, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRegenerateTokenRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "regen-token-123.12345678", []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.21")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/regenerate-token", nil)
|
|
req.RemoteAddr = "203.0.113.21:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRegenerateTokenRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "regen-scope-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.22")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/regenerate-token", nil)
|
|
req.RemoteAddr = "203.0.113.22:1234"
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRegenerateTokenRejectsProxyNonAdmin(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.25")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/regenerate-token", nil)
|
|
req.RemoteAddr = "203.0.113.25:1234"
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy regenerate-token, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestValidateTokenRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "validate-token-123.12345678", []string{config.ScopeSettingsWrite}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.23")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/validate-token", strings.NewReader(`{"token":"abc"}`))
|
|
req.RemoteAddr = "203.0.113.23:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestValidateTokenRequiresSettingsWriteScope(t *testing.T) {
|
|
rawToken := "validate-scope-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.24")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/validate-token", strings.NewReader(`{"token":"abc"}`))
|
|
req.RemoteAddr = "203.0.113.24:1234"
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestValidateTokenRejectsProxyNonAdmin(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.ProxyAuthSecret = "proxy-secret"
|
|
cfg.ProxyAuthUserHeader = "X-Remote-User"
|
|
cfg.ProxyAuthRoleHeader = "X-Remote-Roles"
|
|
cfg.ProxyAuthAdminRole = "admin"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.26")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/validate-token", strings.NewReader(`{"token":"abc"}`))
|
|
req.RemoteAddr = "203.0.113.26:1234"
|
|
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
|
|
req.Header.Set("X-Remote-User", "viewer-user")
|
|
req.Header.Set("X-Remote-Roles", "viewer")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin proxy validate-token, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRecoveryEndpointRejectsRemoteWithoutToken(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.30")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/recovery", strings.NewReader(`{"action":"status"}`))
|
|
req.RemoteAddr = "203.0.113.30:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for remote recovery request, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHealthEndpointIsPublicEvenWhenAuthConfigured(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
monitor, err := monitoring.New(cfg)
|
|
if err != nil {
|
|
t.Fatalf("monitoring.New: %v", err)
|
|
}
|
|
defer monitor.Stop()
|
|
|
|
router := NewRouter(cfg, monitor, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.40")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
|
req.RemoteAddr = "203.0.113.40:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for public health endpoint, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestVersionEndpointIsPublicEvenWhenAuthConfigured(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.41")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
|
req.RemoteAddr = "203.0.113.41:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for public version endpoint, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAgentVersionEndpointIsPublicEvenWhenAuthConfigured(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.42")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/version", nil)
|
|
req.RemoteAddr = "203.0.113.42:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for public agent version endpoint, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestServerInfoEndpointIsPublicEvenWhenAuthConfigured(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.43")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/server/info", nil)
|
|
req.RemoteAddr = "203.0.113.43:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for public server info endpoint, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSecurityStatusIsPublicEvenWhenAuthConfigured(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.44")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil)
|
|
req.RemoteAddr = "203.0.113.44:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for public security status endpoint, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestValidateBootstrapTokenBypassesAuth(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.45")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{}`))
|
|
req.RemoteAddr = "203.0.113.45:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusConflict {
|
|
t.Fatalf("expected 409 when bootstrap token is unavailable, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSecurityStatusHidesBootstrapTokenWhenAuthConfigured(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.49")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil)
|
|
req.RemoteAddr = "203.0.113.49:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for security status, got %d", rec.Code)
|
|
}
|
|
|
|
var payload map[string]interface{}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if _, ok := payload["bootstrapTokenPath"]; ok {
|
|
t.Fatalf("expected bootstrapTokenPath to be omitted when auth is configured")
|
|
}
|
|
}
|
|
|
|
func TestSecurityStatusOmitsPrivilegedDetailsWhenUnauthenticated(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.50")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil)
|
|
req.RemoteAddr = "203.0.113.50:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for security status, got %d", rec.Code)
|
|
}
|
|
|
|
var payload map[string]interface{}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if got := payload["detailLevel"]; got != securityStatusDetailPublic {
|
|
t.Fatalf("detailLevel = %v, want %q", got, securityStatusDetailPublic)
|
|
}
|
|
if got, _ := payload["hasAuthentication"].(bool); got {
|
|
t.Fatalf("hasAuthentication = %v, want false", payload["hasAuthentication"])
|
|
}
|
|
privilegedKeys := []string{
|
|
"apiTokenConfigured",
|
|
"apiTokenHint",
|
|
"authLastModified",
|
|
"bootstrapTokenPath",
|
|
"clientIP",
|
|
"agentUrl",
|
|
"hasAuditLogging",
|
|
"isDocker",
|
|
"inContainer",
|
|
"lxcCtid",
|
|
"dockerContainerName",
|
|
"settingsCapabilities",
|
|
}
|
|
for _, key := range privilegedKeys {
|
|
if _, ok := payload[key]; ok {
|
|
t.Errorf("unauthenticated status exposed privileged field %q", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAuditRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "audit-auth-token-123.12345678", []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/audit", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSecurityStatusIgnoresTokenQueryParam(t *testing.T) {
|
|
rawToken := "status-query-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/security/status?token="+rawToken, nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for security status, got %d", rec.Code)
|
|
}
|
|
|
|
var payload map[string]interface{}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if hint, ok := payload["apiTokenHint"].(string); ok && hint != "" {
|
|
t.Fatalf("expected apiTokenHint to be empty when token passed via query param, got %q", hint)
|
|
}
|
|
if _, ok := payload["tokenScopes"]; ok {
|
|
t.Fatalf("expected tokenScopes to be omitted when unauthenticated")
|
|
}
|
|
}
|
|
|
|
func TestSecurityStatusAcceptsTokenHeader(t *testing.T) {
|
|
rawToken := "status-header-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for security status, got %d", rec.Code)
|
|
}
|
|
|
|
var payload map[string]interface{}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if _, ok := payload["apiTokenHint"]; ok {
|
|
t.Fatalf("scoped token status exposed apiTokenHint: %v", payload["apiTokenHint"])
|
|
}
|
|
if scopes, ok := payload["tokenScopes"].([]interface{}); !ok || len(scopes) == 0 {
|
|
t.Fatalf("expected tokenScopes to be present when authenticated via API token")
|
|
}
|
|
if got := payload["detailLevel"]; got != securityStatusDetailAuthenticated {
|
|
t.Fatalf("detailLevel = %v, want %q", got, securityStatusDetailAuthenticated)
|
|
}
|
|
if _, ok := payload["clientIP"]; ok {
|
|
t.Fatalf("scoped token status exposed privileged clientIP: %v", payload["clientIP"])
|
|
}
|
|
}
|
|
|
|
func TestSecurityStatusExposesPrivilegedDetailsToSettingsReadToken(t *testing.T) {
|
|
rawToken := "status-privileged-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil)
|
|
req.RemoteAddr = "198.51.100.63:54321"
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var payload map[string]interface{}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if got := payload["detailLevel"]; got != securityStatusDetailPrivileged {
|
|
t.Fatalf("detailLevel = %v, want %q", got, securityStatusDetailPrivileged)
|
|
}
|
|
if got, _ := payload["clientIP"].(string); got != "198.51.100.63" {
|
|
t.Fatalf("clientIP = %q, want %q", got, "198.51.100.63")
|
|
}
|
|
if _, ok := payload["apiTokenConfigured"]; !ok {
|
|
t.Fatal("privileged status omitted apiTokenConfigured")
|
|
}
|
|
if got, _ := payload["apiTokenHint"].(string); got != cfg.PrimaryAPITokenHint() {
|
|
t.Fatalf("apiTokenHint = %q, want %q", got, cfg.PrimaryAPITokenHint())
|
|
}
|
|
}
|
|
|
|
func TestAuditVerifyRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "audit-verify-auth-token-123.12345678", []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/audit/event-1/verify", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPathTraversalBlockedForAPIPaths(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/../api/security/status", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for path traversal on api, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPathTraversalBlockedForNonAPIPaths(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/../etc/passwd", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for path traversal on non-api, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPathTraversalBlockedForEncodedAPIPaths(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/%2e%2e/api/security/status", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for encoded path traversal on api, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPathTraversalBlockedForEncodedNonAPIPaths(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/%2e%2e/%2e%2e/etc/passwd", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for encoded path traversal on non-api, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSetupScriptIsPublicEvenWhenAuthConfigured(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/setup-script", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for missing params on public setup script, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPublicDownloadEndpointsBypassAuth(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/install.sh",
|
|
"/install.ps1",
|
|
"/download/pulse-agent",
|
|
"/download/pulse-agent-helper?arch=linux-amd64",
|
|
}
|
|
|
|
for idx, path := range paths {
|
|
ip := "203.0.113." + strconv.Itoa(70+idx)
|
|
ResetRateLimitForIP(ip)
|
|
req := httptest.NewRequest(http.MethodPost, path, nil)
|
|
req.RemoteAddr = ip + ":1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("expected 405 for public download endpoint %s, got %d", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPublicEndpointsBypassAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "public-api-token-123.12345678", []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
monitor, err := monitoring.New(cfg)
|
|
if err != nil {
|
|
t.Fatalf("monitoring.New: %v", err)
|
|
}
|
|
defer monitor.Stop()
|
|
|
|
router := NewRouter(cfg, monitor, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/health",
|
|
"/api/version",
|
|
"/api/agent/version",
|
|
"/api/server/info",
|
|
"/api/security/status",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for public endpoint %s, got %d", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSSHKeyGenerationBlockedInContainer(t *testing.T) {
|
|
t.Setenv("PULSE_DOCKER", "true")
|
|
t.Setenv("PULSE_DEV_ALLOW_CONTAINER_SSH", "")
|
|
homeDir := t.TempDir()
|
|
t.Setenv("HOME", homeDir)
|
|
|
|
handler := NewConfigHandlers(nil, nil, func() error { return nil }, nil, nil, func() {}, false)
|
|
keys := handler.GetOrGenerateSSHKeys()
|
|
if keys.SensorsPublicKey != "" {
|
|
t.Fatalf("expected empty key when container SSH generation is blocked")
|
|
}
|
|
|
|
pubKeyPath := filepath.Join(homeDir, ".ssh", "id_ed25519_sensors.pub")
|
|
if _, err := os.Stat(pubKeyPath); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("expected no key files to be written, got err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestSetupScriptRejectsInvalidSetupToken(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/setup-script?type=pve&host=https://example.com&pulse_url=https://pulse.example.com&setup_token=not-hex", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for invalid setup_token, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Invalid setup_token parameter") {
|
|
t.Fatalf("expected invalid setup_token error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSetupScriptRejectsInvalidHostURL(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/setup-script?type=pve&host=ftp://example.com&pulse_url=https://pulse.example.com", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for invalid host, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Invalid host parameter") {
|
|
t.Fatalf("expected invalid host error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSetupScriptRejectsInvalidPulseURL(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/setup-script?type=pve&host=https://example.com&pulse_url=ftp://pulse.example.com", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for invalid pulse_url, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Invalid pulse_url parameter") {
|
|
t.Fatalf("expected invalid pulse_url error, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSSOOIDCLoginBypassesAuth(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.46")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/oidc/test-provider/login", nil)
|
|
req.RemoteAddr = "203.0.113.46:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404 for unknown OIDC provider on public endpoint, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSSOOIDCCallbackBypassesAuth(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.51")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/oidc/test-provider/callback", nil)
|
|
req.RemoteAddr = "203.0.113.51:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("expected 302 redirect for unknown OIDC provider callback on public endpoint, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestSSOOIDCLegacyCallbackBypassesAuthTokenOnly reproduces the v5->v6 upgrade
|
|
// bug from issue #1533. An upgraded OIDC provider keeps its v5 redirect URL
|
|
// (/api/oidc/callback, the 3-segment DefaultOIDCCallbackPath), so the IdP
|
|
// redirects the browser back to the legacy path with only code/state, no
|
|
// session cookie and no API token. In API-token-only mode
|
|
// (AuthUser=="" && AuthPass=="" && HasAPITokens()) the global auth middleware
|
|
// used to reject that inbound redirect with
|
|
// "API token required via Authorization header or X-API-Token header"
|
|
// because the public-path allowlist only recognised the 4-segment
|
|
// per-provider path. The legacy login and callback paths must bypass auth and
|
|
// reach handleSSOOIDC*, which map them to the migrated legacy provider.
|
|
func TestSSOOIDCLegacyCallbackBypassesAuthTokenOnly(t *testing.T) {
|
|
// Token-only mode: this is the exact config that emits the reported error.
|
|
record := newTokenRecord(t, "legacy-oidc-token-123.12345678", []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
if cfg.AuthUser != "" || cfg.AuthPass != "" || !cfg.HasAPITokens() {
|
|
t.Fatalf("test setup must reproduce token-only mode: authUser=%q authPass set=%v hasTokens=%v", cfg.AuthUser, cfg.AuthPass != "", cfg.HasAPITokens())
|
|
}
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
// No legacy provider is configured, so the callback handler redirects with a
|
|
// provider_not_found error (302) and the login handler returns 404. Either
|
|
// way the request reached the handler, proving auth was bypassed. Before the
|
|
// fix both returned 401 with the API-token error.
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
clientIP string
|
|
wantCode int
|
|
}{
|
|
{"legacy callback", "/api/oidc/callback?code=abc&state=xyz", "203.0.113.61", http.StatusFound},
|
|
{"legacy login", "/api/oidc/login", "203.0.113.62", http.StatusNotFound},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ResetRateLimitForIP(tc.clientIP)
|
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
|
req.RemoteAddr = tc.clientIP + ":1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code == http.StatusUnauthorized {
|
|
t.Fatalf("legacy OIDC path %q was rejected by API-token auth (401): %s", tc.path, strings.TrimSpace(rec.Body.String()))
|
|
}
|
|
if strings.Contains(rec.Body.String(), "API token required") {
|
|
t.Fatalf("legacy OIDC path %q returned the API-token error body: %s", tc.path, strings.TrimSpace(rec.Body.String()))
|
|
}
|
|
if rec.Code != tc.wantCode {
|
|
t.Fatalf("legacy OIDC path %q: expected status %d (auth bypassed, reached handler), got %d", tc.path, tc.wantCode, rec.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAIOAuthCallbackBypassesAuth(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.47")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/ai/oauth/callback", nil)
|
|
req.RemoteAddr = "203.0.113.47:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusTemporaryRedirect {
|
|
t.Fatalf("expected 307 redirect for OAuth callback, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestLoginEndpointBypassesAuth(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
ResetRateLimitForIP("203.0.113.48")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/login", nil)
|
|
req.RemoteAddr = "203.0.113.48:1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("expected 405 for login GET, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestLegacyInstallScriptAliasesDoNotBypassAuth(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
paths := []string{
|
|
"/api/install/install-docker.sh",
|
|
"/api/install/install.sh",
|
|
"/api/install/install.ps1",
|
|
}
|
|
|
|
for idx, path := range paths {
|
|
ip := "203.0.113." + strconv.Itoa(80+idx)
|
|
ResetRateLimitForIP(ip)
|
|
req := httptest.NewRequest(http.MethodPost, path, nil)
|
|
req.RemoteAddr = ip + ":1234"
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for removed install alias %s, got %d", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLogoutRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "logout-token-123.12345678", []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/logout", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestStateRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "state-token-123.12345678", []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestStateRequiresMonitoringReadScope(t *testing.T) {
|
|
rawToken := "state-scope-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing monitoring:read scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeMonitoringRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringRead, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestStateSummaryRequiresAuthInAPIMode(t *testing.T) {
|
|
record := newTokenRecord(t, "state-summary-token-123.12345678", []string{config.ScopeMonitoringRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/state/summary", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestStateSummaryRequiresMonitoringReadScope(t *testing.T) {
|
|
rawToken := "state-summary-scope-token-123.12345678"
|
|
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/state/summary", nil)
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for missing monitoring:read scope, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), config.ScopeMonitoringRead) {
|
|
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringRead, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestVerifyTemperatureSSHAllowsSetupToken(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
token := "0123456789abcdef0123456789abcdef"
|
|
tokenHash := auth.HashAPIToken(token)
|
|
router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh", strings.NewReader(`{"nodes":""}`))
|
|
req.Header.Set("X-Setup-Token", token)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 with setup token, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "No nodes to verify") {
|
|
t.Fatalf("expected verify response, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestVerifyTemperatureSSHRejectsSetupTokenOrgMismatch(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
token := "fedcba9876543210fedcba9876543210"
|
|
tokenHash := auth.HashAPIToken(token)
|
|
router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{
|
|
ExpiresAt: time.Now().Add(time.Minute),
|
|
OrgID: "org-a",
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh", strings.NewReader(`{"nodes":""}`))
|
|
req.Header.Set("X-Setup-Token", token)
|
|
req.Header.Set("X-Pulse-Org-ID", "org-b")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code == http.StatusOK {
|
|
t.Fatalf("expected setup token org mismatch to be rejected, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestVerifyTemperatureSSHRejectsSetupTokenOrgIDQueryBypass(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
token := "11223344556677889900aabbccddeeff"
|
|
tokenHash := auth.HashAPIToken(token)
|
|
router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{
|
|
ExpiresAt: time.Now().Add(time.Minute),
|
|
OrgID: "org-a",
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh?org_id=org-a", strings.NewReader(`{"nodes":""}`))
|
|
req.Header.Set("X-Setup-Token", token)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code == http.StatusOK {
|
|
t.Fatalf("expected org_id query bypass attempt to be rejected, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSSHConfigAllowsSetupToken(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
t.Setenv("HOME", t.TempDir())
|
|
|
|
token := "abcdef0123456789abcdef0123456789"
|
|
tokenHash := auth.HashAPIToken(token)
|
|
router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/ssh-config", strings.NewReader("Host example\nHostname example\n"))
|
|
req.Header.Set("X-Setup-Token", token)
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 with setup token, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `"success":true`) {
|
|
t.Fatalf("expected success response, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSSHConfigRejectsSetupTokenOrgMismatch(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t)
|
|
cfg.AuthUser = "admin"
|
|
cfg.AuthPass = "hashed"
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
t.Setenv("HOME", t.TempDir())
|
|
|
|
token := "00112233445566778899aabbccddeeff"
|
|
tokenHash := auth.HashAPIToken(token)
|
|
router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{
|
|
ExpiresAt: time.Now().Add(time.Minute),
|
|
OrgID: "org-a",
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/ssh-config", strings.NewReader("Host example\nHostname example\n"))
|
|
req.Header.Set("X-Setup-Token", token)
|
|
req.Header.Set("X-Pulse-Org-ID", "org-b")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code == http.StatusOK {
|
|
t.Fatalf("expected setup token org mismatch to be rejected, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestVerifyTemperatureSSHRejectsSetupTokenQueryParam(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t, newTokenRecord(t, "settings-write-token", []string{config.ScopeSettingsWrite}, nil))
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
|
|
token := "abcdefabcdefabcdefabcdefabcdefab"
|
|
tokenHash := auth.HashAPIToken(token)
|
|
router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh?auth_token="+token, strings.NewReader(`{"nodes":""}`))
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 when setup token is only provided in query string, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSSHConfigRejectsSetupTokenQueryParam(t *testing.T) {
|
|
cfg := newTestConfigWithTokens(t, newTokenRecord(t, "settings-write-token", []string{config.ScopeSettingsWrite}, nil))
|
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
|
t.Setenv("HOME", t.TempDir())
|
|
|
|
token := "deadbeefdeadbeefdeadbeefdeadbeef"
|
|
tokenHash := auth.HashAPIToken(token)
|
|
router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/ssh-config?auth_token="+token, strings.NewReader("Host example\nHostname example\n"))
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 when setup token is only provided in query string, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestCheckCSRF_HeaderDoesNotBypassWhenSessionCookiePresent regression-tests the
|
|
// fix for a CSRF bypass: CheckCSRF was skipping the entire CSRF check whenever
|
|
// the request carried Authorization: Basic, Authorization: Bearer, or
|
|
// X-API-Token, without validating the credential. A cross-origin attacker
|
|
// could fetch() with credentials: 'include' and an arbitrary Authorization
|
|
// header — the browser would still auto-attach the victim's session cookie
|
|
// and the server would skip CSRF, fully bypassing protection. The contract is
|
|
// now: a session cookie is the only signal for whether CSRF applies. If a
|
|
// session cookie is present, CSRF must be valid regardless of any other
|
|
// auth-style header.
|
|
func TestCheckCSRF_HeaderDoesNotBypassWhenSessionCookiePresent(t *testing.T) {
|
|
initCSRFStoreForSecurityTest(t)
|
|
cases := []struct {
|
|
name string
|
|
setHeader func(*http.Request)
|
|
description string
|
|
}{
|
|
{
|
|
name: "x_api_token",
|
|
setHeader: func(r *http.Request) {
|
|
r.Header.Set("X-API-Token", "some-api-token")
|
|
},
|
|
description: "X-API-Token must not bypass CSRF when a session cookie is present",
|
|
},
|
|
{
|
|
name: "authorization_basic",
|
|
setHeader: func(r *http.Request) {
|
|
r.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
|
|
},
|
|
description: "Authorization: Basic must not bypass CSRF when a session cookie is present",
|
|
},
|
|
{
|
|
name: "authorization_bearer",
|
|
setHeader: func(r *http.Request) {
|
|
r.Header.Set("Authorization", "Bearer some-token")
|
|
},
|
|
description: "Authorization: Bearer must not bypass CSRF when a session cookie is present",
|
|
},
|
|
{
|
|
name: "authorization_bearer_mixed_case",
|
|
setHeader: func(r *http.Request) {
|
|
// Mixed-case scheme to ensure the old lower-case prefix check
|
|
// is not reintroduced as a guarded skip.
|
|
r.Header.Set("Authorization", "BeArEr some-token")
|
|
},
|
|
description: "Mixed-case Bearer must not bypass CSRF when a session cookie is present",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest("POST", "/api/test", nil)
|
|
tc.setHeader(req)
|
|
req.AddCookie(&http.Cookie{
|
|
Name: "pulse_session",
|
|
Value: "test-session-id-1234567890",
|
|
})
|
|
|
|
if CheckCSRF(w, req) {
|
|
t.Fatal(tc.description)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRequirePermissionUsesContextUsername regresses the X-Authenticated-User
|
|
// response-header-as-identity bug. RequirePermission previously read the
|
|
// authenticated username from w.Header().Get("X-Authenticated-User"). That
|
|
// header is mutable across the handler chain; any middleware sitting between
|
|
// checkAuth and RequirePermission that wrote to the response header could
|
|
// substitute an arbitrary identity, and the RBAC authorizer would make its
|
|
// decision against the substituted value. The contract is now: the identity
|
|
// comes from the REQUEST context (set by attachUserContext during auth).
|
|
// When the response header and the context disagree, the context wins.
|
|
func TestRequirePermissionUsesContextUsername(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
|
|
var observedSubject string
|
|
authorizer := &mockAuthorizerFn{fn: func(ctx context.Context, action string, resource string) (bool, error) {
|
|
observedSubject = auth.GetUser(ctx)
|
|
return true, nil
|
|
}}
|
|
|
|
handler := RequirePermission(cfg, authorizer, "read", "logs", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
req := newLoopbackRequest("GET", "/api/test", nil)
|
|
|
|
// Simulate a downstream middleware that has written a different value
|
|
// to the response header AFTER checkAuth ran. The header below is the
|
|
// would-be-attacker-supplied value; the context (set by attachUserContext)
|
|
// holds the real authenticated user.
|
|
rr := httptest.NewRecorder()
|
|
rr.Header().Set("X-Authenticated-User", "evil-spoofed-user")
|
|
|
|
ctxReq := req.WithContext(auth.WithUser(req.Context(), "real-authenticated-user"))
|
|
|
|
handler.ServeHTTP(rr, ctxReq)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rr.Code)
|
|
}
|
|
if observedSubject != "real-authenticated-user" {
|
|
t.Fatalf("RBAC subject must come from context, got %q (header was %q)", observedSubject, "evil-spoofed-user")
|
|
}
|
|
}
|
|
|
|
// Instance-wide webhook security settings must reach every tenant org's
|
|
// notification manager: the allowlist is stored globally, so applying it only
|
|
// to the request's org (or only to the default org on reload) leaves other
|
|
// tenants' webhook targets failing SSRF validation with no org-side remedy.
|
|
func TestSystemSettingsWebhookAllowlistPropagatesToAllTenantManagers(t *testing.T) {
|
|
t.Setenv("PULSE_MULTI_TENANT_ENABLED", "true")
|
|
defer SetMultiTenantEnabled(false)
|
|
SetMultiTenantEnabled(true)
|
|
|
|
dataDir := t.TempDir()
|
|
tokenVal := "allowlist-propagation-token-123.12345678"
|
|
cfg := &config.Config{
|
|
DataPath: dataDir,
|
|
APITokens: []config.APITokenRecord{
|
|
{ID: "tok-allowlist", Hash: auth.HashAPIToken(tokenVal), Name: "Allowlist Test Token"},
|
|
},
|
|
}
|
|
persistence := config.NewConfigPersistence(dataDir)
|
|
mtp := config.NewMultiTenantPersistence(dataDir)
|
|
mtm := monitoring.NewMultiTenantMonitor(cfg, mtp, nil)
|
|
t.Cleanup(mtm.Stop)
|
|
|
|
for _, org := range []struct{ id, name string }{
|
|
{"org-a", "Org A"},
|
|
{"org-b", "Org B"},
|
|
} {
|
|
if err := mtp.SaveOrganization(&models.Organization{ID: org.id, DisplayName: org.name}); err != nil {
|
|
t.Fatalf("SaveOrganization(%s): %v", org.id, err)
|
|
}
|
|
if _, err := mtm.GetMonitor(org.id); err != nil {
|
|
t.Fatalf("GetMonitor(%s): %v", org.id, err)
|
|
}
|
|
}
|
|
|
|
// Match the production router, which always supplies its already-created
|
|
// primary monitor. Passing nil here makes the constructor create and start a
|
|
// default monitor asynchronously against cfg just before this test mutates
|
|
// cfg, introducing a test-only race that production cannot take.
|
|
primaryMonitor, ok := mtm.PeekMonitor("org-a")
|
|
if !ok {
|
|
t.Fatal("org-a monitor was not initialized")
|
|
}
|
|
h := NewSystemSettingsHandler(cfg, persistence, nil, mtm, primaryMonitor, func() {}, nil)
|
|
|
|
// Both org managers must reject the private target before the update.
|
|
for _, orgID := range []string{"org-a", "org-b"} {
|
|
m, err := mtm.GetMonitor(orgID)
|
|
if err != nil {
|
|
t.Fatalf("GetMonitor(%s): %v", orgID, err)
|
|
}
|
|
if err := m.GetNotificationManager().ValidateWebhookURL("http://192.0.2.10:9999/hook"); err == nil {
|
|
t.Fatalf("%s: expected private webhook target to be rejected before allowlist update", orgID)
|
|
}
|
|
}
|
|
|
|
body := bytes.NewBufferString(`{"webhookAllowedPrivateCIDRs":"192.0.2.0/24"}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system/settings/update", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-API-Token", tokenVal)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleUpdateSystemSettings(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("HandleUpdateSystemSettings responded %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// Every live tenant manager must observe the new allowlist, regardless of
|
|
// which org the admin's request context pointed at.
|
|
for _, orgID := range []string{"org-a", "org-b"} {
|
|
m, err := mtm.GetMonitor(orgID)
|
|
if err != nil {
|
|
t.Fatalf("GetMonitor(%s): %v", orgID, err)
|
|
}
|
|
if err := m.GetNotificationManager().ValidateWebhookURL("http://192.0.2.10:9999/hook"); err != nil {
|
|
t.Fatalf("%s: expected private webhook target to be allowed after allowlist update, got %v", orgID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Agents already deployed hold a runtime token bound to the invented
|
|
// agent-<hostname>; once they restart under their acknowledged identity the
|
|
// command channel refused them for good. The token moves once to the real
|
|
// identity on exactly the same hostname, persists that it was repaired, and is
|
|
// strict again afterwards, even if a hostname rebind recreates the
|
|
// agent-<hostname> shape.
|
|
func TestDeployPlaceholderTokenMovesToTheRegisteringAgentOnce(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cfg := &config.Config{DataPath: dir}
|
|
raw, err := auth.GenerateAPIToken()
|
|
if err != nil {
|
|
t.Fatalf("generate token: %v", err)
|
|
}
|
|
record, err := config.NewAPITokenRecord(raw, "deploy-runtime-a", []string{config.ScopeAgentReport, config.ScopeAgentExec})
|
|
if err != nil {
|
|
t.Fatalf("new token record: %v", err)
|
|
}
|
|
record.Metadata = map[string]string{
|
|
"bound_agent_id": "agent-a",
|
|
"bound_hostname": "a",
|
|
"deploy_job_id": "dep_1",
|
|
agentExecBindingVersionKey: agentExecBindingVersion,
|
|
}
|
|
cfg.APITokens = []config.APITokenRecord{*record}
|
|
persistence := config.NewConfigPersistence(dir)
|
|
if err := persistence.SaveAPITokens(cfg.APITokens); err != nil {
|
|
t.Fatalf("persist token: %v", err)
|
|
}
|
|
router := &Router{config: cfg, persistence: persistence}
|
|
|
|
if _, ok := router.admitAgentExecToken(raw, "agent-b", "a.example"); ok {
|
|
t.Fatal("a placeholder token moved on an equivalent but different hostname")
|
|
}
|
|
if !commandConfigAllowedForToken(&cfg.APITokens[0], models.Host{ID: "agent-b", Hostname: "a"}) {
|
|
t.Fatal("the config gate refused the repair the command channel admits")
|
|
}
|
|
if admission, ok := router.admitAgentExecToken(raw, "agent-b", "a"); !ok || admission.AgentID != "agent-b" {
|
|
t.Fatalf("placeholder repair admission = %+v ok=%v, want the registering agent", admission, ok)
|
|
}
|
|
persisted, err := persistence.LoadAPITokens()
|
|
if err != nil {
|
|
t.Fatalf("reload tokens: %v", err)
|
|
}
|
|
if got := persisted[0].Metadata; got["bound_agent_id"] != "agent-b" || got[agentbinding.DeployIdentityKey] != agentbinding.DeployIdentityRepaired {
|
|
t.Fatalf("persisted binding after repair = %v, want agent-b marked repaired", got)
|
|
}
|
|
|
|
// The same identity may re-bind a renamed hostname, which recreates the
|
|
// agent-<hostname> shape on host b. It must not unlock a second move.
|
|
if _, ok := router.admitAgentExecToken(raw, "agent-b", "b"); !ok {
|
|
t.Fatal("the bound identity could not re-bind its renamed hostname")
|
|
}
|
|
if _, ok := router.admitAgentExecToken(raw, "another-machine", "b"); ok {
|
|
t.Fatal("the repaired token moved a second time")
|
|
}
|
|
if commandConfigAllowedForToken(&cfg.APITokens[0], models.Host{ID: "another-machine", Hostname: "b"}) {
|
|
t.Fatal("the config gate allowed an identity the command channel refuses")
|
|
}
|
|
}
|
|
|
|
// The exact attack on a versionless historical token: an equivalent-hostname
|
|
// registration must not migrate it without the repair marker, or a hostname
|
|
// rebind could recreate the placeholder shape and unlock a second move.
|
|
func TestVersionlessDeployPlaceholderCannotBeMovedTwice(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cfg := &config.Config{DataPath: dir}
|
|
raw, err := auth.GenerateAPIToken()
|
|
if err != nil {
|
|
t.Fatalf("generate token: %v", err)
|
|
}
|
|
record, err := config.NewAPITokenRecord(raw, "deploy-runtime-a", []string{config.ScopeAgentReport, config.ScopeAgentExec})
|
|
if err != nil {
|
|
t.Fatalf("new token record: %v", err)
|
|
}
|
|
record.Metadata = map[string]string{
|
|
"bound_agent_id": "agent-a",
|
|
"bound_hostname": "a",
|
|
"deploy_job_id": "dep_1",
|
|
}
|
|
cfg.APITokens = []config.APITokenRecord{*record}
|
|
router := &Router{config: cfg, persistence: config.NewConfigPersistence(dir)}
|
|
|
|
if _, ok := router.admitAgentExecToken(raw, "agent-b", "a.example"); ok {
|
|
t.Fatal("a versionless placeholder migrated on an equivalent hostname")
|
|
}
|
|
// The deployed agent first registers under the identity enrollment handed
|
|
// it, then under its acknowledged identity after a restart.
|
|
if _, ok := router.admitAgentExecToken(raw, "agent-a", "a"); !ok {
|
|
t.Fatal("the deployed agent's first registration was refused")
|
|
}
|
|
if admission, ok := router.admitAgentExecToken(raw, "machine-id", "a"); !ok || admission.AgentID != "machine-id" {
|
|
t.Fatalf("restart registration = %+v ok=%v, want the one-time repair", admission, ok)
|
|
}
|
|
if got := cfg.APITokens[0].Metadata[agentbinding.DeployIdentityKey]; got != agentbinding.DeployIdentityRepaired {
|
|
t.Fatalf("repair marker = %q, want %q", got, agentbinding.DeployIdentityRepaired)
|
|
}
|
|
if _, ok := router.admitAgentExecToken(raw, "another-machine", "a"); ok {
|
|
t.Fatal("the repaired token moved a second time")
|
|
}
|
|
}
|
|
|
|
// A token issued by current enrollment leaves identity to the agent. Even if
|
|
// the agent it first binds happens to be named agent-<hostname>, the token is
|
|
// not a historical placeholder and never moves.
|
|
func TestCurrentDeployTokenNeverMovesOnceBound(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cfg := &config.Config{DataPath: dir}
|
|
raw, err := auth.GenerateAPIToken()
|
|
if err != nil {
|
|
t.Fatalf("generate token: %v", err)
|
|
}
|
|
record, err := config.NewAPITokenRecord(raw, "deploy-runtime-c", []string{config.ScopeAgentReport, config.ScopeAgentExec})
|
|
if err != nil {
|
|
t.Fatalf("new token record: %v", err)
|
|
}
|
|
record.Metadata = map[string]string{
|
|
"bound_hostname": "c",
|
|
"deploy_job_id": "dep_2",
|
|
agentExecBindingVersionKey: agentExecBindingVersion,
|
|
agentbinding.DeployIdentityKey: agentbinding.DeployIdentityAgent,
|
|
}
|
|
cfg.APITokens = []config.APITokenRecord{*record}
|
|
router := &Router{config: cfg, persistence: config.NewConfigPersistence(dir)}
|
|
|
|
if _, ok := router.admitAgentExecToken(raw, "agent-c", "c"); !ok {
|
|
t.Fatal("first registration of a current deploy token was refused")
|
|
}
|
|
if _, ok := router.admitAgentExecToken(raw, "machine-id", "c"); ok {
|
|
t.Fatal("a current deploy token moved after its first binding")
|
|
}
|
|
}
|
|
|
|
const reportIdentityMachineID = "49a2dbb3-3cc8-43b7-ad57-eb1711142454"
|
|
|
|
// reportIdentityReport is a report as pulse-agent sends it: the host carries
|
|
|
|
// the machine-derived ID and the agent block the ID it presents everywhere,
|
|
|
|
// including on command registration.
|
|
|
|
func reportIdentityReport(presentedID, machineID, hostname string) agentshost.Report {
|
|
return agentshost.Report{
|
|
Agent: agentshost.AgentInfo{
|
|
ID: presentedID,
|
|
Version: "6.5.0",
|
|
Type: "unified",
|
|
IntervalSeconds: 30,
|
|
Hostname: hostname,
|
|
},
|
|
Host: agentshost.HostInfo{
|
|
ID: machineID,
|
|
Hostname: hostname,
|
|
MachineID: machineID,
|
|
Platform: "linux",
|
|
OSName: "debian",
|
|
},
|
|
Timestamp: time.Now().UTC(),
|
|
}
|
|
}
|
|
|
|
func newReportIdentityRouter(t *testing.T, cfg *config.Config) *Router {
|
|
t.Helper()
|
|
monitor, err := monitoring.New(cfg)
|
|
if err != nil {
|
|
t.Fatalf("new monitor: %v", err)
|
|
}
|
|
t.Cleanup(monitor.Stop)
|
|
return NewRouter(cfg, monitor, nil, nil, func() error { return nil }, "6.5.0")
|
|
}
|
|
|
|
func reportIdentityToken(t *testing.T, cfg *config.Config, tokenID string) config.APITokenRecord {
|
|
t.Helper()
|
|
config.Mu.Lock()
|
|
defer config.Mu.Unlock()
|
|
for index := range cfg.APITokens {
|
|
if cfg.APITokens[index].ID == tokenID {
|
|
return cfg.APITokens[index].Clone()
|
|
}
|
|
}
|
|
t.Fatalf("token %s not found", tokenID)
|
|
return config.APITokenRecord{}
|
|
}
|
|
|
|
func postReportIdentityReport(t *testing.T, router *Router, token string, report agentshost.Report) string {
|
|
t.Helper()
|
|
code, payload := issue1753PostReport(t, router, token, report)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("report rejected with %d (%v)", code, payload)
|
|
}
|
|
ack, _ := payload["agentId"].(string)
|
|
return ack
|
|
}
|
|
|
|
func postCollectorReduction(t *testing.T, router *Router, token, agentID, hostname string) int {
|
|
t.Helper()
|
|
body, err := json.Marshal(collectorAuthorityReductionRequest{AgentID: agentID, Hostname: hostname})
|
|
if err != nil {
|
|
t.Fatalf("marshal reduction: %v", err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/api/agents/collector/reduce-authority", bytes.NewReader(body))
|
|
req.Header.Set("X-API-Token", token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
return rec.Code
|
|
}
|
|
|
|
// A deployed agent enrolled without commands never registers a command
|
|
|
|
// channel. Its runtime token stayed bound to the node's hostname alone, so the
|
|
|
|
// safe-profile migration's authority reduction, which needs the agent's ID on
|
|
|
|
// the token, was refused with "Collector credential binding mismatch" for as
|
|
|
|
// long as the agent ran. The first acknowledged report now names the agent.
|
|
|
|
func autoRegisteredInstallToken(t *testing.T, raw, hostname string, enableCommands bool) config.APITokenRecord {
|
|
t.Helper()
|
|
role := agenttokens.CredentialKindMonitoringCollector
|
|
if enableCommands {
|
|
role = agenttokens.CredentialKindLegacyFullTrust
|
|
}
|
|
record := newTokenRecord(t, raw, agenttokens.ProxmoxScopes(enableCommands), map[string]string{
|
|
"install_type": "pve",
|
|
"issued_via": agentbinding.IssuedViaConfig,
|
|
"bound_hostname": hostname,
|
|
"bound_at": time.Now().UTC().Format(time.RFC3339),
|
|
agenttokens.RuntimeRoleMetadataKey: role,
|
|
})
|
|
record.OrgID = "default"
|
|
return record
|
|
}
|
|
|
|
// The report path and the command channel bind the same identity, and once
|
|
|
|
// either has named the agent the token never takes another identity, whatever
|
|
|
|
// later reports resolve.
|
|
|
|
func TestReportedIdentityMatchesCommandRegistrationAndNeverMoves(t *testing.T) {
|
|
const raw = "report-identity-pve1-token.12345678"
|
|
record := autoRegisteredInstallToken(t, raw, "pve1", true)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := newReportIdentityRouter(t, cfg)
|
|
|
|
postReportIdentityReport(t, router, raw, reportIdentityReport("machine-a", "machine-a", "pve1"))
|
|
bound := reportIdentityToken(t, cfg, record.ID)
|
|
if got := bound.Metadata["bound_agent_id"]; got != "machine-a" {
|
|
t.Fatalf("bound_agent_id = %q, want machine-a", got)
|
|
}
|
|
if got := bound.Metadata[agenttokens.RuntimeRoleMetadataKey]; got != agenttokens.CredentialKindLegacyFullTrust {
|
|
t.Fatalf("runtime role = %q, want it untouched", got)
|
|
}
|
|
// Without the binding version a versionless token would still accept any
|
|
// identity on its hostname through the legacy migration.
|
|
if got := bound.Metadata[agentExecBindingVersionKey]; got != agentExecBindingVersion {
|
|
t.Fatalf("binding version = %q, want %q so the recorded ID is immutable", got, agentExecBindingVersion)
|
|
}
|
|
if _, ok := router.admitAgentExecToken(raw, "machine-b", "pve1"); ok {
|
|
t.Fatal("another identity's command registration moved the identity the report recorded")
|
|
}
|
|
if _, ok := router.admitAgentExecToken(raw, "machine-a", "pve1"); !ok {
|
|
t.Fatal("the reporting agent's own command registration was refused")
|
|
}
|
|
|
|
// Another machine presenting this token under the same hostname reports
|
|
// under its own identity, but the token stays with the agent it named.
|
|
issue1753PostReport(t, router, raw, reportIdentityReport("machine-b", "machine-b", "pve1"))
|
|
if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "machine-a" {
|
|
t.Fatalf("bound_agent_id after another machine reported = %q, want machine-a", got)
|
|
}
|
|
if _, ok := router.admitAgentExecToken(raw, "machine-b", "pve1"); ok {
|
|
t.Fatal("a second identity was admitted on a token the report path had already bound")
|
|
}
|
|
|
|
// A token the command channel bound first keeps that identity even when the
|
|
// server resolves this agent's reports to another one.
|
|
const deployRaw = "report-identity-deploy-token.12345678"
|
|
deployRecord := newTokenRecord(t, deployRaw, agenttokens.HostScopes(true), map[string]string{
|
|
"bound_hostname": "pve-node3",
|
|
"deploy_job_id": "dep_1",
|
|
agentExecBindingVersionKey: agentExecBindingVersion,
|
|
agentbinding.DeployIdentityKey: agentbinding.DeployIdentityAgent,
|
|
})
|
|
deployRecord.OrgID = "default"
|
|
config.Mu.Lock()
|
|
cfg.UpsertAPIToken(deployRecord)
|
|
config.Mu.Unlock()
|
|
if _, ok := router.admitAgentExecToken(deployRaw, "configured-id", "pve-node3"); !ok {
|
|
t.Fatal("first command registration refused")
|
|
}
|
|
ack := postReportIdentityReport(t, router, deployRaw, reportIdentityReport("configured-id", reportIdentityMachineID, "pve-node3"))
|
|
if ack == "configured-id" {
|
|
t.Fatalf("precondition: the server resolved the presented ID %q", ack)
|
|
}
|
|
if got := reportIdentityToken(t, cfg, deployRecord.ID).Metadata["bound_agent_id"]; got != "configured-id" {
|
|
t.Fatalf("bound_agent_id = %q, want the command channel's configured-id", got)
|
|
}
|
|
}
|
|
|
|
// As with a command registration, the first identity a token records is
|
|
|
|
// final. An agent that reported under its machine ID with commands off, then
|
|
|
|
// restarts under a configured --agent-id with commands on, meets a token
|
|
|
|
// already bound to the machine ID, exactly as it would had its first run
|
|
|
|
// registered a command channel.
|
|
|
|
func TestReportedIdentityIsFinalForALaterReconfiguredAgent(t *testing.T) {
|
|
const raw = "report-identity-reconfigured-token.12345678"
|
|
record := autoRegisteredInstallToken(t, raw, "pve9", true)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := newReportIdentityRouter(t, cfg)
|
|
|
|
postReportIdentityReport(t, router, raw, reportIdentityReport(reportIdentityMachineID, reportIdentityMachineID, "pve9"))
|
|
ack := postReportIdentityReport(t, router, raw, reportIdentityReport("configured-id", reportIdentityMachineID, "pve9"))
|
|
if ack != reportIdentityMachineID {
|
|
t.Fatalf("precondition: acknowledged agentId = %q, want the machine ID", ack)
|
|
}
|
|
if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != reportIdentityMachineID {
|
|
t.Fatalf("bound_agent_id = %q, want the first reported identity", got)
|
|
}
|
|
if _, ok := router.admitAgentExecToken(raw, "configured-id", "pve9"); ok {
|
|
t.Fatal("a reconfigured identity moved a token the report path had bound")
|
|
}
|
|
if _, ok := router.admitAgentExecToken(raw, reportIdentityMachineID, "pve9"); !ok {
|
|
t.Fatal("the recorded identity's command registration was refused")
|
|
}
|
|
}
|
|
|
|
// An agent started with a configured --agent-id presents that ID on its
|
|
|
|
// command channel, while Pulse resolves its reports to the machine ID. The
|
|
|
|
// report path cannot tell which identity the token should carry, so it records
|
|
|
|
// nothing and leaves the command channel's first registration unchanged.
|
|
|
|
func TestReportedIdentityRecordsNothingWhenTheResolvedIdentityIsNotThePresentedOne(t *testing.T) {
|
|
const raw = "report-identity-configured-token.12345678"
|
|
record := newTokenRecord(t, raw, agenttokens.HostScopes(true), map[string]string{
|
|
"bound_hostname": "pve-node4",
|
|
"deploy_job_id": "dep_1",
|
|
agentExecBindingVersionKey: agentExecBindingVersion,
|
|
agentbinding.DeployIdentityKey: agentbinding.DeployIdentityAgent,
|
|
})
|
|
record.OrgID = "default"
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := newReportIdentityRouter(t, cfg)
|
|
|
|
ack := postReportIdentityReport(t, router, raw, reportIdentityReport("configured-id", reportIdentityMachineID, "pve-node4"))
|
|
if ack != reportIdentityMachineID {
|
|
t.Fatalf("precondition: acknowledged agentId = %q, want the machine ID", ack)
|
|
}
|
|
if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "" {
|
|
t.Fatalf("bound_agent_id = %q, want nothing recorded while the identities disagree", got)
|
|
}
|
|
if _, ok := router.admitAgentExecToken(raw, "configured-id", "pve-node4"); !ok {
|
|
t.Fatal("the agent's command registration under its configured ID was refused")
|
|
}
|
|
}
|
|
|
|
// A report the ordering watermark rejects is answered with the stored host,
|
|
|
|
// whose hostname came from another report. The binding must be judged on the
|
|
|
|
// rejected report's own hostname, the one its agent's command registration
|
|
|
|
// presents, never on the stored one.
|
|
|
|
func TestReportedIdentityJudgesAReplayedReportByItsOwnHostname(t *testing.T) {
|
|
const raw = "report-identity-replay-token.12345678"
|
|
record := newTokenRecord(t, raw, agenttokens.HostScopes(false), map[string]string{
|
|
"bound_hostname": "node.site-a",
|
|
"deploy_job_id": "dep_1",
|
|
agentExecBindingVersionKey: agentExecBindingVersion,
|
|
agentbinding.DeployIdentityKey: agentbinding.DeployIdentityAgent,
|
|
})
|
|
record.OrgID = "default"
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := newReportIdentityRouter(t, cfg)
|
|
|
|
start := time.Now().UTC().Add(-time.Minute)
|
|
retired := reportIdentityReport(reportIdentityMachineID, reportIdentityMachineID, "node.site-b")
|
|
retired.Timestamp = start
|
|
postReportIdentityReport(t, router, raw, retired)
|
|
newer := reportIdentityReport("configured-id", reportIdentityMachineID, "node")
|
|
newer.Timestamp = start.Add(30 * time.Second)
|
|
postReportIdentityReport(t, router, raw, newer)
|
|
if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "" {
|
|
t.Fatalf("precondition: bound_agent_id = %q before the replay", got)
|
|
}
|
|
|
|
postReportIdentityReport(t, router, raw, retired)
|
|
if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "" {
|
|
t.Fatalf("bound_agent_id = %q from a replayed report whose own hostname the token refuses", got)
|
|
}
|
|
}
|
|
|
|
// A failed save leaves the token exactly as it was and the next report
|
|
|
|
// records the identity.
|
|
|
|
func TestReportedIdentityRestoresTheTokenWhenTheSaveFails(t *testing.T) {
|
|
const raw = "report-identity-save-token.12345678"
|
|
record := autoRegisteredInstallToken(t, raw, "pve5", false)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := newReportIdentityRouter(t, cfg)
|
|
|
|
blocked := filepath.Join(t.TempDir(), "blocked-state")
|
|
failing := config.NewConfigPersistence(blocked)
|
|
if err := os.RemoveAll(blocked); err != nil {
|
|
t.Fatalf("remove persistence directory: %v", err)
|
|
}
|
|
if err := os.WriteFile(blocked, []byte("not a directory"), 0o600); err != nil {
|
|
t.Fatalf("create persistence blocker: %v", err)
|
|
}
|
|
working := router.persistence
|
|
router.persistence = failing
|
|
|
|
report := reportIdentityReport("machine-e", "machine-e", "pve5")
|
|
postReportIdentityReport(t, router, raw, report)
|
|
unchanged := reportIdentityToken(t, cfg, record.ID)
|
|
for _, key := range []string{"bound_agent_id", agentExecBindingVersionKey} {
|
|
if _, present := unchanged.Metadata[key]; present {
|
|
t.Fatalf("%s = %q after a failed save, want it absent", key, unchanged.Metadata[key])
|
|
}
|
|
}
|
|
if got := unchanged.Metadata["bound_at"]; got != record.Metadata["bound_at"] {
|
|
t.Fatalf("bound_at = %q after a failed save, want %q", got, record.Metadata["bound_at"])
|
|
}
|
|
|
|
router.persistence = working
|
|
postReportIdentityReport(t, router, raw, report)
|
|
retried := reportIdentityToken(t, cfg, record.ID)
|
|
if got := retried.Metadata["bound_agent_id"]; got != "machine-e" {
|
|
t.Fatalf("bound_agent_id after the retry = %q, want machine-e", got)
|
|
}
|
|
if got := retried.Metadata[agentExecBindingVersionKey]; got != agentExecBindingVersion {
|
|
t.Fatalf("binding version after the retry = %q, want %q", got, agentExecBindingVersion)
|
|
}
|
|
}
|
|
|
|
// The ID is resolved in the request's organization, so only a token whose
|
|
|
|
// command sessions run in that organization records it.
|
|
|
|
func TestReportedIdentityRequiresTheTokensOwnOrganization(t *testing.T) {
|
|
const raw = "report-identity-org-token.12345678"
|
|
record := autoRegisteredInstallToken(t, raw, "pve6", false)
|
|
record.OrgID = "org-a"
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := &Router{config: cfg, persistence: config.NewConfigPersistence(cfg.DataPath)}
|
|
|
|
router.recordReportedAgentIdentity("default", record.ID, "machine-f", "machine-f", "pve6")
|
|
if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "" {
|
|
t.Fatalf("bound_agent_id = %q from another organization's report", got)
|
|
}
|
|
router.recordReportedAgentIdentity("org-a", record.ID, "machine-f", "machine-f", "pve6")
|
|
if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "machine-f" {
|
|
t.Fatalf("bound_agent_id = %q, want machine-f from the token's own organization", got)
|
|
}
|
|
}
|
|
|
|
// A token that expired after it authenticated the report records nothing.
|
|
|
|
func TestReportedIdentityIgnoresAnExpiredToken(t *testing.T) {
|
|
record := autoRegisteredInstallToken(t, "report-identity-expired-token.12345678", "pve8", false)
|
|
expired := time.Now().Add(-time.Minute)
|
|
record.ExpiresAt = &expired
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := &Router{config: cfg, persistence: config.NewConfigPersistence(cfg.DataPath)}
|
|
|
|
router.recordReportedAgentIdentity("default", record.ID, "machine-h", "machine-h", "pve8")
|
|
if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "" {
|
|
t.Fatalf("bound_agent_id = %q on an expired token", got)
|
|
}
|
|
}
|
|
|
|
// The handler offers the recorder only reports the monitor applied under the
|
|
|
|
// token, and only when the token snapshot qualifies, so a token that never will
|
|
|
|
// (here, an agent presenting a configured ID) does not take the exclusive token
|
|
|
|
// lock on every report. Mock mode discards reports and acknowledges the
|
|
|
|
// presented ID unresolved, so it must not record anything on a real token.
|
|
|
|
func TestReportHandlerOffersOnlyQualifyingLiveReportsToTheRecorder(t *testing.T) {
|
|
const raw = "report-identity-mock-token.12345678"
|
|
record := autoRegisteredInstallToken(t, raw, "pve7", false)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
monitor, err := monitoring.New(cfg)
|
|
if err != nil {
|
|
t.Fatalf("new monitor: %v", err)
|
|
}
|
|
defer monitor.Stop()
|
|
handler := NewUnifiedAgentHandlers(nil, monitor, nil)
|
|
type recorded struct{ organizationID, tokenID, presentedID, hostID, hostname string }
|
|
var calls []recorded
|
|
handler.SetReportedIdentityRecorder(func(organizationID, tokenID, presentedID, hostID, hostname string) {
|
|
calls = append(calls, recorded{organizationID, tokenID, presentedID, hostID, hostname})
|
|
})
|
|
post := func(presentedID string) {
|
|
t.Helper()
|
|
body, err := json.Marshal(reportIdentityReport(presentedID, "machine-g", "pve7"))
|
|
if err != nil {
|
|
t.Fatalf("marshal report: %v", err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/api/agents/agent/report", bytes.NewReader(body))
|
|
attachAPITokenRecord(req, &record)
|
|
rec := httptest.NewRecorder()
|
|
handler.HandleReport(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("report status = %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
post("configured-id")
|
|
if len(calls) != 0 {
|
|
t.Fatalf("recorder offered a report whose presented ID was not resolved: %+v", calls)
|
|
}
|
|
post("machine-g")
|
|
want := recorded{"default", record.ID, "machine-g", "machine-g", "pve7"}
|
|
if len(calls) != 1 || calls[0] != want {
|
|
t.Fatalf("recorder calls = %+v, want one call %+v", calls, want)
|
|
}
|
|
|
|
previous := mock.IsMockEnabled()
|
|
if err := mock.SetEnabled(true); err != nil {
|
|
t.Fatalf("enable mock mode: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = mock.SetEnabled(previous) })
|
|
post("machine-g")
|
|
if len(calls) != 1 {
|
|
t.Fatalf("recorder calls in mock mode = %+v, want none beyond the live report", calls[1:])
|
|
}
|
|
}
|
|
|
|
// postReportIdentityAck posts an authenticated report through the router and
|
|
// returns the whole acknowledgement.
|
|
func postReportIdentityAck(t *testing.T, router *Router, token string, report agentshost.Report) map[string]any {
|
|
t.Helper()
|
|
code, payload := issue1753PostReport(t, router, token, report)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("report rejected with %d (%v)", code, payload)
|
|
}
|
|
return payload
|
|
}
|
|
|
|
// A token already bound to one identity is never moved by report contents.
|
|
// Identity resolution can map a report onto another host's continuity record
|
|
// by machine ID without a token check, so a report resolving elsewhere names
|
|
// no command identity, leaves the binding as it was, and the agent keeps
|
|
// registering under the ID it presents.
|
|
func TestReportAckNeverMovesAnEstablishedCommandBinding(t *testing.T) {
|
|
const raw = "command-identity-established-token.12345678"
|
|
record := newTokenRecord(t, raw, agenttokens.HostScopes(true), map[string]string{
|
|
"issued_via": agentbinding.IssuedViaConfig,
|
|
"install_type": "host",
|
|
"bound_agent_id": "configured-id",
|
|
"bound_hostname": "host-established",
|
|
agentExecBindingVersionKey: agentExecBindingVersion,
|
|
agenttokens.RuntimeRoleMetadataKey: agenttokens.CredentialKindLegacyFullTrust,
|
|
})
|
|
record.OrgID = "default"
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := newReportIdentityRouter(t, cfg)
|
|
|
|
ack := postReportIdentityAck(t, router, raw, reportIdentityReport("configured-id", reportIdentityMachineID, "host-established"))
|
|
if ack["agentId"] != reportIdentityMachineID {
|
|
t.Fatalf("precondition: acknowledged agentId = %v, want the machine ID", ack["agentId"])
|
|
}
|
|
if named, present := ack["commandAgentId"]; present {
|
|
t.Fatalf("ack named command identity %v for a token bound to another identity", named)
|
|
}
|
|
if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "configured-id" {
|
|
t.Fatalf("bound_agent_id = %q after the report, want it unchanged", got)
|
|
}
|
|
if _, ok := router.admitAgentExecToken(raw, reportIdentityMachineID, "host-established"); ok {
|
|
t.Fatal("the established binding moved to the resolved identity")
|
|
}
|
|
if _, ok := router.admitAgentExecToken(raw, "configured-id", "host-established"); !ok {
|
|
t.Fatal("the agent lost its established command identity")
|
|
}
|
|
}
|
|
|
|
// Deploy enrollment binds the runtime token to the node's hostname only. When
|
|
// the agent presents another ID than Pulse resolves (a configured --agent-id),
|
|
// the report path records nothing, and the acknowledgement names the resolved
|
|
// ID so the agent's command channel backfills that one instead.
|
|
func TestReportAckNamesTheResolvedIdentityForAHostnameBoundDeployToken(t *testing.T) {
|
|
const raw = "command-identity-deploy-token.12345678"
|
|
record := newTokenRecord(t, raw, agenttokens.HostScopes(true), map[string]string{
|
|
"bound_hostname": "pve-node5",
|
|
"deploy_job_id": "dep_5",
|
|
agentExecBindingVersionKey: agentExecBindingVersion,
|
|
agentbinding.DeployIdentityKey: agentbinding.DeployIdentityAgent,
|
|
})
|
|
record.OrgID = "default"
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := newReportIdentityRouter(t, cfg)
|
|
|
|
ack := postReportIdentityAck(t, router, raw, reportIdentityReport("configured-id", reportIdentityMachineID, "pve-node5"))
|
|
if ack["commandAgentId"] != reportIdentityMachineID || ack["agentId"] != reportIdentityMachineID {
|
|
t.Fatalf("ack agentId=%v commandAgentId=%v, want both the resolved %q", ack["agentId"], ack["commandAgentId"], reportIdentityMachineID)
|
|
}
|
|
if _, ok := router.admitAgentExecToken(raw, reportIdentityMachineID, "pve-node5"); !ok {
|
|
t.Fatal("the command channel could not backfill the named identity")
|
|
}
|
|
if _, ok := router.admitAgentExecToken(raw, "configured-id", "pve-node5"); ok {
|
|
t.Fatal("the backfilled token admitted the presented identity too")
|
|
}
|
|
bound := reportIdentityToken(t, cfg, record.ID)
|
|
if !commandConfigAllowedForToken(&bound, models.Host{ID: reportIdentityMachineID, Hostname: "pve-node5"}) {
|
|
t.Fatal("the config gate refused the identity the command channel admits")
|
|
}
|
|
}
|
|
|
|
// A historical deploy token still bound to the invented agent-<hostname> may
|
|
// move once, on exactly its hostname, to the identity that registers. Naming
|
|
// the resolved ID lands that one move on the identity the agent's reports and
|
|
// restarts use, not on whatever ID the agent happened to present.
|
|
func TestReportAckLandsTheDeployPlaceholderRepairOnTheResolvedIdentity(t *testing.T) {
|
|
const raw = "command-identity-placeholder-token.12345678"
|
|
record := newTokenRecord(t, raw, agenttokens.HostScopes(true), map[string]string{
|
|
"bound_agent_id": "agent-pve-node6",
|
|
"bound_hostname": "pve-node6",
|
|
"deploy_job_id": "dep_6",
|
|
agentExecBindingVersionKey: agentExecBindingVersion,
|
|
})
|
|
record.OrgID = "default"
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := newReportIdentityRouter(t, cfg)
|
|
|
|
ack := postReportIdentityAck(t, router, raw, reportIdentityReport("configured-id", reportIdentityMachineID, "pve-node6"))
|
|
if ack["commandAgentId"] != reportIdentityMachineID {
|
|
t.Fatalf("ack commandAgentId = %v, want the resolved %q", ack["commandAgentId"], reportIdentityMachineID)
|
|
}
|
|
if _, ok := router.admitAgentExecToken(raw, reportIdentityMachineID, "pve-node6"); !ok {
|
|
t.Fatal("the placeholder token did not take its one-time repair to the named identity")
|
|
}
|
|
if got := reportIdentityToken(t, cfg, record.ID).Metadata; got["bound_agent_id"] != reportIdentityMachineID ||
|
|
got[agentbinding.DeployIdentityKey] != agentbinding.DeployIdentityRepaired {
|
|
t.Fatalf("binding after repair = %v, want %q marked repaired", got, reportIdentityMachineID)
|
|
}
|
|
if _, ok := router.admitAgentExecToken(raw, "configured-id", "pve-node6"); ok {
|
|
t.Fatal("the repaired token moved a second time")
|
|
}
|
|
}
|
|
|
|
// The report path records the first identity an agent reports under, and it
|
|
// is final. An agent later restarted under a different --agent-id is refused
|
|
// under that ID, so the acknowledgement names the recorded identity and the
|
|
// agent's command channel registers under it instead.
|
|
func TestReportAckNamesTheRecordedIdentityToALaterReconfiguredAgent(t *testing.T) {
|
|
const raw = "command-identity-reconfigured-token.12345678"
|
|
record := autoRegisteredInstallToken(t, raw, "pve10", true)
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := newReportIdentityRouter(t, cfg)
|
|
|
|
postReportIdentityReport(t, router, raw, reportIdentityReport(reportIdentityMachineID, reportIdentityMachineID, "pve10"))
|
|
ack := postReportIdentityAck(t, router, raw, reportIdentityReport("configured-id", reportIdentityMachineID, "pve10"))
|
|
if ack["commandAgentId"] != reportIdentityMachineID {
|
|
t.Fatalf("ack commandAgentId = %v, want the recorded %q", ack["commandAgentId"], reportIdentityMachineID)
|
|
}
|
|
if _, ok := router.admitAgentExecToken(raw, reportIdentityMachineID, "pve10"); !ok {
|
|
t.Fatal("the reconfigured agent's registration under the named identity was refused")
|
|
}
|
|
}
|
|
|
|
// Mock mode discards reports and acknowledges the presented ID unresolved, on
|
|
// a host held under no token. Naming that ID would steer a fresh token's first
|
|
// command registration onto an identity Pulse never resolved, so a mock ack
|
|
// names no command identity.
|
|
func TestReportAckNamesNoCommandIdentityInMockMode(t *testing.T) {
|
|
const raw = "command-identity-mock-token.12345678"
|
|
record := newTokenRecord(t, raw, agenttokens.HostScopes(true), map[string]string{
|
|
"issued_via": agentbinding.IssuedViaConfig,
|
|
"install_type": "host",
|
|
})
|
|
record.OrgID = "default"
|
|
cfg := newTestConfigWithTokens(t, record)
|
|
router := newReportIdentityRouter(t, cfg)
|
|
|
|
previous := mock.IsMockEnabled()
|
|
if err := mock.SetEnabled(true); err != nil {
|
|
t.Fatalf("enable mock mode: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = mock.SetEnabled(previous) })
|
|
|
|
ack := postReportIdentityAck(t, router, raw, reportIdentityReport("configured-id", reportIdentityMachineID, "host-mock"))
|
|
if ack["agentId"] != "configured-id" {
|
|
t.Fatalf("precondition: mock ack agentId = %v, want the presented ID echoed", ack["agentId"])
|
|
}
|
|
if named, present := ack["commandAgentId"]; present {
|
|
t.Fatalf("mock ack named command identity %v", named)
|
|
}
|
|
}
|