use std::io::Write;
use serde_json::Value;
use crate::error::CoreError;
use crate::webdev as bundle;
pub const DEFAULT_PROJECT: &str = "ign-cli";
const SCRIPT_EXEC_ROUTE_ROOT: &str = "com.inductiveautomation.webdev/resources/cli/scriptExec";
const SCRIPT_EXEC_RESOURCE_JSON: &str = include_str!(
"../../webdev/routes/com.inductiveautomation.webdev/resources/cli/scriptExec/resource.json"
);
const SCRIPT_EXEC_CONFIG_JSON: &str = include_str!(
"../../webdev/routes/com.inductiveautomation.webdev/resources/cli/scriptExec/config.json"
);
pub fn always_on_routes() -> Vec<String> {
let mut routes = Vec::new();
for (name, _) in bundle::ROUTE_FILES {
let Some(rest) = name.strip_prefix("com.inductiveautomation.webdev/resources/cli/") else {
continue;
};
if let Some((route, file)) = rest.rsplit_once('/')
&& file == "doPost.py"
&& !routes.iter().any(|known: &String| known == route)
{
routes.push(route.to_string());
}
}
routes
}
pub(crate) fn route_url(project: &str, route: &str) -> String {
format!("/system/webdev/{project}/cli/{route}")
}
pub(crate) fn testing_route_url(project: &str, route: &str) -> String {
format!("/system/webdev/{project}/testing/{route}")
}
pub async fn testing_discover(
api: &super::ReqwestGatewayApi,
project: &str,
) -> Result<serde_json::Value, CoreError> {
api.post_json(
&testing_route_url(project, "run"),
&serde_json::json!({ "discover": true }),
)
.await?
.json::<serde_json::Value>()
.await
.map_err(|err| {
CoreError::Internal(format!(
"testing discover response did not match the expected shape: {err}"
))
})
}
pub async fn testing_run(
api: &super::ReqwestGatewayApi,
project: &str,
body: &serde_json::Value,
) -> Result<serde_json::Value, CoreError> {
let response = api
.post_json(&testing_route_url(project, "run"), body)
.await?;
response.json::<serde_json::Value>().await.map_err(|err| {
CoreError::Internal(format!(
"testing run response did not match the expected shape: {err}"
))
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TestingProbe {
Present {
discovered: serde_json::Value,
},
Absent,
Unlicensed,
AuthGated {
status: u16,
},
LazyCompile {
body: String,
},
}
pub async fn testing_probe(
api: &super::ReqwestGatewayApi,
project: &str,
) -> Result<TestingProbe, CoreError> {
let (url, response) = api
.testing_post_raw(project, "run", &serde_json::json!({"discover": true}))
.await?;
let status = response.status();
if status.is_success() {
let text = response.text().await.unwrap_or_default();
let discovered = serde_json::from_str::<Value>(&text).map_err(|err| {
CoreError::Internal(format!(
"testing discover response did not match the expected shape: {err}"
))
})?;
return Ok(TestingProbe::Present { discovered });
}
match status.as_u16() {
401 | 403 => Ok(TestingProbe::AuthGated {
status: status.as_u16(),
}),
402 => Ok(TestingProbe::Unlicensed),
404 | 405 | 501 => Ok(TestingProbe::Absent),
500 => Ok(TestingProbe::LazyCompile {
body: response.text().await.unwrap_or_default(),
}),
other => Err(CoreError::Internal(format!(
"unexpected HTTP {other} from the testing route probe at {url}"
))),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteProbe {
Present {
route_version: String,
},
Absent,
Unlicensed,
AuthGated,
Denied {
code: String,
message: String,
traceback: Option<String>,
},
}
#[derive(Debug)]
pub(crate) enum RouteBody {
Ok(Value),
Denied {
code: String,
message: String,
traceback: Option<String>,
},
}
pub(crate) fn parse_route_body(body: &str) -> Result<RouteBody, CoreError> {
let value: Value = serde_json::from_str(body).map_err(|err| {
CoreError::Internal(format!(
"webdev route answered a body that is not the {{ok, data|error}} envelope: {err}"
))
})?;
if value.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(RouteBody::Ok(
value.get("data").cloned().unwrap_or(Value::Null),
))
} else {
let code = value
.pointer("/error/code")
.and_then(Value::as_str)
.unwrap_or("route_error")
.to_string();
let message = value
.pointer("/error/message")
.and_then(Value::as_str)
.unwrap_or("(the route sent no message)")
.to_string();
let traceback = value
.pointer("/error/traceback")
.and_then(Value::as_str)
.map(str::to_string);
Ok(RouteBody::Denied {
code,
message,
traceback,
})
}
}
pub(crate) fn denial_to_error(
code: &str,
message: &str,
traceback: Option<&str>,
endpoint: String,
) -> CoreError {
match code {
"not_found" => CoreError::NotFound {
endpoint: Some(endpoint),
},
"no_alarm_journal" => CoreError::AlarmJournalMissing {
endpoint: Some(endpoint),
},
"provider_root_unsupported" => CoreError::ProviderRootUnsupported {
endpoint: Some(endpoint),
},
_ => {
let mut full = message.to_string();
if let Some(traceback) = traceback {
full.push_str("\nroute traceback: ");
full.push_str(traceback);
}
CoreError::WebdevRouteError {
code: code.to_string(),
message: full,
endpoint: Some(endpoint),
}
}
}
}
pub fn build_deploy_zip(
project_title: &str,
with_script_exec: bool,
secret: Option<&str>,
with_testing: bool,
) -> Result<Vec<u8>, CoreError> {
build_deploy_zip_with(project_title, with_script_exec, secret, with_testing, None)
}
#[derive(Clone, PartialEq, Eq)]
pub struct TagDeleteInstall {
pub secret: String,
pub user_source: String,
pub role: String,
pub providers: Vec<String>,
}
impl std::fmt::Debug for TagDeleteInstall {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TagDeleteInstall")
.field("secret", &"<redacted>")
.field("user_source", &self.user_source)
.field("role", &self.role)
.field("providers", &self.providers)
.finish()
}
}
const TAG_DELETE_ROUTE_ROOT: &str = "com.inductiveautomation.webdev/resources/cli/tagDelete";
const TAG_DELETE_MARKER_PREFIX: &str = "__IGN_TAG_DELETE_";
pub fn validate_tag_delete_install(install: &TagDeleteInstall) -> Result<(), CoreError> {
let invalid = |reason: String| Err(CoreError::InvalidInput { reason });
if install.secret.len() != 64
|| !install
.secret
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
{
return invalid("tag delete secret: must be 64 lowercase hex characters".into());
}
for (field, value) in [
("tag delete user source", &install.user_source),
("tag delete role", &install.role),
] {
let len = value.chars().count();
let charset_ok = value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '_' | '-' | '.'));
if !(1..=64).contains(&len) || !charset_ok {
return invalid(format!(
"{field} {value:?}: must be 1 to 64 characters of ASCII letters, digits, \
space, '_', '-' or '.'"
));
}
}
if install.providers.is_empty() {
return invalid(
"tag delete providers: the allowlist must name at least one provider".into(),
);
}
for (i, provider) in install.providers.iter().enumerate() {
if provider.is_empty() {
return invalid(format!(
"tag delete providers[{i}]: the provider name is empty"
));
}
if provider.eq_ignore_ascii_case("System") {
return invalid(format!(
"tag delete providers[{i}]: the System provider is read-only and never allowlisted"
));
}
if provider
.chars()
.any(|c| matches!(c, '\'' | '"' | '\\') || c.is_control())
{
return invalid(format!(
"tag delete providers[{i}] {provider:?}: the provider name holds a quote, a \
backslash or a control character"
));
}
if install.providers[..i]
.iter()
.any(|seen| seen.eq_ignore_ascii_case(provider))
{
return invalid(format!(
"tag delete providers[{i}] {provider:?}: duplicate provider"
));
}
}
Ok(())
}
fn ascii_json_list(providers: &[String]) -> Result<String, CoreError> {
let encoded = serde_json::to_string(providers)
.map_err(|err| CoreError::Internal(format!("cannot encode the provider list: {err}")))?;
let mut out = String::with_capacity(encoded.len());
for c in encoded.chars() {
if c.is_ascii() {
out.push(c);
} else {
let mut units = [0u16; 2];
for unit in c.encode_utf16(&mut units) {
out.push_str(&format!("\\u{unit:04x}"));
}
}
}
Ok(out)
}
fn tag_delete_members(install: &TagDeleteInstall) -> Result<Vec<(String, String)>, CoreError> {
validate_tag_delete_install(install)?;
let do_post = bundle::TAG_DELETE_TEMPLATE
.replacen("__IGN_TAG_DELETE_SECRET__", &install.secret, 1)
.replacen(
"__IGN_TAG_DELETE_PROVIDERS__",
&ascii_json_list(&install.providers)?,
1,
);
let mut config: Value =
serde_json::from_str(bundle::TAG_DELETE_CONFIG_TEMPLATE).map_err(|err| {
CoreError::Internal(format!("tagDelete config.json does not parse: {err}"))
})?;
let handler = config
.get_mut("doPost")
.and_then(Value::as_object_mut)
.ok_or_else(|| CoreError::Internal("tagDelete config.json has no doPost object".into()))?;
if handler.get("require-auth") != Some(&Value::Bool(true)) {
return Err(CoreError::Internal(
"tagDelete config.json must keep require-auth true".into(),
));
}
handler.insert("required-roles".into(), Value::String(install.role.clone()));
handler.insert(
"user-source".into(),
Value::String(install.user_source.clone()),
);
let config = serde_json::to_string(&config)
.map_err(|err| CoreError::Internal(format!("cannot re-serialize config.json: {err}")))?;
let members = vec![
(
format!("{TAG_DELETE_ROUTE_ROOT}/resource.json"),
bundle::TAG_DELETE_RESOURCE_JSON.to_string(),
),
(format!("{TAG_DELETE_ROUTE_ROOT}/config.json"), config),
(format!("{TAG_DELETE_ROUTE_ROOT}/doPost.py"), do_post),
];
if let Some((name, _)) = members
.iter()
.find(|(_, body)| body.contains(TAG_DELETE_MARKER_PREFIX))
{
return Err(CoreError::Internal(format!(
"{name} still holds a tagDelete install marker after substitution (fail-closed guard)"
)));
}
Ok(members)
}
pub fn build_deploy_zip_with(
project_title: &str,
with_script_exec: bool,
secret: Option<&str>,
with_testing: bool,
tag_delete: Option<&TagDeleteInstall>,
) -> Result<Vec<u8>, CoreError> {
let tag_delete_members = tag_delete.map(tag_delete_members).transpose()?;
let script_exec_py = match (with_script_exec, secret) {
(false, _) => None,
(true, Some(secret)) => {
Some(bundle::SCRIPT_EXEC_TEMPLATE.replace("__IGN_CLI_SECRET__", secret))
}
(true, None) => {
return Err(CoreError::Internal(
"scriptExec deploy requires a substituted secret — the deploy \
action generates the secret before packing (fail-closed guard)"
.into(),
));
}
};
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for (name, contents) in bundle::ROUTE_FILES {
let body = if *name == "project.json" && project_title != DEFAULT_PROJECT {
retitle_project_json(contents, project_title)?
} else {
(*contents).to_string()
};
writer.start_file(*name, options).map_err(zip_write_err)?;
writer.write_all(body.as_bytes()).map_err(|err| {
CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
})?;
}
if let Some(script_exec_py) = script_exec_py {
for (name, body) in [
(
format!("{SCRIPT_EXEC_ROUTE_ROOT}/resource.json"),
SCRIPT_EXEC_RESOURCE_JSON.to_string(),
),
(
format!("{SCRIPT_EXEC_ROUTE_ROOT}/config.json"),
SCRIPT_EXEC_CONFIG_JSON.to_string(),
),
(
format!("{SCRIPT_EXEC_ROUTE_ROOT}/doPost.py"),
script_exec_py,
),
] {
writer
.start_file(name.as_str(), options)
.map_err(zip_write_err)?;
writer.write_all(body.as_bytes()).map_err(|err| {
CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
})?;
}
}
for (name, body) in tag_delete_members.into_iter().flatten() {
writer
.start_file(name.as_str(), options)
.map_err(zip_write_err)?;
writer.write_all(body.as_bytes()).map_err(|err| {
CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
})?;
}
if with_testing {
let marker_total: usize = bundle::testing::TESTING_FILES
.iter()
.map(|(_, contents)| contents.matches(bundle::testing::PROJECT_MARKER).count())
.sum();
if marker_total != bundle::testing::PROJECT_MARKER_COUNT {
return Err(CoreError::Internal(format!(
"testing bundle marker count drifted: found {marker_total}, pinned {} — \
a template edit changed the substitution contract",
bundle::testing::PROJECT_MARKER_COUNT
)));
}
for (name, contents) in bundle::testing::TESTING_FILES {
let body = contents.replace(bundle::testing::PROJECT_MARKER, project_title);
writer.start_file(*name, options).map_err(zip_write_err)?;
writer.write_all(body.as_bytes()).map_err(|err| {
CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
})?;
}
}
writer
.finish()
.map_err(zip_write_err)
.map(|cursor| cursor.into_inner())
}
fn zip_write_err(err: zip::result::ZipError) -> CoreError {
CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
}
fn retitle_project_json(project_json: &str, title: &str) -> Result<String, CoreError> {
let mut value: Value = serde_json::from_str(project_json).map_err(|err| {
CoreError::Internal(format!("embedded project.json does not parse: {err}"))
})?;
value["title"] = Value::String(title.to_string());
serde_json::to_string(&value)
.map_err(|err| CoreError::Internal(format!("cannot re-serialize project.json: {err}")))
}
#[cfg(test)]
mod tests {
use super::{
DEFAULT_PROJECT, RouteBody, TagDeleteInstall, always_on_routes, build_deploy_zip,
build_deploy_zip_with, denial_to_error, parse_route_body, validate_tag_delete_install,
};
use crate::error::CoreError;
#[test]
fn always_on_routes_derive_from_the_manifest() {
assert_eq!(
always_on_routes(),
vec![
"tags".to_string(),
"tagConfig".to_string(),
"alarms".to_string(),
"tagHistory".to_string(),
]
);
}
#[test]
fn parse_route_body_envelope_shapes() {
match parse_route_body(r#"{"ok":true,"data":{"routeVersion":"1.0.0"}}"#)
.expect("ok body parses")
{
RouteBody::Ok(data) => {
assert_eq!(data["routeVersion"], "1.0.0");
}
other => panic!("wrong verdict: {other:?}"),
}
match parse_route_body(
r#"{"ok":false,"error":{"code":"secret_mismatch","message":"nope"}}"#,
)
.expect("denial parses")
{
RouteBody::Denied {
code,
message,
traceback,
} => {
assert_eq!(code, "secret_mismatch");
assert_eq!(message, "nope");
assert!(traceback.is_none(), "no traceback on the wire");
}
other => panic!("wrong verdict: {other:?}"),
}
match parse_route_body(
r#"{"ok":false,"error":{"code":"route_error","message":"boom","traceback":"Traceback (most recent call last):\n ValueError: nope"}}"#,
)
.expect("denial with traceback parses")
{
RouteBody::Denied { code, traceback, .. } => {
assert_eq!(code, "route_error");
assert_eq!(
traceback.as_deref(),
Some("Traceback (most recent call last):\n ValueError: nope")
);
}
other => panic!("wrong verdict: {other:?}"),
}
match parse_route_body(r#"{"ok":true}"#).expect("bare ok parses") {
RouteBody::Ok(data) => assert!(data.is_null()),
other => panic!("wrong verdict: {other:?}"),
}
match parse_route_body(r#"{"ok":false}"#).expect("bare denial parses") {
RouteBody::Denied { code, .. } => assert_eq!(code, "route_error"),
other => panic!("wrong verdict: {other:?}"),
}
let err = parse_route_body("<html>jetty</html>").expect_err("non-envelope fails");
assert!(matches!(err, CoreError::Internal(_)), "{err}");
}
#[test]
fn denial_mapping_reuses_not_found_and_rides_the_rest() {
let not_found = denial_to_error("not_found", "no such path", None, "/x".into());
assert_eq!(not_found.code(), "not_found");
assert_eq!(not_found.exit_code(), 6);
let secret = denial_to_error("secret_required", "missing header", None, "/x".into());
assert_eq!(secret.code(), "webdev_route_error");
assert_eq!(secret.exit_code(), 6);
assert!(secret.to_string().contains("secret_required"));
assert!(
secret.to_string().contains("missing header"),
"no traceback → the message rides VERBATIM (no suffix)"
);
let blown = denial_to_error(
"route_error",
"error processing action",
Some("java.lang.IllegalArgumentException: Invalid UUID string: 3f2504e0"),
"/x".into(),
);
assert_eq!(blown.code(), "webdev_route_error");
let text = blown.to_string();
assert!(
text.contains("\nroute traceback: java.lang.IllegalArgumentException: Invalid UUID string: 3f2504e0"),
"the traceback rides the message: {text}"
);
let journal = denial_to_error(
"no_alarm_journal",
"No alarm journal profile specified",
None,
"/system/webdev/ign-cli/cli/alarms".into(),
);
assert_eq!(journal.code(), "alarm_journal_missing");
assert_eq!(journal.exit_code(), 6);
assert!(
journal.hint().unwrap().contains("journal profile"),
"hint names the chain: {journal}"
);
let root = denial_to_error(
"provider_root_unsupported",
"provider-root tag paths are not supported on WebDev threads (no RpcContext) -- use a subtree path like [provider]folder",
None,
"/system/webdev/ign-cli/cli/tagConfig".into(),
);
assert_eq!(root.code(), "provider_root_unsupported");
assert_eq!(root.exit_code(), 6);
assert!(
root.to_string().contains("subtree like [provider]folder"),
"the fixed Display names the subtree workaround: {root}"
);
}
#[test]
fn deploy_zip_fails_closed_without_a_script_exec_secret() {
let err = build_deploy_zip(DEFAULT_PROJECT, true, None, false).expect_err("must refuse");
assert!(matches!(err, CoreError::Internal(_)), "{err}");
assert_eq!(err.exit_code(), 1);
}
const FAKE_SECRET: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const TAG_DELETE_ROOT: &str = "com.inductiveautomation.webdev/resources/cli/tagDelete";
fn install() -> TagDeleteInstall {
TagDeleteInstall {
secret: FAKE_SECRET.to_string(),
user_source: "default".to_string(),
role: "Fake Deploy-Role_1.x".to_string(),
providers: vec!["P26Del".to_string(), "Other".to_string()],
}
}
fn members(zip_bytes: &[u8]) -> Vec<(String, String)> {
let mut archive =
zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).expect("built zip is readable");
(0..archive.len())
.map(|index| {
let mut file = archive.by_index(index).expect("member");
let mut body = String::new();
std::io::Read::read_to_string(&mut file, &mut body).expect("member reads");
(file.name().to_string(), body)
})
.collect()
}
fn invalid_reason(install: &TagDeleteInstall) -> String {
match validate_tag_delete_install(install) {
Err(CoreError::InvalidInput { reason }) => reason,
other => panic!("expected invalid_input, got {other:?}"),
}
}
#[test]
fn tag_delete_install_validation() {
validate_tag_delete_install(&install()).expect("a valid install passes");
for secret in [
"",
"abc",
&FAKE_SECRET.to_uppercase(),
&format!("{}g", &FAKE_SECRET[..63]),
&format!("{FAKE_SECRET}0"),
] {
let mut bad = install();
bad.secret = secret.to_string();
let reason = invalid_reason(&bad);
assert!(reason.contains("secret"), "{reason}");
if !secret.is_empty() {
assert!(!reason.contains(secret), "the secret is never echoed");
}
}
for (field, value) in [
("user source", ""),
("user source", "a'b"),
("user source", "tab\tname"),
("user source", "naïve"),
("role", ""),
("role", "x\"y"),
("role", "back\\slash"),
("role", &"r".repeat(65)),
] {
let mut bad = install();
if field == "role" {
bad.role = value.to_string();
} else {
bad.user_source = value.to_string();
}
let reason = invalid_reason(&bad);
assert!(reason.contains(field), "{field} {value:?}: {reason}");
}
let mut edge = install();
edge.role = "r".repeat(64);
edge.user_source = "A b_c-d.9".to_string();
validate_tag_delete_install(&edge).expect("64 chars and the full charset pass");
for providers in [
vec![],
vec!["System".to_string()],
vec!["P1".to_string(), "system".to_string()],
vec!["P1".to_string(), "p1".to_string()],
vec!["a'b".to_string()],
vec!["a\"b".to_string()],
vec!["a\\b".to_string()],
vec!["a\nb".to_string()],
vec![String::new()],
] {
let mut bad = install();
bad.providers = providers.clone();
let reason = invalid_reason(&bad);
assert!(reason.contains("provider"), "{providers:?}: {reason}");
}
}
#[test]
fn tag_delete_pack_substitutes_every_marker() {
let zip = build_deploy_zip_with(DEFAULT_PROJECT, false, None, false, Some(&install()))
.expect("pack");
let packed = members(&zip);
let get = |name: &str| {
packed
.iter()
.find(|(member, _)| member == &format!("{TAG_DELETE_ROOT}/{name}"))
.map(|(_, body)| body.clone())
.unwrap_or_else(|| panic!("{name} member"))
};
assert_eq!(
packed
.iter()
.filter(|(name, _)| name.starts_with(TAG_DELETE_ROOT))
.count(),
3
);
assert_eq!(packed.len(), 16, "13 always-on members plus three");
for (name, body) in &packed {
assert!(!body.contains("__IGN_TAG_DELETE_"), "{name} keeps a marker");
}
let do_post = get("doPost.py");
assert!(do_post.contains(&format!("SECRET = None or '{FAKE_SECRET}'")));
assert!(do_post.contains(r#"PROVIDERS_JSON = None or '["P26Del","Other"]'"#));
let config: serde_json::Value = serde_json::from_str(&get("config.json")).expect("json");
assert_eq!(config["doPost"]["require-auth"], true);
assert_eq!(config["doPost"]["required-roles"], "Fake Deploy-Role_1.x");
assert_eq!(config["doPost"]["user-source"], "default");
assert_eq!(
get("resource.json"),
crate::webdev::TAG_DELETE_RESOURCE_JSON
);
let mut wide = install();
wide.providers = vec!["Prövider".to_string()];
let zip =
build_deploy_zip_with(DEFAULT_PROJECT, false, None, false, Some(&wide)).expect("pack");
let do_post = members(&zip)
.into_iter()
.find(|(name, _)| name.ends_with("tagDelete/doPost.py"))
.expect("doPost")
.1;
assert!(do_post.contains(r#"PROVIDERS_JSON = None or '["Pr\u00f6vider"]'"#));
assert!(do_post.is_ascii());
let plain = build_deploy_zip(DEFAULT_PROJECT, true, Some(FAKE_SECRET), true).expect("pack");
assert!(
members(&plain)
.iter()
.all(|(name, _)| !name.contains("tagDelete"))
);
let all = build_deploy_zip_with(
DEFAULT_PROJECT,
true,
Some(FAKE_SECRET),
true,
Some(&install()),
)
.expect("pack");
let names: Vec<String> = members(&all).into_iter().map(|(name, _)| name).collect();
assert!(names.iter().any(|name| name.contains("cli/scriptExec/")));
assert!(names.iter().any(|name| name.contains("cli/tagDelete/")));
assert!(names.iter().any(|name| name.contains("testing/")));
}
#[test]
fn tag_delete_pack_refuses_bad_installs_and_left_markers() {
let mut bad = install();
bad.providers = vec!["System".to_string()];
let err = build_deploy_zip_with(DEFAULT_PROJECT, false, None, false, Some(&bad))
.expect_err("System refused");
assert_eq!(err.code(), "invalid_input");
let mut marker_provider = install();
marker_provider.providers = vec!["__IGN_TAG_DELETE_SECRET__".to_string()];
let err =
build_deploy_zip_with(DEFAULT_PROJECT, false, None, false, Some(&marker_provider))
.expect_err("a left marker refuses");
assert!(matches!(err, CoreError::Internal(_)), "{err}");
let mut marker_role = install();
marker_role.role = "__IGN_TAG_DELETE_ROLE__".to_string();
let err = build_deploy_zip_with(DEFAULT_PROJECT, false, None, false, Some(&marker_role))
.expect_err("a left marker refuses");
assert!(matches!(err, CoreError::Internal(_)), "{err}");
}
}