返回 CodeWhale
scope_tests.rs
根目录 / crates / tui / src / config / scope_tests.rs
1 //! Hermetic configuration-scope tests: a provider authorized once must stay
2 //! visible from every folder, repository, and worktree.
3 //!
4 //! Everything here uses sealed fixtures (temp `CODEWHALE_HOME`, test keys in
5 //! the real secret-store path, no network, no OAuth). The claims proven:
6 //!
7 //! - readiness is identical across unrelated workspaces unless an explicit
8 //! workspace override was selected;
9 //! - an explicit workspace config (via `CODEWHALE_CONFIG_PATH`) can select a
10 //! different route but never makes a user-global credential disappear;
11 //! - unavailable truly means unavailable, with a precise reason;
12 //! - readers never rewrite configuration (concurrent processes cannot revert
13 //! a newer selection).
14
15 use std::path::{Path, PathBuf};
16 use std::sync::Arc;
17
18 use crate::config::{ApiProvider, Config};
19 use crate::provider_readiness::{ResolvedProviderReadiness, resolve_for_model};
20
21 struct HomeGuard {
22 prev_home: Option<std::ffi::OsString>,
23 prev_config_path: Option<std::ffi::OsString>,
24 }
25
26 impl Drop for HomeGuard {
27 fn drop(&mut self) {
28 // SAFETY: serialised by lock_test_env held by the caller.
29 unsafe {
30 match &self.prev_home {
31 Some(v) => std::env::set_var("CODEWHALE_HOME", v),
32 None => std::env::remove_var("CODEWHALE_HOME"),
33 }
34 match &self.prev_config_path {
35 Some(v) => std::env::set_var("CODEWHALE_CONFIG_PATH", v),
36 None => std::env::remove_var("CODEWHALE_CONFIG_PATH"),
37 }
38 }
39 }
40 }
41
42 /// Sealed user-global home with a saved DeepSeek API key, created once per
43 /// process. Tests must hold `lock_test_env` before touching it.
44 fn sealed_home() -> &'static Path {
45 static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
46 HOME.get_or_init(|| {
47 let dir = tempfile::TempDir::new().expect("temp home").keep();
48 // Seed the user-global config: DeepSeek is the authorized provider.
49 // The config lives at $CODEWHALE_HOME/config.toml (the primary path
50 // when CODEWHALE_HOME is explicit).
51 std::fs::write(
52 dir.join("config.toml"),
53 r#"provider = "deepseek"
54 [providers.deepseek]
55 api_key = "sk-test-scope-deepseek"
56 "#,
57 )
58 .expect("write config");
59 dir
60 })
61 }
62
63 /// Point `CODEWHALE_HOME` at the sealed home (optionally also pinning
64 /// `CODEWHALE_CONFIG_PATH`). Caller must hold `lock_test_env`.
65 fn sealed_env(config_path: Option<&Path>) -> HomeGuard {
66 let prev_home = std::env::var_os("CODEWHALE_HOME");
67 let prev_config_path = std::env::var_os("CODEWHALE_CONFIG_PATH");
68 // SAFETY: serialised by lock_test_env held by the caller.
69 unsafe {
70 std::env::set_var("CODEWHALE_HOME", sealed_home());
71 match config_path {
72 Some(path) => std::env::set_var("CODEWHALE_CONFIG_PATH", path),
73 None => std::env::remove_var("CODEWHALE_CONFIG_PATH"),
74 }
75 }
76 HomeGuard {
77 prev_home,
78 prev_config_path,
79 }
80 }
81
82 fn deepseek_readiness(config: &Config) -> ResolvedProviderReadiness {
83 resolve_for_model(
84 config,
85 ApiProvider::Deepseek,
86 "deepseek-v4-pro",
87 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
88 )
89 }
90
91 fn workspace_with_config(dir: &Path, provider: &str) -> PathBuf {
92 let ws = dir.join(provider);
93 std::fs::create_dir_all(ws.join(".codewhale")).expect("workspace dir");
94 std::fs::write(
95 ws.join(".codewhale").join("config.toml"),
96 format!(
97 r#"provider = "{provider}"
98
99 [providers.{provider}]
100 # deliberately no api_key — this workspace selects a route it never
101 # authorized anywhere.
102 "#
103 ),
104 )
105 .expect("write workspace config");
106 ws
107 }
108
109 #[test]
110 fn readiness_is_identical_across_unrelated_workspaces() {
111 let _lock = crate::test_support::lock_test_env();
112 let _home = sealed_env(None);
113
114 let base = tempfile::TempDir::new().expect("temp base");
115 let ws_a = base.path().join("project-a");
116 let ws_b = base.path().join("project-b");
117 std::fs::create_dir_all(&ws_a).expect("ws a");
118 std::fs::create_dir_all(&ws_b).expect("ws b");
119
120 // Same user-global home, two unrelated folders, no workspace overrides.
121 let config_a = Config::load(None, None).expect("load from A");
122 let config_b = Config::load(None, None).expect("load from B");
123
124 let readiness_a = deepseek_readiness(&config_a);
125 let readiness_b = deepseek_readiness(&config_b);
126 assert_eq!(
127 readiness_a.label(),
128 readiness_b.label(),
129 "readiness must not depend on the launch folder: {} vs {}",
130 readiness_a.label(),
131 readiness_b.label()
132 );
133 assert!(
134 readiness_a.can_attempt(),
135 "the user-global key must make DeepSeek attemptable from A: {}",
136 readiness_a.label()
137 );
138 assert!(
139 readiness_b.can_attempt(),
140 "the user-global key must make DeepSeek attemptable from B: {}",
141 readiness_b.label()
142 );
143 }
144
145 #[test]
146 fn explicit_workspace_config_selects_its_route_without_locking_user_global() {
147 let _lock = crate::test_support::lock_test_env();
148 let base = tempfile::TempDir::new().expect("temp base");
149 // Workspace A deliberately selects zai with no credential anywhere.
150 let ws_a = workspace_with_config(base.path(), "zai");
151 // The explicit config path is the workspace file — this is the
152 // "launched from that folder with --config" shape.
153 let config_path = ws_a.join(".codewhale").join("config.toml");
154 let _home = sealed_env(Some(&config_path));
155
156 let config = Config::load(Some(config_path.clone()), None).expect("load workspace config");
157 // The workspace selection IS honored for the session route.
158 assert_eq!(
159 config.api_provider(),
160 ApiProvider::Zai,
161 "the explicit workspace config selects zai"
162 );
163
164 // The user-global DeepSeek authorization did not disappear: it resolves
165 // from the user-global credential sources regardless of which config file
166 // was loaded.
167 let deepseek = deepseek_readiness(&config);
168 assert!(
169 deepseek.can_attempt(),
170 "authorization established once must stay visible: {}",
171 deepseek.label()
172 );
173
174 // The uncredentialed zai route is unavailable with a precise reason —
175 // never a lie, never a silent substitution.
176 let zai = resolve_for_model(
177 &config,
178 ApiProvider::Zai,
179 "GLM-5.2",
180 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
181 );
182 assert!(
183 !zai.can_attempt(),
184 "zai has no credential anywhere: {}",
185 zai.label()
186 );
187 let reason = zai.blocked_reason().map(|r| r.into_owned());
188 assert!(
189 reason.as_ref().is_some_and(|r| !r.trim().is_empty()),
190 "unavailable must carry a precise reason: {reason:?}"
191 );
192 }
193
194 fn write_nested_zai_config(base: &std::path::Path) -> std::path::PathBuf {
195 let nested = base.join("parent-repo").join("nested-repo");
196 std::fs::create_dir_all(nested.join(".codewhale")).expect("nested dir");
197 std::fs::write(
198 nested.join(".codewhale").join("config.toml"),
199 r#"provider = "zai"
200
201 [providers.zai]
202 "#,
203 )
204 .expect("nested config");
205 nested
206 }
207
208 fn assert_user_global_survives_workspace(label: &str, config_path: std::path::PathBuf) {
209 let _home = sealed_env(Some(&config_path));
210 let config = Config::load(Some(config_path.clone()), None)
211 .unwrap_or_else(|err| panic!("load from {label}: {err}"));
212 let deepseek = deepseek_readiness(&config);
213 assert!(
214 deepseek.can_attempt(),
215 "{label}: user-global authorization must survive: {}",
216 deepseek.label()
217 );
218 }
219
220 #[test]
221 fn nested_repo_does_not_change_readiness() {
222 let _lock = crate::test_support::lock_test_env();
223 let base = tempfile::TempDir::new().expect("temp base");
224 let nested = write_nested_zai_config(base.path());
225 assert_user_global_survives_workspace("nested", nested.join(".codewhale/config.toml"));
226 }
227
228 #[cfg(unix)]
229 #[test]
230 fn symlinked_worktree_does_not_change_readiness() {
231 let _lock = crate::test_support::lock_test_env();
232 let base = tempfile::TempDir::new().expect("temp base");
233 let nested = write_nested_zai_config(base.path());
234 let symlinked = base.path().join("symlink-worktree");
235 std::os::unix::fs::symlink(&nested, &symlinked).expect("symlink");
236 assert_user_global_survives_workspace("symlinked", symlinked.join(".codewhale/config.toml"));
237 }
238
239 #[test]
240 fn unavailable_truly_means_unavailable_with_a_reason() {
241 let _lock = crate::test_support::lock_test_env();
242 let _home = sealed_env(None);
243
244 let config = Config::load(None, None).expect("load config");
245 // Moonshot has no key anywhere in the sealed fixtures.
246 let moonshot = resolve_for_model(
247 &config,
248 ApiProvider::Moonshot,
249 "kimi-k2.6",
250 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
251 );
252 assert!(!moonshot.can_attempt());
253 let reason = moonshot.blocked_reason().map(|r| r.into_owned());
254 assert!(
255 reason.as_ref().is_some_and(|r| !r.trim().is_empty()),
256 "unavailable must carry a precise reason: {reason:?}"
257 );
258 }
259
260 #[test]
261 fn repeated_readers_never_rewrite_configuration() {
262 let _lock = crate::test_support::lock_test_env();
263 let _home = sealed_env(None);
264
265 let config_path = sealed_home().join("config.toml");
266 let before = std::fs::read(&config_path).expect("read config before");
267
268 // Resolve twice from the same sealed fixtures — a reader must never
269 // write anything, so a later process can never revert a newer selection
270 // by merely loading it. (Thread spawns are deliberately not used: they
271 // would contend on the process-wide test env lock; the property under
272 // test is that loading is side-effect-free, which a second load proves.)
273 for _ in 0..2 {
274 let config = Config::load(Some(config_path.clone()), None).expect("load");
275 let readiness = deepseek_readiness(&config);
276 assert!(readiness.can_attempt());
277 }
278
279 let after = std::fs::read(&config_path).expect("read config after");
280 assert_eq!(before, after, "readers must never rewrite configuration");
281 }
282
283 /// The scope contract visible to the UI: a workspace selection changes only
284 /// that workspace's selected configuration. Proven at the store level so the
285 /// Fleet selection files behave the same way as the config path above.
286 #[test]
287 fn workspace_fleet_selection_affects_only_that_workspace() {
288 use crate::fleet::store::{FleetFile, FleetScope, save_fleet, selected_fleet, set_selected};
289 let _lock = crate::test_support::lock_test_env();
290 // A FRESH personal home per test: the shared sealed home would pick up
291 // the parallel personal-selection test's writes.
292 let home = tempfile::TempDir::new().expect("temp home");
293 std::fs::create_dir_all(home.path().join("fleets")).expect("fleets dir");
294 let prev = std::env::var_os("CODEWHALE_HOME");
295 // SAFETY: serialised by lock_test_env.
296 unsafe { std::env::set_var("CODEWHALE_HOME", home.path()) };
297
298 let base = tempfile::TempDir::new().expect("temp base");
299 let ws_a = base.path().join("ws-a");
300 let ws_b = base.path().join("ws-b");
301 std::fs::create_dir_all(&ws_a).expect("ws a");
302 std::fs::create_dir_all(&ws_b).expect("ws b");
303
304 let fleet = FleetFile::new("Team A".to_string(), None).expect("fleet");
305 save_fleet(&fleet, FleetScope::Personal, &ws_a).expect("save personal");
306
307 // Workspace A selects the fleet for this folder only — the selection may
308 // point at the personal Fleet, never silently shadowing or copying it.
309 set_selected("Team A", FleetScope::Workspace, &ws_a).expect("select in A");
310
311 // B is untouched: no selection there.
312 assert!(
313 selected_fleet(&ws_a).is_some(),
314 "A has its folder selection"
315 );
316 assert!(selected_fleet(&ws_b).is_none(), "B must be unaffected");
317
318 // A's selection resolves to the personal Fleet file (no copy was made),
319 // labeled by the scope it actually lives in.
320 let sel = selected_fleet(&ws_a).expect("selected in A");
321 assert_eq!(sel.scope, FleetScope::Personal);
322 assert!(
323 !ws_a.join(".codewhale/fleets/team-a.toml").exists(),
324 "a workspace selection must not copy the fleet file"
325 );
326 // SAFETY: serialised by lock_test_env.
327 unsafe {
328 match prev {
329 Some(v) => std::env::set_var("CODEWHALE_HOME", v),
330 None => std::env::remove_var("CODEWHALE_HOME"),
331 }
332 }
333 }
334
335 /// A saved personal Fleet persists across a "restart": a fresh load from the
336 /// same sealed home still resolves the selection.
337 #[test]
338 fn personal_fleet_selection_persists_across_restart() {
339 use crate::fleet::store::{FleetFile, FleetScope, save_fleet, selected_fleet, set_selected};
340 let _lock = crate::test_support::lock_test_env();
341 // Fresh personal home per test (see the workspace-selection test).
342 let home = tempfile::TempDir::new().expect("temp home");
343 std::fs::create_dir_all(home.path().join("fleets")).expect("fleets dir");
344 let prev = std::env::var_os("CODEWHALE_HOME");
345 // SAFETY: serialised by lock_test_env.
346 unsafe { std::env::set_var("CODEWHALE_HOME", home.path()) };
347
348 let ws = tempfile::TempDir::new().expect("temp ws");
349 let fleet = FleetFile::new("My Default".to_string(), None).expect("fleet");
350 save_fleet(&fleet, FleetScope::Personal, ws.path()).expect("save personal");
351 set_selected("My Default", FleetScope::Personal, ws.path()).expect("select");
352
353 // "Restart": a fresh resolution from the same home.
354 let sel = selected_fleet(ws.path()).expect("selection after restart");
355 assert_eq!(sel.name, "My Default");
356 assert_eq!(sel.scope, FleetScope::Personal);
357 // SAFETY: serialised by lock_test_env.
358 unsafe {
359 match prev {
360 Some(v) => std::env::set_var("CODEWHALE_HOME", v),
361 None => std::env::remove_var("CODEWHALE_HOME"),
362 }
363 }
364 }
365
366 /// Arc-wrapped read path used by concurrent engine threads: resolving the
367 /// route from a shared config must be cheap and side-effect-free.
368 #[allow(dead_code)]
369 fn resolve_shared(config: Arc<Config>) -> ResolvedProviderReadiness {
370 resolve_for_model(
371 &config,
372 ApiProvider::Deepseek,
373 "deepseek-v4-pro",
374 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
375 )
376 }
377
377 lines RUST