Files
pulse/internal/api/agent_uninstall_scope_test.go
rcourtman 111a62cfe8 Require an admin for non-token agent uninstall
POST /api/agents/agent/uninstall (and its host alias) is a collector's
self-removal: a token holding agent:report removes the agent that token
owns. For any caller that is not a token, the handler instead takes the
administrative removal path and deletes any host agent by ID. The route
was only RequireAuth plus RequireScope(agent:report), and browser
sessions bypass token scopes, so a non-admin SSO, proxy or organization
member session could remove any agent, while the equivalent unlink and
delete routes require an admin.

The route now uses RequireAdmin like its neighbours. Token callers are
unchanged: RequireAdmin admits them and the agent:report scope check
still applies, so collector and install.ps1 self-uninstall keep working.

The existing non-admin proxy test gains the two uninstall routes, and a
new test keeps the token path reachable and the scope check in place.

Contract-Neutral: Behavioural fix that gives the administrative removal path the admin gate its sibling routes have; no new API surface or contract.
2026-10-11 16:30:23 +01:00

45 lines
1.8 KiB
Go

package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
// A collector uninstalls itself with its own agent:report token. Callers that
// are not tokens (browser sessions, proxy users) reach the administrative
// removal path in the handler, so they must be administrators, while the
// collector's token path keeps working.
func TestAgentUninstallKeepsTokenSelfRemovalReachable(t *testing.T) {
const reportToken = "agent-uninstall-report.12345678"
const unrelatedToken = "agent-uninstall-unrelated.12345678"
cfg := newTestConfigWithTokens(t,
newTokenRecord(t, reportToken, []string{config.ScopeAgentReport}, nil),
newTokenRecord(t, unrelatedToken, []string{config.ScopeMonitoringRead}, nil),
)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
for _, path := range []string{"/api/agents/agent/uninstall", "/api/agents/host/uninstall"} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
req.Header.Set("X-API-Token", unrelatedToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("token without agent:report POST %s = %d, want 403", path, rec.Code)
}
// An empty agentId is rejected by the handler itself, which proves the
// request got past authentication and the scope gate.
req = httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
req.Header.Set("X-API-Token", reportToken)
rec = httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "missing_agent_id") {
t.Errorf("agent:report token POST %s = %d %q, want 400 missing_agent_id from the handler", path, rec.Code, rec.Body.String())
}
}
}