返回 CodeWhale
tests.rs
根目录 / crates / cloud-facts / src / tests.rs
1 use std::path::PathBuf;
2 use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
3
4 use codewhale_config::cloud_facts::{CloudFactsState, KeyStatus, TrustedKey, overlay};
5 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
6 use tokio::net::TcpListener;
7
8 use super::*;
9
10 // All tests that read or mutate the process overlay/environment hold `lock()`.
11 struct TestEnv {
12 name: &'static str,
13 previous: Option<std::ffi::OsString>,
14 }
15 impl TestEnv {
16 fn set(name: &'static str, value: &str) -> Self {
17 let previous = std::env::var_os(name);
18 // SAFETY: the test module serializes environment-dependent work.
19 unsafe { std::env::set_var(name, value) };
20 Self { name, previous }
21 }
22 }
23 impl Drop for TestEnv {
24 fn drop(&mut self) {
25 // SAFETY: the test module serializes environment-dependent work.
26 unsafe {
27 match &self.previous {
28 Some(value) => std::env::set_var(self.name, value),
29 None => std::env::remove_var(self.name),
30 }
31 }
32 }
33 }
34
35 /// Cross-language fixture signed with the TEST-ONLY key.
36 const FIXTURE_V7: &str = include_str!("../../../docs/cloud-facts/fixtures/envelope-stable-v7.json");
37 const FIXTURE_FUTURE_V8: &str =
38 include_str!("../../../docs/cloud-facts/fixtures/envelope-future-only-v8.json");
39
40 fn test_keys() -> &'static [TrustedKey] {
41 static KEYS: OnceLock<Vec<TrustedKey>> = OnceLock::new();
42 KEYS.get_or_init(|| {
43 vec![TrustedKey {
44 key_id: "cwf-test-only",
45 public_key: [
46 243, 225, 75, 13, 110, 14, 162, 181, 4, 77, 69, 100, 179, 72, 105, 64, 8, 185, 46,
47 62, 48, 131, 121, 35, 42, 55, 216, 23, 50, 219, 39, 181,
48 ],
49 status: KeyStatus::Active,
50 }]
51 })
52 }
53
54 /// The overlay/status are process-wide; serialize tests that touch them.
55 fn lock() -> MutexGuard<'static, ()> {
56 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
57 LOCK.get_or_init(|| Mutex::new(()))
58 .lock()
59 .unwrap_or_else(|p| p.into_inner())
60 }
61
62 fn settings(dir: &tempfile::TempDir, url: Option<String>) -> Settings {
63 Settings {
64 enabled: true,
65 channel: "stable".into(),
66 url,
67 ttl_secs: 3600,
68 cache_path: Some(dir.path().join("facts").join(CACHE_FILE)),
69 local_path: None,
70 }
71 }
72
73 // Explicit fixture transport: CI still blocks the production refresh path.
74 fn refresh_fixture<'a>(
75 settings: &'a Settings,
76 force: bool,
77 keys: &'a [TrustedKey],
78 ) -> impl std::future::Future<Output = Result<RefreshOutcome, RefreshError>> + 'a {
79 configure_with_keys(settings, keys);
80 refresh_using(settings, force, keys, None, false, fetch)
81 }
82
83 /// One canned HTTP response per connection; records the request line/headers.
84 struct MockServer {
85 url: String,
86 requests: Arc<Mutex<Vec<String>>>,
87 }
88
89 /// `(status, headers, body)` canned HTTP response.
90 type CannedResponse = (u16, Vec<(&'static str, String)>, String);
91
92 async fn mock_server(responses: Vec<CannedResponse>) -> MockServer {
93 let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
94 let addr = listener.local_addr().expect("addr");
95 let requests = Arc::new(Mutex::new(Vec::new()));
96 let seen = Arc::clone(&requests);
97 tokio::spawn(async move {
98 let mut responses = responses.into_iter();
99 while let Ok((mut stream, _)) = listener.accept().await {
100 let mut buf = vec![0u8; 8192];
101 let n = stream.read(&mut buf).await.unwrap_or(0);
102 let head = String::from_utf8_lossy(&buf[..n]).into_owned();
103 seen.lock().unwrap().push(head);
104 let (status, headers, body) =
105 responses
106 .next()
107 .unwrap_or((500, vec![], "no more canned responses".into()));
108 let reason = match status {
109 200 => "OK",
110 304 => "Not Modified",
111 404 => "Not Found",
112 _ => "Error",
113 };
114 let mut out = format!("HTTP/1.1 {status} {reason}\r\nConnection: close\r\n");
115 for (k, v) in headers {
116 out.push_str(&format!("{k}: {v}\r\n"));
117 }
118 out.push_str(&format!("Content-Length: {}\r\n\r\n{}", body.len(), body));
119 let _ = stream.write_all(out.as_bytes()).await;
120 let _ = stream.shutdown().await;
121 }
122 });
123 MockServer {
124 url: format!("http://{addr}/api/facts/v1/{{channel}}"),
125 requests,
126 }
127 }
128
129 fn rt() -> tokio::runtime::Runtime {
130 tokio::runtime::Builder::new_current_thread()
131 .enable_all()
132 .build()
133 .expect("runtime")
134 }
135
136 #[test]
137 fn flag_off_means_no_client_no_file_and_off_status() {
138 let _lock = lock();
139 overlay::clear();
140 let dir = tempfile::tempdir().unwrap();
141 let mut s = settings(&dir, Some("http://127.0.0.1:1/{channel}".into()));
142 s.enabled = false;
143 configure_with_keys(&s, test_keys());
144 assert_eq!(maybe_load_persisted_cache_with_keys(&s, test_keys()), None);
145 assert_eq!(status().state, CloudFactsState::Off);
146 let err = rt()
147 .block_on(refresh_fixture(&s, true, test_keys()))
148 .unwrap_err();
149 assert_eq!(err, RefreshError::Disabled);
150 assert!(
151 !dir.path().join("facts").exists(),
152 "flag off must write nothing"
153 );
154 assert!(overlay::overlay().is_none());
155 }
156
157 #[test]
158 fn enabled_with_no_active_key_is_inert_and_never_fetches() {
159 let _lock = lock();
160 overlay::clear();
161 let dir = tempfile::tempdir().unwrap();
162 let s = settings(&dir, Some("http://127.0.0.1:1/{channel}".into()));
163 configure_with_keys(&s, &[]);
164 assert_eq!(maybe_load_persisted_cache_with_keys(&s, &[]), None);
165 assert_eq!(status().state, CloudFactsState::Inert);
166 let err = rt().block_on(refresh_fixture(&s, true, &[])).unwrap_err();
167 assert_eq!(err, RefreshError::Inert);
168 assert!(!dir.path().join("facts").exists());
169 }
170
171 #[test]
172 fn network_200_verifies_installs_caches_and_304_keeps_it() {
173 let _lock = lock();
174 overlay::clear();
175 let rt = rt();
176 let server = rt.block_on(mock_server(vec![
177 (
178 200,
179 vec![
180 ("ETag", "\"stable-v7-abc\"".into()),
181 ("Content-Type", "application/json".into()),
182 ],
183 FIXTURE_V7.into(),
184 ),
185 (
186 304,
187 vec![("ETag", "\"stable-v7-abc\"".into())],
188 String::new(),
189 ),
190 ]));
191 let dir = tempfile::tempdir().unwrap();
192 let s = settings(&dir, Some(server.url.clone()));
193
194 let outcome = rt.block_on(refresh_fixture(&s, true, test_keys())).unwrap();
195 assert_eq!(outcome, RefreshOutcome::Updated { facts_version: 7 });
196 let st = status();
197 assert!(
198 matches!(
199 st.state,
200 CloudFactsState::Verified {
201 facts_version: 7,
202 origin: FactsOrigin::Network,
203 patches: 5,
204 defaults: 1,
205 announcements: 1,
206 ..
207 }
208 ),
209 "{st:?}"
210 );
211 assert_eq!(st.etag.as_deref(), Some("\"stable-v7-abc\""));
212 let overlay = overlay::overlay().expect("overlay installed");
213 assert_eq!(overlay.facts_version, 7);
214 assert_eq!(
215 overlay::cloud_default_model("deepseek")
216 .map(|(m, _)| m)
217 .as_deref(),
218 Some("deepseek-v4-pro")
219 );
220
221 // Cache file exists, is secret-free, and carries the envelope + etag.
222 let cache = std::fs::read_to_string(s.cache_path.as_ref().unwrap()).unwrap();
223 assert!(cache.contains("stable-v7-abc"));
224 assert!(cache.contains("cwf-test-only"));
225 for needle in ["api_key", "authorization", "bearer", "password"] {
226 assert!(!cache.to_lowercase().contains(&format!("\"{needle}\"")));
227 }
228
229 // Second fetch sends If-None-Match and keeps the overlay on 304.
230 let outcome = rt.block_on(refresh_fixture(&s, true, test_keys())).unwrap();
231 assert_eq!(
232 outcome,
233 RefreshOutcome::NotModified {
234 facts_version: Some(7)
235 }
236 );
237 let requests = server.requests.lock().unwrap();
238 assert_eq!(requests.len(), 2);
239 assert!(
240 requests[1]
241 .to_lowercase()
242 .contains("if-none-match: \"stable-v7-abc\""),
243 "{}",
244 requests[1]
245 );
246 for req in requests.iter() {
247 assert!(
248 req.contains(&format!("User-Agent: {USER_AGENT}"))
249 || req.to_lowercase().contains("user-agent: codewhale/")
250 );
251 assert!(!req.to_lowercase().contains("cookie"));
252 assert!(
253 req.lines()
254 .next()
255 .unwrap()
256 .contains("/api/facts/v1/stable HTTP/1.1"),
257 "{}",
258 req.lines().next().unwrap()
259 );
260 }
261 assert!(matches!(
262 status().state,
263 CloudFactsState::Verified {
264 facts_version: 7,
265 ..
266 }
267 ));
268 overlay::clear();
269 }
270
271 #[test]
272 fn persisted_cache_round_trips_and_a_tampered_cache_is_rejected_and_cleared() {
273 let _lock = lock();
274 overlay::clear();
275 let rt = rt();
276 let server = rt.block_on(mock_server(vec![(200, vec![], FIXTURE_V7.into())]));
277 let dir = tempfile::tempdir().unwrap();
278 let s = settings(&dir, Some(server.url.clone()));
279 rt.block_on(refresh_fixture(&s, true, test_keys())).unwrap();
280 overlay::clear();
281
282 // A newly admitted startup seeds the overlay without a network call.
283 configure_with_keys(&s, test_keys());
284 assert_eq!(
285 maybe_load_persisted_cache_with_keys(&s, test_keys()),
286 Some(7)
287 );
288 assert!(matches!(
289 status().state,
290 CloudFactsState::Verified {
291 origin: FactsOrigin::DiskCache,
292 ..
293 }
294 ));
295 assert!(overlay::overlay().is_some());
296 overlay::clear();
297
298 // Tamper one payload byte on disk.
299 let path = s.cache_path.clone().unwrap();
300 let text = std::fs::read_to_string(&path).unwrap();
301 let mut cache: serde_json::Value = serde_json::from_str(&text).unwrap();
302 let env = cache["envelope"]
303 .as_str()
304 .unwrap()
305 .replace("\"facts_version\": 7", "\"facts_version\": 9");
306 cache["envelope"] = serde_json::Value::String(env);
307 std::fs::write(&path, serde_json::to_vec(&cache).unwrap()).unwrap();
308 configure_with_keys(&s, test_keys());
309 assert_eq!(maybe_load_persisted_cache_with_keys(&s, test_keys()), None);
310 assert!(
311 matches!(status().state, CloudFactsState::Rejected { .. }),
312 "{:?}",
313 status().state
314 );
315 let cleared = load_cache(&path).expect("retain the rollback floor");
316 assert!(cleared.envelope.is_empty());
317 assert_eq!(cleared.highest_seen_version, Some(7));
318 assert!(overlay::overlay().is_none());
319 }
320
321 #[test]
322 fn local_path_loads_without_network_and_scope_rejection_is_reported() {
323 let _lock = lock();
324 overlay::clear();
325 let dir = tempfile::tempdir().unwrap();
326 let local = dir.path().join("envelope.json");
327 std::fs::write(&local, FIXTURE_V7).unwrap();
328 let mut s = settings(&dir, Some("http://127.0.0.1:1/{channel}".into()));
329 s.local_path = Some(local.clone());
330 let outcome = rt()
331 .block_on(refresh_fixture(&s, true, test_keys()))
332 .unwrap();
333 assert_eq!(outcome, RefreshOutcome::Updated { facts_version: 7 });
334 assert!(matches!(
335 status().state,
336 CloudFactsState::Verified {
337 origin: FactsOrigin::LocalFile,
338 ..
339 }
340 ));
341
342 std::fs::write(&local, FIXTURE_FUTURE_V8).unwrap();
343 let err = rt()
344 .block_on(refresh_fixture(&s, true, test_keys()))
345 .unwrap_err();
346 assert!(matches!(
347 err,
348 RefreshError::Rejected(FactsRejection::NotApplicable { .. })
349 ));
350 assert!(matches!(
351 status().state,
352 CloudFactsState::NotApplicable { .. }
353 ));
354 overlay::clear();
355 }
356
357 #[test]
358 fn server_errors_keep_prior_facts_and_persist_backoff() {
359 let _lock = lock();
360 overlay::clear();
361 let rt = rt();
362 let server = rt.block_on(mock_server(vec![
363 (200, vec![], FIXTURE_V7.into()),
364 (500, vec![], "boom".into()),
365 (200, vec![], "x".repeat(MAX_BODY_BYTES + 1)),
366 ]));
367 let dir = tempfile::tempdir().unwrap();
368 let s = settings(&dir, Some(server.url.clone()));
369 rt.block_on(refresh_fixture(&s, true, test_keys())).unwrap();
370
371 let err = rt
372 .block_on(refresh_fixture(&s, true, test_keys()))
373 .unwrap_err();
374 assert_eq!(err, RefreshError::HttpStatus(500));
375 assert!(
376 matches!(
377 status().state,
378 CloudFactsState::Failed {
379 keeping: Some(7),
380 ..
381 }
382 ),
383 "{:?}",
384 status().state
385 );
386 assert!(
387 overlay::overlay().is_some(),
388 "prior verified facts survive a failure"
389 );
390
391 // Backoff is persisted and honoured by non-forced refreshes.
392 let cache: serde_json::Value =
393 serde_json::from_str(&std::fs::read_to_string(s.cache_path.as_ref().unwrap()).unwrap())
394 .unwrap();
395 assert!(cache["backoff_until"].as_u64().unwrap() > now_unix());
396 let err = rt
397 .block_on(refresh_fixture(&s, false, test_keys()))
398 .unwrap_err();
399 assert!(matches!(err, RefreshError::BackingOff { .. }));
400
401 // Oversized body is refused before verification.
402 let err = rt
403 .block_on(refresh_fixture(&s, true, test_keys()))
404 .unwrap_err();
405 assert!(matches!(err, RefreshError::TooLarge(_)));
406 assert!(overlay::overlay().is_some());
407 overlay::clear();
408 }
409
410 #[test]
411 fn not_found_means_no_facts_not_failure() {
412 let _lock = lock();
413 overlay::clear();
414 let rt = rt();
415 let server = rt.block_on(mock_server(vec![(
416 404,
417 vec![],
418 "{\"error\":\"no-facts\"}".into(),
419 )]));
420 let dir = tempfile::tempdir().unwrap();
421 let s = settings(&dir, Some(server.url.clone()));
422 let outcome = rt.block_on(refresh_fixture(&s, true, test_keys())).unwrap();
423 assert_eq!(outcome, RefreshOutcome::NoFacts);
424 assert_eq!(status().state, CloudFactsState::BundledOnly);
425 assert!(overlay::overlay().is_none());
426 }
427
428 #[test]
429 fn settings_resolve_url_template_and_channel_validation() {
430 let s = Settings {
431 channel: "beta".into(),
432 ..Settings::default()
433 };
434 assert_eq!(s.url(), "https://codewhale.net/api/facts/v1/beta");
435 assert!(valid_channel("stable"));
436 assert!(valid_channel("beta-2"));
437 assert!(!valid_channel("-bad"));
438 assert!(!valid_channel("Stable"));
439 assert!(!valid_channel(""));
440 assert_eq!(
441 Settings::default().cache_path.as_deref(),
442 None::<&std::path::Path>,
443 "default settings resolve the cache under CODEWHALE_HOME"
444 );
445 let _ = PathBuf::new();
446 }
447
448 #[test]
449 fn disable_rejects_late_200_and_304_and_preserves_cache_bytes() {
450 let _lock = lock();
451 for not_modified in [false, true] {
452 overlay::clear();
453 let dir = tempfile::tempdir().unwrap();
454 let s = settings(&dir, Some("https://fixture.invalid/{channel}".into()));
455 configure_with_keys(&s, test_keys());
456 rt().block_on(async {
457 refresh_using(&s, true, test_keys(), None, false, |_, _| async {
458 Ok(Fetched::Body {
459 bytes: FIXTURE_V7.as_bytes().to_vec(),
460 etag: Some("v7".into()),
461 })
462 })
463 .await
464 .unwrap();
465 let before = std::fs::read(s.cache_path.as_ref().unwrap()).unwrap();
466 let admitted = ticket(&s, test_keys()).unwrap();
467 let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
468 let (release_tx, release_rx) = tokio::sync::oneshot::channel();
469 let refresh = refresh_using(
470 &s,
471 true,
472 test_keys(),
473 Some(admitted),
474 false,
475 |_, _| async move {
476 entered_tx.send(()).unwrap();
477 release_rx.await.unwrap();
478 Ok(if not_modified {
479 Fetched::NotModified
480 } else {
481 Fetched::Body {
482 bytes: FIXTURE_V7.as_bytes().to_vec(),
483 etag: Some("late".into()),
484 }
485 })
486 },
487 );
488 let disable = async {
489 entered_rx.await.unwrap();
490 let mut disabled = s.clone();
491 disabled.enabled = false;
492 configure_with_keys(&disabled, test_keys());
493 release_tx.send(()).unwrap();
494 };
495 let (result, ()) = tokio::join!(refresh, disable);
496 assert!(
497 std::fs::read(s.cache_path.as_ref().unwrap()).unwrap() == before,
498 "a disabled refresh must not change cache bytes"
499 );
500 assert_eq!(result, Err(RefreshError::Superseded));
501 assert!(overlay::overlay().is_none());
502 assert_eq!(status().state, CloudFactsState::Off);
503 assert_eq!(
504 refresh_using(&s, true, test_keys(), None, false, |_, _| async {
505 panic!("old settings cannot create transport")
506 })
507 .await,
508 Err(RefreshError::Superseded)
509 );
510 });
511 }
512 }
513
514 #[test]
515 fn changed_source_drops_etag_and_304_reverifies_cached_envelope() {
516 let _lock = lock();
517 overlay::clear();
518 let dir = tempfile::tempdir().unwrap();
519 let mut s = settings(&dir, Some("https://first.invalid/{channel}".into()));
520 configure_with_keys(&s, test_keys());
521 rt().block_on(async {
522 refresh_using(&s, true, test_keys(), None, false, |_, _| async {
523 Ok(Fetched::Body {
524 bytes: FIXTURE_V7.as_bytes().to_vec(),
525 etag: Some("first-v7".into()),
526 })
527 })
528 .await
529 .unwrap();
530 s.url = Some("https://second.invalid/{channel}".into());
531 configure_with_keys(&s, test_keys());
532 refresh_using(&s, true, test_keys(), None, false, |_, etag| async move {
533 assert!(
534 etag.is_none(),
535 "validators must not cross source boundaries"
536 );
537 Ok(Fetched::Body {
538 bytes: FIXTURE_V7.as_bytes().to_vec(),
539 etag: Some("second-v7".into()),
540 })
541 })
542 .await
543 .unwrap();
544 let path = s.cache_path.as_ref().unwrap();
545 let mut cache = load_cache(path).unwrap();
546 assert_eq!(cache.highest_seen_version, Some(7));
547 cache.highest_seen_version = Some(8);
548 save_cache(path, &cache);
549 let result = refresh_using(&s, true, test_keys(), None, false, |_, etag| async move {
550 assert_eq!(etag.as_deref(), Some("second-v7"));
551 Ok(Fetched::NotModified)
552 })
553 .await;
554 assert!(
555 matches!(result, Err(RefreshError::Rejected(_))),
556 "{result:?}"
557 );
558 assert!(overlay::overlay().is_none());
559 let rejected = load_cache(path).unwrap();
560 assert_eq!(rejected.highest_seen_version, Some(8));
561 assert!(rejected.envelope.is_empty());
562 assert!(rejected.etag.is_none());
563 });
564 }
565
566 #[test]
567 fn fixture_transport_is_explicit_and_production_suppression_still_wins() {
568 let _lock = lock();
569 overlay::clear();
570 let dir = tempfile::tempdir().unwrap();
571 let s = settings(&dir, Some("https://fixture.invalid/{channel}".into()));
572 configure_with_keys(&s, test_keys());
573 let _ci = TestEnv::set("CI", "true");
574 rt().block_on(async {
575 assert_eq!(
576 refresh_with_keys(&s, true, test_keys()).await,
577 Err(RefreshError::Suppressed)
578 );
579 let suppressed = refresh_using(&s, true, test_keys(), None, true, |_, _| async {
580 panic!("production suppression precedes transport")
581 })
582 .await;
583 assert_eq!(suppressed, Err(RefreshError::Suppressed));
584 let fixture = refresh_using(&s, true, test_keys(), None, false, |_, _| async {
585 Ok(Fetched::NotFound)
586 })
587 .await;
588 assert_eq!(fixture, Ok(RefreshOutcome::NoFacts));
589 });
590 }
591
592 #[test]
593 fn bounded_files_reject_oversize_outer_cache_and_nonregular_inputs() {
594 let _lock = lock();
595 let dir = tempfile::tempdir().unwrap();
596 let path = dir.path().join("large");
597 std::fs::File::create(&path)
598 .unwrap()
599 .set_len((MAX_CACHE_BYTES + 1) as u64)
600 .unwrap();
601 assert!(matches!(
602 read_bounded_regular(&path, MAX_CACHE_BYTES),
603 Err(RefreshError::TooLarge(_))
604 ));
605 assert!(load_cache(&path).is_none());
606 assert!(read_bounded_regular(dir.path(), MAX_BODY_BYTES).is_err());
607 #[cfg(unix)]
608 {
609 use std::os::unix::fs::symlink;
610 let target = dir.path().join("target");
611 std::fs::write(&target, FIXTURE_V7).unwrap();
612 let linked = dir.path().join("symlink");
613 symlink(&target, &linked).unwrap();
614 assert!(read_bounded_regular(&linked, MAX_BODY_BYTES).is_err());
615 let hardlink = dir.path().join("hardlink");
616 std::fs::hard_link(&target, &hardlink).unwrap();
617 assert!(read_bounded_regular(&target, MAX_BODY_BYTES).is_err());
618 let fifo = dir.path().join("fifo");
619 let raw = std::ffi::CString::new(fifo.as_os_str().as_encoded_bytes()).unwrap();
620 assert_eq!(unsafe { libc::mkfifo(raw.as_ptr(), 0o600) }, 0);
621 assert!(read_bounded_regular(&fifo, MAX_BODY_BYTES).is_err());
622 }
623 }
624
625 #[test]
626 fn chunked_fetch_stops_at_body_limit_without_waiting_for_end_of_stream() {
627 let _lock = lock();
628 rt().block_on(async {
629 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
630 let url = format!("http://{}/facts", listener.local_addr().unwrap());
631 let server = tokio::spawn(async move {
632 let (mut stream, _) = listener.accept().await.unwrap();
633 let mut head = [0u8; 4096];
634 let _ = stream.read(&mut head).await;
635 stream
636 .write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")
637 .await
638 .unwrap();
639 let chunk = vec![b'x'; 8192];
640 for _ in 0..(MAX_BODY_BYTES / chunk.len() + 1) {
641 if stream.write_all(b"2000\r\n").await.is_err() {
642 return;
643 }
644 if stream.write_all(&chunk).await.is_err() {
645 return;
646 }
647 if stream.write_all(b"\r\n").await.is_err() {
648 return;
649 }
650 }
651 std::future::pending::<()>().await;
652 });
653 let result = tokio::time::timeout(Duration::from_secs(3), fetch(url, None))
654 .await
655 .expect("oversized stream must be rejected before timeout/end-of-body");
656 assert!(matches!(result, Err(RefreshError::TooLarge(_))));
657 server.abort();
658 });
659 }
660
661 #[test]
662 fn hard_disable_blocks_local_cache_transport_and_late_publication() {
663 let _lock = lock();
664 overlay::clear();
665 let dir = tempfile::tempdir().unwrap();
666 let local = dir.path().join("local.json");
667 std::fs::write(&local, FIXTURE_V7).unwrap();
668 let mut s = settings(&dir, None);
669 s.local_path = Some(local);
670 rt().block_on(refresh_fixture(&s, true, test_keys()))
671 .unwrap();
672 let path = s.cache_path.as_ref().unwrap();
673 let before = std::fs::read(path).unwrap();
674 let prior = ticket(&s, test_keys()).unwrap();
675 let _disable = TestEnv::set(ENV_DISABLE, "1");
676 assert!(overlay::overlay().is_none());
677 assert_eq!(maybe_load_persisted_cache_with_keys(&s, test_keys()), None);
678 let result = rt().block_on(refresh_using(
679 &s,
680 true,
681 test_keys(),
682 Some(prior),
683 false,
684 |_, _| async { panic!("hard disable must precede local reads and transport") },
685 ));
686 assert_eq!(result, Err(RefreshError::Disabled));
687 assert_eq!(std::fs::read(path).unwrap(), before);
688 configure_with_keys(&s, test_keys());
689 assert_eq!(status().state, CloudFactsState::Off);
690 assert!(!s.resolve().enabled);
691 }
692
693 #[test]
694 fn source_switch_and_disable_keep_channel_floor_after_cache_removal() {
695 let _lock = lock();
696 overlay::clear();
697 let dir = tempfile::tempdir().unwrap();
698 let mut s = settings(&dir, Some("https://first.invalid/{channel}".into()));
699 configure_with_keys(&s, test_keys());
700 rt().block_on(refresh_using(
701 &s,
702 true,
703 test_keys(),
704 None,
705 false,
706 |_, _| async {
707 Ok(Fetched::Body {
708 bytes: FIXTURE_V7.as_bytes().to_vec(),
709 etag: Some("first-v7".into()),
710 })
711 },
712 ))
713 .unwrap();
714 std::fs::remove_file(s.cache_path.as_ref().unwrap()).unwrap();
715 overlay::clear();
716 s.url = Some("https://second.invalid/{channel}".into());
717 configure_with_keys(&s, test_keys());
718 assert_eq!(overlay::highest_seen("stable"), Some(7));
719 let cache = cache_for(&s, test_keys()).unwrap();
720 assert_eq!(cache.highest_seen_version, Some(7));
721 assert!(cache.envelope.is_empty());
722 assert!(cache.etag.is_none());
723 }
724
725 #[test]
726 fn forged_cache_timestamps_cannot_make_facts_fresh_or_block_refresh() {
727 let _lock = lock();
728 overlay::clear();
729 let dir = tempfile::tempdir().unwrap();
730 let s = settings(&dir, Some("https://fixture.invalid/{channel}".into()));
731 configure_with_keys(&s, test_keys());
732 rt().block_on(async {
733 refresh_using(&s, true, test_keys(), None, false, |_, _| async {
734 Ok(Fetched::Body {
735 bytes: FIXTURE_V7.as_bytes().to_vec(),
736 etag: Some("v7".into()),
737 })
738 })
739 .await
740 .unwrap();
741 let path = s.cache_path.as_ref().unwrap();
742 let mut cache = load_cache(path).unwrap();
743 cache.fetched_at = u64::MAX;
744 cache.backoff_until = Some(u64::MAX);
745 save_cache(path, &cache);
746 assert_eq!(
747 maybe_load_persisted_cache_with_keys(&s, test_keys()),
748 Some(7)
749 );
750 assert!(
751 overlay::overlay().is_none(),
752 "future local metadata cannot grant authority"
753 );
754 let result = refresh_using(&s, false, test_keys(), None, false, |_, etag| async move {
755 assert_eq!(etag.as_deref(), Some("v7"));
756 Ok(Fetched::NotModified)
757 })
758 .await;
759 assert_eq!(
760 result,
761 Ok(RefreshOutcome::NotModified {
762 facts_version: Some(7)
763 })
764 );
765 assert!(overlay::overlay().is_some());
766 let repaired = load_cache(path).unwrap();
767 assert!(repaired.fetched_at <= now_unix());
768 assert!(repaired.backoff_until.is_none());
769 assert_eq!(repaired.highest_seen_version, Some(7));
770 });
771 }
772
773 #[test]
774 fn cache_restart_recovers_authenticated_floor_before_source_switch() {
775 const CHILD_CACHE: &str = "CODEWHALE_TEST_CLOUD_RESTART_CACHE";
776 let _lock = lock();
777 if let Some(path) = std::env::var_os(CHILD_CACHE) {
778 // This child runs only this test, so there is no inherited process
779 // overlay to mask a lost disk rollback receipt.
780 assert_eq!(overlay::highest_seen("stable"), None);
781 let s = Settings {
782 enabled: true,
783 url: Some("https://second.invalid/{channel}".into()),
784 cache_path: Some(path.into()),
785 ..Settings::default()
786 };
787 configure_with_keys(&s, test_keys());
788 let cache = cache_for(&s, test_keys()).unwrap();
789 assert_eq!(cache.highest_seen_version, Some(7));
790 assert!(cache.envelope.is_empty());
791 assert!(cache.etag.is_none());
792 return;
793 }
794 overlay::clear();
795 let dir = tempfile::tempdir().unwrap();
796 let s = settings(&dir, Some("https://first.invalid/{channel}".into()));
797 configure_with_keys(&s, test_keys());
798 rt().block_on(refresh_using(
799 &s,
800 true,
801 test_keys(),
802 None,
803 false,
804 |_, _| async {
805 Ok(Fetched::Body {
806 bytes: FIXTURE_V7.as_bytes().to_vec(),
807 etag: Some("first-v7".into()),
808 })
809 },
810 ))
811 .unwrap();
812 let path = s.cache_path.as_ref().unwrap();
813 let mut cache = load_cache(path).unwrap();
814 cache.highest_seen_version = Some(0);
815 save_cache(path, &cache);
816 let child = std::process::Command::new(std::env::current_exe().unwrap())
817 .args([
818 "--exact",
819 "tests::cache_restart_recovers_authenticated_floor_before_source_switch",
820 "--nocapture",
821 ])
822 .env(CHILD_CACHE, path)
823 .output()
824 .unwrap();
825 assert!(
826 child.status.success(),
827 "fresh-process check failed: {}{}",
828 String::from_utf8_lossy(&child.stdout),
829 String::from_utf8_lossy(&child.stderr)
830 );
831 }
832
832 lines RUST