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 bundle_generate(
374            &self,
375        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
376            unreachable!("not part of this action")
377        }
378        async fn bundle_status(
379            &self,
380        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
381            unreachable!("not part of this action")
382        }
383        async fn bundle_download(
384            &self,
385            _out: &std::path::Path,
386        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
387            unreachable!("not part of this action")
388        }
389        async fn tag_provider_list(
390            &self,
391            _query: &crate::client::query::ListQuery,
392        ) -> Result<
393            crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
394            CoreError,
395        > {
396            unreachable!("not part of this action")
397        }
398        async fn tag_provider_find(
399            &self,
400            _name: &str,
401        ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
402            unreachable!("not part of this action")
403        }
404        async fn tag_provider_create(
405            &self,
406            _body: &[crate::client::tags::TagProviderCreate],
407        ) -> Result<(), CoreError> {
408            unreachable!("not part of this action")
409        }
410        async fn tag_provider_delete(
411            &self,
412            _name: &str,
413            _signature: &str,
414        ) -> Result<(), CoreError> {
415            unreachable!("not part of this action")
416        }
417        async fn webdev_route_probe(
418            &self,
419            _project: &str,
420            route: &str,
421            _extra_headers: &[(&str, &str)],
422        ) -> Result<RouteProbe, CoreError> {
423            (self.probe)(route)
424        }
425        async fn project_import(
426            &self,
427            _name: &str,
428            zip: Vec<u8>,
429            overwrite: bool,
430        ) -> Result<ImportOutcome, CoreError> {
431            (self.import)(zip, overwrite)
432        }
433        async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
434            unreachable!("not part of this action")
435        }
436        async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
437            unreachable!("not part of this action")
438        }
439        async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
440            unreachable!("not part of this action")
441        }
442        async fn modules(
443            &self,
444            _quarantined: bool,
445            _query: &crate::client::query::ListQuery,
446        ) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
447        {
448            unreachable!("not part of this action")
449        }
450        async fn metrics_current(
451            &self,
452        ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
453            unreachable!("not part of this action")
454        }
455        async fn metrics_historic(
456            &self,
457        ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
458            unreachable!("not part of this action")
459        }
460        async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
461            unreachable!("not part of this action")
462        }
463        async fn designers(
464            &self,
465            _query: &crate::client::query::ListQuery,
466        ) -> Result<
467            crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
468            CoreError,
469        > {
470            unreachable!("not part of this action")
471        }
472        async fn perspective_sessions(
473            &self,
474            _query: &crate::client::query::ListQuery,
475        ) -> Result<
476            crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
477            CoreError,
478        > {
479            unreachable!("not part of this action")
480        }
481        async fn vision_clients(
482            &self,
483            _query: &crate::client::query::ListQuery,
484        ) -> Result<
485            crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
486            CoreError,
487        > {
488            unreachable!("not part of this action")
489        }
490        async fn terminate_perspective_session(
491            &self,
492            _id: &str,
493            _message: Option<&str>,
494        ) -> Result<(), CoreError> {
495            unreachable!("not part of this action")
496        }
497        async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
498            unreachable!("not part of this action")
499        }
500        async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
501            unreachable!("not part of this action")
502        }
503        async fn database_connections(
504            &self,
505        ) -> Result<
506            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
507            CoreError,
508        > {
509            unreachable!("not part of this action")
510        }
511        async fn opc_connections(
512            &self,
513        ) -> Result<
514            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
515            CoreError,
516        > {
517            unreachable!("not part of this action")
518        }
519        async fn logs(
520            &self,
521            _filter: &crate::client::logs::LogQuery,
522        ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
523        {
524            unreachable!("not part of this action")
525        }
526        async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
527            unreachable!("not part of this action")
528        }
529        async fn loggers(
530            &self,
531            _query: &crate::client::query::ListQuery,
532        ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
533        {
534            unreachable!("not part of this action")
535        }
536        async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
537            unreachable!("not part of this action")
538        }
539        async fn reset_logger_levels(&self) -> Result<(), CoreError> {
540            unreachable!("not part of this action")
541        }
542        async fn restart(&self) -> Result<(), CoreError> {
543            unreachable!("not part of this action")
544        }
545        async fn scan_projects(&self) -> Result<(), CoreError> {
546            unreachable!("not part of this action")
547        }
548        async fn security_properties(
549            &self,
550        ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
551            unreachable!("not part of this action")
552        }
553        async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
554            unreachable!("not part of this action")
555        }
556        async fn webdev_route_call(
557            &self,
558            _project: &str,
559            _route: &str,
560            _body: &serde_json::Value,
561            _extra_headers: &[(&str, &str)],
562        ) -> Result<serde_json::Value, CoreError> {
563            unreachable!("not part of this action")
564        }
565        async fn projects(
566            &self,
567            _query: &crate::client::query::ListQuery,
568        ) -> Result<
569            crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
570            CoreError,
571        > {
572            unreachable!("not part of this action")
573        }
574        async fn project_find(
575            &self,
576            _name: &str,
577        ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
578            unreachable!("not part of this action")
579        }
580        async fn project_create(
581            &self,
582            _body: &crate::client::projects::ProjectCreate,
583        ) -> Result<(), CoreError> {
584            unreachable!("not part of this action")
585        }
586        async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
587            unreachable!("not part of this action")
588        }
589        async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
590            unreachable!("not part of this action")
591        }
592        async fn project_modify(
593            &self,
594            _name: &str,
595            _body: &crate::client::projects::ProjectModify,
596        ) -> Result<(), CoreError> {
597            unreachable!("not part of this action")
598        }
599        async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
600            unreachable!("not part of this action")
601        }
602        async fn project_export_to_file(
603            &self,
604            _name: &str,
605            _out: &std::path::Path,
606        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
607            unreachable!("not part of this action")
608        }
609        async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
610            unreachable!("not part of this action")
611        }
612        async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
613            unreachable!("not part of this action")
614        }
615        async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
616            unreachable!("not part of this action")
617        }
618        async fn backup_download(
619            &self,
620            _out: &std::path::Path,
621            _backup_type: crate::client::backup::BackupType,
622        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
623            unreachable!("not part of this action")
624        }
625        async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
626            unreachable!("not part of this action")
627        }
628        async fn eam_task_history(
629            &self,
630            _limit: Option<u32>,
631            _search: Option<&str>,
632        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
633        {
634            unreachable!("not part of this action")
635        }
636        async fn eam_task_definitions(
637            &self,
638        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
639        {
640            unreachable!("not part of this action")
641        }
642        async fn eam_task_find(
643            &self,
644            _name: &str,
645        ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
646            unreachable!("not part of this action")
647        }
648        async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
649            unreachable!("not part of this action")
650        }
651        async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
652            unreachable!("not part of this action")
653        }
654        async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
655            unreachable!("not part of this action")
656        }
657        async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
658            unreachable!("not part of this action")
659        }
660        async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
661            unreachable!("not part of this action")
662        }
663        async fn eam_tasks_scheduled(
664            &self,
665            _running: bool,
666        ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
667            unreachable!("not part of this action")
668        }
669        async fn eam_task_modify(
670            &self,
671            _definition: &serde_json::Value,
672        ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
673            unreachable!("not part of this action")
674        }
675        async fn eam_task_delete(
676            &self,
677            _name: &str,
678            _signature: &str,
679            _confirm: bool,
680        ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
681            unreachable!("not part of this action")
682        }
683        async fn api_call(
684            &self,
685            _call: &crate::client::apicall::ApiCallRequest,
686        ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
687            unreachable!("not part of this action")
688        }
689        async fn license_status(
690            &self,
691        ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
692            unreachable!("not part of this action")
693        }
694        async fn redundancy_status(
695            &self,
696        ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
697            unreachable!("not part of this action")
698        }
699        async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
700            unreachable!("not part of this action")
701        }
702    }
703
704    /// Temp config with one `dev` profile (no auth — the action never
705    /// resolves credentials itself).
706    fn temp_config() -> (tempfile::TempDir, PathBuf) {
707        let dir = tempfile::tempdir().expect("tempdir");
708        let path = dir.path().join("config.toml");
709        std::fs::write(
710            &path,
711            "active = \"dev\"\n\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n",
712        )
713        .expect("write config");
714        (dir, path)
715    }
716
717    /// Read the stored secret back out of a config (test eyes only).
718    fn stored_secret(path: &std::path::Path) -> Option<String> {
719        crate::config::load(path)
720            .expect("config reloads")
721            .profiles
722            .get("dev")
723            .and_then(|profile| profile.webdev_secret.clone())
724    }
725
726    /// A rig whose import always succeeds and records nothing.
727    fn importing_rig() -> WebdevRig {
728        WebdevRig {
729            probe: |_| unreachable!("deploy never probes"),
730            import: Box::new(|_zip, _overwrite| Ok(ok_import())),
731        }
732    }
733
734    /// Deploy WITHOUT --with-script-exec never ships the route and
735    /// never stores a secret.
736    #[tokio::test]
737    async fn deploy_without_script_exec_ships_only_the_always_on_bundle() {
738        let (dir, config) = temp_config();
739        let seen_zip = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
740        let recorder = std::sync::Arc::clone(&seen_zip);
741        let rig = WebdevRig {
742            probe: |_| unreachable!("deploy never probes"),
743            import: Box::new(move |zip, overwrite| {
744                assert!(overwrite, "deploy ALWAYS overwrite-imports");
745                *recorder.lock().expect("zip lock") = zip;
746                Ok(ok_import())
747            }),
748        };
749        let result = webdev_deploy(&rig, "ign-cli", false, false, &config, "dev", false)
750            .await
751            .expect("plain deploy");
752        assert_eq!(
753            result.routes,
754            vec!["tags", "tagConfig", "alarms", "tagHistory"]
755        );
756        assert!(!result.script_exec);
757        assert!(!result.secret_rotated);
758        assert_eq!(result.import["success"], true);
759
760        // The uploaded zip carries NO scriptExec member and no secret
761        // landed in the config.
762        let names = member_names(&seen_zip.lock().expect("zip lock"));
763        assert!(names.iter().all(|name| !name.contains("scriptExec")));
764        assert_eq!(stored_secret(&config), None);
765        let _ = dir; // keeps the tempdir alive for the asserts above
766    }
767
768    /// Deploy --with-script-exec with NO stored secret: generates,
769    /// persists 0600, bakes into the zip — and the SERIALIZED result
770    /// never carries the value (the redaction pin).
771    #[tokio::test]
772    async fn deploy_with_script_exec_generates_and_redacts_the_secret() {
773        let (dir, config) = temp_config();
774        let seen_zip = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
775        let recorder = std::sync::Arc::clone(&seen_zip);
776        let rig = WebdevRig {
777            probe: |_| unreachable!("deploy never probes"),
778            import: Box::new(move |zip, _| {
779                *recorder.lock().expect("zip lock") = zip;
780                Ok(ok_import())
781            }),
782        };
783        let result = webdev_deploy(&rig, "ign-cli", true, false, &config, "dev", false)
784            .await
785            .expect("scriptExec deploy");
786        assert_eq!(
787            result.routes,
788            vec!["tags", "tagConfig", "alarms", "tagHistory", "scriptExec"]
789        );
790        assert!(result.script_exec && result.secret_rotated);
791
792        let secret = stored_secret(&config).expect("secret persisted");
793        assert_eq!(secret.len(), 64, "32 bytes hex-encoded");
794        assert!(
795            secret.chars().all(|c| c.is_ascii_hexdigit()),
796            "hex alphabet: {secret}"
797        );
798        #[cfg(unix)]
799        {
800            use std::os::unix::fs::PermissionsExt;
801            let mode = std::fs::metadata(&config)
802                .expect("config stat")
803                .permissions()
804                .mode();
805            assert_eq!(mode & 0o777, 0o600, "the save path re-asserts 0600");
806        }
807
808        // The baked route carries the secret; the serialized result
809        // does NOT (redaction — Phase 1's canary pattern).
810        let do_post = member(
811            &seen_zip.lock().expect("zip lock"),
812            "com.inductiveautomation.webdev/resources/cli/scriptExec/doPost.py",
813        );
814        assert!(String::from_utf8_lossy(&do_post).contains(&secret));
815        let serialized = serde_json::to_string(&result).expect("result serializes");
816        assert!(!serialized.contains(&secret), "redaction: {serialized}");
817        let envelope_check: WebdevDeployResult = result;
818        let again = serde_json::to_string(&envelope_check).expect("serializes");
819        assert!(!again.contains(&secret));
820        let _ = dir;
821    }
822
823    /// --rotate-secret regenerates even when a secret exists; a plain
824    /// re-deploy reuses the stored one unchanged.
825    #[tokio::test]
826    async fn rotate_regenerates_and_plain_redeploy_reuses() {
827        let (dir, config) = temp_config();
828        // Seed a stored secret.
829        let mut seeded = crate::config::load(&config).expect("load");
830        seeded.profiles.get_mut("dev").unwrap().webdev_secret = Some("aa11".into());
831        crate::config::save(&config, &seeded).expect("seed save");
832
833        // Plain scriptExec redeploy: reuses `aa11`, rotated=false.
834        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
835        let recorder = std::sync::Arc::clone(&seen);
836        let rig = WebdevRig {
837            probe: |_| unreachable!(),
838            import: Box::new(move |zip, _| {
839                *recorder.lock().expect("lock") = zip;
840                Ok(ok_import())
841            }),
842        };
843        let result = webdev_deploy(&rig, "ign-cli", true, false, &config, "dev", false)
844            .await
845            .expect("reuse deploy");
846        assert!(!result.secret_rotated);
847        assert_eq!(stored_secret(&config).as_deref(), Some("aa11"));
848        let seen = seen.lock().expect("lock").clone();
849        let do_post = member(
850            &seen,
851            "com.inductiveautomation.webdev/resources/cli/scriptExec/doPost.py",
852        );
853        assert!(
854            String::from_utf8_lossy(&do_post).contains("aa11"),
855            "the STORED secret rode the zip's scriptExec member"
856        );
857
858        // --rotate-secret: a fresh 64-char hex replaces it.
859        let result = webdev_deploy(
860            &importing_rig(),
861            "ign-cli",
862            true,
863            true,
864            &config,
865            "dev",
866            false,
867        )
868        .await
869        .expect("rotate deploy");
870        assert!(result.secret_rotated);
871        let rotated = stored_secret(&config).expect("rotated secret stored");
872        assert_eq!(rotated.len(), 64);
873        assert_ne!(rotated, "aa11");
874        let _ = dir;
875    }
876
877    /// The status matrix: every probe state maps onto its row; ok is
878    /// gated ONLY by the always-on routes; scriptExec rides along
879    /// exactly when a secret is configured.
880    #[tokio::test]
881    async fn status_maps_the_full_probe_matrix() {
882        // All present + matching: ok=true.
883        let healthy = WebdevRig {
884            probe: |_| present(BUNDLE_VERSION),
885            import: Box::new(|_, _| unreachable!("status never imports")),
886        };
887        let result = webdev_status(&healthy, "ign-cli", None)
888            .await
889            .expect("status sweep");
890        assert_eq!(result.routes.len(), 4);
891        assert!(
892            result
893                .routes
894                .iter()
895                .all(|row| row.status == RouteStatus::Present)
896        );
897        assert!(result.ok);
898
899        // One route absent, one mismatched: ok=false, degradation is
900        // DATA (rows carry their own verdicts).
901        let degraded = WebdevRig {
902            probe: |route| {
903                Ok(match route {
904                    "tags" => RouteProbe::Absent,
905                    "tagConfig" => present("9.9.9").expect("fixture"),
906                    "alarms" => present(BUNDLE_VERSION).expect("fixture"),
907                    "tagHistory" => RouteProbe::Unlicensed,
908                    "scriptExec" => RouteProbe::Denied {
909                        code: "secret_mismatch".into(),
910                        message: "mismatch".into(),
911                        traceback: None,
912                    },
913                    _ => RouteProbe::AuthGated,
914                })
915            },
916            import: Box::new(|_, _| unreachable!()),
917        };
918        let result = webdev_status(&degraded, "ign-cli", Some("stored-secret"))
919            .await
920            .expect("degraded sweep still completes");
921        let by_route = |name: &str| {
922            result
923                .routes
924                .iter()
925                .find(|row| row.route == name)
926                .unwrap_or_else(|| panic!("{name} row"))
927        };
928        assert_eq!(by_route("tags").status, RouteStatus::Absent);
929        assert_eq!(by_route("tagConfig").status, RouteStatus::VersionMismatch);
930        assert_eq!(
931            by_route("tagConfig").deployed_version.as_deref(),
932            Some("9.9.9")
933        );
934        assert_eq!(by_route("tagHistory").status, RouteStatus::Unlicensed);
935        assert_eq!(by_route("scriptExec").status, RouteStatus::SecretMismatch);
936        assert!(!result.ok);
937        // scriptExec never gates ok: the healthy sweep with a secret
938        // whose probe denies stays ok=true.
939        let gated_exec = WebdevRig {
940            probe: |route| {
941                if route == "scriptExec" {
942                    Ok(RouteProbe::AuthGated)
943                } else {
944                    present(BUNDLE_VERSION)
945                }
946            },
947            import: Box::new(|_, _| unreachable!()),
948        };
949        let result = webdev_status(&gated_exec, "ign-cli", Some("s"))
950            .await
951            .expect("sweep");
952        assert!(result.ok, "scriptExec never gates ok");
953        assert_eq!(result.routes.len(), 5);
954    }
955
956    /// THE precondition refusal matrix (must-have truth #3): before
957    /// deploy → routes_not_deployed naming `ign webdev deploy`;
958    /// mismatch → route_version_mismatch with both versions named.
959    #[tokio::test]
960    async fn precondition_refuses_the_undeployed_and_mismatched() {
961        let undeployed = WebdevRig {
962            probe: |_| Ok(RouteProbe::Absent),
963            import: Box::new(|_, _| unreachable!()),
964        };
965        let err = webdev_precondition(&undeployed, "ign-cli")
966            .await
967            .expect_err("absent refuses");
968        assert_eq!(err.code(), "routes_not_deployed");
969        assert_eq!(err.exit_code(), 6);
970        assert!(
971            err.hint().unwrap().contains("ign webdev deploy"),
972            "hint names the fix"
973        );
974
975        let older = WebdevRig {
976            probe: |_| present("0.9.0"),
977            import: Box::new(|_, _| unreachable!()),
978        };
979        let err = webdev_precondition(&older, "ign-cli")
980            .await
981            .expect_err("older refuses");
982        assert_eq!(err.code(), "route_version_mismatch");
983        assert!(
984            err.to_string().contains("0.9.0") && err.to_string().contains(BUNDLE_VERSION),
985            "both versions named: {err}"
986        );
987
988        let matching = WebdevRig {
989            probe: |_| present(BUNDLE_VERSION),
990            import: Box::new(|_, _| unreachable!()),
991        };
992        webdev_precondition(&matching, "ign-cli")
993            .await
994            .expect("matching handshake passes");
995    }
996
997    /// Generated secrets are hex and (statistically) unique across
998    /// draws — the shape the route's fail-closed detector relies on.
999    #[test]
1000    fn generated_secrets_are_hex_and_unique() {
1001        let a = generate_secret().expect("secret");
1002        let b = generate_secret().expect("secret");
1003        assert_eq!(a.len(), 64);
1004        assert_ne!(a, b);
1005    }
1006
1007    fn member_names(zip_bytes: &[u8]) -> Vec<String> {
1008        let mut archive =
1009            zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).expect("built zip is readable");
1010        (0..archive.len())
1011            .map(|index| archive.by_index(index).expect("member").name().to_string())
1012            .collect()
1013    }
1014
1015    fn member(zip_bytes: &[u8], name: &str) -> Vec<u8> {
1016        let mut archive =
1017            zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).expect("built zip is readable");
1018        let mut file = archive.by_name(name).expect("member present");
1019        let mut bytes = Vec::new();
1020        std::io::Read::read_to_end(&mut file, &mut bytes).expect("member reads");
1021        bytes
1022    }
1023}