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 thread_dump(
374 &self,
375 ) -> Result<crate::client::threads::FormattedThreadDump, CoreError> {
376 unreachable!("not part of this action")
377 }
378 async fn thread_deadlocks(
379 &self,
380 ) -> Result<crate::client::threads::DeadlocksWire, CoreError> {
381 unreachable!("not part of this action")
382 }
383 async fn uninstall_module(
384 &self,
385 _gateway_module_id: &str,
386 _module_id: &str,
387 ) -> Result<(), CoreError> {
388 unreachable!("not part of this action")
389 }
390 async fn bundle_generate(
391 &self,
392 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
393 unreachable!("not part of this action")
394 }
395 async fn bundle_status(
396 &self,
397 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
398 unreachable!("not part of this action")
399 }
400 async fn bundle_download(
401 &self,
402 _out: &std::path::Path,
403 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
404 unreachable!("not part of this action")
405 }
406 async fn tag_provider_list(
407 &self,
408 _query: &crate::client::query::ListQuery,
409 ) -> Result<
410 crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
411 CoreError,
412 > {
413 unreachable!("not part of this action")
414 }
415 async fn tag_provider_find(
416 &self,
417 _name: &str,
418 ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
419 unreachable!("not part of this action")
420 }
421 async fn tag_provider_create(
422 &self,
423 _body: &[crate::client::tags::TagProviderCreate],
424 ) -> Result<(), CoreError> {
425 unreachable!("not part of this action")
426 }
427 async fn tag_provider_delete(
428 &self,
429 _name: &str,
430 _signature: &str,
431 ) -> Result<(), CoreError> {
432 unreachable!("not part of this action")
433 }
434 async fn webdev_route_probe(
435 &self,
436 _project: &str,
437 route: &str,
438 _extra_headers: &[(&str, &str)],
439 ) -> Result<RouteProbe, CoreError> {
440 (self.probe)(route)
441 }
442 async fn project_import(
443 &self,
444 _name: &str,
445 zip: Vec<u8>,
446 overwrite: bool,
447 ) -> Result<ImportOutcome, CoreError> {
448 (self.import)(zip, overwrite)
449 }
450 async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
451 unreachable!("not part of this action")
452 }
453 async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
454 unreachable!("not part of this action")
455 }
456 async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
457 unreachable!("not part of this action")
458 }
459 async fn modules(
460 &self,
461 _quarantined: bool,
462 _query: &crate::client::query::ListQuery,
463 ) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
464 {
465 unreachable!("not part of this action")
466 }
467 async fn metrics_current(
468 &self,
469 ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
470 unreachable!("not part of this action")
471 }
472 async fn metrics_historic(
473 &self,
474 ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
475 unreachable!("not part of this action")
476 }
477 async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
478 unreachable!("not part of this action")
479 }
480 async fn designers(
481 &self,
482 _query: &crate::client::query::ListQuery,
483 ) -> Result<
484 crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
485 CoreError,
486 > {
487 unreachable!("not part of this action")
488 }
489 async fn perspective_sessions(
490 &self,
491 _query: &crate::client::query::ListQuery,
492 ) -> Result<
493 crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
494 CoreError,
495 > {
496 unreachable!("not part of this action")
497 }
498 async fn vision_clients(
499 &self,
500 _query: &crate::client::query::ListQuery,
501 ) -> Result<
502 crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
503 CoreError,
504 > {
505 unreachable!("not part of this action")
506 }
507 async fn terminate_perspective_session(
508 &self,
509 _id: &str,
510 _message: Option<&str>,
511 ) -> Result<(), CoreError> {
512 unreachable!("not part of this action")
513 }
514 async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
515 unreachable!("not part of this action")
516 }
517 async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
518 unreachable!("not part of this action")
519 }
520 async fn database_connections(
521 &self,
522 ) -> Result<
523 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
524 CoreError,
525 > {
526 unreachable!("not part of this action")
527 }
528 async fn opc_connections(
529 &self,
530 ) -> Result<
531 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
532 CoreError,
533 > {
534 unreachable!("not part of this action")
535 }
536 async fn logs(
537 &self,
538 _filter: &crate::client::logs::LogQuery,
539 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
540 {
541 unreachable!("not part of this action")
542 }
543 async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
544 unreachable!("not part of this action")
545 }
546 async fn loggers(
547 &self,
548 _query: &crate::client::query::ListQuery,
549 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
550 {
551 unreachable!("not part of this action")
552 }
553 async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
554 unreachable!("not part of this action")
555 }
556 async fn reset_logger_levels(&self) -> Result<(), CoreError> {
557 unreachable!("not part of this action")
558 }
559 async fn restart(&self) -> Result<(), CoreError> {
560 unreachable!("not part of this action")
561 }
562 async fn scan_projects(&self) -> Result<(), CoreError> {
563 unreachable!("not part of this action")
564 }
565 async fn security_properties(
566 &self,
567 ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
568 unreachable!("not part of this action")
569 }
570 async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
571 unreachable!("not part of this action")
572 }
573 async fn webdev_route_call(
574 &self,
575 _project: &str,
576 _route: &str,
577 _body: &serde_json::Value,
578 _extra_headers: &[(&str, &str)],
579 ) -> Result<serde_json::Value, CoreError> {
580 unreachable!("not part of this action")
581 }
582 async fn projects(
583 &self,
584 _query: &crate::client::query::ListQuery,
585 ) -> Result<
586 crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
587 CoreError,
588 > {
589 unreachable!("not part of this action")
590 }
591 async fn project_find(
592 &self,
593 _name: &str,
594 ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
595 unreachable!("not part of this action")
596 }
597 async fn project_create(
598 &self,
599 _body: &crate::client::projects::ProjectCreate,
600 ) -> Result<(), CoreError> {
601 unreachable!("not part of this action")
602 }
603 async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
604 unreachable!("not part of this action")
605 }
606 async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
607 unreachable!("not part of this action")
608 }
609 async fn project_modify(
610 &self,
611 _name: &str,
612 _body: &crate::client::projects::ProjectModify,
613 ) -> Result<(), CoreError> {
614 unreachable!("not part of this action")
615 }
616 async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
617 unreachable!("not part of this action")
618 }
619 async fn project_export_to_file(
620 &self,
621 _name: &str,
622 _out: &std::path::Path,
623 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
624 unreachable!("not part of this action")
625 }
626 async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
627 unreachable!("not part of this action")
628 }
629 async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
630 unreachable!("not part of this action")
631 }
632 async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
633 unreachable!("not part of this action")
634 }
635 async fn backup_download(
636 &self,
637 _out: &std::path::Path,
638 _backup_type: crate::client::backup::BackupType,
639 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
640 unreachable!("not part of this action")
641 }
642 async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
643 unreachable!("not part of this action")
644 }
645 async fn eam_task_history(
646 &self,
647 _limit: Option<u32>,
648 _search: Option<&str>,
649 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
650 {
651 unreachable!("not part of this action")
652 }
653 async fn eam_task_definitions(
654 &self,
655 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
656 {
657 unreachable!("not part of this action")
658 }
659 async fn eam_task_find(
660 &self,
661 _name: &str,
662 ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
663 unreachable!("not part of this action")
664 }
665 async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
666 unreachable!("not part of this action")
667 }
668 async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
669 unreachable!("not part of this action")
670 }
671 async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
672 unreachable!("not part of this action")
673 }
674 async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
675 unreachable!("not part of this action")
676 }
677 async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
678 unreachable!("not part of this action")
679 }
680 async fn eam_tasks_scheduled(
681 &self,
682 _running: bool,
683 ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
684 unreachable!("not part of this action")
685 }
686 async fn eam_task_modify(
687 &self,
688 _definition: &serde_json::Value,
689 ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
690 unreachable!("not part of this action")
691 }
692 async fn eam_task_delete(
693 &self,
694 _name: &str,
695 _signature: &str,
696 _confirm: bool,
697 ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
698 unreachable!("not part of this action")
699 }
700 async fn api_call(
701 &self,
702 _call: &crate::client::apicall::ApiCallRequest,
703 ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
704 unreachable!("not part of this action")
705 }
706 async fn license_status(
707 &self,
708 ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
709 unreachable!("not part of this action")
710 }
711 async fn redundancy_status(
712 &self,
713 ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
714 unreachable!("not part of this action")
715 }
716 async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
717 unreachable!("not part of this action")
718 }
719 }
720
721 fn temp_config() -> (tempfile::TempDir, PathBuf) {
724 let dir = tempfile::tempdir().expect("tempdir");
725 let path = dir.path().join("config.toml");
726 std::fs::write(
727 &path,
728 "active = \"dev\"\n\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n",
729 )
730 .expect("write config");
731 (dir, path)
732 }
733
734 fn stored_secret(path: &std::path::Path) -> Option<String> {
736 crate::config::load(path)
737 .expect("config reloads")
738 .profiles
739 .get("dev")
740 .and_then(|profile| profile.webdev_secret.clone())
741 }
742
743 fn importing_rig() -> WebdevRig {
745 WebdevRig {
746 probe: |_| unreachable!("deploy never probes"),
747 import: Box::new(|_zip, _overwrite| Ok(ok_import())),
748 }
749 }
750
751 #[tokio::test]
754 async fn deploy_without_script_exec_ships_only_the_always_on_bundle() {
755 let (dir, config) = temp_config();
756 let seen_zip = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
757 let recorder = std::sync::Arc::clone(&seen_zip);
758 let rig = WebdevRig {
759 probe: |_| unreachable!("deploy never probes"),
760 import: Box::new(move |zip, overwrite| {
761 assert!(overwrite, "deploy ALWAYS overwrite-imports");
762 *recorder.lock().expect("zip lock") = zip;
763 Ok(ok_import())
764 }),
765 };
766 let result = webdev_deploy(&rig, "ign-cli", false, false, &config, "dev", false)
767 .await
768 .expect("plain deploy");
769 assert_eq!(
770 result.routes,
771 vec!["tags", "tagConfig", "alarms", "tagHistory"]
772 );
773 assert!(!result.script_exec);
774 assert!(!result.secret_rotated);
775 assert_eq!(result.import["success"], true);
776
777 let names = member_names(&seen_zip.lock().expect("zip lock"));
780 assert!(names.iter().all(|name| !name.contains("scriptExec")));
781 assert_eq!(stored_secret(&config), None);
782 let _ = dir; }
784
785 #[tokio::test]
789 async fn deploy_with_script_exec_generates_and_redacts_the_secret() {
790 let (dir, config) = temp_config();
791 let seen_zip = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
792 let recorder = std::sync::Arc::clone(&seen_zip);
793 let rig = WebdevRig {
794 probe: |_| unreachable!("deploy never probes"),
795 import: Box::new(move |zip, _| {
796 *recorder.lock().expect("zip lock") = zip;
797 Ok(ok_import())
798 }),
799 };
800 let result = webdev_deploy(&rig, "ign-cli", true, false, &config, "dev", false)
801 .await
802 .expect("scriptExec deploy");
803 assert_eq!(
804 result.routes,
805 vec!["tags", "tagConfig", "alarms", "tagHistory", "scriptExec"]
806 );
807 assert!(result.script_exec && result.secret_rotated);
808
809 let secret = stored_secret(&config).expect("secret persisted");
810 assert_eq!(secret.len(), 64, "32 bytes hex-encoded");
811 assert!(
812 secret.chars().all(|c| c.is_ascii_hexdigit()),
813 "hex alphabet: {secret}"
814 );
815 #[cfg(unix)]
816 {
817 use std::os::unix::fs::PermissionsExt;
818 let mode = std::fs::metadata(&config)
819 .expect("config stat")
820 .permissions()
821 .mode();
822 assert_eq!(mode & 0o777, 0o600, "the save path re-asserts 0600");
823 }
824
825 let do_post = member(
828 &seen_zip.lock().expect("zip lock"),
829 "com.inductiveautomation.webdev/resources/cli/scriptExec/doPost.py",
830 );
831 assert!(String::from_utf8_lossy(&do_post).contains(&secret));
832 let serialized = serde_json::to_string(&result).expect("result serializes");
833 assert!(!serialized.contains(&secret), "redaction: {serialized}");
834 let envelope_check: WebdevDeployResult = result;
835 let again = serde_json::to_string(&envelope_check).expect("serializes");
836 assert!(!again.contains(&secret));
837 let _ = dir;
838 }
839
840 #[tokio::test]
843 async fn rotate_regenerates_and_plain_redeploy_reuses() {
844 let (dir, config) = temp_config();
845 let mut seeded = crate::config::load(&config).expect("load");
847 seeded.profiles.get_mut("dev").unwrap().webdev_secret = Some("aa11".into());
848 crate::config::save(&config, &seeded).expect("seed save");
849
850 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
852 let recorder = std::sync::Arc::clone(&seen);
853 let rig = WebdevRig {
854 probe: |_| unreachable!(),
855 import: Box::new(move |zip, _| {
856 *recorder.lock().expect("lock") = zip;
857 Ok(ok_import())
858 }),
859 };
860 let result = webdev_deploy(&rig, "ign-cli", true, false, &config, "dev", false)
861 .await
862 .expect("reuse deploy");
863 assert!(!result.secret_rotated);
864 assert_eq!(stored_secret(&config).as_deref(), Some("aa11"));
865 let seen = seen.lock().expect("lock").clone();
866 let do_post = member(
867 &seen,
868 "com.inductiveautomation.webdev/resources/cli/scriptExec/doPost.py",
869 );
870 assert!(
871 String::from_utf8_lossy(&do_post).contains("aa11"),
872 "the STORED secret rode the zip's scriptExec member"
873 );
874
875 let result = webdev_deploy(
877 &importing_rig(),
878 "ign-cli",
879 true,
880 true,
881 &config,
882 "dev",
883 false,
884 )
885 .await
886 .expect("rotate deploy");
887 assert!(result.secret_rotated);
888 let rotated = stored_secret(&config).expect("rotated secret stored");
889 assert_eq!(rotated.len(), 64);
890 assert_ne!(rotated, "aa11");
891 let _ = dir;
892 }
893
894 #[tokio::test]
898 async fn status_maps_the_full_probe_matrix() {
899 let healthy = WebdevRig {
901 probe: |_| present(BUNDLE_VERSION),
902 import: Box::new(|_, _| unreachable!("status never imports")),
903 };
904 let result = webdev_status(&healthy, "ign-cli", None)
905 .await
906 .expect("status sweep");
907 assert_eq!(result.routes.len(), 4);
908 assert!(
909 result
910 .routes
911 .iter()
912 .all(|row| row.status == RouteStatus::Present)
913 );
914 assert!(result.ok);
915
916 let degraded = WebdevRig {
919 probe: |route| {
920 Ok(match route {
921 "tags" => RouteProbe::Absent,
922 "tagConfig" => present("9.9.9").expect("fixture"),
923 "alarms" => present(BUNDLE_VERSION).expect("fixture"),
924 "tagHistory" => RouteProbe::Unlicensed,
925 "scriptExec" => RouteProbe::Denied {
926 code: "secret_mismatch".into(),
927 message: "mismatch".into(),
928 traceback: None,
929 },
930 _ => RouteProbe::AuthGated,
931 })
932 },
933 import: Box::new(|_, _| unreachable!()),
934 };
935 let result = webdev_status(°raded, "ign-cli", Some("stored-secret"))
936 .await
937 .expect("degraded sweep still completes");
938 let by_route = |name: &str| {
939 result
940 .routes
941 .iter()
942 .find(|row| row.route == name)
943 .unwrap_or_else(|| panic!("{name} row"))
944 };
945 assert_eq!(by_route("tags").status, RouteStatus::Absent);
946 assert_eq!(by_route("tagConfig").status, RouteStatus::VersionMismatch);
947 assert_eq!(
948 by_route("tagConfig").deployed_version.as_deref(),
949 Some("9.9.9")
950 );
951 assert_eq!(by_route("tagHistory").status, RouteStatus::Unlicensed);
952 assert_eq!(by_route("scriptExec").status, RouteStatus::SecretMismatch);
953 assert!(!result.ok);
954 let gated_exec = WebdevRig {
957 probe: |route| {
958 if route == "scriptExec" {
959 Ok(RouteProbe::AuthGated)
960 } else {
961 present(BUNDLE_VERSION)
962 }
963 },
964 import: Box::new(|_, _| unreachable!()),
965 };
966 let result = webdev_status(&gated_exec, "ign-cli", Some("s"))
967 .await
968 .expect("sweep");
969 assert!(result.ok, "scriptExec never gates ok");
970 assert_eq!(result.routes.len(), 5);
971 }
972
973 #[tokio::test]
977 async fn precondition_refuses_the_undeployed_and_mismatched() {
978 let undeployed = WebdevRig {
979 probe: |_| Ok(RouteProbe::Absent),
980 import: Box::new(|_, _| unreachable!()),
981 };
982 let err = webdev_precondition(&undeployed, "ign-cli")
983 .await
984 .expect_err("absent refuses");
985 assert_eq!(err.code(), "routes_not_deployed");
986 assert_eq!(err.exit_code(), 6);
987 assert!(
988 err.hint().unwrap().contains("ign webdev deploy"),
989 "hint names the fix"
990 );
991
992 let older = WebdevRig {
993 probe: |_| present("0.9.0"),
994 import: Box::new(|_, _| unreachable!()),
995 };
996 let err = webdev_precondition(&older, "ign-cli")
997 .await
998 .expect_err("older refuses");
999 assert_eq!(err.code(), "route_version_mismatch");
1000 assert!(
1001 err.to_string().contains("0.9.0") && err.to_string().contains(BUNDLE_VERSION),
1002 "both versions named: {err}"
1003 );
1004
1005 let matching = WebdevRig {
1006 probe: |_| present(BUNDLE_VERSION),
1007 import: Box::new(|_, _| unreachable!()),
1008 };
1009 webdev_precondition(&matching, "ign-cli")
1010 .await
1011 .expect("matching handshake passes");
1012 }
1013
1014 #[test]
1017 fn generated_secrets_are_hex_and_unique() {
1018 let a = generate_secret().expect("secret");
1019 let b = generate_secret().expect("secret");
1020 assert_eq!(a.len(), 64);
1021 assert_ne!(a, b);
1022 }
1023
1024 fn member_names(zip_bytes: &[u8]) -> Vec<String> {
1025 let mut archive =
1026 zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).expect("built zip is readable");
1027 (0..archive.len())
1028 .map(|index| archive.by_index(index).expect("member").name().to_string())
1029 .collect()
1030 }
1031
1032 fn member(zip_bytes: &[u8], name: &str) -> Vec<u8> {
1033 let mut archive =
1034 zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).expect("built zip is readable");
1035 let mut file = archive.by_name(name).expect("member present");
1036 let mut bytes = Vec::new();
1037 std::io::Read::read_to_end(&mut file, &mut bytes).expect("member reads");
1038 bytes
1039 }
1040}