pub mod testing;
pub const ROUTE_BUNDLE_VERSION: &str = "1.3.0";
pub const MIN_CLI: &str = "1.0";
pub const ROUTE_FILES: &[(&str, &str)] = &[
(
"project.json",
include_str!("../../webdev/routes/project.json"),
),
(
"com.inductiveautomation.webdev/resources/cli/tags/resource.json",
include_str!(
"../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tags/resource.json"
),
),
(
"com.inductiveautomation.webdev/resources/cli/tags/config.json",
include_str!(
"../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tags/config.json"
),
),
(
"com.inductiveautomation.webdev/resources/cli/tags/doPost.py",
include_str!(
"../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tags/doPost.py"
),
),
(
"com.inductiveautomation.webdev/resources/cli/tagConfig/resource.json",
include_str!(
"../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagConfig/resource.json"
),
),
(
"com.inductiveautomation.webdev/resources/cli/tagConfig/config.json",
include_str!(
"../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagConfig/config.json"
),
),
(
"com.inductiveautomation.webdev/resources/cli/tagConfig/doPost.py",
include_str!(
"../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagConfig/doPost.py"
),
),
(
"com.inductiveautomation.webdev/resources/cli/alarms/resource.json",
include_str!(
"../../webdev/routes/com.inductiveautomation.webdev/resources/cli/alarms/resource.json"
),
),
(
"com.inductiveautomation.webdev/resources/cli/alarms/config.json",
include_str!(
"../../webdev/routes/com.inductiveautomation.webdev/resources/cli/alarms/config.json"
),
),
(
"com.inductiveautomation.webdev/resources/cli/alarms/doPost.py",
include_str!(
"../../webdev/routes/com.inductiveautomation.webdev/resources/cli/alarms/doPost.py"
),
),
(
"com.inductiveautomation.webdev/resources/cli/tagHistory/resource.json",
include_str!(
"../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagHistory/resource.json"
),
),
(
"com.inductiveautomation.webdev/resources/cli/tagHistory/config.json",
include_str!(
"../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagHistory/config.json"
),
),
(
"com.inductiveautomation.webdev/resources/cli/tagHistory/doPost.py",
include_str!(
"../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagHistory/doPost.py"
),
),
];
pub const SCRIPT_EXEC_TEMPLATE: &str = include_str!(
"../../webdev/routes/com.inductiveautomation.webdev/resources/cli/scriptExec/doPost.py"
);
pub const TAG_DELETE_TEMPLATE: &str = include_str!(
"../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagDelete/doPost.py"
);
pub const TAG_DELETE_CONFIG_TEMPLATE: &str = include_str!(
"../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagDelete/config.json"
);
pub const TAG_DELETE_RESOURCE_JSON: &str = include_str!(
"../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagDelete/resource.json"
);
#[cfg(test)]
mod tests {
use super::*;
const VERSION_FILE: &str = include_str!("../../webdev/routes/VERSION");
#[test]
fn route_sources_carry_the_embedded_handshake_constants() {
let route_version = format!("ROUTE_VERSION = '{}'", ROUTE_BUNDLE_VERSION);
let min_cli = format!("MIN_CLI = '{}'", MIN_CLI);
let mut do_post_count = 0;
for (name, contents) in ROUTE_FILES {
if name.ends_with("doPost.py") {
do_post_count += 1;
assert!(
contents.contains(&route_version),
"{name}: missing {route_version}"
);
assert!(contents.contains(&min_cli), "{name}: missing {min_cli}");
}
}
assert_eq!(do_post_count, 4, "expected the four always-on dispatchers");
assert_eq!(
VERSION_FILE.trim(),
ROUTE_BUNDLE_VERSION,
"webdev/routes/VERSION drifted from ROUTE_BUNDLE_VERSION"
);
}
#[test]
fn always_on_bundle_carries_no_secret_placeholder() {
for (name, contents) in ROUTE_FILES {
assert!(
!contents.contains("__IGN_CLI_SECRET__"),
"{name}: the secret placeholder must not ship in the always-on bundle"
);
}
}
#[test]
fn script_exec_template_is_substitutable_and_fail_closed() {
assert_eq!(
SCRIPT_EXEC_TEMPLATE.matches("__IGN_CLI_SECRET__").count(),
1,
"the deploy substitution needs exactly one marker occurrence"
);
assert!(
SCRIPT_EXEC_TEMPLATE.contains("SECRET = None"),
"the template must keep its fail-closed SECRET default"
);
}
#[test]
fn member_names_use_forward_slashes_only() {
for (name, _) in ROUTE_FILES {
assert!(!name.contains('\\'), "backslash in member name: {name}");
}
}
#[test]
fn manifest_lists_exactly_thirteen_members() {
assert_eq!(ROUTE_FILES.len(), 13);
assert_eq!(
ROUTE_FILES
.iter()
.filter(|(name, _)| *name == "project.json")
.count(),
1
);
assert_eq!(
ROUTE_FILES
.iter()
.filter(|(name, _)| name.ends_with("doPost.py"))
.count(),
4
);
for (name, _) in ROUTE_FILES {
assert!(
*name == "project.json"
|| name.starts_with("com.inductiveautomation.webdev/resources/cli/"),
"member outside the Designer-native layout: {name}"
);
}
}
#[test]
fn tag_delete_templates_carry_each_marker_once() {
for marker in ["__IGN_TAG_DELETE_SECRET__", "__IGN_TAG_DELETE_PROVIDERS__"] {
assert_eq!(
TAG_DELETE_TEMPLATE.matches(marker).count(),
1,
"doPost.py: {marker}"
);
assert_eq!(TAG_DELETE_CONFIG_TEMPLATE.matches(marker).count(), 0);
}
for marker in ["__IGN_TAG_DELETE_ROLE__", "__IGN_TAG_DELETE_USER_SOURCE__"] {
assert_eq!(
TAG_DELETE_CONFIG_TEMPLATE.matches(marker).count(),
1,
"config.json: {marker}"
);
assert_eq!(TAG_DELETE_TEMPLATE.matches(marker).count(), 0);
}
assert!(TAG_DELETE_TEMPLATE.contains("SECRET = None or '__IGN_TAG_DELETE_SECRET__'"));
assert!(
TAG_DELETE_TEMPLATE.contains("PROVIDERS_JSON = None or '__IGN_TAG_DELETE_PROVIDERS__'")
);
let config: serde_json::Value =
serde_json::from_str(TAG_DELETE_CONFIG_TEMPLATE).expect("config template parses");
assert_eq!(config["doPost"]["require-auth"], true);
let resource: serde_json::Value =
serde_json::from_str(TAG_DELETE_RESOURCE_JSON).expect("resource.json parses");
assert_eq!(
resource["files"],
serde_json::json!(["config.json", "doPost.py"])
);
}
#[test]
fn tag_delete_template_has_one_write_call() {
assert!(
TAG_DELETE_TEMPLATE.contains(&format!("ROUTE_VERSION = '{}'", ROUTE_BUNDLE_VERSION))
);
assert!(TAG_DELETE_TEMPLATE.contains(&format!("MIN_CLI = '{}'", MIN_CLI)));
assert_eq!(
TAG_DELETE_TEMPLATE.matches("system.tag.deleteTags").count(),
1
);
for forbidden in [
"system.tag.configure",
"importTags",
"writeBlocking",
"writeAsync",
"exec ",
"eval(",
] {
assert!(
!TAG_DELETE_TEMPLATE.contains(forbidden),
"tagDelete doPost.py must not hold {forbidden:?}"
);
}
assert!(!TAG_DELETE_TEMPLATE.contains("Bearer"));
}
#[test]
fn tag_delete_template_audits_around_the_write() {
let source = TAG_DELETE_TEMPLATE;
let at = |needle: &str| {
source
.find(needle)
.unwrap_or_else(|| panic!("doPost.py lacks {needle:?}"))
};
let begin = at("u'delete-begin ");
let write = at("system.tag.deleteTags(");
let finally = at("finally:");
let end = at("u'delete-end ");
assert!(begin < write, "the begin line precedes the write");
assert!(
write < finally && finally < end,
"the end line is in a finally"
);
assert!(
!source.contains("'existsAfter': bool(system.tag.exists("),
"an exists read must not raise out of the answer"
);
assert!(
source.contains("existsAfter = None"),
"a failed read is null"
);
let lines: Vec<&str> = source.lines().collect();
for (index, line) in lines.iter().enumerate() {
let bytes = line.as_bytes();
for i in 1..bytes.len().saturating_sub(1) {
let starts =
i == 1 || !(bytes[i - 2].is_ascii_alphanumeric() || bytes[i - 2] == b'_');
assert!(
!(bytes[i - 1] == b'f' && (bytes[i] == b'\'' || bytes[i] == b'"') && starts),
"doPost.py line {}: an f-string is not Jython 2.7: {line}",
index + 1
);
}
}
}
#[test]
fn always_on_bundle_carries_no_tag_delete_marker() {
for (name, contents) in ROUTE_FILES {
assert!(
!contents.contains("__IGN_TAG_DELETE_"),
"{name}: a tagDelete marker must not ship in the always-on bundle"
);
assert!(!name.contains("tagDelete"), "{name}");
}
}
#[test]
fn tagconfig_route_source_refuses_provider_roots() {
let (_, source) = ROUTE_FILES
.iter()
.find(|(name, _)| {
*name == "com.inductiveautomation.webdev/resources/cli/tagConfig/doPost.py"
})
.expect("tagConfig doPost.py in the manifest");
assert!(
source.contains("provider_root_unsupported"),
"the tagConfig route must keep its provider-root refusal"
);
assert!(
source.contains("def is_provider_root("),
"the bracket-form detector must stay nested inside doPost (byte-0 rule)"
);
assert!(
source.contains("'No RpcContext' in traceback.format_exc()"),
"the bare-form RpcContext translation must stay"
);
}
}