//! The tests are the contract. //! //! Two of them are load-bearing beyond ordinary coverage: //! `every_payload_field_is_bounded` walks a fully-populated batch and asserts //! that every string leaf is a member of a declared enum set or one of exactly //! three regexed strings, and `every_string_leaf_survives_redaction_unchanged` //! runs the workflow crate's disclosure redactor over each leaf **individually** //! — never over the serialized document, which would be one whitespace-free //! token and would report clean no matter what it contained. use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use codewhale_config::{ CliRuntimeOverrides, ConfigToml, ResolvedRuntimeOptions, SetupState, TELEMETRY_NOTICE_VERSION, }; use serde_json::Value; use crate::buffer; use crate::decision::{ EndpointError, TelemetryDecision, decide_in_home, load_setup_state_for_decision_at, permission_still_enabled_in_home, re_decide_with_setup_path, validate_endpoint, }; use crate::envelope; use crate::event::*; // ---------------------------------------------------------------- fixtures -- fn temp_home() -> tempfile::TempDir { tempfile::tempdir().expect("temp home") } fn root_of(home: &tempfile::TempDir) -> PathBuf { home.path().join(crate::TELEMETRY_DIR) } /// A resolved-options value with everything but telemetry left at its default. fn resolved(telemetry: bool, explicit_off: bool, endpoint: Option<&str>) -> ResolvedRuntimeOptions { let mut options = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); options.telemetry = telemetry; options.telemetry_explicit_off = explicit_off; options.telemetry_endpoint = endpoint.map(str::to_string); options } fn accepted_setup() -> SetupState { let mut setup = SetupState::default(); setup.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, true); setup } fn declined_setup() -> SetupState { let mut setup = SetupState::default(); setup.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, false); setup } fn stale_setup() -> SetupState { let mut setup = SetupState::default(); setup.record_telemetry_notice("0", true); setup } #[test] fn setup_state_loader_defaults_only_when_the_privacy_record_is_absent() { let home = temp_home(); let path = home.path().join("setup_state.json"); assert!( load_setup_state_for_decision_at(&path).is_some(), "a genuinely fresh install uses the documented default" ); std::fs::write(&path, b"{not-json").expect("write corrupt setup state"); assert!( load_setup_state_for_decision_at(&path).is_none(), "an existing unreadable privacy record must fail closed" ); accepted_setup() .save_to(&path) .expect("write valid setup state"); assert!( load_setup_state_for_decision_at(&path) .is_some_and(|setup| setup.telemetry_accepted(TELEMETRY_NOTICE_VERSION)), "a valid setup state remains usable" ); } #[test] fn flush_redecision_fails_closed_on_a_corrupt_setup_state() { let home = temp_home(); let config_path = home.path().join("config.toml"); let setup_path = home.path().join("setup_state.json"); std::fs::write(&config_path, "telemetry = true\n").expect("write config"); std::fs::write(&setup_path, b"{not-json").expect("write corrupt setup state"); assert!(matches!( re_decide_with_setup_path(Some(&config_path), &setup_path, Surface::Exec), TelemetryDecision::ForcedOff )); } /// One instance of every event variant, populated with the most adversarial /// values the schema permits. /// /// **This list is hand-written, and the compiler cannot make you extend it.** /// A new `Event` variant carrying a free-form `String` would be walked by none /// of the red-line tests below — they all start here — and `golden_payload_v1` /// would still pass, because it serializes this same fixture. Nothing closes /// that hole from inside this file; enumerating an enum's variants needs /// reflection this workspace deliberately does not depend on. What does bite is /// [`Event::is_bounded`], whose `match self` is exhaustive: adding a variant /// fails the build until its author states a bound. If you are that author, /// add the variant here too. fn every_event() -> Vec { vec![ Event::InstallOrUpgrade { kind: InstallKind::Upgrade, previous_version: Some("0.9.3-rc.1".to_string()), }, Event::SessionStart { source: SessionSource::Resume, }, Event::SessionEnd { duration_bucket: DurationBucket::OneToTen, exit_class: ExitClass::Panic, cold_start_bucket: Some(ColdStartBucket::Mid), providers: vec!["custom".to_string(), "deepseek".to_string()], counters: Counters { turns: 14, tool_calls: 61, fleet_dispatch: 0, workflow_run: 0, subagent_spawn: 2, mcp_server_connected: 0, memory_search: 0, approval_modal_shown: 0, approval_auto_allowed: 0, command_palette_open: 3, }, errors: Errors { auth_preflight_failed: 0, provider_http_4xx: 0, provider_http_5xx: 1, tool_denied_by_policy: 0, tool_timeout: 0, network_error: 0, }, turn_wall: TurnWall { lt_5s: 9, five_to_thirty: 4, thirty_to_onetwenty: 1, gte_120s: 0, }, }, Event::Panic { site: "crates/tui/src/tui/ui.rs:8801:17".to_string(), }, Event::ProductUsage { counters: ProductCounters { page_view: 1, ..ProductCounters::default() }, }, Event::OperationsSummary { requests: 10, errors: 1, duration_ms_total: 1500, duration_ms_max: 300, probes: 2, probes_failed: 0, }, Event::ModelUsage { provider: "deepseek".to_string(), model: "other".to_string(), counters: crate::event::ModelCounters { turns_ok: 12, turns_failed: 1, turns_interrupted: 0, input_tokens: 418_230, output_tokens: 9_120, cache_read_tokens: 380_001, cache_write_tokens: 0, }, turn_wall: TurnWall { lt_5s: 3, five_to_thirty: 8, thirty_to_onetwenty: 2, gte_120s: 0, }, }, Event::ToolUsage { tool: "read_file".to_string(), calls: 41, errors: 2, }, ] } /// A fully-populated batch. This is the artifact the red-line tests walk. pub(crate) fn every_field_batch() -> Batch { Batch { schema_version: SCHEMA_VERSION, notice_version: NOTICE_VERSION, sent_at: "2026-08-03T18:04:11Z".to_string(), install_id: "3f2a9c1e-0000-4000-8000-000000000001".to_string(), app_version: "0.9.4".to_string(), git_sha: Some("abcdef012345".to_string()), surface: Surface::ControlPlane, os: Os::Macos, arch: Arch::Aarch64, libc: Libc::None, tty: true, events: every_event(), } } // --------------------------------------------------------- schema red lines -- fn walk_strings(value: &Value, path: &str, out: &mut Vec<(String, String)>) { match value { Value::String(text) => out.push((path.to_string(), text.clone())), Value::Array(items) => { for (index, item) in items.iter().enumerate() { walk_strings(item, &format!("{path}[{index}]"), out); } } Value::Object(map) => { for (key, item) in map { let child = if path.is_empty() { key.clone() } else { format!("{path}.{key}") }; walk_strings(item, &child, out); } } _ => {} } } pub(crate) fn string_leaves(value: &Value) -> Vec<(String, String)> { let mut out = Vec::new(); walk_strings(value, "", &mut out); out } // The version and panic-site rules are the shipped predicates, not local // copies. A test that re-implements the rule it is checking passes against a // binary that enforces nothing — which is exactly what // `a_hostile_buffer_line_never_reaches_a_batch` found the first time. use crate::event::{is_reduced_panic_site, is_release_version_string as is_app_version}; fn is_short_sha(value: &str) -> bool { value.len() == 12 && value .bytes() .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) } /// Every closed-enum string this schema may ever emit. fn closed_enum_values() -> Vec { let mut values: Vec = Vec::new(); values.extend(Surface::ALL.iter().map(|v| v.as_str().to_string())); values.extend(Os::ALL.iter().map(|v| v.as_str().to_string())); values.extend(Arch::ALL.iter().map(|v| v.as_str().to_string())); values.extend(Libc::ALL.iter().map(|v| v.as_str().to_string())); values.extend(InstallKind::ALL.iter().map(|v| v.as_str().to_string())); values.extend(SessionSource::ALL.iter().map(|v| v.as_str().to_string())); values.extend(DurationBucket::ALL.iter().map(|v| v.as_str().to_string())); values.extend(ExitClass::ALL.iter().map(|v| v.as_str().to_string())); values.extend(ColdStartBucket::ALL.iter().map(|v| v.as_str().to_string())); // The `event` tag values. values.extend(every_event().iter().map(|e| e.name().to_string())); // `providers` entries: `ProviderKind::as_str()` is a `&'static str` from a // closed enum, and `Custom` yields the literal "custom". values.extend( codewhale_config::ProviderKind::all() .iter() .map(|kind| kind.as_str().to_string()), ); values } #[test] fn every_payload_field_is_bounded() { let batch = every_field_batch(); let json = serde_json::to_value(&batch).expect("serialize batch"); let enums = closed_enum_values(); let leaves = string_leaves(&json); assert!( leaves.len() >= 10, "the walk found suspiciously few string leaves: {leaves:?}" ); for (path, value) in leaves { let ok = match path.as_str() { "app_version" => is_app_version(&value), "git_sha" => is_short_sha(&value), "sent_at" => value.ends_with('Z') && value.len() == 20, "install_id" => uuid::Uuid::parse_str(&value).is_ok(), p if p.ends_with(".site") => is_reduced_panic_site(&value), p if p.ends_with(".previous_version") => is_app_version(&value), p if p.ends_with(".tool") => is_reported_tool_name(&value), p if p.ends_with(".model") => is_reported_model_id(&value), _ => enums.contains(&value), }; assert!(ok, "unbounded string leaf at {path}: {value:?}"); } } #[test] fn counters_and_errors_serialize_every_field_including_zeros() { let json = serde_json::to_value(Counters::default()).expect("serialize counters"); let object = json.as_object().expect("counters is an object"); assert_eq!(object.len(), Counters::FIELDS.len()); for field in Counters::FIELDS { assert_eq!(object.get(*field), Some(&Value::from(0u32)), "{field}"); } let json = serde_json::to_value(Errors::default()).expect("serialize errors"); let object = json.as_object().expect("errors is an object"); assert_eq!(object.len(), Errors::FIELDS.len()); for field in Errors::FIELDS { assert_eq!(object.get(*field), Some(&Value::from(0u32)), "{field}"); } let json = serde_json::to_value(TurnWall::default()).expect("serialize turn_wall"); let object = json.as_object().expect("turn_wall is an object"); assert_eq!(object.len(), TurnWall::FIELDS.len()); for field in TurnWall::FIELDS { assert_eq!(object.get(*field), Some(&Value::from(0u32)), "{field}"); } } #[test] fn no_event_field_is_ever_omitted() { // Options serialize as `null` rather than being skipped, so the key set on // the wire is closed and the doc-match test can be exact. let event = Event::SessionEnd { duration_bucket: DurationBucket::Lt1m, exit_class: ExitClass::Clean, cold_start_bucket: None, providers: Vec::new(), counters: Counters::default(), errors: Errors::default(), turn_wall: TurnWall::default(), }; let json = serde_json::to_value(&event).expect("serialize"); assert_eq!(json.get("cold_start_bucket"), Some(&Value::Null)); let event = Event::InstallOrUpgrade { kind: InstallKind::Install, previous_version: None, }; let json = serde_json::to_value(&event).expect("serialize"); assert_eq!(json.get("previous_version"), Some(&Value::Null)); } // ------------------------------------------------------ scrubber assertions -- /// Run the workflow crate's disclosure redactor over **each string leaf**. /// /// Never over the serialized document: `redact_for_disclosure` tokenizes with /// `input.split(' ')`, and a compact `serde_json` batch has no spaces, so the /// whole document would be one token and every classifier would fail on it. A /// batch containing an absolute path, a live-looking key, and a whole prompt /// would report clean. The gate would detect nothing while appearing to pass. fn redaction_kinds_over_leaves(json: &Value) -> Vec { let mut kinds = Vec::new(); for (_, value) in string_leaves(json) { let redaction = codewhale_workflow::redaction::redact_for_disclosure(&value); if redaction.redacted() { kinds.extend(redaction.kinds()); } } kinds } #[test] fn every_string_leaf_survives_redaction_unchanged() { let json = serde_json::to_value(every_field_batch()).expect("serialize batch"); let kinds = redaction_kinds_over_leaves(&json); assert!( kinds.is_empty(), "a real payload tripped the disclosure redactor: {kinds:?}" ); } #[test] fn redaction_catches_a_planted_absolute_path() { let mut json = serde_json::to_value(every_field_batch()).expect("serialize batch"); json["app_version"] = Value::from("/Users/hunter/src/app/main.rs"); let kinds = redaction_kinds_over_leaves(&json); assert!( kinds.iter().any(|k| k == "absolute_path"), "the negative control did not fire: {kinds:?}" ); } #[test] fn redaction_catches_a_planted_secret() { let mut json = serde_json::to_value(every_field_batch()).expect("serialize batch"); // Deliberately low-entropy: a realistic token in a fixture trips secret // scanners at push time. json["app_version"] = Value::from("api_key=sk-live-abcdef0123456789abcdef"); let kinds = redaction_kinds_over_leaves(&json); assert!( kinds.iter().any(|k| k == "secret"), "the negative control did not fire: {kinds:?}" ); } #[test] fn panic_site_is_the_only_field_that_may_carry_a_path() { // `panic_site` is a repo-relative path by design, so it is the one // documented exemption. Prove the redactor would flag such a value, and // that no other leaf in a real payload carries one. let planted = codewhale_workflow::redaction::redact_for_disclosure("crates/tui/src/main.rs"); assert!( planted.kinds().iter().any(|k| k == "relative_path"), "the redactor no longer classifies a repo-relative path: {:?}", planted.kinds() ); let json = serde_json::to_value(every_field_batch()).expect("serialize batch"); for (path, value) in string_leaves(&json) { if path.ends_with(".site") { continue; } let redaction = codewhale_workflow::redaction::redact_for_disclosure(&value); assert!( !redaction.kinds().iter().any(|k| k.ends_with("path")), "a non-exempt leaf carries a path: {path} = {value:?}" ); } } // ------------------------------------------- the drain path is a boundary -- /// The buffer is a **deserializer input**, not an internal channel. /// /// Every bound above is a property of how this process *builds* an event. /// `flush` re-reads `buffer.jsonl` and hands the lines to `serde`, and any /// process running as the user can append to that file — including a `Bash` /// tool call the session made on the model's behalf, since `$CODEWHALE_HOME` /// is a predictable path. Before `Event::is_bounded` existed, an appended /// `{"event":"panic","site":"…/Users/victim/secret-repo"}` was POSTed verbatim /// to the configured endpoint under the user's install id; the process-level /// proof of that is `a_hostile_buffer_line_never_reaches_a_batch` in /// `crates/tui/tests/telemetry_contract.rs`. #[test] fn hostile_buffer_lines_are_dropped_before_they_reach_a_batch() { let hostile = [ // A path, which is the class `panic_site` is the sole exemption for. r#"{"event":"panic","site":"/Users/victim/src/secret-repo/main.rs"}"#, // A whole prompt in the one field that is allowed to look like text. r#"{"event":"panic","site":"rewrite the auth module for acme-corp"}"#, // A frame outside the `crates/` allowlist, spelled to look inside it. r#"{"event":"panic","site":"../vendor/crates/foo/src/lib.rs:1:1"}"#, // `previous_version` is read back from `state.json`, never validated // at the point it is written. r#"{"event":"install_or_upgrade","kind":"upgrade","previous_version":"/Users/victim/.ssh/id_ed25519"}"#, // A customer's `[providers.]` table key — the exact string // `record_provider` takes a `ProviderKind` by value to avoid. r#"{"event":"session_end","duration_bucket":"lt_1m","exit_class":"clean","cold_start_bucket":null,"providers":["acme_internal_gateway"],"counters":{"turns":0,"tool_calls":0,"fleet_dispatch":0,"workflow_run":0,"subagent_spawn":0,"mcp_server_connected":0,"memory_search":0,"approval_modal_shown":0,"approval_auto_allowed":0,"command_palette_open":0},"errors":{"auth_preflight_failed":0,"provider_http_4xx":0,"provider_http_5xx":0,"tool_denied_by_policy":0,"tool_timeout":0,"network_error":0},"turn_wall":{"lt_5s":0,"5_30s":0,"30_120s":0,"gte_120s":0}}"#, ]; for line in hostile { let event = serde_json::from_str::(line) .unwrap_or_else(|error| panic!("the fixture must be parseable: {error}\n{line}")); assert!( !event.is_bounded(), "an out-of-bounds event passed the drain check: {line}" ); let parsed = crate::actor::parse_events(&[line.to_string()]); assert!( parsed.is_empty(), "a hostile buffer line survived the drain: {line}" ); } } /// The drain check must not delete real telemetry. Everything this process /// legitimately records has to survive a round trip through the buffer. #[test] fn every_legitimately_recorded_event_survives_the_drain() { let lines: Vec = every_event() .iter() .map(|event| serde_json::to_string(event).expect("serialize")) .collect(); assert_eq!( crate::actor::parse_events(&lines).len(), lines.len(), "the drain check dropped an event this process builds itself" ); // Dialect kinds (`deepseek-anthropic`, the Model Studio plan variants) are // absent from the selectable `ProviderKind::ALL` catalog subset, // but captured intrinsic kinds include them for real routes. Narrowing the // provider bound to the catalog would drop those users' `session_end`. for kind in [ codewhale_config::ProviderKind::DeepseekAnthropic, codewhale_config::ProviderKind::MinimaxAnthropic, codewhale_config::ProviderKind::Custom, ] { assert!( crate::event::is_known_provider_id(kind.as_str()), "a real routed provider is not a legal `providers` entry: {}", kind.as_str() ); } } /// `install_id` is the one envelope field read verbatim off disk into a batch. #[test] fn a_non_uuid_install_id_on_disk_is_replaced_rather_than_sent() { let home = temp_home(); let root = root_of(&home); buffer::ensure_dir(&root).expect("create telemetry root"); std::fs::write( buffer::install_id_path(&root), serde_json::json!({ "schema_version": 1, "install_id": "/Users/victim/src/secret-repo", "rotated_at": envelope::now_rfc3339(), }) .to_string(), ) .expect("plant a hostile install id"); let record = envelope::read_or_create_install_id(&root).expect("read install id"); assert!( uuid::Uuid::parse_str(&record.install_id).is_ok(), "a non-UUID install id was carried onto the wire: {:?}", record.install_id ); } /// Audit R05-09: only a canonical v4 id with a past `rotated_at` survives a /// read. A v1 (MAC + clock), nil, braced, uppercase, or padded id is replaced, /// and so is a future timestamp that would otherwise never rotate. #[test] fn a_non_canonical_or_future_dated_install_id_is_rotated() { let now = envelope::now_rfc3339(); let future = (chrono::Utc::now() + chrono::Duration::days(3650)) .to_rfc3339_opts(chrono::SecondsFormat::Secs, true); let canonical = "3f2a9c1e-0000-4000-8000-000000000001"; for (install_id, rotated_at) in [ ("6ba7b810-9dad-11d1-80b4-00c04fd430c8", now.as_str()), ("00000000-0000-0000-0000-000000000000", now.as_str()), ("{3f2a9c1e-0000-4000-8000-000000000001}", now.as_str()), ("3F2A9C1E-0000-4000-8000-000000000001", now.as_str()), (" 3f2a9c1e-0000-4000-8000-000000000001 ", now.as_str()), (canonical, future.as_str()), ] { let home = temp_home(); let root = root_of(&home); buffer::ensure_dir(&root).expect("create telemetry root"); std::fs::write( buffer::install_id_path(&root), serde_json::json!({ "schema_version": 1, "install_id": install_id, "rotated_at": rotated_at, }) .to_string(), ) .expect("plant install id"); let record = envelope::read_or_create_install_id(&root).expect("read install id"); assert_ne!(record.install_id, install_id, "kept {install_id:?}"); let minted = uuid::Uuid::parse_str(&record.install_id).expect("minted uuid"); assert_eq!(minted.get_version(), Some(uuid::Version::Random)); } // The canonical, past-dated form is kept. let home = temp_home(); let root = root_of(&home); buffer::ensure_dir(&root).expect("create telemetry root"); let kept = envelope::InstallId { schema_version: 1, install_id: canonical.to_string(), rotated_at: now.clone(), }; std::fs::write( buffer::install_id_path(&root), serde_json::to_string(&kept).unwrap(), ) .expect("plant canonical id"); let record = envelope::read_or_create_install_id(&root).expect("read install id"); assert_eq!(record.install_id, canonical); } // ------------------------------------------------------------- panic sites -- #[test] fn panic_site_reduces_dependency_frames() { assert_eq!( envelope::reduce_panic_site("crates/tui/src/x.rs", 9, 1), "crates/tui/src/x.rs:9:1" ); assert_eq!( envelope::reduce_panic_site( "/Users/builder/.cargo/registry/src/index.crates.io-1949cf8c/ratatui-0.29.0/src/y.rs", 1, 1 ), "" ); assert_eq!( envelope::reduce_panic_site("/rustc/deadbeef/library/core/src/panicking.rs", 1, 1), "" ); // A path that merely mentions `crates/` somewhere is not a `crates/` frame. assert_eq!( envelope::reduce_panic_site("../vendor/crates/foo/src/lib.rs", 1, 1), "" ); } #[test] fn git_sha_is_null_without_release_env() { // The build script emits `CODEWHALE_RELEASE_BUILD_SHA` only when // `CODEWHALE_BUILD_SHA` (or a build-only compatibility alias) was in the // build environment, so on a developer machine this is `None` and on // release CI it is twelve hex characters. Both are asserted, because the // test has to pass in both places and neither shape may ever be a path, a // version, or a full sha. // The rule that produces it lives in `codewhale-build-support` and is // tested there against an injected environment; what is asserted here is // that whatever reaches the payload is `null` or twelve lowercase hex // characters, and never a path, a version, or a full sha. if let Some(sha) = envelope::release_build_sha() { assert!( is_short_sha(&sha), "release sha has the wrong shape: {sha:?}" ); } assert_eq!( envelope::short_hex_sha("ABCDEF0123456789abcdef0123456789abcdef01"), Some("abcdef012345".to_string()) ); assert_eq!(envelope::short_hex_sha("not-a-sha"), None); assert_eq!(envelope::short_hex_sha("abc123"), None); } // --------------------------------------------------------------- decisions -- #[test] fn decision_matrix_is_exhaustive() { let home = temp_home(); let path = home.path(); // Row: nobody has said anything. No acceptance is inferred. assert!(matches!( decide_in_home( Some(path), &resolved(true, false, None), &SetupState::default(), Surface::Tui ), TelemetryDecision::Enabled(_) )); // Row: a human said off. That is an answer. assert!(matches!( decide_in_home( Some(path), &resolved(false, true, None), &accepted_setup(), Surface::Tui ), TelemetryDecision::OptedOut )); // Row: config on with no notice record also uses the default-on policy. assert!(matches!( decide_in_home( Some(path), &resolved(true, false, None), &SetupState::default(), Surface::Tui ), TelemetryDecision::Enabled(_) )); // Row: on, asked, declined. assert!(matches!( decide_in_home( Some(path), &resolved(true, false, None), &declined_setup(), Surface::Tui ), TelemetryDecision::OptedOut )); // Row: old explicit yes remains on under the current default-on policy. assert!(matches!( decide_in_home( Some(path), &resolved(true, false, None), &stale_setup(), Surface::Tui ), TelemetryDecision::Enabled(_) )); // Row: on and accepted, no home to keep state in. assert!(matches!( decide_in_home( None, &resolved(true, false, None), &accepted_setup(), Surface::Tui ), TelemetryDecision::ForcedOff )); // Row: on and accepted, plaintext endpoint to a public host. assert!(matches!( decide_in_home( Some(path), &resolved(true, false, Some("http://example.com/t")), &accepted_setup(), Surface::Tui ), TelemetryDecision::ForcedOff )); // Row: on and accepted, no endpoint — the dry-run sink, which resolution // reaches from an explicitly empty `telemetry_endpoint`. (The *shipped* // default is `DEFAULT_TELEMETRY_ENDPOINT`; this predicate never sees it, // because it reads an already-resolved value.) let decision = decide_in_home( Some(path), &resolved(true, false, None), &accepted_setup(), Surface::Exec, ); let TelemetryDecision::Enabled(consent) = decision else { panic!("an accepted, endpoint-less machine must be Enabled"); }; assert_eq!(consent.endpoint(), None); assert_eq!(consent.surface(), Surface::Exec); assert_eq!(consent.root(), root_of(&home)); // Row: on and accepted, https endpoint. assert!( decide_in_home( Some(path), &resolved(true, false, Some("https://example.com/t")), &accepted_setup(), Surface::Tui ) .is_enabled() ); // Row: every headless surface uses the same documented default and kill // switches. for surface in Surface::ALL { assert!( decide_in_home( Some(path), &resolved(true, false, None), &SetupState::default(), *surface ) .is_enabled(), "{surface:?} uses default-on without inferring acceptance" ); } } #[test] fn every_surface_spelling_parses_back_to_its_variant() { // `Surface::parse` is the only place a name becomes a variant, and an // embedder declares itself by name, so the round trip has to be exact in // both directions. A value that can be spelled must parse; a variant must // not be reachable under a second spelling. for surface in Surface::ALL { assert_eq!( Surface::parse(surface.as_str()), Some(*surface), "{surface:?} does not parse back from its own wire spelling" ); } assert_eq!( Surface::parse("TUI"), None, "parsing is exact, not case-folded" ); assert_eq!(Surface::parse(" vscode-extension "), None, "no trimming"); assert_eq!(Surface::parse("vscode"), None); assert_eq!(Surface::parse("ide"), None); assert_eq!(Surface::parse(""), None); assert_eq!(Surface::parse("desktop"), Some(Surface::Desktop)); } #[test] fn an_unparseable_env_value_forces_off_and_does_not_wipe() { // The floor in `codewhale-config` turns an unreadable `CODEWHALE_TELEMETRY` // into `telemetry == false` *without* setting `telemetry_explicit_off`. A // typo is not a user answer and must never destroy state. let home = temp_home(); let root = root_of(&home); buffer::ensure_dir(&root).expect("create root"); let buffer_path = buffer::buffer_path(&root); buffer::append( &root, &buffer_path, "{\"event\":\"session_start\",\"source\":\"api\"}", ) .expect("seed"); let decision = decide_in_home( Some(home.path()), &resolved(false, false, None), &accepted_setup(), Surface::Tui, ); assert!(matches!(decision, TelemetryDecision::ForcedOff)); assert!(!buffer::tombstone_present(&root)); assert_eq!(buffer::read_lines(&buffer_path).len(), 1); } #[test] fn only_opt_out_touches_disk() { // Every ForcedOff row against a seeded, consenting home must leave it // byte-identical. This is the finding that a "wipe on resolved false" would // have broken: `false` is the *default*, so it fired on every ordinary run. let forced_off_rows: Vec<(ResolvedRuntimeOptions, SetupState)> = vec![ (resolved(false, false, None), accepted_setup()), ( resolved(true, false, Some("http://example.com/t")), accepted_setup(), ), ]; for (options, setup) in forced_off_rows { let home = temp_home(); let root = root_of(&home); buffer::ensure_dir(&root).expect("create root"); let before = seed_consenting_home(&root); let decision = decide_in_home(Some(home.path()), &options, &setup, Surface::Tui); assert!( matches!(decision, TelemetryDecision::ForcedOff), "expected ForcedOff, got {}", decision.label() ); assert_eq!(snapshot(&root), before, "a ForcedOff run touched disk"); } // Every OptedOut row wipes: tombstone present, data truncated, lock file // still present, identity gone. let home = temp_home(); let root = root_of(&home); buffer::ensure_dir(&root).expect("create root"); seed_consenting_home(&root); let decision = decide_in_home( Some(home.path()), &resolved(false, true, None), &accepted_setup(), Surface::Tui, ); assert!(matches!(decision, TelemetryDecision::OptedOut)); assert!(buffer::tombstone_present(&root)); assert!(buffer::buffer_path(&root).exists()); assert!(buffer::read_lines(&buffer::buffer_path(&root)).is_empty()); assert!(buffer::read_lines(&buffer::dryrun_path(&root)).is_empty()); assert!( buffer::lock_path(&root).exists(), "the lock file must survive a wipe: unlinking it leaves appenders on a dead inode" ); assert!(!buffer::install_id_path(&root).exists()); assert!(!buffer::state_path(&root).exists()); } #[test] fn default_on_does_not_hide_a_durable_sidecar_decline() { let home = temp_home(); let root = root_of(&home); buffer::ensure_dir(&root).expect("create root"); let before = seed_consenting_home(&root); let mut options = resolved(false, false, None); // A run-scoped kill switch still preserves the preexisting ordering. options.telemetry_source = codewhale_config::TelemetrySource::Env; assert!(matches!( decide_in_home(Some(home.path()), &options, &declined_setup(), Surface::Tui), TelemetryDecision::ForcedOff )); assert_eq!(snapshot(&root), before); // Once that temporary switch is gone, the durable decline must wipe even // though the new shipped configuration preference is on. options.telemetry = true; options.telemetry_source = codewhale_config::TelemetrySource::Default; assert!(matches!( decide_in_home(Some(home.path()), &options, &declined_setup(), Surface::Tui), TelemetryDecision::OptedOut )); assert!(buffer::tombstone_present(&root)); assert!(!buffer::install_id_path(&root).exists()); assert!(buffer::read_lines(&buffer::buffer_path(&root)).is_empty()); } #[test] fn the_tombstone_outlives_every_run_the_opt_out_covers() { // `docs/TELEMETRY.md` says the opt-out's tombstone survives, and an // adversary showed it did not: one ordinary run afterwards called // `buffer::arm`, which removes the tombstone, and minted a fresh install // id. Both halves of that are now impossible, and for the same reason — // the opt-out is a *persisted* statement, so every later run re-reads it, // takes the OptedOut branch again, and never reaches arming at all. let home = temp_home(); let root = root_of(&home); buffer::ensure_dir(&root).expect("create root"); seed_consenting_home(&root); // The user writes `telemetry = false`. let opted_out = resolved(false, true, None); assert!(matches!( decide_in_home( Some(home.path()), &opted_out, &accepted_setup(), Surface::Tui ), TelemetryDecision::OptedOut )); assert!(buffer::tombstone_present(&root)); let after_wipe = snapshot(&root); // Three more launches of any surface, with the setting still in place. for surface in [Surface::Tui, Surface::Exec, Surface::AppServer] { let decision = decide_in_home(Some(home.path()), &opted_out, &accepted_setup(), surface); assert!( matches!(decision, TelemetryDecision::OptedOut), "{surface:?} re-read the opt-out as {}", decision.label() ); assert!( buffer::tombstone_present(&root), "{surface:?} cleared the tombstone" ); assert!( !buffer::install_id_path(&root).exists(), "{surface:?} minted a new identity for an opted-out machine" ); assert!(!buffer::state_path(&root).exists()); assert!(buffer::read_lines(&buffer::buffer_path(&root)).is_empty()); assert!(buffer::read_lines(&buffer::dryrun_path(&root)).is_empty()); assert_eq!(snapshot(&root), after_wipe, "{surface:?} touched disk"); } // Only writing the setting back turns collection on again, and that is the // one path allowed to clear the tombstone. let TelemetryDecision::Enabled(consent) = decide_in_home( Some(home.path()), &resolved(true, false, None), &accepted_setup(), Surface::Tui, ) else { panic!("an explicit re-enable must produce consent"); }; buffer::arm(&root, consent.tombstone_generation(), || true).expect("re-consent arms"); assert!(!buffer::tombstone_present(&root)); } #[test] fn a_run_scoped_kill_switch_costs_a_consenting_user_nothing() { // The documented one-command recipe — `CODEWHALE_TELEMETRY=0 codewhale` — // used to take the destructive opt-out branch, so it deleted the install // id and truncated the user's own dry-run records every time it was used. // The resolver now reports that as "off, but nobody revoked anything", and // this is the half of that contract the telemetry crate owns. let home = temp_home(); let root = root_of(&home); buffer::ensure_dir(&root).expect("create root"); let before = seed_consenting_home(&root); let identity = std::fs::read(buffer::install_id_path(&root)).expect("seeded install id"); for _ in 0..3 { let decision = decide_in_home( Some(home.path()), // `telemetry == false`, `telemetry_explicit_off == false`: the // shape a run-scoped kill switch resolves to. &resolved(false, false, None), &accepted_setup(), Surface::Exec, ); assert!(matches!(decision, TelemetryDecision::ForcedOff)); } assert_eq!(snapshot(&root), before, "a kill-switch run touched disk"); assert!(!buffer::tombstone_present(&root)); assert_eq!( std::fs::read(buffer::install_id_path(&root)).expect("install id"), identity, "the install id churned across a kill-switch run" ); } #[test] fn an_opt_out_on_a_fresh_home_creates_nothing() { let home = temp_home(); let root = root_of(&home); let decision = decide_in_home( Some(home.path()), &resolved(false, true, None), &SetupState::default(), Surface::Tui, ); assert!(matches!(decision, TelemetryDecision::OptedOut)); assert!( !root.exists(), "a fresh user who opts out must not get a telemetry directory" ); } fn seed_consenting_home(root: &Path) -> Vec<(String, Vec)> { buffer::append( root, &buffer::buffer_path(root), "{\"event\":\"session_start\",\"source\":\"interactive\"}", ) .expect("seed buffer"); buffer::append_locked(root, &buffer::dryrun_path(root), "{\"schema_version\":1}") .expect("seed dryrun"); envelope::read_or_create_install_id(root).expect("seed install id"); envelope::write_state(root, &envelope::TelemetryState::default()).expect("seed state"); snapshot(root) } fn snapshot(root: &Path) -> Vec<(String, Vec)> { let Ok(entries) = std::fs::read_dir(root) else { return Vec::new(); }; let mut out: Vec<(String, Vec)> = entries .filter_map(Result::ok) .map(|entry| { let name = entry.file_name().to_string_lossy().to_string(); let body = std::fs::read(entry.path()).unwrap_or_default(); (name, body) }) .collect(); out.sort(); out } #[test] fn failed_wipe_fails_closed() { let home = temp_home(); let root = root_of(&home); buffer::ensure_dir(&root).expect("create root"); seed_consenting_home(&root); // Make the buffer un-truncatable. The tombstone is written first, so even // when the rest of the wipe fails the buffer is permanently undrainable. let buffer_path = buffer::buffer_path(&root); let readonly_worked = make_read_only(&buffer_path); let result = buffer::wipe(&root); assert!( buffer::tombstone_present(&root), "the tombstone must survive" ); if readonly_worked { assert!(result.is_err(), "a failed truncate must be reported"); } assert!( buffer::drain(&root).is_empty(), "a tombstoned buffer must never drain, wipe failure or not" ); assert!( buffer::append( &root, &buffer_path, "{\"event\":\"session_start\",\"source\":\"api\"}" ) .is_none(), "a tombstoned buffer must never accept an append" ); } #[cfg(unix)] fn make_read_only(path: &Path) -> bool { use std::os::unix::fs::PermissionsExt as _; // Root ignores the mode bits, so this fixture cannot be relied on there. if geteuid_is_root() { return false; } std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o400)).is_ok() } #[cfg(unix)] fn geteuid_is_root() -> bool { unsafe extern "C" { fn geteuid() -> u32; } unsafe { geteuid() == 0 } } #[cfg(not(unix))] fn make_read_only(_path: &Path) -> bool { false } // --------------------------------------------------------------- endpoints -- #[test] fn plain_http_is_rejected_except_on_loopback() { assert!(validate_endpoint("https://example.com/t").is_ok()); assert_eq!( validate_endpoint("http://example.com/t"), Err(EndpointError::InsecureScheme) ); assert!(validate_endpoint("http://127.0.0.1:9/x").is_ok()); assert!(validate_endpoint("http://localhost:9/x").is_ok()); assert!(validate_endpoint("http://[::1]:9/x").is_ok()); assert_eq!( validate_endpoint("ftp://example.com/t"), Err(EndpointError::UnsupportedScheme) ); assert_eq!( validate_endpoint("example.com"), Err(EndpointError::Unparseable) ); } #[test] fn no_environment_variable_can_authorize_plaintext() { // `CODEWHALE_ALLOW_INSECURE_HTTP` is a *provider* trust decision — it // permits an insecure model base URL for harnesses that intercept model // traffic. Honouring it here would let that decision also authorize // telemetry POSTs to an arbitrary host. No override of any kind exists. unsafe { std::env::set_var("CODEWHALE_ALLOW_INSECURE_HTTP", "1") }; let with_env = validate_endpoint("http://example.com/t"); unsafe { std::env::remove_var("CODEWHALE_ALLOW_INSECURE_HTTP") }; assert_eq!(with_env, Err(EndpointError::InsecureScheme)); } // -------------------------------------------------------- install identity -- #[test] fn install_id_is_random_and_rotates() { let first_home = temp_home(); let second_home = temp_home(); let first_root = root_of(&first_home); let second_root = root_of(&second_home); let first = envelope::read_or_create_install_id(&first_root).expect("mint"); let second = envelope::read_or_create_install_id(&second_root).expect("mint"); assert_ne!( first.install_id, second.install_id, "two fresh homes must not share an id" ); assert!(uuid::Uuid::parse_str(&first.install_id).is_ok()); // Stable across reads on the same home. let again = envelope::read_or_create_install_id(&first_root).expect("re-read"); assert_eq!(first.install_id, again.install_id); // Not a function of hostname, user, or path: nothing derivable appears in // the value, and two homes under the same user differ. for derived in [ std::env::var("USER").unwrap_or_default(), std::env::var("HOME").unwrap_or_default(), first_root.display().to_string(), ] { if derived.trim().is_empty() { continue; } assert!(!first.install_id.contains(derived.trim())); } // 91 days old rotates. let stale = envelope::InstallId { schema_version: 1, install_id: first.install_id.clone(), rotated_at: (chrono::Utc::now() - chrono::Duration::days(91)) .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), }; codewhale_config::persistence::atomic_write_json(&buffer::install_id_path(&first_root), &stale) .expect("write stale id"); let rotated = envelope::read_or_create_install_id(&first_root).expect("rotate"); assert_ne!(rotated.install_id, first.install_id); assert_ne!(rotated.rotated_at, stale.rotated_at); } // ------------------------------------------------------------------ buffer -- fn line(n: usize) -> String { serde_json::to_string(&Event::Panic { site: format!("crates/tui/src/x.rs:{n}:1"), }) .expect("serialize") } #[test] fn ring_buffer_drops_oldest_at_cap_for_both_sinks() { let home = temp_home(); let root = root_of(&home); for path in [buffer::buffer_path(&root), buffer::dryrun_path(&root)] { for n in 0..600 { buffer::append(&root, &path, &line(n)).expect("append"); } let kept = buffer::read_lines(&path); assert_eq!(kept.len(), buffer::MAX_EVENTS, "{}", path.display()); assert_eq!( kept.first().expect("first"), &line(600 - buffer::MAX_EVENTS) ); assert_eq!(kept.last().expect("last"), &line(599)); } } #[test] fn probe_threshold_cannot_hide_an_over_cap_buffer() { // The append path skips the count probe below a byte threshold. That is // only safe if `MAX_EVENTS` lines cannot fit under it. let shortest = serde_json::to_string(&Event::SessionStart { source: SessionSource::Api, }) .expect("serialize"); let floor = (shortest.len() as u64 + 1) * buffer::MAX_EVENTS as u64; assert!( floor > 4096, "the shortest event is now small enough that {} of them fit under the probe threshold", buffer::MAX_EVENTS ); } #[test] fn a_line_over_pipe_buf_is_dropped_not_split() { let home = temp_home(); let root = root_of(&home); let path = buffer::buffer_path(&root); let huge = format!("{{\"pad\":\"{}\"}}", "x".repeat(buffer::MAX_LINE_BYTES)); assert!(buffer::append(&root, &path, &huge).is_none()); assert!(buffer::read_lines(&path).is_empty()); } #[test] fn drain_skips_a_torn_trailing_line() { let home = temp_home(); let root = root_of(&home); let path = buffer::buffer_path(&root); buffer::append(&root, &path, &line(1)).expect("append"); buffer::append(&root, &path, &line(2)).expect("append"); // `std::process::exit` on the signal path can cut a concurrent write. { use std::io::Write as _; let mut file = std::fs::OpenOptions::new() .append(true) .open(&path) .expect("open"); file.write_all(b"{\"event\":\"pan").expect("tear"); } let drained = buffer::drain(&root); assert_eq!(drained.len(), 3, "the drain returns raw lines"); let parsed: Vec = drained .iter() .filter_map(|l| serde_json::from_str(l).ok()) .collect(); assert_eq!(parsed.len(), 2, "the torn line must not reach a batch"); assert!(buffer::read_lines(&path).is_empty(), "drain truncates"); } #[test] fn append_drops_without_blocking_on_a_held_privacy_lock() { let home = temp_home(); let root = root_of(&home); buffer::ensure_dir(&root).expect("create root"); let path = buffer::buffer_path(&root); let held = std::sync::Arc::new(std::sync::Barrier::new(2)); let release = std::sync::Arc::new(std::sync::Barrier::new(2)); let holder_root = root.clone(); let holder_held = held.clone(); let holder_release = release.clone(); let holder = std::thread::spawn(move || { buffer::with_lock(&holder_root, || { holder_held.wait(); holder_release.wait(); Ok(()) }) }); held.wait(); let started = Instant::now(); let outcome = buffer::append(&root, &path, &line(7)); let elapsed = started.elapsed(); release.wait(); holder.join().expect("holder thread").expect("holder lock"); assert!(outcome.is_none(), "a contended append must be dropped"); assert!( elapsed < Duration::from_millis(250), "an append waited {elapsed:?} on the privacy lock" ); assert!( buffer::read_lines(&path).is_empty(), "the panic-safe path bypassed the privacy lock" ); } #[test] fn a_tombstoned_buffer_never_appends_or_drains() { let home = temp_home(); let root = root_of(&home); buffer::ensure_dir(&root).expect("create root"); buffer::append(&root, &buffer::buffer_path(&root), &line(1)).expect("append"); buffer::wipe(&root).expect("wipe"); assert!(buffer::append(&root, &buffer::buffer_path(&root), &line(2)).is_none()); assert!(buffer::append_locked(&root, &buffer::dryrun_path(&root), "{}").is_none()); assert!(buffer::drain(&root).is_empty()); } #[test] fn arming_truncates_a_pre_consent_buffer() { let home = temp_home(); let root = root_of(&home); buffer::ensure_dir(&root).expect("create root"); buffer::append(&root, &buffer::buffer_path(&root), &line(1)).expect("append"); buffer::wipe(&root).expect("wipe"); let generation = buffer::tombstone_generation(&root).expect("read wipe generation"); buffer::arm(&root, generation.as_ref(), || true).expect("arm"); assert!(!buffer::tombstone_present(&root)); assert!(buffer::read_lines(&buffer::buffer_path(&root)).is_empty()); } #[test] fn stale_consent_cannot_clear_a_newer_opt_out_but_fresh_reenable_can() { let home = temp_home(); let root = root_of(&home); let config_path = home.path().join("config.toml"); let setup_path = home.path().join("setup_state.json"); accepted_setup() .save_to(&setup_path) .expect("write accepted setup state"); // This process resolved the old enabled config before another process // persisted an opt-out and completed its wipe. let stale_resolved = resolved(true, false, None); let TelemetryDecision::Enabled(pre_wipe_consent) = decide_in_home( Some(home.path()), &stale_resolved, &accepted_setup(), Surface::Tui, ) else { panic!("pre-wipe enabled facts must produce consent"); }; std::fs::write(&config_path, "telemetry = false\n").expect("persist opt-out"); buffer::ensure_dir(&root).expect("create telemetry root"); buffer::wipe(&root).expect("complete newer wipe"); assert!( buffer::arm(&root, pre_wipe_consent.tombstone_generation(), || true).is_err(), "an old consent token cleared a newer tombstone generation" ); assert!(buffer::tombstone_present(&root)); // Even the difficult ordering — stale config facts combined with the new // tombstone generation — cannot arm, because arm re-reads the durable // predicate while holding the wipe lock. let TelemetryDecision::Enabled(stale_consent) = decide_in_home( Some(home.path()), &stale_resolved, &accepted_setup(), Surface::Tui, ) else { panic!("fixture must carry stale enabled facts"); }; assert!( buffer::arm(&root, stale_consent.tombstone_generation(), || { permission_still_enabled_in_home( Some(&config_path), &setup_path, Some(home.path()), &root, ) }) .is_err(), "stale consent cleared a completed opt-out" ); assert!(buffer::tombstone_present(&root)); // The documented explicit re-enable updates the durable register first. A // fresh consent observes both that value and the current generation, so it // may clear exactly that tombstone. std::fs::write(&config_path, "telemetry = true\n").expect("persist re-enable"); let TelemetryDecision::Enabled(fresh_consent) = decide_in_home( Some(home.path()), &resolved(true, false, None), &accepted_setup(), Surface::Tui, ) else { panic!("fresh re-enable must produce consent"); }; buffer::arm(&root, fresh_consent.tombstone_generation(), || { permission_still_enabled_in_home(Some(&config_path), &setup_path, Some(home.path()), &root) }) .expect("fresh re-enable arms"); assert!(!buffer::tombstone_present(&root)); } #[test] fn completed_wipe_blocks_identity_and_state_recreation() { let home = temp_home(); let root = root_of(&home); buffer::ensure_dir(&root).expect("create telemetry root"); envelope::read_or_create_install_id(&root).expect("seed install id"); envelope::write_state(&root, &envelope::TelemetryState::default()).expect("seed state"); buffer::wipe(&root).expect("wipe telemetry home"); assert!( envelope::read_or_create_install_id(&root).is_err(), "an in-flight flush recreated the deleted install id" ); assert!( envelope::write_state(&root, &envelope::TelemetryState::default()).is_err(), "an in-flight flush recreated state after opt-out" ); assert!(!buffer::install_id_path(&root).exists()); assert!(!buffer::state_path(&root).exists()); } // ------------------------------------------------------------ unarmed gate -- #[test] fn record_blocking_is_a_noop_when_unarmed() { // The process panic hook is installed before the command line is parsed, so // this is the state the hook runs in for every user who never opted in. let home = temp_home(); let root = root_of(&home); assert!(!crate::is_armed()); crate::record_blocking(Event::Panic { site: "crates/tui/src/x.rs:1:1".to_string(), }); crate::record(Event::SessionStart { source: SessionSource::Interactive, }); crate::set_exit_class(ExitClass::Panic); assert_eq!(crate::exit_class(), ExitClass::Clean); assert!( !root.exists(), "an unarmed process must create no directory" ); } // ------------------------------------------------------------------ client -- #[test] fn endpoint_unset_writes_the_dry_run_sink() { let home = temp_home(); let root = root_of(&home); let batch = every_field_batch(); assert_eq!( crate::client::send(&root, None, &batch), crate::client::SendOutcome::DryRun ); let written = buffer::read_lines(&buffer::dryrun_path(&root)); assert_eq!(written.len(), 1); let round_tripped: Batch = serde_json::from_str(&written[0]).expect("parse dry-run batch"); assert_eq!(round_tripped, batch); assert!( !buffer::buffer_path(&root).exists(), "the dry-run sink is a separate file from the pending buffer" ); } #[test] fn a_tombstoned_home_sends_nothing_even_with_an_endpoint() { let home = temp_home(); let root = root_of(&home); buffer::ensure_dir(&root).expect("create root"); buffer::wipe(&root).expect("wipe"); // The tombstone check fires before any client is constructed, so this // asserts on the sink rather than on network timing. assert_eq!( crate::client::send(&root, Some("http://127.0.0.1:1/t"), &every_field_batch()), crate::client::SendOutcome::Dropped ); assert!(buffer::read_lines(&buffer::dryrun_path(&root)).is_empty()); } #[test] fn wipe_and_delivery_share_one_ordering_boundary() { let home = temp_home(); let root = root_of(&home); let entered = std::sync::Arc::new(std::sync::Barrier::new(2)); let release = std::sync::Arc::new(std::sync::Barrier::new(2)); let send_root = root.clone(); let send_entered = entered.clone(); let send_release = release.clone(); let send = std::thread::spawn(move || { crate::client::send_with_transport( &send_root, Some("https://telemetry.codewhale.ai/v1/batch"), &every_field_batch(), move |_, _, _| { send_entered.wait(); send_release.wait(); crate::client::SendOutcome::Accepted }, ) }); entered.wait(); // The real send path is paused inside its transport callback. A // non-blocking probe must observe the same lock that wipe takes; this // deterministically pins the entire delivery inside the boundary without // depending on loopback networking in a restricted test sandbox. assert!( buffer::try_with_lock(&root, || Ok(())) .expect("probe privacy lock") .is_none(), "network delivery did not hold the wipe lock" ); // Start the real blocking wipe while the POST is still in flight. It can // only complete after the response releases the sender's privacy guard. let wipe_root = root.clone(); let wipe = std::thread::spawn(move || buffer::wipe(&wipe_root)); release.wait(); assert_eq!( send.join().expect("send thread"), crate::client::SendOutcome::Accepted ); wipe.join() .expect("wipe thread") .expect("wipe after delivery"); assert!(buffer::tombstone_present(&root)); assert_eq!( crate::client::send(&root, Some("http://127.0.0.1:1/t"), &every_field_batch()), crate::client::SendOutcome::Dropped, "a send crossed the completed wipe boundary" ); } // ----------------------------------------------------------------- buckets -- #[test] fn buckets_are_half_open_at_every_boundary() { assert_eq!(DurationBucket::from_secs(0), DurationBucket::Lt1m); assert_eq!(DurationBucket::from_secs(59), DurationBucket::Lt1m); assert_eq!(DurationBucket::from_secs(60), DurationBucket::OneToTen); assert_eq!(DurationBucket::from_secs(599), DurationBucket::OneToTen); assert_eq!(DurationBucket::from_secs(600), DurationBucket::TenToSixty); assert_eq!(DurationBucket::from_secs(3599), DurationBucket::TenToSixty); assert_eq!(DurationBucket::from_secs(3600), DurationBucket::Gt60m); assert_eq!(ColdStartBucket::from_millis(249), ColdStartBucket::Lt250); assert_eq!(ColdStartBucket::from_millis(250), ColdStartBucket::Mid); assert_eq!(ColdStartBucket::from_millis(999), ColdStartBucket::Mid); assert_eq!(ColdStartBucket::from_millis(1000), ColdStartBucket::Slow); assert_eq!(ColdStartBucket::from_millis(2999), ColdStartBucket::Slow); assert_eq!(ColdStartBucket::from_millis(3000), ColdStartBucket::Gte3000); let mut wall = TurnWall::default(); for secs in [0, 4, 5, 29, 30, 119, 120, 10_000] { wall.observe_secs(secs); } assert_eq!(wall.lt_5s, 2); assert_eq!(wall.five_to_thirty, 2); assert_eq!(wall.thirty_to_onetwenty, 2); assert_eq!(wall.gte_120s, 2); } #[test] fn exit_class_round_trips_through_the_atomic_encoding() { for class in ExitClass::ALL { assert_eq!(ExitClass::from_u8(class.as_u8()), *class); } // An exit code is never the source: 130 is both a cancelled turn and SIGINT. assert_eq!(ExitClass::from_u8(130), ExitClass::Clean); } // ---------------------------------------------------------------- counters -- #[test] fn custom_provider_emits_literal_custom() { let counters = crate::SessionCounters::default(); counters.record_provider(codewhale_config::ProviderKind::Custom); counters.record_provider(codewhale_config::ProviderKind::Deepseek); counters.record_provider(codewhale_config::ProviderKind::Custom); let providers = counters.providers(); assert_eq!( providers, vec!["custom".to_string(), "deepseek".to_string()] ); } #[test] fn counter_bumps_land_in_the_named_field() { let counters = crate::SessionCounters::default(); counters.bump(crate::Counter::Turns); counters.bump(crate::Counter::Turns); counters.bump(crate::Counter::CommandPaletteOpen); counters.bump_error(crate::ErrorCounter::ProviderHttp5xx); counters.observe_turn_secs(3); let snapshot = counters.counters(); assert_eq!(snapshot.turns, 2); assert_eq!(snapshot.command_palette_open, 1); assert_eq!(snapshot.tool_calls, 0); assert_eq!(counters.errors().provider_http_5xx, 1); assert_eq!(counters.turn_wall().lt_5s, 1); } #[test] fn http_status_maps_to_the_class_counter_and_nothing_else() { assert_eq!( crate::counters::http_status_counter(404), Some(crate::ErrorCounter::ProviderHttp4xx) ); assert_eq!( crate::counters::http_status_counter(503), Some(crate::ErrorCounter::ProviderHttp5xx) ); assert_eq!(crate::counters::http_status_counter(200), None); assert_eq!(crate::counters::http_status_counter(302), None); } // --------------------------------------------------------------- API shape -- #[test] fn no_public_api_accepts_a_bare_bool() { // `init` takes a `TelemetryConsent` **by value**, and `TelemetryConsent` has // no public constructor other than `decide`. This is a shape assertion: it // stops compiling if the signature is ever widened. let init: fn(crate::TelemetryConsent) = crate::init; let _ = init; // The only source of one is `decide`, which still applies every persistent // and run-scoped opt-out before constructing the capability. let home = temp_home(); assert!( decide_in_home( Some(home.path()), &resolved(true, false, None), &accepted_setup(), Surface::Cli ) .is_enabled() ); } // ------------------------------------------------- docs and code are welded -- const TELEMETRY_DOC: &str = include_str!("../../../docs/TELEMETRY.md"); const GOLDEN_V4: &str = include_str!("../tests/golden/v4.json"); /// Extract the fenced ```jsonc blocks from the schema doc, in order. fn jsonc_blocks(doc: &str) -> Vec { let mut blocks = Vec::new(); let mut current: Option = None; for raw in doc.lines() { let line = raw.trim_end(); match current.as_mut() { None => { if line.trim() == "```jsonc" { current = Some(String::new()); } } Some(body) => { if line.trim() == "```" { blocks.push(std::mem::take(body)); current = None; } else { body.push_str(line); body.push('\n'); } } } } blocks } /// Every `"name":` key in a jsonc block, including nested objects. Values are /// never matched: a key is an identifier-shaped string followed by a colon, and /// no value in these blocks has that shape. fn documented_keys(block: &str) -> std::collections::BTreeSet { let bytes: Vec = block.chars().collect(); let mut keys = std::collections::BTreeSet::new(); let mut index = 0; while index < bytes.len() { if bytes[index] != '"' { index += 1; continue; } let start = index + 1; let mut end = start; while end < bytes.len() && bytes[end] != '"' { end += 1; } if end >= bytes.len() { break; } let candidate: String = bytes[start..end].iter().collect(); let mut after = end + 1; while after < bytes.len() && bytes[after] == ' ' { after += 1; } let is_key = after < bytes.len() && bytes[after] == ':'; let identifier_shaped = !candidate.is_empty() && candidate .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'); if is_key && identifier_shaped { keys.insert(candidate); } index = end + 1; } keys } /// Every key of a serialized value, including nested objects. fn serialized_keys(value: &Value) -> std::collections::BTreeSet { let mut keys = std::collections::BTreeSet::new(); fn walk(value: &Value, out: &mut std::collections::BTreeSet) { match value { Value::Object(map) => { for (key, item) in map { out.insert(key.clone()); walk(item, out); } } Value::Array(items) => { for item in items { walk(item, out); } } _ => {} } } walk(value, &mut keys); keys } /// First-column entries of the markdown table that follows `heading`. fn table_first_column(doc: &str, heading: &str) -> Vec { let mut lines = doc.lines().skip_while(|line| line.trim() != heading); let mut rows = Vec::new(); let mut in_table = false; for line in lines.by_ref() { let trimmed = line.trim(); if !trimmed.starts_with('|') { if in_table { break; } continue; } in_table = true; let first = trimmed .trim_matches('|') .split('|') .next() .unwrap_or("") .trim(); if first.is_empty() || first.chars().all(|c| c == '-' || c == ':') { continue; } let name = first.trim_matches('`').to_string(); if name.eq_ignore_ascii_case("field") || name.eq_ignore_ascii_case("file") { continue; } rows.push(name); } rows } #[test] fn event_field_names_match_documented_schema() { let blocks = jsonc_blocks(TELEMETRY_DOC); assert_eq!( blocks.len(), 9, "expected one jsonc block for the envelope and one per event variant; \ a parse miss must fail rather than silently pass" ); // The envelope. let documented = documented_keys(&blocks[0]); let declared: std::collections::BTreeSet = Batch::FIELDS.iter().map(|f| (*f).to_string()).collect(); assert_eq!(documented.len(), Batch::FIELDS.len()); assert_eq!( documented, declared, "the batch envelope drifted from the doc" ); // One block per event variant, in the order the doc presents them. let events = every_event(); assert_eq!(events.len(), blocks.len() - 1); for (index, event) in events.iter().enumerate() { let block = &blocks[index + 1]; let documented = documented_keys(block); let serialized = serialized_keys(&serde_json::to_value(event).expect("serialize")); assert!( !documented.is_empty(), "no keys parsed out of the {} block", event.name() ); assert_eq!( documented, serialized, "the `{}` event drifted from the doc", event.name() ); } // The envelope table, row for row. let envelope_rows = table_first_column(TELEMETRY_DOC, "### Batch envelope — sent on every POST"); assert_eq!( envelope_rows.len(), Batch::FIELDS.len(), "the envelope table lost or gained a row: {envelope_rows:?}" ); assert_eq!( envelope_rows, Batch::FIELDS .iter() .map(|f| (*f).to_string()) .collect::>() ); // The counters and errors tables, which are the two closed field sets a // contributor is most likely to extend without touching the doc. let counter_rows = table_first_column( TELEMETRY_DOC, "**`counters`** — closed field set. Every bump happens at the **call site**, never inside a conditionally-entered handler:", ); assert_eq!( counter_rows, Counters::FIELDS .iter() .map(|f| (*f).to_string()) .collect::>(), "the counters table drifted from `Counters`" ); let error_rows = table_first_column( TELEMETRY_DOC, "**`errors`** — closed field set. Every value is a **variant discriminant**, never `err.to_string()`:", ); assert_eq!( error_rows, Errors::FIELDS .iter() .map(|f| (*f).to_string()) .collect::>(), "the errors table drifted from `Errors`" ); } #[test] fn golden_payload_v4() { assert_eq!(NOTICE_VERSION.to_string(), TELEMETRY_NOTICE_VERSION); // `crates/telemetry/tests/golden/v4.json` is one fully-populated instance of // the envelope and every event. Any field add, remove, or retype fails here // until the developer re-blesses it under a bumped `SCHEMA_VERSION` — and it // is also the artifact a future receiver author reads to know exactly what // v1 was. // // Re-bless with: `CODEWHALE_BLESS_TELEMETRY_GOLDEN=1 cargo test -p codewhale-telemetry` let batch = every_field_batch(); assert_eq!( batch.schema_version, SCHEMA_VERSION, "the fixture must be built at the current schema version" ); let mut rendered = serde_json::to_string_pretty(&batch).expect("serialize"); rendered.push('\n'); if std::env::var("CODEWHALE_BLESS_TELEMETRY_GOLDEN").is_ok() { let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden/v4.json"); std::fs::create_dir_all(path.parent().expect("parent")).expect("create golden dir"); std::fs::write(&path, &rendered).expect("write golden"); return; } assert_eq!( rendered, GOLDEN_V4, "the v4 payload changed; bump SCHEMA_VERSION and re-bless the golden file" ); } #[test] fn version_comparison_names_install_upgrade_and_downgrade() { assert!(crate::version_is_older("0.9.3", "0.9.4")); assert!(crate::version_is_older("0.9", "0.9.4")); assert!(crate::version_is_older("0.10.0", "1.0.0")); assert!(!crate::version_is_older("0.9.4", "0.9.4")); assert!(!crate::version_is_older("0.9.5", "0.9.4")); // A pre-release suffix is not part of the ordering question being asked. assert!(!crate::version_is_older("0.9.4-rc.1", "0.9.4")); // Unparseable segments read as zero, so an unknown version never invents an // upgrade that did not happen. assert!(!crate::version_is_older("nightly", "0.0.0")); } #[test] fn an_install_or_upgrade_is_reported_once_per_version() { let home = temp_home(); let root = root_of(&home); buffer::ensure_dir(&root).expect("create telemetry dir"); // No prior record on this machine. let mut state = envelope::read_state(&root); assert_eq!(state.last_version, None); // The state file is written before the event is queued, so the second // launch at the same version has nothing left to report. state.last_version = Some(env!("CARGO_PKG_VERSION").to_string()); envelope::write_state(&root, &state).expect("write state"); assert_eq!( envelope::read_state(&root).last_version.as_deref(), Some(env!("CARGO_PKG_VERSION")) ); // The previous version is read from this file and from nowhere else — // never from session history or config mtimes, which answer the same // question under a different privacy contract. let entries: Vec = std::fs::read_dir(&root) .expect("read dir") .filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned())) .collect(); assert!( entries.iter().any(|name| name == "state.json"), "expected state.json in {entries:?}" ); } #[test] fn the_notice_summarizes_what_the_schema_collects_and_states_every_red_line() { use crate::notice; let body = notice::NOTICE_BODY; // The modal names the useful product categories and links the exact // field-by-field schema. `install_id` is "a random ID stored on this // machine"; transport metadata remains in the linked document. The body // wraps at 72 columns, so multi-word claims are matched across the // reflowed whitespace. let flat: String = body.split_whitespace().collect(); for claim in [ "version", "OS and CPU family", "session duration and outcome", "aggregate feature and error counters", "random ID stored on this machine", "every 90 days", "on by default", "PostHog", ] { assert!( flat.contains(&claim.split_whitespace().collect::()), "the notice does not describe: {claim}" ); } // And every red line has to be stated as *not collected*, not as // anonymized or sampled — two promises this client does not make. for red_line in [ "conversations", "code", "prompts", "files", "repo or branch names", "model content", "credentials", "per-turn or per-tool timeline", ] { assert!( flat.contains(&red_line.split_whitespace().collect::()), "the notice does not disclaim: {red_line}" ); } assert!(!body.to_ascii_lowercase().contains("anonymized")); // The modal names the persistent opt-out because that is the switch that // also fulfils its deletion promise. Run-only kill switches stay in the // linked schema document, which explains that they erase nothing. assert!(body.contains("codewhale config set telemetry false")); assert!(!body.contains("CODEWHALE_TELEMETRY=0")); assert!(body.contains("docs/TELEMETRY.md")); }