mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 12:42:43 +00:00
9e37d629ac
Telemetry could see only saved connections, so an install that tried to reach a node and could not was indistinguishable from one that never opened the add-node dialog. Both report zero configured connections and stall at the same activation stage. Fleet data shows that population is real and concentrated three to one in container deployments, and nothing recorded whether those installs attempted a connection at all. Record node connection test attempts and failures in a bounded, day-bucketed tally in the config directory, pruned to a 31-day retention window, and report both over the install-ID rotation window as node_test_attempts_30d and node_test_failures_30d. Recording starts only once a request carries a target and credentials, so an incomplete form is never counted as a node that could not be reached. A host string that turns out to be unusable does count, because the attempt was made and it failed. Only the add-node dialog endpoint is instrumented: instrumenting the unused test-config endpoint as well would double-count a single operator action. The tally holds counts alone. Hosts, credentials, and error text never enter it, which is why it is plain JSON rather than encrypted history.
1273 lines
44 KiB
Go
1273 lines
44 KiB
Go
package telemetry
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"regexp"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"context"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/testutil"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
|
|
)
|
|
|
|
func TestGetOrCreateInstallID_CreatesNew(t *testing.T) {
|
|
dir := t.TempDir()
|
|
now := time.Date(2026, 3, 28, 12, 0, 0, 0, time.UTC)
|
|
id := getOrCreateInstallIDAt(dir, now)
|
|
if id == "" {
|
|
t.Fatal("expected non-empty install ID")
|
|
}
|
|
|
|
// Verify file was persisted.
|
|
data, err := os.ReadFile(filepath.Join(dir, installIDFile))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
record := decodeInstallIDRecord(t, data)
|
|
if record.InstallID != id {
|
|
t.Fatalf("persisted install ID = %q, want %q", record.InstallID, id)
|
|
}
|
|
if !record.IssuedAt.Equal(now) {
|
|
t.Fatalf("persisted issued_at = %v, want %v", record.IssuedAt, now)
|
|
}
|
|
}
|
|
|
|
func TestGetOrCreateInstallID_ReusesExisting(t *testing.T) {
|
|
dir := t.TempDir()
|
|
now := time.Date(2026, 3, 28, 12, 0, 0, 0, time.UTC)
|
|
|
|
// Create first ID.
|
|
id1 := getOrCreateInstallIDAt(dir, now)
|
|
|
|
// Second call should return the same ID.
|
|
id2 := getOrCreateInstallIDAt(dir, now.Add(7*24*time.Hour))
|
|
if id1 != id2 {
|
|
t.Fatalf("expected same ID across calls, got %q and %q", id1, id2)
|
|
}
|
|
}
|
|
|
|
func TestGetOrCreateInstallID_RegeneratesInvalid(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// Write garbage.
|
|
if err := os.WriteFile(filepath.Join(dir, installIDFile), []byte("not-a-uuid\n"), 0600); err != nil {
|
|
t.Fatalf("write install id file: %v", err)
|
|
}
|
|
|
|
id := getOrCreateInstallIDAt(dir, time.Date(2026, 3, 28, 12, 0, 0, 0, time.UTC))
|
|
if id == "" || id == "not-a-uuid" {
|
|
t.Fatalf("expected new valid UUID, got %q", id)
|
|
}
|
|
}
|
|
|
|
func TestGetOrCreateInstallID_RotatesExpiredRecord(t *testing.T) {
|
|
dir := t.TempDir()
|
|
start := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
|
|
|
first := getOrCreateInstallIDAt(dir, start)
|
|
second := getOrCreateInstallIDAt(dir, start.Add(installIDRotationWindow+time.Hour))
|
|
if first == second {
|
|
t.Fatalf("expected install ID rotation after %v, got same value %q", installIDRotationWindow, first)
|
|
}
|
|
}
|
|
|
|
func TestGetOrCreateInstallID_RotatesLegacyPlaintextID(t *testing.T) {
|
|
dir := t.TempDir()
|
|
legacyID := uuid.New().String()
|
|
if err := os.WriteFile(filepath.Join(dir, installIDFile), []byte(legacyID+"\n"), 0600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
now := time.Date(2026, 3, 28, 12, 0, 0, 0, time.UTC)
|
|
rotated := getOrCreateInstallIDAt(dir, now)
|
|
if rotated == legacyID {
|
|
t.Fatalf("expected legacy plaintext install ID to rotate, got same value %q", rotated)
|
|
}
|
|
|
|
record := decodeInstallIDRecordFile(t, filepath.Join(dir, installIDFile))
|
|
if record.InstallID != rotated {
|
|
t.Fatalf("persisted install ID = %q, want %q", record.InstallID, rotated)
|
|
}
|
|
}
|
|
|
|
func TestResetInstallID_RewritesRecordImmediately(t *testing.T) {
|
|
dir := t.TempDir()
|
|
start := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
|
|
original := getOrCreateInstallIDAt(dir, start)
|
|
|
|
resetAt := start.Add(12 * time.Hour)
|
|
rotated, err := resetInstallIDAt(dir, resetAt)
|
|
if err != nil {
|
|
t.Fatalf("resetInstallIDAt: %v", err)
|
|
}
|
|
if rotated == "" {
|
|
t.Fatal("expected non-empty rotated install ID")
|
|
}
|
|
if rotated == original {
|
|
t.Fatalf("expected reset to rotate install ID, got same value %q", rotated)
|
|
}
|
|
|
|
record := decodeInstallIDRecordFile(t, filepath.Join(dir, installIDFile))
|
|
if record.InstallID != rotated {
|
|
t.Fatalf("persisted install ID = %q, want %q", record.InstallID, rotated)
|
|
}
|
|
if !record.IssuedAt.Equal(resetAt) {
|
|
t.Fatalf("persisted issued_at = %v, want %v", record.IssuedAt, resetAt)
|
|
}
|
|
}
|
|
|
|
func TestIsEnabled(t *testing.T) {
|
|
tests := []struct {
|
|
value string
|
|
want bool
|
|
}{
|
|
{"true", true},
|
|
{"1", true},
|
|
{"false", false},
|
|
{"", true}, // enabled by default when env var is not set
|
|
{"0", false},
|
|
{"yes", false}, // only "true"/"1" are truthy; unknown values disable
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.value, func(t *testing.T) {
|
|
t.Setenv("PULSE_TELEMETRY", tt.value)
|
|
if got := IsEnabled(); got != tt.want {
|
|
t.Fatalf("IsEnabled() = %v for PULSE_TELEMETRY=%q, want %v", got, tt.value, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClassifyPulseIntelligenceProActivationProof(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input PulseIntelligenceProActivationProofInput
|
|
want PulseIntelligenceProActivationProof
|
|
}{
|
|
{
|
|
name: "not started",
|
|
want: PulseIntelligenceProActivationProof{
|
|
ValueProofState: PulseIntelligenceProActivationValueProofNotStarted,
|
|
},
|
|
},
|
|
{
|
|
name: "starter only stays in progress",
|
|
input: PulseIntelligenceProActivationProofInput{
|
|
ProActivationStarterCount: 1,
|
|
},
|
|
want: PulseIntelligenceProActivationProof{
|
|
ValueProofState: PulseIntelligenceProActivationValueProofInProgress,
|
|
},
|
|
},
|
|
{
|
|
name: "approved verified outcome resolves without external agent readiness",
|
|
input: PulseIntelligenceProActivationProofInput{
|
|
ProActivationStarterCount: 1,
|
|
PatrolIssueEvidenceCount: 1,
|
|
ContextualCollaborationCount: 1,
|
|
ApprovedDecisionCount: 1,
|
|
VerifiedOutcomeCount: 1,
|
|
},
|
|
want: PulseIntelligenceProActivationProof{
|
|
Completed: true,
|
|
Resolved: true,
|
|
ValueProofState: PulseIntelligenceProActivationValueProofVerified,
|
|
},
|
|
},
|
|
{
|
|
name: "rejected governed decision records proof state before full completion",
|
|
input: PulseIntelligenceProActivationProofInput{
|
|
ProActivationStarterCount: 1,
|
|
RejectedDecisionCount: 1,
|
|
},
|
|
want: PulseIntelligenceProActivationProof{
|
|
ValueProofState: PulseIntelligenceProActivationValueProofGovernedDecisionRecorded,
|
|
},
|
|
},
|
|
{
|
|
name: "rejected governed decision completes without resolving",
|
|
input: PulseIntelligenceProActivationProofInput{
|
|
ProActivationStarterCount: 1,
|
|
PatrolIssueEvidenceCount: 1,
|
|
ContextualCollaborationCount: 1,
|
|
RejectedDecisionCount: 1,
|
|
},
|
|
want: PulseIntelligenceProActivationProof{
|
|
Completed: true,
|
|
ValueProofState: PulseIntelligenceProActivationValueProofGovernedDecisionRecorded,
|
|
},
|
|
},
|
|
{
|
|
name: "approved verified outcome resolves",
|
|
input: PulseIntelligenceProActivationProofInput{
|
|
ProActivationStarterCount: 1,
|
|
PatrolIssueEvidenceCount: 1,
|
|
ContextualCollaborationCount: 1,
|
|
ApprovedDecisionCount: 1,
|
|
VerifiedOutcomeCount: 1,
|
|
ExternalAgentReady: true,
|
|
},
|
|
want: PulseIntelligenceProActivationProof{
|
|
Completed: true,
|
|
Resolved: true,
|
|
ValueProofState: PulseIntelligenceProActivationValueProofVerified,
|
|
},
|
|
},
|
|
{
|
|
name: "approved decision without verified outcome stays in progress",
|
|
input: PulseIntelligenceProActivationProofInput{
|
|
ProActivationStarterCount: 1,
|
|
PatrolIssueEvidenceCount: 1,
|
|
ContextualCollaborationCount: 1,
|
|
ApprovedDecisionCount: 1,
|
|
ExternalAgentReady: true,
|
|
},
|
|
want: PulseIntelligenceProActivationProof{
|
|
ValueProofState: PulseIntelligenceProActivationValueProofInProgress,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := ClassifyPulseIntelligenceProActivationProof(tt.input); got != tt.want {
|
|
t.Fatalf("ClassifyPulseIntelligenceProActivationProof() = %#v, want %#v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClassifyPulseIntelligencePatrolControlProof(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input PulseIntelligencePatrolControlProofInput
|
|
want PulseIntelligencePatrolControlProof
|
|
}{
|
|
{
|
|
name: "not started",
|
|
want: PulseIntelligencePatrolControlProof{
|
|
ValueProofState: PulseIntelligenceProActivationValueProofNotStarted,
|
|
},
|
|
},
|
|
{
|
|
name: "starter only stays in progress",
|
|
input: PulseIntelligencePatrolControlProofInput{
|
|
PatrolControlStarterCount: 1,
|
|
},
|
|
want: PulseIntelligencePatrolControlProof{
|
|
ValueProofState: PulseIntelligenceProActivationValueProofInProgress,
|
|
},
|
|
},
|
|
{
|
|
name: "rejected governed decision completes without resolving",
|
|
input: PulseIntelligencePatrolControlProofInput{
|
|
PatrolControlStarterCount: 1,
|
|
PatrolIssueEvidenceCount: 1,
|
|
ContextualCollaborationCount: 1,
|
|
RejectedDecisionCount: 1,
|
|
},
|
|
want: PulseIntelligencePatrolControlProof{
|
|
Completed: true,
|
|
ValueProofState: PulseIntelligenceProActivationValueProofGovernedDecisionRecorded,
|
|
},
|
|
},
|
|
{
|
|
name: "approved verified outcome resolves",
|
|
input: PulseIntelligencePatrolControlProofInput{
|
|
PatrolControlStarterCount: 1,
|
|
PatrolIssueEvidenceCount: 1,
|
|
ContextualCollaborationCount: 1,
|
|
ApprovedDecisionCount: 1,
|
|
VerifiedOutcomeCount: 1,
|
|
ExternalAgentReady: true,
|
|
},
|
|
want: PulseIntelligencePatrolControlProof{
|
|
Completed: true,
|
|
Resolved: true,
|
|
ValueProofState: PulseIntelligenceProActivationValueProofVerified,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := ClassifyPulseIntelligencePatrolControlProof(tt.input); got != tt.want {
|
|
t.Fatalf("ClassifyPulseIntelligencePatrolControlProof() = %#v, want %#v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
legacy := ClassifyPulseIntelligencePatrolAutonomyProof(PulseIntelligencePatrolAutonomyProofInput{
|
|
PatrolAutonomyStarterCount: 1,
|
|
PatrolIssueEvidenceCount: 1,
|
|
ContextualCollaborationCount: 1,
|
|
ApprovedDecisionCount: 1,
|
|
VerifiedOutcomeCount: 1,
|
|
})
|
|
if legacy != (PulseIntelligencePatrolAutonomyProof{
|
|
Completed: true,
|
|
Resolved: true,
|
|
ValueProofState: PulseIntelligenceProActivationValueProofVerified,
|
|
}) {
|
|
t.Fatalf("legacy Patrol autonomy classifier alias drifted: %#v", legacy)
|
|
}
|
|
}
|
|
|
|
func TestApplySnapshot(t *testing.T) {
|
|
base := Ping{
|
|
InstallID: "test-id",
|
|
Version: "6.0.0",
|
|
Platform: "docker",
|
|
OS: "linux",
|
|
Arch: "amd64",
|
|
}
|
|
|
|
snap := func() Snapshot {
|
|
return Snapshot{
|
|
PVENodes: 3,
|
|
VMs: 10,
|
|
Containers: 5,
|
|
AgentHosts: 2,
|
|
DockerContainers: 12,
|
|
KubernetesPods: 18,
|
|
StoragePools: 4,
|
|
PhysicalDisks: 9,
|
|
TrueNASSystems: 1,
|
|
TrueNASApps: 3,
|
|
VMwareHosts: 2,
|
|
AvailabilityTargets: 6,
|
|
AIEnabled: true,
|
|
PatrolEnabled: true,
|
|
DiscoveryEnabled: true,
|
|
NotificationsEnabled: true,
|
|
AIActionsEnabled: true,
|
|
AlertAIEnabled: true,
|
|
ActiveAlerts: 2,
|
|
PaidLicense: true,
|
|
HasAPITokens: true,
|
|
RBACCustomRoles: 3,
|
|
RBACUserAssignments: 7,
|
|
AuditReads30d: 41,
|
|
ReportSchedules: 5,
|
|
ReportSchedulesEnabled: 4,
|
|
ReportSchedulesRun30d: 2,
|
|
AgentProfiles: 9,
|
|
UpdateAttempts30d: 4,
|
|
UpdateSuccesses30d: 2,
|
|
UpdateFailures30d: 1,
|
|
UpdateLastFailureCategory: "download",
|
|
PulseIntelligenceLoopConfigured: true,
|
|
PulseIntelligenceLoopActive30d: true,
|
|
PulseIntelligenceCompleteOperationsLoop30d: true,
|
|
PulseIntelligenceApprovedExecutionLoop30d: true,
|
|
PulseIntelligenceResolvedOperationsLoop30d: true,
|
|
PulseIntelligencePatrolControlCompletedOperationsLoop30d: true,
|
|
PulseIntelligencePatrolControlResolvedOperationsLoop30d: true,
|
|
PulseIntelligencePatrolControlPaidCompletedOperationsLoop30d: true,
|
|
PulseIntelligencePatrolControlPaidResolvedOperationsLoop30d: true,
|
|
PulseIntelligenceProActivationCompletedOperationsLoop30d: true,
|
|
PulseIntelligenceProActivationResolvedOperationsLoop30d: true,
|
|
PulseIntelligenceProActivationPaidCompletedOperationsLoop30d: true,
|
|
PulseIntelligenceProActivationPaidResolvedOperationsLoop30d: true,
|
|
PulseIntelligenceGovernedActionActive30d: true,
|
|
PulseIntelligenceAssistantOperationsLoop30d: true,
|
|
PulseIntelligenceAssistantApprovedExecutionLoop30d: true,
|
|
PulseIntelligenceAssistantApprovedActionSuccessLoop30d: true,
|
|
PulseIntelligenceAssistantResolvedOperationsLoop30d: true,
|
|
PulseIntelligenceExternalAgentOperationsLoop30d: true,
|
|
PulseIntelligenceExternalAgentApprovedExecutionLoop30d: true,
|
|
PulseIntelligenceExternalAgentApprovedActionSuccessLoop30d: true,
|
|
PulseIntelligenceExternalAgentResolvedOperationsLoop30d: true,
|
|
PulseIntelligenceMCPAdapterOperationsLoop30d: true,
|
|
PulseIntelligenceMCPAdapterApprovedExecutionLoop30d: true,
|
|
PulseIntelligenceMCPAdapterApprovedActionSuccessLoop30d: true,
|
|
PulseIntelligenceMCPAdapterResolvedOperationsLoop30d: true,
|
|
PulseIntelligenceOperationsLoopStarterRequests30d: 5,
|
|
PulseIntelligenceAssistantOperationsLoopStarterRequests30d: 2,
|
|
PulseIntelligencePatrolOperationsLoopStarterRequests30d: 1,
|
|
PulseIntelligencePatrolControlOperationsLoopStarterRequests30d: 3,
|
|
PulseIntelligenceProActivationOperationsLoopStarterRequests30d: 1,
|
|
PulseIntelligenceMCPOperationsLoopStarterRequests30d: 1,
|
|
PulseIntelligenceAssistantAICalls30d: 7,
|
|
PulseIntelligenceAssistantContextAICalls30d: 4,
|
|
PulseIntelligenceAssistantToolCalls30d: 9,
|
|
PulseIntelligencePatrolAICalls30d: 3,
|
|
PulseIntelligencePatrolRuns30d: 4,
|
|
PulseIntelligencePatrolNewFindings30d: 5,
|
|
PulseIntelligencePatrolInvestigations30d: 6,
|
|
PulseIntelligencePatrolResolvedFindings30d: 3,
|
|
PulseIntelligencePatrolBlockedCause: "provider_not_configured",
|
|
PulseIntelligenceExternalAgentEnabled: true,
|
|
PulseIntelligenceExternalAgentUsed30d: true,
|
|
PulseIntelligenceMCPAdapterUsed30d: true,
|
|
PulseIntelligenceExternalAgentContextRequests30d: 8,
|
|
PulseIntelligenceExternalAgentEventStreamRequests30d: 2,
|
|
PulseIntelligenceExternalAgentProvisioningRequests30d: 1,
|
|
PulseIntelligenceExternalAgentOperatorStateRequests30d: 3,
|
|
PulseIntelligenceExternalAgentFindingRequests30d: 5,
|
|
PulseIntelligenceExternalAgentActionRequests30d: 4,
|
|
PulseIntelligenceActionPlans30d: 6,
|
|
PulseIntelligenceApprovalRequests30d: 2,
|
|
PulseIntelligenceRejectedActionDecisions30d: 1,
|
|
PulseIntelligenceApprovedActionDecisions30d: 1,
|
|
PulseIntelligenceApprovedActionAttempts30d: 1,
|
|
PulseIntelligenceApprovedActionSuccesses30d: 1,
|
|
PulseIntelligenceApprovedActionInFlight30d: 2,
|
|
PulseIntelligenceApprovedActionUnclassified30d: 3,
|
|
PulseIntelligenceApprovedActionRefusalsPlanStale30d: 4,
|
|
PulseIntelligenceApprovedActionRefusalsPolicy30d: 5,
|
|
PulseIntelligenceApprovedActionRefusalsCapability30d: 6,
|
|
PulseIntelligenceApprovedActionRefusalsTargetChanged30d: 7,
|
|
PulseIntelligenceApprovedActionRefusalsPrerequisite30d: 8,
|
|
PulseIntelligenceApprovedActionRefusalsContract30d: 9,
|
|
PulseIntelligenceApprovedActionRefusalsOther30d: 10,
|
|
PulseIntelligenceVerifiedFindingResolutions30d: 8,
|
|
}
|
|
}
|
|
|
|
ping := applySnapshot(base, snap)
|
|
|
|
if ping.InstallID != "test-id" {
|
|
t.Fatal("base fields should be preserved")
|
|
}
|
|
if ping.PVENodes != 3 {
|
|
t.Fatalf("PVENodes = %d, want 3", ping.PVENodes)
|
|
}
|
|
if ping.VMs != 10 {
|
|
t.Fatalf("VMs = %d, want 10", ping.VMs)
|
|
}
|
|
if ping.AgentHosts != 2 || ping.DockerContainers != 12 || ping.KubernetesPods != 18 {
|
|
t.Fatalf("expanded workload counts not applied: %#v", ping)
|
|
}
|
|
if ping.StoragePools != 4 || ping.PhysicalDisks != 9 {
|
|
t.Fatalf("expanded storage counts not applied: %#v", ping)
|
|
}
|
|
if ping.TrueNASSystems != 1 || ping.TrueNASApps != 3 || ping.VMwareHosts != 2 || ping.AvailabilityTargets != 6 {
|
|
t.Fatalf("expanded platform counts not applied: %#v", ping)
|
|
}
|
|
if !ping.AIEnabled {
|
|
t.Fatal("AIEnabled should be true")
|
|
}
|
|
if !ping.PatrolEnabled || !ping.DiscoveryEnabled || !ping.NotificationsEnabled || !ping.AIActionsEnabled {
|
|
t.Fatalf("expanded feature flags not applied: %#v", ping)
|
|
}
|
|
if !ping.AlertAIEnabled {
|
|
t.Fatal("AlertAIEnabled should be true")
|
|
}
|
|
if !ping.PaidLicense {
|
|
t.Fatal("PaidLicense should be true")
|
|
}
|
|
if !ping.HasAPITokens {
|
|
t.Fatal("HasAPITokens should be true")
|
|
}
|
|
if ping.RBACCustomRoles != 3 || ping.RBACUserAssignments != 7 {
|
|
t.Fatalf("RBAC adoption counts not applied: %#v", ping)
|
|
}
|
|
if ping.AuditReads30d != 41 {
|
|
t.Fatalf("audit read count not applied: %#v", ping)
|
|
}
|
|
if ping.ReportSchedules != 5 || ping.ReportSchedulesEnabled != 4 || ping.ReportSchedulesRun30d != 2 {
|
|
t.Fatalf("report schedule adoption counts not applied: %#v", ping)
|
|
}
|
|
if ping.AgentProfiles != 9 {
|
|
t.Fatalf("agent profile count not applied: %#v", ping)
|
|
}
|
|
if ping.UpdateAttempts30d != 4 || ping.UpdateSuccesses30d != 2 || ping.UpdateFailures30d != 1 ||
|
|
ping.UpdateLastFailureCategory != "download" {
|
|
t.Fatalf("update telemetry counters not applied: %#v", ping)
|
|
}
|
|
if !ping.PulseIntelligenceLoopConfigured || !ping.PulseIntelligenceLoopActive30d ||
|
|
!ping.PulseIntelligenceCompleteOperationsLoop30d ||
|
|
!ping.PulseIntelligenceApprovedExecutionLoop30d ||
|
|
!ping.PulseIntelligenceResolvedOperationsLoop30d ||
|
|
!ping.PulseIntelligencePatrolControlCompletedOperationsLoop30d ||
|
|
!ping.PulseIntelligencePatrolControlResolvedOperationsLoop30d ||
|
|
!ping.PulseIntelligencePatrolControlPaidCompletedOperationsLoop30d ||
|
|
!ping.PulseIntelligencePatrolControlPaidResolvedOperationsLoop30d ||
|
|
!ping.PulseIntelligenceProActivationCompletedOperationsLoop30d ||
|
|
!ping.PulseIntelligenceProActivationResolvedOperationsLoop30d ||
|
|
!ping.PulseIntelligenceProActivationPaidCompletedOperationsLoop30d ||
|
|
!ping.PulseIntelligenceProActivationPaidResolvedOperationsLoop30d ||
|
|
!ping.PulseIntelligenceGovernedActionActive30d ||
|
|
!ping.PulseIntelligenceAssistantOperationsLoop30d ||
|
|
!ping.PulseIntelligenceAssistantApprovedExecutionLoop30d ||
|
|
!ping.PulseIntelligenceAssistantApprovedActionSuccessLoop30d ||
|
|
!ping.PulseIntelligenceAssistantResolvedOperationsLoop30d ||
|
|
!ping.PulseIntelligenceExternalAgentOperationsLoop30d ||
|
|
!ping.PulseIntelligenceExternalAgentApprovedExecutionLoop30d ||
|
|
!ping.PulseIntelligenceExternalAgentApprovedActionSuccessLoop30d ||
|
|
!ping.PulseIntelligenceExternalAgentResolvedOperationsLoop30d ||
|
|
!ping.PulseIntelligenceMCPAdapterOperationsLoop30d ||
|
|
!ping.PulseIntelligenceMCPAdapterApprovedExecutionLoop30d ||
|
|
!ping.PulseIntelligenceMCPAdapterApprovedActionSuccessLoop30d ||
|
|
!ping.PulseIntelligenceMCPAdapterResolvedOperationsLoop30d {
|
|
t.Fatalf("Pulse Intelligence adoption state not applied: %#v", ping)
|
|
}
|
|
if ping.PulseIntelligenceAssistantAICalls30d != 7 ||
|
|
ping.PulseIntelligenceOperationsLoopStarterRequests30d != 5 ||
|
|
ping.PulseIntelligenceAssistantOperationsLoopStarterRequests30d != 2 ||
|
|
ping.PulseIntelligencePatrolOperationsLoopStarterRequests30d != 1 ||
|
|
ping.PulseIntelligencePatrolControlOperationsLoopStarterRequests30d != 3 ||
|
|
ping.PulseIntelligenceProActivationOperationsLoopStarterRequests30d != 1 ||
|
|
ping.PulseIntelligenceMCPOperationsLoopStarterRequests30d != 1 ||
|
|
ping.PulseIntelligenceAssistantContextAICalls30d != 4 ||
|
|
ping.PulseIntelligenceAssistantToolCalls30d != 9 ||
|
|
ping.PulseIntelligencePatrolAICalls30d != 3 ||
|
|
ping.PulseIntelligencePatrolRuns30d != 4 ||
|
|
ping.PulseIntelligencePatrolNewFindings30d != 5 ||
|
|
ping.PulseIntelligencePatrolInvestigations30d != 6 ||
|
|
ping.PulseIntelligencePatrolResolvedFindings30d != 3 ||
|
|
ping.PulseIntelligencePatrolBlockedCause != "provider_not_configured" ||
|
|
ping.PulseIntelligenceActionPlans30d != 6 ||
|
|
ping.PulseIntelligenceApprovalRequests30d != 2 ||
|
|
ping.PulseIntelligenceRejectedActionDecisions30d != 1 ||
|
|
ping.PulseIntelligenceApprovedActionDecisions30d != 1 ||
|
|
ping.PulseIntelligenceApprovedActionAttempts30d != 1 ||
|
|
ping.PulseIntelligenceApprovedActionSuccesses30d != 1 ||
|
|
ping.PulseIntelligenceApprovedActionInFlight30d != 2 ||
|
|
ping.PulseIntelligenceApprovedActionUnclassified30d != 3 ||
|
|
ping.PulseIntelligenceApprovedActionRefusalsPlanStale30d != 4 ||
|
|
ping.PulseIntelligenceApprovedActionRefusalsPolicy30d != 5 ||
|
|
ping.PulseIntelligenceApprovedActionRefusalsCapability30d != 6 ||
|
|
ping.PulseIntelligenceApprovedActionRefusalsTargetChanged30d != 7 ||
|
|
ping.PulseIntelligenceApprovedActionRefusalsPrerequisite30d != 8 ||
|
|
ping.PulseIntelligenceApprovedActionRefusalsContract30d != 9 ||
|
|
ping.PulseIntelligenceApprovedActionRefusalsOther30d != 10 ||
|
|
ping.PulseIntelligenceVerifiedFindingResolutions30d != 8 {
|
|
t.Fatalf("Pulse Intelligence counters not applied: %#v", ping)
|
|
}
|
|
if !ping.PulseIntelligenceExternalAgentEnabled || !ping.PulseIntelligenceExternalAgentUsed30d ||
|
|
!ping.PulseIntelligenceMCPAdapterUsed30d {
|
|
t.Fatalf("Pulse Intelligence external-agent booleans not applied: %#v", ping)
|
|
}
|
|
if ping.PulseIntelligenceExternalAgentContextRequests30d != 8 ||
|
|
ping.PulseIntelligenceExternalAgentEventStreamRequests30d != 2 ||
|
|
ping.PulseIntelligenceExternalAgentProvisioningRequests30d != 1 ||
|
|
ping.PulseIntelligenceExternalAgentOperatorStateRequests30d != 3 ||
|
|
ping.PulseIntelligenceExternalAgentFindingRequests30d != 5 ||
|
|
ping.PulseIntelligenceExternalAgentActionRequests30d != 4 {
|
|
t.Fatalf("Pulse Intelligence external-agent class counters not applied: %#v", ping)
|
|
}
|
|
}
|
|
|
|
func TestApplyUpdateTelemetrySnapshotSummarizesHistory(t *testing.T) {
|
|
history, err := updates.NewUpdateHistory(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("NewUpdateHistory: %v", err)
|
|
}
|
|
now := time.Date(2026, 6, 28, 14, 0, 0, 0, time.UTC)
|
|
ctx := context.Background()
|
|
entries := []updates.UpdateHistoryEntry{
|
|
{
|
|
EventID: "old-success",
|
|
Timestamp: now.Add(-(installIDRotationWindow + time.Hour)),
|
|
Action: "update",
|
|
Status: updates.StatusSuccess,
|
|
},
|
|
{
|
|
EventID: "recent-success",
|
|
Timestamp: now.Add(-2 * time.Hour),
|
|
Action: "update",
|
|
Status: updates.StatusSuccess,
|
|
},
|
|
{
|
|
EventID: "recent-failed-download",
|
|
Timestamp: now.Add(-time.Hour),
|
|
Action: "update",
|
|
Status: updates.StatusFailed,
|
|
Error: &updates.UpdateError{
|
|
Message: "failed to download update: upstream timed out",
|
|
Details: "raw details stay local",
|
|
},
|
|
},
|
|
{
|
|
EventID: "recent-check",
|
|
Timestamp: now.Add(-30 * time.Minute),
|
|
Action: "check",
|
|
Status: updates.StatusSuccess,
|
|
},
|
|
}
|
|
for _, entry := range entries {
|
|
if _, err := history.CreateEntry(ctx, entry); err != nil {
|
|
t.Fatalf("CreateEntry(%s): %v", entry.EventID, err)
|
|
}
|
|
}
|
|
|
|
var snap Snapshot
|
|
ApplyUpdateTelemetrySnapshot(&snap, history, now)
|
|
|
|
if snap.UpdateAttempts30d != 2 {
|
|
t.Fatalf("UpdateAttempts30d = %d, want 2", snap.UpdateAttempts30d)
|
|
}
|
|
if snap.UpdateSuccesses30d != 1 {
|
|
t.Fatalf("UpdateSuccesses30d = %d, want 1", snap.UpdateSuccesses30d)
|
|
}
|
|
if snap.UpdateFailures30d != 1 {
|
|
t.Fatalf("UpdateFailures30d = %d, want 1", snap.UpdateFailures30d)
|
|
}
|
|
if snap.UpdateLastFailureCategory != "download" {
|
|
t.Fatalf("UpdateLastFailureCategory = %q, want download", snap.UpdateLastFailureCategory)
|
|
}
|
|
}
|
|
|
|
func TestApplyUpdateTelemetrySnapshotDoesNotExposeRawFailureText(t *testing.T) {
|
|
history, err := updates.NewUpdateHistory(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("NewUpdateHistory: %v", err)
|
|
}
|
|
now := time.Date(2026, 6, 28, 14, 0, 0, 0, time.UTC)
|
|
if _, err := history.CreateEntry(context.Background(), updates.UpdateHistoryEntry{
|
|
EventID: "recent-sensitive-failure",
|
|
Timestamp: now.Add(-time.Minute),
|
|
Action: "update",
|
|
Status: updates.StatusFailed,
|
|
Error: &updates.UpdateError{
|
|
Code: "UPSTREAM_503",
|
|
Message: "failed for https://updates.example.test/private/build?token=secret",
|
|
Details: "/home/alice/pulse/update.log contained local command output",
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("CreateEntry: %v", err)
|
|
}
|
|
|
|
var snap Snapshot
|
|
ApplyUpdateTelemetrySnapshot(&snap, history, now)
|
|
|
|
if snap.UpdateLastFailureCategory != "unknown" {
|
|
t.Fatalf("UpdateLastFailureCategory = %q, want unknown", snap.UpdateLastFailureCategory)
|
|
}
|
|
for _, disallowed := range []string{"https://", "updates.example", "/home/alice", "token=", "command output"} {
|
|
if strings.Contains(snap.UpdateLastFailureCategory, disallowed) {
|
|
t.Fatalf("UpdateLastFailureCategory leaked raw failure text %q in %q", disallowed, snap.UpdateLastFailureCategory)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAllTelemetryFieldsAreDisclosed(t *testing.T) {
|
|
pingType := reflect.TypeOf(Ping{})
|
|
fieldLabels := make([]string, 0)
|
|
for i := 0; i < pingType.NumField(); i++ {
|
|
jsonName := strings.Split(pingType.Field(i).Tag.Get("json"), ",")[0]
|
|
fieldLabels = append(fieldLabels, normalizedTelemetryDisclosureLabel(jsonName))
|
|
}
|
|
if len(fieldLabels) == 0 {
|
|
t.Fatal("expected telemetry fields on Ping")
|
|
}
|
|
|
|
for _, relativePath := range []string{
|
|
filepath.Join("..", "..", "docs", "PRIVACY.md"),
|
|
filepath.Join("..", "..", "frontend-modern", "public", "docs", "PRIVACY.md"),
|
|
} {
|
|
raw, err := os.ReadFile(relativePath)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", relativePath, err)
|
|
}
|
|
doc := normalizedTelemetryDisclosureTableText(string(raw))
|
|
for _, label := range fieldLabels {
|
|
if !strings.Contains(doc, label) {
|
|
t.Errorf("%s must disclose telemetry field %q", relativePath, label)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTelemetryPrivacyDocsDisclosePseudonymousIdentityAndIPHandling(t *testing.T) {
|
|
anonymousTelemetryClaimPattern := regexp.MustCompile(`(?i)\banonymous\b[^\n]{0,120}\btelemetry\b|\btelemetry\b[^\n]{0,120}\banonymous\b`)
|
|
for _, relativePath := range []string{
|
|
filepath.Join("..", "..", "docs", "PRIVACY.md"),
|
|
filepath.Join("..", "..", "frontend-modern", "public", "docs", "PRIVACY.md"),
|
|
} {
|
|
raw, err := os.ReadFile(relativePath)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", relativePath, err)
|
|
}
|
|
content := string(raw)
|
|
normalized := normalizedTelemetryDisclosureText(content)
|
|
|
|
for _, required := range []string{
|
|
"outbound usage telemetry",
|
|
"enabled by default",
|
|
"rotating pseudonymous install ID",
|
|
"Pulse does not send browser events or an event-level clickstream",
|
|
"Lifecycle and outcome signals are deliberately limited to closed buckets, booleans, and aggregate counts",
|
|
"PULSE_TELEMETRY=false",
|
|
"The license server uses request IP addresses transiently for abuse/rate limiting",
|
|
} {
|
|
if !strings.Contains(content, required) {
|
|
t.Errorf("%s must disclose %q", relativePath, required)
|
|
}
|
|
}
|
|
if !strings.Contains(normalized, "does not store ip addresses in telemetry rows") {
|
|
t.Errorf("%s must disclose that telemetry rows do not store IP addresses", relativePath)
|
|
}
|
|
if anonymousTelemetryClaimPattern.MatchString(content) {
|
|
t.Errorf("%s must not describe outbound usage telemetry as anonymous", relativePath)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTelemetryPrivacyDocsKeepObserverProposalContentLocal(t *testing.T) {
|
|
for _, relativePath := range []string{
|
|
filepath.Join("..", "..", "docs", "PRIVACY.md"),
|
|
filepath.Join("..", "..", "frontend-modern", "public", "docs", "PRIVACY.md"),
|
|
} {
|
|
raw, err := os.ReadFile(relativePath)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", relativePath, err)
|
|
}
|
|
content := string(raw)
|
|
for _, required := range []string{
|
|
"model-authored observer proposal",
|
|
"encrypts that artifact with the retained objective",
|
|
"excludes it from public objective reads and later prompt seeds",
|
|
"does not include its content in usage telemetry or audit messages",
|
|
} {
|
|
if !strings.Contains(content, required) {
|
|
t.Errorf("%s must disclose %q", relativePath, required)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRepositoryDoesNotClaimTelemetryIsAnonymous(t *testing.T) {
|
|
repoRoot := filepath.Clean(filepath.Join("..", ".."))
|
|
anonymousTelemetryClaimPattern := regexp.MustCompile(`(?i)\banonymous\b[^\n]{0,120}\btelemetry\b|\btelemetry\b[^\n]{0,120}\banonymous\b`)
|
|
scannableExtensions := map[string]bool{
|
|
".go": true,
|
|
".html": true,
|
|
".json": true,
|
|
".md": true,
|
|
".py": true,
|
|
".sh": true,
|
|
".ts": true,
|
|
".tsx": true,
|
|
".yaml": true,
|
|
".yml": true,
|
|
}
|
|
|
|
err := filepath.WalkDir(repoRoot, func(path string, entry fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if entry.IsDir() {
|
|
switch entry.Name() {
|
|
case ".claude", ".git", "build", "dist", "node_modules", "tmp", "vendor":
|
|
return fs.SkipDir
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
relativePath, err := filepath.Rel(repoRoot, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
normalizedPath := filepath.ToSlash(relativePath)
|
|
baseName := entry.Name()
|
|
if normalizedPath == "docs/release-control/v6/internal/status.json" ||
|
|
strings.Contains(normalizedPath, "/__tests__/") ||
|
|
strings.Contains(normalizedPath, "/tests/") ||
|
|
strings.HasSuffix(baseName, "_test.go") ||
|
|
strings.HasSuffix(baseName, "_test.py") ||
|
|
strings.Contains(baseName, ".test.") {
|
|
return nil
|
|
}
|
|
if !scannableExtensions[strings.ToLower(filepath.Ext(baseName))] {
|
|
return nil
|
|
}
|
|
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if match := anonymousTelemetryClaimPattern.Find(raw); match != nil {
|
|
t.Errorf("%s must describe telemetry as pseudonymous, not %q", normalizedPath, match)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("scan repository telemetry language: %v", err)
|
|
}
|
|
}
|
|
|
|
func normalizedTelemetryDisclosureTableText(value string) string {
|
|
tableLines := make([]string, 0)
|
|
for _, line := range strings.Split(value, "\n") {
|
|
if strings.HasPrefix(strings.TrimSpace(line), "|") {
|
|
tableLines = append(tableLines, line)
|
|
}
|
|
}
|
|
return normalizedTelemetryDisclosureText(strings.Join(tableLines, "\n"))
|
|
}
|
|
|
|
func normalizedTelemetryDisclosureLabel(jsonName string) string {
|
|
if strings.HasPrefix(jsonName, "pulse_intelligence_") {
|
|
label := strings.TrimPrefix(jsonName, "pulse_intelligence_")
|
|
return normalizedTelemetryDisclosureText("Pulse Intelligence " + strings.ReplaceAll(label, "_", " "))
|
|
}
|
|
return normalizedTelemetryDisclosureText(strings.ReplaceAll(jsonName, "_", " "))
|
|
}
|
|
|
|
func normalizedTelemetryDisclosureText(value string) string {
|
|
value = strings.ToLower(value)
|
|
replacer := strings.NewReplacer(
|
|
"`", " ",
|
|
"|", " ",
|
|
"_", " ",
|
|
"-", " ",
|
|
"/", " ",
|
|
",", " ",
|
|
".", " ",
|
|
":", " ",
|
|
";", " ",
|
|
"*", " ",
|
|
"(", " ",
|
|
")", " ",
|
|
"[", " ",
|
|
"]", " ",
|
|
)
|
|
value = replacer.Replace(value)
|
|
return strings.Join(strings.Fields(value), " ")
|
|
}
|
|
|
|
func TestApplySnapshot_NilFunc(t *testing.T) {
|
|
base := Ping{InstallID: "test-id", Version: "6.0.0"}
|
|
ping := applySnapshot(base, nil)
|
|
if ping.InstallID != "test-id" {
|
|
t.Fatal("should return base when func is nil")
|
|
}
|
|
if ping.PVENodes != 0 {
|
|
t.Fatal("snapshot fields should be zero when func is nil")
|
|
}
|
|
}
|
|
|
|
func TestBuildPreview_UsesCurrentHeartbeatPayload(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
preview, err := BuildPreview(Config{
|
|
Version: "v6.0.0-rc.1-45-gABCDEF",
|
|
DataDir: dir,
|
|
IsDocker: true,
|
|
GetSnapshot: func() Snapshot {
|
|
return Snapshot{
|
|
PVENodes: 3,
|
|
VMs: 10,
|
|
ActiveAlerts: 2,
|
|
AIEnabled: true,
|
|
NotificationFailures7d: 3,
|
|
NotificationFailuresAuthentication7d: 2,
|
|
NotificationFailuresConnectivity7d: 1,
|
|
}
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("BuildPreview: %v", err)
|
|
}
|
|
|
|
if preview.Event != "heartbeat" {
|
|
t.Fatalf("preview event = %q, want heartbeat", preview.Event)
|
|
}
|
|
if preview.Platform != "docker" {
|
|
t.Fatalf("preview platform = %q, want docker", preview.Platform)
|
|
}
|
|
if preview.Version != "6.0.0-rc.1+git.45.gabcdef" {
|
|
t.Fatalf("preview version = %q, want normalized development version", preview.Version)
|
|
}
|
|
if preview.VersionRaw != "v6.0.0-rc.1-45-gABCDEF" {
|
|
t.Fatalf("preview raw version = %q, want original version string", preview.VersionRaw)
|
|
}
|
|
if preview.VersionChannel != "dev" {
|
|
t.Fatalf("preview version channel = %q, want dev", preview.VersionChannel)
|
|
}
|
|
if preview.VersionBuild != "git.45.gabcdef" {
|
|
t.Fatalf("preview version build = %q, want git.45.gabcdef", preview.VersionBuild)
|
|
}
|
|
if !preview.VersionDevelopment {
|
|
t.Fatal("expected preview to mark development build")
|
|
}
|
|
if preview.VersionPublished {
|
|
t.Fatal("development preview must not be marked as published release")
|
|
}
|
|
if preview.PVENodes != 3 || preview.VMs != 10 || preview.ActiveAlerts != 2 {
|
|
t.Fatalf("preview snapshot = %#v", preview)
|
|
}
|
|
if preview.NotificationFailures7d != 3 ||
|
|
preview.NotificationFailuresAuthentication7d != 2 ||
|
|
preview.NotificationFailuresConnectivity7d != 1 {
|
|
t.Fatalf("preview notification failure classes = %#v", preview)
|
|
}
|
|
if preview.InstallID == "" {
|
|
t.Fatal("expected preview install ID")
|
|
}
|
|
if preview.SchemaVersion != TelemetrySchemaVersion || preview.SentAt == "" {
|
|
t.Fatalf("preview schema identity = version %d sent_at %q", preview.SchemaVersion, preview.SentAt)
|
|
}
|
|
if preview.DeploymentMethod != "container_other" {
|
|
t.Fatalf("preview deployment method = %q, want container_other", preview.DeploymentMethod)
|
|
}
|
|
if preview.ActivationStage != "outcome_observed" || !preview.MonitoringActive {
|
|
t.Fatalf("preview lifecycle = stage %q active %v, want outcome_observed/true", preview.ActivationStage, preview.MonitoringActive)
|
|
}
|
|
if preview.EstateSizeBucket != "11_50" {
|
|
t.Fatalf("preview estate bucket = %q, want 11_50", preview.EstateSizeBucket)
|
|
}
|
|
if preview.TimeToFirstMonitoredResourceBucket != "present_at_first_observation" {
|
|
t.Fatalf("preview time-to-monitoring bucket = %q, want present_at_first_observation", preview.TimeToFirstMonitoredResourceBucket)
|
|
}
|
|
|
|
record := decodeInstallIDRecordFile(t, filepath.Join(dir, installIDFile))
|
|
if record.InstallID != preview.InstallID {
|
|
t.Fatalf("persisted install ID = %q, want %q", record.InstallID, preview.InstallID)
|
|
}
|
|
}
|
|
|
|
func TestBuildPingAt_RotatesIdentifierDuringLongRunningSession(t *testing.T) {
|
|
dir := t.TempDir()
|
|
start := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
|
cfg := Config{DataDir: dir}
|
|
|
|
first, err := buildPingAt(cfg, "startup", start)
|
|
if err != nil {
|
|
t.Fatalf("build first ping: %v", err)
|
|
}
|
|
second, err := buildPingAt(cfg, "heartbeat", start.Add(installIDRotationWindow+time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("build second ping: %v", err)
|
|
}
|
|
if first.InstallID == second.InstallID {
|
|
t.Fatalf("expected per-event install ID rotation, got %q twice", first.InstallID)
|
|
}
|
|
}
|
|
|
|
func TestDeploymentMethodRejectsFreeFormValues(t *testing.T) {
|
|
t.Setenv("PULSE_DEPLOYMENT_METHOD", "/home/alice/private-install")
|
|
if got := deploymentMethod(Config{IsDocker: true}); got != "container_other" {
|
|
t.Fatalf("docker invalid deployment value = %q, want container_other", got)
|
|
}
|
|
if got := deploymentMethod(Config{}); got != "binary_other" {
|
|
t.Fatalf("binary invalid deployment value = %q, want binary_other", got)
|
|
}
|
|
|
|
t.Setenv("PULSE_DEPLOYMENT_METHOD", "systemd")
|
|
if got := deploymentMethod(Config{}); got != "systemd" {
|
|
t.Fatalf("closed deployment value = %q, want systemd", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildPingAt_PersistsOnlyCoarseLifecycleMilestones(t *testing.T) {
|
|
dir := t.TempDir()
|
|
start := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
|
snapshot := Snapshot{AuthConfigured: true}
|
|
cfg := Config{
|
|
DataDir: dir,
|
|
GetSnapshot: func() Snapshot {
|
|
return snapshot
|
|
},
|
|
}
|
|
|
|
first, err := buildPingAt(cfg, "heartbeat", start)
|
|
if err != nil {
|
|
t.Fatalf("build first ping: %v", err)
|
|
}
|
|
if first.ActivationStage != "secured" || first.TimeToFirstMonitoredResourceBucket != "not_observed" {
|
|
t.Fatalf("first lifecycle = %#v", first)
|
|
}
|
|
|
|
snapshot = Snapshot{
|
|
AuthConfigured: true,
|
|
ConfiguredConnections: 1,
|
|
PVENodes: 1,
|
|
VMs: 4,
|
|
AlertsFired30d: 1,
|
|
AlertsResolved30d: 1,
|
|
}
|
|
second, err := buildPingAt(cfg, "heartbeat", start.Add(2*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("build second ping: %v", err)
|
|
}
|
|
if second.ActivationStage != "outcome_observed" || !second.MonitoringActive || !second.OutcomeObserved30d {
|
|
t.Fatalf("second lifecycle = %#v", second)
|
|
}
|
|
if second.TimeToFirstMonitoredResourceBucket != "1_6h" {
|
|
t.Fatalf("time-to-monitoring bucket = %q, want 1_6h", second.TimeToFirstMonitoredResourceBucket)
|
|
}
|
|
|
|
snapshot = Snapshot{}
|
|
third, err := buildPingAt(cfg, "heartbeat", start.Add(8*24*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("build third ping: %v", err)
|
|
}
|
|
if third.ActivationStage != "outcome_observed" || third.MonitoringActive {
|
|
t.Fatalf("historical/current lifecycle split = stage %q active %v", third.ActivationStage, third.MonitoringActive)
|
|
}
|
|
if third.KnownInstallAgeBucket != "8_30d" {
|
|
t.Fatalf("known install age bucket = %q, want 8_30d", third.KnownInstallAgeBucket)
|
|
}
|
|
|
|
data, err := os.ReadFile(filepath.Join(dir, lifecycleStateFile))
|
|
if err != nil {
|
|
t.Fatalf("read lifecycle state: %v", err)
|
|
}
|
|
var stored map[string]any
|
|
if err := json.Unmarshal(data, &stored); err != nil {
|
|
t.Fatalf("decode lifecycle state: %v", err)
|
|
}
|
|
for key := range stored {
|
|
switch key {
|
|
case "first_observed_at", "first_monitored_resource_at", "highest_observed_activation":
|
|
default:
|
|
t.Fatalf("unexpected lifecycle state field %q", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSend_Success(t *testing.T) {
|
|
var received atomic.Int32
|
|
var lastPing Ping
|
|
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(body, &lastPing)
|
|
received.Add(1)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
// Override the endpoint for testing.
|
|
origEndpoint := pingEndpoint
|
|
pingEndpoint = ts.URL
|
|
defer func() { pingEndpoint = origEndpoint }()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
ping := Ping{
|
|
InstallID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
Version: "6.0.0-rc.1",
|
|
VersionRaw: "v6.0.0-rc.1",
|
|
VersionChannel: "rc",
|
|
VersionDevelopment: false,
|
|
VersionPublished: true,
|
|
Event: "startup",
|
|
Platform: "docker",
|
|
OS: "linux",
|
|
Arch: "amd64",
|
|
}
|
|
if err := send(ctx, ping); err != nil {
|
|
t.Fatalf("send: %v", err)
|
|
}
|
|
|
|
if received.Load() != 1 {
|
|
t.Fatalf("expected 1 request to reach server, got %d", received.Load())
|
|
}
|
|
if lastPing.InstallID != ping.InstallID {
|
|
t.Errorf("install_id = %q, want %q", lastPing.InstallID, ping.InstallID)
|
|
}
|
|
if lastPing.Event != "startup" {
|
|
t.Errorf("event = %q, want %q", lastPing.Event, "startup")
|
|
}
|
|
if lastPing.Version != "6.0.0-rc.1" {
|
|
t.Errorf("version = %q, want %q", lastPing.Version, "6.0.0-rc.1")
|
|
}
|
|
if lastPing.VersionRaw != "v6.0.0-rc.1" {
|
|
t.Errorf("version_raw = %q, want %q", lastPing.VersionRaw, "v6.0.0-rc.1")
|
|
}
|
|
if lastPing.VersionChannel != "rc" {
|
|
t.Errorf("version_channel = %q, want rc", lastPing.VersionChannel)
|
|
}
|
|
if !lastPing.VersionPublished {
|
|
t.Error("expected version_is_published_release to be true")
|
|
}
|
|
}
|
|
|
|
func TestSend_UsesReducedCommercialSignals(t *testing.T) {
|
|
var rawBody []byte
|
|
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
rawBody, _ = io.ReadAll(r.Body)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
origEndpoint := pingEndpoint
|
|
pingEndpoint = ts.URL
|
|
defer func() { pingEndpoint = origEndpoint }()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := send(ctx, Ping{
|
|
InstallID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
Version: "6.0.0",
|
|
Event: "heartbeat",
|
|
Platform: "binary",
|
|
OS: "linux",
|
|
Arch: "amd64",
|
|
PaidLicense: true,
|
|
HasAPITokens: true,
|
|
}); err != nil {
|
|
t.Fatalf("send: %v", err)
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(rawBody, &payload); err != nil {
|
|
t.Fatalf("Unmarshal: %v", err)
|
|
}
|
|
if _, ok := payload["license_tier"]; ok {
|
|
t.Fatal("legacy license_tier field should not be sent")
|
|
}
|
|
if _, ok := payload["api_tokens"]; ok {
|
|
t.Fatal("legacy api_tokens field should not be sent")
|
|
}
|
|
if got, ok := payload["paid_license"].(bool); !ok || !got {
|
|
t.Fatalf("paid_license = %#v, want true", payload["paid_license"])
|
|
}
|
|
if got, ok := payload["has_api_tokens"].(bool); !ok || !got {
|
|
t.Fatalf("has_api_tokens = %#v, want true", payload["has_api_tokens"])
|
|
}
|
|
}
|
|
|
|
func TestSend_ReturnsNonSuccessStatus(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "rejected", http.StatusUnprocessableEntity)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
origEndpoint := pingEndpoint
|
|
pingEndpoint = ts.URL
|
|
defer func() { pingEndpoint = origEndpoint }()
|
|
|
|
err := send(context.Background(), Ping{InstallID: uuid.New().String()})
|
|
if err == nil || !strings.Contains(err.Error(), "HTTP 422") {
|
|
t.Fatalf("send error = %v, want HTTP 422", err)
|
|
}
|
|
}
|
|
|
|
func TestJitteredHeartbeat_WithinBounds(t *testing.T) {
|
|
min := heartbeatInterval - maxHeartbeatJitter
|
|
max := heartbeatInterval + maxHeartbeatJitter
|
|
|
|
for i := 0; i < 1000; i++ {
|
|
d := jitteredHeartbeat()
|
|
if d < min || d > max {
|
|
t.Fatalf("jitteredHeartbeat() = %v, want [%v, %v]", d, min, max)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestJitteredHeartbeat_NotConstant(t *testing.T) {
|
|
seen := make(map[time.Duration]bool)
|
|
for i := 0; i < 100; i++ {
|
|
seen[jitteredHeartbeat()] = true
|
|
}
|
|
if len(seen) < 2 {
|
|
t.Fatal("jitteredHeartbeat() returned the same value 100 times — jitter is not working")
|
|
}
|
|
}
|
|
|
|
func TestSendEvent_SuppressedWhileMockModeEnabled(t *testing.T) {
|
|
var received atomic.Int32
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
received.Add(1)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
origEndpoint := pingEndpoint
|
|
pingEndpoint = ts.URL
|
|
defer func() { pingEndpoint = origEndpoint }()
|
|
|
|
testutil.SetMockMode(t, true)
|
|
|
|
cfg := Config{DataDir: t.TempDir()}
|
|
sendEvent(context.Background(), cfg, "startup")
|
|
sendEvent(context.Background(), cfg, "heartbeat")
|
|
|
|
if got := received.Load(); got != 0 {
|
|
t.Fatalf("expected no telemetry pings while mock mode is enabled, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestSendEvent_SendsWhenMockModeDisabled(t *testing.T) {
|
|
var received atomic.Int32
|
|
var lastPing Ping
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(body, &lastPing)
|
|
received.Add(1)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
origEndpoint := pingEndpoint
|
|
pingEndpoint = ts.URL
|
|
defer func() { pingEndpoint = origEndpoint }()
|
|
|
|
testutil.SetMockMode(t, false)
|
|
|
|
sendEvent(context.Background(), Config{DataDir: t.TempDir()}, "heartbeat")
|
|
|
|
if got := received.Load(); got != 1 {
|
|
t.Fatalf("expected 1 telemetry ping with mock mode disabled, got %d", got)
|
|
}
|
|
if lastPing.Event != "heartbeat" {
|
|
t.Errorf("event = %q, want %q", lastPing.Event, "heartbeat")
|
|
}
|
|
}
|
|
|
|
func TestStartStop_DisabledByDefault(t *testing.T) {
|
|
// Start should be a no-op when Enabled is false (the default).
|
|
Start(context.Background(), Config{
|
|
Version: "6.0.0",
|
|
DataDir: t.TempDir(),
|
|
Enabled: false,
|
|
})
|
|
|
|
// Stop should also be safe when nothing was started.
|
|
Stop()
|
|
}
|
|
|
|
func decodeInstallIDRecordFile(t *testing.T, path string) installIDRecord {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s): %v", path, err)
|
|
}
|
|
return decodeInstallIDRecord(t, data)
|
|
}
|
|
|
|
func decodeInstallIDRecord(t *testing.T, data []byte) installIDRecord {
|
|
t.Helper()
|
|
var record installIDRecord
|
|
if err := json.Unmarshal(data, &record); err != nil {
|
|
t.Fatalf("Unmarshal install ID record: %v", err)
|
|
}
|
|
return record
|
|
}
|
|
|
|
// The node connection test counts are only useful if they leave the install, so
|
|
// assert the snapshot-to-ping mapping rather than trusting field order.
|
|
func TestBuildPingCarriesNodeTestCounts(t *testing.T) {
|
|
ping := BuildPingForSnapshot(Snapshot{
|
|
NodeTestAttempts30d: 9,
|
|
NodeTestFailures30d: 4,
|
|
})
|
|
if ping.NodeTestAttempts30d != 9 {
|
|
t.Fatalf("node_test_attempts_30d = %d, want 9", ping.NodeTestAttempts30d)
|
|
}
|
|
if ping.NodeTestFailures30d != 4 {
|
|
t.Fatalf("node_test_failures_30d = %d, want 4", ping.NodeTestFailures30d)
|
|
}
|
|
}
|