Skip to main content

ignition_core/actions/
webdev.rs

1//! The WebDev deploy/status actions (05-03, WEB-01 + WEB-02) — the
2//! hinge layer every tag command in 05-04..06 rides.
3//!
4//! `webdev_deploy` installs the embedded route bundle
5//! ([`crate::webdev`], 05-01) into the dedicated project through the
6//! 03-02 import machinery with `overwrite=true` — the CLI owns the
7//! project wholesale, so replace-not-merge is CORRECT here (research
8//! deploy guidance) and deploy is deliberately NOT `--yes`-guarded.
9//! The project is born from the first deploy zip: NO pre-flight
10//! create (Pitfall 10's one-shot "resource already exists" quirk).
11//!
12//! `webdev_status` probes every route's `version` action and reports
13//! the per-route matrix ({present, absent, unlicensed, auth_gated,
14//! secret_mismatch, version_mismatch}) — a READ: exit 0 whenever the
15//! sweep completes, degradation is data (the doctor precedent).
16//!
17//! `webdev_precondition` is the cheap refusal every
18//! WebDev-DEPENDENT command runs first (05-04+): absent routes or a
19//! version mismatch refuse exit 6 naming `ign webdev deploy` — the
20//! roadmap's actionable-error criterion, no auto-upgrade magic.
21//!
22//! The scriptExec secret lifecycle lives HERE: deploy generates a
23//! 32-byte hex secret from `/dev/urandom` (zero-dep — the workspace
24//! has no `rand`; unix-only is fine, no Windows CI is locked),
25//! persists it in the profile config at 0600 (the ONE
26//! value-carrying exception on [`crate::config::Profile`], documented
27//! there), and bakes it into the route zip BEFORE upload. The secret
28//! NEVER appears in any action result, log, or JSON envelope — the
29//! redaction test below pins that.
30
31use std::path::Path;
32
33use serde::Serialize;
34
35use crate::client::GatewayApi;
36use crate::client::webdev::{self as seam, RouteProbe};
37use crate::config;
38use crate::error::CoreError;
39use crate::webdev as bundle;
40
41/// The secret-bearing scriptExec route name (deploy/status append it
42/// only when explicitly requested / configured). `pub(crate)` since
43/// 07-03: the script action reuses the EXACT spelling (one constant,
44/// no drift).
45pub(crate) const SCRIPT_EXEC_ROUTE: &str = "scriptExec";
46
47/// The header the scriptExec gate compares (case-insensitive
48/// server-side; the CLI sends the canonical form). `pub(crate)` since
49/// 07-03 — the script action's calls carry it too.
50pub(crate) const SECRET_HEADER: &str = "X-Ignition-CLI-Secret";
51
52/// `ign webdev deploy` result — ALL keys always (the agent shape);
53/// the import answer rides as the opaque-success object verbatim.
54/// The secret appears in NONE of them (redaction).
55#[derive(Debug, Serialize)]
56pub struct WebdevDeployResult {
57    /// The project the bundle deployed into.
58    pub project: String,
59    /// Route folder names deployed, manifest order (+ scriptExec when
60    /// it shipped).
61    pub routes: Vec<String>,
62    /// Whether scriptExec rode the deploy.
63    pub script_exec: bool,
64    /// Whether a NEW secret was generated and persisted (first
65    /// scriptExec deploy or `--rotate-secret`).
66    pub secret_rotated: bool,
67    /// The import endpoint's opaque answer (the 03-02
68    /// `{"status":"success"}`-normalized object).
69    pub import: serde_json::Value,
70}
71
72/// One route's status-sweep row — ALL keys always.
73#[derive(Debug, Clone, PartialEq, Serialize)]
74pub struct RouteStatusRow {
75    /// Route folder name.
76    pub route: String,
77    /// The per-route matrix verdict.
78    pub status: RouteStatus,
79    /// The route's answered `routeVersion` (absent states → null).
80    pub deployed_version: Option<String>,
81    /// The embedded bundle version this CLI expects (always known).
82    pub expected_version: Option<String>,
83}
84
85/// The per-route status matrix (WebDev-dependent commands refuse on
86/// the same discrimination; status reports it as data).
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
88#[serde(rename_all = "snake_case")]
89pub enum RouteStatus {
90    /// Deployed, answering, version matches the embedded bundle.
91    Present,
92    /// 405 — not deployed (the 8.3 absent marker).
93    Absent,
94    /// 402 — the WebDev module is unlicensed (trial-expired gateway).
95    Unlicensed,
96    /// 401/403 — present but rejecting the credential.
97    AuthGated,
98    /// The scriptExec gate refused the configured secret (deployed
99    /// elsewhere / stale) — redeploy or `--rotate-secret`.
100    SecretMismatch,
101    /// Deployed but the handshake version differs from the bundle's.
102    VersionMismatch,
103}
104
105/// `ign webdev status` result — ALL keys always.
106#[derive(Debug, Serialize)]
107pub struct WebdevStatusResult {
108    /// The probed project.
109    pub project: String,
110    /// One row per always-on route (+ scriptExec when a secret is
111    /// configured — unprobed otherwise, per the plan's conditional).
112    pub routes: Vec<RouteStatusRow>,
113    /// True only when every ALWAYS-ON route is present with matching
114    /// versions (scriptExec never gates `ok` — it deploys on explicit
115    /// request only).
116    pub ok: bool,
117}
118
119/// `ign webdev deploy` — pack the embedded bundle, import it
120/// overwrite-style, own the secret lifecycle.
121///
122/// Secret rules (the plan's LOCKED posture): a fresh secret is
123/// generated + persisted 0600 when (a) `--with-script-exec` finds no
124/// stored secret, or (b) `--rotate-secret` asks regardless; a plain
125/// scriptExec deploy reuses the stored secret unchanged. Persisting
126/// happens BEFORE the upload: a failed import then leaves a stored
127/// secret the NEXT deploy packs as-is (self-healing), and a broken
128/// config store refuses before any gateway I/O.
129pub async fn webdev_deploy(
130    api: &dyn GatewayApi,
131    project: &str,
132    with_script_exec: bool,
133    rotate_secret: bool,
134    config_path: &Path,
135    profile_name: &str,
136    with_testing: bool,
137) -> Result<WebdevDeployResult, CoreError> {
138    // Secret lifecycle first — refuse on an unwritable config store
139    // BEFORE touching the gateway.
140    let mut config = config::load(config_path)?;
141    let existing = config
142        .profiles
143        .get(profile_name)
144        .and_then(|profile| profile.webdev_secret.clone());
145    let (pack_secret, secret_rotated) = if rotate_secret || (with_script_exec && existing.is_none())
146    {
147        let secret = generate_secret()?;
148        config
149            .profiles
150            .get_mut(profile_name)
151            .ok_or_else(|| {
152                CoreError::Internal(format!(
153                    "profile {profile_name:?} vanished from the config between dispatch and deploy"
154                ))
155            })?
156            .webdev_secret = Some(secret.clone());
157        config::save(config_path, &config)?; // re-asserts 0600
158        (Some(secret), true)
159    } else if with_script_exec {
160        (existing, false)
161    } else {
162        (None, false)
163    };
164
165    // Pack (scriptExec only when flagged — build_deploy_zip's
166    // fail-closed guard covers the (true, None) bug case) and import
167    // overwrite=true through the 03-02 machinery. NO pre-flight
168    // project create (Pitfall 10).
169    let zip = seam::build_deploy_zip(
170        project,
171        with_script_exec,
172        pack_secret.as_deref(),
173        with_testing,
174    )?;
175    let mut routes = seam::always_on_routes();
176    if with_script_exec {
177        routes.push(SCRIPT_EXEC_ROUTE.to_string());
178    }
179    if with_testing {
180        routes.extend(
181            crate::webdev::testing::TESTING_ROUTES
182                .iter()
183                .map(|route| format!("testing/{route}")),
184        );
185    }
186    let import = api.project_import(project, zip, true).await?;
187
188    Ok(WebdevDeployResult {
189        project: project.to_string(),
190        routes,
191        script_exec: with_script_exec,
192        secret_rotated,
193        import: import.response,
194    })
195}
196
197/// `ign webdev status` — the version-handshake sweep. The 4 always-on
198/// routes always ride; scriptExec's version action is probed ONLY
199/// when a secret is configured (its header rides along — research OQ4:
200/// AuthGated → auth_gated, secret denials → secret_mismatch).
201pub async fn webdev_status(
202    api: &dyn GatewayApi,
203    project: &str,
204    secret: Option<&str>,
205) -> Result<WebdevStatusResult, CoreError> {
206    let mut routes = Vec::new();
207    let mut ok = true;
208    for route in seam::always_on_routes() {
209        let probe = api.webdev_route_probe(project, &route, &[]).await?;
210        let row = classify_probe(&route, probe);
211        ok &= row.status == RouteStatus::Present;
212        routes.push(row);
213    }
214    if let Some(secret) = secret {
215        let probe = api
216            .webdev_route_probe(project, SCRIPT_EXEC_ROUTE, &[(SECRET_HEADER, secret)])
217            .await?;
218        // scriptExec never gates `ok` — it ships on explicit request.
219        routes.push(classify_probe(SCRIPT_EXEC_ROUTE, probe));
220    }
221    Ok(WebdevStatusResult {
222        project: project.to_string(),
223        routes,
224        ok,
225    })
226}
227
228/// The cheap precondition every WebDev-DEPENDENT command runs first
229/// (05-04's tags family onward): probe the canonical `tags` route and
230/// refuse with the actionable matrix — absent → `routes_not_deployed`
231/// (exit 6, hint names `ign webdev deploy`), version mismatch →
232/// `route_version_mismatch` (hint direction-aware: redeploy or update
233/// ign), unlicensed → `webdev_unlicensed`. No auto-upgrade magic.
234pub async fn webdev_precondition(api: &dyn GatewayApi, project: &str) -> Result<(), CoreError> {
235    const ROUTE: &str = "tags";
236    let endpoint = seam::route_url(project, ROUTE);
237    match api.webdev_route_probe(project, ROUTE, &[]).await? {
238        RouteProbe::Present { route_version } if route_version == bundle::ROUTE_BUNDLE_VERSION => {
239            Ok(())
240        }
241        RouteProbe::Present { route_version } => Err(CoreError::RouteVersionMismatch {
242            route: ROUTE.to_string(),
243            deployed: route_version,
244            expected: bundle::ROUTE_BUNDLE_VERSION.to_string(),
245            endpoint: Some(endpoint),
246        }),
247        RouteProbe::Absent => Err(CoreError::RoutesNotDeployed {
248            project: project.to_string(),
249            route: ROUTE.to_string(),
250            endpoint: Some(endpoint),
251        }),
252        RouteProbe::Unlicensed => Err(CoreError::WebdevUnlicensed {
253            endpoint: Some(endpoint),
254        }),
255        RouteProbe::AuthGated => Err(CoreError::Auth {
256            status: 401,
257            endpoint: Some(endpoint),
258        }),
259        RouteProbe::Denied {
260            code,
261            message,
262            traceback,
263        } => {
264            let mut full = message;
265            if let Some(traceback) = traceback {
266                full.push_str("\nroute traceback: ");
267                full.push_str(&traceback);
268            }
269            Err(CoreError::WebdevRouteError {
270                code,
271                message: full,
272                endpoint: Some(endpoint),
273            })
274        }
275    }
276}
277
278/// Map one probe onto a status row (the matrix the sweep reports and
279/// the precondition refuses on).
280fn classify_probe(route: &str, probe: RouteProbe) -> RouteStatusRow {
281    let expected = bundle::ROUTE_BUNDLE_VERSION;
282    match probe {
283        RouteProbe::Present { route_version } => {
284            let status = if route_version == expected {
285                RouteStatus::Present
286            } else {
287                RouteStatus::VersionMismatch
288            };
289            RouteStatusRow {
290                route: route.to_string(),
291                status,
292                deployed_version: Some(route_version),
293                expected_version: Some(expected.to_string()),
294            }
295        }
296        RouteProbe::Denied { code, .. } => {
297            // With the shipped routes the version action's only
298            // denials are the secret gate's (the gate runs before
299            // dispatch); any OTHER code means the path holds a route
300            // this CLI does not recognize — the redeploy advice that
301            // version_mismatch carries is the honest fix either way.
302            let status = if code == "secret_required" || code == "secret_mismatch" {
303                RouteStatus::SecretMismatch
304            } else {
305                RouteStatus::VersionMismatch
306            };
307            absent_row(route, status)
308        }
309        RouteProbe::Absent => absent_row(route, RouteStatus::Absent),
310        RouteProbe::Unlicensed => absent_row(route, RouteStatus::Unlicensed),
311        RouteProbe::AuthGated => absent_row(route, RouteStatus::AuthGated),
312    }
313}
314
315/// A row for every probe state that answered no version.
316fn absent_row(route: &str, status: RouteStatus) -> RouteStatusRow {
317    RouteStatusRow {
318        route: route.to_string(),
319        status,
320        deployed_version: None,
321        expected_version: Some(bundle::ROUTE_BUNDLE_VERSION.to_string()),
322    }
323}
324
325/// 32 random bytes, hex-encoded (64 chars) — via the `getrandom`
326/// crate (already in the tree via ring; 0.2 pinned to dedupe).
327/// The pre-Windows `/dev/urandom` read is gone with the reversal of
328/// the no-Windows decision: getrandom rides the OS CSPRNG everywhere
329/// (BCryptGenRandom on Windows, getrandom(2)/urandom on unix).
330fn generate_secret() -> Result<String, CoreError> {
331    let mut bytes = [0u8; 32];
332    getrandom::getrandom(&mut bytes)
333        .map_err(|err| CoreError::Internal(format!("cannot generate the deploy secret: {err}")))?;
334    Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
335}
336
337#[cfg(test)]
338mod tests {
339    use super::{
340        RouteProbe, RouteStatus, WebdevDeployResult, generate_secret, webdev_deploy,
341        webdev_precondition, webdev_status,
342    };
343    use crate::client::GatewayApi;
344    use crate::client::projects::ImportOutcome;
345    use crate::error::CoreError;
346    use crate::webdev::ROUTE_BUNDLE_VERSION as BUNDLE_VERSION;
347    use std::path::PathBuf;
348
349    /// A scripted double: probes answer from a lookup, the import
350    /// callback packs the zip for inspection (recorded through a
351    /// Mutex so the closure stays `Fn` — `&self` callable — and the
352    /// rig `Sync`, both required by `&dyn GatewayApi`). Everything
353    /// else is unreachable (the established action-double shape).
354    struct WebdevRig {
355        probe: fn(&str) -> Result<RouteProbe, CoreError>,
356        import: Box<dyn Fn(Vec<u8>, bool) -> Result<ImportOutcome, CoreError> + Send + Sync>,
357    }
358
359    fn present(version: &str) -> Result<RouteProbe, CoreError> {
360        Ok(RouteProbe::Present {
361            route_version: version.to_string(),
362        })
363    }
364
365    fn ok_import() -> ImportOutcome {
366        ImportOutcome {
367            response: serde_json::json!({"success": true}),
368        }
369    }
370
371    #[async_trait::async_trait]
372    impl GatewayApi for WebdevRig {
373        async fn uninstall_module(
374            &self,
375            _gateway_module_id: &str,
376            _module_id: &str,
377        ) -> Result<(), CoreError> {
378            unreachable!("not part of this action")
379        }
380        async fn bundle_generate(
381            &self,
382        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
383            unreachable!("not part of this action")
384        }
385        async fn bundle_status(
386            &self,
387        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
388            unreachable!("not part of this action")
389        }
390        async fn bundle_download(
391            &self,
392            _out: &std::path::Path,
393        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
394            unreachable!("not part of this action")
395        }
396        async fn tag_provider_list(
397            &self,
398            _query: &crate::client::query::ListQuery,
399        ) -> Result<
400            crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
401            CoreError,
402        > {
403            unreachable!("not part of this action")
404        }
405        async fn tag_provider_find(
406            &self,
407            _name: &str,
408        ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
409            unreachable!("not part of this action")
410        }
411        async fn tag_provider_create(
412            &self,
413            _body: &[crate::client::tags::TagProviderCreate],
414        ) -> Result<(), CoreError> {
415            unreachable!("not part of this action")
416        }
417        async fn tag_provider_delete(
418            &self,
419            _name: &str,
420            _signature: &str,
421        ) -> Result<(), CoreError> {
422            unreachable!("not part of this action")
423        }
424        async fn webdev_route_probe(
425            &self,
426            _project: &str,
427            route: &str,
428            _extra_headers: &[(&str, &str)],
429        ) -> Result<RouteProbe, CoreError> {
430            (self.probe)(route)
431        }
432        async fn project_import(
433            &self,
434            _name: &str,
435            zip: Vec<u8>,
436            overwrite: bool,
437        ) -> Result<ImportOutcome, CoreError> {
438            (self.import)(zip, overwrite)
439        }
440        async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
441            unreachable!("not part of this action")
442        }
443        async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
444            unreachable!("not part of this action")
445        }
446        async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
447            unreachable!("not part of this action")
448        }
449        async fn modules(
450            &self,
451            _quarantined: bool,
452            _query: &crate::client::query::ListQuery,
453        ) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
454        {
455            unreachable!("not part of this action")
456        }
457        async fn metrics_current(
458            &self,
459        ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
460            unreachable!("not part of this action")
461        }
462        async fn metrics_historic(
463            &self,
464        ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
465            unreachable!("not part of this action")
466        }
467        async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
468            unreachable!("not part of this action")
469        }
470        async fn designers(
471            &self,
472            _query: &crate::client::query::ListQuery,
473        ) -> Result<
474            crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
475            CoreError,
476        > {
477            unreachable!("not part of this action")
478        }
479        async fn perspective_sessions(
480            &self,
481            _query: &crate::client::query::ListQuery,
482        ) -> Result<
483            crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
484            CoreError,
485        > {
486            unreachable!("not part of this action")
487        }
488        async fn vision_clients(
489            &self,
490            _query: &crate::client::query::ListQuery,
491        ) -> Result<
492            crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
493            CoreError,
494        > {
495            unreachable!("not part of this action")
496        }
497        async fn terminate_perspective_session(
498            &self,
499            _id: &str,
500            _message: Option<&str>,
501        ) -> Result<(), CoreError> {
502            unreachable!("not part of this action")
503        }
504        async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
505            unreachable!("not part of this action")
506        }
507        async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
508            unreachable!("not part of this action")
509        }
510        async fn database_connections(
511            &self,
512        ) -> Result<
513            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
514            CoreError,
515        > {
516            unreachable!("not part of this action")
517        }
518        async fn opc_connections(
519            &self,
520        ) -> Result<
521            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
522            CoreError,
523        > {
524            unreachable!("not part of this action")
525        }
526        async fn logs(
527            &self,
528            _filter: &crate::client::logs::LogQuery,
529        ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
530        {
531            unreachable!("not part of this action")
532        }
533        async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
534            unreachable!("not part of this action")
535        }
536        async fn loggers(
537            &self,
538            _query: &crate::client::query::ListQuery,
539        ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
540        {
541            unreachable!("not part of this action")
542        }
543        async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
544            unreachable!("not part of this action")
545        }
546        async fn reset_logger_levels(&self) -> Result<(), CoreError> {
547            unreachable!("not part of this action")
548        }
549        async fn restart(&self) -> Result<(), CoreError> {
550            unreachable!("not part of this action")
551        }
552        async fn scan_projects(&self) -> Result<(), CoreError> {
553            unreachable!("not part of this action")
554        }
555        async fn security_properties(
556            &self,
557        ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
558            unreachable!("not part of this action")
559        }
560        async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
561            unreachable!("not part of this action")
562        }
563        async fn webdev_route_call(
564            &self,
565            _project: &str,
566            _route: &str,
567            _body: &serde_json::Value,
568            _extra_headers: &[(&str, &str)],
569        ) -> Result<serde_json::Value, CoreError> {
570            unreachable!("not part of this action")
571        }
572        async fn projects(
573            &self,
574            _query: &crate::client::query::ListQuery,
575        ) -> Result<
576            crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
577            CoreError,
578        > {
579            unreachable!("not part of this action")
580        }
581        async fn project_find(
582            &self,
583            _name: &str,
584        ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
585            unreachable!("not part of this action")
586        }
587        async fn project_create(
588            &self,
589            _body: &crate::client::projects::ProjectCreate,
590        ) -> Result<(), CoreError> {
591            unreachable!("not part of this action")
592        }
593        async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
594            unreachable!("not part of this action")
595        }
596        async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
597            unreachable!("not part of this action")
598        }
599        async fn project_modify(
600            &self,
601            _name: &str,
602            _body: &crate::client::projects::ProjectModify,
603        ) -> Result<(), CoreError> {
604            unreachable!("not part of this action")
605        }
606        async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
607            unreachable!("not part of this action")
608        }
609        async fn project_export_to_file(
610            &self,
611            _name: &str,
612            _out: &std::path::Path,
613        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
614            unreachable!("not part of this action")
615        }
616        async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
617            unreachable!("not part of this action")
618        }
619        async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
620            unreachable!("not part of this action")
621        }
622        async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
623            unreachable!("not part of this action")
624        }
625        async fn backup_download(
626            &self,
627            _out: &std::path::Path,
628            _backup_type: crate::client::backup::BackupType,
629        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
630            unreachable!("not part of this action")
631        }
632        async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
633            unreachable!("not part of this action")
634        }
635        async fn eam_task_history(
636            &self,
637            _limit: Option<u32>,
638            _search: Option<&str>,
639        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
640        {
641            unreachable!("not part of this action")
642        }
643        async fn eam_task_definitions(
644            &self,
645        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
646        {
647            unreachable!("not part of this action")
648        }
649        async fn eam_task_find(
650            &self,
651            _name: &str,
652        ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
653            unreachable!("not part of this action")
654        }
655        async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
656            unreachable!("not part of this action")
657        }
658        async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
659            unreachable!("not part of this action")
660        }
661        async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
662            unreachable!("not part of this action")
663        }
664        async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
665            unreachable!("not part of this action")
666        }
667        async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
668            unreachable!("not part of this action")
669        }
670        async fn eam_tasks_scheduled(
671            &self,
672            _running: bool,
673        ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
674            unreachable!("not part of this action")
675        }
676        async fn eam_task_modify(
677            &self,
678            _definition: &serde_json::Value,
679        ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
680            unreachable!("not part of this action")
681        }
682        async fn eam_task_delete(
683            &self,
684            _name: &str,
685            _signature: &str,
686            _confirm: bool,
687        ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
688            unreachable!("not part of this action")
689        }
690        async fn api_call(
691            &self,
692            _call: &crate::client::apicall::ApiCallRequest,
693        ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
694            unreachable!("not part of this action")
695        }
696        async fn license_status(
697            &self,
698        ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
699            unreachable!("not part of this action")
700        }
701        async fn redundancy_status(
702            &self,
703        ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
704            unreachable!("not part of this action")
705        }
706        async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
707            unreachable!("not part of this action")
708        }
709    }
710
711    /// Temp config with one `dev` profile (no auth — the action never
712    /// resolves credentials itself).
713    fn temp_config() -> (tempfile::TempDir, PathBuf) {
714        let dir = tempfile::tempdir().expect("tempdir");
715        let path = dir.path().join("config.toml");
716        std::fs::write(
717            &path,
718            "active = \"dev\"\n\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n",
719        )
720        .expect("write config");
721        (dir, path)
722    }
723
724    /// Read the stored secret back out of a config (test eyes only).
725    fn stored_secret(path: &std::path::Path) -> Option<String> {
726        crate::config::load(path)
727            .expect("config reloads")
728            .profiles
729            .get("dev")
730            .and_then(|profile| profile.webdev_secret.clone())
731    }
732
733    /// A rig whose import always succeeds and records nothing.
734    fn importing_rig() -> WebdevRig {
735        WebdevRig {
736            probe: |_| unreachable!("deploy never probes"),
737            import: Box::new(|_zip, _overwrite| Ok(ok_import())),
738        }
739    }
740
741    /// Deploy WITHOUT --with-script-exec never ships the route and
742    /// never stores a secret.
743    #[tokio::test]
744    async fn deploy_without_script_exec_ships_only_the_always_on_bundle() {
745        let (dir, config) = temp_config();
746        let seen_zip = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
747        let recorder = std::sync::Arc::clone(&seen_zip);
748        let rig = WebdevRig {
749            probe: |_| unreachable!("deploy never probes"),
750            import: Box::new(move |zip, overwrite| {
751                assert!(overwrite, "deploy ALWAYS overwrite-imports");
752                *recorder.lock().expect("zip lock") = zip;
753                Ok(ok_import())
754            }),
755        };
756        let result = webdev_deploy(&rig, "ign-cli", false, false, &config, "dev", false)
757            .await
758            .expect("plain deploy");
759        assert_eq!(
760            result.routes,
761            vec!["tags", "tagConfig", "alarms", "tagHistory"]
762        );
763        assert!(!result.script_exec);
764        assert!(!result.secret_rotated);
765        assert_eq!(result.import["success"], true);
766
767        // The uploaded zip carries NO scriptExec member and no secret
768        // landed in the config.
769        let names = member_names(&seen_zip.lock().expect("zip lock"));
770        assert!(names.iter().all(|name| !name.contains("scriptExec")));
771        assert_eq!(stored_secret(&config), None);
772        let _ = dir; // keeps the tempdir alive for the asserts above
773    }
774
775    /// Deploy --with-script-exec with NO stored secret: generates,
776    /// persists 0600, bakes into the zip — and the SERIALIZED result
777    /// never carries the value (the redaction pin).
778    #[tokio::test]
779    async fn deploy_with_script_exec_generates_and_redacts_the_secret() {
780        let (dir, config) = temp_config();
781        let seen_zip = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
782        let recorder = std::sync::Arc::clone(&seen_zip);
783        let rig = WebdevRig {
784            probe: |_| unreachable!("deploy never probes"),
785            import: Box::new(move |zip, _| {
786                *recorder.lock().expect("zip lock") = zip;
787                Ok(ok_import())
788            }),
789        };
790        let result = webdev_deploy(&rig, "ign-cli", true, false, &config, "dev", false)
791            .await
792            .expect("scriptExec deploy");
793        assert_eq!(
794            result.routes,
795            vec!["tags", "tagConfig", "alarms", "tagHistory", "scriptExec"]
796        );
797        assert!(result.script_exec && result.secret_rotated);
798
799        let secret = stored_secret(&config).expect("secret persisted");
800        assert_eq!(secret.len(), 64, "32 bytes hex-encoded");
801        assert!(
802            secret.chars().all(|c| c.is_ascii_hexdigit()),
803            "hex alphabet: {secret}"
804        );
805        #[cfg(unix)]
806        {
807            use std::os::unix::fs::PermissionsExt;
808            let mode = std::fs::metadata(&config)
809                .expect("config stat")
810                .permissions()
811                .mode();
812            assert_eq!(mode & 0o777, 0o600, "the save path re-asserts 0600");
813        }
814
815        // The baked route carries the secret; the serialized result
816        // does NOT (redaction — Phase 1's canary pattern).
817        let do_post = member(
818            &seen_zip.lock().expect("zip lock"),
819            "com.inductiveautomation.webdev/resources/cli/scriptExec/doPost.py",
820        );
821        assert!(String::from_utf8_lossy(&do_post).contains(&secret));
822        let serialized = serde_json::to_string(&result).expect("result serializes");
823        assert!(!serialized.contains(&secret), "redaction: {serialized}");
824        let envelope_check: WebdevDeployResult = result;
825        let again = serde_json::to_string(&envelope_check).expect("serializes");
826        assert!(!again.contains(&secret));
827        let _ = dir;
828    }
829
830    /// --rotate-secret regenerates even when a secret exists; a plain
831    /// re-deploy reuses the stored one unchanged.
832    #[tokio::test]
833    async fn rotate_regenerates_and_plain_redeploy_reuses() {
834        let (dir, config) = temp_config();
835        // Seed a stored secret.
836        let mut seeded = crate::config::load(&config).expect("load");
837        seeded.profiles.get_mut("dev").unwrap().webdev_secret = Some("aa11".into());
838        crate::config::save(&config, &seeded).expect("seed save");
839
840        // Plain scriptExec redeploy: reuses `aa11`, rotated=false.
841        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
842        let recorder = std::sync::Arc::clone(&seen);
843        let rig = WebdevRig {
844            probe: |_| unreachable!(),
845            import: Box::new(move |zip, _| {
846                *recorder.lock().expect("lock") = zip;
847                Ok(ok_import())
848            }),
849        };
850        let result = webdev_deploy(&rig, "ign-cli", true, false, &config, "dev", false)
851            .await
852            .expect("reuse deploy");
853        assert!(!result.secret_rotated);
854        assert_eq!(stored_secret(&config).as_deref(), Some("aa11"));
855        let seen = seen.lock().expect("lock").clone();
856        let do_post = member(
857            &seen,
858            "com.inductiveautomation.webdev/resources/cli/scriptExec/doPost.py",
859        );
860        assert!(
861            String::from_utf8_lossy(&do_post).contains("aa11"),
862            "the STORED secret rode the zip's scriptExec member"
863        );
864
865        // --rotate-secret: a fresh 64-char hex replaces it.
866        let result = webdev_deploy(
867            &importing_rig(),
868            "ign-cli",
869            true,
870            true,
871            &config,
872            "dev",
873            false,
874        )
875        .await
876        .expect("rotate deploy");
877        assert!(result.secret_rotated);
878        let rotated = stored_secret(&config).expect("rotated secret stored");
879        assert_eq!(rotated.len(), 64);
880        assert_ne!(rotated, "aa11");
881        let _ = dir;
882    }
883
884    /// The status matrix: every probe state maps onto its row; ok is
885    /// gated ONLY by the always-on routes; scriptExec rides along
886    /// exactly when a secret is configured.
887    #[tokio::test]
888    async fn status_maps_the_full_probe_matrix() {
889        // All present + matching: ok=true.
890        let healthy = WebdevRig {
891            probe: |_| present(BUNDLE_VERSION),
892            import: Box::new(|_, _| unreachable!("status never imports")),
893        };
894        let result = webdev_status(&healthy, "ign-cli", None)
895            .await
896            .expect("status sweep");
897        assert_eq!(result.routes.len(), 4);
898        assert!(
899            result
900                .routes
901                .iter()
902                .all(|row| row.status == RouteStatus::Present)
903        );
904        assert!(result.ok);
905
906        // One route absent, one mismatched: ok=false, degradation is
907        // DATA (rows carry their own verdicts).
908        let degraded = WebdevRig {
909            probe: |route| {
910                Ok(match route {
911                    "tags" => RouteProbe::Absent,
912                    "tagConfig" => present("9.9.9").expect("fixture"),
913                    "alarms" => present(BUNDLE_VERSION).expect("fixture"),
914                    "tagHistory" => RouteProbe::Unlicensed,
915                    "scriptExec" => RouteProbe::Denied {
916                        code: "secret_mismatch".into(),
917                        message: "mismatch".into(),
918                        traceback: None,
919                    },
920                    _ => RouteProbe::AuthGated,
921                })
922            },
923            import: Box::new(|_, _| unreachable!()),
924        };
925        let result = webdev_status(&degraded, "ign-cli", Some("stored-secret"))
926            .await
927            .expect("degraded sweep still completes");
928        let by_route = |name: &str| {
929            result
930                .routes
931                .iter()
932                .find(|row| row.route == name)
933                .unwrap_or_else(|| panic!("{name} row"))
934        };
935        assert_eq!(by_route("tags").status, RouteStatus::Absent);
936        assert_eq!(by_route("tagConfig").status, RouteStatus::VersionMismatch);
937        assert_eq!(
938            by_route("tagConfig").deployed_version.as_deref(),
939            Some("9.9.9")
940        );
941        assert_eq!(by_route("tagHistory").status, RouteStatus::Unlicensed);
942        assert_eq!(by_route("scriptExec").status, RouteStatus::SecretMismatch);
943        assert!(!result.ok);
944        // scriptExec never gates ok: the healthy sweep with a secret
945        // whose probe denies stays ok=true.
946        let gated_exec = WebdevRig {
947            probe: |route| {
948                if route == "scriptExec" {
949                    Ok(RouteProbe::AuthGated)
950                } else {
951                    present(BUNDLE_VERSION)
952                }
953            },
954            import: Box::new(|_, _| unreachable!()),
955        };
956        let result = webdev_status(&gated_exec, "ign-cli", Some("s"))
957            .await
958            .expect("sweep");
959        assert!(result.ok, "scriptExec never gates ok");
960        assert_eq!(result.routes.len(), 5);
961    }
962
963    /// THE precondition refusal matrix (must-have truth #3): before
964    /// deploy → routes_not_deployed naming `ign webdev deploy`;
965    /// mismatch → route_version_mismatch with both versions named.
966    #[tokio::test]
967    async fn precondition_refuses_the_undeployed_and_mismatched() {
968        let undeployed = WebdevRig {
969            probe: |_| Ok(RouteProbe::Absent),
970            import: Box::new(|_, _| unreachable!()),
971        };
972        let err = webdev_precondition(&undeployed, "ign-cli")
973            .await
974            .expect_err("absent refuses");
975        assert_eq!(err.code(), "routes_not_deployed");
976        assert_eq!(err.exit_code(), 6);
977        assert!(
978            err.hint().unwrap().contains("ign webdev deploy"),
979            "hint names the fix"
980        );
981
982        let older = WebdevRig {
983            probe: |_| present("0.9.0"),
984            import: Box::new(|_, _| unreachable!()),
985        };
986        let err = webdev_precondition(&older, "ign-cli")
987            .await
988            .expect_err("older refuses");
989        assert_eq!(err.code(), "route_version_mismatch");
990        assert!(
991            err.to_string().contains("0.9.0") && err.to_string().contains(BUNDLE_VERSION),
992            "both versions named: {err}"
993        );
994
995        let matching = WebdevRig {
996            probe: |_| present(BUNDLE_VERSION),
997            import: Box::new(|_, _| unreachable!()),
998        };
999        webdev_precondition(&matching, "ign-cli")
1000            .await
1001            .expect("matching handshake passes");
1002    }
1003
1004    /// Generated secrets are hex and (statistically) unique across
1005    /// draws — the shape the route's fail-closed detector relies on.
1006    #[test]
1007    fn generated_secrets_are_hex_and_unique() {
1008        let a = generate_secret().expect("secret");
1009        let b = generate_secret().expect("secret");
1010        assert_eq!(a.len(), 64);
1011        assert_ne!(a, b);
1012    }
1013
1014    fn member_names(zip_bytes: &[u8]) -> Vec<String> {
1015        let mut archive =
1016            zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).expect("built zip is readable");
1017        (0..archive.len())
1018            .map(|index| archive.by_index(index).expect("member").name().to_string())
1019            .collect()
1020    }
1021
1022    fn member(zip_bytes: &[u8], name: &str) -> Vec<u8> {
1023        let mut archive =
1024            zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).expect("built zip is readable");
1025        let mut file = archive.by_name(name).expect("member present");
1026        let mut bytes = Vec::new();
1027        std::io::Read::read_to_end(&mut file, &mut bytes).expect("member reads");
1028        bytes
1029    }
1030}