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