|
| 1 | +package telemetry |
| 2 | + |
| 3 | +import ( |
| 4 | + "encoding/json" |
| 5 | + "strings" |
| 6 | + "testing" |
| 7 | + "time" |
| 8 | + |
| 9 | + "github.com/denisbrodbeck/machineid" |
| 10 | + "go.uber.org/zap" |
| 11 | + |
| 12 | + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" |
| 13 | +) |
| 14 | + |
| 15 | +// isLowerHex reports whether s is a non-empty string of lowercase hex digits. |
| 16 | +func isLowerHex(s string) bool { |
| 17 | + if s == "" { |
| 18 | + return false |
| 19 | + } |
| 20 | + for _, r := range s { |
| 21 | + switch { |
| 22 | + case r >= '0' && r <= '9': |
| 23 | + case r >= 'a' && r <= 'f': |
| 24 | + default: |
| 25 | + return false |
| 26 | + } |
| 27 | + } |
| 28 | + return true |
| 29 | +} |
| 30 | + |
| 31 | +// newMachineIDTestService builds a minimal telemetry Service suitable for |
| 32 | +// exercising BuildPayload in machine-id tests. |
| 33 | +func newMachineIDTestService(t *testing.T) *Service { |
| 34 | + t.Helper() |
| 35 | + cfg := &config.Config{ |
| 36 | + Telemetry: &config.TelemetryConfig{ |
| 37 | + AnonymousID: "fixed-id", |
| 38 | + AnonymousIDCreatedAt: time.Now().UTC().Format(time.RFC3339), |
| 39 | + }, |
| 40 | + } |
| 41 | + return New(cfg, "", "v1.2.3", "personal", zap.NewNop()) |
| 42 | +} |
| 43 | + |
| 44 | +// TestMachineID_PresentAndStable asserts the machine_id field is populated and |
| 45 | +// identical across two payload builds on the same machine. |
| 46 | +func TestMachineID_PresentAndStable(t *testing.T) { |
| 47 | + const fixed = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" |
| 48 | + prev := machineIDProvider |
| 49 | + machineIDProvider = func() string { return fixed } |
| 50 | + resetMachineIDForTest() |
| 51 | + defer func() { |
| 52 | + machineIDProvider = prev |
| 53 | + resetMachineIDForTest() |
| 54 | + }() |
| 55 | + |
| 56 | + svc := newMachineIDTestService(t) |
| 57 | + |
| 58 | + p1 := svc.BuildPayload() |
| 59 | + p2 := svc.BuildPayload() |
| 60 | + |
| 61 | + if p1.MachineID == "" { |
| 62 | + t.Fatalf("machine_id is empty, want %q", fixed) |
| 63 | + } |
| 64 | + if p1.MachineID != fixed { |
| 65 | + t.Errorf("machine_id = %q, want %q", p1.MachineID, fixed) |
| 66 | + } |
| 67 | + if p1.MachineID != p2.MachineID { |
| 68 | + t.Errorf("machine_id not stable across builds: %q != %q", p1.MachineID, p2.MachineID) |
| 69 | + } |
| 70 | +} |
| 71 | + |
| 72 | +// TestMachineID_OmittedWhenUnavailable asserts that when the OS machine id |
| 73 | +// cannot be read, the field is empty and omitted from the serialized JSON |
| 74 | +// (never failing the heartbeat). |
| 75 | +func TestMachineID_OmittedWhenUnavailable(t *testing.T) { |
| 76 | + prev := machineIDProvider |
| 77 | + machineIDProvider = func() string { return "" } |
| 78 | + resetMachineIDForTest() |
| 79 | + defer func() { |
| 80 | + machineIDProvider = prev |
| 81 | + resetMachineIDForTest() |
| 82 | + }() |
| 83 | + |
| 84 | + svc := newMachineIDTestService(t) |
| 85 | + payload := svc.BuildPayload() |
| 86 | + |
| 87 | + if payload.MachineID != "" { |
| 88 | + t.Errorf("machine_id = %q, want empty when unavailable", payload.MachineID) |
| 89 | + } |
| 90 | + |
| 91 | + data, err := json.Marshal(payload) |
| 92 | + if err != nil { |
| 93 | + t.Fatalf("marshal failed: %v", err) |
| 94 | + } |
| 95 | + if strings.Contains(string(data), "machine_id") { |
| 96 | + t.Errorf("empty machine_id should be omitted from JSON, got: %s", data) |
| 97 | + } |
| 98 | +} |
| 99 | + |
| 100 | +// TestResolveMachineID_CachedOncePerProcess asserts resolveMachineID probes the |
| 101 | +// provider at most once and caches the result. |
| 102 | +func TestResolveMachineID_CachedOncePerProcess(t *testing.T) { |
| 103 | + calls := 0 |
| 104 | + prev := machineIDProvider |
| 105 | + machineIDProvider = func() string { |
| 106 | + calls++ |
| 107 | + return "cached-value" |
| 108 | + } |
| 109 | + resetMachineIDForTest() |
| 110 | + defer func() { |
| 111 | + machineIDProvider = prev |
| 112 | + resetMachineIDForTest() |
| 113 | + }() |
| 114 | + |
| 115 | + _ = resolveMachineID() |
| 116 | + _ = resolveMachineID() |
| 117 | + _ = resolveMachineID() |
| 118 | + |
| 119 | + if calls != 1 { |
| 120 | + t.Errorf("machineIDProvider called %d times, want 1 (result must be cached)", calls) |
| 121 | + } |
| 122 | +} |
| 123 | + |
| 124 | +// TestProtectedMachineID_NotRawAndHex verifies the production hash is a |
| 125 | +// lowercase-hex string, is not the raw OS machine id, and is scoped to the app |
| 126 | +// key (differs from the raw id and from a bare hash of another app key). Skips |
| 127 | +// when the OS machine id is unreadable (e.g. CI containers without |
| 128 | +// /etc/machine-id) — the graceful-fallback path is covered separately. |
| 129 | +func TestProtectedMachineID_NotRawAndHex(t *testing.T) { |
| 130 | + raw, err := machineid.ID() |
| 131 | + if err != nil || raw == "" { |
| 132 | + t.Skipf("OS machine id unavailable on this host (%v); skipping raw-comparison", err) |
| 133 | + } |
| 134 | + |
| 135 | + got := protectedMachineID() |
| 136 | + if got == "" { |
| 137 | + t.Fatal("protectedMachineID returned empty despite a readable OS machine id") |
| 138 | + } |
| 139 | + if !isLowerHex(got) { |
| 140 | + t.Errorf("protectedMachineID = %q, want lowercase hex", got) |
| 141 | + } |
| 142 | + // HMAC-SHA256 hex is 64 chars. |
| 143 | + if len(got) != 64 { |
| 144 | + t.Errorf("protectedMachineID length = %d, want 64 (SHA-256 hex)", len(got)) |
| 145 | + } |
| 146 | + if strings.Contains(got, raw) || got == raw { |
| 147 | + t.Errorf("protectedMachineID leaked the raw machine id") |
| 148 | + } |
| 149 | +} |
| 150 | + |
| 151 | +// TestProtectedMachineID_RawNeverInPayload builds a real payload (production |
| 152 | +// provider) and asserts the raw OS machine id never appears in the serialized |
| 153 | +// JSON, while the hashed machine_id does. Skips when the raw id is unreadable. |
| 154 | +func TestProtectedMachineID_RawNeverInPayload(t *testing.T) { |
| 155 | + raw, err := machineid.ID() |
| 156 | + if err != nil || raw == "" { |
| 157 | + t.Skipf("OS machine id unavailable on this host (%v); skipping", err) |
| 158 | + } |
| 159 | + |
| 160 | + // Ensure the production provider is in effect and the cache is fresh. |
| 161 | + resetMachineIDForTest() |
| 162 | + defer resetMachineIDForTest() |
| 163 | + |
| 164 | + svc := newMachineIDTestService(t) |
| 165 | + payload := svc.BuildPayload() |
| 166 | + |
| 167 | + data, err := json.Marshal(payload) |
| 168 | + if err != nil { |
| 169 | + t.Fatalf("marshal failed: %v", err) |
| 170 | + } |
| 171 | + js := string(data) |
| 172 | + |
| 173 | + if strings.Contains(js, raw) { |
| 174 | + t.Errorf("PRIVACY VIOLATION: payload contains the raw machine id") |
| 175 | + } |
| 176 | + if payload.MachineID != "" && !strings.Contains(js, payload.MachineID) { |
| 177 | + t.Errorf("hashed machine_id missing from serialized payload") |
| 178 | + } |
| 179 | +} |
0 commit comments