Skip to main content

ignition_core/actions/
doctor.rs

1//! The doctor action (02-05, HLTH-10) — the self-service preflight:
2//! one structured `checks[]` report diagnosing URL, liveness,
3//! commissioning, auth (401-vs-403 made specific via the
4//! security-properties deep-dive), write permission, WebDev-route
5//! presence, and rig presence. Serde models OUT, no printing.
6//!
7//! Every classification is the research's empirically-verified failure
8//! taxonomy (02-RESEARCH §Doctor inputs — each row verified live on a
9//! real 8.3.6 gateway):
10//! - `/StatusPing` separates DOWN-ness from auth failure BY
11//!   CONSTRUCTION (this check carries no credential);
12//! - 302→`/welcome` on any `/data` probe = uncommissioned;
13//! - 401 = token not recognized (the `name:key` format failure);
14//!   403 = recognized but under-permitted (the three-part setup);
15//! - `scan/projects` is igw-cli's harmless rescan write probe;
16//! - `/system/webdev`: the version-action probe answers 405 = absent
17//!   (the live-proven 8.3 marker — the Phase-2 404 assumption was
18//!   research-Pitfall-1 wrong, re-pinned 05-03), 402 = module
19//!   unlicensed, 200 = present.
20//!
21//! EXIT CONTRACT (planner decision, README-documented): the doctor
22//! exits 0 whenever the diagnosis COMPLETES — failing checks are the
23//! product, not CLI errors (agents parse `checks[]`; humans read the
24//! table). Config errors (no profile) still exit 3 through the normal
25//! dispatch path.
26
27use std::net::{TcpStream, ToSocketAddrs};
28use std::time::Duration;
29
30use serde::Serialize;
31
32use crate::client::GatewayApi;
33use crate::client::webdev::RouteProbe;
34use crate::error::CoreError;
35
36/// TCP dial timeout for the url check (separates DNS/firewall from
37/// HTTP — the igw-cli pattern).
38const DIAL_TIMEOUT: Duration = Duration::from_secs(3);
39
40/// The state a healthy gateway reports on StatusPing.
41const RUNNING: &str = "RUNNING";
42
43/// One check row. The `checks[]` keys are contract: exactly
44/// `{name, status, detail, hint}` (hint serializes as `null` when
45/// absent — agents can key on it unconditionally).
46#[derive(Debug, Clone, Serialize)]
47pub struct CheckResult {
48    /// Which check: url / liveness / commissioned / auth / permissions /
49    /// write / webdev / rig.
50    pub name: String,
51    /// ok | warn | fail | skip.
52    pub status: CheckStatus,
53    /// What was observed.
54    pub detail: String,
55    /// The actionable next step, when there is one.
56    pub hint: Option<String>,
57}
58
59/// A check's verdict.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
61#[serde(rename_all = "lowercase")]
62pub enum CheckStatus {
63    /// The check passed.
64    Ok,
65    /// Passed with something worth flagging (restart mid-flight,
66    /// read-only token, route absent).
67    Warn,
68    /// The check failed — the detail + hint carry the diagnosis.
69    Fail,
70    /// Not requested or not assessable in this context.
71    Skip,
72}
73
74/// The doctor's output model.
75#[derive(Debug, Serialize)]
76pub struct DoctorResult {
77    /// The checks, in execution order (url, liveness, commissioned,
78    /// auth, permissions, write, webdev, rig).
79    pub checks: Vec<CheckResult>,
80}
81
82/// The doctor's options (the CLI's `--check-write` / `--webdev-route`;
83/// no other options exist by design).
84#[derive(Debug, Default)]
85pub struct DoctorOptions {
86    /// Probe write permission via the harmless scan/projects rescan.
87    pub check_write: bool,
88    /// Probe one WebDev route's presence (`/system/webdev/<NAME>`).
89    pub webdev_route: Option<String>,
90}
91
92/// Run the check sequence. `profile_url` is the raw configured URL
93/// (the url check parses it itself); `credential_present` makes the
94/// 401 diagnosis honest (a missing credential is a DIFFERENT failure
95/// than an unrecognized one). NEVER returns Err — the diagnosis
96/// completing IS success (exit 0).
97pub async fn doctor(
98    api: &dyn GatewayApi,
99    profile_url: &str,
100    credential_present: bool,
101    opts: &DoctorOptions,
102) -> DoctorResult {
103    let mut checks = Vec::with_capacity(8);
104
105    // 1. url: parse + TCP dial (separates DNS/firewall from HTTP).
106    checks.push(check_url(profile_url));
107
108    // 2. liveness: the UNAUTHENTICATED StatusPing — down-ness and auth
109    //    failure are separated by construction (no credential here).
110    checks.push(check_liveness(api).await);
111
112    // 3 + 4. commissioned + auth read: ONE gateway-info probe feeds
113    //    both (the 302→/welcome classification runs before the
114    //    401/403 reading of the same response).
115    let gateway_info = api.gateway_info().await;
116    checks.push(check_commissioned(&gateway_info));
117    let auth_status = check_auth(&gateway_info, credential_present);
118    checks.push(auth_status.clone());
119
120    // 5. permissions deep-dive: when the token WORKS, surface the
121    //    gateway's actual read/write permission wiring; when auth
122    //    failed with 403, attempting the read confirms whether the
123    //    token can read the security config at all (the three-part
124    //    diagnosis's part 2).
125    checks.push(check_permissions(api, &auth_status).await);
126
127    // 6. write probe (only with --check-write).
128    checks.push(check_write(api, opts).await);
129
130    // 7. webdev route presence (only with --webdev-route).
131    checks.push(check_webdev(api, opts).await);
132
133    // 8. rig: local-only, no gateway calls.
134    checks.push(check_rig());
135
136    DoctorResult { checks }
137}
138
139/// Build a row.
140fn row(name: &str, status: CheckStatus, detail: String, hint: Option<String>) -> CheckResult {
141    CheckResult {
142        name: name.to_string(),
143        status,
144        detail,
145        hint,
146    }
147}
148
149/// 1. url: parse the profile URL, then TCP dial host:port with a 3 s
150///    timeout — a DNS or firewall failure is a DIFFERENT diagnosis
151///    than an HTTP-level one (igw-cli pattern).
152fn check_url(raw: &str) -> CheckResult {
153    let url = match url::Url::parse(raw) {
154        Ok(url) => url,
155        Err(err) => {
156            return row(
157                "url",
158                CheckStatus::Fail,
159                format!("cannot parse the profile url {raw:?}: {err}"),
160                Some("fix the profile url with `ign profile add`".to_string()),
161            );
162        }
163    };
164    let Some(host) = url.host_str().map(str::to_string) else {
165        return row(
166            "url",
167            CheckStatus::Fail,
168            format!("the profile url {raw:?} carries no host"),
169            Some("fix the profile url with `ign profile add`".to_string()),
170        );
171    };
172    let port = url.port_or_known_default().unwrap_or(80);
173    let addrs = match (host.as_str(), port).to_socket_addrs() {
174        Ok(addrs) => addrs.collect::<Vec<_>>(),
175        Err(err) => {
176            return row(
177                "url",
178                CheckStatus::Fail,
179                format!("DNS resolution of {host} failed: {err}"),
180                Some("check the hostname / VPN / DNS".to_string()),
181            );
182        }
183    };
184    let mut last_err = None;
185    for addr in &addrs {
186        match TcpStream::connect_timeout(addr, DIAL_TIMEOUT) {
187            Ok(_) => {
188                return row(
189                    "url",
190                    CheckStatus::Ok,
191                    format!("TCP connect to {host}:{port} succeeded"),
192                    None,
193                );
194            }
195            Err(err) => last_err = Some(err),
196        }
197    }
198    let err = last_err.unwrap_or_else(|| {
199        std::io::Error::new(
200            std::io::ErrorKind::AddrNotAvailable,
201            "no addresses resolved",
202        )
203    });
204    row(
205        "url",
206        CheckStatus::Fail,
207        format!("TCP connect to {host}:{port} failed: {err}"),
208        Some(format!(
209            "check the gateway host/port ({host}:{port}) and any firewall/VPN"
210        )),
211    )
212}
213
214/// 2. liveness: the unauth StatusPing — RUNNING / mid-restart state /
215///    no answer. This check carries no credential BY CONSTRUCTION, so
216///    its failure can never be an auth problem.
217async fn check_liveness(api: &dyn GatewayApi) -> CheckResult {
218    match api.status_ping().await {
219        Ok(ping) if ping.state == RUNNING => row(
220            "liveness",
221            CheckStatus::Ok,
222            format!("gateway {RUNNING} (unauthenticated /StatusPing)"),
223            None,
224        ),
225        Ok(ping) => row(
226            "liveness",
227            CheckStatus::Warn,
228            format!(
229                "gateway {} — restarting or not ready (unauthenticated /StatusPing)",
230                ping.state
231            ),
232            Some("gateway not RUNNING yet; try `ign wait gateway`".to_string()),
233        ),
234        Err(CoreError::GatewayRestarting { .. }) => row(
235            "liveness",
236            CheckStatus::Warn,
237            "webserver up but services restarting (503)".to_string(),
238            Some("try `ign wait restart`".to_string()),
239        ),
240        Err(err) => row(
241            "liveness",
242            CheckStatus::Fail,
243            format!("gateway down — no /StatusPing answer: {err}"),
244            Some("check the gateway process/container and the url row above".to_string()),
245        ),
246    }
247}
248
249/// 3. commissioned: a 302→`/welcome` on the gateway-info probe means
250///    the gateway is uncommissioned (it 302s EVERY /data route at the
251///    wizard — verified on a fresh container).
252fn check_commissioned(
253    gateway_info: &Result<crate::client::version::GatewayInfo, CoreError>,
254) -> CheckResult {
255    match gateway_info {
256        Err(CoreError::GatewayNotCommissioned { .. }) => row(
257            "commissioned",
258            CheckStatus::Fail,
259            "every /data route redirects to /welcome — gateway not commissioned".to_string(),
260            Some("open http://<host>:<port>/welcome in a browser and complete the commissioning wizard".to_string()),
261        ),
262        _ => row(
263            "commissioned",
264            CheckStatus::Ok,
265            "no /welcome redirect on /data routes".to_string(),
266            None,
267        ),
268    }
269}
270
271/// 4. auth read: gateway-info with the credential — the verified
272///    401-vs-403 split, with the no-credential case kept honest.
273fn check_auth(
274    gateway_info: &Result<crate::client::version::GatewayInfo, CoreError>,
275    credential_present: bool,
276) -> CheckResult {
277    match gateway_info {
278        Ok(info) => row(
279            "auth",
280            CheckStatus::Ok,
281            format!(
282                "gateway-info read succeeded (HTTP 200, gateway {})",
283                info.ignition_version
284            ),
285            None,
286        ),
287        Err(CoreError::Auth { status: 401, .. }) => {
288            let (detail, hint) = if credential_present {
289                (
290                    "token not recognized (HTTP 401 on gateway-info)".to_string(),
291                    "the X-Ignition-API-Token header must be the FULL `name:key` string from the gateway UI (Platform→Security→API Keys); Basic auth does not work on 8.3 /data routes — create an API token".to_string(),
292                )
293            } else {
294                (
295                    "no credential resolved for this profile (gateway-info answered 401)".to_string(),
296                    "set IGNITION_TOKEN (or the profile's token_env / keyring) to an API token".to_string(),
297                )
298            };
299            row("auth", CheckStatus::Fail, detail, Some(hint))
300        }
301        Err(CoreError::Auth { status: 403, .. }) => row(
302            "auth",
303            CheckStatus::Fail,
304            "token recognized but under-permitted (HTTP 403 on gateway-info)".to_string(),
305            Some("Ignition token setup is three parts: (1) the token holds an adequate security level, (2) the gateway's read/write permissions include that level (default: only Authenticated/Roles/Administrator), (3) 'Require secure connections' is unchecked for http gateways — the permissions row below helps with part 2".to_string()),
306        ),
307        Err(CoreError::GatewayNotCommissioned { .. }) => row(
308            "auth",
309            CheckStatus::Skip,
310            "gateway not commissioned — auth not assessable".to_string(),
311            None,
312        ),
313        Err(CoreError::GatewayRestarting { .. }) => row(
314            "auth",
315            CheckStatus::Skip,
316            "gateway restarting — auth not assessable yet".to_string(),
317            Some("re-run doctor once the gateway is RUNNING".to_string()),
318        ),
319        Err(CoreError::Network { .. }) => row(
320            "auth",
321            CheckStatus::Skip,
322            "gateway unreachable — auth not assessable".to_string(),
323            None,
324        ),
325        Err(err) => row(
326            "auth",
327            CheckStatus::Fail,
328            format!("gateway-info probe failed: {err}"),
329            None,
330        ),
331    }
332}
333
334/// 5. permissions deep-dive: read the security-properties singleton
335///    and surface the actual read/write permission wiring — the specific
336///    instruction for the 403 case's part 2. Also attempted on a 403 (the
337///    read failing too CONFIRMS the wiring diagnosis); skipped for other
338///    auth failures (the read needs a working token).
339async fn check_permissions(api: &dyn GatewayApi, auth: &CheckResult) -> CheckResult {
340    let attempt = match auth.status {
341        CheckStatus::Ok => true,
342        // A 403 token is recognized — attempt the read; its failure is
343        // itself diagnostic.
344        CheckStatus::Fail if auth.detail.contains("403") => true,
345        _ => false,
346    };
347    if !attempt {
348        return row(
349            "permissions",
350            CheckStatus::Skip,
351            "auth read failed — the security-properties read needs a working token".to_string(),
352            None,
353        );
354    }
355    match api.security_properties().await {
356        Ok(props) => {
357            let read = props
358                .read_permissions
359                .as_ref()
360                .map(|value| serde_json::to_string(value).unwrap_or_default())
361                .unwrap_or_else(|| "(absent)".to_string());
362            let write = props
363                .write_permissions
364                .as_ref()
365                .map(|value| serde_json::to_string(value).unwrap_or_default())
366                .unwrap_or_else(|| "(absent)".to_string());
367            row(
368                "permissions",
369                CheckStatus::Ok,
370                format!("readPermissions: {read}; writePermissions: {write}"),
371                None,
372            )
373        }
374        Err(CoreError::Auth { status: 403, .. }) => row(
375            "permissions",
376            CheckStatus::Warn,
377            "this token cannot read security-properties either (HTTP 403) — the gateway's read/write permissions likely exclude the token's security level (three-part cause 2)".to_string(),
378            Some("in the gateway UI (Platform→Security→Permissions) add the token's security level to the read/write permission lists, or grant the token a level the permissions already include".to_string()),
379        ),
380        Err(err) => row(
381            "permissions",
382            CheckStatus::Warn,
383            format!("could not read security-properties: {err}"),
384            None,
385        ),
386    }
387}
388
389/// 6. write probe (only with --check-write): the harmless
390///    scan/projects rescan — 2xx = write permission, 403 = read-only
391///    token (igw-cli's choice; set+reset of a logger level would be more
392///    visibly mutating and is deliberately NOT used).
393async fn check_write(api: &dyn GatewayApi, opts: &DoctorOptions) -> CheckResult {
394    if !opts.check_write {
395        return row(
396            "write",
397            CheckStatus::Skip,
398            "not requested (--check-write)".to_string(),
399            None,
400        );
401    }
402    match api.scan_projects().await {
403        Ok(()) => row(
404            "write",
405            CheckStatus::Ok,
406            "scan/projects accepted (2xx) — write permitted".to_string(),
407            None,
408        ),
409        Err(CoreError::Auth { status: 403, .. }) => row(
410            "write",
411            CheckStatus::Warn,
412            "read-only token (HTTP 403 on scan/projects)".to_string(),
413            Some(
414                "grant the token write permission or use a token with an adequate security level"
415                    .to_string(),
416            ),
417        ),
418        Err(err) => row(
419            "write",
420            CheckStatus::Fail,
421            format!("scan/projects probe failed: {err}"),
422            None,
423        ),
424    }
425}
426
427/// 7. webdev route presence (only with --webdev-route NAME): the
428///    05-03 re-pin — probe the route's `version` action inside the
429///    CLI's ign-cli project via `webdev_route_probe`. **405 = absent**
430///    (the live-proven 8.3 marker; the Phase-2 404 assumption was
431///    research-Pitfall-1 WRONG), 402 = module unlicensed,
432///    200 = present (+ handshake version). The status code IS the
433///    answer — never classified.
434async fn check_webdev(api: &dyn GatewayApi, opts: &DoctorOptions) -> CheckResult {
435    let Some(route) = opts.webdev_route.as_deref() else {
436        return row(
437            "webdev",
438            CheckStatus::Skip,
439            "not requested (--webdev-route NAME)".to_string(),
440            None,
441        );
442    };
443    match api
444        .webdev_route_probe(crate::client::webdev::DEFAULT_PROJECT, route, &[])
445        .await
446    {
447        Ok(RouteProbe::Present { route_version }) => row(
448            "webdev",
449            CheckStatus::Ok,
450            format!("route {route:?} present (version {route_version})"),
451            None,
452        ),
453        Ok(RouteProbe::Absent) => row(
454            "webdev",
455            CheckStatus::Warn,
456            format!("route {route:?} absent (HTTP 405 — the 8.3 absent marker)"),
457            Some(
458                "run `ign webdev deploy` to install the CLI's routes (or check the \
459                  route name)"
460                    .to_string(),
461            ),
462        ),
463        Ok(RouteProbe::Unlicensed) => row(
464            "webdev",
465            CheckStatus::Warn,
466            "WebDev module unlicensed (HTTP 402 — trial-expired rigs cannot \
467              serve /system/webdev routes)"
468                .to_string(),
469            Some(
470                "license the gateway; on a rig, `ign rig trial reset --yes` restarts \
471                  an expired trial"
472                    .to_string(),
473            ),
474        ),
475        Ok(RouteProbe::AuthGated) => row(
476            "webdev",
477            CheckStatus::Ok,
478            format!("route {route:?} present (auth-gated — HTTP 401/403)"),
479            None,
480        ),
481        Ok(RouteProbe::Denied { code, .. }) => row(
482            "webdev",
483            CheckStatus::Ok,
484            format!("route {route:?} present (denied: {code})"),
485            None,
486        ),
487        Err(err) => row(
488            "webdev",
489            CheckStatus::Fail,
490            format!("route {route:?} probe failed: {err}"),
491            None,
492        ),
493    }
494}
495
496/// 8. rig: local-only — Docker reachable → ok with its version;
497///    absent → skip (Phase 4 owns real rig detection). No gateway calls.
498fn check_rig() -> CheckResult {
499    match std::process::Command::new("docker")
500        .arg("--version")
501        .output()
502    {
503        Ok(output) if output.status.success() => {
504            let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
505            row("rig", CheckStatus::Ok, version, None)
506        }
507        _ => row(
508            "rig",
509            CheckStatus::Skip,
510            "no Docker / Phase 4 rig detection".to_string(),
511            None,
512        ),
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::{CheckStatus, DoctorOptions};
519    use crate::client::GatewayApi;
520    use crate::client::query::ListEnvelope;
521    use crate::client::restart::SecurityProperties;
522    use crate::client::status::StatusPing;
523    use crate::client::version::GatewayInfo;
524    use crate::error::CoreError;
525
526    /// A scripted double serving fn-pointer results (CoreError is not
527    /// Clone, so each call constructs its error fresh); everything
528    /// else unreachable.
529    struct DoctorRig {
530        ping: fn() -> Result<StatusPing, CoreError>,
531        info: fn() -> Result<GatewayInfo, CoreError>,
532        props: fn() -> Result<SecurityProperties, CoreError>,
533        webdev_probe: fn() -> Result<crate::client::webdev::RouteProbe, CoreError>,
534    }
535
536    fn tags_present() -> Result<crate::client::webdev::RouteProbe, CoreError> {
537        Ok(crate::client::webdev::RouteProbe::Present {
538            route_version: crate::webdev::ROUTE_BUNDLE_VERSION.to_string(),
539        })
540    }
541
542    fn tags_absent() -> Result<crate::client::webdev::RouteProbe, CoreError> {
543        Ok(crate::client::webdev::RouteProbe::Absent)
544    }
545
546    fn webdev_unlicensed() -> Result<crate::client::webdev::RouteProbe, CoreError> {
547        Ok(crate::client::webdev::RouteProbe::Unlicensed)
548    }
549
550    fn running() -> Result<StatusPing, CoreError> {
551        Ok(StatusPing {
552            state: "RUNNING".into(),
553        })
554    }
555
556    fn ok_info() -> Result<GatewayInfo, CoreError> {
557        Ok(serde_json::from_value(serde_json::json!({
558            "name": "GW",
559            "edition": "standard",
560            "ignitionVersion": "8.3.6 (b2026042713)"
561        }))
562        .expect("gateway-info fixture parses"))
563    }
564
565    fn ok_props() -> Result<SecurityProperties, CoreError> {
566        Ok(serde_json::from_value(serde_json::json!({
567            "readPermissions": {"anyOf": ["Authenticated/Roles/Administrator"]},
568            "writePermissions": {"anyOf": ["Authenticated/Roles/Administrator"]}
569        }))
570        .expect("security-properties fixture parses"))
571    }
572
573    fn info_403() -> Result<GatewayInfo, CoreError> {
574        Err(CoreError::Auth {
575            status: 403,
576            endpoint: None,
577        })
578    }
579
580    fn info_401() -> Result<GatewayInfo, CoreError> {
581        Err(CoreError::Auth {
582            status: 401,
583            endpoint: None,
584        })
585    }
586
587    fn props_403() -> Result<SecurityProperties, CoreError> {
588        Err(CoreError::Auth {
589            status: 403,
590            endpoint: None,
591        })
592    }
593
594    fn props_401() -> Result<SecurityProperties, CoreError> {
595        Err(CoreError::Auth {
596            status: 401,
597            endpoint: None,
598        })
599    }
600
601    #[async_trait::async_trait]
602    impl GatewayApi for DoctorRig {
603        async fn bundle_generate(
604            &self,
605        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
606            unreachable!("not part of this action")
607        }
608        async fn bundle_status(
609            &self,
610        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
611            unreachable!("not part of this action")
612        }
613        async fn bundle_download(
614            &self,
615            _out: &std::path::Path,
616        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
617            unreachable!("not part of this action")
618        }
619        async fn tag_provider_list(
620            &self,
621            _query: &crate::client::query::ListQuery,
622        ) -> Result<
623            crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
624            CoreError,
625        > {
626            unreachable!("not part of this action")
627        }
628        async fn tag_provider_find(
629            &self,
630            _name: &str,
631        ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
632            unreachable!("not part of this action")
633        }
634        async fn tag_provider_create(
635            &self,
636            _body: &[crate::client::tags::TagProviderCreate],
637        ) -> Result<(), CoreError> {
638            unreachable!("not part of this action")
639        }
640        async fn tag_provider_delete(
641            &self,
642            _name: &str,
643            _signature: &str,
644        ) -> Result<(), CoreError> {
645            unreachable!("not part of this action")
646        }
647        async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
648            unreachable!("not part of this action")
649        }
650        async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
651            unreachable!("not part of this action")
652        }
653        async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
654            unreachable!("not part of this action")
655        }
656        async fn backup_download(
657            &self,
658            _out: &std::path::Path,
659            _backup_type: crate::client::backup::BackupType,
660        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
661            unreachable!("not part of this action")
662        }
663        async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
664            unreachable!("not part of this action")
665        }
666        async fn eam_task_history(
667            &self,
668            _limit: Option<u32>,
669            _search: Option<&str>,
670        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
671        {
672            unreachable!("not part of this action")
673        }
674        async fn eam_task_definitions(
675            &self,
676        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
677        {
678            unreachable!("not part of this action")
679        }
680        async fn eam_task_find(
681            &self,
682            _name: &str,
683        ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
684            unreachable!("not part of this action")
685        }
686        async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
687            unreachable!("not part of this action")
688        }
689        async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
690            unreachable!("not part of this action")
691        }
692        async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
693            unreachable!("not part of this action")
694        }
695        async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
696            unreachable!("not part of this action")
697        }
698        async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
699            unreachable!("not part of this action")
700        }
701        async fn eam_tasks_scheduled(
702            &self,
703            _running: bool,
704        ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
705            unreachable!("not part of this action")
706        }
707        async fn eam_task_modify(
708            &self,
709            _definition: &serde_json::Value,
710        ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
711            unreachable!("not part of this action")
712        }
713        async fn eam_task_delete(
714            &self,
715            _name: &str,
716            _signature: &str,
717            _confirm: bool,
718        ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
719            unreachable!("not part of this action")
720        }
721        async fn api_call(
722            &self,
723            _call: &crate::client::apicall::ApiCallRequest,
724        ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
725            unreachable!("not part of this action")
726        }
727        async fn license_status(
728            &self,
729        ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
730            unreachable!("not part of this action")
731        }
732        async fn redundancy_status(
733            &self,
734        ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
735            unreachable!("not part of this action")
736        }
737        async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
738            unreachable!("not part of this action")
739        }
740        async fn gateway_info(&self) -> Result<GatewayInfo, CoreError> {
741            (self.info)()
742        }
743        async fn status_ping(&self) -> Result<StatusPing, CoreError> {
744            (self.ping)()
745        }
746        async fn security_properties(&self) -> Result<SecurityProperties, CoreError> {
747            (self.props)()
748        }
749        async fn scan_projects(&self) -> Result<(), CoreError> {
750            Err(CoreError::Auth {
751                status: 403,
752                endpoint: None,
753            })
754        }
755        async fn restart(&self) -> Result<(), CoreError> {
756            unreachable!("not part of this action")
757        }
758        async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
759            unreachable!("not part of this action")
760        }
761        async fn modules(
762            &self,
763            _quarantined: bool,
764            _query: &crate::client::query::ListQuery,
765        ) -> Result<ListEnvelope<crate::client::status::ModuleInfo>, CoreError> {
766            unreachable!("not part of this action")
767        }
768        async fn metrics_current(
769            &self,
770        ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
771            unreachable!("not part of this action")
772        }
773        async fn metrics_historic(
774            &self,
775        ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
776            unreachable!("not part of this action")
777        }
778        async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
779            unreachable!("not part of this action")
780        }
781        async fn designers(
782            &self,
783            _query: &crate::client::query::ListQuery,
784        ) -> Result<ListEnvelope<crate::client::sessions::DesignerInfo>, CoreError> {
785            unreachable!("not part of this action")
786        }
787        async fn perspective_sessions(
788            &self,
789            _query: &crate::client::query::ListQuery,
790        ) -> Result<ListEnvelope<crate::client::sessions::PerspectiveSession>, CoreError> {
791            unreachable!("not part of this action")
792        }
793        async fn vision_clients(
794            &self,
795            _query: &crate::client::query::ListQuery,
796        ) -> Result<ListEnvelope<crate::client::sessions::VisionClient>, CoreError> {
797            unreachable!("not part of this action")
798        }
799        async fn terminate_perspective_session(
800            &self,
801            _id: &str,
802            _message: Option<&str>,
803        ) -> Result<(), CoreError> {
804            unreachable!("not part of this action")
805        }
806        async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
807            unreachable!("not part of this action")
808        }
809        async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
810            unreachable!("not part of this action")
811        }
812        async fn database_connections(
813            &self,
814        ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
815        {
816            unreachable!("not part of this action")
817        }
818        async fn opc_connections(
819            &self,
820        ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
821        {
822            unreachable!("not part of this action")
823        }
824        async fn logs(
825            &self,
826            _filter: &crate::client::logs::LogQuery,
827        ) -> Result<ListEnvelope<crate::client::logs::LogEntry>, CoreError> {
828            unreachable!("not part of this action")
829        }
830        async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
831            unreachable!("not part of this action")
832        }
833        async fn loggers(
834            &self,
835            _query: &crate::client::query::ListQuery,
836        ) -> Result<ListEnvelope<crate::client::logs::LoggerInfo>, CoreError> {
837            unreachable!("not part of this action")
838        }
839        async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
840            unreachable!("not part of this action")
841        }
842        async fn reset_logger_levels(&self) -> Result<(), CoreError> {
843            unreachable!("not part of this action")
844        }
845        async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
846            unreachable!("not part of this action")
847        }
848        async fn webdev_route_call(
849            &self,
850            _project: &str,
851            _route: &str,
852            _body: &serde_json::Value,
853            _extra_headers: &[(&str, &str)],
854        ) -> Result<serde_json::Value, CoreError> {
855            unreachable!("not part of this action")
856        }
857        async fn webdev_route_probe(
858            &self,
859            _project: &str,
860            _route: &str,
861            _extra_headers: &[(&str, &str)],
862        ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
863            (self.webdev_probe)()
864        }
865        async fn projects(
866            &self,
867            _query: &crate::client::query::ListQuery,
868        ) -> Result<
869            crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
870            CoreError,
871        > {
872            unreachable!("not part of this action")
873        }
874        async fn project_find(
875            &self,
876            _name: &str,
877        ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
878            unreachable!("not part of this action")
879        }
880        async fn project_create(
881            &self,
882            _body: &crate::client::projects::ProjectCreate,
883        ) -> Result<(), CoreError> {
884            unreachable!("not part of this action")
885        }
886        async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
887            unreachable!("not part of this action")
888        }
889        async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
890            unreachable!("not part of this action")
891        }
892        async fn project_modify(
893            &self,
894            _name: &str,
895            _body: &crate::client::projects::ProjectModify,
896        ) -> Result<(), CoreError> {
897            unreachable!("not part of this action")
898        }
899        async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
900            unreachable!("not part of this action")
901        }
902        async fn project_export_to_file(
903            &self,
904            _name: &str,
905            _out: &std::path::Path,
906        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
907            unreachable!("not part of this action")
908        }
909        async fn project_import(
910            &self,
911            _name: &str,
912            _zip: Vec<u8>,
913            _overwrite: bool,
914        ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
915            unreachable!("not part of this action")
916        }
917    }
918
919    fn healthy_rig() -> DoctorRig {
920        DoctorRig {
921            ping: running,
922            info: ok_info,
923            props: ok_props,
924            webdev_probe: tags_present,
925        }
926    }
927
928    /// The check ORDER is contract (README documents the table): url,
929    /// liveness, commissioned, auth, permissions, write, webdev, rig.
930    #[tokio::test]
931    async fn checks_run_in_the_documented_order() {
932        let result = super::doctor(
933            &healthy_rig(),
934            "http://127.0.0.1:1",
935            true,
936            &DoctorOptions::default(),
937        )
938        .await;
939        let names: Vec<&str> = result.checks.iter().map(|c| c.name.as_str()).collect();
940        assert_eq!(
941            names,
942            vec![
943                "url",
944                "liveness",
945                "commissioned",
946                "auth",
947                "permissions",
948                "write",
949                "webdev",
950                "rig"
951            ],
952        );
953        // A healthy rig: url FAILS (dead port dial), everything gateway
954        // side is ok, write/webdev skip without their flags.
955        let by_name = |name: &str| {
956            result
957                .checks
958                .iter()
959                .find(|c| c.name == name)
960                .unwrap_or_else(|| panic!("{name} row present"))
961        };
962        assert_eq!(by_name("liveness").status, CheckStatus::Ok);
963        assert_eq!(by_name("commissioned").status, CheckStatus::Ok);
964        assert_eq!(by_name("auth").status, CheckStatus::Ok);
965        assert_eq!(by_name("permissions").status, CheckStatus::Ok);
966        assert_eq!(by_name("write").status, CheckStatus::Skip);
967        assert_eq!(by_name("webdev").status, CheckStatus::Skip);
968    }
969
970    /// The healthy permissions row surfaces the ACTUAL wiring
971    /// (readPermissions/writePermissions verbatim).
972    #[tokio::test]
973    async fn healthy_permissions_row_surfaces_the_wiring() {
974        let result = super::doctor(
975            &healthy_rig(),
976            "http://127.0.0.1:1",
977            true,
978            &DoctorOptions::default(),
979        )
980        .await;
981        let perms = result
982            .checks
983            .iter()
984            .find(|c| c.name == "permissions")
985            .unwrap();
986        assert!(
987            perms.detail.contains("readPermissions"),
988            "detail: {}",
989            perms.detail
990        );
991        assert!(
992            perms.detail.contains("Authenticated/Roles/Administrator"),
993            "the wiring value surfaces verbatim: {}",
994            perms.detail
995        );
996    }
997
998    /// The 403 wiring diagnosis: auth fails with the three-part hint
999    /// AND the permissions deep-dive CONFIRMS the token cannot read
1000    /// the security config either (the part-2 confirmation).
1001    #[tokio::test]
1002    async fn the_403_case_carries_the_three_part_hint_and_permissions_detail() {
1003        let rig = DoctorRig {
1004            ping: running,
1005            info: info_403,
1006            props: props_403,
1007            webdev_probe: tags_present,
1008        };
1009        let result =
1010            super::doctor(&rig, "http://127.0.0.1:1", true, &DoctorOptions::default()).await;
1011        let auth = result.checks.iter().find(|c| c.name == "auth").unwrap();
1012        assert_eq!(auth.status, CheckStatus::Fail);
1013        let hint = auth.hint.as_deref().unwrap();
1014        assert!(hint.contains("three parts"), "hint: {hint}");
1015        assert!(hint.contains("permissions"), "hint: {hint}");
1016        let perms = result
1017            .checks
1018            .iter()
1019            .find(|c| c.name == "permissions")
1020            .unwrap();
1021        assert_eq!(perms.status, CheckStatus::Warn);
1022        assert!(
1023            perms.detail.contains("cannot read security-properties"),
1024            "detail: {}",
1025            perms.detail
1026        );
1027    }
1028
1029    /// A no-credential 401 is diagnosed as UNCONFIGURED, not as a bad
1030    /// token — the honest split; permissions skip (needs a working
1031    /// token).
1032    #[tokio::test]
1033    async fn the_no_credential_401_is_its_own_diagnosis() {
1034        let rig = DoctorRig {
1035            ping: running,
1036            info: info_401,
1037            props: props_401,
1038            webdev_probe: tags_present,
1039        };
1040        let result =
1041            super::doctor(&rig, "http://127.0.0.1:1", false, &DoctorOptions::default()).await;
1042        let auth = result.checks.iter().find(|c| c.name == "auth").unwrap();
1043        assert_eq!(auth.status, CheckStatus::Fail);
1044        assert!(
1045            auth.detail.contains("no credential resolved"),
1046            "detail: {}",
1047            auth.detail
1048        );
1049        assert!(
1050            auth.hint.as_deref().unwrap().contains("IGNITION_TOKEN"),
1051            "hint names the fix"
1052        );
1053        let perms = result
1054            .checks
1055            .iter()
1056            .find(|c| c.name == "permissions")
1057            .unwrap();
1058        assert_eq!(perms.status, CheckStatus::Skip);
1059    }
1060
1061    /// A token-present 401 names the name:key format failure.
1062    #[tokio::test]
1063    async fn the_token_401_names_the_name_key_format() {
1064        let rig = DoctorRig {
1065            ping: running,
1066            info: info_401,
1067            props: props_401,
1068            webdev_probe: tags_present,
1069        };
1070        let result =
1071            super::doctor(&rig, "http://127.0.0.1:1", true, &DoctorOptions::default()).await;
1072        let auth = result.checks.iter().find(|c| c.name == "auth").unwrap();
1073        assert!(
1074            auth.hint.as_deref().unwrap().contains("name:key"),
1075            "hint: {:?}",
1076            auth.hint
1077        );
1078    }
1079
1080    /// The url check parses and TCP-dials: a dead port FAILS with a
1081    /// connect diagnosis (127.0.0.1:1 refuses instantly).
1082    #[tokio::test]
1083    async fn url_check_dials_and_reports_a_dead_port() {
1084        let result = super::doctor(
1085            &healthy_rig(),
1086            "http://127.0.0.1:1",
1087            true,
1088            &DoctorOptions::default(),
1089        )
1090        .await;
1091        let url = result.checks.first().unwrap();
1092        assert_eq!(url.status, CheckStatus::Fail);
1093        assert!(url.detail.contains("TCP connect"), "detail: {}", url.detail);
1094    }
1095
1096    /// The write probe: 403 → warn "read-only token" (the rig's
1097    /// scan_projects answers 403); skipped without --check-write.
1098    #[tokio::test]
1099    async fn write_probe_warns_read_only_on_403() {
1100        let result = super::doctor(
1101            &healthy_rig(),
1102            "http://127.0.0.1:1",
1103            true,
1104            &DoctorOptions {
1105                check_write: true,
1106                webdev_route: None,
1107            },
1108        )
1109        .await;
1110        let write = result.checks.iter().find(|c| c.name == "write").unwrap();
1111        assert_eq!(write.status, CheckStatus::Warn);
1112        assert!(
1113            write.detail.contains("read-only token"),
1114            "detail: {}",
1115            write.detail
1116        );
1117    }
1118
1119    /// THE 05-03 re-pin: a 405 answer is ABSENT (warn + `ign webdev
1120    /// deploy` hint) — replacing the documented-but-wrong Phase-2 404
1121    /// assumption (research Pitfall 1).
1122    #[tokio::test]
1123    async fn webdev_405_means_absent_with_a_deploy_hint() {
1124        let rig = DoctorRig {
1125            webdev_probe: tags_absent,
1126            ..healthy_rig()
1127        };
1128        let result = super::doctor(
1129            &rig,
1130            "http://127.0.0.1:1",
1131            true,
1132            &DoctorOptions {
1133                check_write: false,
1134                webdev_route: Some("tags".into()),
1135            },
1136        )
1137        .await;
1138        let webdev = result.checks.iter().find(|c| c.name == "webdev").unwrap();
1139        assert_eq!(webdev.status, CheckStatus::Warn);
1140        assert!(
1141            webdev.detail.contains("405"),
1142            "the 405 marker surfaces: {}",
1143            webdev.detail
1144        );
1145        assert!(
1146            webdev
1147                .hint
1148                .as_deref()
1149                .unwrap()
1150                .contains("ign webdev deploy"),
1151            "hint names the fix"
1152        );
1153    }
1154
1155    /// A present route answers ok with its handshake version; a 402
1156    /// rig warns "module unlicensed" (the trial-expired state).
1157    #[tokio::test]
1158    async fn webdev_present_ok_and_402_unlicensed() {
1159        let result = super::doctor(
1160            &healthy_rig(),
1161            "http://127.0.0.1:1",
1162            true,
1163            &DoctorOptions {
1164                check_write: false,
1165                webdev_route: Some("tags".into()),
1166            },
1167        )
1168        .await;
1169        let webdev = result.checks.iter().find(|c| c.name == "webdev").unwrap();
1170        assert_eq!(webdev.status, CheckStatus::Ok);
1171        assert!(
1172            webdev.detail.contains("present (version"),
1173            "detail carries the handshake version: {}",
1174            webdev.detail
1175        );
1176
1177        let rig = DoctorRig {
1178            webdev_probe: webdev_unlicensed,
1179            ..healthy_rig()
1180        };
1181        let result = super::doctor(
1182            &rig,
1183            "http://127.0.0.1:1",
1184            true,
1185            &DoctorOptions {
1186                check_write: false,
1187                webdev_route: Some("tags".into()),
1188            },
1189        )
1190        .await;
1191        let webdev = result.checks.iter().find(|c| c.name == "webdev").unwrap();
1192        assert_eq!(webdev.status, CheckStatus::Warn);
1193        assert!(
1194            webdev.detail.contains("unlicensed"),
1195            "detail: {}",
1196            webdev.detail
1197        );
1198    }
1199
1200    /// Serialization pins: statuses are lowercase; the checks[] keys
1201    /// are exactly {name, status, detail, hint} with hint null-able.
1202    #[test]
1203    fn check_result_serializes_with_exactly_four_keys() {
1204        let body = serde_json::to_value(super::CheckResult {
1205            name: "auth".into(),
1206            status: CheckStatus::Fail,
1207            detail: "detail".into(),
1208            hint: None,
1209        })
1210        .expect("serialize");
1211        assert_eq!(
1212            body,
1213            serde_json::json!({
1214                "name": "auth",
1215                "status": "fail",
1216                "detail": "detail",
1217                "hint": null
1218            })
1219        );
1220    }
1221}