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}