1use std::io::Write;
33
34use serde_json::Value;
35
36use crate::error::CoreError;
37use crate::webdev as bundle;
38
39pub const DEFAULT_PROJECT: &str = "ign-cli";
43
44const SCRIPT_EXEC_ROUTE_ROOT: &str = "com.inductiveautomation.webdev/resources/cli/scriptExec";
49const SCRIPT_EXEC_RESOURCE_JSON: &str = include_str!(
50 "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/scriptExec/resource.json"
51);
52const SCRIPT_EXEC_CONFIG_JSON: &str = include_str!(
53 "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/scriptExec/config.json"
54);
55
56pub fn always_on_routes() -> Vec<String> {
60 let mut routes = Vec::new();
61 for (name, _) in bundle::ROUTE_FILES {
62 let Some(rest) = name.strip_prefix("com.inductiveautomation.webdev/resources/cli/") else {
63 continue;
64 };
65 if let Some((route, file)) = rest.rsplit_once('/')
66 && file == "doPost.py"
67 && !routes.iter().any(|known: &String| known == route)
68 {
69 routes.push(route.to_string());
70 }
71 }
72 routes
73}
74
75pub(crate) fn route_url(project: &str, route: &str) -> String {
80 format!("/system/webdev/{project}/cli/{route}")
81}
82
83pub(crate) fn testing_route_url(project: &str, route: &str) -> String {
87 format!("/system/webdev/{project}/testing/{route}")
88}
89
90pub async fn testing_discover(
96 api: &super::ReqwestGatewayApi,
97 project: &str,
98) -> Result<serde_json::Value, CoreError> {
99 api.post_json(
100 &testing_route_url(project, "run"),
101 &serde_json::json!({ "discover": true }),
102 )
103 .await?
104 .json::<serde_json::Value>()
105 .await
106 .map_err(|err| {
107 CoreError::Internal(format!(
108 "testing discover response did not match the expected shape: {err}"
109 ))
110 })
111}
112
113pub async fn testing_run(
120 api: &super::ReqwestGatewayApi,
121 project: &str,
122 body: &serde_json::Value,
123) -> Result<serde_json::Value, CoreError> {
124 let response = api
125 .post_json(&testing_route_url(project, "run"), body)
126 .await?;
127 response.json::<serde_json::Value>().await.map_err(|err| {
128 CoreError::Internal(format!(
129 "testing run response did not match the expected shape: {err}"
130 ))
131 })
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum TestingProbe {
150 Present {
153 discovered: serde_json::Value,
155 },
156 Absent,
162 Unlicensed,
164 AuthGated {
166 status: u16,
168 },
169 LazyCompile {
174 body: String,
176 },
177}
178
179pub async fn testing_probe(
185 api: &super::ReqwestGatewayApi,
186 project: &str,
187) -> Result<TestingProbe, CoreError> {
188 let (url, response) = api
189 .testing_post_raw(project, "run", &serde_json::json!({"discover": true}))
190 .await?;
191 let status = response.status();
192 if status.is_success() {
193 let text = response.text().await.unwrap_or_default();
194 let discovered = serde_json::from_str::<Value>(&text).map_err(|err| {
195 CoreError::Internal(format!(
196 "testing discover response did not match the expected shape: {err}"
197 ))
198 })?;
199 return Ok(TestingProbe::Present { discovered });
200 }
201 match status.as_u16() {
202 401 | 403 => Ok(TestingProbe::AuthGated {
203 status: status.as_u16(),
204 }),
205 402 => Ok(TestingProbe::Unlicensed),
206 404 | 405 | 501 => Ok(TestingProbe::Absent),
207 500 => Ok(TestingProbe::LazyCompile {
208 body: response.text().await.unwrap_or_default(),
209 }),
210 other => Err(CoreError::Internal(format!(
211 "unexpected HTTP {other} from the testing route probe at {url}"
212 ))),
213 }
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
221pub enum RouteProbe {
222 Present {
225 route_version: String,
227 },
228 Absent,
231 Unlicensed,
234 AuthGated,
238 Denied {
242 code: String,
244 message: String,
246 traceback: Option<String>,
250 },
251}
252
253#[derive(Debug)]
256pub(crate) enum RouteBody {
257 Ok(Value),
259 Denied {
262 code: String,
263 message: String,
264 traceback: Option<String>,
265 },
266}
267
268pub(crate) fn parse_route_body(body: &str) -> Result<RouteBody, CoreError> {
276 let value: Value = serde_json::from_str(body).map_err(|err| {
277 CoreError::Internal(format!(
278 "webdev route answered a body that is not the {{ok, data|error}} envelope: {err}"
279 ))
280 })?;
281 if value.get("ok").and_then(Value::as_bool) == Some(true) {
282 Ok(RouteBody::Ok(
283 value.get("data").cloned().unwrap_or(Value::Null),
284 ))
285 } else {
286 let code = value
287 .pointer("/error/code")
288 .and_then(Value::as_str)
289 .unwrap_or("route_error")
290 .to_string();
291 let message = value
292 .pointer("/error/message")
293 .and_then(Value::as_str)
294 .unwrap_or("(the route sent no message)")
295 .to_string();
296 let traceback = value
297 .pointer("/error/traceback")
298 .and_then(Value::as_str)
299 .map(str::to_string);
300 Ok(RouteBody::Denied {
301 code,
302 message,
303 traceback,
304 })
305 }
306}
307
308pub(crate) fn denial_to_error(
323 code: &str,
324 message: &str,
325 traceback: Option<&str>,
326 endpoint: String,
327) -> CoreError {
328 match code {
329 "not_found" => CoreError::NotFound {
330 endpoint: Some(endpoint),
331 },
332 "no_alarm_journal" => CoreError::AlarmJournalMissing {
333 endpoint: Some(endpoint),
334 },
335 "provider_root_unsupported" => CoreError::ProviderRootUnsupported {
341 endpoint: Some(endpoint),
342 },
343 _ => {
344 let mut full = message.to_string();
345 if let Some(traceback) = traceback {
346 full.push_str("\nroute traceback: ");
347 full.push_str(traceback);
348 }
349 CoreError::WebdevRouteError {
350 code: code.to_string(),
351 message: full,
352 endpoint: Some(endpoint),
353 }
354 }
355 }
356}
357
358pub fn build_deploy_zip(
383 project_title: &str,
384 with_script_exec: bool,
385 secret: Option<&str>,
386 with_testing: bool,
387) -> Result<Vec<u8>, CoreError> {
388 build_deploy_zip_with(project_title, with_script_exec, secret, with_testing, None)
389}
390
391#[derive(Clone, PartialEq, Eq)]
394pub struct TagDeleteInstall {
395 pub secret: String,
397 pub user_source: String,
399 pub role: String,
401 pub providers: Vec<String>,
403}
404
405impl std::fmt::Debug for TagDeleteInstall {
406 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407 f.debug_struct("TagDeleteInstall")
408 .field("secret", &"<redacted>")
409 .field("user_source", &self.user_source)
410 .field("role", &self.role)
411 .field("providers", &self.providers)
412 .finish()
413 }
414}
415
416const TAG_DELETE_ROUTE_ROOT: &str = "com.inductiveautomation.webdev/resources/cli/tagDelete";
418
419const TAG_DELETE_MARKER_PREFIX: &str = "__IGN_TAG_DELETE_";
422
423pub fn validate_tag_delete_install(install: &TagDeleteInstall) -> Result<(), CoreError> {
429 let invalid = |reason: String| Err(CoreError::InvalidInput { reason });
430 if install.secret.len() != 64
431 || !install
432 .secret
433 .bytes()
434 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
435 {
436 return invalid("tag delete secret: must be 64 lowercase hex characters".into());
437 }
438 for (field, value) in [
439 ("tag delete user source", &install.user_source),
440 ("tag delete role", &install.role),
441 ] {
442 let len = value.chars().count();
443 let charset_ok = value
444 .chars()
445 .all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '_' | '-' | '.'));
446 if !(1..=64).contains(&len) || !charset_ok {
447 return invalid(format!(
448 "{field} {value:?}: must be 1 to 64 characters of ASCII letters, digits, \
449 space, '_', '-' or '.'"
450 ));
451 }
452 }
453 if install.providers.is_empty() {
454 return invalid(
455 "tag delete providers: the allowlist must name at least one provider".into(),
456 );
457 }
458 for (i, provider) in install.providers.iter().enumerate() {
459 if provider.is_empty() {
460 return invalid(format!(
461 "tag delete providers[{i}]: the provider name is empty"
462 ));
463 }
464 if provider.eq_ignore_ascii_case("System") {
465 return invalid(format!(
466 "tag delete providers[{i}]: the System provider is read-only and never allowlisted"
467 ));
468 }
469 if provider
470 .chars()
471 .any(|c| matches!(c, '\'' | '"' | '\\') || c.is_control())
472 {
473 return invalid(format!(
474 "tag delete providers[{i}] {provider:?}: the provider name holds a quote, a \
475 backslash or a control character"
476 ));
477 }
478 if install.providers[..i]
479 .iter()
480 .any(|seen| seen.eq_ignore_ascii_case(provider))
481 {
482 return invalid(format!(
483 "tag delete providers[{i}] {provider:?}: duplicate provider"
484 ));
485 }
486 }
487 Ok(())
488}
489
490fn ascii_json_list(providers: &[String]) -> Result<String, CoreError> {
494 let encoded = serde_json::to_string(providers)
495 .map_err(|err| CoreError::Internal(format!("cannot encode the provider list: {err}")))?;
496 let mut out = String::with_capacity(encoded.len());
497 for c in encoded.chars() {
498 if c.is_ascii() {
499 out.push(c);
500 } else {
501 let mut units = [0u16; 2];
502 for unit in c.encode_utf16(&mut units) {
503 out.push_str(&format!("\\u{unit:04x}"));
504 }
505 }
506 }
507 Ok(out)
508}
509
510fn tag_delete_members(install: &TagDeleteInstall) -> Result<Vec<(String, String)>, CoreError> {
515 validate_tag_delete_install(install)?;
516 let do_post = bundle::TAG_DELETE_TEMPLATE
517 .replacen("__IGN_TAG_DELETE_SECRET__", &install.secret, 1)
518 .replacen(
519 "__IGN_TAG_DELETE_PROVIDERS__",
520 &ascii_json_list(&install.providers)?,
521 1,
522 );
523
524 let mut config: Value =
525 serde_json::from_str(bundle::TAG_DELETE_CONFIG_TEMPLATE).map_err(|err| {
526 CoreError::Internal(format!("tagDelete config.json does not parse: {err}"))
527 })?;
528 let handler = config
529 .get_mut("doPost")
530 .and_then(Value::as_object_mut)
531 .ok_or_else(|| CoreError::Internal("tagDelete config.json has no doPost object".into()))?;
532 if handler.get("require-auth") != Some(&Value::Bool(true)) {
533 return Err(CoreError::Internal(
534 "tagDelete config.json must keep require-auth true".into(),
535 ));
536 }
537 handler.insert("required-roles".into(), Value::String(install.role.clone()));
538 handler.insert(
539 "user-source".into(),
540 Value::String(install.user_source.clone()),
541 );
542 let config = serde_json::to_string(&config)
543 .map_err(|err| CoreError::Internal(format!("cannot re-serialize config.json: {err}")))?;
544
545 let members = vec![
546 (
547 format!("{TAG_DELETE_ROUTE_ROOT}/resource.json"),
548 bundle::TAG_DELETE_RESOURCE_JSON.to_string(),
549 ),
550 (format!("{TAG_DELETE_ROUTE_ROOT}/config.json"), config),
551 (format!("{TAG_DELETE_ROUTE_ROOT}/doPost.py"), do_post),
552 ];
553 if let Some((name, _)) = members
554 .iter()
555 .find(|(_, body)| body.contains(TAG_DELETE_MARKER_PREFIX))
556 {
557 return Err(CoreError::Internal(format!(
558 "{name} still holds a tagDelete install marker after substitution (fail-closed guard)"
559 )));
560 }
561 Ok(members)
562}
563
564pub fn build_deploy_zip_with(
569 project_title: &str,
570 with_script_exec: bool,
571 secret: Option<&str>,
572 with_testing: bool,
573 tag_delete: Option<&TagDeleteInstall>,
574) -> Result<Vec<u8>, CoreError> {
575 let tag_delete_members = tag_delete.map(tag_delete_members).transpose()?;
576 let script_exec_py = match (with_script_exec, secret) {
577 (false, _) => None,
578 (true, Some(secret)) => {
579 Some(bundle::SCRIPT_EXEC_TEMPLATE.replace("__IGN_CLI_SECRET__", secret))
580 }
581 (true, None) => {
582 return Err(CoreError::Internal(
583 "scriptExec deploy requires a substituted secret — the deploy \
584 action generates the secret before packing (fail-closed guard)"
585 .into(),
586 ));
587 }
588 };
589
590 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
591 let options = zip::write::SimpleFileOptions::default()
592 .compression_method(zip::CompressionMethod::Deflated);
593
594 for (name, contents) in bundle::ROUTE_FILES {
595 let body = if *name == "project.json" && project_title != DEFAULT_PROJECT {
596 retitle_project_json(contents, project_title)?
597 } else {
598 (*contents).to_string()
599 };
600 writer.start_file(*name, options).map_err(zip_write_err)?;
601 writer.write_all(body.as_bytes()).map_err(|err| {
602 CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
603 })?;
604 }
605
606 if let Some(script_exec_py) = script_exec_py {
607 for (name, body) in [
608 (
609 format!("{SCRIPT_EXEC_ROUTE_ROOT}/resource.json"),
610 SCRIPT_EXEC_RESOURCE_JSON.to_string(),
611 ),
612 (
613 format!("{SCRIPT_EXEC_ROUTE_ROOT}/config.json"),
614 SCRIPT_EXEC_CONFIG_JSON.to_string(),
615 ),
616 (
617 format!("{SCRIPT_EXEC_ROUTE_ROOT}/doPost.py"),
618 script_exec_py,
619 ),
620 ] {
621 writer
622 .start_file(name.as_str(), options)
623 .map_err(zip_write_err)?;
624 writer.write_all(body.as_bytes()).map_err(|err| {
625 CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
626 })?;
627 }
628 }
629
630 for (name, body) in tag_delete_members.into_iter().flatten() {
631 writer
632 .start_file(name.as_str(), options)
633 .map_err(zip_write_err)?;
634 writer.write_all(body.as_bytes()).map_err(|err| {
635 CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
636 })?;
637 }
638
639 if with_testing {
643 let marker_total: usize = bundle::testing::TESTING_FILES
644 .iter()
645 .map(|(_, contents)| contents.matches(bundle::testing::PROJECT_MARKER).count())
646 .sum();
647 if marker_total != bundle::testing::PROJECT_MARKER_COUNT {
648 return Err(CoreError::Internal(format!(
649 "testing bundle marker count drifted: found {marker_total}, pinned {} — \
650 a template edit changed the substitution contract",
651 bundle::testing::PROJECT_MARKER_COUNT
652 )));
653 }
654 for (name, contents) in bundle::testing::TESTING_FILES {
655 let body = contents.replace(bundle::testing::PROJECT_MARKER, project_title);
656 writer.start_file(*name, options).map_err(zip_write_err)?;
657 writer.write_all(body.as_bytes()).map_err(|err| {
658 CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
659 })?;
660 }
661 }
662
663 writer
664 .finish()
665 .map_err(zip_write_err)
666 .map(|cursor| cursor.into_inner())
667}
668
669fn zip_write_err(err: zip::result::ZipError) -> CoreError {
670 CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
671}
672
673fn retitle_project_json(project_json: &str, title: &str) -> Result<String, CoreError> {
677 let mut value: Value = serde_json::from_str(project_json).map_err(|err| {
678 CoreError::Internal(format!("embedded project.json does not parse: {err}"))
679 })?;
680 value["title"] = Value::String(title.to_string());
681 serde_json::to_string(&value)
682 .map_err(|err| CoreError::Internal(format!("cannot re-serialize project.json: {err}")))
683}
684
685#[cfg(test)]
686mod tests {
687 use super::{
688 DEFAULT_PROJECT, RouteBody, TagDeleteInstall, always_on_routes, build_deploy_zip,
689 build_deploy_zip_with, denial_to_error, parse_route_body, validate_tag_delete_install,
690 };
691 use crate::error::CoreError;
692
693 #[test]
696 fn always_on_routes_derive_from_the_manifest() {
697 assert_eq!(
698 always_on_routes(),
699 vec![
700 "tags".to_string(),
701 "tagConfig".to_string(),
702 "alarms".to_string(),
703 "tagHistory".to_string(),
704 ]
705 );
706 }
707
708 #[test]
712 fn parse_route_body_envelope_shapes() {
713 match parse_route_body(r#"{"ok":true,"data":{"routeVersion":"1.0.0"}}"#)
714 .expect("ok body parses")
715 {
716 RouteBody::Ok(data) => {
717 assert_eq!(data["routeVersion"], "1.0.0");
718 }
719 other => panic!("wrong verdict: {other:?}"),
720 }
721
722 match parse_route_body(
723 r#"{"ok":false,"error":{"code":"secret_mismatch","message":"nope"}}"#,
724 )
725 .expect("denial parses")
726 {
727 RouteBody::Denied {
728 code,
729 message,
730 traceback,
731 } => {
732 assert_eq!(code, "secret_mismatch");
733 assert_eq!(message, "nope");
734 assert!(traceback.is_none(), "no traceback on the wire");
735 }
736 other => panic!("wrong verdict: {other:?}"),
737 }
738
739 match parse_route_body(
742 r#"{"ok":false,"error":{"code":"route_error","message":"boom","traceback":"Traceback (most recent call last):\n ValueError: nope"}}"#,
743 )
744 .expect("denial with traceback parses")
745 {
746 RouteBody::Denied { code, traceback, .. } => {
747 assert_eq!(code, "route_error");
748 assert_eq!(
749 traceback.as_deref(),
750 Some("Traceback (most recent call last):\n ValueError: nope")
751 );
752 }
753 other => panic!("wrong verdict: {other:?}"),
754 }
755
756 match parse_route_body(r#"{"ok":true}"#).expect("bare ok parses") {
758 RouteBody::Ok(data) => assert!(data.is_null()),
759 other => panic!("wrong verdict: {other:?}"),
760 }
761
762 match parse_route_body(r#"{"ok":false}"#).expect("bare denial parses") {
765 RouteBody::Denied { code, .. } => assert_eq!(code, "route_error"),
766 other => panic!("wrong verdict: {other:?}"),
767 }
768
769 let err = parse_route_body("<html>jetty</html>").expect_err("non-envelope fails");
770 assert!(matches!(err, CoreError::Internal(_)), "{err}");
771 }
772
773 #[test]
779 fn denial_mapping_reuses_not_found_and_rides_the_rest() {
780 let not_found = denial_to_error("not_found", "no such path", None, "/x".into());
781 assert_eq!(not_found.code(), "not_found");
782 assert_eq!(not_found.exit_code(), 6);
783
784 let secret = denial_to_error("secret_required", "missing header", None, "/x".into());
785 assert_eq!(secret.code(), "webdev_route_error");
786 assert_eq!(secret.exit_code(), 6);
787 assert!(secret.to_string().contains("secret_required"));
788 assert!(
789 secret.to_string().contains("missing header"),
790 "no traceback → the message rides VERBATIM (no suffix)"
791 );
792
793 let blown = denial_to_error(
797 "route_error",
798 "error processing action",
799 Some("java.lang.IllegalArgumentException: Invalid UUID string: 3f2504e0"),
800 "/x".into(),
801 );
802 assert_eq!(blown.code(), "webdev_route_error");
803 let text = blown.to_string();
804 assert!(
805 text.contains("\nroute traceback: java.lang.IllegalArgumentException: Invalid UUID string: 3f2504e0"),
806 "the traceback rides the message: {text}"
807 );
808
809 let journal = denial_to_error(
813 "no_alarm_journal",
814 "No alarm journal profile specified",
815 None,
816 "/system/webdev/ign-cli/cli/alarms".into(),
817 );
818 assert_eq!(journal.code(), "alarm_journal_missing");
819 assert_eq!(journal.exit_code(), 6);
820 assert!(
821 journal.hint().unwrap().contains("journal profile"),
822 "hint names the chain: {journal}"
823 );
824
825 let root = denial_to_error(
830 "provider_root_unsupported",
831 "provider-root tag paths are not supported on WebDev threads (no RpcContext) -- use a subtree path like [provider]folder",
832 None,
833 "/system/webdev/ign-cli/cli/tagConfig".into(),
834 );
835 assert_eq!(root.code(), "provider_root_unsupported");
836 assert_eq!(root.exit_code(), 6);
837 assert!(
838 root.to_string().contains("subtree like [provider]folder"),
839 "the fixed Display names the subtree workaround: {root}"
840 );
841 }
842
843 #[test]
846 fn deploy_zip_fails_closed_without_a_script_exec_secret() {
847 let err = build_deploy_zip(DEFAULT_PROJECT, true, None, false).expect_err("must refuse");
848 assert!(matches!(err, CoreError::Internal(_)), "{err}");
849 assert_eq!(err.exit_code(), 1);
850 }
851
852 const FAKE_SECRET: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
853 const TAG_DELETE_ROOT: &str = "com.inductiveautomation.webdev/resources/cli/tagDelete";
854
855 fn install() -> TagDeleteInstall {
856 TagDeleteInstall {
857 secret: FAKE_SECRET.to_string(),
858 user_source: "default".to_string(),
859 role: "Fake Deploy-Role_1.x".to_string(),
860 providers: vec!["P26Del".to_string(), "Other".to_string()],
861 }
862 }
863
864 fn members(zip_bytes: &[u8]) -> Vec<(String, String)> {
865 let mut archive =
866 zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).expect("built zip is readable");
867 (0..archive.len())
868 .map(|index| {
869 let mut file = archive.by_index(index).expect("member");
870 let mut body = String::new();
871 std::io::Read::read_to_string(&mut file, &mut body).expect("member reads");
872 (file.name().to_string(), body)
873 })
874 .collect()
875 }
876
877 fn invalid_reason(install: &TagDeleteInstall) -> String {
878 match validate_tag_delete_install(install) {
879 Err(CoreError::InvalidInput { reason }) => reason,
880 other => panic!("expected invalid_input, got {other:?}"),
881 }
882 }
883
884 #[test]
887 fn tag_delete_install_validation() {
888 validate_tag_delete_install(&install()).expect("a valid install passes");
889
890 for secret in [
891 "",
892 "abc",
893 &FAKE_SECRET.to_uppercase(),
894 &format!("{}g", &FAKE_SECRET[..63]),
895 &format!("{FAKE_SECRET}0"),
896 ] {
897 let mut bad = install();
898 bad.secret = secret.to_string();
899 let reason = invalid_reason(&bad);
900 assert!(reason.contains("secret"), "{reason}");
901 if !secret.is_empty() {
902 assert!(!reason.contains(secret), "the secret is never echoed");
903 }
904 }
905
906 for (field, value) in [
907 ("user source", ""),
908 ("user source", "a'b"),
909 ("user source", "tab\tname"),
910 ("user source", "naïve"),
911 ("role", ""),
912 ("role", "x\"y"),
913 ("role", "back\\slash"),
914 ("role", &"r".repeat(65)),
915 ] {
916 let mut bad = install();
917 if field == "role" {
918 bad.role = value.to_string();
919 } else {
920 bad.user_source = value.to_string();
921 }
922 let reason = invalid_reason(&bad);
923 assert!(reason.contains(field), "{field} {value:?}: {reason}");
924 }
925 let mut edge = install();
926 edge.role = "r".repeat(64);
927 edge.user_source = "A b_c-d.9".to_string();
928 validate_tag_delete_install(&edge).expect("64 chars and the full charset pass");
929
930 for providers in [
931 vec![],
932 vec!["System".to_string()],
933 vec!["P1".to_string(), "system".to_string()],
934 vec!["P1".to_string(), "p1".to_string()],
935 vec!["a'b".to_string()],
936 vec!["a\"b".to_string()],
937 vec!["a\\b".to_string()],
938 vec!["a\nb".to_string()],
939 vec![String::new()],
940 ] {
941 let mut bad = install();
942 bad.providers = providers.clone();
943 let reason = invalid_reason(&bad);
944 assert!(reason.contains("provider"), "{providers:?}: {reason}");
945 }
946 }
947
948 #[test]
952 fn tag_delete_pack_substitutes_every_marker() {
953 let zip = build_deploy_zip_with(DEFAULT_PROJECT, false, None, false, Some(&install()))
954 .expect("pack");
955 let packed = members(&zip);
956 let get = |name: &str| {
957 packed
958 .iter()
959 .find(|(member, _)| member == &format!("{TAG_DELETE_ROOT}/{name}"))
960 .map(|(_, body)| body.clone())
961 .unwrap_or_else(|| panic!("{name} member"))
962 };
963 assert_eq!(
964 packed
965 .iter()
966 .filter(|(name, _)| name.starts_with(TAG_DELETE_ROOT))
967 .count(),
968 3
969 );
970 assert_eq!(packed.len(), 16, "13 always-on members plus three");
971 for (name, body) in &packed {
972 assert!(!body.contains("__IGN_TAG_DELETE_"), "{name} keeps a marker");
973 }
974 let do_post = get("doPost.py");
975 assert!(do_post.contains(&format!("SECRET = None or '{FAKE_SECRET}'")));
976 assert!(do_post.contains(r#"PROVIDERS_JSON = None or '["P26Del","Other"]'"#));
977 let config: serde_json::Value = serde_json::from_str(&get("config.json")).expect("json");
978 assert_eq!(config["doPost"]["require-auth"], true);
979 assert_eq!(config["doPost"]["required-roles"], "Fake Deploy-Role_1.x");
980 assert_eq!(config["doPost"]["user-source"], "default");
981 assert_eq!(
982 get("resource.json"),
983 crate::webdev::TAG_DELETE_RESOURCE_JSON
984 );
985
986 let mut wide = install();
989 wide.providers = vec!["Prövider".to_string()];
990 let zip =
991 build_deploy_zip_with(DEFAULT_PROJECT, false, None, false, Some(&wide)).expect("pack");
992 let do_post = members(&zip)
993 .into_iter()
994 .find(|(name, _)| name.ends_with("tagDelete/doPost.py"))
995 .expect("doPost")
996 .1;
997 assert!(do_post.contains(r#"PROVIDERS_JSON = None or '["Pr\u00f6vider"]'"#));
998 assert!(do_post.is_ascii());
999
1000 let plain = build_deploy_zip(DEFAULT_PROJECT, true, Some(FAKE_SECRET), true).expect("pack");
1002 assert!(
1003 members(&plain)
1004 .iter()
1005 .all(|(name, _)| !name.contains("tagDelete"))
1006 );
1007 let all = build_deploy_zip_with(
1009 DEFAULT_PROJECT,
1010 true,
1011 Some(FAKE_SECRET),
1012 true,
1013 Some(&install()),
1014 )
1015 .expect("pack");
1016 let names: Vec<String> = members(&all).into_iter().map(|(name, _)| name).collect();
1017 assert!(names.iter().any(|name| name.contains("cli/scriptExec/")));
1018 assert!(names.iter().any(|name| name.contains("cli/tagDelete/")));
1019 assert!(names.iter().any(|name| name.contains("testing/")));
1020 }
1021
1022 #[test]
1025 fn tag_delete_pack_refuses_bad_installs_and_left_markers() {
1026 let mut bad = install();
1027 bad.providers = vec!["System".to_string()];
1028 let err = build_deploy_zip_with(DEFAULT_PROJECT, false, None, false, Some(&bad))
1029 .expect_err("System refused");
1030 assert_eq!(err.code(), "invalid_input");
1031
1032 let mut marker_provider = install();
1033 marker_provider.providers = vec!["__IGN_TAG_DELETE_SECRET__".to_string()];
1034 let err =
1035 build_deploy_zip_with(DEFAULT_PROJECT, false, None, false, Some(&marker_provider))
1036 .expect_err("a left marker refuses");
1037 assert!(matches!(err, CoreError::Internal(_)), "{err}");
1038
1039 let mut marker_role = install();
1040 marker_role.role = "__IGN_TAG_DELETE_ROLE__".to_string();
1041 let err = build_deploy_zip_with(DEFAULT_PROJECT, false, None, false, Some(&marker_role))
1042 .expect_err("a left marker refuses");
1043 assert!(matches!(err, CoreError::Internal(_)), "{err}");
1044 }
1045}