Files
pulse/pkg/server/server_test.go
Richard Courtman 139ee65b25 Stop test binaries reporting to the production telemetry endpoint
pkg/server tests boot the real server through Run() with the version
literal "test-version", which internal/updates normalizes to
0.0.0-test-version. Each test runs against its own t.TempDir(), so every
run minted a fresh install ID. The startup ping waits two minutes and so
never fired inside a short test, but the service-health failure reporter
added on 2026-08-29 sends synchronously from a deferred handler as soon
as Run() returns an error, so every CI shard containing pkg/server posted
one ping.

The licence server recorded 317 single-ping installs between 2026-08-29
and 2026-09-03 - 311 from linux/amd64 CI runners, 3 from a maintainer
workstation - still arriving at roughly 60 a day. The canonical clean
denominator excludes single-ping installs and was unaffected, but raw
install counts and the operator-evidence blocked-cause read counted them
as real installations.

A test binary is not an installation, which is the same reason mock mode
already suppresses pings, so the guard belongs beside it in the telemetry
package rather than at the four call sites: send() now refuses the
production endpoint whenever testing.Testing() reports true. The check
compares against productionPingEndpoint, so telemetry's own tests keep
asserting on real ping content through a redirected endpoint, and the
server tests additionally opt out at the config layer to say so locally.
2026-09-03 23:54:37 +01:00

449 lines
14 KiB
Go

