Skip to main content

ignition_core/client/
webdev.rs

1//! The WebDev client seam (05-03) — the CLI's own gateway-side
2//! surface at `/system/webdev/{project}/cli/{route}` (NOT
3//! `/data/webdev/*` — that prefix does not exist; 05-RESEARCH wire
4//! protocol, live-proven on 8.3.3).
5//!
6//! This module owns the seam's PURE pieces so the trait impl in
7//! [`super`] stays thin delegation (the per-capability-file
8//! convention): the ONE path builder ([`route_url`]), the
9//! presence/version discrimination enum ([`RouteProbe`]), the
10//! shared 200-body envelope parser ([`parse_route_body`]) and its
11//! taxonomy mapping ([`denial_to_error`]), and the deploy zip
12//! builder ([`build_deploy_zip`]).
13//!
14//! THE wire rules pinned here (05-RESEARCH, all live-proven):
15//! - **405 = absent, NOT 404** — missing routes AND missing projects
16//!   both answer 405 (Pitfall 1; doctor's Phase-2 404 assumption was
17//!   wrong and 05-03 re-pins it).
18//! - **402 = module unlicensed** — a trial-expired gateway's WebDev
19//!   servlet answers 402 with an HTML page (cross-verified 8.3.6).
20//! - **Denials ride HTTP 200** — WebDev IGNORES a `status` key in
21//!   route returns; every refusal is detectable only from the body
22//!   envelope `{ok, data|error}`. The status code alone is NEVER a
23//!   success verdict (Pitfall 2).
24//!
25//! [`build_deploy_zip`] packs the embedded 05-01 bundle
26//! ([`crate::webdev`]) into the project-zip the deploy action
27//! uploads through the 03-02 import machinery — scriptExec ONLY with
28//! a SUBSTITUTED secret (fail closed: the template's placeholder
29//! must never ship, the 05-01 structural guarantee enforced here at
30//! the type level).
31
32use std::io::Write;
33
34use serde_json::Value;
35
36use crate::error::CoreError;
37use crate::webdev as bundle;
38
39/// The deploy project the CLI owns wholesale — born from the first
40/// deploy zip, overwrite-replaced by every later deploy (05-RESEARCH
41/// deploy guidance; `--project` overrides it deliberately).
42pub const DEFAULT_PROJECT: &str = "ign-cli";
43
44/// The scriptExec route folder's zip root (the template's static
45/// siblings — 05-01 embedded only the doPost.py TEMPLATE in
46/// `crate::webdev`, so the two gate files embed HERE, at the seam
47/// that packs them).
48const SCRIPT_EXEC_ROUTE_ROOT: &str = "com.inductiveautomation.webdev/resources/cli/scriptExec";
49const SCRIPT_EXEC_RESOURCE_JSON: &str = include_str!(
50    "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/scriptExec/resource.json"
51);
52const SCRIPT_EXEC_CONFIG_JSON: &str = include_str!(
53    "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/scriptExec/config.json"
54);
55
56/// The always-on route folders, in manifest order — DERIVED from
57/// [`bundle::ROUTE_FILES`] so the deploy set, the status sweep, and
58/// the manifest itself can never drift apart.
59pub fn always_on_routes() -> Vec<String> {
60    let mut routes = Vec::new();
61    for (name, _) in bundle::ROUTE_FILES {
62        let Some(rest) = name.strip_prefix("com.inductiveautomation.webdev/resources/cli/") else {
63            continue;
64        };
65        if let Some((route, file)) = rest.rsplit_once('/')
66            && file == "doPost.py"
67            && !routes.iter().any(|known: &String| known == route)
68        {
69            routes.push(route.to_string());
70        }
71    }
72    routes
73}
74
75/// Path builder: `/system/webdev/{project}/cli/{route}` — the `cli/`
76/// folder segment is PART of the route folder path (the wire
77/// protocol's URL shape; the `cli` folder groups the CLI's routes
78/// inside the deploy project).
79pub(crate) fn route_url(project: &str, route: &str) -> String {
80    format!("/system/webdev/{project}/cli/{route}")
81}
82
83/// The testing bundle's URL space — `/system/webdev/{project}/testing/
84/// {route}` (the `run`/`tags` folders live BESIDE `cli/`, not under
85/// it; ADOPT-04).
86pub(crate) fn testing_route_url(project: &str, route: &str) -> String {
87    format!("/system/webdev/{project}/testing/{route}")
88}
89
90/// `POST …/testing/run` with `{"discover": true}` — the module list
91/// (the route is POST-ONLY: the two-method doGet+doPost config does
92/// not register on live 8.3.6 — both methods read disabled → 501;
93/// live-pinned, so discover rides the POST body). Answers
94/// `{"discovered_modules": [...], "count": N}`.
95pub async fn testing_discover(
96    api: &super::ReqwestGatewayApi,
97    project: &str,
98) -> Result<serde_json::Value, CoreError> {
99    api.post_json(
100        &testing_route_url(project, "run"),
101        &serde_json::json!({ "discover": true }),
102    )
103    .await?
104    .json::<serde_json::Value>()
105    .await
106    .map_err(|err| {
107        CoreError::Internal(format!(
108            "testing discover response did not match the expected shape: {err}"
109        ))
110    })
111}
112
113/// `POST …/testing/run` — execute tests (`{"module"|"package"|"format"}`
114/// body; empty body = run_all). The doPost sets HTTP 207 on
115/// failures/errors and 200 on green — BOTH are classified-success
116/// shapes here; the pass/fail verdict rides the BODY
117/// (`passed/failed/errors/total`), never the status line (the
118/// module's own Pitfall-2 rule).
119pub async fn testing_run(
120    api: &super::ReqwestGatewayApi,
121    project: &str,
122    body: &serde_json::Value,
123) -> Result<serde_json::Value, CoreError> {
124    let response = api
125        .post_json(&testing_route_url(project, "run"), body)
126        .await?;
127    response.json::<serde_json::Value>().await.map_err(|err| {
128        CoreError::Internal(format!(
129            "testing run response did not match the expected shape: {err}"
130        ))
131    })
132}
133
134/// The TESTING bundle's presence discrimination (QUICK-p0g) — the
135/// [`RouteProbe`] twin for the `testing/` URL space. A SEPARATE enum
136/// on purpose: `RouteProbe`'s variant set is pinned by the webdev
137/// tests and the two route families answer different shapes (the
138/// testing routes have no `{ok, data|error}` envelope and no
139/// `routeVersion` handshake).
140///
141/// WHY a raw-status path exists at all: [`super::classify::classify`]
142/// maps 404 → `NotFound` and collapses **405, 500 and 501** into
143/// `Internal` (exit 1, no status preserved). Running the presence
144/// check through it would make "the testing bundle was never
145/// deployed" (a target-state refusal, exit 6) indistinguishable from
146/// a CLI bug — so the status code IS the answer here, the
147/// [`super::GatewayApi::webdev_route_probe`] precedent.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum TestingProbe {
150    /// 2xx — the bundle answers; the parsed discover body rides along
151    /// so the probe doubles as the discover round trip.
152    Present {
153        /// The `{"discovered_modules": [...], "count": N}` answer.
154        discovered: serde_json::Value,
155    },
156    /// 404 | 405 | 501 — the bundle is not deployed in this project.
157    /// All three are absent markers: 405 is the live-proven 8.3
158    /// WebDev marker for a missing route folder, 404 appears when the
159    /// project itself is absent on some paths, and 501 is what a
160    /// route whose methods failed to register answers.
161    Absent,
162    /// 402 — the WebDev module is installed but unlicensed.
163    Unlicensed,
164    /// 401 | 403 — something answers but rejects the credential.
165    AuthGated {
166        /// The rejecting status, preserved for the error.
167        status: u16,
168    },
169    /// 500 — the first-touch lazy-compile blowup (a freshly deployed
170    /// route can answer 500 on its FIRST request while WebDev
171    /// compiles the module; live-pinned in `actions::adopt`). The
172    /// body rides along so a SECOND 500 can be reported honestly.
173    LazyCompile {
174        /// The route's 500 body, verbatim.
175        body: String,
176    },
177}
178
179/// `POST …/testing/run` with `{"discover": true}` through the RAW
180/// transport — the presence probe. Only transport failures are
181/// errors; every status the gateway answered maps onto a
182/// [`TestingProbe`] variant (or an `Internal` naming the status when
183/// the enum has no variant for it).
184pub async fn testing_probe(
185    api: &super::ReqwestGatewayApi,
186    project: &str,
187) -> Result<TestingProbe, CoreError> {
188    let (url, response) = api
189        .testing_post_raw(project, "run", &serde_json::json!({"discover": true}))
190        .await?;
191    let status = response.status();
192    if status.is_success() {
193        let text = response.text().await.unwrap_or_default();
194        let discovered = serde_json::from_str::<Value>(&text).map_err(|err| {
195            CoreError::Internal(format!(
196                "testing discover response did not match the expected shape: {err}"
197            ))
198        })?;
199        return Ok(TestingProbe::Present { discovered });
200    }
201    match status.as_u16() {
202        401 | 403 => Ok(TestingProbe::AuthGated {
203            status: status.as_u16(),
204        }),
205        402 => Ok(TestingProbe::Unlicensed),
206        404 | 405 | 501 => Ok(TestingProbe::Absent),
207        500 => Ok(TestingProbe::LazyCompile {
208            body: response.text().await.unwrap_or_default(),
209        }),
210        other => Err(CoreError::Internal(format!(
211            "unexpected HTTP {other} from the testing route probe at {url}"
212        ))),
213    }
214}
215
216/// The presence/version discrimination — the probe enum. The status
217/// code IS the answer (deliberately NOT run through classify, the
218/// [`super::GatewayApi::webdev_route_status`] precedent); only
219/// transport failures are errors.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub enum RouteProbe {
222    /// 200 + ok body from the version action: deployed and answering
223    /// its handshake `routeVersion`.
224    Present {
225        /// The route's `routeVersion` handshake answer.
226        route_version: String,
227    },
228    /// 405 — the live-proven 8.3 absent marker (missing route or
229    /// missing project; NOT 404).
230    Absent,
231    /// 402 — the WebDev module is installed but unlicensed
232    /// (trial-expired gateway).
233    Unlicensed,
234    /// 401/403 — something answers at the path but rejects the
235    /// credential: present but auth-gated (research Open Question 4's
236    /// resolution: report, never conflate with absent).
237    AuthGated,
238    /// 200 body denial (`{ok:false, error{code,message}}`) — present
239    /// and refusing: the scriptExec gate's `secret_required` /
240    /// `secret_mismatch`, or any other stable route-contract code.
241    Denied {
242        /// The route's machine error code (05-01 contract).
243        code: String,
244        /// The route's human message.
245        message: String,
246        /// The route's Python traceback, when the denial carries one
247        /// (the envelope's optional `error.traceback` — surfaced so
248        /// route-side exceptions are not a black box, 05-08).
249        traceback: Option<String>,
250    },
251}
252
253/// The 200-body verdict shared by [`super::GatewayApi::webdev_route_call`]
254/// and [`super::GatewayApi::webdev_route_probe`].
255#[derive(Debug)]
256pub(crate) enum RouteBody {
257    /// `ok:true` — `data` (Null when the route sent none).
258    Ok(Value),
259    /// `ok:false` — the route's structured refusal (traceback when
260    /// the envelope carried one).
261    Denied {
262        code: String,
263        message: String,
264        traceback: Option<String>,
265    },
266}
267
268/// Parse a 200 body as the route envelope. A body that is not the
269/// `{ok, data|error}` shape is an internal-class honesty error — the
270/// CLI's own routes ALWAYS answer the envelope, so anything else
271/// means the path is not ours (a foreign route or an HTML error page
272/// that smuggled past the status line). Missing `error` fields
273/// degrade to the route contract's generic `route_error` code rather
274/// than guessing.
275pub(crate) fn parse_route_body(body: &str) -> Result<RouteBody, CoreError> {
276    let value: Value = serde_json::from_str(body).map_err(|err| {
277        CoreError::Internal(format!(
278            "webdev route answered a body that is not the {{ok, data|error}} envelope: {err}"
279        ))
280    })?;
281    if value.get("ok").and_then(Value::as_bool) == Some(true) {
282        Ok(RouteBody::Ok(
283            value.get("data").cloned().unwrap_or(Value::Null),
284        ))
285    } else {
286        let code = value
287            .pointer("/error/code")
288            .and_then(Value::as_str)
289            .unwrap_or("route_error")
290            .to_string();
291        let message = value
292            .pointer("/error/message")
293            .and_then(Value::as_str)
294            .unwrap_or("(the route sent no message)")
295            .to_string();
296        let traceback = value
297            .pointer("/error/traceback")
298            .and_then(Value::as_str)
299            .map(str::to_string);
300        Ok(RouteBody::Denied {
301            code,
302            message,
303            traceback,
304        })
305    }
306}
307
308/// Map a body denial onto the taxonomy: the route contract's
309/// `not_found` code reuses the existing [`CoreError::NotFound`] slug
310/// (it means exactly that — the named thing is absent); the alarms
311/// route's `no_alarm_journal` maps to the actionable
312/// [`CoreError::AlarmJournalMissing`] (default rigs ALWAYS deny
313/// history there — the missing journal chain is target state, not a
314/// route bug, 05-06); every other code — known-but-unmapped like
315/// `secret_required`, or unknown from a future route — rides
316/// [`CoreError::WebdevRouteError`] with code + message verbatim, the
317/// stable contract agents branch on. A denial that carried a
318/// traceback gets it appended to the message
319/// (`"\nroute traceback: {tb}"`) — the route-side exception is
320/// visible instead of a black box (05-08); without one the message
321/// is byte-identical to the pre-traceback era.
322pub(crate) fn denial_to_error(
323    code: &str,
324    message: &str,
325    traceback: Option<&str>,
326    endpoint: String,
327) -> CoreError {
328    match code {
329        "not_found" => CoreError::NotFound {
330            endpoint: Some(endpoint),
331        },
332        "no_alarm_journal" => CoreError::AlarmJournalMissing {
333            endpoint: Some(endpoint),
334        },
335        // The tagConfig route's provider-root refusal (07-06): the
336        // route detects the bracket form pre-call and translates the
337        // bare form's 'No RpcContext' throw — the honest
338        // platform-limitation slug over a generic route error (the
339        // no_alarm_journal seam precedent).
340        "provider_root_unsupported" => CoreError::ProviderRootUnsupported {
341            endpoint: Some(endpoint),
342        },
343        _ => {
344            let mut full = message.to_string();
345            if let Some(traceback) = traceback {
346                full.push_str("\nroute traceback: ");
347                full.push_str(traceback);
348            }
349            CoreError::WebdevRouteError {
350                code: code.to_string(),
351                message: full,
352                endpoint: Some(endpoint),
353            }
354        }
355    }
356}
357
358/// Pack the deploy zip: the embedded always-on bundle VERBATIM (the
359/// project title substituted into `project.json`'s `title` ONLY when
360/// `project_title` differs from [`DEFAULT_PROJECT`] — the manifest
361/// already says `ign-cli`), plus — when `with_script_exec` —
362/// scriptExec's three members with the secret SUBSTITUTED into the
363/// template's `__IGN_CLI_SECRET__` marker (exactly-once replace; the
364/// 05-01 contract test pins the marker count).
365///
366/// FAIL CLOSED: `with_script_exec` + `None` secret is an internal
367/// bug guard (the deploy action generates the secret BEFORE packing;
368/// shipping the unsubstituted template would arm the gate with the
369/// publicly-known placeholder). `Some` WITHOUT `with_script_exec` is
370/// tolerated and ignored — a stored profile secret never forces a
371/// scriptExec deploy.
372///
373/// `with_testing` appends the embedded TESTING bundle
374/// ([`bundle::testing::TESTING_FILES`]) after the always-on routes —
375/// the Jython framework + `testing/run`/`testing/tags` WebDev routes,
376/// with every `__IGN_CLI_PROJECT__` marker substituted for
377/// `project_title` (the scriptExec marker pattern, count-pinned).
378///
379/// Members ride fixed `SimpleFileOptions` + deflate (the 05-02
380/// deterministic-zip convention) so identical inputs pack
381/// identically.
382pub fn build_deploy_zip(
383    project_title: &str,
384    with_script_exec: bool,
385    secret: Option<&str>,
386    with_testing: bool,
387) -> Result<Vec<u8>, CoreError> {
388    let script_exec_py = match (with_script_exec, secret) {
389        (false, _) => None,
390        (true, Some(secret)) => {
391            Some(bundle::SCRIPT_EXEC_TEMPLATE.replace("__IGN_CLI_SECRET__", secret))
392        }
393        (true, None) => {
394            return Err(CoreError::Internal(
395                "scriptExec deploy requires a substituted secret — the deploy \
396                 action generates the secret before packing (fail-closed guard)"
397                    .into(),
398            ));
399        }
400    };
401
402    let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
403    let options = zip::write::SimpleFileOptions::default()
404        .compression_method(zip::CompressionMethod::Deflated);
405
406    for (name, contents) in bundle::ROUTE_FILES {
407        let body = if *name == "project.json" && project_title != DEFAULT_PROJECT {
408            retitle_project_json(contents, project_title)?
409        } else {
410            (*contents).to_string()
411        };
412        writer.start_file(*name, options).map_err(zip_write_err)?;
413        writer.write_all(body.as_bytes()).map_err(|err| {
414            CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
415        })?;
416    }
417
418    if let Some(script_exec_py) = script_exec_py {
419        for (name, body) in [
420            (
421                format!("{SCRIPT_EXEC_ROUTE_ROOT}/resource.json"),
422                SCRIPT_EXEC_RESOURCE_JSON.to_string(),
423            ),
424            (
425                format!("{SCRIPT_EXEC_ROUTE_ROOT}/config.json"),
426                SCRIPT_EXEC_CONFIG_JSON.to_string(),
427            ),
428            (
429                format!("{SCRIPT_EXEC_ROUTE_ROOT}/doPost.py"),
430                script_exec_py,
431            ),
432        ] {
433            writer
434                .start_file(name.as_str(), options)
435                .map_err(zip_write_err)?;
436            writer.write_all(body.as_bytes()).map_err(|err| {
437                CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
438            })?;
439        }
440    }
441
442    // The testing bundle (ADOPT-04): every member's marker substituted
443    // with the deploy project — an unsubstituted marker on a gateway
444    // is the bug the count-pin exists to catch before it ships.
445    if with_testing {
446        let marker_total: usize = bundle::testing::TESTING_FILES
447            .iter()
448            .map(|(_, contents)| contents.matches(bundle::testing::PROJECT_MARKER).count())
449            .sum();
450        if marker_total != bundle::testing::PROJECT_MARKER_COUNT {
451            return Err(CoreError::Internal(format!(
452                "testing bundle marker count drifted: found {marker_total}, pinned {} — \
453                 a template edit changed the substitution contract",
454                bundle::testing::PROJECT_MARKER_COUNT
455            )));
456        }
457        for (name, contents) in bundle::testing::TESTING_FILES {
458            let body = contents.replace(bundle::testing::PROJECT_MARKER, project_title);
459            writer.start_file(*name, options).map_err(zip_write_err)?;
460            writer.write_all(body.as_bytes()).map_err(|err| {
461                CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
462            })?;
463        }
464    }
465
466    writer
467        .finish()
468        .map_err(zip_write_err)
469        .map(|cursor| cursor.into_inner())
470}
471
472fn zip_write_err(err: zip::result::ZipError) -> CoreError {
473    CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
474}
475
476/// Swap `project.json`'s `title` for a `--project` override — only
477/// `title` moves (name/description/enabled/parent ride verbatim; the
478/// import NAME is the URL's concern, not the manifest's).
479fn retitle_project_json(project_json: &str, title: &str) -> Result<String, CoreError> {
480    let mut value: Value = serde_json::from_str(project_json).map_err(|err| {
481        CoreError::Internal(format!("embedded project.json does not parse: {err}"))
482    })?;
483    value["title"] = Value::String(title.to_string());
484    serde_json::to_string(&value)
485        .map_err(|err| CoreError::Internal(format!("cannot re-serialize project.json: {err}")))
486}
487
488#[cfg(test)]
489mod tests {
490    use super::{
491        DEFAULT_PROJECT, RouteBody, always_on_routes, build_deploy_zip, denial_to_error,
492        parse_route_body,
493    };
494    use crate::error::CoreError;
495
496    /// The route list is DERIVED from the manifest — the four
497    /// always-on folders, manifest order, no drift possible.
498    #[test]
499    fn always_on_routes_derive_from_the_manifest() {
500        assert_eq!(
501            always_on_routes(),
502            vec![
503                "tags".to_string(),
504                "tagConfig".to_string(),
505                "alarms".to_string(),
506                "tagHistory".to_string(),
507            ]
508        );
509    }
510
511    /// Envelope parsing: ok:true yields data; ok:false yields the
512    /// code+message; a non-envelope body is internal-class; missing
513    /// error fields degrade to the generic contract code.
514    #[test]
515    fn parse_route_body_envelope_shapes() {
516        match parse_route_body(r#"{"ok":true,"data":{"routeVersion":"1.0.0"}}"#)
517            .expect("ok body parses")
518        {
519            RouteBody::Ok(data) => {
520                assert_eq!(data["routeVersion"], "1.0.0");
521            }
522            other => panic!("wrong verdict: {other:?}"),
523        }
524
525        match parse_route_body(
526            r#"{"ok":false,"error":{"code":"secret_mismatch","message":"nope"}}"#,
527        )
528        .expect("denial parses")
529        {
530            RouteBody::Denied {
531                code,
532                message,
533                traceback,
534            } => {
535                assert_eq!(code, "secret_mismatch");
536                assert_eq!(message, "nope");
537                assert!(traceback.is_none(), "no traceback on the wire");
538            }
539            other => panic!("wrong verdict: {other:?}"),
540        }
541
542        // A denial carrying the optional traceback keeps it (the
543        // black-box fix's parse half).
544        match parse_route_body(
545            r#"{"ok":false,"error":{"code":"route_error","message":"boom","traceback":"Traceback (most recent call last):\n  ValueError: nope"}}"#,
546        )
547        .expect("denial with traceback parses")
548        {
549            RouteBody::Denied { code, traceback, .. } => {
550                assert_eq!(code, "route_error");
551                assert_eq!(
552                    traceback.as_deref(),
553                    Some("Traceback (most recent call last):\n  ValueError: nope")
554                );
555            }
556            other => panic!("wrong verdict: {other:?}"),
557        }
558
559        // ok:true without data → Null (routes may answer bare oks).
560        match parse_route_body(r#"{"ok":true}"#).expect("bare ok parses") {
561            RouteBody::Ok(data) => assert!(data.is_null()),
562            other => panic!("wrong verdict: {other:?}"),
563        }
564
565        // ok:false without an error object → the generic code, never
566        // a guess.
567        match parse_route_body(r#"{"ok":false}"#).expect("bare denial parses") {
568            RouteBody::Denied { code, .. } => assert_eq!(code, "route_error"),
569            other => panic!("wrong verdict: {other:?}"),
570        }
571
572        let err = parse_route_body("<html>jetty</html>").expect_err("non-envelope fails");
573        assert!(matches!(err, CoreError::Internal(_)), "{err}");
574    }
575
576    /// The taxonomy mapping: `not_found` reuses the existing slug;
577    /// everything else (known secret codes included) rides
578    /// `webdev_route_error` verbatim; a traceback appends to the
579    /// message (`\nroute traceback: {tb}`) while its absence keeps
580    /// the message byte-identical.
581    #[test]
582    fn denial_mapping_reuses_not_found_and_rides_the_rest() {
583        let not_found = denial_to_error("not_found", "no such path", None, "/x".into());
584        assert_eq!(not_found.code(), "not_found");
585        assert_eq!(not_found.exit_code(), 6);
586
587        let secret = denial_to_error("secret_required", "missing header", None, "/x".into());
588        assert_eq!(secret.code(), "webdev_route_error");
589        assert_eq!(secret.exit_code(), 6);
590        assert!(secret.to_string().contains("secret_required"));
591        assert!(
592            secret.to_string().contains("missing header"),
593            "no traceback → the message rides VERBATIM (no suffix)"
594        );
595
596        // THE black-box fix: a denial with a traceback shows it —
597        // the "Invalid UUID string" class is diagnosable from CLI
598        // output alone.
599        let blown = denial_to_error(
600            "route_error",
601            "error processing action",
602            Some("java.lang.IllegalArgumentException: Invalid UUID string: 3f2504e0"),
603            "/x".into(),
604        );
605        assert_eq!(blown.code(), "webdev_route_error");
606        let text = blown.to_string();
607        assert!(
608            text.contains("\nroute traceback: java.lang.IllegalArgumentException: Invalid UUID string: 3f2504e0"),
609            "the traceback rides the message: {text}"
610        );
611
612        // The alarms route's journal-missing denial maps to the
613        // actionable slug (default rigs ALWAYS hit it — the missing
614        // journal chain is target state, not a route bug).
615        let journal = denial_to_error(
616            "no_alarm_journal",
617            "No alarm journal profile specified",
618            None,
619            "/system/webdev/ign-cli/cli/alarms".into(),
620        );
621        assert_eq!(journal.code(), "alarm_journal_missing");
622        assert_eq!(journal.exit_code(), 6);
623        assert!(
624            journal.hint().unwrap().contains("journal profile"),
625            "hint names the chain: {journal}"
626        );
627
628        // The tagConfig route's provider-root denial (07-06): the
629        // dedicated slug + exit 6, the message names the subtree
630        // workaround (the fixed Display — the route's own message
631        // stays consistent with it).
632        let root = denial_to_error(
633            "provider_root_unsupported",
634            "provider-root tag paths are not supported on WebDev threads (no RpcContext) -- use a subtree path like [provider]folder",
635            None,
636            "/system/webdev/ign-cli/cli/tagConfig".into(),
637        );
638        assert_eq!(root.code(), "provider_root_unsupported");
639        assert_eq!(root.exit_code(), 6);
640        assert!(
641            root.to_string().contains("subtree like [provider]folder"),
642            "the fixed Display names the subtree workaround: {root}"
643        );
644    }
645
646    /// THE fail-closed guard: scriptExec packing demands a secret —
647    /// `None` + `with_script_exec` refuses BEFORE any zip is built.
648    #[test]
649    fn deploy_zip_fails_closed_without_a_script_exec_secret() {
650        let err = build_deploy_zip(DEFAULT_PROJECT, true, None, false).expect_err("must refuse");
651        assert!(matches!(err, CoreError::Internal(_)), "{err}");
652        assert_eq!(err.exit_code(), 1);
653    }
654}