ignition_core/session.rs
1//! The execution session — the ONE seam every auth/gateway-client
2//! resolution flows through (CORE-09).
3//!
4//! Naming: THIS is an "execution session" — a resolved profile name plus
5//! the gateway client it builds. It is NOT a gateway session; the
6//! unrelated `client::sessions` family models the GATEWAY's
7//! Designer/Perspective/Vision sessions. One word, two domains — the
8//! doc-comments everywhere else say "gateway session" when they mean
9//! that other thing.
10//!
11//! Choreography (LOCKED — copied verbatim from the duplicated resolution
12//! sites this seam replaces: main.rs's `resolve_profile_context` +
13//! `resolve_gateway_api` + `resolve_headerless_api` + `rig_gateway_client`,
14//! and the TUI's `context::resolve_from` + `rig_client_with`):
15//!
16//! 1. The `IGNITION_URL` env overlay is applied FIRST, scoped to the
17//! WOULD-BE selection (flag > active) — `config::apply_env_overlay`.
18//! 2. THEN the selection resolves — `config::resolve_selection`
19//! (flag > active; unknown name → `ProfileNotFound` with the known
20//! profiles in the hint; nothing resolvable → `NoActiveProfile`,
21//! exactly what main.rs's `Ok(None)` consumers do today).
22//! 3. THEN the LOCKED secret chain (env tokens → keyring → basic pair)
23//! — `config::resolve_secret` over the one chain built below.
24//!
25//! The client is built from the POST-OVERLAY profile — the research-
26//! locked precedence (flag > `IGNITION_URL` env > profile value) must
27//! hold at the construction site, not just in the config unit tests
28//! (main.rs:431's contract, preserved here verbatim).
29//!
30//! Three credential modes, one per real call-site family:
31//!
32//! - [`Session::resolve`] — REQUIRED credential (the authed reads: a
33//! missing secret is `SecretUnavailable`, exit 3 — never degraded).
34//! - [`Session::resolve_degraded`] — the credential DEGRADES to `None`
35//! when the chain exhausts (version / waits / doctor: these must run
36//! without a secret; every other credential error still propagates).
37//! - [`Session::for_url`] — headerless-BY-CONSTRUCTION rig clients: a
38//! caller-derived URL + explicit credential, no config read at all.
39//!
40//! Concrete-with-deref (PLANNER DECISION, research open question 4):
41//! the handle is `Arc<ReqwestGatewayApi>`, not `Arc<dyn GatewayApi>` —
42//! the TUI's workers/`ClientHandle` are concretely typed and Phase 8's
43//! goal is construction-site unification, not handle-type churn;
44//! dyn-widening rides Phase 14 where MCP actually needs dyn. [`Deref`]
45//! feeds every existing free-fn action over `&GatewayApi` unchanged —
46//! the `version()` dyn precedent (`actions/version.rs`) and the
47//! `rig_stream.rs` cast precedent both keep working.
48//!
49//! Redaction boundary UNCHANGED: [`Secret::expose`] stays confined to
50//! the client's `apply_auth` header site (CORE-02's grep-auditable
51//! rule). This module composes existing public config fns and
52//! introduces NO new exposure path.
53//!
54//! `IGNITION_PROFILE` is deliberately NOT read here: the bin folds it
55//! into `--profile` in exactly one place (`apply_env_defaults`), so the
56//! flag this seam receives is already the effective selection.
57
58use std::ops::Deref;
59use std::sync::Arc;
60
61use crate::client::ReqwestGatewayApi;
62use crate::config::{self, AuthRef, Config, Credential, Profile, SecretStore};
63use crate::error::CoreError;
64
65/// One resolved execution context: a named profile and the gateway
66/// client built from its POST-OVERLAY state. Construct through the three
67/// constructors — never by struct literal (the fields are the resolved
68/// choreography's output, not inputs).
69///
70/// Manual `Debug` (no derive): the client isn't `Debug` and must never
71/// be rendered credential-side — the profile NAME is the only safe
72/// field (the redaction discipline, CORE-02).
73pub struct Session {
74 profile: String,
75 url: url::Url,
76 credential_present: bool,
77 api: Arc<ReqwestGatewayApi>,
78}
79
80impl std::fmt::Debug for Session {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 f.debug_struct("Session")
83 .field("profile", &self.profile)
84 .field("api", &"ReqwestGatewayApi")
85 .finish()
86 }
87}
88
89impl Session {
90 /// Resolve through config: REQUIRED credential mode. Loads config
91 /// (`IGNITION_CLI_CONFIG` first, platform path second), applies the
92 /// env overlay scoped to the would-be selection, resolves the
93 /// selection, then walks the LOCKED secret chain with NO
94 /// degradation — a missing secret is `CoreError::SecretUnavailable`
95 /// (exit 3), correct for authed reads. Replaces main.rs's
96 /// `resolve_gateway_api` (and `named_profile_client`: per-side
97 /// resolution is just `resolve(Some(name))`).
98 pub fn resolve(profile_flag: Option<&str>) -> Result<Self, CoreError> {
99 let mut config = config::load(&config::config_path())?;
100 Self::resolve_loaded(&mut config, profile_flag).map(|(session, _)| session)
101 }
102
103 /// Resolve through a config the CALLER already loaded — the caller
104 /// owns the load policy, the seam keeps everything downstream
105 /// (overlay scoped to the selection → selection → LOCKED secret
106 /// chain → REQUIRED-credential construction). This is the cockpit's
107 /// entry point: the TUI loads with `config::load_for_tui`, whose
108 /// NEW-surface degradation contract (a schema typo warns and
109 /// defaults instead of killing startup) must apply BEFORE the seam
110 /// runs, while selection/auth failures stay fatal. Returns the
111 /// session AND the selected POST-OVERLAY profile — the url string
112 /// and cadence fields consumers like the cockpit display, which a
113 /// `Session` deliberately doesn't re-expose.
114 pub fn resolve_loaded(
115 config: &mut Config,
116 profile_flag: Option<&str>,
117 ) -> Result<(Self, Profile), CoreError> {
118 let (name, profile) = resolve_selected(config, profile_flag)?;
119 let credential = config::resolve_secret(&name, &profile.auth, &locked_secret_chain())?;
120 let api = ReqwestGatewayApi::new(&profile, Some(credential))?;
121 Ok((
122 Self {
123 profile: name,
124 url: profile.url.clone(),
125 credential_present: true,
126 api: Arc::new(api),
127 },
128 profile,
129 ))
130 }
131
132 /// Resolve ONE NAMED side of a multi-profile command through a
133 /// config the CALLER already loaded — the project diff/sync shape.
134 /// Verbatim port of main.rs's `named_profile_client`: the selection
135 /// runs against the caller's config with NO env overlay applied
136 /// (side B carries its own URL even while `IGNITION_URL` overlays
137 /// the envelope's active profile — the contract the diff/sync
138 /// goldens pin), then the LOCKED chain (required) and the client
139 /// construction. The caller's `name` rides the secret resolution
140 /// verbatim, and the impossible empty-selection arm stays
141 /// `CoreError::Internal` exactly as the original wrote it.
142 pub fn resolve_side(config: &mut Config, name: &str) -> Result<Self, CoreError> {
143 let Some((_resolved, profile)) = config::resolve_selection(config, Some(name))? else {
144 return Err(CoreError::Internal(
145 "a named profile selection resolved to nothing".to_string(),
146 ));
147 };
148 let credential = config::resolve_secret(name, &profile.auth, &locked_secret_chain())?;
149 let api = ReqwestGatewayApi::new(&profile, Some(credential))?;
150 Ok(Self {
151 profile: name.to_string(),
152 url: profile.url.clone(),
153 credential_present: true,
154 api: Arc::new(api),
155 })
156 }
157
158 /// Resolve through config with the credential DEGRADED to `None`
159 /// when the secret chain exhausts: `version` must not demand a
160 /// secret (gateway-info answers), the wait commands must keep
161 /// polling while auth is broken, the doctor diagnoses absent auth
162 /// for a living. Every OTHER credential error propagates (the
163 /// `resolve_secret_opt` behavior behind main.rs:431/760 and
164 /// `resolve_headerless_api`). Selection errors are NOT degraded:
165 /// no target is `NoActiveProfile`, as today.
166 pub fn resolve_degraded(profile_flag: Option<&str>) -> Result<Self, CoreError> {
167 let mut config = config::load(&config::config_path())?;
168 let (name, profile) = resolve_selected(&mut config, profile_flag)?;
169 let credential = resolve_secret_opt(&name, &profile.auth)?;
170 let credential_present = credential.is_some();
171 let api = ReqwestGatewayApi::new(&profile, credential)?;
172 Ok(Self {
173 profile: name,
174 url: profile.url.clone(),
175 credential_present,
176 api: Arc::new(api),
177 })
178 }
179
180 /// Resolve ONLY a credential for a selection — the adopt
181 /// composition path (ADOPT-03): the bootstrap resolves DEGRADED
182 /// (the OIDC dance needs no token), while the post-bootstrap
183 /// steps (`--project`/`--checkout`/`--bake`) need a working token
184 /// when the key step SKIPPED (the mint path already holds the
185 /// fresh key). Same overlay → selection → LOCKED chain as
186 /// [`Self::resolve_degraded`], no client construction.
187 pub fn resolve_credential_opt(
188 profile_flag: Option<&str>,
189 ) -> Result<Option<Credential>, CoreError> {
190 let mut config = config::load(&config::config_path())?;
191 let (name, profile) = resolve_selected(&mut config, profile_flag)?;
192 resolve_secret_opt(&name, &profile.auth)
193 }
194
195 /// Headerless-BY-CONSTRUCTION client for the rig family: a caller-
196 /// derived gateway URL (never a profile's), an explicit optional
197 /// credential, and the caller's `ssl_verify` (rig probes use
198 /// `false` — localhost probes against self-signed rig https are the
199 /// norm). No config is read and no secret is resolved. Replaces
200 /// main.rs's `rig_gateway_client` + the TUI's `rig_client_with`
201 /// (the URL DERIVATION stays at the call sites; only the
202 /// `ReqwestGatewayApi` construction moves behind this constructor —
203 /// no second client construction anywhere).
204 ///
205 /// Rig sessions carry NO profile: [`Self::profile_name`] returns
206 /// the empty string (callers translate to `None` where the output
207 /// model wants a profile echo).
208 pub fn for_url(
209 url: url::Url,
210 credential: Option<Credential>,
211 ssl_verify: bool,
212 ) -> Result<Self, CoreError> {
213 let profile = Profile {
214 url,
215 label: None,
216 ssl_verify,
217 auth: AuthRef::default(),
218 webdev_secret: None,
219 poll_interval_secs: None,
220 };
221 let credential_present = credential.is_some();
222 let api = ReqwestGatewayApi::new(&profile, credential)?;
223 Ok(Self {
224 profile: String::new(),
225 url: profile.url.clone(),
226 credential_present,
227 api: Arc::new(api),
228 })
229 }
230
231 /// The resolved profile's name — empty for [`Self::for_url`] rig
232 /// sessions (no profile exists).
233 pub fn profile_name(&self) -> &str {
234 &self.profile
235 }
236
237 /// The resolved profile's configured gateway URL — the POST-OVERLAY
238 /// value (flag > `IGNITION_URL` env > profile), i.e. exactly what the
239 /// session's client targets. Doctor's url check re-parses this raw
240 /// value: the honest diagnosis must describe the URL the client
241 /// ACTUALLY connects to, overlay included. For [`Self::for_url`] rig
242 /// sessions this is the caller-derived rig URL.
243 pub fn profile_url(&self) -> &url::Url {
244 &self.url
245 }
246
247 /// Whether a credential resolved into this session's client — the
248 /// doctor's `credential_present` flag (a MISSING credential is a
249 /// different diagnosis than an UNRECOGNIZED one; the degraded chain
250 /// decides, this accessor reports, and the secret itself never
251 /// crosses the seam). Always `true` for [`Self::resolve`]; the
252 /// caller's `Some`-ness for [`Self::for_url`].
253 pub fn credential_present(&self) -> bool {
254 self.credential_present
255 }
256
257 /// The gateway client, borrowed.
258 pub fn api(&self) -> &ReqwestGatewayApi {
259 &self.api
260 }
261
262 /// The gateway client as an owned `Arc` handle — the TUI's
263 /// workers/`ClientHandle` shape, without a second construction.
264 pub fn api_handle(&self) -> Arc<ReqwestGatewayApi> {
265 Arc::clone(&self.api)
266 }
267}
268
269/// Feeds every existing free-fn action over `&GatewayApi` unchanged:
270/// `version(&*session, …)` and friends work exactly as they did against
271/// a bare `ReqwestGatewayApi`.
272impl Deref for Session {
273 type Target = ReqwestGatewayApi;
274
275 fn deref(&self) -> &Self::Target {
276 &self.api
277 }
278}
279
280/// THE LOCKED secret chain (env tokens → keyring → basic pair), built
281/// in exactly one place — the chain, not the structs, encodes the
282/// order (main.rs's private `secret_chain`, now shared).
283fn locked_secret_chain() -> Vec<Box<dyn SecretStore>> {
284 vec![
285 Box::new(config::EnvStore),
286 Box::new(config::KeyringStore),
287 Box::new(config::BasicEnvStore),
288 ]
289}
290
291/// Overlay scoped to the WOULD-BE selection FIRST, then the selection —
292/// the verbatim `resolve_profile_context` / TUI `resolve_from`
293/// choreography: the `IGNITION_URL` env overlay targets the profile the
294/// command is ABOUT to select (flag > active), and only then does the
295/// selection resolve against the overlaid config. Nothing resolvable →
296/// `NoActiveProfile` (the main.rs `Ok(None)` consumer behavior — not a
297/// new error class).
298fn resolve_selected(
299 config: &mut config::Config,
300 flag: Option<&str>,
301) -> Result<(String, Profile), CoreError> {
302 let overlay_target = flag.map(str::to_string).or_else(|| config.active.clone());
303 config::apply_env_overlay(config, overlay_target.as_deref());
304 match config::resolve_selection(config, flag)? {
305 Some((name, profile)) => Ok((name, profile)),
306 None => Err(CoreError::NoActiveProfile),
307 }
308}
309
310/// Credential resolution degraded for non-authenticating consumers: the
311/// LOCKED chain with `SecretUnavailable` mapped to `Ok(None)` — every
312/// other credential error propagates (the main.rs `resolve_secret_opt`
313/// port, verbatim).
314fn resolve_secret_opt(profile: &str, auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
315 config::resolve_secret(profile, auth, &locked_secret_chain())
316 .map(Some)
317 .or_else(|err| match err {
318 CoreError::SecretUnavailable { .. } => Ok(None),
319 other => Err(other),
320 })
321}
322
323#[cfg(test)]
324mod tests {
325 use super::{Session, resolve_selected};
326 use crate::client::GatewayApi;
327 use crate::config::ENV_LOCK;
328 use crate::error::CoreError;
329
330 /// The gateway-info JSON body — the one field every 8.3 gateway
331 /// answers with (`ignitionVersion`, the `version` alias tolerated).
332 fn info_body() -> serde_json::Value {
333 serde_json::json!({ "ignitionVersion": "8.3.6 (b2026042713)" })
334 }
335
336 /// Lowercased Debug dump of a recorded request's headers — the
337 /// status_contract.rs presence/absence assertion pattern.
338 fn headers_debug(request: &wiremock::Request) -> String {
339 format!("{:?}", request.headers).to_lowercase()
340 }
341
342 /// Write a config.toml into a tempdir and point
343 /// `IGNITION_CLI_CONFIG` at it. Caller holds `ENV_LOCK`.
344 fn isolate_config(dir: &tempfile::TempDir, toml: &str) {
345 let path = dir.path().join("config.toml");
346 std::fs::write(&path, toml).expect("write config fixture");
347 // SAFETY: single-threaded under ENV_LOCK; each test unsets the
348 // var before returning.
349 unsafe { std::env::set_var("IGNITION_CLI_CONFIG", &path) };
350 }
351
352 fn unset(name: &str) {
353 // SAFETY: single-threaded under ENV_LOCK.
354 unsafe { std::env::remove_var(name) };
355 }
356
357 /// Two-profile fixture: `a` active at `url_a`, `b` at `url_b`, both
358 /// on the generic `IGNITION_TOKEN` auth reference.
359 fn two_profile_toml(url_a: &str, url_b: &str) -> String {
360 format!(
361 r#"
362active = "a"
363
364[profiles.a]
365url = "{url_a}"
366
367[profiles.b]
368url = "{url_b}"
369"#
370 )
371 }
372
373 /// A scoped gateway-info mock — the guard records the requests that
374 /// actually arrived (the status_contract.rs pattern).
375 async fn mount_info(server: &wiremock::MockServer, expected: u64) -> wiremock::MockGuard {
376 wiremock::Mock::given(wiremock::matchers::method("GET"))
377 .and(wiremock::matchers::path("/data/api/v1/gateway-info"))
378 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(info_body()))
379 .expect(expected)
380 .mount_as_scoped(server)
381 .await
382 }
383
384 /// PRECEDENCE: `IGNITION_URL` overrides the SELECTED profile only —
385 /// select profile B by flag while the env carries the overlay URL;
386 /// the session's client must target the OVERLAY (mock_a) for the
387 /// selected profile, and a second session without the overlay env
388 /// targets B's OWN URL (mock_b).
389 ///
390 /// Env mutation stays inside the `ENV_LOCK` scope; the `await`ed
391 /// requests run AFTER the guard drops — the client snapshots URL +
392 /// credential at construction, so the env vars are already gone.
393 #[tokio::test]
394 async fn env_overlay_targets_the_selected_profile() {
395 let mock_a = wiremock::MockServer::start().await;
396 let mock_b = wiremock::MockServer::start().await;
397 let guard_a = mount_info(&mock_a, 1).await;
398 let guard_b = mount_info(&mock_b, 1).await;
399 let dir = tempfile::tempdir().expect("tempdir");
400
401 // Phase 1: overlay set → the session targets the overlay URL.
402 let session = {
403 let _lock = ENV_LOCK.lock().expect("env lock");
404 isolate_config(
405 &dir,
406 &two_profile_toml(mock_b.uri().as_str(), mock_b.uri().as_str()),
407 );
408 // SAFETY: single-threaded under ENV_LOCK.
409 unsafe { std::env::set_var("IGNITION_URL", mock_a.uri()) };
410 // SAFETY: single-threaded under ENV_LOCK.
411 unsafe { std::env::set_var("IGNITION_TOKEN", "overlay-token") };
412 let session = Session::resolve(Some("b")).expect("resolve selects b");
413 // SAFETY: single-threaded under ENV_LOCK.
414 unsafe { std::env::remove_var("IGNITION_URL") };
415 // SAFETY: single-threaded under ENV_LOCK.
416 unsafe { std::env::remove_var("IGNITION_TOKEN") };
417 session
418 };
419 assert_eq!(session.profile_name(), "b");
420 // The overlay URL (mock_a) answered for the selected profile —
421 // NOT the profile's own URL (also mock_b here).
422 let info = session.gateway_info().await.expect("overlay url answers");
423 assert_eq!(info.ignition_version, "8.3.6 (b2026042713)");
424 assert_eq!(
425 guard_a.received_requests().await.len(),
426 1,
427 "the env overlay URL took the request"
428 );
429 assert_eq!(
430 guard_b.received_requests().await.len(),
431 0,
432 "the profile's own URL stayed untouched while the overlay was set"
433 );
434
435 // Phase 2: overlay gone → the session targets B's OWN URL.
436 // (The required-mode credential is re-supplied per phase — the
437 // env vars were already cleaned before phase 1's await.)
438 let session = {
439 let _lock = ENV_LOCK.lock().expect("env lock");
440 isolate_config(
441 &dir,
442 &two_profile_toml(mock_b.uri().as_str(), mock_b.uri().as_str()),
443 );
444 // SAFETY: single-threaded under ENV_LOCK.
445 unsafe { std::env::set_var("IGNITION_TOKEN", "overlay-token") };
446 let session = Session::resolve(Some("b")).expect("resolve again");
447 // SAFETY: single-threaded under ENV_LOCK.
448 unsafe { std::env::remove_var("IGNITION_TOKEN") };
449 // SAFETY: single-threaded under ENV_LOCK.
450 unsafe { std::env::remove_var("IGNITION_CLI_CONFIG") };
451 session
452 };
453 let info = session.gateway_info().await.expect("own url answers");
454 assert_eq!(info.ignition_version, "8.3.6 (b2026042713)");
455 assert_eq!(
456 guard_b.received_requests().await.len(),
457 1,
458 "without the overlay the selected profile's own URL answers"
459 );
460 }
461
462 /// SELECTION: flag > active; unknown name → `ProfileNotFound` with
463 /// the known profiles in the hint; nothing resolvable →
464 /// `NoActiveProfile` (the existing main.rs consumer of
465 /// `resolve_selection`'s `Ok(None)` — not a new error class).
466 /// `IGNITION_PROFILE` is deliberately inert here: the bin folds it
467 /// into the flag (`apply_env_defaults` — the one env→flag home), so
468 /// the seam never re-reads it.
469 #[test]
470 fn selection_precedence_flag_over_active_and_errors() {
471 let _lock = ENV_LOCK.lock().expect("env lock");
472 let dir = tempfile::tempdir().expect("tempdir");
473 isolate_config(
474 &dir,
475 &two_profile_toml("http://a.example:9088/", "http://b.example:9088/"),
476 );
477 // SAFETY: single-threaded under ENV_LOCK.
478 unsafe { std::env::set_var("IGNITION_TOKEN", "t") };
479
480 // Flag beats active.
481 let session = Session::resolve(Some("b")).expect("flag selects b");
482 assert_eq!(session.profile_name(), "b");
483
484 // Unknown name → ProfileNotFound carrying the knowns.
485 let err = Session::resolve(Some("nope")).expect_err("unknown errors");
486 match &err {
487 CoreError::ProfileNotFound { name, known } => {
488 assert_eq!(name, "nope");
489 assert_eq!(known, &vec!["a".to_string(), "b".to_string()]);
490 }
491 other => panic!("wrong error class: {other}"),
492 }
493 assert_eq!(err.exit_code(), 3);
494
495 // Nothing resolvable → NoActiveProfile, both modes.
496 isolate_config(&dir, "");
497 assert!(matches!(
498 Session::resolve(None).expect_err("no selection errors"),
499 CoreError::NoActiveProfile
500 ));
501 assert!(matches!(
502 Session::resolve_degraded(None).expect_err("no selection errors"),
503 CoreError::NoActiveProfile
504 ));
505
506 // The bin's env fold is upstream: IGNITION_PROFILE alone must
507 // NOT drive the seam's selection.
508 isolate_config(
509 &dir,
510 &two_profile_toml("http://a.example:9088/", "http://b.example:9088/"),
511 );
512 // SAFETY: single-threaded under ENV_LOCK.
513 unsafe { std::env::set_var("IGNITION_PROFILE", "b") };
514 let session = Session::resolve(None).expect("active wins without a flag");
515 assert_eq!(
516 session.profile_name(),
517 "a",
518 "IGNITION_PROFILE folding belongs to the bin, not the seam"
519 );
520
521 unset("IGNITION_PROFILE");
522 unset("IGNITION_TOKEN");
523 unset("IGNITION_CLI_CONFIG");
524 }
525
526 /// SECRET CHAIN (LOCKED): an env token beats the basic env pair
527 /// (env-first), and — when NO secret exists anywhere — required
528 /// mode errors with `SecretUnavailable` (exit 3) while degraded
529 /// mode returns a header-less client (the authed gateway-info
530 /// request carries NO auth header at all).
531 ///
532 /// Same lock discipline as the precedence test: env mutations and
533 /// the sync `resolve*` calls hold `ENV_LOCK`; the `await`ed
534 /// requests run with the guard dropped (the client already
535 /// snapshotted its credential).
536 #[tokio::test]
537 async fn locked_chain_env_first_required_errors_degraded_headerless() {
538 let mock = wiremock::MockServer::start().await;
539 let guard = mount_info(&mock, 2).await;
540 let dir = tempfile::tempdir().expect("tempdir");
541
542 // Env token beats the basic pair: both sets, the authed request
543 // carries the TOKEN header and no Authorization header.
544 let session = {
545 let _lock = ENV_LOCK.lock().expect("env lock");
546 isolate_config(
547 &dir,
548 &two_profile_toml(mock.uri().as_str(), mock.uri().as_str()),
549 );
550 // SAFETY: single-threaded under ENV_LOCK.
551 unsafe {
552 std::env::set_var("IGNITION_TOKEN", "chain-token");
553 std::env::set_var("IGNITION_USER", "admin");
554 std::env::set_var("IGNITION_PASSWORD", "pw");
555 }
556 let session = Session::resolve(Some("a")).expect("env token resolves");
557 // SAFETY: single-threaded under ENV_LOCK.
558 unsafe {
559 std::env::remove_var("IGNITION_TOKEN");
560 std::env::remove_var("IGNITION_USER");
561 std::env::remove_var("IGNITION_PASSWORD");
562 }
563 session
564 };
565 let info = session.gateway_info().await.expect("token answers");
566 assert_eq!(info.ignition_version, "8.3.6 (b2026042713)");
567 let requests = guard.received_requests().await;
568 assert_eq!(requests.len(), 1, "one token-carrying request so far");
569 let headers = headers_debug(&requests[0]);
570 assert!(
571 headers.contains("x-ignition-api-token"),
572 "env token must ride the token header: {headers}"
573 );
574 assert!(
575 !headers.contains("authorization"),
576 "basic pair must lose to the env token: {headers}"
577 );
578
579 // No secret anywhere: required mode refuses; degraded mode
580 // survives (the authed request goes out with ZERO auth headers
581 // — a credential must not sneak in from the OS keyring either).
582 {
583 let _lock = ENV_LOCK.lock().expect("env lock");
584 isolate_config(
585 &dir,
586 &two_profile_toml(mock.uri().as_str(), mock.uri().as_str()),
587 );
588 let err = Session::resolve(Some("a")).expect_err("required mode demands a secret");
589 assert!(matches!(err, CoreError::SecretUnavailable { .. }));
590 assert_eq!(err.exit_code(), 3);
591 assert!(
592 err.hint().expect("hint").contains("IGNITION_TOKEN"),
593 "hint names the env path"
594 );
595 let degraded = Session::resolve_degraded(Some("a")).expect("degraded tolerates none");
596 // SAFETY: single-threaded under ENV_LOCK.
597 unsafe { std::env::remove_var("IGNITION_CLI_CONFIG") };
598 degraded
599 }
600 .gateway_info()
601 .await
602 .map(|info| {
603 assert_eq!(info.ignition_version, "8.3.6 (b2026042713)");
604 })
605 .expect("headerless answers");
606 let requests = guard.received_requests().await;
607 assert_eq!(requests.len(), 2, "the headerless request arrived");
608 let headers = headers_debug(&requests[1]);
609 assert!(
610 !headers.contains("x-ignition-api-token"),
611 "degraded mode must be header-less: {headers}"
612 );
613 assert!(
614 !headers.contains("authorization"),
615 "degraded mode must be header-less: {headers}"
616 );
617 }
618
619 /// The shared selection helper maps `Ok(None)` → `NoActiveProfile`
620 /// (never a silent pass-through) — the exact consumer behavior the
621 /// main.rs call sites implement today.
622 #[test]
623 fn resolve_selected_maps_none_to_no_active_profile() {
624 let _lock = ENV_LOCK.lock().expect("env lock");
625 let dir = tempfile::tempdir().expect("tempdir");
626 isolate_config(&dir, "");
627 let mut config = crate::config::load(&crate::config::config_path()).expect("load");
628 let err = resolve_selected(&mut config, None).expect_err("none → error");
629 assert!(matches!(err, CoreError::NoActiveProfile));
630 unset("IGNITION_CLI_CONFIG");
631 }
632}