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