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