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