返回 CodeWhale
tests.rs
根目录 / crates / telemetry / src / tests.rs
1 //! The tests are the contract.
2 //!
3 //! Two of them are load-bearing beyond ordinary coverage:
4 //! `every_payload_field_is_bounded` walks a fully-populated batch and asserts
5 //! that every string leaf is a member of a declared enum set or one of exactly
6 //! three regexed strings, and `every_string_leaf_survives_redaction_unchanged`
7 //! runs the workflow crate's disclosure redactor over each leaf **individually**
8 //! — never over the serialized document, which would be one whitespace-free
9 //! token and would report clean no matter what it contained.
10
11 use std::path::{Path, PathBuf};
12 use std::time::{Duration, Instant};
13
14 use codewhale_config::{
15 CliRuntimeOverrides, ConfigToml, ResolvedRuntimeOptions, SetupState, TELEMETRY_NOTICE_VERSION,
16 };
17 use serde_json::Value;
18
19 use crate::buffer;
20 use crate::decision::{EndpointError, TelemetryDecision, decide_in_home, validate_endpoint};
21 use crate::envelope;
22 use crate::event::*;
23
24 // ---------------------------------------------------------------- fixtures --
25
26 fn temp_home() -> tempfile::TempDir {
27 tempfile::tempdir().expect("temp home")
28 }
29
30 fn root_of(home: &tempfile::TempDir) -> PathBuf {
31 home.path().join(crate::TELEMETRY_DIR)
32 }
33
34 /// A resolved-options value with everything but telemetry left at its default.
35 fn resolved(telemetry: bool, explicit_off: bool, endpoint: Option<&str>) -> ResolvedRuntimeOptions {
36 let mut options =
37 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
38 options.telemetry = telemetry;
39 options.telemetry_explicit_off = explicit_off;
40 options.telemetry_endpoint = endpoint.map(str::to_string);
41 options
42 }
43
44 fn accepted_setup() -> SetupState {
45 let mut setup = SetupState::default();
46 setup.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, true);
47 setup
48 }
49
50 fn declined_setup() -> SetupState {
51 let mut setup = SetupState::default();
52 setup.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, false);
53 setup
54 }
55
56 fn stale_setup() -> SetupState {
57 let mut setup = SetupState::default();
58 setup.record_telemetry_notice("0", true);
59 setup
60 }
61
62 /// One instance of every event variant, populated with the most adversarial
63 /// values the schema permits.
64 ///
65 /// **This list is hand-written, and the compiler cannot make you extend it.**
66 /// A new `Event` variant carrying a free-form `String` would be walked by none
67 /// of the red-line tests below — they all start here — and `golden_payload_v1`
68 /// would still pass, because it serializes this same fixture. Nothing closes
69 /// that hole from inside this file; enumerating an enum's variants needs
70 /// reflection this workspace deliberately does not depend on. What does bite is
71 /// [`Event::is_bounded`], whose `match self` is exhaustive: adding a variant
72 /// fails the build until its author states a bound. If you are that author,
73 /// add the variant here too.
74 fn every_event() -> Vec<Event> {
75 vec![
76 Event::InstallOrUpgrade {
77 kind: InstallKind::Upgrade,
78 previous_version: Some("0.9.3-rc.1".to_string()),
79 },
80 Event::SessionStart {
81 source: SessionSource::Resume,
82 },
83 Event::SessionEnd {
84 duration_bucket: DurationBucket::OneToTen,
85 exit_class: ExitClass::Panic,
86 cold_start_bucket: Some(ColdStartBucket::Mid),
87 providers: vec!["custom".to_string(), "deepseek".to_string()],
88 counters: Counters {
89 turns: 14,
90 tool_calls: 61,
91 fleet_dispatch: 0,
92 workflow_run: 0,
93 subagent_spawn: 2,
94 mcp_server_connected: 0,
95 memory_search: 0,
96 approval_modal_shown: 0,
97 approval_auto_allowed: 0,
98 command_palette_open: 3,
99 },
100 errors: Errors {
101 auth_preflight_failed: 0,
102 provider_http_4xx: 0,
103 provider_http_5xx: 1,
104 tool_denied_by_policy: 0,
105 tool_timeout: 0,
106 network_error: 0,
107 },
108 turn_wall: TurnWall {
109 lt_5s: 9,
110 five_to_thirty: 4,
111 thirty_to_onetwenty: 1,
112 gte_120s: 0,
113 },
114 },
115 Event::Panic {
116 site: "crates/tui/src/tui/ui.rs:8801:17".to_string(),
117 },
118 ]
119 }
120
121 /// A fully-populated batch. This is the artifact the red-line tests walk.
122 pub(crate) fn every_field_batch() -> Batch {
123 Batch {
124 schema_version: SCHEMA_VERSION,
125 sent_at: "2026-08-03T18:04:11Z".to_string(),
126 install_id: "3f2a9c1e-0000-4000-8000-000000000001".to_string(),
127 app_version: "0.9.4".to_string(),
128 git_sha: Some("abcdef012345".to_string()),
129 surface: Surface::Tui,
130 os: Os::Macos,
131 arch: Arch::Aarch64,
132 libc: Libc::None,
133 tty: true,
134 events: every_event(),
135 }
136 }
137
138 // --------------------------------------------------------- schema red lines --
139
140 fn walk_strings(value: &Value, path: &str, out: &mut Vec<(String, String)>) {
141 match value {
142 Value::String(text) => out.push((path.to_string(), text.clone())),
143 Value::Array(items) => {
144 for (index, item) in items.iter().enumerate() {
145 walk_strings(item, &format!("{path}[{index}]"), out);
146 }
147 }
148 Value::Object(map) => {
149 for (key, item) in map {
150 let child = if path.is_empty() {
151 key.clone()
152 } else {
153 format!("{path}.{key}")
154 };
155 walk_strings(item, &child, out);
156 }
157 }
158 _ => {}
159 }
160 }
161
162 pub(crate) fn string_leaves(value: &Value) -> Vec<(String, String)> {
163 let mut out = Vec::new();
164 walk_strings(value, "", &mut out);
165 out
166 }
167
168 // The version and panic-site rules are the shipped predicates, not local
169 // copies. A test that re-implements the rule it is checking passes against a
170 // binary that enforces nothing — which is exactly what
171 // `a_hostile_buffer_line_never_reaches_a_batch` found the first time.
172 use crate::event::{is_reduced_panic_site, is_release_version_string as is_app_version};
173
174 fn is_short_sha(value: &str) -> bool {
175 value.len() == 12
176 && value
177 .bytes()
178 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
179 }
180
181 /// Every closed-enum string this schema may ever emit.
182 fn closed_enum_values() -> Vec<String> {
183 let mut values: Vec<String> = Vec::new();
184 values.extend(Surface::ALL.iter().map(|v| v.as_str().to_string()));
185 values.extend(Os::ALL.iter().map(|v| v.as_str().to_string()));
186 values.extend(Arch::ALL.iter().map(|v| v.as_str().to_string()));
187 values.extend(Libc::ALL.iter().map(|v| v.as_str().to_string()));
188 values.extend(InstallKind::ALL.iter().map(|v| v.as_str().to_string()));
189 values.extend(SessionSource::ALL.iter().map(|v| v.as_str().to_string()));
190 values.extend(DurationBucket::ALL.iter().map(|v| v.as_str().to_string()));
191 values.extend(ExitClass::ALL.iter().map(|v| v.as_str().to_string()));
192 values.extend(ColdStartBucket::ALL.iter().map(|v| v.as_str().to_string()));
193 // The `event` tag values.
194 values.extend(every_event().iter().map(|e| e.name().to_string()));
195 // `providers` entries: `ProviderKind::as_str()` is a `&'static str` from a
196 // closed enum, and `Custom` yields the literal "custom".
197 values.extend(
198 codewhale_config::ProviderKind::all()
199 .iter()
200 .map(|kind| kind.as_str().to_string()),
201 );
202 values
203 }
204
205 #[test]
206 fn every_payload_field_is_bounded() {
207 let batch = every_field_batch();
208 let json = serde_json::to_value(&batch).expect("serialize batch");
209 let enums = closed_enum_values();
210 let leaves = string_leaves(&json);
211 assert!(
212 leaves.len() >= 10,
213 "the walk found suspiciously few string leaves: {leaves:?}"
214 );
215
216 for (path, value) in leaves {
217 let ok = match path.as_str() {
218 "app_version" => is_app_version(&value),
219 "git_sha" => is_short_sha(&value),
220 "sent_at" => value.ends_with('Z') && value.len() == 20,
221 "install_id" => uuid::Uuid::parse_str(&value).is_ok(),
222 p if p.ends_with(".site") => is_reduced_panic_site(&value),
223 p if p.ends_with(".previous_version") => is_app_version(&value),
224 _ => enums.contains(&value),
225 };
226 assert!(ok, "unbounded string leaf at {path}: {value:?}");
227 }
228 }
229
230 #[test]
231 fn counters_and_errors_serialize_every_field_including_zeros() {
232 let json = serde_json::to_value(Counters::default()).expect("serialize counters");
233 let object = json.as_object().expect("counters is an object");
234 assert_eq!(object.len(), Counters::FIELDS.len());
235 for field in Counters::FIELDS {
236 assert_eq!(object.get(*field), Some(&Value::from(0u32)), "{field}");
237 }
238
239 let json = serde_json::to_value(Errors::default()).expect("serialize errors");
240 let object = json.as_object().expect("errors is an object");
241 assert_eq!(object.len(), Errors::FIELDS.len());
242 for field in Errors::FIELDS {
243 assert_eq!(object.get(*field), Some(&Value::from(0u32)), "{field}");
244 }
245
246 let json = serde_json::to_value(TurnWall::default()).expect("serialize turn_wall");
247 let object = json.as_object().expect("turn_wall is an object");
248 assert_eq!(object.len(), TurnWall::FIELDS.len());
249 for field in TurnWall::FIELDS {
250 assert_eq!(object.get(*field), Some(&Value::from(0u32)), "{field}");
251 }
252 }
253
254 #[test]
255 fn no_event_field_is_ever_omitted() {
256 // Options serialize as `null` rather than being skipped, so the key set on
257 // the wire is closed and the doc-match test can be exact.
258 let event = Event::SessionEnd {
259 duration_bucket: DurationBucket::Lt1m,
260 exit_class: ExitClass::Clean,
261 cold_start_bucket: None,
262 providers: Vec::new(),
263 counters: Counters::default(),
264 errors: Errors::default(),
265 turn_wall: TurnWall::default(),
266 };
267 let json = serde_json::to_value(&event).expect("serialize");
268 assert_eq!(json.get("cold_start_bucket"), Some(&Value::Null));
269
270 let event = Event::InstallOrUpgrade {
271 kind: InstallKind::Install,
272 previous_version: None,
273 };
274 let json = serde_json::to_value(&event).expect("serialize");
275 assert_eq!(json.get("previous_version"), Some(&Value::Null));
276 }
277
278 // ------------------------------------------------------ scrubber assertions --
279
280 /// Run the workflow crate's disclosure redactor over **each string leaf**.
281 ///
282 /// Never over the serialized document: `redact_for_disclosure` tokenizes with
283 /// `input.split(' ')`, and a compact `serde_json` batch has no spaces, so the
284 /// whole document would be one token and every classifier would fail on it. A
285 /// batch containing an absolute path, a live-looking key, and a whole prompt
286 /// would report clean. The gate would detect nothing while appearing to pass.
287 fn redaction_kinds_over_leaves(json: &Value) -> Vec<String> {
288 let mut kinds = Vec::new();
289 for (_, value) in string_leaves(json) {
290 let redaction = codewhale_workflow::redaction::redact_for_disclosure(&value);
291 if redaction.redacted() {
292 kinds.extend(redaction.kinds());
293 }
294 }
295 kinds
296 }
297
298 #[test]
299 fn every_string_leaf_survives_redaction_unchanged() {
300 let json = serde_json::to_value(every_field_batch()).expect("serialize batch");
301 let kinds = redaction_kinds_over_leaves(&json);
302 assert!(
303 kinds.is_empty(),
304 "a real payload tripped the disclosure redactor: {kinds:?}"
305 );
306 }
307
308 #[test]
309 fn redaction_catches_a_planted_absolute_path() {
310 let mut json = serde_json::to_value(every_field_batch()).expect("serialize batch");
311 json["app_version"] = Value::from("/Users/hunter/src/app/main.rs");
312 let kinds = redaction_kinds_over_leaves(&json);
313 assert!(
314 kinds.iter().any(|k| k == "absolute_path"),
315 "the negative control did not fire: {kinds:?}"
316 );
317 }
318
319 #[test]
320 fn redaction_catches_a_planted_secret() {
321 let mut json = serde_json::to_value(every_field_batch()).expect("serialize batch");
322 // Deliberately low-entropy: a realistic token in a fixture trips secret
323 // scanners at push time.
324 json["app_version"] = Value::from("api_key=sk-live-abcdef0123456789abcdef");
325 let kinds = redaction_kinds_over_leaves(&json);
326 assert!(
327 kinds.iter().any(|k| k == "secret"),
328 "the negative control did not fire: {kinds:?}"
329 );
330 }
331
332 #[test]
333 fn panic_site_is_the_only_field_that_may_carry_a_path() {
334 // `panic_site` is a repo-relative path by design, so it is the one
335 // documented exemption. Prove the redactor would flag such a value, and
336 // that no other leaf in a real payload carries one.
337 let planted = codewhale_workflow::redaction::redact_for_disclosure("crates/tui/src/main.rs");
338 assert!(
339 planted.kinds().iter().any(|k| k == "relative_path"),
340 "the redactor no longer classifies a repo-relative path: {:?}",
341 planted.kinds()
342 );
343
344 let json = serde_json::to_value(every_field_batch()).expect("serialize batch");
345 for (path, value) in string_leaves(&json) {
346 if path.ends_with(".site") {
347 continue;
348 }
349 let redaction = codewhale_workflow::redaction::redact_for_disclosure(&value);
350 assert!(
351 !redaction.kinds().iter().any(|k| k.ends_with("path")),
352 "a non-exempt leaf carries a path: {path} = {value:?}"
353 );
354 }
355 }
356
357 // ------------------------------------------- the drain path is a boundary --
358
359 /// The buffer is a **deserializer input**, not an internal channel.
360 ///
361 /// Every bound above is a property of how this process *builds* an event.
362 /// `flush` re-reads `buffer.jsonl` and hands the lines to `serde`, and any
363 /// process running as the user can append to that file — including a `Bash`
364 /// tool call the session made on the model's behalf, since `$CODEWHALE_HOME`
365 /// is a predictable path. Before `Event::is_bounded` existed, an appended
366 /// `{"event":"panic","site":"…/Users/victim/secret-repo"}` was POSTed verbatim
367 /// to the configured endpoint under the user's install id; the process-level
368 /// proof of that is `a_hostile_buffer_line_never_reaches_a_batch` in
369 /// `crates/tui/tests/telemetry_contract.rs`.
370 #[test]
371 fn hostile_buffer_lines_are_dropped_before_they_reach_a_batch() {
372 let hostile = [
373 // A path, which is the class `panic_site` is the sole exemption for.
374 r#"{"event":"panic","site":"/Users/victim/src/secret-repo/main.rs"}"#,
375 // A whole prompt in the one field that is allowed to look like text.
376 r#"{"event":"panic","site":"rewrite the auth module for acme-corp"}"#,
377 // A frame outside the `crates/` allowlist, spelled to look inside it.
378 r#"{"event":"panic","site":"../vendor/crates/foo/src/lib.rs:1:1"}"#,
379 // `previous_version` is read back from `state.json`, never validated
380 // at the point it is written.
381 r#"{"event":"install_or_upgrade","kind":"upgrade","previous_version":"/Users/victim/.ssh/id_ed25519"}"#,
382 // A customer's `[providers.<name>]` table key — the exact string
383 // `record_provider` takes a `ProviderKind` by value to avoid.
384 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}}"#,
385 ];
386 for line in hostile {
387 let event = serde_json::from_str::<Event>(line)
388 .unwrap_or_else(|error| panic!("the fixture must be parseable: {error}\n{line}"));
389 assert!(
390 !event.is_bounded(),
391 "an out-of-bounds event passed the drain check: {line}"
392 );
393 let parsed = crate::actor::parse_events(&[line.to_string()]);
394 assert!(
395 parsed.is_empty(),
396 "a hostile buffer line survived the drain: {line}"
397 );
398 }
399 }
400
401 /// The drain check must not delete real telemetry. Everything this process
402 /// legitimately records has to survive a round trip through the buffer.
403 #[test]
404 fn every_legitimately_recorded_event_survives_the_drain() {
405 let lines: Vec<String> = every_event()
406 .iter()
407 .map(|event| serde_json::to_string(event).expect("serialize"))
408 .collect();
409 assert_eq!(
410 crate::actor::parse_events(&lines).len(),
411 lines.len(),
412 "the drain check dropped an event this process builds itself"
413 );
414
415 // Dialect kinds (`deepseek-anthropic`, the Model Studio plan variants) are
416 // absent from `ProviderKind::ALL`, which is the 36-row *catalog* subset,
417 // but `ApiProvider::kind()` yields them for real routes. Narrowing the
418 // provider bound to the catalog would drop those users' `session_end`.
419 for kind in [
420 codewhale_config::ProviderKind::DeepseekAnthropic,
421 codewhale_config::ProviderKind::MinimaxAnthropic,
422 codewhale_config::ProviderKind::Custom,
423 ] {
424 assert!(
425 crate::event::is_known_provider_id(kind.as_str()),
426 "a real routed provider is not a legal `providers` entry: {}",
427 kind.as_str()
428 );
429 }
430 }
431
432 /// `install_id` is the one envelope field read verbatim off disk into a batch.
433 #[test]
434 fn a_non_uuid_install_id_on_disk_is_replaced_rather_than_sent() {
435 let home = temp_home();
436 let root = root_of(&home);
437 buffer::ensure_dir(&root).expect("create telemetry root");
438 std::fs::write(
439 buffer::install_id_path(&root),
440 serde_json::json!({
441 "schema_version": 1,
442 "install_id": "/Users/victim/src/secret-repo",
443 "rotated_at": envelope::now_rfc3339(),
444 })
445 .to_string(),
446 )
447 .expect("plant a hostile install id");
448
449 let record = envelope::read_or_create_install_id(&root).expect("read install id");
450 assert!(
451 uuid::Uuid::parse_str(&record.install_id).is_ok(),
452 "a non-UUID install id was carried onto the wire: {:?}",
453 record.install_id
454 );
455 }
456
457 // ------------------------------------------------------------- panic sites --
458
459 #[test]
460 fn panic_site_reduces_dependency_frames() {
461 assert_eq!(
462 envelope::reduce_panic_site("crates/tui/src/x.rs", 9, 1),
463 "crates/tui/src/x.rs:9:1"
464 );
465 assert_eq!(
466 envelope::reduce_panic_site(
467 "/Users/builder/.cargo/registry/src/index.crates.io-1949cf8c/ratatui-0.29.0/src/y.rs",
468 1,
469 1
470 ),
471 "<dep>"
472 );
473 assert_eq!(
474 envelope::reduce_panic_site("/rustc/deadbeef/library/core/src/panicking.rs", 1, 1),
475 "<dep>"
476 );
477 // A path that merely mentions `crates/` somewhere is not a `crates/` frame.
478 assert_eq!(
479 envelope::reduce_panic_site("../vendor/crates/foo/src/lib.rs", 1, 1),
480 "<dep>"
481 );
482 }
483
484 #[test]
485 fn git_sha_is_null_without_release_env() {
486 // The build script emits `CODEWHALE_RELEASE_BUILD_SHA` only when
487 // `DEEPSEEK_BUILD_SHA` or `GITHUB_SHA` was in the build environment, so on
488 // a developer machine this is `None` and on release CI it is twelve hex
489 // characters. Both are asserted, because the test has to pass in both
490 // places and neither shape may ever be a path, a version, or a full sha.
491 // The rule that produces it lives in `codewhale-build-support` and is
492 // tested there against an injected environment; what is asserted here is
493 // that whatever reaches the payload is `null` or twelve lowercase hex
494 // characters, and never a path, a version, or a full sha.
495 if let Some(sha) = envelope::release_build_sha() {
496 assert!(
497 is_short_sha(&sha),
498 "release sha has the wrong shape: {sha:?}"
499 );
500 }
501 assert_eq!(
502 envelope::short_hex_sha("ABCDEF0123456789abcdef0123456789abcdef01"),
503 Some("abcdef012345".to_string())
504 );
505 assert_eq!(envelope::short_hex_sha("not-a-sha"), None);
506 assert_eq!(envelope::short_hex_sha("abc123"), None);
507 }
508
509 // --------------------------------------------------------------- decisions --
510
511 #[test]
512 fn decision_matrix_is_exhaustive() {
513 let home = temp_home();
514 let path = home.path();
515
516 // Row: nobody has said anything. Default off is not an answer.
517 assert!(matches!(
518 decide_in_home(
519 Some(path),
520 &resolved(false, false, None),
521 &SetupState::default(),
522 Surface::Tui
523 ),
524 TelemetryDecision::ForcedOff
525 ));
526
527 // Row: a human said off. That is an answer.
528 assert!(matches!(
529 decide_in_home(
530 Some(path),
531 &resolved(false, true, None),
532 &accepted_setup(),
533 Surface::Tui
534 ),
535 TelemetryDecision::OptedOut
536 ));
537
538 // Row: on, but never asked. A pre-existing `telemetry = true` is not
539 // consent — the key has been settable and inert for a long time.
540 assert!(matches!(
541 decide_in_home(
542 Some(path),
543 &resolved(true, false, None),
544 &SetupState::default(),
545 Surface::Tui
546 ),
547 TelemetryDecision::ForcedOff
548 ));
549
550 // Row: on, asked, declined.
551 assert!(matches!(
552 decide_in_home(
553 Some(path),
554 &resolved(true, false, None),
555 &declined_setup(),
556 Surface::Tui
557 ),
558 TelemetryDecision::OptedOut
559 ));
560
561 // Row: on, but the notice content changed since they answered.
562 assert!(matches!(
563 decide_in_home(
564 Some(path),
565 &resolved(true, false, None),
566 &stale_setup(),
567 Surface::Tui
568 ),
569 TelemetryDecision::ForcedOff
570 ));
571
572 // Row: on and accepted, no home to keep state in.
573 assert!(matches!(
574 decide_in_home(
575 None,
576 &resolved(true, false, None),
577 &accepted_setup(),
578 Surface::Tui
579 ),
580 TelemetryDecision::ForcedOff
581 ));
582
583 // Row: on and accepted, plaintext endpoint to a public host.
584 assert!(matches!(
585 decide_in_home(
586 Some(path),
587 &resolved(true, false, Some("http://example.com/t")),
588 &accepted_setup(),
589 Surface::Tui
590 ),
591 TelemetryDecision::ForcedOff
592 ));
593
594 // Row: on and accepted, no endpoint — the dry-run sink, which resolution
595 // reaches from an explicitly empty `telemetry_endpoint`. (The *shipped*
596 // default is `DEFAULT_TELEMETRY_ENDPOINT`; this predicate never sees it,
597 // because it reads an already-resolved value.)
598 let decision = decide_in_home(
599 Some(path),
600 &resolved(true, false, None),
601 &accepted_setup(),
602 Surface::Exec,
603 );
604 let TelemetryDecision::Enabled(consent) = decision else {
605 panic!("an accepted, endpoint-less machine must be Enabled");
606 };
607 assert_eq!(consent.endpoint(), None);
608 assert_eq!(consent.surface(), Surface::Exec);
609 assert_eq!(consent.root(), root_of(&home));
610
611 // Row: on and accepted, https endpoint.
612 assert!(
613 decide_in_home(
614 Some(path),
615 &resolved(true, false, Some("https://example.com/t")),
616 &accepted_setup(),
617 Surface::Tui
618 )
619 .is_enabled()
620 );
621
622 // Row: every headless surface is reachable, because consent is
623 // machine-scoped: a TTY-recorded decision authorizes later exec, cli,
624 // app-server, mcp-server, and serve runs on the same home.
625 for surface in Surface::ALL {
626 assert!(
627 decide_in_home(
628 Some(path),
629 &resolved(true, false, None),
630 &accepted_setup(),
631 *surface
632 )
633 .is_enabled(),
634 "{surface:?} must be able to emit on a consenting machine"
635 );
636 }
637 }
638
639 #[test]
640 fn an_unparseable_env_value_forces_off_and_does_not_wipe() {
641 // The floor in `codewhale-config` turns an unreadable `CODEWHALE_TELEMETRY`
642 // into `telemetry == false` *without* setting `telemetry_explicit_off`. A
643 // typo is not a user answer and must never destroy state.
644 let home = temp_home();
645 let root = root_of(&home);
646 buffer::ensure_dir(&root).expect("create root");
647 let buffer_path = buffer::buffer_path(&root);
648 buffer::append(
649 &root,
650 &buffer_path,
651 "{\"event\":\"session_start\",\"source\":\"api\"}",
652 )
653 .expect("seed");
654
655 let decision = decide_in_home(
656 Some(home.path()),
657 &resolved(false, false, None),
658 &accepted_setup(),
659 Surface::Tui,
660 );
661 assert!(matches!(decision, TelemetryDecision::ForcedOff));
662 assert!(!buffer::tombstone_present(&root));
663 assert_eq!(buffer::read_lines(&buffer_path).len(), 1);
664 }
665
666 #[test]
667 fn only_opt_out_touches_disk() {
668 // Every ForcedOff row against a seeded, consenting home must leave it
669 // byte-identical. This is the finding that a "wipe on resolved false" would
670 // have broken: `false` is the *default*, so it fired on every ordinary run.
671 let forced_off_rows: Vec<(ResolvedRuntimeOptions, SetupState)> = vec![
672 (resolved(false, false, None), accepted_setup()),
673 (resolved(true, false, None), SetupState::default()),
674 (resolved(true, false, None), stale_setup()),
675 (
676 resolved(true, false, Some("http://example.com/t")),
677 accepted_setup(),
678 ),
679 ];
680
681 for (options, setup) in forced_off_rows {
682 let home = temp_home();
683 let root = root_of(&home);
684 buffer::ensure_dir(&root).expect("create root");
685 let before = seed_consenting_home(&root);
686
687 let decision = decide_in_home(Some(home.path()), &options, &setup, Surface::Tui);
688 assert!(
689 matches!(decision, TelemetryDecision::ForcedOff),
690 "expected ForcedOff, got {}",
691 decision.label()
692 );
693 assert_eq!(snapshot(&root), before, "a ForcedOff run touched disk");
694 }
695
696 // Every OptedOut row wipes: tombstone present, data truncated, lock file
697 // still present, identity gone.
698 let home = temp_home();
699 let root = root_of(&home);
700 buffer::ensure_dir(&root).expect("create root");
701 seed_consenting_home(&root);
702
703 let decision = decide_in_home(
704 Some(home.path()),
705 &resolved(false, true, None),
706 &accepted_setup(),
707 Surface::Tui,
708 );
709 assert!(matches!(decision, TelemetryDecision::OptedOut));
710 assert!(buffer::tombstone_present(&root));
711 assert!(buffer::buffer_path(&root).exists());
712 assert!(buffer::read_lines(&buffer::buffer_path(&root)).is_empty());
713 assert!(buffer::read_lines(&buffer::dryrun_path(&root)).is_empty());
714 assert!(
715 buffer::lock_path(&root).exists(),
716 "the lock file must survive a wipe: unlinking it leaves appenders on a dead inode"
717 );
718 assert!(!buffer::install_id_path(&root).exists());
719 assert!(!buffer::state_path(&root).exists());
720 }
721
722 #[test]
723 fn the_tombstone_outlives_every_run_the_opt_out_covers() {
724 // `docs/TELEMETRY.md` says the opt-out's tombstone survives, and an
725 // adversary showed it did not: one ordinary run afterwards called
726 // `buffer::arm`, which removes the tombstone, and minted a fresh install
727 // id. Both halves of that are now impossible, and for the same reason —
728 // the opt-out is a *persisted* statement, so every later run re-reads it,
729 // takes the OptedOut branch again, and never reaches arming at all.
730 let home = temp_home();
731 let root = root_of(&home);
732 buffer::ensure_dir(&root).expect("create root");
733 seed_consenting_home(&root);
734
735 // The user writes `telemetry = false`.
736 let opted_out = resolved(false, true, None);
737 assert!(matches!(
738 decide_in_home(
739 Some(home.path()),
740 &opted_out,
741 &accepted_setup(),
742 Surface::Tui
743 ),
744 TelemetryDecision::OptedOut
745 ));
746 assert!(buffer::tombstone_present(&root));
747 let after_wipe = snapshot(&root);
748
749 // Three more launches of any surface, with the setting still in place.
750 for surface in [Surface::Tui, Surface::Exec, Surface::AppServer] {
751 let decision = decide_in_home(Some(home.path()), &opted_out, &accepted_setup(), surface);
752 assert!(
753 matches!(decision, TelemetryDecision::OptedOut),
754 "{surface:?} re-read the opt-out as {}",
755 decision.label()
756 );
757 assert!(
758 buffer::tombstone_present(&root),
759 "{surface:?} cleared the tombstone"
760 );
761 assert!(
762 !buffer::install_id_path(&root).exists(),
763 "{surface:?} minted a new identity for an opted-out machine"
764 );
765 assert_eq!(snapshot(&root), after_wipe, "{surface:?} touched disk");
766 }
767
768 // Only writing the setting back turns collection on again, and that is the
769 // one path allowed to clear the tombstone.
770 assert!(
771 decide_in_home(
772 Some(home.path()),
773 &resolved(true, false, None),
774 &accepted_setup(),
775 Surface::Tui,
776 )
777 .is_enabled()
778 );
779 buffer::arm(&root).expect("re-consent arms");
780 assert!(!buffer::tombstone_present(&root));
781 }
782
783 #[test]
784 fn a_run_scoped_kill_switch_costs_a_consenting_user_nothing() {
785 // The documented one-command recipe — `CODEWHALE_TELEMETRY=0 codewhale` —
786 // used to take the destructive opt-out branch, so it deleted the install
787 // id and truncated the user's own dry-run records every time it was used.
788 // The resolver now reports that as "off, but nobody revoked anything", and
789 // this is the half of that contract the telemetry crate owns.
790 let home = temp_home();
791 let root = root_of(&home);
792 buffer::ensure_dir(&root).expect("create root");
793 let before = seed_consenting_home(&root);
794 let identity = std::fs::read(buffer::install_id_path(&root)).expect("seeded install id");
795
796 for _ in 0..3 {
797 let decision = decide_in_home(
798 Some(home.path()),
799 // `telemetry == false`, `telemetry_explicit_off == false`: the
800 // shape a run-scoped kill switch resolves to.
801 &resolved(false, false, None),
802 &accepted_setup(),
803 Surface::Exec,
804 );
805 assert!(matches!(decision, TelemetryDecision::ForcedOff));
806 }
807
808 assert_eq!(snapshot(&root), before, "a kill-switch run touched disk");
809 assert!(!buffer::tombstone_present(&root));
810 assert_eq!(
811 std::fs::read(buffer::install_id_path(&root)).expect("install id"),
812 identity,
813 "the install id churned across a kill-switch run"
814 );
815 }
816
817 #[test]
818 fn an_opt_out_on_a_fresh_home_creates_nothing() {
819 let home = temp_home();
820 let root = root_of(&home);
821 let decision = decide_in_home(
822 Some(home.path()),
823 &resolved(false, true, None),
824 &SetupState::default(),
825 Surface::Tui,
826 );
827 assert!(matches!(decision, TelemetryDecision::OptedOut));
828 assert!(
829 !root.exists(),
830 "a user who never opted in must not get a telemetry directory for saying no"
831 );
832 }
833
834 fn seed_consenting_home(root: &Path) -> Vec<(String, Vec<u8>)> {
835 buffer::append(
836 root,
837 &buffer::buffer_path(root),
838 "{\"event\":\"session_start\",\"source\":\"interactive\"}",
839 )
840 .expect("seed buffer");
841 buffer::append_locked(root, &buffer::dryrun_path(root), "{\"schema_version\":1}")
842 .expect("seed dryrun");
843 envelope::read_or_create_install_id(root).expect("seed install id");
844 envelope::write_state(root, &envelope::TelemetryState::default()).expect("seed state");
845 snapshot(root)
846 }
847
848 fn snapshot(root: &Path) -> Vec<(String, Vec<u8>)> {
849 let Ok(entries) = std::fs::read_dir(root) else {
850 return Vec::new();
851 };
852 let mut out: Vec<(String, Vec<u8>)> = entries
853 .filter_map(Result::ok)
854 .map(|entry| {
855 let name = entry.file_name().to_string_lossy().to_string();
856 let body = std::fs::read(entry.path()).unwrap_or_default();
857 (name, body)
858 })
859 .collect();
860 out.sort();
861 out
862 }
863
864 #[test]
865 fn failed_wipe_fails_closed() {
866 let home = temp_home();
867 let root = root_of(&home);
868 buffer::ensure_dir(&root).expect("create root");
869 seed_consenting_home(&root);
870
871 // Make the buffer un-truncatable. The tombstone is written first, so even
872 // when the rest of the wipe fails the buffer is permanently undrainable.
873 let buffer_path = buffer::buffer_path(&root);
874 let readonly_worked = make_read_only(&buffer_path);
875
876 let result = buffer::wipe(&root);
877 assert!(
878 buffer::tombstone_present(&root),
879 "the tombstone must survive"
880 );
881 if readonly_worked {
882 assert!(result.is_err(), "a failed truncate must be reported");
883 }
884 assert!(
885 buffer::drain(&root).is_empty(),
886 "a tombstoned buffer must never drain, wipe failure or not"
887 );
888 assert!(
889 buffer::append(
890 &root,
891 &buffer_path,
892 "{\"event\":\"session_start\",\"source\":\"api\"}"
893 )
894 .is_none(),
895 "a tombstoned buffer must never accept an append"
896 );
897 }
898
899 #[cfg(unix)]
900 fn make_read_only(path: &Path) -> bool {
901 use std::os::unix::fs::PermissionsExt as _;
902 // Root ignores the mode bits, so this fixture cannot be relied on there.
903 if geteuid_is_root() {
904 return false;
905 }
906 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o400)).is_ok()
907 }
908
909 #[cfg(unix)]
910 fn geteuid_is_root() -> bool {
911 unsafe extern "C" {
912 fn geteuid() -> u32;
913 }
914 unsafe { geteuid() == 0 }
915 }
916
917 #[cfg(not(unix))]
918 fn make_read_only(_path: &Path) -> bool {
919 false
920 }
921
922 // --------------------------------------------------------------- endpoints --
923
924 #[test]
925 fn plain_http_is_rejected_except_on_loopback() {
926 assert!(validate_endpoint("https://example.com/t").is_ok());
927 assert_eq!(
928 validate_endpoint("http://example.com/t"),
929 Err(EndpointError::InsecureScheme)
930 );
931 assert!(validate_endpoint("http://127.0.0.1:9/x").is_ok());
932 assert!(validate_endpoint("http://localhost:9/x").is_ok());
933 assert!(validate_endpoint("http://[::1]:9/x").is_ok());
934 assert_eq!(
935 validate_endpoint("ftp://example.com/t"),
936 Err(EndpointError::UnsupportedScheme)
937 );
938 assert_eq!(
939 validate_endpoint("example.com"),
940 Err(EndpointError::Unparseable)
941 );
942 }
943
944 #[test]
945 fn no_environment_variable_can_authorize_plaintext() {
946 // `CODEWHALE_ALLOW_INSECURE_HTTP` is a *provider* trust decision — it
947 // permits an insecure model base URL for harnesses that intercept model
948 // traffic. Honouring it here would let that decision also authorize
949 // telemetry POSTs to an arbitrary host. No override of any kind exists.
950 unsafe { std::env::set_var("CODEWHALE_ALLOW_INSECURE_HTTP", "1") };
951 let with_env = validate_endpoint("http://example.com/t");
952 unsafe { std::env::remove_var("CODEWHALE_ALLOW_INSECURE_HTTP") };
953 assert_eq!(with_env, Err(EndpointError::InsecureScheme));
954 }
955
956 // -------------------------------------------------------- install identity --
957
958 #[test]
959 fn install_id_is_random_and_rotates() {
960 let first_home = temp_home();
961 let second_home = temp_home();
962 let first_root = root_of(&first_home);
963 let second_root = root_of(&second_home);
964
965 let first = envelope::read_or_create_install_id(&first_root).expect("mint");
966 let second = envelope::read_or_create_install_id(&second_root).expect("mint");
967 assert_ne!(
968 first.install_id, second.install_id,
969 "two fresh homes must not share an id"
970 );
971 assert!(uuid::Uuid::parse_str(&first.install_id).is_ok());
972
973 // Stable across reads on the same home.
974 let again = envelope::read_or_create_install_id(&first_root).expect("re-read");
975 assert_eq!(first.install_id, again.install_id);
976
977 // Not a function of hostname, user, or path: nothing derivable appears in
978 // the value, and two homes under the same user differ.
979 for derived in [
980 std::env::var("USER").unwrap_or_default(),
981 std::env::var("HOME").unwrap_or_default(),
982 first_root.display().to_string(),
983 ] {
984 if derived.trim().is_empty() {
985 continue;
986 }
987 assert!(!first.install_id.contains(derived.trim()));
988 }
989
990 // 91 days old rotates.
991 let stale = envelope::InstallId {
992 schema_version: 1,
993 install_id: first.install_id.clone(),
994 rotated_at: (chrono::Utc::now() - chrono::Duration::days(91))
995 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
996 };
997 codewhale_config::persistence::atomic_write_json(&buffer::install_id_path(&first_root), &stale)
998 .expect("write stale id");
999 let rotated = envelope::read_or_create_install_id(&first_root).expect("rotate");
1000 assert_ne!(rotated.install_id, first.install_id);
1001 assert_ne!(rotated.rotated_at, stale.rotated_at);
1002 }
1003
1004 // ------------------------------------------------------------------ buffer --
1005
1006 fn line(n: usize) -> String {
1007 serde_json::to_string(&Event::Panic {
1008 site: format!("crates/tui/src/x.rs:{n}:1"),
1009 })
1010 .expect("serialize")
1011 }
1012
1013 #[test]
1014 fn ring_buffer_drops_oldest_at_cap_for_both_sinks() {
1015 let home = temp_home();
1016 let root = root_of(&home);
1017 for path in [buffer::buffer_path(&root), buffer::dryrun_path(&root)] {
1018 for n in 0..600 {
1019 buffer::append(&root, &path, &line(n)).expect("append");
1020 }
1021 let kept = buffer::read_lines(&path);
1022 assert_eq!(kept.len(), buffer::MAX_EVENTS, "{}", path.display());
1023 assert_eq!(
1024 kept.first().expect("first"),
1025 &line(600 - buffer::MAX_EVENTS)
1026 );
1027 assert_eq!(kept.last().expect("last"), &line(599));
1028 }
1029 }
1030
1031 #[test]
1032 fn probe_threshold_cannot_hide_an_over_cap_buffer() {
1033 // The append path skips the count probe below a byte threshold. That is
1034 // only safe if `MAX_EVENTS` lines cannot fit under it.
1035 let shortest = serde_json::to_string(&Event::SessionStart {
1036 source: SessionSource::Api,
1037 })
1038 .expect("serialize");
1039 let floor = (shortest.len() as u64 + 1) * buffer::MAX_EVENTS as u64;
1040 assert!(
1041 floor > 4096,
1042 "the shortest event is now small enough that {} of them fit under the probe threshold",
1043 buffer::MAX_EVENTS
1044 );
1045 }
1046
1047 #[test]
1048 fn a_line_over_pipe_buf_is_dropped_not_split() {
1049 let home = temp_home();
1050 let root = root_of(&home);
1051 let path = buffer::buffer_path(&root);
1052 let huge = format!("{{\"pad\":\"{}\"}}", "x".repeat(buffer::MAX_LINE_BYTES));
1053 assert!(buffer::append(&root, &path, &huge).is_none());
1054 assert!(buffer::read_lines(&path).is_empty());
1055 }
1056
1057 #[test]
1058 fn drain_skips_a_torn_trailing_line() {
1059 let home = temp_home();
1060 let root = root_of(&home);
1061 let path = buffer::buffer_path(&root);
1062 buffer::append(&root, &path, &line(1)).expect("append");
1063 buffer::append(&root, &path, &line(2)).expect("append");
1064 // `std::process::exit` on the signal path can cut a concurrent write.
1065 {
1066 use std::io::Write as _;
1067 let mut file = std::fs::OpenOptions::new()
1068 .append(true)
1069 .open(&path)
1070 .expect("open");
1071 file.write_all(b"{\"event\":\"pan").expect("tear");
1072 }
1073 let drained = buffer::drain(&root);
1074 assert_eq!(drained.len(), 3, "the drain returns raw lines");
1075 let parsed: Vec<Event> = drained
1076 .iter()
1077 .filter_map(|l| serde_json::from_str(l).ok())
1078 .collect();
1079 assert_eq!(parsed.len(), 2, "the torn line must not reach a batch");
1080 assert!(buffer::read_lines(&path).is_empty(), "drain truncates");
1081 }
1082
1083 #[test]
1084 fn append_never_blocks_on_a_held_lock() {
1085 let home = temp_home();
1086 let root = root_of(&home);
1087 buffer::ensure_dir(&root).expect("create root");
1088 let path = buffer::buffer_path(&root);
1089
1090 let held = std::sync::Arc::new(std::sync::Barrier::new(2));
1091 let release = std::sync::Arc::new(std::sync::Barrier::new(2));
1092 let holder_root = root.clone();
1093 let holder_held = held.clone();
1094 let holder_release = release.clone();
1095 let holder = std::thread::spawn(move || {
1096 buffer::with_lock(&holder_root, || {
1097 holder_held.wait();
1098 holder_release.wait();
1099 Ok(())
1100 })
1101 });
1102
1103 held.wait();
1104 let started = Instant::now();
1105 buffer::append(&root, &path, &line(7)).expect("append under a held lock");
1106 let elapsed = started.elapsed();
1107 release.wait();
1108 holder.join().expect("holder thread").expect("holder lock");
1109
1110 assert!(
1111 elapsed < Duration::from_millis(250),
1112 "an append waited {elapsed:?} on a lock it must never take"
1113 );
1114 assert_eq!(buffer::read_lines(&path).len(), 1);
1115 }
1116
1117 #[test]
1118 fn a_tombstoned_buffer_never_appends_or_drains() {
1119 let home = temp_home();
1120 let root = root_of(&home);
1121 buffer::ensure_dir(&root).expect("create root");
1122 buffer::append(&root, &buffer::buffer_path(&root), &line(1)).expect("append");
1123 buffer::wipe(&root).expect("wipe");
1124
1125 assert!(buffer::append(&root, &buffer::buffer_path(&root), &line(2)).is_none());
1126 assert!(buffer::append_locked(&root, &buffer::dryrun_path(&root), "{}").is_none());
1127 assert!(buffer::drain(&root).is_empty());
1128 }
1129
1130 #[test]
1131 fn arming_truncates_a_pre_consent_buffer() {
1132 let home = temp_home();
1133 let root = root_of(&home);
1134 buffer::ensure_dir(&root).expect("create root");
1135 buffer::append(&root, &buffer::buffer_path(&root), &line(1)).expect("append");
1136 buffer::wipe(&root).expect("wipe");
1137
1138 buffer::arm(&root).expect("arm");
1139 assert!(!buffer::tombstone_present(&root));
1140 assert!(buffer::read_lines(&buffer::buffer_path(&root)).is_empty());
1141 }
1142
1143 // ------------------------------------------------------------ unarmed gate --
1144
1145 #[test]
1146 fn record_blocking_is_a_noop_when_unarmed() {
1147 // The process panic hook is installed before the command line is parsed, so
1148 // this is the state the hook runs in for every user who never opted in.
1149 let home = temp_home();
1150 let root = root_of(&home);
1151 assert!(!crate::is_armed());
1152 crate::record_blocking(Event::Panic {
1153 site: "crates/tui/src/x.rs:1:1".to_string(),
1154 });
1155 crate::record(Event::SessionStart {
1156 source: SessionSource::Interactive,
1157 });
1158 crate::set_exit_class(ExitClass::Panic);
1159 assert_eq!(crate::exit_class(), ExitClass::Clean);
1160 assert_eq!(
1161 crate::flush_blocking(Duration::from_millis(10)),
1162 crate::FlushOutcome::Empty
1163 );
1164 assert!(!crate::startup_drain_due());
1165 assert!(
1166 !root.exists(),
1167 "an unarmed process must create no directory"
1168 );
1169 }
1170
1171 // ------------------------------------------------------------------ client --
1172
1173 #[test]
1174 fn endpoint_unset_writes_the_dry_run_sink() {
1175 let home = temp_home();
1176 let root = root_of(&home);
1177 let batch = every_field_batch();
1178 assert_eq!(
1179 crate::client::send(&root, None, &batch),
1180 crate::client::SendOutcome::DryRun
1181 );
1182 let written = buffer::read_lines(&buffer::dryrun_path(&root));
1183 assert_eq!(written.len(), 1);
1184 let round_tripped: Batch = serde_json::from_str(&written[0]).expect("parse dry-run batch");
1185 assert_eq!(round_tripped, batch);
1186 assert!(
1187 !buffer::buffer_path(&root).exists(),
1188 "the dry-run sink is a separate file from the pending buffer"
1189 );
1190 }
1191
1192 #[test]
1193 fn a_tombstoned_home_sends_nothing_even_with_an_endpoint() {
1194 let home = temp_home();
1195 let root = root_of(&home);
1196 buffer::ensure_dir(&root).expect("create root");
1197 buffer::wipe(&root).expect("wipe");
1198 // The tombstone check fires before any client is constructed, so this
1199 // asserts on the sink rather than on network timing.
1200 assert_eq!(
1201 crate::client::send(&root, Some("http://127.0.0.1:1/t"), &every_field_batch()),
1202 crate::client::SendOutcome::Dropped
1203 );
1204 assert!(buffer::read_lines(&buffer::dryrun_path(&root)).is_empty());
1205 }
1206
1207 // ----------------------------------------------------------------- buckets --
1208
1209 #[test]
1210 fn buckets_are_half_open_at_every_boundary() {
1211 assert_eq!(DurationBucket::from_secs(0), DurationBucket::Lt1m);
1212 assert_eq!(DurationBucket::from_secs(59), DurationBucket::Lt1m);
1213 assert_eq!(DurationBucket::from_secs(60), DurationBucket::OneToTen);
1214 assert_eq!(DurationBucket::from_secs(599), DurationBucket::OneToTen);
1215 assert_eq!(DurationBucket::from_secs(600), DurationBucket::TenToSixty);
1216 assert_eq!(DurationBucket::from_secs(3599), DurationBucket::TenToSixty);
1217 assert_eq!(DurationBucket::from_secs(3600), DurationBucket::Gt60m);
1218
1219 assert_eq!(ColdStartBucket::from_millis(249), ColdStartBucket::Lt250);
1220 assert_eq!(ColdStartBucket::from_millis(250), ColdStartBucket::Mid);
1221 assert_eq!(ColdStartBucket::from_millis(999), ColdStartBucket::Mid);
1222 assert_eq!(ColdStartBucket::from_millis(1000), ColdStartBucket::Slow);
1223 assert_eq!(ColdStartBucket::from_millis(2999), ColdStartBucket::Slow);
1224 assert_eq!(ColdStartBucket::from_millis(3000), ColdStartBucket::Gte3000);
1225
1226 let mut wall = TurnWall::default();
1227 for secs in [0, 4, 5, 29, 30, 119, 120, 10_000] {
1228 wall.observe_secs(secs);
1229 }
1230 assert_eq!(wall.lt_5s, 2);
1231 assert_eq!(wall.five_to_thirty, 2);
1232 assert_eq!(wall.thirty_to_onetwenty, 2);
1233 assert_eq!(wall.gte_120s, 2);
1234 }
1235
1236 #[test]
1237 fn exit_class_round_trips_through_the_atomic_encoding() {
1238 for class in ExitClass::ALL {
1239 assert_eq!(ExitClass::from_u8(class.as_u8()), *class);
1240 }
1241 // An exit code is never the source: 130 is both a cancelled turn and SIGINT.
1242 assert_eq!(ExitClass::from_u8(130), ExitClass::Clean);
1243 }
1244
1245 // ---------------------------------------------------------------- counters --
1246
1247 #[test]
1248 fn custom_provider_emits_literal_custom() {
1249 let counters = crate::SessionCounters::default();
1250 counters.record_provider(codewhale_config::ProviderKind::Custom);
1251 counters.record_provider(codewhale_config::ProviderKind::Deepseek);
1252 counters.record_provider(codewhale_config::ProviderKind::Custom);
1253 let providers = counters.providers();
1254 assert_eq!(
1255 providers,
1256 vec!["custom".to_string(), "deepseek".to_string()]
1257 );
1258 }
1259
1260 #[test]
1261 fn counter_bumps_land_in_the_named_field() {
1262 let counters = crate::SessionCounters::default();
1263 counters.bump(crate::Counter::Turns);
1264 counters.bump(crate::Counter::Turns);
1265 counters.bump(crate::Counter::CommandPaletteOpen);
1266 counters.bump_error(crate::ErrorCounter::ProviderHttp5xx);
1267 counters.observe_turn_secs(3);
1268
1269 let snapshot = counters.counters();
1270 assert_eq!(snapshot.turns, 2);
1271 assert_eq!(snapshot.command_palette_open, 1);
1272 assert_eq!(snapshot.tool_calls, 0);
1273 assert_eq!(counters.errors().provider_http_5xx, 1);
1274 assert_eq!(counters.turn_wall().lt_5s, 1);
1275 }
1276
1277 #[test]
1278 fn http_status_maps_to_the_class_counter_and_nothing_else() {
1279 assert_eq!(
1280 crate::counters::http_status_counter(404),
1281 Some(crate::ErrorCounter::ProviderHttp4xx)
1282 );
1283 assert_eq!(
1284 crate::counters::http_status_counter(503),
1285 Some(crate::ErrorCounter::ProviderHttp5xx)
1286 );
1287 assert_eq!(crate::counters::http_status_counter(200), None);
1288 assert_eq!(crate::counters::http_status_counter(302), None);
1289 }
1290
1291 // --------------------------------------------------------------- API shape --
1292
1293 #[test]
1294 fn no_public_api_accepts_a_bare_bool() {
1295 // `init` takes a `TelemetryConsent` **by value**, and `TelemetryConsent` has
1296 // no public constructor other than `decide`. This is a shape assertion: it
1297 // stops compiling if the signature is ever widened.
1298 let init: fn(crate::TelemetryConsent) = crate::init;
1299 let _ = init;
1300
1301 // The only source of one is `decide`, which needs both a resolved config
1302 // and a setup-state record — neither of which a caller can fake into "yes"
1303 // without the user having answered.
1304 let home = temp_home();
1305 assert!(
1306 !decide_in_home(
1307 Some(home.path()),
1308 &resolved(true, false, None),
1309 &SetupState::default(),
1310 Surface::Cli
1311 )
1312 .is_enabled()
1313 );
1314 }
1315
1316 // ------------------------------------------------- docs and code are welded --
1317
1318 const TELEMETRY_DOC: &str = include_str!("../../../docs/TELEMETRY.md");
1319 const GOLDEN_V1: &str = include_str!("../tests/golden/v1.json");
1320
1321 /// Extract the fenced ```jsonc blocks from the schema doc, in order.
1322 fn jsonc_blocks(doc: &str) -> Vec<String> {
1323 let mut blocks = Vec::new();
1324 let mut current: Option<String> = None;
1325 for raw in doc.lines() {
1326 let line = raw.trim_end();
1327 match current.as_mut() {
1328 None => {
1329 if line.trim() == "```jsonc" {
1330 current = Some(String::new());
1331 }
1332 }
1333 Some(body) => {
1334 if line.trim() == "```" {
1335 blocks.push(std::mem::take(body));
1336 current = None;
1337 } else {
1338 body.push_str(line);
1339 body.push('\n');
1340 }
1341 }
1342 }
1343 }
1344 blocks
1345 }
1346
1347 /// Every `"name":` key in a jsonc block, including nested objects. Values are
1348 /// never matched: a key is an identifier-shaped string followed by a colon, and
1349 /// no value in these blocks has that shape.
1350 fn documented_keys(block: &str) -> std::collections::BTreeSet<String> {
1351 let bytes: Vec<char> = block.chars().collect();
1352 let mut keys = std::collections::BTreeSet::new();
1353 let mut index = 0;
1354 while index < bytes.len() {
1355 if bytes[index] != '"' {
1356 index += 1;
1357 continue;
1358 }
1359 let start = index + 1;
1360 let mut end = start;
1361 while end < bytes.len() && bytes[end] != '"' {
1362 end += 1;
1363 }
1364 if end >= bytes.len() {
1365 break;
1366 }
1367 let candidate: String = bytes[start..end].iter().collect();
1368 let mut after = end + 1;
1369 while after < bytes.len() && bytes[after] == ' ' {
1370 after += 1;
1371 }
1372 let is_key = after < bytes.len() && bytes[after] == ':';
1373 let identifier_shaped = !candidate.is_empty()
1374 && candidate
1375 .chars()
1376 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
1377 if is_key && identifier_shaped {
1378 keys.insert(candidate);
1379 }
1380 index = end + 1;
1381 }
1382 keys
1383 }
1384
1385 /// Every key of a serialized value, including nested objects.
1386 fn serialized_keys(value: &Value) -> std::collections::BTreeSet<String> {
1387 let mut keys = std::collections::BTreeSet::new();
1388 fn walk(value: &Value, out: &mut std::collections::BTreeSet<String>) {
1389 match value {
1390 Value::Object(map) => {
1391 for (key, item) in map {
1392 out.insert(key.clone());
1393 walk(item, out);
1394 }
1395 }
1396 Value::Array(items) => {
1397 for item in items {
1398 walk(item, out);
1399 }
1400 }
1401 _ => {}
1402 }
1403 }
1404 walk(value, &mut keys);
1405 keys
1406 }
1407
1408 /// First-column entries of the markdown table that follows `heading`.
1409 fn table_first_column(doc: &str, heading: &str) -> Vec<String> {
1410 let mut lines = doc.lines().skip_while(|line| line.trim() != heading);
1411 let mut rows = Vec::new();
1412 let mut in_table = false;
1413 for line in lines.by_ref() {
1414 let trimmed = line.trim();
1415 if !trimmed.starts_with('|') {
1416 if in_table {
1417 break;
1418 }
1419 continue;
1420 }
1421 in_table = true;
1422 let first = trimmed
1423 .trim_matches('|')
1424 .split('|')
1425 .next()
1426 .unwrap_or("")
1427 .trim();
1428 if first.is_empty() || first.chars().all(|c| c == '-' || c == ':') {
1429 continue;
1430 }
1431 let name = first.trim_matches('`').to_string();
1432 if name.eq_ignore_ascii_case("field") || name.eq_ignore_ascii_case("file") {
1433 continue;
1434 }
1435 rows.push(name);
1436 }
1437 rows
1438 }
1439
1440 #[test]
1441 fn event_field_names_match_documented_schema() {
1442 let blocks = jsonc_blocks(TELEMETRY_DOC);
1443 assert_eq!(
1444 blocks.len(),
1445 5,
1446 "expected one jsonc block for the envelope and one per event variant; \
1447 a parse miss must fail rather than silently pass"
1448 );
1449
1450 // The envelope.
1451 let documented = documented_keys(&blocks[0]);
1452 let declared: std::collections::BTreeSet<String> =
1453 Batch::FIELDS.iter().map(|f| (*f).to_string()).collect();
1454 assert_eq!(documented.len(), Batch::FIELDS.len());
1455 assert_eq!(
1456 documented, declared,
1457 "the batch envelope drifted from the doc"
1458 );
1459
1460 // One block per event variant, in the order the doc presents them.
1461 let events = every_event();
1462 assert_eq!(events.len(), blocks.len() - 1);
1463 for (index, event) in events.iter().enumerate() {
1464 let block = &blocks[index + 1];
1465 let documented = documented_keys(block);
1466 let serialized = serialized_keys(&serde_json::to_value(event).expect("serialize"));
1467 assert!(
1468 !documented.is_empty(),
1469 "no keys parsed out of the {} block",
1470 event.name()
1471 );
1472 assert_eq!(
1473 documented,
1474 serialized,
1475 "the `{}` event drifted from the doc",
1476 event.name()
1477 );
1478 }
1479
1480 // The envelope table, row for row.
1481 let envelope_rows =
1482 table_first_column(TELEMETRY_DOC, "### Batch envelope — sent on every POST");
1483 assert_eq!(
1484 envelope_rows.len(),
1485 Batch::FIELDS.len(),
1486 "the envelope table lost or gained a row: {envelope_rows:?}"
1487 );
1488 assert_eq!(
1489 envelope_rows,
1490 Batch::FIELDS
1491 .iter()
1492 .map(|f| (*f).to_string())
1493 .collect::<Vec<_>>()
1494 );
1495
1496 // The counters and errors tables, which are the two closed field sets a
1497 // contributor is most likely to extend without touching the doc.
1498 let counter_rows = table_first_column(
1499 TELEMETRY_DOC,
1500 "**`counters`** — closed field set. Every bump happens at the **call site**, never inside a conditionally-entered handler:",
1501 );
1502 assert_eq!(
1503 counter_rows,
1504 Counters::FIELDS
1505 .iter()
1506 .map(|f| (*f).to_string())
1507 .collect::<Vec<_>>(),
1508 "the counters table drifted from `Counters`"
1509 );
1510 let error_rows = table_first_column(
1511 TELEMETRY_DOC,
1512 "**`errors`** — closed field set. Every value is a **variant discriminant**, never `err.to_string()`:",
1513 );
1514 assert_eq!(
1515 error_rows,
1516 Errors::FIELDS
1517 .iter()
1518 .map(|f| (*f).to_string())
1519 .collect::<Vec<_>>(),
1520 "the errors table drifted from `Errors`"
1521 );
1522 }
1523
1524 #[test]
1525 fn golden_payload_v1() {
1526 // `crates/telemetry/tests/golden/v1.json` is one fully-populated instance of
1527 // the envelope and every event. Any field add, remove, or retype fails here
1528 // until the developer re-blesses it under a bumped `SCHEMA_VERSION` — and it
1529 // is also the artifact a future receiver author reads to know exactly what
1530 // v1 was.
1531 //
1532 // Re-bless with: `CODEWHALE_BLESS_TELEMETRY_GOLDEN=1 cargo test -p codewhale-telemetry`
1533 let batch = every_field_batch();
1534 assert_eq!(
1535 batch.schema_version, SCHEMA_VERSION,
1536 "the fixture must be built at the current schema version"
1537 );
1538 let mut rendered = serde_json::to_string_pretty(&batch).expect("serialize");
1539 rendered.push('\n');
1540
1541 if std::env::var("CODEWHALE_BLESS_TELEMETRY_GOLDEN").is_ok() {
1542 let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden/v1.json");
1543 std::fs::create_dir_all(path.parent().expect("parent")).expect("create golden dir");
1544 std::fs::write(&path, &rendered).expect("write golden");
1545 return;
1546 }
1547
1548 assert_eq!(
1549 rendered, GOLDEN_V1,
1550 "the v1 payload changed; bump SCHEMA_VERSION and re-bless the golden file"
1551 );
1552 }
1553
1554 #[test]
1555 fn version_comparison_names_install_upgrade_and_downgrade() {
1556 assert!(crate::version_is_older("0.9.3", "0.9.4"));
1557 assert!(crate::version_is_older("0.9", "0.9.4"));
1558 assert!(crate::version_is_older("0.10.0", "1.0.0"));
1559 assert!(!crate::version_is_older("0.9.4", "0.9.4"));
1560 assert!(!crate::version_is_older("0.9.5", "0.9.4"));
1561 // A pre-release suffix is not part of the ordering question being asked.
1562 assert!(!crate::version_is_older("0.9.4-rc.1", "0.9.4"));
1563 // Unparseable segments read as zero, so an unknown version never invents an
1564 // upgrade that did not happen.
1565 assert!(!crate::version_is_older("nightly", "0.0.0"));
1566 }
1567
1568 #[test]
1569 fn an_install_or_upgrade_is_reported_once_per_version() {
1570 let home = temp_home();
1571 let root = root_of(&home);
1572 buffer::ensure_dir(&root).expect("create telemetry dir");
1573
1574 // No prior record on this machine.
1575 let mut state = envelope::read_state(&root);
1576 assert_eq!(state.last_version, None);
1577
1578 // The state file is written before the event is queued, so the second
1579 // launch at the same version has nothing left to report.
1580 state.last_version = Some(env!("CARGO_PKG_VERSION").to_string());
1581 envelope::write_state(&root, &state).expect("write state");
1582 assert_eq!(
1583 envelope::read_state(&root).last_version.as_deref(),
1584 Some(env!("CARGO_PKG_VERSION"))
1585 );
1586
1587 // The previous version is read from this file and from nowhere else —
1588 // never from session history or config mtimes, which answer the same
1589 // question under a different privacy contract.
1590 let entries: Vec<String> = std::fs::read_dir(&root)
1591 .expect("read dir")
1592 .filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
1593 .collect();
1594 assert!(
1595 entries.iter().any(|name| name == "state.json"),
1596 "expected state.json in {entries:?}"
1597 );
1598 }
1599
1600 #[test]
1601 fn the_notice_promises_exactly_what_the_schema_collects() {
1602 use crate::notice;
1603
1604 let body = notice::NOTICE_BODY;
1605
1606 // Everything the envelope carries has to be described. `install_id` is
1607 // "a random ID stored on this machine"; the rest are named directly.
1608 for claim in [
1609 "which version you run",
1610 "OS and CPU family",
1611 "which features you used",
1612 "how long sessions ran",
1613 "how they ended",
1614 "random ID stored on this machine",
1615 "every 90 days",
1616 ] {
1617 assert!(
1618 body.contains(claim),
1619 "the notice does not describe: {claim}"
1620 );
1621 }
1622
1623 // And every red line has to be stated as *not collected*, not as
1624 // anonymized or sampled — two promises this client does not make.
1625 for red_line in [
1626 "prompts",
1627 "code",
1628 "file names",
1629 "paths",
1630 "repo or branch names",
1631 "model output",
1632 "model names",
1633 "credentials",
1634 ] {
1635 assert!(
1636 body.contains(red_line),
1637 "the notice does not disclaim: {red_line}"
1638 );
1639 }
1640 assert!(body.contains("Not sampled, not hashed"));
1641 assert!(!body.to_ascii_lowercase().contains("anonymized"));
1642
1643 // The two documented ways out, both of which are real.
1644 assert!(body.contains("codewhale config set telemetry false"));
1645 assert!(body.contains("CODEWHALE_TELEMETRY=0"));
1646 assert!(body.contains("docs/TELEMETRY.md"));
1647 }
1648
1649 #[test]
1650 fn only_an_affirmative_answer_is_an_answer() {
1651 use crate::notice::answer_is_yes;
1652
1653 assert!(answer_is_yes("y"));
1654 assert!(answer_is_yes("Y\n"));
1655 assert!(answer_is_yes(" yes \n"));
1656 // Enter, EOF, a typo, and a stray keystroke all decline. The default is
1657 // the safe direction and it is reachable without aiming.
1658 assert!(!answer_is_yes(""));
1659 assert!(!answer_is_yes("\n"));
1660 assert!(!answer_is_yes("n"));
1661 assert!(!answer_is_yes("ye"));
1662 assert!(!answer_is_yes("1"));
1663 assert!(!answer_is_yes("true"));
1664 }
1665
1666 #[test]
1667 fn the_notice_prompt_capitalises_the_declining_default() {
1668 // `[y/N]`, not `[Y/n]` and not `[y/n]`. The shape of the prompt is the
1669 // first thing a user reads about which way Enter goes.
1670 assert!(crate::notice::NOTICE_PROMPT.contains("[y/N]"));
1671 assert!(!crate::notice::NOTICE_PROMPT.contains("[Y/n]"));
1672 }
1673
1673 lines RUST