1use std::path::Path;
32
33use serde::Serialize;
34
35use crate::client::GatewayApi;
36use crate::client::webdev::{self as seam, RouteProbe};
37use crate::config;
38use crate::error::CoreError;
39use crate::webdev as bundle;
40
41pub(crate) const SCRIPT_EXEC_ROUTE: &str = "scriptExec";
46
47pub(crate) const SECRET_HEADER: &str = "X-Ignition-CLI-Secret";
51
52#[derive(Debug, Serialize)]
56pub struct WebdevDeployResult {
57 pub project: String,
59 pub routes: Vec<String>,
62 pub script_exec: bool,
64 pub secret_rotated: bool,
67 pub import: serde_json::Value,
70}
71
72#[derive(Debug, Clone, PartialEq, Serialize)]
74pub struct RouteStatusRow {
75 pub route: String,
77 pub status: RouteStatus,
79 pub deployed_version: Option<String>,
81 pub expected_version: Option<String>,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
88#[serde(rename_all = "snake_case")]
89pub enum RouteStatus {
90 Present,
92 Absent,
94 Unlicensed,
96 AuthGated,
98 SecretMismatch,
101 VersionMismatch,
103}
104
105#[derive(Debug, Serialize)]
107pub struct WebdevStatusResult {
108 pub project: String,
110 pub routes: Vec<RouteStatusRow>,
113 pub ok: bool,
117}
118
119pub async fn webdev_deploy(
130 api: &dyn GatewayApi,
131 project: &str,
132 with_script_exec: bool,
133 rotate_secret: bool,
134 config_path: &Path,
135 profile_name: &str,
136 with_testing: bool,
137) -> Result<WebdevDeployResult, CoreError> {
138 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(
170 project,
171 with_script_exec,
172 pack_secret.as_deref(),
173 with_testing,
174 )?;
175 let mut routes = seam::always_on_routes();
176 if with_script_exec {
177 routes.push(SCRIPT_EXEC_ROUTE.to_string());
178 }
179 if with_testing {
180 routes.extend(
181 crate::webdev::testing::TESTING_ROUTES
182 .iter()
183 .map(|route| format!("testing/{route}")),
184 );
185 }
186 let import = api.project_import(project, zip, true).await?;
187
188 Ok(WebdevDeployResult {
189 project: project.to_string(),
190 routes,
191 script_exec: with_script_exec,
192 secret_rotated,
193 import: import.response,
194 })
195}
196
197pub async fn webdev_status(
202 api: &dyn GatewayApi,
203 project: &str,
204 secret: Option<&str>,
205) -> Result<WebdevStatusResult, CoreError> {
206 let mut routes = Vec::new();
207 let mut ok = true;
208 for route in seam::always_on_routes() {
209 let probe = api.webdev_route_probe(project, &route, &[]).await?;
210 let row = classify_probe(&route, probe);
211 ok &= row.status == RouteStatus::Present;
212 routes.push(row);
213 }
214 if let Some(secret) = secret {
215 let probe = api
216 .webdev_route_probe(project, SCRIPT_EXEC_ROUTE, &[(SECRET_HEADER, secret)])
217 .await?;
218 routes.push(classify_probe(SCRIPT_EXEC_ROUTE, probe));
220 }
221 Ok(WebdevStatusResult {
222 project: project.to_string(),
223 routes,
224 ok,
225 })
226}
227
228pub async fn webdev_precondition(api: &dyn GatewayApi, project: &str) -> Result<(), CoreError> {
235 const ROUTE: &str = "tags";
236 let endpoint = seam::route_url(project, ROUTE);
237 match api.webdev_route_probe(project, ROUTE, &[]).await? {
238 RouteProbe::Present { route_version } if route_version == bundle::ROUTE_BUNDLE_VERSION => {
239 Ok(())
240 }
241 RouteProbe::Present { route_version } => Err(CoreError::RouteVersionMismatch {
242 route: ROUTE.to_string(),
243 deployed: route_version,
244 expected: bundle::ROUTE_BUNDLE_VERSION.to_string(),
245 endpoint: Some(endpoint),
246 }),
247 RouteProbe::Absent => Err(CoreError::RoutesNotDeployed {
248 project: project.to_string(),
249 route: ROUTE.to_string(),
250 endpoint: Some(endpoint),
251 }),
252 RouteProbe::Unlicensed => Err(CoreError::WebdevUnlicensed {
253 endpoint: Some(endpoint),
254 }),
255 RouteProbe::AuthGated => Err(CoreError::Auth {
256 status: 401,
257 endpoint: Some(endpoint),
258 }),
259 RouteProbe::Denied {
260 code,
261 message,
262 traceback,
263 } => {
264 let mut full = message;
265 if let Some(traceback) = traceback {
266 full.push_str("\nroute traceback: ");
267 full.push_str(&traceback);
268 }
269 Err(CoreError::WebdevRouteError {
270 code,
271 message: full,
272 endpoint: Some(endpoint),
273 })
274 }
275 }
276}
277
278fn classify_probe(route: &str, probe: RouteProbe) -> RouteStatusRow {
281 let expected = bundle::ROUTE_BUNDLE_VERSION;
282 match probe {
283 RouteProbe::Present { route_version } => {
284 let status = if route_version == expected {
285 RouteStatus::Present
286 } else {
287 RouteStatus::VersionMismatch
288 };
289 RouteStatusRow {
290 route: route.to_string(),
291 status,
292 deployed_version: Some(route_version),
293 expected_version: Some(expected.to_string()),
294 }
295 }
296 RouteProbe::Denied { code, .. } => {
297 let status = if code == "secret_required" || code == "secret_mismatch" {
303 RouteStatus::SecretMismatch
304 } else {
305 RouteStatus::VersionMismatch
306 };
307 absent_row(route, status)
308 }
309 RouteProbe::Absent => absent_row(route, RouteStatus::Absent),
310 RouteProbe::Unlicensed => absent_row(route, RouteStatus::Unlicensed),
311 RouteProbe::AuthGated => absent_row(route, RouteStatus::AuthGated),
312 }
313}
314
315fn absent_row(route: &str, status: RouteStatus) -> RouteStatusRow {
317 RouteStatusRow {
318 route: route.to_string(),
319 status,
320 deployed_version: None,
321 expected_version: Some(bundle::ROUTE_BUNDLE_VERSION.to_string()),
322 }
323}
324
325fn generate_secret() -> Result<String, CoreError> {
331 let mut bytes = [0u8; 32];
332 getrandom::getrandom(&mut bytes)
333 .map_err(|err| CoreError::Internal(format!("cannot generate the deploy secret: {err}")))?;
334 Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
335}
336
337#[cfg(test)]
338mod tests {
339 use super::{
340 RouteProbe, RouteStatus, WebdevDeployResult, generate_secret, webdev_deploy,
341 webdev_precondition, webdev_status,
342 };
343 use crate::client::GatewayApi;
344 use crate::client::projects::ImportOutcome;
345 use crate::error::CoreError;
346 use crate::webdev::ROUTE_BUNDLE_VERSION as BUNDLE_VERSION;
347 use std::path::PathBuf;
348
349 struct WebdevRig {
355 probe: fn(&str) -> Result<RouteProbe, CoreError>,
356 import: Box<dyn Fn(Vec<u8>, bool) -> Result<ImportOutcome, CoreError> + Send + Sync>,
357 }
358
359 fn present(version: &str) -> Result<RouteProbe, CoreError> {
360 Ok(RouteProbe::Present {
361 route_version: version.to_string(),
362 })
363 }
364
365 fn ok_import() -> ImportOutcome {
366 ImportOutcome {
367 response: serde_json::json!({"success": true}),
368 }
369 }
370
371 #[async_trait::async_trait]
372 impl GatewayApi for WebdevRig {
373 async fn bundle_generate(
374 &self,
375 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
376 unreachable!("not part of this action")
377 }
378 async fn bundle_status(
379 &self,
380 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
381 unreachable!("not part of this action")
382 }
383 async fn bundle_download(
384 &self,
385 _out: &std::path::Path,
386 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
387 unreachable!("not part of this action")
388 }
389 async fn tag_provider_list(
390 &self,
391 _query: &crate::client::query::ListQuery,
392 ) -> Result<
393 crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
394 CoreError,
395 > {
396 unreachable!("not part of this action")
397 }
398 async fn tag_provider_find(
399 &self,
400 _name: &str,
401 ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
402 unreachable!("not part of this action")
403 }
404 async fn tag_provider_create(
405 &self,
406 _body: &[crate::client::tags::TagProviderCreate],
407 ) -> Result<(), CoreError> {
408 unreachable!("not part of this action")
409 }
410 async fn tag_provider_delete(
411 &self,
412 _name: &str,
413 _signature: &str,
414 ) -> Result<(), CoreError> {
415 unreachable!("not part of this action")
416 }
417 async fn webdev_route_probe(
418 &self,
419 _project: &str,
420 route: &str,
421 _extra_headers: &[(&str, &str)],
422 ) -> Result<RouteProbe, CoreError> {
423 (self.probe)(route)
424 }
425 async fn project_import(
426 &self,
427 _name: &str,
428 zip: Vec<u8>,
429 overwrite: bool,
430 ) -> Result<ImportOutcome, CoreError> {
431 (self.import)(zip, overwrite)
432 }
433 async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
434 unreachable!("not part of this action")
435 }
436 async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
437 unreachable!("not part of this action")
438 }
439 async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
440 unreachable!("not part of this action")
441 }
442 async fn modules(
443 &self,
444 _quarantined: bool,
445 _query: &crate::client::query::ListQuery,
446 ) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
447 {
448 unreachable!("not part of this action")
449 }
450 async fn metrics_current(
451 &self,
452 ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
453 unreachable!("not part of this action")
454 }
455 async fn metrics_historic(
456 &self,
457 ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
458 unreachable!("not part of this action")
459 }
460 async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
461 unreachable!("not part of this action")
462 }
463 async fn designers(
464 &self,
465 _query: &crate::client::query::ListQuery,
466 ) -> Result<
467 crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
468 CoreError,
469 > {
470 unreachable!("not part of this action")
471 }
472 async fn perspective_sessions(
473 &self,
474 _query: &crate::client::query::ListQuery,
475 ) -> Result<
476 crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
477 CoreError,
478 > {
479 unreachable!("not part of this action")
480 }
481 async fn vision_clients(
482 &self,
483 _query: &crate::client::query::ListQuery,
484 ) -> Result<
485 crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
486 CoreError,
487 > {
488 unreachable!("not part of this action")
489 }
490 async fn terminate_perspective_session(
491 &self,
492 _id: &str,
493 _message: Option<&str>,
494 ) -> Result<(), CoreError> {
495 unreachable!("not part of this action")
496 }
497 async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
498 unreachable!("not part of this action")
499 }
500 async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
501 unreachable!("not part of this action")
502 }
503 async fn database_connections(
504 &self,
505 ) -> Result<
506 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
507 CoreError,
508 > {
509 unreachable!("not part of this action")
510 }
511 async fn opc_connections(
512 &self,
513 ) -> Result<
514 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
515 CoreError,
516 > {
517 unreachable!("not part of this action")
518 }
519 async fn logs(
520 &self,
521 _filter: &crate::client::logs::LogQuery,
522 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
523 {
524 unreachable!("not part of this action")
525 }
526 async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
527 unreachable!("not part of this action")
528 }
529 async fn loggers(
530 &self,
531 _query: &crate::client::query::ListQuery,
532 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
533 {
534 unreachable!("not part of this action")
535 }
536 async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
537 unreachable!("not part of this action")
538 }
539 async fn reset_logger_levels(&self) -> Result<(), CoreError> {
540 unreachable!("not part of this action")
541 }
542 async fn restart(&self) -> Result<(), CoreError> {
543 unreachable!("not part of this action")
544 }
545 async fn scan_projects(&self) -> Result<(), CoreError> {
546 unreachable!("not part of this action")
547 }
548 async fn security_properties(
549 &self,
550 ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
551 unreachable!("not part of this action")
552 }
553 async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
554 unreachable!("not part of this action")
555 }
556 async fn webdev_route_call(
557 &self,
558 _project: &str,
559 _route: &str,
560 _body: &serde_json::Value,
561 _extra_headers: &[(&str, &str)],
562 ) -> Result<serde_json::Value, CoreError> {
563 unreachable!("not part of this action")
564 }
565 async fn projects(
566 &self,
567 _query: &crate::client::query::ListQuery,
568 ) -> Result<
569 crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
570 CoreError,
571 > {
572 unreachable!("not part of this action")
573 }
574 async fn project_find(
575 &self,
576 _name: &str,
577 ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
578 unreachable!("not part of this action")
579 }
580 async fn project_create(
581 &self,
582 _body: &crate::client::projects::ProjectCreate,
583 ) -> Result<(), CoreError> {
584 unreachable!("not part of this action")
585 }
586 async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
587 unreachable!("not part of this action")
588 }
589 async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
590 unreachable!("not part of this action")
591 }
592 async fn project_modify(
593 &self,
594 _name: &str,
595 _body: &crate::client::projects::ProjectModify,
596 ) -> Result<(), CoreError> {
597 unreachable!("not part of this action")
598 }
599 async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
600 unreachable!("not part of this action")
601 }
602 async fn project_export_to_file(
603 &self,
604 _name: &str,
605 _out: &std::path::Path,
606 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
607 unreachable!("not part of this action")
608 }
609 async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
610 unreachable!("not part of this action")
611 }
612 async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
613 unreachable!("not part of this action")
614 }
615 async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
616 unreachable!("not part of this action")
617 }
618 async fn backup_download(
619 &self,
620 _out: &std::path::Path,
621 _backup_type: crate::client::backup::BackupType,
622 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
623 unreachable!("not part of this action")
624 }
625 async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
626 unreachable!("not part of this action")
627 }
628 async fn eam_task_history(
629 &self,
630 _limit: Option<u32>,
631 _search: Option<&str>,
632 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
633 {
634 unreachable!("not part of this action")
635 }
636 async fn eam_task_definitions(
637 &self,
638 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
639 {
640 unreachable!("not part of this action")
641 }
642 async fn eam_task_find(
643 &self,
644 _name: &str,
645 ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
646 unreachable!("not part of this action")
647 }
648 async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
649 unreachable!("not part of this action")
650 }
651 async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
652 unreachable!("not part of this action")
653 }
654 async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
655 unreachable!("not part of this action")
656 }
657 async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
658 unreachable!("not part of this action")
659 }
660 async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
661 unreachable!("not part of this action")
662 }
663 async fn eam_tasks_scheduled(
664 &self,
665 _running: bool,
666 ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
667 unreachable!("not part of this action")
668 }
669 async fn eam_task_modify(
670 &self,
671 _definition: &serde_json::Value,
672 ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
673 unreachable!("not part of this action")
674 }
675 async fn eam_task_delete(
676 &self,
677 _name: &str,
678 _signature: &str,
679 _confirm: bool,
680 ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
681 unreachable!("not part of this action")
682 }
683 async fn api_call(
684 &self,
685 _call: &crate::client::apicall::ApiCallRequest,
686 ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
687 unreachable!("not part of this action")
688 }
689 async fn license_status(
690 &self,
691 ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
692 unreachable!("not part of this action")
693 }
694 async fn redundancy_status(
695 &self,
696 ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
697 unreachable!("not part of this action")
698 }
699 async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
700 unreachable!("not part of this action")
701 }
702 }
703
704 fn temp_config() -> (tempfile::TempDir, PathBuf) {
707 let dir = tempfile::tempdir().expect("tempdir");
708 let path = dir.path().join("config.toml");
709 std::fs::write(
710 &path,
711 "active = \"dev\"\n\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n",
712 )
713 .expect("write config");
714 (dir, path)
715 }
716
717 fn stored_secret(path: &std::path::Path) -> Option<String> {
719 crate::config::load(path)
720 .expect("config reloads")
721 .profiles
722 .get("dev")
723 .and_then(|profile| profile.webdev_secret.clone())
724 }
725
726 fn importing_rig() -> WebdevRig {
728 WebdevRig {
729 probe: |_| unreachable!("deploy never probes"),
730 import: Box::new(|_zip, _overwrite| Ok(ok_import())),
731 }
732 }
733
734 #[tokio::test]
737 async fn deploy_without_script_exec_ships_only_the_always_on_bundle() {
738 let (dir, config) = temp_config();
739 let seen_zip = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
740 let recorder = std::sync::Arc::clone(&seen_zip);
741 let rig = WebdevRig {
742 probe: |_| unreachable!("deploy never probes"),
743 import: Box::new(move |zip, overwrite| {
744 assert!(overwrite, "deploy ALWAYS overwrite-imports");
745 *recorder.lock().expect("zip lock") = zip;
746 Ok(ok_import())
747 }),
748 };
749 let result = webdev_deploy(&rig, "ign-cli", false, false, &config, "dev", false)
750 .await
751 .expect("plain deploy");
752 assert_eq!(
753 result.routes,
754 vec!["tags", "tagConfig", "alarms", "tagHistory"]
755 );
756 assert!(!result.script_exec);
757 assert!(!result.secret_rotated);
758 assert_eq!(result.import["success"], true);
759
760 let names = member_names(&seen_zip.lock().expect("zip lock"));
763 assert!(names.iter().all(|name| !name.contains("scriptExec")));
764 assert_eq!(stored_secret(&config), None);
765 let _ = dir; }
767
768 #[tokio::test]
772 async fn deploy_with_script_exec_generates_and_redacts_the_secret() {
773 let (dir, config) = temp_config();
774 let seen_zip = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
775 let recorder = std::sync::Arc::clone(&seen_zip);
776 let rig = WebdevRig {
777 probe: |_| unreachable!("deploy never probes"),
778 import: Box::new(move |zip, _| {
779 *recorder.lock().expect("zip lock") = zip;
780 Ok(ok_import())
781 }),
782 };
783 let result = webdev_deploy(&rig, "ign-cli", true, false, &config, "dev", false)
784 .await
785 .expect("scriptExec deploy");
786 assert_eq!(
787 result.routes,
788 vec!["tags", "tagConfig", "alarms", "tagHistory", "scriptExec"]
789 );
790 assert!(result.script_exec && result.secret_rotated);
791
792 let secret = stored_secret(&config).expect("secret persisted");
793 assert_eq!(secret.len(), 64, "32 bytes hex-encoded");
794 assert!(
795 secret.chars().all(|c| c.is_ascii_hexdigit()),
796 "hex alphabet: {secret}"
797 );
798 #[cfg(unix)]
799 {
800 use std::os::unix::fs::PermissionsExt;
801 let mode = std::fs::metadata(&config)
802 .expect("config stat")
803 .permissions()
804 .mode();
805 assert_eq!(mode & 0o777, 0o600, "the save path re-asserts 0600");
806 }
807
808 let do_post = member(
811 &seen_zip.lock().expect("zip lock"),
812 "com.inductiveautomation.webdev/resources/cli/scriptExec/doPost.py",
813 );
814 assert!(String::from_utf8_lossy(&do_post).contains(&secret));
815 let serialized = serde_json::to_string(&result).expect("result serializes");
816 assert!(!serialized.contains(&secret), "redaction: {serialized}");
817 let envelope_check: WebdevDeployResult = result;
818 let again = serde_json::to_string(&envelope_check).expect("serializes");
819 assert!(!again.contains(&secret));
820 let _ = dir;
821 }
822
823 #[tokio::test]
826 async fn rotate_regenerates_and_plain_redeploy_reuses() {
827 let (dir, config) = temp_config();
828 let mut seeded = crate::config::load(&config).expect("load");
830 seeded.profiles.get_mut("dev").unwrap().webdev_secret = Some("aa11".into());
831 crate::config::save(&config, &seeded).expect("seed save");
832
833 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
835 let recorder = std::sync::Arc::clone(&seen);
836 let rig = WebdevRig {
837 probe: |_| unreachable!(),
838 import: Box::new(move |zip, _| {
839 *recorder.lock().expect("lock") = zip;
840 Ok(ok_import())
841 }),
842 };
843 let result = webdev_deploy(&rig, "ign-cli", true, false, &config, "dev", false)
844 .await
845 .expect("reuse deploy");
846 assert!(!result.secret_rotated);
847 assert_eq!(stored_secret(&config).as_deref(), Some("aa11"));
848 let seen = seen.lock().expect("lock").clone();
849 let do_post = member(
850 &seen,
851 "com.inductiveautomation.webdev/resources/cli/scriptExec/doPost.py",
852 );
853 assert!(
854 String::from_utf8_lossy(&do_post).contains("aa11"),
855 "the STORED secret rode the zip's scriptExec member"
856 );
857
858 let result = webdev_deploy(
860 &importing_rig(),
861 "ign-cli",
862 true,
863 true,
864 &config,
865 "dev",
866 false,
867 )
868 .await
869 .expect("rotate deploy");
870 assert!(result.secret_rotated);
871 let rotated = stored_secret(&config).expect("rotated secret stored");
872 assert_eq!(rotated.len(), 64);
873 assert_ne!(rotated, "aa11");
874 let _ = dir;
875 }
876
877 #[tokio::test]
881 async fn status_maps_the_full_probe_matrix() {
882 let healthy = WebdevRig {
884 probe: |_| present(BUNDLE_VERSION),
885 import: Box::new(|_, _| unreachable!("status never imports")),
886 };
887 let result = webdev_status(&healthy, "ign-cli", None)
888 .await
889 .expect("status sweep");
890 assert_eq!(result.routes.len(), 4);
891 assert!(
892 result
893 .routes
894 .iter()
895 .all(|row| row.status == RouteStatus::Present)
896 );
897 assert!(result.ok);
898
899 let degraded = WebdevRig {
902 probe: |route| {
903 Ok(match route {
904 "tags" => RouteProbe::Absent,
905 "tagConfig" => present("9.9.9").expect("fixture"),
906 "alarms" => present(BUNDLE_VERSION).expect("fixture"),
907 "tagHistory" => RouteProbe::Unlicensed,
908 "scriptExec" => RouteProbe::Denied {
909 code: "secret_mismatch".into(),
910 message: "mismatch".into(),
911 traceback: None,
912 },
913 _ => RouteProbe::AuthGated,
914 })
915 },
916 import: Box::new(|_, _| unreachable!()),
917 };
918 let result = webdev_status(°raded, "ign-cli", Some("stored-secret"))
919 .await
920 .expect("degraded sweep still completes");
921 let by_route = |name: &str| {
922 result
923 .routes
924 .iter()
925 .find(|row| row.route == name)
926 .unwrap_or_else(|| panic!("{name} row"))
927 };
928 assert_eq!(by_route("tags").status, RouteStatus::Absent);
929 assert_eq!(by_route("tagConfig").status, RouteStatus::VersionMismatch);
930 assert_eq!(
931 by_route("tagConfig").deployed_version.as_deref(),
932 Some("9.9.9")
933 );
934 assert_eq!(by_route("tagHistory").status, RouteStatus::Unlicensed);
935 assert_eq!(by_route("scriptExec").status, RouteStatus::SecretMismatch);
936 assert!(!result.ok);
937 let gated_exec = WebdevRig {
940 probe: |route| {
941 if route == "scriptExec" {
942 Ok(RouteProbe::AuthGated)
943 } else {
944 present(BUNDLE_VERSION)
945 }
946 },
947 import: Box::new(|_, _| unreachable!()),
948 };
949 let result = webdev_status(&gated_exec, "ign-cli", Some("s"))
950 .await
951 .expect("sweep");
952 assert!(result.ok, "scriptExec never gates ok");
953 assert_eq!(result.routes.len(), 5);
954 }
955
956 #[tokio::test]
960 async fn precondition_refuses_the_undeployed_and_mismatched() {
961 let undeployed = WebdevRig {
962 probe: |_| Ok(RouteProbe::Absent),
963 import: Box::new(|_, _| unreachable!()),
964 };
965 let err = webdev_precondition(&undeployed, "ign-cli")
966 .await
967 .expect_err("absent refuses");
968 assert_eq!(err.code(), "routes_not_deployed");
969 assert_eq!(err.exit_code(), 6);
970 assert!(
971 err.hint().unwrap().contains("ign webdev deploy"),
972 "hint names the fix"
973 );
974
975 let older = WebdevRig {
976 probe: |_| present("0.9.0"),
977 import: Box::new(|_, _| unreachable!()),
978 };
979 let err = webdev_precondition(&older, "ign-cli")
980 .await
981 .expect_err("older refuses");
982 assert_eq!(err.code(), "route_version_mismatch");
983 assert!(
984 err.to_string().contains("0.9.0") && err.to_string().contains(BUNDLE_VERSION),
985 "both versions named: {err}"
986 );
987
988 let matching = WebdevRig {
989 probe: |_| present(BUNDLE_VERSION),
990 import: Box::new(|_, _| unreachable!()),
991 };
992 webdev_precondition(&matching, "ign-cli")
993 .await
994 .expect("matching handshake passes");
995 }
996
997 #[test]
1000 fn generated_secrets_are_hex_and_unique() {
1001 let a = generate_secret().expect("secret");
1002 let b = generate_secret().expect("secret");
1003 assert_eq!(a.len(), 64);
1004 assert_ne!(a, b);
1005 }
1006
1007 fn member_names(zip_bytes: &[u8]) -> Vec<String> {
1008 let mut archive =
1009 zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).expect("built zip is readable");
1010 (0..archive.len())
1011 .map(|index| archive.by_index(index).expect("member").name().to_string())
1012 .collect()
1013 }
1014
1015 fn member(zip_bytes: &[u8], name: &str) -> Vec<u8> {
1016 let mut archive =
1017 zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).expect("built zip is readable");
1018 let mut file = archive.by_name(name).expect("member present");
1019 let mut bytes = Vec::new();
1020 std::io::Read::read_to_end(&mut file, &mut bytes).expect("member reads");
1021 bytes
1022 }
1023}