返回 CodeWhale
telemetry_kill_switch_dispatch.rs
根目录 / crates / cli / tests / telemetry_kill_switch_dispatch.rs
1 //! The kill switch has to reach the in-process runtime that would emit.
2 //!
3 //! The single `codewhale` binary resolves dispatcher overrides, states the
4 //! telemetry floor in its environment, and then calls `codewhale_tui::run`.
5 //! That runtime re-resolves telemetry before it can arm. These tests drive the
6 //! real binary through the keyless `features list` command and use the local
7 //! dry-run sink as the end-to-end observable: an enabled positive control must
8 //! write session events, while a kill switch must create no telemetry state.
9
10 #![cfg(unix)]
11
12 use std::fs;
13 use std::process::Command;
14
15 use codewhale_config::{SetupState, TELEMETRY_NOTICE_VERSION};
16 use tempfile::TempDir;
17
18 /// `CODEWHALE_TELEMETRY=0` beats `--telemetry true`, end to end.
19 ///
20 /// The positive control proves the runtime is enabled before the kill switch is
21 /// applied, so the zero-state assertion cannot pass vacuously.
22 #[test]
23 fn env_off_beats_cli_on_end_to_end() {
24 // Positive control first: the flag reaches the in-process runtime and
25 // arms its dry-run sink, so the assertion below is about the floor and not
26 // about a command that never crossed the dispatch boundary.
27 let on = dispatch_and_read_telemetry(None);
28 let dry_run = on
29 .dry_run
30 .expect("current consent plus `--telemetry true` must write the dry-run sink");
31 assert!(
32 dry_run.contains("\"event\":\"session_start\"")
33 && dry_run.contains("\"event\":\"session_end\""),
34 "the real in-process runtime must record a complete session: {dry_run}"
35 );
36
37 let off = dispatch_and_read_telemetry(Some("0"));
38 assert!(
39 !off.telemetry_dir_exists && off.dry_run.is_none(),
40 "`CODEWHALE_TELEMETRY=0` must beat `--telemetry true` before the runtime arms"
41 );
42 }
43
44 /// A real endpoint must remain queued locally instead of becoming short-CLI
45 /// network latency. The next interactive session owns delivery.
46 #[test]
47 fn short_cli_exit_persists_without_network_delivery() {
48 let evidence = dispatch_and_read_telemetry_with_endpoint(None, None, None, false);
49 let pending = evidence
50 .pending
51 .expect("short CLI must seal its pending telemetry before process exit");
52 assert!(
53 pending.contains("\"event\":\"session_start\"")
54 && pending.contains("\"event\":\"session_end\""),
55 "the pending buffer must contain the complete short CLI session: {pending}"
56 );
57 assert!(
58 evidence.dry_run.is_none(),
59 "a configured endpoint must stay pending rather than use the dry-run sink"
60 );
61 }
62
63 /// A value the resolver cannot parse resolves to off, rather than falling
64 /// through to the flag.
65 #[test]
66 fn an_unparseable_telemetry_env_value_keeps_the_in_process_runtime_off() {
67 let evidence = dispatch_and_read_telemetry(Some("maybe"));
68 assert!(
69 !evidence.telemetry_dir_exists && evidence.dry_run.is_none(),
70 "a typo in the kill switch must never arm the in-process runtime"
71 );
72 }
73
74 /// An explicit durable enable uses the Settings transition to clear old no.
75 /// The optional versioned compatibility command still rejects old versions.
76 #[test]
77 fn config_set_true_reenables_a_historical_decline_through_settings() {
78 let fixture = TempDir::new().expect("fixture root");
79 let home = fixture.path().join("home");
80 let codewhale_home = fixture.path().join("codewhale-home");
81 let workspace = fixture.path().join("workspace");
82 for dir in [&home, &codewhale_home, &workspace] {
83 fs::create_dir_all(dir).expect("create fixture dir");
84 }
85
86 let mut state = SetupState::default();
87 state.record_telemetry_notice("1", false);
88 let state_path = codewhale_home.join("setup_state.json");
89 state
90 .save_to(&state_path)
91 .expect("write historical decline");
92
93 let config_path = fixture.path().join("config.toml");
94 fs::write(&config_path, "telemetry_endpoint = \"\"\n").expect("write config");
95
96 let command = || {
97 let mut command = Command::new(codewhale_binary());
98 command
99 .current_dir(&workspace)
100 .env_clear()
101 .env("PATH", std::env::var_os("PATH").expect("PATH"))
102 .env("HOME", &home)
103 .env("USERPROFILE", &home)
104 .env("CODEWHALE_HOME", &codewhale_home)
105 .env("CODEWHALE_SECRET_BACKEND", "file")
106 .arg("--config")
107 .arg(&config_path);
108 command
109 };
110 let before = fs::read(&state_path).unwrap();
111 let declined = command()
112 .env("CODEWHALE_TELEMETRY", "true")
113 .args(["--telemetry", "true", "features", "list"])
114 .output()
115 .expect("run with a historical sidecar-only decline");
116 assert!(declined.status.success());
117 assert!(!codewhale_home.join("telemetry").exists());
118 assert_eq!(
119 fs::read(&state_path).unwrap(),
120 before,
121 "default-on and run-scoped on must preserve the old explicit no"
122 );
123 let output = command()
124 .args(["config", "set", "telemetry", "true"])
125 .output()
126 .expect("run config set");
127 assert!(
128 output.status.success(),
129 "config set failed\nstdout:\n{}\nstderr:\n{}",
130 String::from_utf8_lossy(&output.stdout),
131 String::from_utf8_lossy(&output.stderr)
132 );
133 assert!(
134 fs::read_to_string(&config_path)
135 .expect("read config")
136 .contains("telemetry = true")
137 );
138 let state = SetupState::load_from(&state_path).expect("read setup state");
139 assert!(state.telemetry_accepted(TELEMETRY_NOTICE_VERSION));
140 assert!(!state.telemetry_opted_out());
141
142 let outdated = command()
143 .args(["config", "telemetry", "--accept-notice", "3"])
144 .output()
145 .expect("attempt outdated consent");
146 assert!(!outdated.status.success());
147 assert!(
148 !SetupState::load_from(&state_path)
149 .expect("read enabled state")
150 .telemetry_opted_out()
151 );
152
153 let accepted = command()
154 .args([
155 "config",
156 "telemetry",
157 "--accept-notice",
158 TELEMETRY_NOTICE_VERSION,
159 ])
160 .output()
161 .expect("accept current notice");
162 assert!(
163 accepted.status.success(),
164 "current notice acceptance failed: {}",
165 String::from_utf8_lossy(&accepted.stderr)
166 );
167 let state = SetupState::load_from(&state_path).expect("read accepted state");
168 assert!(state.telemetry_accepted(TELEMETRY_NOTICE_VERSION));
169 assert!(!state.telemetry_opted_out());
170 }
171
172 #[test]
173 fn missing_preference_defaults_on_without_inventing_acceptance() {
174 for version in [None, Some("1"), Some("4")] {
175 let evidence = dispatch_and_read_telemetry_with_endpoint(None, Some(""), version, false);
176 let batch: serde_json::Value = serde_json::from_str(
177 evidence
178 .dry_run
179 .as_ref()
180 .expect("default-on writes dry run")
181 .lines()
182 .next()
183 .unwrap(),
184 )
185 .unwrap();
186 assert_eq!(batch["schema_version"], 3);
187 assert_eq!(batch["notice_version"], 5);
188 assert!(batch.get("consent_version").is_none());
189 let state = evidence.setup.expect("disclosure display marker");
190 assert_eq!(
191 state.telemetry_notice_shown_for.as_deref(),
192 Some(TELEMETRY_NOTICE_VERSION)
193 );
194 assert_eq!(state.telemetry_notice_decided_for.as_deref(), version);
195 assert!(!state.telemetry_accepted(TELEMETRY_NOTICE_VERSION));
196 assert!(evidence.stderr.contains("on by default"));
197 assert!(evidence.stderr.contains("Codewhale and PostHog"));
198 assert!(
199 evidence
200 .stderr
201 .contains("codewhale config set telemetry false")
202 );
203 }
204 }
205
206 struct DispatchEvidence {
207 telemetry_dir_exists: bool,
208 dry_run: Option<String>,
209 pending: Option<String>,
210 setup: Option<SetupState>,
211 stderr: String,
212 }
213
214 /// Run the real dispatcher into a keyless in-process command and report the
215 /// telemetry state it actually left behind.
216 fn dispatch_and_read_telemetry(telemetry_env: Option<&str>) -> DispatchEvidence {
217 dispatch_and_read_telemetry_with_endpoint(
218 telemetry_env,
219 Some(""),
220 Some(TELEMETRY_NOTICE_VERSION),
221 true,
222 )
223 }
224
225 fn dispatch_and_read_telemetry_with_endpoint(
226 telemetry_env: Option<&str>,
227 endpoint: Option<&str>,
228 consent_version: Option<&str>,
229 cli_on: bool,
230 ) -> DispatchEvidence {
231 let fixture = TempDir::new().expect("fixture root");
232 let home = fixture.path().join("home");
233 let codewhale_home = fixture.path().join("codewhale-home");
234 let workspace = fixture.path().join("workspace");
235 for dir in [&home, &codewhale_home, &workspace] {
236 fs::create_dir_all(dir).expect("create fixture dir");
237 }
238
239 if let Some(version) = consent_version {
240 let mut state = SetupState::default();
241 state.record_telemetry_notice(version, true);
242 state
243 .save_to(&codewhale_home.join("setup_state.json"))
244 .expect("write fixture consent");
245 }
246
247 let config_path = fixture.path().join("config.toml");
248 let mut config = String::new();
249 if let Some(endpoint) = endpoint {
250 config.push_str(&format!("telemetry_endpoint = {endpoint:?}\n"));
251 }
252 fs::write(&config_path, config).expect("write config");
253
254 let mut command = Command::new(codewhale_binary());
255 command
256 .current_dir(&workspace)
257 .env_clear()
258 .env("PATH", std::env::var_os("PATH").expect("PATH"))
259 .env("HOME", &home)
260 .env("USERPROFILE", &home)
261 .env("CODEWHALE_HOME", &codewhale_home)
262 .env("CODEWHALE_SECRET_BACKEND", "file")
263 .env(
264 "CODEWHALE_RELEASE_BASE_URL",
265 "https://example.invalid/releases",
266 )
267 .arg("--config")
268 .arg(&config_path);
269 if cli_on {
270 command.args(["--telemetry", "true"]);
271 }
272 command.args(["features", "list"]);
273 if let Some(value) = telemetry_env {
274 command.env("CODEWHALE_TELEMETRY", value);
275 }
276 let output = command.output().expect("run codewhale dispatcher");
277 assert!(
278 output.status.success(),
279 "the in-process feature command must succeed\nstdout:\n{}\nstderr:\n{}",
280 String::from_utf8_lossy(&output.stdout),
281 String::from_utf8_lossy(&output.stderr)
282 );
283 assert!(
284 String::from_utf8_lossy(&output.stdout).contains("feature\tstage\tenabled"),
285 "the real in-process feature command must have run\nstdout:\n{}\nstderr:\n{}",
286 String::from_utf8_lossy(&output.stdout),
287 String::from_utf8_lossy(&output.stderr)
288 );
289
290 let telemetry_dir = codewhale_home.join("telemetry");
291 let dry_run = match fs::read_to_string(telemetry_dir.join("dryrun.jsonl")) {
292 Ok(contents) => Some(contents),
293 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
294 Err(error) => panic!("read telemetry dry-run sink: {error}"),
295 };
296 let pending = match fs::read_to_string(telemetry_dir.join("buffer.jsonl")) {
297 Ok(contents) => Some(contents),
298 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
299 Err(error) => panic!("read telemetry pending buffer: {error}"),
300 };
301 DispatchEvidence {
302 telemetry_dir_exists: telemetry_dir.exists(),
303 dry_run,
304 pending,
305 setup: SetupState::load_from(&codewhale_home.join("setup_state.json")),
306 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
307 }
308 }
309
310 fn codewhale_binary() -> &'static str {
311 env!("CARGO_BIN_EXE_codewhale")
312 }
313
313 lines RUST