Files
heicode/heicode/controller/heicode_telemetry_test.go
T
chenchenandClaude Opus 4.8 2e37495133 feat(telemetry): retention purge + context field whitelist + size caps (#32)
Telemetry up-gating hardening (code portion of #32):

- Context field whitelist: telemetry `context` is filtered to a small set of
  non-content diagnostic keys (route/retryable/phase/exit_code/duration_ms/
  attempt) before persistence. Unknown keys — including potentially identifying
  ones (email, full file path, prompt, raw IP) — are dropped, so a client
  regression cannot land arbitrary JSON in the store. Empty/unparseable/no-allowed-key
  context is dropped to "".
- Per-field size cap: stack_top and context are truncated to 8KiB after
  redaction (backstop against unbounded blobs within batch limits).
- Retention: daily master-only task deletes telemetry rows older than
  HEICODE_TELEMETRY_RETENTION_DAYS (default 30; <=0 disables).
  HEICODE_TELEMETRY_RETENTION_INTERVAL_HOURS (default 24) sets cadence.
  model.DeleteTelemetryEventsBefore(cutoff) + controller.StartTelemetryRetentionTask()
  wired into main.go under IsMasterNode.
- GET /api/heicode/config telemetry block now surfaces retention_days for
  client/admin transparency.

Tests: whitelist drop/keep, size cap, redaction-within-allowed-key. go build/vet
clean; controller telemetry tests pass.

Affects: Manager only (telemetry ingest + retention). No billing/consume-log
change (telemetry still never bills). Privacy-doc disclosure + production
enable-checklist portions of #32 tracked in heicodeDocs sync (#34) / desktop
client API docs (#35).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:40:23 +08:00

110 lines
5.3 KiB
Go

package controller
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
// Uses the client's authoritative simulation batch (winos#23 §4) verbatim as the
// ingest fixture, so HM parsing/mapping stays in lock-step with what the client
// actually sends (#24).
const telemetryClientFixtureBatch = `[
{"client_id":"d3f1c0a2-7b44-4e19-9a8c-2f6b1e0c5a77","app_version":"0.5.0","platform":"win32","os_version":"10.0.26100","arch":"x64","locale":"zh-CN","error_category":"ui_crash","error_code":"RENDERER_ERROR","error_message_hash":"9f2a7c1b4e8d","stack_hash":"a1b2c3d4e5f6","stack_top":["at MessageList (MessageList.tsx:212:9)","at renderWithHooks (react-dom.production.min.js:0:0)"],"context":{"route":"chat"},"timestamp":"2026-06-09T07:21:33.123Z","session_seq":1},
{"client_id":"d3f1c0a2-7b44-4e19-9a8c-2f6b1e0c5a77","app_version":"0.0.0-heicode-local","platform":"win32","os_version":"10.0.26100","arch":"x64","locale":"zh-CN","error_category":"cli_startup_failed","error_code":"CLI_STARTUP_TIMEOUT","error_message_hash":"5c8e1f0a9b2d","stack_hash":"000000000000","stack_top":[],"context":{"retryable":true},"timestamp":"2026-06-09T07:22:01.880Z","session_seq":2}
]`
func TestParseTelemetryBatch_ClientFixture(t *testing.T) {
events, err := parseTelemetryBatch([]byte(telemetryClientFixtureBatch))
require.NoError(t, err)
require.Len(t, events, 2)
require.Equal(t, "d3f1c0a2-7b44-4e19-9a8c-2f6b1e0c5a77", events[0].ClientId)
require.Equal(t, "ui_crash", events[0].ErrorCategory)
require.Equal(t, "9f2a7c1b4e8d", events[0].ErrorMessageHash)
require.Len(t, events[0].StackTop, 2)
require.Equal(t, 1, events[0].SessionSeq)
// sentinel app_version is parsed as-is (must be tolerated on ingest)
require.Equal(t, "0.0.0-heicode-local", events[1].AppVersion)
}
func TestTelemetryToModel_MappingAndDefaults(t *testing.T) {
events, err := parseTelemetryBatch([]byte(telemetryClientFixtureBatch))
require.NoError(t, err)
const uid, dev, now = 4242, "d3f1c0a2-7b44-4e19-9a8c-2f6b1e0c5a77", int64(1700000000)
m0 := events[0].toModel(uid, dev, now)
require.Equal(t, uid, m0.UserId) // device -> account link recorded
require.Equal(t, dev, m0.ClientId)
require.EqualValues(t, now, m0.ReceivedAt) // server time, not client
require.Equal(t, "ui_crash", m0.ErrorCategory)
require.Equal(t, 1, m0.SchemaVersion) // missing schema_version defaults to 1
require.JSONEq(t, `{"route":"chat"}`, m0.ContextJSON)
require.JSONEq(t, `["at MessageList (MessageList.tsx:212:9)","at renderWithHooks (react-dom.production.min.js:0:0)"]`, m0.StackTopJSON)
require.Equal(t, "2026-06-09T07:21:33.123Z", m0.EventTimestamp)
// empty client_id falls back to the verified device id
ev := telemetryEventIn{ClientId: "", SchemaVersion: 0}
m := ev.toModel(uid, dev, now)
require.Equal(t, dev, m.ClientId)
require.Equal(t, 1, m.SchemaVersion)
require.Equal(t, "", m.StackTopJSON)
require.Equal(t, "", m.ContextJSON)
}
func TestParseTelemetryBatch_RejectsNonArray(t *testing.T) {
_, err := parseTelemetryBatch([]byte(`{"events":[]}`)) // wrapped object, not the contract
require.Error(t, err)
}
// Defense-in-depth (#24 review §3): even if the client failed to sanitize, the
// server must strip plaintext secrets from stack_top / context before storing.
func TestTelemetryToModel_RedactsSecrets(t *testing.T) {
ev := telemetryEventIn{
ClientId: "dev-1",
StackTop: []string{"at boom (auth.ts) key=sk-abcDEF1234567890"},
// "route" is whitelisted (#32) so it survives the field filter; redaction
// must still strip the Bearer token carried inside an allowed key.
Context: json.RawMessage(`{"route":"Authorization: Bearer aZ09tokenVALUE","retryable":true}`),
}
m := ev.toModel(7, "dev-1", 1700000000)
require.NotContains(t, m.StackTopJSON, "sk-abcDEF1234567890", "sk- secret must be redacted in stack_top")
require.Contains(t, m.StackTopJSON, "REDACTED")
require.NotContains(t, m.ContextJSON, "aZ09tokenVALUE", "Bearer token must be redacted in context")
require.Contains(t, m.ContextJSON, "retryable") // non-secret whitelisted content preserved
}
// #32: context must be restricted to a key whitelist so a client regression
// cannot land arbitrary/identifying JSON in the telemetry store.
func TestFilterTelemetryContext_Whitelist(t *testing.T) {
// allowed keys kept, unknown keys (incl. potentially identifying) dropped
out := filterTelemetryContext(json.RawMessage(
`{"route":"chat","retryable":true,"email":"a@b.com","file":"C:/Users/x/secret.go","prompt":"hi"}`))
require.Contains(t, out, "route")
require.Contains(t, out, "retryable")
require.NotContains(t, out, "email")
require.NotContains(t, out, "a@b.com")
require.NotContains(t, out, "secret.go")
require.NotContains(t, out, "prompt")
// no allowed keys -> dropped entirely
require.Equal(t, "", filterTelemetryContext(json.RawMessage(`{"email":"a@b.com"}`)))
// non-object / malformed -> dropped
require.Equal(t, "", filterTelemetryContext(json.RawMessage(`"a string"`)))
require.Equal(t, "", filterTelemetryContext(json.RawMessage(`not json`)))
require.Equal(t, "", filterTelemetryContext(nil))
}
// #32: per-field size cap is a backstop against unbounded blobs.
func TestCapTelemetryField(t *testing.T) {
require.Equal(t, "short", capTelemetryField("short"))
big := make([]byte, telemetryMaxFieldBytes+100)
for i := range big {
big[i] = 'a'
}
require.Len(t, capTelemetryField(string(big)), telemetryMaxFieldBytes)
}