1use std::io::Read;
32use std::path::Path;
33
34use serde::Serialize;
35
36use crate::client::GatewayApi;
37use crate::client::webdev::{self as seam, RouteProbe};
38use crate::config;
39use crate::error::CoreError;
40use crate::webdev as bundle;
41
42pub(crate) const SCRIPT_EXEC_ROUTE: &str = "scriptExec";
47
48pub(crate) const SECRET_HEADER: &str = "X-Ignition-CLI-Secret";
52
53#[derive(Debug, Serialize)]
57pub struct WebdevDeployResult {
58 pub project: String,
60 pub routes: Vec<String>,
63 pub script_exec: bool,
65 pub secret_rotated: bool,
68 pub import: serde_json::Value,
71}
72
73#[derive(Debug, Clone, PartialEq, Serialize)]
75pub struct RouteStatusRow {
76 pub route: String,
78 pub status: RouteStatus,
80 pub deployed_version: Option<String>,
82 pub expected_version: Option<String>,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
89#[serde(rename_all = "snake_case")]
90pub enum RouteStatus {
91 Present,
93 Absent,
95 Unlicensed,
97 AuthGated,
99 SecretMismatch,
102 VersionMismatch,
104}
105
106#[derive(Debug, Serialize)]
108pub struct WebdevStatusResult {
109 pub project: String,
111 pub routes: Vec<RouteStatusRow>,
114 pub ok: bool,
118}
119
120pub async fn webdev_deploy(
131 api: &dyn GatewayApi,
132 project: &str,
133 with_script_exec: bool,
134 rotate_secret: bool,
135 config_path: &Path,
136 profile_name: &str,
137) -> Result<WebdevDeployResult, CoreError> {
138 let mut config = config::load(config_path)?;
141 let existing = config
142 .profiles
143 .get(profile_name)
144 .and_then(|profile| profile.webdev_secret.clone());
145 let (pack_secret, secret_rotated) = if rotate_secret || (with_script_exec && existing.is_none())
146 {
147 let secret = generate_secret()?;
148 config
149 .profiles
150 .get_mut(profile_name)
151 .ok_or_else(|| {
152 CoreError::Internal(format!(
153 "profile {profile_name:?} vanished from the config between dispatch and deploy"
154 ))
155 })?
156 .webdev_secret = Some(secret.clone());
157 config::save(config_path, &config)?; (Some(secret), true)
159 } else if with_script_exec {
160 (existing, false)
161 } else {
162 (None, false)
163 };
164
165 let zip = seam::build_deploy_zip(project, with_script_exec, pack_secret.as_deref())?;
170 let mut routes = seam::always_on_routes();
171 if with_script_exec {
172 routes.push(SCRIPT_EXEC_ROUTE.to_string());
173 }
174 let import = api.project_import(project, zip, true).await?;
175
176 Ok(WebdevDeployResult {
177 project: project.to_string(),
178 routes,
179 script_exec: with_script_exec,
180 secret_rotated,
181 import: import.response,
182 })
183}
184
185pub async fn webdev_status(
190 api: &dyn GatewayApi,
191 project: &str,
192 secret: Option<&str>,
193) -> Result<WebdevStatusResult, CoreError> {
194 let mut routes = Vec::new();
195 let mut ok = true;
196 for route in seam::always_on_routes() {
197 let probe = api.webdev_route_probe(project, &route, &[]).await?;
198 let row = classify_probe(&route, probe);
199 ok &= row.status == RouteStatus::Present;
200 routes.push(row);
201 }
202 if let Some(secret) = secret {
203 let probe = api
204 .webdev_route_probe(project, SCRIPT_EXEC_ROUTE, &[(SECRET_HEADER, secret)])
205 .await?;
206 routes.push(classify_probe(SCRIPT_EXEC_ROUTE, probe));
208 }
209 Ok(WebdevStatusResult {
210 project: project.to_string(),
211 routes,
212 ok,
213 })
214}
215
216pub async fn webdev_precondition(api: &dyn GatewayApi, project: &str) -> Result<(), CoreError> {
223 const ROUTE: &str = "tags";
224 let endpoint = seam::route_url(project, ROUTE);
225 match api.webdev_route_probe(project, ROUTE, &[]).await? {
226 RouteProbe::Present { route_version } if route_version == bundle::ROUTE_BUNDLE_VERSION => {
227 Ok(())
228 }
229 RouteProbe::Present { route_version } => Err(CoreError::RouteVersionMismatch {
230 route: ROUTE.to_string(),
231 deployed: route_version,
232 expected: bundle::ROUTE_BUNDLE_VERSION.to_string(),
233 endpoint: Some(endpoint),
234 }),
235 RouteProbe::Absent => Err(CoreError::RoutesNotDeployed {
236 project: project.to_string(),
237 route: ROUTE.to_string(),
238 endpoint: Some(endpoint),
239 }),
240 RouteProbe::Unlicensed => Err(CoreError::WebdevUnlicensed {
241 endpoint: Some(endpoint),
242 }),
243 RouteProbe::AuthGated => Err(CoreError::Auth {
244 status: 401,
245 endpoint: Some(endpoint),
246 }),
247 RouteProbe::Denied {
248 code,
249 message,
250 traceback,
251 } => {
252 let mut full = message;
253 if let Some(traceback) = traceback {
254 full.push_str("\nroute traceback: ");
255 full.push_str(&traceback);
256 }
257 Err(CoreError::WebdevRouteError {
258 code,
259 message: full,
260 endpoint: Some(endpoint),
261 })
262 }
263 }
264}
265
266fn classify_probe(route: &str, probe: RouteProbe) -> RouteStatusRow {
269 let expected = bundle::ROUTE_BUNDLE_VERSION;
270 match probe {
271 RouteProbe::Present { route_version } => {
272 let status = if route_version == expected {
273 RouteStatus::Present
274 } else {
275 RouteStatus::VersionMismatch
276 };
277 RouteStatusRow {
278 route: route.to_string(),
279 status,
280 deployed_version: Some(route_version),
281 expected_version: Some(expected.to_string()),
282 }
283 }
284 RouteProbe::Denied { code, .. } => {
285 let status = if code == "secret_required" || code == "secret_mismatch" {
291 RouteStatus::SecretMismatch
292 } else {
293 RouteStatus::VersionMismatch
294 };
295 absent_row(route, status)
296 }
297 RouteProbe::Absent => absent_row(route, RouteStatus::Absent),
298 RouteProbe::Unlicensed => absent_row(route, RouteStatus::Unlicensed),
299 RouteProbe::AuthGated => absent_row(route, RouteStatus::AuthGated),
300 }
301}
302
303fn absent_row(route: &str, status: RouteStatus) -> RouteStatusRow {
305 RouteStatusRow {
306 route: route.to_string(),
307 status,
308 deployed_version: None,
309 expected_version: Some(bundle::ROUTE_BUNDLE_VERSION.to_string()),
310 }
311}
312
313fn generate_secret() -> Result<String, CoreError> {
317 let mut bytes = [0u8; 32];
318 let mut source = std::fs::File::open("/dev/urandom").map_err(|err| {
319 CoreError::Internal(format!(
320 "cannot open /dev/urandom for secret generation: {err}"
321 ))
322 })?;
323 source
324 .read_exact(&mut bytes)
325 .map_err(|err| CoreError::Internal(format!("cannot read /dev/urandom: {err}")))?;
326 Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
327}
328
329#[cfg(test)]
330mod tests {
331 use super::{
332 RouteProbe, RouteStatus, WebdevDeployResult, generate_secret, webdev_deploy,
333 webdev_precondition, webdev_status,
334 };
335 use crate::client::GatewayApi;
336 use crate::client::projects::ImportOutcome;
337 use crate::error::CoreError;
338 use crate::webdev::ROUTE_BUNDLE_VERSION as BUNDLE_VERSION;
339 use std::path::PathBuf;
340
341 struct WebdevRig {
347 probe: fn(&str) -> Result<RouteProbe, CoreError>,
348 import: Box<dyn Fn(Vec<u8>, bool) -> Result<ImportOutcome, CoreError> + Send + Sync>,
349 }
350
351 fn present(version: &str) -> Result<RouteProbe, CoreError> {
352 Ok(RouteProbe::Present {
353 route_version: version.to_string(),
354 })
355 }
356
357 fn ok_import() -> ImportOutcome {
358 ImportOutcome {
359 response: serde_json::json!({"success": true}),
360 }
361 }
362
363 #[async_trait::async_trait]
364 impl GatewayApi for WebdevRig {
365 async fn tag_provider_list(
366 &self,
367 _query: &crate::client::query::ListQuery,
368 ) -> Result<
369 crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
370 CoreError,
371 > {
372 unreachable!("not part of this action")
373 }
374 async fn tag_provider_find(
375 &self,
376 _name: &str,
377 ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
378 unreachable!("not part of this action")
379 }
380 async fn tag_provider_create(
381 &self,
382 _body: &[crate::client::tags::TagProviderCreate],
383 ) -> Result<(), CoreError> {
384 unreachable!("not part of this action")
385 }
386 async fn tag_provider_delete(
387 &self,
388 _name: &str,
389 _signature: &str,
390 ) -> Result<(), CoreError> {
391 unreachable!("not part of this action")
392 }
393 async fn webdev_route_probe(
394 &self,
395 _project: &str,
396 route: &str,
397 _extra_headers: &[(&str, &str)],
398 ) -> Result<RouteProbe, CoreError> {
399 (self.probe)(route)
400 }
401 async fn project_import(
402 &self,
403 _name: &str,
404 zip: Vec<u8>,
405 overwrite: bool,
406 ) -> Result<ImportOutcome, CoreError> {
407 (self.import)(zip, overwrite)
408 }
409 async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
410 unreachable!("not part of this action")
411 }
412 async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
413 unreachable!("not part of this action")
414 }
415 async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
416 unreachable!("not part of this action")
417 }
418 async fn modules(
419 &self,
420 _quarantined: bool,
421 _query: &crate::client::query::ListQuery,
422 ) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
423 {
424 unreachable!("not part of this action")
425 }
426 async fn metrics_current(
427 &self,
428 ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
429 unreachable!("not part of this action")
430 }
431 async fn metrics_historic(
432 &self,
433 ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
434 unreachable!("not part of this action")
435 }
436 async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
437 unreachable!("not part of this action")
438 }
439 async fn designers(
440 &self,
441 _query: &crate::client::query::ListQuery,
442 ) -> Result<
443 crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
444 CoreError,
445 > {
446 unreachable!("not part of this action")
447 }
448 async fn perspective_sessions(
449 &self,
450 _query: &crate::client::query::ListQuery,
451 ) -> Result<
452 crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
453 CoreError,
454 > {
455 unreachable!("not part of this action")
456 }
457 async fn vision_clients(
458 &self,
459 _query: &crate::client::query::ListQuery,
460 ) -> Result<
461 crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
462 CoreError,
463 > {
464 unreachable!("not part of this action")
465 }
466 async fn terminate_perspective_session(
467 &self,
468 _id: &str,
469 _message: Option<&str>,
470 ) -> Result<(), CoreError> {
471 unreachable!("not part of this action")
472 }
473 async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
474 unreachable!("not part of this action")
475 }
476 async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
477 unreachable!("not part of this action")
478 }
479 async fn database_connections(
480 &self,
481 ) -> Result<
482 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
483 CoreError,
484 > {
485 unreachable!("not part of this action")
486 }
487 async fn opc_connections(
488 &self,
489 ) -> Result<
490 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
491 CoreError,
492 > {
493 unreachable!("not part of this action")
494 }
495 async fn logs(
496 &self,
497 _filter: &crate::client::logs::LogQuery,
498 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
499 {
500 unreachable!("not part of this action")
501 }
502 async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
503 unreachable!("not part of this action")
504 }
505 async fn loggers(
506 &self,
507 _query: &crate::client::query::ListQuery,
508 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
509 {
510 unreachable!("not part of this action")
511 }
512 async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
513 unreachable!("not part of this action")
514 }
515 async fn reset_logger_levels(&self) -> Result<(), CoreError> {
516 unreachable!("not part of this action")
517 }
518 async fn restart(&self) -> Result<(), CoreError> {
519 unreachable!("not part of this action")
520 }
521 async fn scan_projects(&self) -> Result<(), CoreError> {
522 unreachable!("not part of this action")
523 }
524 async fn security_properties(
525 &self,
526 ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
527 unreachable!("not part of this action")
528 }
529 async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
530 unreachable!("not part of this action")
531 }
532 async fn webdev_route_call(
533 &self,
534 _project: &str,
535 _route: &str,
536 _body: &serde_json::Value,
537 _extra_headers: &[(&str, &str)],
538 ) -> Result<serde_json::Value, CoreError> {
539 unreachable!("not part of this action")
540 }
541 async fn projects(
542 &self,
543 _query: &crate::client::query::ListQuery,
544 ) -> Result<
545 crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
546 CoreError,
547 > {
548 unreachable!("not part of this action")
549 }
550 async fn project_find(
551 &self,
552 _name: &str,
553 ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
554 unreachable!("not part of this action")
555 }
556 async fn project_create(
557 &self,
558 _body: &crate::client::projects::ProjectCreate,
559 ) -> Result<(), CoreError> {
560 unreachable!("not part of this action")
561 }
562 async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
563 unreachable!("not part of this action")
564 }
565 async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
566 unreachable!("not part of this action")
567 }
568 async fn project_modify(
569 &self,
570 _name: &str,
571 _body: &crate::client::projects::ProjectModify,
572 ) -> Result<(), CoreError> {
573 unreachable!("not part of this action")
574 }
575 async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
576 unreachable!("not part of this action")
577 }
578 async fn project_export_to_file(
579 &self,
580 _name: &str,
581 _out: &std::path::Path,
582 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
583 unreachable!("not part of this action")
584 }
585 async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
586 unreachable!("not part of this action")
587 }
588 async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
589 unreachable!("not part of this action")
590 }
591 async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
592 unreachable!("not part of this action")
593 }
594 async fn backup_download(
595 &self,
596 _out: &std::path::Path,
597 _backup_type: crate::client::backup::BackupType,
598 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
599 unreachable!("not part of this action")
600 }
601 async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
602 unreachable!("not part of this action")
603 }
604 async fn eam_task_history(
605 &self,
606 _limit: Option<u32>,
607 _search: Option<&str>,
608 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
609 {
610 unreachable!("not part of this action")
611 }
612 async fn eam_task_definitions(
613 &self,
614 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
615 {
616 unreachable!("not part of this action")
617 }
618 async fn eam_task_find(
619 &self,
620 _name: &str,
621 ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
622 unreachable!("not part of this action")
623 }
624 async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
625 unreachable!("not part of this action")
626 }
627 async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
628 unreachable!("not part of this action")
629 }
630 }
631
632 fn temp_config() -> (tempfile::TempDir, PathBuf) {
635 let dir = tempfile::tempdir().expect("tempdir");
636 let path = dir.path().join("config.toml");
637 std::fs::write(
638 &path,
639 "active = \"dev\"\n\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n",
640 )
641 .expect("write config");
642 (dir, path)
643 }
644
645 fn stored_secret(path: &std::path::Path) -> Option<String> {
647 crate::config::load(path)
648 .expect("config reloads")
649 .profiles
650 .get("dev")
651 .and_then(|profile| profile.webdev_secret.clone())
652 }
653
654 fn importing_rig() -> WebdevRig {
656 WebdevRig {
657 probe: |_| unreachable!("deploy never probes"),
658 import: Box::new(|_zip, _overwrite| Ok(ok_import())),
659 }
660 }
661
662 #[tokio::test]
665 async fn deploy_without_script_exec_ships_only_the_always_on_bundle() {
666 let (dir, config) = temp_config();
667 let seen_zip = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
668 let recorder = std::sync::Arc::clone(&seen_zip);
669 let rig = WebdevRig {
670 probe: |_| unreachable!("deploy never probes"),
671 import: Box::new(move |zip, overwrite| {
672 assert!(overwrite, "deploy ALWAYS overwrite-imports");
673 *recorder.lock().expect("zip lock") = zip;
674 Ok(ok_import())
675 }),
676 };
677 let result = webdev_deploy(&rig, "ign-cli", false, false, &config, "dev")
678 .await
679 .expect("plain deploy");
680 assert_eq!(
681 result.routes,
682 vec!["tags", "tagConfig", "alarms", "tagHistory"]
683 );
684 assert!(!result.script_exec);
685 assert!(!result.secret_rotated);
686 assert_eq!(result.import["success"], true);
687
688 let names = member_names(&seen_zip.lock().expect("zip lock"));
691 assert!(names.iter().all(|name| !name.contains("scriptExec")));
692 assert_eq!(stored_secret(&config), None);
693 let _ = dir; }
695
696 #[tokio::test]
700 async fn deploy_with_script_exec_generates_and_redacts_the_secret() {
701 let (dir, config) = temp_config();
702 let seen_zip = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
703 let recorder = std::sync::Arc::clone(&seen_zip);
704 let rig = WebdevRig {
705 probe: |_| unreachable!("deploy never probes"),
706 import: Box::new(move |zip, _| {
707 *recorder.lock().expect("zip lock") = zip;
708 Ok(ok_import())
709 }),
710 };
711 let result = webdev_deploy(&rig, "ign-cli", true, false, &config, "dev")
712 .await
713 .expect("scriptExec deploy");
714 assert_eq!(
715 result.routes,
716 vec!["tags", "tagConfig", "alarms", "tagHistory", "scriptExec"]
717 );
718 assert!(result.script_exec && result.secret_rotated);
719
720 let secret = stored_secret(&config).expect("secret persisted");
721 assert_eq!(secret.len(), 64, "32 bytes hex-encoded");
722 assert!(
723 secret.chars().all(|c| c.is_ascii_hexdigit()),
724 "hex alphabet: {secret}"
725 );
726 #[cfg(unix)]
727 {
728 use std::os::unix::fs::PermissionsExt;
729 let mode = std::fs::metadata(&config)
730 .expect("config stat")
731 .permissions()
732 .mode();
733 assert_eq!(mode & 0o777, 0o600, "the save path re-asserts 0600");
734 }
735
736 let do_post = member(
739 &seen_zip.lock().expect("zip lock"),
740 "com.inductiveautomation.webdev/resources/cli/scriptExec/doPost.py",
741 );
742 assert!(String::from_utf8_lossy(&do_post).contains(&secret));
743 let serialized = serde_json::to_string(&result).expect("result serializes");
744 assert!(!serialized.contains(&secret), "redaction: {serialized}");
745 let envelope_check: WebdevDeployResult = result;
746 let again = serde_json::to_string(&envelope_check).expect("serializes");
747 assert!(!again.contains(&secret));
748 let _ = dir;
749 }
750
751 #[tokio::test]
754 async fn rotate_regenerates_and_plain_redeploy_reuses() {
755 let (dir, config) = temp_config();
756 let mut seeded = crate::config::load(&config).expect("load");
758 seeded.profiles.get_mut("dev").unwrap().webdev_secret = Some("aa11".into());
759 crate::config::save(&config, &seeded).expect("seed save");
760
761 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
763 let recorder = std::sync::Arc::clone(&seen);
764 let rig = WebdevRig {
765 probe: |_| unreachable!(),
766 import: Box::new(move |zip, _| {
767 *recorder.lock().expect("lock") = zip;
768 Ok(ok_import())
769 }),
770 };
771 let result = webdev_deploy(&rig, "ign-cli", true, false, &config, "dev")
772 .await
773 .expect("reuse deploy");
774 assert!(!result.secret_rotated);
775 assert_eq!(stored_secret(&config).as_deref(), Some("aa11"));
776 let seen = seen.lock().expect("lock").clone();
777 let do_post = member(
778 &seen,
779 "com.inductiveautomation.webdev/resources/cli/scriptExec/doPost.py",
780 );
781 assert!(
782 String::from_utf8_lossy(&do_post).contains("aa11"),
783 "the STORED secret rode the zip's scriptExec member"
784 );
785
786 let result = webdev_deploy(&importing_rig(), "ign-cli", true, true, &config, "dev")
788 .await
789 .expect("rotate deploy");
790 assert!(result.secret_rotated);
791 let rotated = stored_secret(&config).expect("rotated secret stored");
792 assert_eq!(rotated.len(), 64);
793 assert_ne!(rotated, "aa11");
794 let _ = dir;
795 }
796
797 #[tokio::test]
801 async fn status_maps_the_full_probe_matrix() {
802 let healthy = WebdevRig {
804 probe: |_| present(BUNDLE_VERSION),
805 import: Box::new(|_, _| unreachable!("status never imports")),
806 };
807 let result = webdev_status(&healthy, "ign-cli", None)
808 .await
809 .expect("status sweep");
810 assert_eq!(result.routes.len(), 4);
811 assert!(
812 result
813 .routes
814 .iter()
815 .all(|row| row.status == RouteStatus::Present)
816 );
817 assert!(result.ok);
818
819 let degraded = WebdevRig {
822 probe: |route| {
823 Ok(match route {
824 "tags" => RouteProbe::Absent,
825 "tagConfig" => present("9.9.9").expect("fixture"),
826 "alarms" => present(BUNDLE_VERSION).expect("fixture"),
827 "tagHistory" => RouteProbe::Unlicensed,
828 "scriptExec" => RouteProbe::Denied {
829 code: "secret_mismatch".into(),
830 message: "mismatch".into(),
831 traceback: None,
832 },
833 _ => RouteProbe::AuthGated,
834 })
835 },
836 import: Box::new(|_, _| unreachable!()),
837 };
838 let result = webdev_status(°raded, "ign-cli", Some("stored-secret"))
839 .await
840 .expect("degraded sweep still completes");
841 let by_route = |name: &str| {
842 result
843 .routes
844 .iter()
845 .find(|row| row.route == name)
846 .unwrap_or_else(|| panic!("{name} row"))
847 };
848 assert_eq!(by_route("tags").status, RouteStatus::Absent);
849 assert_eq!(by_route("tagConfig").status, RouteStatus::VersionMismatch);
850 assert_eq!(
851 by_route("tagConfig").deployed_version.as_deref(),
852 Some("9.9.9")
853 );
854 assert_eq!(by_route("tagHistory").status, RouteStatus::Unlicensed);
855 assert_eq!(by_route("scriptExec").status, RouteStatus::SecretMismatch);
856 assert!(!result.ok);
857 let gated_exec = WebdevRig {
860 probe: |route| {
861 if route == "scriptExec" {
862 Ok(RouteProbe::AuthGated)
863 } else {
864 present(BUNDLE_VERSION)
865 }
866 },
867 import: Box::new(|_, _| unreachable!()),
868 };
869 let result = webdev_status(&gated_exec, "ign-cli", Some("s"))
870 .await
871 .expect("sweep");
872 assert!(result.ok, "scriptExec never gates ok");
873 assert_eq!(result.routes.len(), 5);
874 }
875
876 #[tokio::test]
880 async fn precondition_refuses_the_undeployed_and_mismatched() {
881 let undeployed = WebdevRig {
882 probe: |_| Ok(RouteProbe::Absent),
883 import: Box::new(|_, _| unreachable!()),
884 };
885 let err = webdev_precondition(&undeployed, "ign-cli")
886 .await
887 .expect_err("absent refuses");
888 assert_eq!(err.code(), "routes_not_deployed");
889 assert_eq!(err.exit_code(), 6);
890 assert!(
891 err.hint().unwrap().contains("ign webdev deploy"),
892 "hint names the fix"
893 );
894
895 let older = WebdevRig {
896 probe: |_| present("0.9.0"),
897 import: Box::new(|_, _| unreachable!()),
898 };
899 let err = webdev_precondition(&older, "ign-cli")
900 .await
901 .expect_err("older refuses");
902 assert_eq!(err.code(), "route_version_mismatch");
903 assert!(
904 err.to_string().contains("0.9.0") && err.to_string().contains(BUNDLE_VERSION),
905 "both versions named: {err}"
906 );
907
908 let matching = WebdevRig {
909 probe: |_| present(BUNDLE_VERSION),
910 import: Box::new(|_, _| unreachable!()),
911 };
912 webdev_precondition(&matching, "ign-cli")
913 .await
914 .expect("matching handshake passes");
915 }
916
917 #[test]
920 fn generated_secrets_are_hex_and_unique() {
921 let a = generate_secret().expect("secret");
922 let b = generate_secret().expect("secret");
923 assert_eq!(a.len(), 64);
924 assert_ne!(a, b);
925 }
926
927 fn member_names(zip_bytes: &[u8]) -> Vec<String> {
928 let mut archive =
929 zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).expect("built zip is readable");
930 (0..archive.len())
931 .map(|index| archive.by_index(index).expect("member").name().to_string())
932 .collect()
933 }
934
935 fn member(zip_bytes: &[u8], name: &str) -> Vec<u8> {
936 let mut archive =
937 zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).expect("built zip is readable");
938 let mut file = archive.by_name(name).expect("member present");
939 let mut bytes = Vec::new();
940 std::io::Read::read_to_end(&mut file, &mut bytes).expect("member reads");
941 bytes
942 }
943}