package server
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
"github.com/rcourtman/pulse-go-rewrite/internal/telemetry"
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
"github.com/rcourtman/pulse-go-rewrite/pkg/extensions"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
)
func TestRunBindsPackagedVersionIdentity(t *testing.T) {
previous := updates.BuildVersion
t.Cleanup(func() {
updates.BuildVersion = previous
})
updates.BuildVersion = "stale-image-version"
bindRuntimeVersion(" 6.2.2-patrol.qualification.5cffa5462 ")
versionInfo, err := updates.GetCurrentVersion()
if err != nil {
t.Fatalf("get current version: %v", err)
}
if got, want := versionInfo.Version, "6.2.2-patrol.qualification.5cffa5462"; got != want {
t.Fatalf("runtime version = %q, want packaged version %q", got, want)
}
}
func TestAgentIngestHandler(t *testing.T) {
var innerCalled bool
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
innerCalled = true
w.WriteHeader(http.StatusOK)
})
h := agentIngestHandler(inner)
cases := []struct {
path string
wantInner bool
wantCode int
}{
{"/api/agents/agent/report", true, http.StatusOK},
{"/api/agents/docker/report", true, http.StatusOK},
{"/api/agents/kubernetes/report", true, http.StatusOK},
{"/api/agents/agent/lookup", true, http.StatusOK},
{"/api/agents/agent/config", true, http.StatusOK},
{"/api/agent/ws", true, http.StatusOK},
{"/api/agent/version", true, http.StatusOK},
{"/api/server/info", true, http.StatusOK},
{"/install.sh", true, http.StatusOK},
{"/install.ps1", true, http.StatusOK},
{"/download/pulse-agent", true, http.StatusOK},
// Everything outside the agent-ingest surface must be rejected so the
// dedicated port never exposes the web UI or the rest of the REST API.
{"/", false, http.StatusNotFound},
{"/index.html", false, http.StatusNotFound},
{"/api/health", false, http.StatusNotFound},
{"/api/state", false, http.StatusNotFound},
{"/api/security/status", false, http.StatusNotFound},
{"/api/agents", false, http.StatusNotFound},
{"/api/agents/../security/status", false, http.StatusNotFound},
{"/api/agents//agent/report", false, http.StatusNotFound},
{"/api/agent/ws/extra", false, http.StatusNotFound},
{"/install.sh/extra", false, http.StatusNotFound},
}
for _, tc := range cases {
innerCalled = false
req := httptest.NewRequest(http.MethodPost, tc.path, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if innerCalled != tc.wantInner {
t.Errorf("path %q: innerCalled=%v, want %v", tc.path, innerCalled, tc.wantInner)
}
if rec.Code != tc.wantCode {
t.Errorf("path %q: status=%d, want %d", tc.path, rec.Code, tc.wantCode)
}
}
}
func TestAgentControlPlaneListenerAdmitsCommandWebSocket(t *testing.T) {
execServer := agentexec.NewServer(func(token, agentID, hostname string) bool {
return token == "exec-token" && agentID == "docker-agent" && hostname == "docker-host"
})
t.Cleanup(execServer.Shutdown)
server := httptest.NewServer(agentIngestHandler(http.HandlerFunc(execServer.HandleWebSocket)))
defer server.Close()
origin, err := securityutil.HTTPOriginForWebSocketBaseURL(server.URL)
if err != nil {
t.Fatalf("origin: %v", err)
}
headers := http.Header{}
headers.Set("Origin", origin)
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/agent/ws"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, headers)
if err != nil {
t.Fatalf("dial dedicated agent command websocket: %v", err)
}
defer conn.Close()
registration, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
AgentID: "docker-agent", Hostname: "docker-host", Token: "exec-token",
})
if err != nil {
t.Fatalf("registration message: %v", err)
}
if err := conn.WriteJSON(registration); err != nil {
t.Fatalf("write registration: %v", err)
}
var response agentexec.Message
if err := conn.ReadJSON(&response); err != nil {
t.Fatalf("read registration acknowledgement: %v", err)
}
var acknowledged agentexec.RegisteredPayload
if err := response.DecodePayload(&acknowledged); err != nil {
t.Fatalf("decode acknowledgement: %v", err)
}
if !acknowledged.Success || !execServer.IsAgentConnected("docker-agent") {
t.Fatalf("dedicated listener did not admit command channel: %+v", acknowledged)
}
}
func TestBusinessHooks(t *testing.T) {
called := false
hook := func(store *metrics.Store) {
called = true
}
SetBusinessHooks(BusinessHooks{
OnMetricsStoreReady: hook,
})
globalHooksMu.Lock()
defer globalHooksMu.Unlock()
if globalHooks.OnMetricsStoreReady == nil {
t.Error("expected OnMetricsStoreReady to be set")
}
// Manually trigger to verify it works
globalHooks.OnMetricsStoreReady(nil)
if !called {
t.Error("expected hook to be called")
}
}
func TestRuntimeIdentityForBusinessHooks(t *testing.T) {
if got := runtimeIdentityForBusinessHooks(BusinessHooks{}); got.Build != pkglicensing.RuntimeBuildCommunity {
t.Fatalf("empty hooks runtime build=%q, want community", got.Build)
}
got := runtimeIdentityForBusinessHooks(BusinessHooks{
BindAuditAdminEndpoints: func(defaults extensions.AuditAdminEndpoints, runtime extensions.AuditAdminRuntime) extensions.AuditAdminEndpoints {
return defaults
},
})
if got.Build != pkglicensing.RuntimeBuildPro {
t.Fatalf("enterprise hooks runtime build=%q, want pro", got.Build)
}
got = runtimeIdentityForBusinessHooks(BusinessHooks{
ResolveAuditStoreConfig: func(string) extensions.AuditStoreConfig {
return extensions.AuditStoreConfig{}
},
})
if got.Build != pkglicensing.RuntimeBuildPro {
t.Fatalf("audit store config hook runtime build=%q, want pro", got.Build)
}
got = runtimeIdentityForBusinessHooks(BusinessHooks{
ResolveMonitoredSystemAdmissionPolicy: func(context.Context, extensions.MonitoredSystemAdmissionInput) extensions.MonitoredSystemAdmissionDecision {
return extensions.MonitoredSystemAdmissionDecision{}
},
})
if got.Build != pkglicensing.RuntimeBuildPro {
t.Fatalf("commercial admission hook runtime build=%q, want pro", got.Build)
}
}
func TestPerformAutoImport_Success(t *testing.T) {
capture := setCaptureAuditLogger(t)
// Setup temp directory
tmpDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tmpDir)
// Create a persistence instance to generate valid encrypted payload
sourceDir := t.TempDir()
sourcePersistence := config.NewConfigPersistence(sourceDir)
passphrase := "test-pass"
encryptedData, err := sourcePersistence.ExportConfig(passphrase)
if err != nil {
t.Fatalf("failed to generate export data: %v", err)
}
t.Setenv("PULSE_INIT_CONFIG_DATA", encryptedData)
t.Setenv("PULSE_INIT_CONFIG_FILE", "")
t.Setenv("PULSE_INIT_CONFIG_PASSPHRASE", passphrase)
// Run PerformAutoImport
if err := PerformAutoImport(); err != nil {
t.Fatalf("PerformAutoImport failed: %v", err)
}
if len(capture.events) != 1 {
t.Fatalf("expected 1 audit event, got %d", len(capture.events))
}
event := capture.events[0]
if event.EventType != "config_auto_import" {
t.Fatalf("unexpected event type: %s", event.EventType)
}
if !event.Success {
t.Fatal("expected success audit event")
}
if event.User != "system" {
t.Fatalf("unexpected audit user: %q", event.User)
}
if event.Path != "/startup/auto-import" {
t.Fatalf("unexpected audit path: %q", event.Path)
}
if !strings.Contains(event.Details, "source=env_data") {
t.Fatalf("expected source in details, got %q", event.Details)
}
// Verify persistence file created (nodes.enc is a good indicator)
_, err = os.Stat(filepath.Join(tmpDir, "nodes.enc"))
if err != nil {
if os.IsNotExist(err) {
t.Error("expected nodes.enc to be created")
} else {
t.Error(err)
}
}
}
func availableTCPPort(t *testing.T) int {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
return listener.Addr().(*net.TCPAddr).Port
}
func waitForHTTPStatus(t *testing.T, url string, want int) {
t.Helper()
client := &http.Client{Timeout: 200 * time.Millisecond}
deadline := time.Now().Add(5 * time.Second)
var lastErr error
var lastStatus int
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err == nil {
lastStatus = resp.StatusCode
resp.Body.Close()
if lastStatus == want {
return
}
} else {
lastErr = err
}
time.Sleep(50 * time.Millisecond)
}
if lastErr != nil {
t.Fatalf("timed out waiting for %s: last error: %v", url, lastErr)
}
t.Fatalf("timed out waiting for %s: last status %d, want %d", url, lastStatus, want)
}
// Minimal test for Server startup context cancellation
func TestServerRun_Shutdown(t *testing.T) {
// Setup minimal environment
tmpDir := t.TempDir()
// Run() reports startup failures over outbound telemetry; a test boot is
// not an installation, so opt this process out at the config layer too.
t.Setenv("PULSE_TELEMETRY", "false")
t.Setenv("PULSE_DATA_DIR", tmpDir)
t.Setenv("PULSE_CONFIG_PATH", tmpDir)
t.Setenv("BIND_ADDRESS", "127.0.0.1")
t.Setenv("FRONTEND_PORT", fmt.Sprintf("%d", availableTCPPort(t)))
oldMetricsPort := MetricsPort
MetricsPort = 0
defer func() { MetricsPort = oldMetricsPort }()
// Create a minimal config; environment variables own the listener ports for this test.
configFile := filepath.Join(tmpDir, "config.yaml")
if err := os.WriteFile(configFile, []byte("bindAddress: 127.0.0.1\nfrontendPort: 0"), 0644); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
// Cancel immediately/shortly to trigger shutdown path
go func() {
time.Sleep(100 * time.Millisecond)
cancel()
}()
err := Run(ctx, "test-version")
if err != nil && err != context.Canceled {
t.Logf("Run returned: %v", err)
}
}
func TestServerRunFailsFastWhenFrontendPortIsAlreadyBound(t *testing.T) {
// Run() reports startup failures over outbound telemetry; a test boot is
// not an installation, so opt this process out at the config layer too.
t.Setenv("PULSE_TELEMETRY", "false")
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
tmpDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tmpDir)
t.Setenv("BIND_ADDRESS", "127.0.0.1")
t.Setenv("FRONTEND_PORT", fmt.Sprintf("%d", listener.Addr().(*net.TCPAddr).Port))
oldMetricsPort := MetricsPort
MetricsPort = 0
defer func() { MetricsPort = oldMetricsPort }()
err = Run(context.Background(), "test-version")
if err == nil || !strings.Contains(err.Error(), "failed to bind UI/API server") {
t.Fatalf("expected frontend bind failure, got %v", err)
}
}
func TestServerRunKeepsFrontendWhenMetricsPortConflicts(t *testing.T) {
// Run() reports startup failures over outbound telemetry; a test boot is
// not an installation, so opt this process out at the config layer too.
t.Setenv("PULSE_TELEMETRY", "false")
port := availableTCPPort(t)
tmpDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tmpDir)
t.Setenv("BIND_ADDRESS", "127.0.0.1")
t.Setenv("FRONTEND_PORT", fmt.Sprintf("%d", port))
oldMetricsPort := MetricsPort
MetricsPort = port
defer func() { MetricsPort = oldMetricsPort }()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- Run(ctx, "test-version")
}()
waitForHTTPStatus(t, fmt.Sprintf("http://127.0.0.1:%d/api/health", port), http.StatusOK)
cancel()
select {
case err := <-errCh:
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for Run to shut down")
}
}
func TestServerRun_RejectsWildcardTrustedProxyCIDR(t *testing.T) {
tmpDir := t.TempDir()
// Run() reports startup failures over outbound telemetry; a test boot is
// not an installation, so opt this process out at the config layer too.
t.Setenv("PULSE_TELEMETRY", "false")
t.Setenv("PULSE_DATA_DIR", tmpDir)
t.Setenv("PULSE_CONFIG_PATH", tmpDir)
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "0.0.0.0/0")
configFile := filepath.Join(tmpDir, "config.yaml")
if err := os.WriteFile(configFile, []byte("bindAddress: 127.0.0.1\nfrontendPort: 0"), 0644); err != nil {
t.Fatal(err)
}
err := Run(context.Background(), "test-version")
if err == nil || !strings.Contains(err.Error(), "wildcard trust range") {
t.Fatalf("expected wildcard trusted proxy configuration to be rejected, got %v", err)
}
}
// Node connection test counts must reach the telemetry snapshot, and counts
// older than the reporting window must not, otherwise the add-node stall this
// counter exists to measure cannot be read from the fleet.
func TestApplyNodeTestTelemetrySnapshotReadsTallyWithinWindow(t *testing.T) {
dir := t.TempDir()
persistence := config.NewConfigPersistence(dir)
now := time.Now().UTC()
if err := persistence.RecordNodeTestOutcome(true, now.AddDate(0, 0, -40)); err != nil {
t.Fatalf("record stale outcome: %v", err)
}
if err := persistence.RecordNodeTestOutcome(true, now); err != nil {
t.Fatalf("record failure: %v", err)
}
if err := persistence.RecordNodeTestOutcome(false, now); err != nil {
t.Fatalf("record success: %v", err)
}
var snap telemetry.Snapshot
applyNodeTestTelemetrySnapshot(&snap, persistence, now)
if snap.NodeTestAttempts30d != 2 {
t.Fatalf("NodeTestAttempts30d = %d, want 2", snap.NodeTestAttempts30d)
}
if snap.NodeTestFailures30d != 1 {
t.Fatalf("NodeTestFailures30d = %d, want 1", snap.NodeTestFailures30d)
}
}
// A missing tally is the normal state on an install that has never opened the
// add-node dialog, and must report zero rather than fail the snapshot.
func TestApplyNodeTestTelemetrySnapshotToleratesMissingTally(t *testing.T) {
var snap telemetry.Snapshot
applyNodeTestTelemetrySnapshot(&snap, config.NewConfigPersistence(t.TempDir()), time.Now().UTC())
if snap.NodeTestAttempts30d != 0 || snap.NodeTestFailures30d != 0 {
t.Fatalf("counts = %d/%d, want 0/0", snap.NodeTestAttempts30d, snap.NodeTestFailures30d)
}
}