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, PathBuf};
32
33use serde::Serialize;
34
35use crate::client::GatewayApi;
36use crate::client::webdev::{self as seam, RouteProbe};
37use crate::config;
38use crate::error::{CoreError, TAG_DELETE_DROP_REFUSAL_PREFIX};
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/// The tagDelete route name (26-17), appended to `routes` when it ships.
53pub(crate) const TAG_DELETE_ROUTE: &str = crate::error::TAG_DELETE_ROUTE_NAME;
54
55/// A request to install the tagDelete route (26-17, D-13) with the deploy.
56/// The provider allowlist is the union of the repos' `deploy/project.yaml`
57/// `tag_providers`, in repo order.
58#[derive(Debug, Clone)]
59pub struct TagDeleteRequest {
60    /// The project repos whose `deploy/project.yaml` name the owned
61    /// providers (at least one).
62    pub repos: Vec<PathBuf>,
63    /// The WebDev user source the route authenticates against.
64    pub user_source: String,
65    /// The role WebDev requires of the caller.
66    pub role: String,
67    /// Regenerate the route secret even when one is stored.
68    pub rotate_secret: bool,
69    /// The route caller's credential (user, password), the profile's env
70    /// pair, when set. It is used for one `version` handshake that reads the
71    /// live allowlist before the install replaces it (26-REVIEW-D WR-D3).
72    /// `None`: the live allowlist cannot be read, and the install warns.
73    pub caller: Option<(config::Secret, config::Secret)>,
74}
75
76/// `ign webdev deploy` result — ALL keys always (the agent shape);
77/// the import answer rides as the opaque-success object verbatim.
78/// The secret appears in NONE of them (redaction).
79#[derive(Debug, Serialize)]
80pub struct WebdevDeployResult {
81    /// The project the bundle deployed into.
82    pub project: String,
83    /// Route folder names deployed, manifest order (+ scriptExec when
84    /// it shipped).
85    pub routes: Vec<String>,
86    /// Whether scriptExec rode the deploy.
87    pub script_exec: bool,
88    /// Whether a NEW secret was generated and persisted (first
89    /// scriptExec deploy or `--rotate-secret`).
90    pub secret_rotated: bool,
91    /// The import endpoint's opaque answer (the 03-02
92    /// `{"status":"success"}`-normalized object).
93    pub import: serde_json::Value,
94    /// Whether the tagDelete route rode the deploy (26-17).
95    pub tag_delete: bool,
96    /// Whether a NEW tagDelete secret was generated and persisted (first
97    /// tag-delete deploy or its rotate).
98    pub tag_delete_secret_rotated: bool,
99    /// The tagDelete route's provider allowlist (empty when it did not
100    /// ship).
101    pub tag_delete_providers: Vec<String>,
102    /// Warnings for the operator (26-REVIEW-D WR-D4: the tagDelete route
103    /// and its stored secret were removed). Empty when there are none.
104    pub warnings: Vec<String>,
105}
106
107/// One route's status-sweep row — ALL keys always.
108#[derive(Debug, Clone, PartialEq, Serialize)]
109pub struct RouteStatusRow {
110    /// Route folder name.
111    pub route: String,
112    /// The per-route matrix verdict.
113    pub status: RouteStatus,
114    /// The route's answered `routeVersion` (absent states → null).
115    pub deployed_version: Option<String>,
116    /// The embedded bundle version this CLI expects (always known).
117    pub expected_version: Option<String>,
118}
119
120/// The per-route status matrix (WebDev-dependent commands refuse on
121/// the same discrimination; status reports it as data).
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
123#[serde(rename_all = "snake_case")]
124pub enum RouteStatus {
125    /// Deployed, answering, version matches the embedded bundle.
126    Present,
127    /// 405 — not deployed (the 8.3 absent marker).
128    Absent,
129    /// 402 — the WebDev module is unlicensed (trial-expired gateway).
130    Unlicensed,
131    /// 401/403 — present but rejecting the credential.
132    AuthGated,
133    /// The scriptExec gate refused the configured secret (deployed
134    /// elsewhere / stale) — redeploy or `--rotate-secret`.
135    SecretMismatch,
136    /// Deployed but the handshake version differs from the bundle's.
137    VersionMismatch,
138}
139
140/// `ign webdev status` result — ALL keys always.
141#[derive(Debug, Serialize)]
142pub struct WebdevStatusResult {
143    /// The probed project.
144    pub project: String,
145    /// One row per always-on route (+ scriptExec when a secret is
146    /// configured — unprobed otherwise, per the plan's conditional).
147    pub routes: Vec<RouteStatusRow>,
148    /// True only when every ALWAYS-ON route is present with matching
149    /// versions (scriptExec never gates `ok` — it deploys on explicit
150    /// request only).
151    pub ok: bool,
152}
153
154/// `ign webdev deploy` — pack the embedded bundle, import it
155/// overwrite-style, own the secret lifecycle.
156///
157/// Secret rules (the plan's LOCKED posture): a fresh secret is
158/// generated + persisted 0600 when (a) `--with-script-exec` finds no
159/// stored secret, or (b) `--rotate-secret` asks regardless; a plain
160/// scriptExec deploy reuses the stored secret unchanged. Persisting
161/// happens BEFORE the upload: a failed import then leaves a stored
162/// secret the NEXT deploy packs as-is (self-healing), and a broken
163/// config store refuses before any gateway I/O.
164pub async fn webdev_deploy(
165    api: &dyn GatewayApi,
166    project: &str,
167    with_script_exec: bool,
168    rotate_secret: bool,
169    config_path: &Path,
170    profile_name: &str,
171    with_testing: bool,
172) -> Result<WebdevDeployResult, CoreError> {
173    webdev_deploy_with(
174        api,
175        project,
176        with_script_exec,
177        rotate_secret,
178        config_path,
179        profile_name,
180        with_testing,
181        None,
182    )
183    .await
184}
185
186/// [`webdev_deploy`] plus, with `tag_delete`, the tagDelete route (26-17,
187/// D-13) in the same import. The allowlist is the repo's
188/// `deploy/project.yaml` `tag_providers`; the route secret follows the
189/// scriptExec rules independently (generated when none is stored or on its
190/// rotate, persisted 0600 as `webdev_tag_delete_secret` before the upload,
191/// never output). Every install value is checked before any secret is
192/// stored and before any gateway request.
193#[allow(clippy::too_many_arguments)]
194pub async fn webdev_deploy_with(
195    api: &dyn GatewayApi,
196    project: &str,
197    with_script_exec: bool,
198    rotate_secret: bool,
199    config_path: &Path,
200    profile_name: &str,
201    with_testing: bool,
202    tag_delete: Option<TagDeleteRequest>,
203) -> Result<WebdevDeployResult, CoreError> {
204    // Secret lifecycle first — refuse on an unwritable config store
205    // BEFORE touching the gateway.
206    let mut config = config::load(config_path)?;
207    let profile = config.profiles.get(profile_name);
208    let existing = profile.and_then(|profile| profile.webdev_secret.clone());
209    let existing_tag_delete = profile.and_then(|profile| profile.webdev_tag_delete_secret.clone());
210
211    let (pack_secret, secret_rotated) = if rotate_secret || (with_script_exec && existing.is_none())
212    {
213        (Some(generate_secret()?), true)
214    } else if with_script_exec {
215        (existing, false)
216    } else {
217        (None, false)
218    };
219
220    let mut warnings = Vec::new();
221
222    // The tagDelete install: allowlist from the repos, secret stored or
223    // fresh, every value validated before anything is persisted.
224    let (install, tag_delete_secret_rotated) = match &tag_delete {
225        None => (None, false),
226        Some(request) => {
227            // The route secret travels inside the import: only over https
228            // or to a loopback host, with `ssl_verify` on for a remote one,
229            // the route client's own rule (26-REVIEW WR-07). The URL is the
230            // one the import's client sends to (26-REVIEW-D WR-D2), not a
231            // fresh read of `IGNITION_URL`; a double without one falls back
232            // to the stored profile.
233            let transport = api.gateway_transport().or_else(|| {
234                config
235                    .profiles
236                    .get(profile_name)
237                    .map(|profile| (profile.url.clone(), profile.ssl_verify))
238            });
239            if let Some((url, ssl_verify)) = &transport
240                && let Some(refusal) =
241                    crate::deploy::tag_delete::transport_refusal(url, *ssl_verify)
242            {
243                return Err(CoreError::TagDeleteNotConfigured {
244                    profile: profile_name.to_string(),
245                    missing: vec![refusal],
246                });
247            }
248            let mut providers: Vec<String> = Vec::new();
249            for repo in &request.repos {
250                for provider in crate::deploy::projects::load_project_file(repo)?.tag_providers {
251                    if !providers.contains(&provider) {
252                        providers.push(provider);
253                    }
254                }
255            }
256            let (secret, rotated) = match existing_tag_delete.clone() {
257                Some(secret) if !request.rotate_secret => (secret, false),
258                _ => (generate_secret()?, true),
259            };
260            let install = seam::TagDeleteInstall {
261                secret,
262                user_source: request.user_source.clone(),
263                role: request.role.clone(),
264                providers,
265            };
266            seam::validate_tag_delete_install(&install)?;
267            // 26-REVIEW-D WR-D3: the overwrite replaces the live allowlist.
268            // With a stored secret a route may be installed: read its
269            // allowlist once and refuse an install that would drop a
270            // provider allowed now. Unreadable: install and warn.
271            if let Some(stored) = &existing_tag_delete {
272                match read_live_allowlist(
273                    profile_name,
274                    transport.as_ref(),
275                    project,
276                    request.caller.clone(),
277                    stored,
278                )
279                .await
280                {
281                    LiveAllowlist::Read(live) => {
282                        refuse_dropped_providers(project, &live, &install.providers)?;
283                    }
284                    LiveAllowlist::Absent => {}
285                    LiveAllowlist::Unreadable(reason) => warnings.push(format!(
286                        "could not read the live tagDelete allowlist on project {project} \
287                         ({reason}); the install replaced it with [{}], so check that no \
288                         other repo's providers were dropped",
289                        install.providers.join(", ")
290                    )),
291                }
292            }
293            (Some(install), rotated)
294        }
295    };
296
297    // 26-REVIEW-D WR-D4: the overwrite import replaces the project, so a
298    // deploy into the route's project without the tag-delete request drops
299    // the tagDelete route. The stored secret goes with it, before the
300    // upload, so the profile reads not configured (a run with removals then
301    // refuses `tag_delete_not_configured`, a rollback reports no route)
302    // instead of waiting on a route that is gone. The deploy is not refused:
303    // adopt and the TUI deploy this way.
304    let tag_delete_removed =
305        tag_delete.is_none() && project == seam::DEFAULT_PROJECT && existing_tag_delete.is_some();
306    if tag_delete_removed {
307        warnings.push(format!(
308            "tagDelete route removed from project {project}: this deploy did not include \
309             --with-tag-delete, so the overwrite import dropped it; the stored tagDelete \
310             secret was removed from profile {profile_name:?}, and tag-owning runs refuse \
311             tag_delete_not_configured until `ign webdev deploy --with-tag-delete` reinstalls it"
312        ));
313    }
314
315    if secret_rotated || tag_delete_secret_rotated || tag_delete_removed {
316        let entry = config.profiles.get_mut(profile_name).ok_or_else(|| {
317            CoreError::Internal(format!(
318                "profile {profile_name:?} vanished from the config between dispatch and deploy"
319            ))
320        })?;
321        if secret_rotated {
322            entry.webdev_secret = pack_secret.clone();
323        }
324        if tag_delete_secret_rotated {
325            entry.webdev_tag_delete_secret = install.as_ref().map(|install| install.secret.clone());
326        }
327        if tag_delete_removed {
328            entry.webdev_tag_delete_secret = None;
329        }
330        config::save(config_path, &config)?; // re-asserts 0600
331    }
332
333    // Pack (scriptExec only when flagged — build_deploy_zip's
334    // fail-closed guard covers the (true, None) bug case) and import
335    // overwrite=true through the 03-02 machinery. NO pre-flight
336    // project create (Pitfall 10).
337    let zip = seam::build_deploy_zip_with(
338        project,
339        with_script_exec,
340        pack_secret.as_deref(),
341        with_testing,
342        install.as_ref(),
343    )?;
344    let mut routes = seam::always_on_routes();
345    if with_script_exec {
346        routes.push(SCRIPT_EXEC_ROUTE.to_string());
347    }
348    if install.is_some() {
349        routes.push(TAG_DELETE_ROUTE.to_string());
350    }
351    if with_testing {
352        routes.extend(
353            crate::webdev::testing::TESTING_ROUTES
354                .iter()
355                .map(|route| format!("testing/{route}")),
356        );
357    }
358    let import = api.project_import(project, zip, true).await?;
359
360    Ok(WebdevDeployResult {
361        project: project.to_string(),
362        routes,
363        script_exec: with_script_exec,
364        secret_rotated,
365        import: import.response,
366        tag_delete: install.is_some(),
367        tag_delete_secret_rotated,
368        tag_delete_providers: install.map(|install| install.providers).unwrap_or_default(),
369        warnings,
370    })
371}
372
373/// The live tagDelete allowlist as read before an install (26-REVIEW-D
374/// WR-D3).
375enum LiveAllowlist {
376    /// The route answered its handshake with this allowlist.
377    Read(Vec<String>),
378    /// The route is not deployed (405): nothing to keep.
379    Absent,
380    /// The allowlist could not be read; the reason names no secret.
381    Unreadable(String),
382}
383
384/// Read the live route's allowlist through one `version` handshake with the
385/// stored route secret and the caller's credential. Never waits or retries.
386async fn read_live_allowlist(
387    profile_name: &str,
388    transport: Option<&(url::Url, bool)>,
389    project: &str,
390    caller: Option<(config::Secret, config::Secret)>,
391    stored_secret: &str,
392) -> LiveAllowlist {
393    use crate::deploy::tag_delete::{TagDeleteClient, TagDeleteRoute};
394    let Some((url, ssl_verify)) = transport else {
395        return LiveAllowlist::Unreadable("no gateway URL is known for the profile".to_string());
396    };
397    let Some((user, password)) = caller else {
398        let (user, password) = config::secret::tag_delete_env_names(profile_name);
399        return LiveAllowlist::Unreadable(format!("{user} and {password} are not both set"));
400    };
401    let client = match TagDeleteClient::new(
402        profile_name,
403        url,
404        *ssl_verify,
405        project,
406        user,
407        password,
408        config::Secret::new(stored_secret),
409    ) {
410        Ok(client) => client,
411        Err(err) => return LiveAllowlist::Unreadable(err.code().to_string()),
412    };
413    match client.handshake().await {
414        Ok(handshake) => LiveAllowlist::Read(handshake.providers),
415        Err(CoreError::RoutesNotDeployed { .. }) => LiveAllowlist::Absent,
416        Err(err) => LiveAllowlist::Unreadable(format!("the handshake answered {}", err.code())),
417    }
418}
419
420/// Refuse an install whose allowlist `new` lacks a provider of `live`.
421fn refuse_dropped_providers(
422    project: &str,
423    live: &[String],
424    new: &[String],
425) -> Result<(), CoreError> {
426    let dropped: Vec<&str> = live
427        .iter()
428        .filter(|provider| !new.contains(provider))
429        .map(String::as_str)
430        .collect();
431    if dropped.is_empty() {
432        return Ok(());
433    }
434    Err(CoreError::InvalidInput {
435        reason: format!(
436            "{TAG_DELETE_DROP_REFUSAL_PREFIX} {} from the live tagDelete allowlist on project \
437             {project} (allowed now: {}; this install: {}); nothing was stored or imported. \
438             Re-run naming every repo whose providers the route must keep (repeat \
439             --tag-delete-repo), or replace the allowlist deliberately: run `ign webdev \
440             deploy` without --with-tag-delete first (it removes the route and its stored \
441             secret), then install",
442            dropped.join(", "),
443            live.join(", "),
444            new.join(", ")
445        ),
446    })
447}
448
449/// `ign webdev status` — the version-handshake sweep. The 4 always-on
450/// routes always ride; scriptExec's version action is probed ONLY
451/// when a secret is configured (its header rides along — research OQ4:
452/// AuthGated → auth_gated, secret denials → secret_mismatch).
453pub async fn webdev_status(
454    api: &dyn GatewayApi,
455    project: &str,
456    secret: Option<&str>,
457) -> Result<WebdevStatusResult, CoreError> {
458    let mut routes = Vec::new();
459    let mut ok = true;
460    for route in seam::always_on_routes() {
461        let probe = api.webdev_route_probe(project, &route, &[]).await?;
462        let row = classify_probe(&route, probe);
463        ok &= row.status == RouteStatus::Present;
464        routes.push(row);
465    }
466    if let Some(secret) = secret {
467        let probe = api
468            .webdev_route_probe(project, SCRIPT_EXEC_ROUTE, &[(SECRET_HEADER, secret)])
469            .await?;
470        // scriptExec never gates `ok` — it ships on explicit request.
471        routes.push(classify_probe(SCRIPT_EXEC_ROUTE, probe));
472    }
473    Ok(WebdevStatusResult {
474        project: project.to_string(),
475        routes,
476        ok,
477    })
478}
479
480/// The cheap precondition every WebDev-DEPENDENT command runs first
481/// (05-04's tags family onward): probe the canonical `tags` route and
482/// refuse with the actionable matrix — absent → `routes_not_deployed`
483/// (exit 6, hint names `ign webdev deploy`), version mismatch →
484/// `route_version_mismatch` (hint direction-aware: redeploy or update
485/// ign), unlicensed → `webdev_unlicensed`. No auto-upgrade magic.
486pub async fn webdev_precondition(api: &dyn GatewayApi, project: &str) -> Result<(), CoreError> {
487    const ROUTE: &str = "tags";
488    let endpoint = seam::route_url(project, ROUTE);
489    match api.webdev_route_probe(project, ROUTE, &[]).await? {
490        RouteProbe::Present { route_version } if route_version == bundle::ROUTE_BUNDLE_VERSION => {
491            Ok(())
492        }
493        RouteProbe::Present { route_version } => Err(CoreError::RouteVersionMismatch {
494            route: ROUTE.to_string(),
495            deployed: route_version,
496            expected: bundle::ROUTE_BUNDLE_VERSION.to_string(),
497            endpoint: Some(endpoint),
498        }),
499        RouteProbe::Absent => Err(CoreError::RoutesNotDeployed {
500            project: project.to_string(),
501            route: ROUTE.to_string(),
502            endpoint: Some(endpoint),
503        }),
504        RouteProbe::Unlicensed => Err(CoreError::WebdevUnlicensed {
505            endpoint: Some(endpoint),
506        }),
507        RouteProbe::AuthGated => Err(CoreError::Auth {
508            status: 401,
509            endpoint: Some(endpoint),
510        }),
511        RouteProbe::Denied {
512            code,
513            message,
514            traceback,
515        } => {
516            let mut full = message;
517            if let Some(traceback) = traceback {
518                full.push_str("\nroute traceback: ");
519                full.push_str(&traceback);
520            }
521            Err(CoreError::WebdevRouteError {
522                code,
523                message: full,
524                endpoint: Some(endpoint),
525            })
526        }
527    }
528}
529
530/// Map one probe onto a status row (the matrix the sweep reports and
531/// the precondition refuses on).
532fn classify_probe(route: &str, probe: RouteProbe) -> RouteStatusRow {
533    let expected = bundle::ROUTE_BUNDLE_VERSION;
534    match probe {
535        RouteProbe::Present { route_version } => {
536            let status = if route_version == expected {
537                RouteStatus::Present
538            } else {
539                RouteStatus::VersionMismatch
540            };
541            RouteStatusRow {
542                route: route.to_string(),
543                status,
544                deployed_version: Some(route_version),
545                expected_version: Some(expected.to_string()),
546            }
547        }
548        RouteProbe::Denied { code, .. } => {
549            // With the shipped routes the version action's only
550            // denials are the secret gate's (the gate runs before
551            // dispatch); any OTHER code means the path holds a route
552            // this CLI does not recognize — the redeploy advice that
553            // version_mismatch carries is the honest fix either way.
554            let status = if code == "secret_required" || code == "secret_mismatch" {
555                RouteStatus::SecretMismatch
556            } else {
557                RouteStatus::VersionMismatch
558            };
559            absent_row(route, status)
560        }
561        RouteProbe::Absent => absent_row(route, RouteStatus::Absent),
562        RouteProbe::Unlicensed => absent_row(route, RouteStatus::Unlicensed),
563        RouteProbe::AuthGated => absent_row(route, RouteStatus::AuthGated),
564    }
565}
566
567/// A row for every probe state that answered no version.
568fn absent_row(route: &str, status: RouteStatus) -> RouteStatusRow {
569    RouteStatusRow {
570        route: route.to_string(),
571        status,
572        deployed_version: None,
573        expected_version: Some(bundle::ROUTE_BUNDLE_VERSION.to_string()),
574    }
575}
576
577/// 32 random bytes, hex-encoded (64 chars) — via the `getrandom`
578/// crate (already in the tree via ring; 0.2 pinned to dedupe).
579/// The pre-Windows `/dev/urandom` read is gone with the reversal of
580/// the no-Windows decision: getrandom rides the OS CSPRNG everywhere
581/// (BCryptGenRandom on Windows, getrandom(2)/urandom on unix).
582fn generate_secret() -> Result<String, CoreError> {
583    let mut bytes = [0u8; 32];
584    getrandom::getrandom(&mut bytes)
585        .map_err(|err| CoreError::Internal(format!("cannot generate the deploy secret: {err}")))?;
586    Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
587}
588
589#[cfg(test)]
590mod tests {
591    use super::{
592        RouteProbe, RouteStatus, TagDeleteRequest, WebdevDeployResult, generate_secret,
593        webdev_deploy, webdev_deploy_with, webdev_precondition, webdev_status,
594    };
595    use crate::client::GatewayApi;
596    use crate::client::projects::ImportOutcome;
597    use crate::error::CoreError;
598    use crate::webdev::ROUTE_BUNDLE_VERSION as BUNDLE_VERSION;
599    use std::path::PathBuf;
600
601    /// A scripted double: probes answer from a lookup, the import
602    /// callback packs the zip for inspection (recorded through a
603    /// Mutex so the closure stays `Fn` — `&self` callable — and the
604    /// rig `Sync`, both required by `&dyn GatewayApi`). Everything
605    /// else is unreachable (the established action-double shape).
606    struct WebdevRig {
607        probe: fn(&str) -> Result<RouteProbe, CoreError>,
608        /// What [`GatewayApi::gateway_transport`] answers (`None`: a double
609        /// with no URL of its own).
610        transport: Option<(url::Url, bool)>,
611        import: Box<dyn Fn(Vec<u8>, bool) -> Result<ImportOutcome, CoreError> + Send + Sync>,
612    }
613
614    fn present(version: &str) -> Result<RouteProbe, CoreError> {
615        Ok(RouteProbe::Present {
616            route_version: version.to_string(),
617        })
618    }
619
620    fn ok_import() -> ImportOutcome {
621        ImportOutcome {
622            response: serde_json::json!({"success": true}),
623        }
624    }
625
626    #[async_trait::async_trait]
627    impl GatewayApi for WebdevRig {
628        fn gateway_transport(&self) -> Option<(url::Url, bool)> {
629            self.transport.clone()
630        }
631        async fn thread_dump(
632            &self,
633        ) -> Result<crate::client::threads::FormattedThreadDump, CoreError> {
634            unreachable!("not part of this action")
635        }
636        async fn thread_deadlocks(
637            &self,
638        ) -> Result<crate::client::threads::DeadlocksWire, CoreError> {
639            unreachable!("not part of this action")
640        }
641        async fn uninstall_module(
642            &self,
643            _gateway_module_id: &str,
644            _module_id: &str,
645        ) -> Result<(), CoreError> {
646            unreachable!("not part of this action")
647        }
648        async fn bundle_generate(
649            &self,
650        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
651            unreachable!("not part of this action")
652        }
653        async fn bundle_status(
654            &self,
655        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
656            unreachable!("not part of this action")
657        }
658        async fn bundle_download(
659            &self,
660            _out: &std::path::Path,
661        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
662            unreachable!("not part of this action")
663        }
664        async fn tag_provider_list(
665            &self,
666            _query: &crate::client::query::ListQuery,
667        ) -> Result<
668            crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
669            CoreError,
670        > {
671            unreachable!("not part of this action")
672        }
673        async fn tag_provider_find(
674            &self,
675            _name: &str,
676        ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
677            unreachable!("not part of this action")
678        }
679        async fn tag_provider_create(
680            &self,
681            _body: &[crate::client::tags::TagProviderCreate],
682        ) -> Result<(), CoreError> {
683            unreachable!("not part of this action")
684        }
685        async fn tag_provider_delete(
686            &self,
687            _name: &str,
688            _signature: &str,
689        ) -> Result<(), CoreError> {
690            unreachable!("not part of this action")
691        }
692        async fn webdev_route_probe(
693            &self,
694            _project: &str,
695            route: &str,
696            _extra_headers: &[(&str, &str)],
697        ) -> Result<RouteProbe, CoreError> {
698            (self.probe)(route)
699        }
700        async fn project_import(
701            &self,
702            _name: &str,
703            zip: Vec<u8>,
704            overwrite: bool,
705        ) -> Result<ImportOutcome, CoreError> {
706            (self.import)(zip, overwrite)
707        }
708        async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
709            unreachable!("not part of this action")
710        }
711        async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
712            unreachable!("not part of this action")
713        }
714        async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
715            unreachable!("not part of this action")
716        }
717        async fn modules(
718            &self,
719            _quarantined: bool,
720            _query: &crate::client::query::ListQuery,
721        ) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
722        {
723            unreachable!("not part of this action")
724        }
725        async fn metrics_current(
726            &self,
727        ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
728            unreachable!("not part of this action")
729        }
730        async fn metrics_historic(
731            &self,
732        ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
733            unreachable!("not part of this action")
734        }
735        async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
736            unreachable!("not part of this action")
737        }
738        async fn designers(
739            &self,
740            _query: &crate::client::query::ListQuery,
741        ) -> Result<
742            crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
743            CoreError,
744        > {
745            unreachable!("not part of this action")
746        }
747        async fn perspective_sessions(
748            &self,
749            _query: &crate::client::query::ListQuery,
750        ) -> Result<
751            crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
752            CoreError,
753        > {
754            unreachable!("not part of this action")
755        }
756        async fn vision_clients(
757            &self,
758            _query: &crate::client::query::ListQuery,
759        ) -> Result<
760            crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
761            CoreError,
762        > {
763            unreachable!("not part of this action")
764        }
765        async fn terminate_perspective_session(
766            &self,
767            _id: &str,
768            _message: Option<&str>,
769        ) -> Result<(), CoreError> {
770            unreachable!("not part of this action")
771        }
772        async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
773            unreachable!("not part of this action")
774        }
775        async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
776            unreachable!("not part of this action")
777        }
778        async fn database_connections(
779            &self,
780        ) -> Result<
781            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
782            CoreError,
783        > {
784            unreachable!("not part of this action")
785        }
786        async fn opc_connections(
787            &self,
788        ) -> Result<
789            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
790            CoreError,
791        > {
792            unreachable!("not part of this action")
793        }
794        async fn logs(
795            &self,
796            _filter: &crate::client::logs::LogQuery,
797        ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
798        {
799            unreachable!("not part of this action")
800        }
801        async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
802            unreachable!("not part of this action")
803        }
804        async fn loggers(
805            &self,
806            _query: &crate::client::query::ListQuery,
807        ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
808        {
809            unreachable!("not part of this action")
810        }
811        async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
812            unreachable!("not part of this action")
813        }
814        async fn reset_logger_levels(&self) -> Result<(), CoreError> {
815            unreachable!("not part of this action")
816        }
817        async fn restart(&self) -> Result<(), CoreError> {
818            unreachable!("not part of this action")
819        }
820        async fn scan_projects(&self) -> Result<(), CoreError> {
821            unreachable!("not part of this action")
822        }
823        async fn security_properties(
824            &self,
825        ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
826            unreachable!("not part of this action")
827        }
828        async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
829            unreachable!("not part of this action")
830        }
831        async fn webdev_route_call(
832            &self,
833            _project: &str,
834            _route: &str,
835            _body: &serde_json::Value,
836            _extra_headers: &[(&str, &str)],
837        ) -> Result<serde_json::Value, CoreError> {
838            unreachable!("not part of this action")
839        }
840        async fn projects(
841            &self,
842            _query: &crate::client::query::ListQuery,
843        ) -> Result<
844            crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
845            CoreError,
846        > {
847            unreachable!("not part of this action")
848        }
849        async fn project_find(
850            &self,
851            _name: &str,
852        ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
853            unreachable!("not part of this action")
854        }
855        async fn project_create(
856            &self,
857            _body: &crate::client::projects::ProjectCreate,
858        ) -> Result<(), CoreError> {
859            unreachable!("not part of this action")
860        }
861        async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
862            unreachable!("not part of this action")
863        }
864        async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
865            unreachable!("not part of this action")
866        }
867        async fn project_modify(
868            &self,
869            _name: &str,
870            _body: &crate::client::projects::ProjectModify,
871        ) -> Result<(), CoreError> {
872            unreachable!("not part of this action")
873        }
874        async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
875            unreachable!("not part of this action")
876        }
877        async fn project_export_to_file(
878            &self,
879            _name: &str,
880            _out: &std::path::Path,
881        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
882            unreachable!("not part of this action")
883        }
884        async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
885            unreachable!("not part of this action")
886        }
887        async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
888            unreachable!("not part of this action")
889        }
890        async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
891            unreachable!("not part of this action")
892        }
893        async fn backup_download(
894            &self,
895            _out: &std::path::Path,
896            _backup_type: crate::client::backup::BackupType,
897        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
898            unreachable!("not part of this action")
899        }
900        async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
901            unreachable!("not part of this action")
902        }
903        async fn eam_task_history(
904            &self,
905            _limit: Option<u32>,
906            _search: Option<&str>,
907        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
908        {
909            unreachable!("not part of this action")
910        }
911        async fn eam_task_definitions(
912            &self,
913        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
914        {
915            unreachable!("not part of this action")
916        }
917        async fn eam_task_find(
918            &self,
919            _name: &str,
920        ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
921            unreachable!("not part of this action")
922        }
923        async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
924            unreachable!("not part of this action")
925        }
926        async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
927            unreachable!("not part of this action")
928        }
929        async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
930            unreachable!("not part of this action")
931        }
932        async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
933            unreachable!("not part of this action")
934        }
935        async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
936            unreachable!("not part of this action")
937        }
938        async fn eam_tasks_scheduled(
939            &self,
940            _running: bool,
941        ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
942            unreachable!("not part of this action")
943        }
944        async fn eam_task_modify(
945            &self,
946            _definition: &serde_json::Value,
947        ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
948            unreachable!("not part of this action")
949        }
950        async fn eam_task_delete(
951            &self,
952            _name: &str,
953            _signature: &str,
954            _confirm: bool,
955        ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
956            unreachable!("not part of this action")
957        }
958        async fn api_call(
959            &self,
960            _call: &crate::client::apicall::ApiCallRequest,
961        ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
962            unreachable!("not part of this action")
963        }
964        async fn license_status(
965            &self,
966        ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
967            unreachable!("not part of this action")
968        }
969        async fn redundancy_status(
970            &self,
971        ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
972            unreachable!("not part of this action")
973        }
974        async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
975            unreachable!("not part of this action")
976        }
977    }
978
979    /// Temp config with one `dev` profile (no auth — the action never
980    /// resolves credentials itself).
981    fn temp_config() -> (tempfile::TempDir, PathBuf) {
982        let dir = tempfile::tempdir().expect("tempdir");
983        let path = dir.path().join("config.toml");
984        std::fs::write(
985            &path,
986            "active = \"dev\"\n\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n",
987        )
988        .expect("write config");
989        (dir, path)
990    }
991
992    /// Read the stored secret back out of a config (test eyes only).
993    fn stored_secret(path: &std::path::Path) -> Option<String> {
994        crate::config::load(path)
995            .expect("config reloads")
996            .profiles
997            .get("dev")
998            .and_then(|profile| profile.webdev_secret.clone())
999    }
1000
1001    /// A rig whose import always succeeds and records nothing.
1002    fn importing_rig() -> WebdevRig {
1003        WebdevRig {
1004            probe: |_| unreachable!("deploy never probes"),
1005            transport: None,
1006            import: Box::new(|_zip, _overwrite| Ok(ok_import())),
1007        }
1008    }
1009
1010    /// Deploy WITHOUT --with-script-exec never ships the route and
1011    /// never stores a secret.
1012    #[tokio::test]
1013    async fn deploy_without_script_exec_ships_only_the_always_on_bundle() {
1014        let (dir, config) = temp_config();
1015        let seen_zip = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1016        let recorder = std::sync::Arc::clone(&seen_zip);
1017        let rig = WebdevRig {
1018            probe: |_| unreachable!("deploy never probes"),
1019            transport: None,
1020            import: Box::new(move |zip, overwrite| {
1021                assert!(overwrite, "deploy ALWAYS overwrite-imports");
1022                *recorder.lock().expect("zip lock") = zip;
1023                Ok(ok_import())
1024            }),
1025        };
1026        let result = webdev_deploy(&rig, "ign-cli", false, false, &config, "dev", false)
1027            .await
1028            .expect("plain deploy");
1029        assert_eq!(
1030            result.routes,
1031            vec!["tags", "tagConfig", "alarms", "tagHistory"]
1032        );
1033        assert!(!result.script_exec);
1034        assert!(!result.secret_rotated);
1035        assert_eq!(result.import["success"], true);
1036
1037        // The uploaded zip carries NO scriptExec member and no secret
1038        // landed in the config.
1039        let names = member_names(&seen_zip.lock().expect("zip lock"));
1040        assert!(names.iter().all(|name| !name.contains("scriptExec")));
1041        assert_eq!(stored_secret(&config), None);
1042        let _ = dir; // keeps the tempdir alive for the asserts above
1043    }
1044
1045    /// Deploy --with-script-exec with NO stored secret: generates,
1046    /// persists 0600, bakes into the zip — and the SERIALIZED result
1047    /// never carries the value (the redaction pin).
1048    #[tokio::test]
1049    async fn deploy_with_script_exec_generates_and_redacts_the_secret() {
1050        let (dir, config) = temp_config();
1051        let seen_zip = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1052        let recorder = std::sync::Arc::clone(&seen_zip);
1053        let rig = WebdevRig {
1054            probe: |_| unreachable!("deploy never probes"),
1055            transport: None,
1056            import: Box::new(move |zip, _| {
1057                *recorder.lock().expect("zip lock") = zip;
1058                Ok(ok_import())
1059            }),
1060        };
1061        let result = webdev_deploy(&rig, "ign-cli", true, false, &config, "dev", false)
1062            .await
1063            .expect("scriptExec deploy");
1064        assert_eq!(
1065            result.routes,
1066            vec!["tags", "tagConfig", "alarms", "tagHistory", "scriptExec"]
1067        );
1068        assert!(result.script_exec && result.secret_rotated);
1069
1070        let secret = stored_secret(&config).expect("secret persisted");
1071        assert_eq!(secret.len(), 64, "32 bytes hex-encoded");
1072        assert!(
1073            secret.chars().all(|c| c.is_ascii_hexdigit()),
1074            "hex alphabet: {secret}"
1075        );
1076        #[cfg(unix)]
1077        {
1078            use std::os::unix::fs::PermissionsExt;
1079            let mode = std::fs::metadata(&config)
1080                .expect("config stat")
1081                .permissions()
1082                .mode();
1083            assert_eq!(mode & 0o777, 0o600, "the save path re-asserts 0600");
1084        }
1085
1086        // The baked route carries the secret; the serialized result
1087        // does NOT (redaction — Phase 1's canary pattern).
1088        let do_post = member(
1089            &seen_zip.lock().expect("zip lock"),
1090            "com.inductiveautomation.webdev/resources/cli/scriptExec/doPost.py",
1091        );
1092        assert!(String::from_utf8_lossy(&do_post).contains(&secret));
1093        let serialized = serde_json::to_string(&result).expect("result serializes");
1094        assert!(!serialized.contains(&secret), "redaction: {serialized}");
1095        let envelope_check: WebdevDeployResult = result;
1096        let again = serde_json::to_string(&envelope_check).expect("serializes");
1097        assert!(!again.contains(&secret));
1098        let _ = dir;
1099    }
1100
1101    /// --rotate-secret regenerates even when a secret exists; a plain
1102    /// re-deploy reuses the stored one unchanged.
1103    #[tokio::test]
1104    async fn rotate_regenerates_and_plain_redeploy_reuses() {
1105        let (dir, config) = temp_config();
1106        // Seed a stored secret.
1107        let mut seeded = crate::config::load(&config).expect("load");
1108        seeded.profiles.get_mut("dev").unwrap().webdev_secret = Some("aa11".into());
1109        crate::config::save(&config, &seeded).expect("seed save");
1110
1111        // Plain scriptExec redeploy: reuses `aa11`, rotated=false.
1112        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1113        let recorder = std::sync::Arc::clone(&seen);
1114        let rig = WebdevRig {
1115            probe: |_| unreachable!(),
1116            transport: None,
1117            import: Box::new(move |zip, _| {
1118                *recorder.lock().expect("lock") = zip;
1119                Ok(ok_import())
1120            }),
1121        };
1122        let result = webdev_deploy(&rig, "ign-cli", true, false, &config, "dev", false)
1123            .await
1124            .expect("reuse deploy");
1125        assert!(!result.secret_rotated);
1126        assert_eq!(stored_secret(&config).as_deref(), Some("aa11"));
1127        let seen = seen.lock().expect("lock").clone();
1128        let do_post = member(
1129            &seen,
1130            "com.inductiveautomation.webdev/resources/cli/scriptExec/doPost.py",
1131        );
1132        assert!(
1133            String::from_utf8_lossy(&do_post).contains("aa11"),
1134            "the STORED secret rode the zip's scriptExec member"
1135        );
1136
1137        // --rotate-secret: a fresh 64-char hex replaces it.
1138        let result = webdev_deploy(
1139            &importing_rig(),
1140            "ign-cli",
1141            true,
1142            true,
1143            &config,
1144            "dev",
1145            false,
1146        )
1147        .await
1148        .expect("rotate deploy");
1149        assert!(result.secret_rotated);
1150        let rotated = stored_secret(&config).expect("rotated secret stored");
1151        assert_eq!(rotated.len(), 64);
1152        assert_ne!(rotated, "aa11");
1153        let _ = dir;
1154    }
1155
1156    /// The status matrix: every probe state maps onto its row; ok is
1157    /// gated ONLY by the always-on routes; scriptExec rides along
1158    /// exactly when a secret is configured.
1159    #[tokio::test]
1160    async fn status_maps_the_full_probe_matrix() {
1161        // All present + matching: ok=true.
1162        let healthy = WebdevRig {
1163            probe: |_| present(BUNDLE_VERSION),
1164            transport: None,
1165            import: Box::new(|_, _| unreachable!("status never imports")),
1166        };
1167        let result = webdev_status(&healthy, "ign-cli", None)
1168            .await
1169            .expect("status sweep");
1170        assert_eq!(result.routes.len(), 4);
1171        assert!(
1172            result
1173                .routes
1174                .iter()
1175                .all(|row| row.status == RouteStatus::Present)
1176        );
1177        assert!(result.ok);
1178
1179        // One route absent, one mismatched: ok=false, degradation is
1180        // DATA (rows carry their own verdicts).
1181        let degraded = WebdevRig {
1182            transport: None,
1183            probe: |route| {
1184                Ok(match route {
1185                    "tags" => RouteProbe::Absent,
1186                    "tagConfig" => present("9.9.9").expect("fixture"),
1187                    "alarms" => present(BUNDLE_VERSION).expect("fixture"),
1188                    "tagHistory" => RouteProbe::Unlicensed,
1189                    "scriptExec" => RouteProbe::Denied {
1190                        code: "secret_mismatch".into(),
1191                        message: "mismatch".into(),
1192                        traceback: None,
1193                    },
1194                    _ => RouteProbe::AuthGated,
1195                })
1196            },
1197            import: Box::new(|_, _| unreachable!()),
1198        };
1199        let result = webdev_status(&degraded, "ign-cli", Some("stored-secret"))
1200            .await
1201            .expect("degraded sweep still completes");
1202        let by_route = |name: &str| {
1203            result
1204                .routes
1205                .iter()
1206                .find(|row| row.route == name)
1207                .unwrap_or_else(|| panic!("{name} row"))
1208        };
1209        assert_eq!(by_route("tags").status, RouteStatus::Absent);
1210        assert_eq!(by_route("tagConfig").status, RouteStatus::VersionMismatch);
1211        assert_eq!(
1212            by_route("tagConfig").deployed_version.as_deref(),
1213            Some("9.9.9")
1214        );
1215        assert_eq!(by_route("tagHistory").status, RouteStatus::Unlicensed);
1216        assert_eq!(by_route("scriptExec").status, RouteStatus::SecretMismatch);
1217        assert!(!result.ok);
1218        // scriptExec never gates ok: the healthy sweep with a secret
1219        // whose probe denies stays ok=true.
1220        let gated_exec = WebdevRig {
1221            transport: None,
1222            probe: |route| {
1223                if route == "scriptExec" {
1224                    Ok(RouteProbe::AuthGated)
1225                } else {
1226                    present(BUNDLE_VERSION)
1227                }
1228            },
1229            import: Box::new(|_, _| unreachable!()),
1230        };
1231        let result = webdev_status(&gated_exec, "ign-cli", Some("s"))
1232            .await
1233            .expect("sweep");
1234        assert!(result.ok, "scriptExec never gates ok");
1235        assert_eq!(result.routes.len(), 5);
1236    }
1237
1238    /// THE precondition refusal matrix (must-have truth #3): before
1239    /// deploy → routes_not_deployed naming `ign webdev deploy`;
1240    /// mismatch → route_version_mismatch with both versions named.
1241    #[tokio::test]
1242    async fn precondition_refuses_the_undeployed_and_mismatched() {
1243        let undeployed = WebdevRig {
1244            probe: |_| Ok(RouteProbe::Absent),
1245            transport: None,
1246            import: Box::new(|_, _| unreachable!()),
1247        };
1248        let err = webdev_precondition(&undeployed, "ign-cli")
1249            .await
1250            .expect_err("absent refuses");
1251        assert_eq!(err.code(), "routes_not_deployed");
1252        assert_eq!(err.exit_code(), 6);
1253        assert!(
1254            err.hint().unwrap().contains("ign webdev deploy"),
1255            "hint names the fix"
1256        );
1257
1258        let older = WebdevRig {
1259            probe: |_| present("0.9.0"),
1260            transport: None,
1261            import: Box::new(|_, _| unreachable!()),
1262        };
1263        let err = webdev_precondition(&older, "ign-cli")
1264            .await
1265            .expect_err("older refuses");
1266        assert_eq!(err.code(), "route_version_mismatch");
1267        assert!(
1268            err.to_string().contains("0.9.0") && err.to_string().contains(BUNDLE_VERSION),
1269            "both versions named: {err}"
1270        );
1271
1272        let matching = WebdevRig {
1273            probe: |_| present(BUNDLE_VERSION),
1274            transport: None,
1275            import: Box::new(|_, _| unreachable!()),
1276        };
1277        webdev_precondition(&matching, "ign-cli")
1278            .await
1279            .expect("matching handshake passes");
1280    }
1281
1282    const TAG_DELETE_DO_POST: &str =
1283        "com.inductiveautomation.webdev/resources/cli/tagDelete/doPost.py";
1284    const TAG_DELETE_CONFIG: &str =
1285        "com.inductiveautomation.webdev/resources/cli/tagDelete/config.json";
1286
1287    /// A project repo whose `deploy/project.yaml` owns `providers` (a YAML
1288    /// flow list such as `[P26Del]`).
1289    fn temp_repo(providers: &str) -> tempfile::TempDir {
1290        let dir = tempfile::tempdir().expect("tempdir");
1291        std::fs::create_dir_all(dir.path().join("deploy")).expect("deploy dir");
1292        std::fs::write(
1293            dir.path().join("deploy/project.yaml"),
1294            format!(
1295                "schema_version: 1\nproject: Demo\nenvironments: [dev]\ntag_providers: {providers}\n"
1296            ),
1297        )
1298        .expect("project.yaml");
1299        dir
1300    }
1301
1302    fn tag_delete_request(repo: &std::path::Path, rotate_secret: bool) -> TagDeleteRequest {
1303        TagDeleteRequest {
1304            repos: vec![repo.to_path_buf()],
1305            user_source: "default".into(),
1306            role: "FakeDeployRole".into(),
1307            rotate_secret,
1308            caller: None,
1309        }
1310    }
1311
1312    fn stored_tag_delete_secret(path: &std::path::Path) -> Option<String> {
1313        crate::config::load(path)
1314            .expect("config reloads")
1315            .profiles
1316            .get("dev")
1317            .and_then(|profile| profile.webdev_tag_delete_secret.clone())
1318    }
1319
1320    /// A rig whose import must never run (refusals happen before any
1321    /// gateway request).
1322    fn no_gateway_rig() -> WebdevRig {
1323        WebdevRig {
1324            probe: |_| unreachable!("deploy never probes"),
1325            transport: None,
1326            import: Box::new(|_, _| panic!("no gateway request may happen")),
1327        }
1328    }
1329
1330    /// A rig recording the uploaded zip and, at upload time, the stored
1331    /// tag-delete secret (proving it was persisted BEFORE the upload).
1332    #[allow(clippy::type_complexity)]
1333    fn recording_rig(
1334        config: &std::path::Path,
1335    ) -> (
1336        WebdevRig,
1337        std::sync::Arc<std::sync::Mutex<(Vec<u8>, Option<String>)>>,
1338    ) {
1339        let seen = std::sync::Arc::new(std::sync::Mutex::new((Vec::new(), None)));
1340        let recorder = std::sync::Arc::clone(&seen);
1341        let config = config.to_path_buf();
1342        let rig = WebdevRig {
1343            probe: |_| unreachable!("deploy never probes"),
1344            transport: None,
1345            import: Box::new(move |zip, overwrite| {
1346                assert!(overwrite);
1347                *recorder.lock().expect("lock") = (zip, stored_tag_delete_secret(&config));
1348                Ok(ok_import())
1349            }),
1350        };
1351        (rig, seen)
1352    }
1353
1354    /// No stored secret: one is generated, persisted 0600 before the
1355    /// upload and packed; a second deploy reuses it; rotate regenerates.
1356    /// The result, its Debug form and its JSON carry no secret.
1357    #[tokio::test]
1358    async fn tag_delete_secret_generated_persisted_reused_and_rotated() {
1359        let (dir, config) = temp_config();
1360        let repo = temp_repo("[P26Del]");
1361        let (rig, seen) = recording_rig(&config);
1362        let result = webdev_deploy_with(
1363            &rig,
1364            "ign-cli",
1365            false,
1366            false,
1367            &config,
1368            "dev",
1369            false,
1370            Some(tag_delete_request(repo.path(), false)),
1371        )
1372        .await
1373        .expect("tag delete deploy");
1374        assert!(result.tag_delete && result.tag_delete_secret_rotated);
1375        assert!(!result.script_exec && !result.secret_rotated);
1376        assert_eq!(result.tag_delete_providers, vec!["P26Del".to_string()]);
1377        assert_eq!(
1378            result.routes,
1379            vec!["tags", "tagConfig", "alarms", "tagHistory", "tagDelete"]
1380        );
1381
1382        let secret = stored_tag_delete_secret(&config).expect("secret persisted");
1383        assert_eq!(secret.len(), 64);
1384        assert!(
1385            secret
1386                .bytes()
1387                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
1388        );
1389        assert_eq!(
1390            stored_secret(&config),
1391            None,
1392            "the scriptExec secret is untouched"
1393        );
1394        #[cfg(unix)]
1395        {
1396            use std::os::unix::fs::PermissionsExt;
1397            let mode = std::fs::metadata(&config)
1398                .expect("stat")
1399                .permissions()
1400                .mode();
1401            assert_eq!(mode & 0o777, 0o600);
1402        }
1403        let (zip, at_upload) = seen.lock().expect("lock").clone();
1404        assert_eq!(
1405            at_upload.as_deref(),
1406            Some(secret.as_str()),
1407            "persisted before upload"
1408        );
1409        assert!(String::from_utf8_lossy(&member(&zip, TAG_DELETE_DO_POST)).contains(&secret));
1410        let packed_config: serde_json::Value =
1411            serde_json::from_slice(&member(&zip, TAG_DELETE_CONFIG)).expect("config.json");
1412        assert_eq!(packed_config["doPost"]["required-roles"], "FakeDeployRole");
1413        assert_eq!(packed_config["doPost"]["user-source"], "default");
1414
1415        let serialized = serde_json::to_string(&result).expect("serializes");
1416        assert!(!serialized.contains(&secret), "redaction: {serialized}");
1417        assert!(serialized.contains("\"tag_delete\":true"));
1418        assert!(serialized.contains("\"tag_delete_secret_rotated\":true"));
1419        assert!(serialized.contains("\"tag_delete_providers\":[\"P26Del\"]"));
1420        assert!(!format!("{result:?}").contains(&secret));
1421
1422        // Reuse.
1423        let (rig, seen) = recording_rig(&config);
1424        let result = webdev_deploy_with(
1425            &rig,
1426            "ign-cli",
1427            false,
1428            false,
1429            &config,
1430            "dev",
1431            false,
1432            Some(tag_delete_request(repo.path(), false)),
1433        )
1434        .await
1435        .expect("reuse deploy");
1436        assert!(result.tag_delete && !result.tag_delete_secret_rotated);
1437        assert_eq!(
1438            stored_tag_delete_secret(&config).as_deref(),
1439            Some(secret.as_str())
1440        );
1441        let (zip, _) = seen.lock().expect("lock").clone();
1442        assert!(String::from_utf8_lossy(&member(&zip, TAG_DELETE_DO_POST)).contains(&secret));
1443
1444        // Rotate.
1445        let result = webdev_deploy_with(
1446            &importing_rig(),
1447            "ign-cli",
1448            false,
1449            false,
1450            &config,
1451            "dev",
1452            false,
1453            Some(tag_delete_request(repo.path(), true)),
1454        )
1455        .await
1456        .expect("rotate deploy");
1457        assert!(result.tag_delete_secret_rotated);
1458        let rotated = stored_tag_delete_secret(&config).expect("rotated");
1459        assert_eq!(rotated.len(), 64);
1460        assert_ne!(rotated, secret);
1461        let _ = dir;
1462    }
1463
1464    /// The scriptExec and tag-delete secrets are independent: rotating one
1465    /// never changes the other, and both routes compose in one import.
1466    #[tokio::test]
1467    async fn script_exec_and_tag_delete_secrets_are_independent() {
1468        let (dir, config) = temp_config();
1469        let repo = temp_repo("[P26Del, Other]");
1470        let (rig, seen) = recording_rig(&config);
1471        let result = webdev_deploy_with(
1472            &rig,
1473            "ign-cli",
1474            true,
1475            false,
1476            &config,
1477            "dev",
1478            true,
1479            Some(tag_delete_request(repo.path(), false)),
1480        )
1481        .await
1482        .expect("composed deploy");
1483        assert!(result.script_exec && result.tag_delete);
1484        assert_eq!(
1485            result.tag_delete_providers,
1486            vec!["P26Del".to_string(), "Other".to_string()]
1487        );
1488        assert!(result.routes.contains(&"scriptExec".to_string()));
1489        assert!(result.routes.contains(&"tagDelete".to_string()));
1490        assert!(
1491            result
1492                .routes
1493                .iter()
1494                .any(|route| route.starts_with("testing/"))
1495        );
1496        let names = member_names(&seen.lock().expect("lock").0);
1497        assert!(names.iter().any(|name| name.contains("cli/scriptExec/")));
1498        assert!(names.iter().any(|name| name.contains("cli/tagDelete/")));
1499
1500        let exec_secret = stored_secret(&config).expect("scriptExec secret");
1501        let delete_secret = stored_tag_delete_secret(&config).expect("tagDelete secret");
1502        assert_ne!(exec_secret, delete_secret);
1503
1504        // Rotate scriptExec only.
1505        let result = webdev_deploy_with(
1506            &importing_rig(),
1507            "ign-cli",
1508            true,
1509            true,
1510            &config,
1511            "dev",
1512            false,
1513            Some(tag_delete_request(repo.path(), false)),
1514        )
1515        .await
1516        .expect("rotate scriptExec");
1517        assert!(result.secret_rotated && !result.tag_delete_secret_rotated);
1518        assert_ne!(
1519            stored_secret(&config).as_deref(),
1520            Some(exec_secret.as_str())
1521        );
1522        assert_eq!(
1523            stored_tag_delete_secret(&config).as_deref(),
1524            Some(delete_secret.as_str())
1525        );
1526
1527        // Rotate tag delete only.
1528        let exec_now = stored_secret(&config).expect("scriptExec secret");
1529        let result = webdev_deploy_with(
1530            &importing_rig(),
1531            "ign-cli",
1532            true,
1533            false,
1534            &config,
1535            "dev",
1536            false,
1537            Some(tag_delete_request(repo.path(), true)),
1538        )
1539        .await
1540        .expect("rotate tag delete");
1541        assert!(!result.secret_rotated && result.tag_delete_secret_rotated);
1542        assert_eq!(stored_secret(&config).as_deref(), Some(exec_now.as_str()));
1543        assert_ne!(
1544            stored_tag_delete_secret(&config).as_deref(),
1545            Some(delete_secret.as_str())
1546        );
1547        let serialized = serde_json::to_string(&result).expect("serializes");
1548        for secret in [
1549            exec_now,
1550            stored_tag_delete_secret(&config).expect("tagDelete secret"),
1551        ] {
1552            assert!(!serialized.contains(&secret));
1553            assert!(!format!("{result:?}").contains(&secret));
1554        }
1555        let _ = dir;
1556    }
1557
1558    /// The allowlist comes from the repo; an empty list, `System` in any
1559    /// case, a bad role or user source, or a broken repo refuse before any
1560    /// gateway request and before any secret is stored. No error text
1561    /// carries a stored secret.
1562    #[tokio::test]
1563    async fn tag_delete_refusals_happen_before_any_gateway_request() {
1564        let (dir, config) = temp_config();
1565        let mut seeded = crate::config::load(&config).expect("load");
1566        seeded.profiles.get_mut("dev").expect("dev").webdev_secret =
1567            Some("fakeexecsecretvalue".into());
1568        crate::config::save(&config, &seeded).expect("seed");
1569
1570        for (providers, code) in [
1571            ("[]", "invalid_input"),
1572            ("[System]", "invalid_input"),
1573            ("[P1, system]", "invalid_input"),
1574        ] {
1575            let repo = temp_repo(providers);
1576            let err = webdev_deploy_with(
1577                &no_gateway_rig(),
1578                "ign-cli",
1579                true,
1580                false,
1581                &config,
1582                "dev",
1583                false,
1584                Some(tag_delete_request(repo.path(), false)),
1585            )
1586            .await
1587            .expect_err("refused");
1588            assert_eq!(err.code(), code, "{providers}: {err}");
1589            assert!(!err.to_string().contains("fakeexecsecretvalue"));
1590        }
1591
1592        let repo = temp_repo("[P26Del]");
1593        let mut bad_role = tag_delete_request(repo.path(), false);
1594        bad_role.role = "x'y".into();
1595        let err = webdev_deploy_with(
1596            &no_gateway_rig(),
1597            "ign-cli",
1598            false,
1599            false,
1600            &config,
1601            "dev",
1602            false,
1603            Some(bad_role),
1604        )
1605        .await
1606        .expect_err("bad role");
1607        assert_eq!(err.code(), "invalid_input");
1608        assert!(err.to_string().contains("role"), "{err}");
1609
1610        let empty_repo = tempfile::tempdir().expect("tempdir");
1611        let err = webdev_deploy_with(
1612            &no_gateway_rig(),
1613            "ign-cli",
1614            false,
1615            false,
1616            &config,
1617            "dev",
1618            false,
1619            Some(tag_delete_request(empty_repo.path(), false)),
1620        )
1621        .await
1622        .expect_err("no project.yaml");
1623        assert_eq!(err.code(), "deploy_tree_invalid");
1624
1625        assert_eq!(stored_tag_delete_secret(&config), None, "nothing stored");
1626        let _ = dir;
1627    }
1628
1629    /// 26-REVIEW WR-07: the tagDelete secret goes only over https or to a
1630    /// loopback host (and with `ssl_verify` on for a remote one), the route
1631    /// client's own rule: plain http to a remote host, or unverified TLS to
1632    /// one, refuses `tag_delete_not_configured` before any secret is stored
1633    /// and before any gateway request.
1634    #[tokio::test]
1635    async fn tag_delete_install_refuses_an_unsafe_transport() {
1636        // The double reports no URL, so the check falls back to the stored
1637        // profile; no env read happens (WR-D2).
1638        let repo = temp_repo("[P26Del]");
1639        for (url, ssl_verify, why) in [
1640            ("http://gateway.example.com:8088/", true, "plain http"),
1641            (
1642                "https://gateway.example.com:8043/",
1643                false,
1644                "ssl_verify false",
1645            ),
1646        ] {
1647            let (dir, config) = temp_config();
1648            let mut seeded = crate::config::load(&config).expect("load");
1649            let profile = seeded.profiles.get_mut("dev").expect("dev");
1650            profile.url = url::Url::parse(url).expect("url");
1651            profile.ssl_verify = ssl_verify;
1652            crate::config::save(&config, &seeded).expect("seed");
1653            let err = webdev_deploy_with(
1654                &no_gateway_rig(),
1655                "ign-cli",
1656                false,
1657                false,
1658                &config,
1659                "dev",
1660                false,
1661                Some(tag_delete_request(repo.path(), false)),
1662            )
1663            .await
1664            .expect_err("refused");
1665            assert_eq!(err.code(), "tag_delete_not_configured", "{url}: {err}");
1666            assert!(err.to_string().contains(why), "{url}: {err}");
1667            assert_eq!(stored_tag_delete_secret(&config), None, "{url}");
1668            let _ = dir;
1669        }
1670    }
1671
1672    /// 26-REVIEW-D WR-D2: the transport rule checks the URL the import's
1673    /// client sends to, not a fresh read of `IGNITION_URL`. An env value
1674    /// set by another test (or changed after the session was built) neither
1675    /// refuses a safe client nor admits an unsafe one.
1676    #[test]
1677    fn tag_delete_transport_check_reads_the_clients_url() {
1678        let _lock = crate::config::ENV_LOCK.lock().expect("env lock");
1679        tokio::runtime::Builder::new_current_thread()
1680            .build()
1681            .expect("runtime")
1682            .block_on(transport_check_reads_the_clients_url());
1683    }
1684
1685    async fn transport_check_reads_the_clients_url() {
1686        let repo = temp_repo("[P26Del]");
1687        let loopback = url::Url::parse("http://localhost:9088/").expect("url");
1688        let remote = url::Url::parse("http://gateway.example.com:8088/").expect("url");
1689
1690        // A remote plain-http IGNITION_URL in the env, a loopback client.
1691        let (dir, config) = temp_config();
1692        // SAFETY: single-threaded under ENV_LOCK; removed before return.
1693        unsafe { std::env::set_var("IGNITION_URL", remote.as_str()) };
1694        let mut rig = importing_rig();
1695        rig.transport = Some((loopback, true));
1696        let result = webdev_deploy_with(
1697            &rig,
1698            "ign-cli",
1699            false,
1700            false,
1701            &config,
1702            "dev",
1703            false,
1704            Some(tag_delete_request(repo.path(), false)),
1705        )
1706        .await;
1707        // SAFETY: single-threaded under ENV_LOCK.
1708        unsafe { std::env::remove_var("IGNITION_URL") };
1709        let result = result.expect("the client's loopback URL is safe");
1710        assert!(result.tag_delete);
1711        let _ = dir;
1712
1713        // No env, a loopback profile, a remote plain-http client.
1714        let (dir, config) = temp_config();
1715        let mut rig = no_gateway_rig();
1716        rig.transport = Some((remote, true));
1717        let err = webdev_deploy_with(
1718            &rig,
1719            "ign-cli",
1720            false,
1721            false,
1722            &config,
1723            "dev",
1724            false,
1725            Some(tag_delete_request(repo.path(), false)),
1726        )
1727        .await
1728        .expect_err("the client's remote http URL is refused");
1729        assert_eq!(err.code(), "tag_delete_not_configured", "{err}");
1730        assert!(err.to_string().contains("plain http"), "{err}");
1731        assert_eq!(stored_tag_delete_secret(&config), None);
1732        let _ = dir;
1733    }
1734
1735    /// A config store that cannot be written refuses before any gateway
1736    /// request.
1737    #[cfg(unix)]
1738    #[tokio::test]
1739    async fn tag_delete_broken_config_store_refuses_before_any_gateway_request() {
1740        use std::os::unix::fs::PermissionsExt;
1741        let (dir, config) = temp_config();
1742        let repo = temp_repo("[P26Del]");
1743        std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o400))
1744            .expect("read-only");
1745        let err = webdev_deploy_with(
1746            &no_gateway_rig(),
1747            "ign-cli",
1748            false,
1749            false,
1750            &config,
1751            "dev",
1752            false,
1753            Some(tag_delete_request(repo.path(), false)),
1754        )
1755        .await
1756        .expect_err("unwritable store refuses");
1757        assert_eq!(err.code(), "config_invalid", "{err}");
1758        std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o600)).expect("restore");
1759
1760        std::fs::write(&config, "not = [valid").expect("break");
1761        let err = webdev_deploy_with(
1762            &no_gateway_rig(),
1763            "ign-cli",
1764            false,
1765            false,
1766            &config,
1767            "dev",
1768            false,
1769            Some(tag_delete_request(repo.path(), false)),
1770        )
1771        .await
1772        .expect_err("unparsable store refuses");
1773        assert_ne!(err.code(), "internal");
1774        let _ = dir;
1775    }
1776
1777    /// A deploy without the tag-delete request packs no tagDelete member and
1778    /// reports the three keys as off.
1779    #[tokio::test]
1780    async fn deploy_without_tag_delete_packs_no_tag_delete_member() {
1781        let (dir, config) = temp_config();
1782        let (rig, seen) = recording_rig(&config);
1783        let result = webdev_deploy_with(&rig, "ign-cli", true, false, &config, "dev", false, None)
1784            .await
1785            .expect("deploy");
1786        assert!(!result.tag_delete && !result.tag_delete_secret_rotated);
1787        assert!(result.tag_delete_providers.is_empty());
1788        assert!(!result.routes.contains(&"tagDelete".to_string()));
1789        let names = member_names(&seen.lock().expect("lock").0);
1790        assert!(names.iter().all(|name| !name.contains("tagDelete")));
1791        assert_eq!(stored_tag_delete_secret(&config), None);
1792        assert!(result.warnings.is_empty(), "{:?}", result.warnings);
1793        let _ = dir;
1794    }
1795
1796    /// 26-REVIEW-D WR-D4: a deploy without the tag-delete request (the CLI
1797    /// without `--with-tag-delete`, the TUI, adopt) drops the route, so it
1798    /// also drops the stored tagDelete secret, before the upload, and warns
1799    /// naming the removal. The profile then reads not configured. The
1800    /// deploy itself is not refused, and the warning carries no secret.
1801    #[tokio::test]
1802    async fn deploy_without_tag_delete_removes_the_stored_secret_and_warns() {
1803        let (dir, config) = temp_config();
1804        let mut seeded = crate::config::load(&config).expect("load");
1805        seeded
1806            .profiles
1807            .get_mut("dev")
1808            .expect("dev")
1809            .webdev_tag_delete_secret = Some("a".repeat(64));
1810        crate::config::save(&config, &seeded).expect("seed");
1811
1812        // A deploy into another project leaves the route in `ign-cli`, the
1813        // one the route client calls: the secret stays, no warning.
1814        let result = webdev_deploy(
1815            &importing_rig(),
1816            "Other",
1817            false,
1818            false,
1819            &config,
1820            "dev",
1821            false,
1822        )
1823        .await
1824        .expect("deploy");
1825        assert!(result.warnings.is_empty(), "{:?}", result.warnings);
1826        assert!(stored_tag_delete_secret(&config).is_some());
1827
1828        // `webdev_deploy` is the path adopt and the TUI call.
1829        let (rig, seen) = recording_rig(&config);
1830        let result = webdev_deploy(&rig, "ign-cli", true, false, &config, "dev", false)
1831            .await
1832            .expect("the deploy is not refused");
1833        assert!(!result.tag_delete);
1834        assert_eq!(stored_tag_delete_secret(&config), None, "secret removed");
1835        assert_eq!(seen.lock().expect("lock").1, None, "removed before upload");
1836        assert_eq!(result.warnings.len(), 1, "{:?}", result.warnings);
1837        let warning = &result.warnings[0];
1838        assert!(warning.contains("tagDelete"), "{warning}");
1839        assert!(warning.contains("removed"), "{warning}");
1840        assert!(warning.contains("--with-tag-delete"), "{warning}");
1841        assert!(!warning.contains(&"a".repeat(64)), "{warning}");
1842        assert!(stored_secret(&config).is_some(), "scriptExec kept");
1843
1844        let profile = crate::config::load(&config)
1845            .expect("reload")
1846            .profiles
1847            .remove("dev")
1848            .expect("dev");
1849        match crate::deploy::tag_delete::tag_delete_config("dev", &profile) {
1850            crate::deploy::tag_delete::TagDeleteConfig::NotConfigured { missing } => {
1851                assert!(
1852                    missing
1853                        .iter()
1854                        .any(|part| part == "webdev_tag_delete_secret"),
1855                    "{missing:?}"
1856                );
1857            }
1858            other => panic!("expected NotConfigured, got {other:?}"),
1859        }
1860
1861        // Nothing stored: nothing to remove, no warning.
1862        let result = webdev_deploy(
1863            &importing_rig(),
1864            "ign-cli",
1865            false,
1866            false,
1867            &config,
1868            "dev",
1869            false,
1870        )
1871        .await
1872        .expect("deploy");
1873        assert!(result.warnings.is_empty(), "{:?}", result.warnings);
1874        let _ = dir;
1875    }
1876
1877    /// A deploy that installs the route keeps its secret and warns nothing.
1878    #[tokio::test]
1879    async fn deploy_with_tag_delete_keeps_the_secret_and_does_not_warn() {
1880        let (dir, config) = temp_config();
1881        let repo = temp_repo("[P26Del]");
1882        for _ in 0..2 {
1883            let result = webdev_deploy_with(
1884                &importing_rig(),
1885                "ign-cli",
1886                false,
1887                false,
1888                &config,
1889                "dev",
1890                false,
1891                Some(tag_delete_request(repo.path(), false)),
1892            )
1893            .await
1894            .expect("deploy");
1895            assert!(
1896                !result.warnings.iter().any(|w| w.contains("removed")),
1897                "{:?}",
1898                result.warnings
1899            );
1900            assert!(stored_tag_delete_secret(&config).is_some());
1901        }
1902        let _ = dir;
1903    }
1904
1905    /// A temp config whose `dev` profile points at `url` and holds a stored
1906    /// tagDelete secret.
1907    fn config_with_route_secret(url: &str, secret: &str) -> (tempfile::TempDir, PathBuf) {
1908        let (dir, config) = temp_config();
1909        let mut seeded = crate::config::load(&config).expect("load");
1910        let profile = seeded.profiles.get_mut("dev").expect("dev");
1911        profile.url = url::Url::parse(url).expect("url");
1912        profile.webdev_tag_delete_secret = Some(secret.to_string());
1913        crate::config::save(&config, &seeded).expect("seed");
1914        (dir, config)
1915    }
1916
1917    /// Mount the live route's `version` answer with `providers`, or a bare
1918    /// HTTP `status` when `providers` is `None`.
1919    async fn mount_live_route(server: &wiremock::MockServer, providers: Option<&[&str]>) {
1920        let response = match providers {
1921            Some(providers) => wiremock::ResponseTemplate::new(200).set_body_json(
1922                serde_json::json!({"ok": true, "data": {
1923                    "routeVersion": BUNDLE_VERSION, "minCli": "1.0.0",
1924                    "providers": providers, "maxPaths": 500, "maxPathChars": 1024,
1925                }}),
1926            ),
1927            None => wiremock::ResponseTemplate::new(405),
1928        };
1929        wiremock::Mock::given(wiremock::matchers::method("POST"))
1930            .and(wiremock::matchers::path(
1931                "/system/webdev/ign-cli/cli/tagDelete",
1932            ))
1933            .and(wiremock::matchers::header(
1934                "X-Ignition-CLI-Secret",
1935                "c".repeat(64).as_str(),
1936            ))
1937            .respond_with(response)
1938            .mount(server)
1939            .await;
1940    }
1941
1942    fn caller() -> Option<(crate::config::Secret, crate::config::Secret)> {
1943        Some((
1944            crate::config::Secret::new("deployer"),
1945            crate::config::Secret::new("Pw-fake-Z9"),
1946        ))
1947    }
1948
1949    /// 26-REVIEW-D WR-D3: before an install replaces the route, its live
1950    /// allowlist is read through one handshake (the stored secret and the
1951    /// caller's env pair). An install that would drop a provider allowed
1952    /// now refuses `invalid_input` naming it and both ways forward, before
1953    /// any secret is stored and before the import. Naming every repo
1954    /// installs the union.
1955    #[tokio::test]
1956    async fn tag_delete_install_refuses_to_drop_live_providers() {
1957        let server = wiremock::MockServer::start().await;
1958        mount_live_route(&server, Some(&["PA", "PB"])).await;
1959        let secret = "c".repeat(64);
1960        let (dir, config) = config_with_route_secret(&server.uri(), &secret);
1961        let repo_a = temp_repo("[PA]");
1962        let repo_b = temp_repo("[PB]");
1963
1964        let mut request = tag_delete_request(repo_b.path(), false);
1965        request.caller = caller();
1966        let err = webdev_deploy_with(
1967            &no_gateway_rig(),
1968            "ign-cli",
1969            false,
1970            false,
1971            &config,
1972            "dev",
1973            false,
1974            Some(request.clone()),
1975        )
1976        .await
1977        .expect_err("dropping PA is refused");
1978        assert_eq!(err.code(), "invalid_input", "{err}");
1979        let text = err.to_string();
1980        assert!(text.contains("PA"), "{text}");
1981        assert!(text.contains("--tag-delete-repo"), "{text}");
1982        assert!(text.contains("without --with-tag-delete"), "{text}");
1983        assert!(!text.contains(&secret), "{text}");
1984        let hint = err.hint().unwrap_or_default();
1985        assert!(hint.contains("--tag-delete-repo"), "{hint}");
1986        assert_eq!(
1987            stored_tag_delete_secret(&config).as_deref(),
1988            Some(secret.as_str()),
1989            "nothing stored"
1990        );
1991
1992        // A rotate reads with the stored secret and refuses the same way.
1993        let mut rotate = request.clone();
1994        rotate.rotate_secret = true;
1995        let err = webdev_deploy_with(
1996            &no_gateway_rig(),
1997            "ign-cli",
1998            false,
1999            false,
2000            &config,
2001            "dev",
2002            false,
2003            Some(rotate),
2004        )
2005        .await
2006        .expect_err("a rotate is refused too");
2007        assert_eq!(err.code(), "invalid_input", "{err}");
2008
2009        // Naming both repos keeps both providers.
2010        request.repos = vec![repo_a.path().to_path_buf(), repo_b.path().to_path_buf()];
2011        let result = webdev_deploy_with(
2012            &importing_rig(),
2013            "ign-cli",
2014            false,
2015            false,
2016            &config,
2017            "dev",
2018            false,
2019            Some(request),
2020        )
2021        .await
2022        .expect("the union keeps every live provider");
2023        assert_eq!(result.tag_delete_providers, vec!["PA", "PB"]);
2024        assert!(result.warnings.is_empty(), "{:?}", result.warnings);
2025        let _ = dir;
2026    }
2027
2028    /// A live allowlist that cannot be read (no env pair, a failing route)
2029    /// installs as before and warns; an absent route (405) or no stored
2030    /// secret means nothing is installed, so nothing is read or warned.
2031    #[tokio::test]
2032    async fn tag_delete_install_warns_when_the_live_allowlist_is_unreadable() {
2033        let secret = "c".repeat(64);
2034        let repo = temp_repo("[PB]");
2035
2036        // No env pair: unreadable, warn, install.
2037        let server = wiremock::MockServer::start().await;
2038        mount_live_route(&server, Some(&["PA"])).await;
2039        let (dir, config) = config_with_route_secret(&server.uri(), &secret);
2040        let result = webdev_deploy_with(
2041            &importing_rig(),
2042            "ign-cli",
2043            false,
2044            false,
2045            &config,
2046            "dev",
2047            false,
2048            Some(tag_delete_request(repo.path(), false)),
2049        )
2050        .await
2051        .expect("installs");
2052        assert_eq!(result.warnings.len(), 1, "{:?}", result.warnings);
2053        assert!(
2054            result.warnings[0].contains("could not read the live tagDelete allowlist"),
2055            "{:?}",
2056            result.warnings
2057        );
2058        assert!(!result.warnings[0].contains(&secret));
2059        let _ = dir;
2060
2061        // A route answering 500: unreadable, warn, install.
2062        let server = wiremock::MockServer::start().await;
2063        wiremock::Mock::given(wiremock::matchers::method("POST"))
2064            .respond_with(wiremock::ResponseTemplate::new(500))
2065            .mount(&server)
2066            .await;
2067        let (dir, config) = config_with_route_secret(&server.uri(), &secret);
2068        let mut request = tag_delete_request(repo.path(), false);
2069        request.caller = caller();
2070        let result = webdev_deploy_with(
2071            &importing_rig(),
2072            "ign-cli",
2073            false,
2074            false,
2075            &config,
2076            "dev",
2077            false,
2078            Some(request.clone()),
2079        )
2080        .await
2081        .expect("installs");
2082        assert_eq!(result.warnings.len(), 1, "{:?}", result.warnings);
2083        assert!(result.warnings[0].contains("could not read the live tagDelete allowlist"));
2084        let _ = dir;
2085
2086        // The route is absent (405): nothing to keep, no warning.
2087        let server = wiremock::MockServer::start().await;
2088        mount_live_route(&server, None).await;
2089        let (dir, config) = config_with_route_secret(&server.uri(), &secret);
2090        let result = webdev_deploy_with(
2091            &importing_rig(),
2092            "ign-cli",
2093            false,
2094            false,
2095            &config,
2096            "dev",
2097            false,
2098            Some(request.clone()),
2099        )
2100        .await
2101        .expect("installs");
2102        assert!(result.warnings.is_empty(), "{:?}", result.warnings);
2103        let _ = dir;
2104
2105        // No stored secret: a first install reads nothing, warns nothing.
2106        let (dir, config) = temp_config();
2107        let result = webdev_deploy_with(
2108            &importing_rig(),
2109            "ign-cli",
2110            false,
2111            false,
2112            &config,
2113            "dev",
2114            false,
2115            Some(request),
2116        )
2117        .await
2118        .expect("installs");
2119        assert!(result.warnings.is_empty(), "{:?}", result.warnings);
2120        let _ = dir;
2121    }
2122
2123    /// Generated secrets are hex and (statistically) unique across
2124    /// draws — the shape the route's fail-closed detector relies on.
2125    #[test]
2126    fn generated_secrets_are_hex_and_unique() {
2127        let a = generate_secret().expect("secret");
2128        let b = generate_secret().expect("secret");
2129        assert_eq!(a.len(), 64);
2130        assert_ne!(a, b);
2131    }
2132
2133    fn member_names(zip_bytes: &[u8]) -> Vec<String> {
2134        let mut archive =
2135            zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).expect("built zip is readable");
2136        (0..archive.len())
2137            .map(|index| archive.by_index(index).expect("member").name().to_string())
2138            .collect()
2139    }
2140
2141    fn member(zip_bytes: &[u8], name: &str) -> Vec<u8> {
2142        let mut archive =
2143            zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).expect("built zip is readable");
2144        let mut file = archive.by_name(name).expect("member present");
2145        let mut bytes = Vec::new();
2146        std::io::Read::read_to_end(&mut file, &mut bytes).expect("member reads");
2147        bytes
2148    }
2149}