1pub mod testing;
24
25pub const ROUTE_BUNDLE_VERSION: &str = "1.3.0";
28
29pub const MIN_CLI: &str = "1.0";
31
32pub const ROUTE_FILES: &[(&str, &str)] = &[
41 (
43 "project.json",
44 include_str!("../../webdev/routes/project.json"),
45 ),
46 (
48 "com.inductiveautomation.webdev/resources/cli/tags/resource.json",
49 include_str!(
50 "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tags/resource.json"
51 ),
52 ),
53 (
54 "com.inductiveautomation.webdev/resources/cli/tags/config.json",
55 include_str!(
56 "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tags/config.json"
57 ),
58 ),
59 (
60 "com.inductiveautomation.webdev/resources/cli/tags/doPost.py",
61 include_str!(
62 "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tags/doPost.py"
63 ),
64 ),
65 (
67 "com.inductiveautomation.webdev/resources/cli/tagConfig/resource.json",
68 include_str!(
69 "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagConfig/resource.json"
70 ),
71 ),
72 (
73 "com.inductiveautomation.webdev/resources/cli/tagConfig/config.json",
74 include_str!(
75 "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagConfig/config.json"
76 ),
77 ),
78 (
79 "com.inductiveautomation.webdev/resources/cli/tagConfig/doPost.py",
80 include_str!(
81 "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagConfig/doPost.py"
82 ),
83 ),
84 (
86 "com.inductiveautomation.webdev/resources/cli/alarms/resource.json",
87 include_str!(
88 "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/alarms/resource.json"
89 ),
90 ),
91 (
92 "com.inductiveautomation.webdev/resources/cli/alarms/config.json",
93 include_str!(
94 "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/alarms/config.json"
95 ),
96 ),
97 (
98 "com.inductiveautomation.webdev/resources/cli/alarms/doPost.py",
99 include_str!(
100 "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/alarms/doPost.py"
101 ),
102 ),
103 (
105 "com.inductiveautomation.webdev/resources/cli/tagHistory/resource.json",
106 include_str!(
107 "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagHistory/resource.json"
108 ),
109 ),
110 (
111 "com.inductiveautomation.webdev/resources/cli/tagHistory/config.json",
112 include_str!(
113 "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagHistory/config.json"
114 ),
115 ),
116 (
117 "com.inductiveautomation.webdev/resources/cli/tagHistory/doPost.py",
118 include_str!(
119 "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagHistory/doPost.py"
120 ),
121 ),
122];
123
124pub const SCRIPT_EXEC_TEMPLATE: &str = include_str!(
133 "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/scriptExec/doPost.py"
134);
135
136pub const TAG_DELETE_TEMPLATE: &str = include_str!(
143 "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagDelete/doPost.py"
144);
145
146pub const TAG_DELETE_CONFIG_TEMPLATE: &str = include_str!(
150 "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagDelete/config.json"
151);
152
153pub const TAG_DELETE_RESOURCE_JSON: &str = include_str!(
155 "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagDelete/resource.json"
156);
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 const VERSION_FILE: &str = include_str!("../../webdev/routes/VERSION");
165
166 #[test]
170 fn route_sources_carry_the_embedded_handshake_constants() {
171 let route_version = format!("ROUTE_VERSION = '{}'", ROUTE_BUNDLE_VERSION);
172 let min_cli = format!("MIN_CLI = '{}'", MIN_CLI);
173 let mut do_post_count = 0;
174 for (name, contents) in ROUTE_FILES {
175 if name.ends_with("doPost.py") {
176 do_post_count += 1;
177 assert!(
178 contents.contains(&route_version),
179 "{name}: missing {route_version}"
180 );
181 assert!(contents.contains(&min_cli), "{name}: missing {min_cli}");
182 }
183 }
184 assert_eq!(do_post_count, 4, "expected the four always-on dispatchers");
185 assert_eq!(
186 VERSION_FILE.trim(),
187 ROUTE_BUNDLE_VERSION,
188 "webdev/routes/VERSION drifted from ROUTE_BUNDLE_VERSION"
189 );
190 }
191
192 #[test]
195 fn always_on_bundle_carries_no_secret_placeholder() {
196 for (name, contents) in ROUTE_FILES {
197 assert!(
198 !contents.contains("__IGN_CLI_SECRET__"),
199 "{name}: the secret placeholder must not ship in the always-on bundle"
200 );
201 }
202 }
203
204 #[test]
207 fn script_exec_template_is_substitutable_and_fail_closed() {
208 assert_eq!(
209 SCRIPT_EXEC_TEMPLATE.matches("__IGN_CLI_SECRET__").count(),
210 1,
211 "the deploy substitution needs exactly one marker occurrence"
212 );
213 assert!(
214 SCRIPT_EXEC_TEMPLATE.contains("SECRET = None"),
215 "the template must keep its fail-closed SECRET default"
216 );
217 }
218
219 #[test]
223 fn member_names_use_forward_slashes_only() {
224 for (name, _) in ROUTE_FILES {
225 assert!(!name.contains('\\'), "backslash in member name: {name}");
226 }
227 }
228
229 #[test]
233 fn manifest_lists_exactly_thirteen_members() {
234 assert_eq!(ROUTE_FILES.len(), 13);
235 assert_eq!(
236 ROUTE_FILES
237 .iter()
238 .filter(|(name, _)| *name == "project.json")
239 .count(),
240 1
241 );
242 assert_eq!(
243 ROUTE_FILES
244 .iter()
245 .filter(|(name, _)| name.ends_with("doPost.py"))
246 .count(),
247 4
248 );
249 for (name, _) in ROUTE_FILES {
250 assert!(
251 *name == "project.json"
252 || name.starts_with("com.inductiveautomation.webdev/resources/cli/"),
253 "member outside the Designer-native layout: {name}"
254 );
255 }
256 }
257
258 #[test]
262 fn tag_delete_templates_carry_each_marker_once() {
263 for marker in ["__IGN_TAG_DELETE_SECRET__", "__IGN_TAG_DELETE_PROVIDERS__"] {
264 assert_eq!(
265 TAG_DELETE_TEMPLATE.matches(marker).count(),
266 1,
267 "doPost.py: {marker}"
268 );
269 assert_eq!(TAG_DELETE_CONFIG_TEMPLATE.matches(marker).count(), 0);
270 }
271 for marker in ["__IGN_TAG_DELETE_ROLE__", "__IGN_TAG_DELETE_USER_SOURCE__"] {
272 assert_eq!(
273 TAG_DELETE_CONFIG_TEMPLATE.matches(marker).count(),
274 1,
275 "config.json: {marker}"
276 );
277 assert_eq!(TAG_DELETE_TEMPLATE.matches(marker).count(), 0);
278 }
279 assert!(TAG_DELETE_TEMPLATE.contains("SECRET = None or '__IGN_TAG_DELETE_SECRET__'"));
280 assert!(
281 TAG_DELETE_TEMPLATE.contains("PROVIDERS_JSON = None or '__IGN_TAG_DELETE_PROVIDERS__'")
282 );
283 let config: serde_json::Value =
284 serde_json::from_str(TAG_DELETE_CONFIG_TEMPLATE).expect("config template parses");
285 assert_eq!(config["doPost"]["require-auth"], true);
286 let resource: serde_json::Value =
287 serde_json::from_str(TAG_DELETE_RESOURCE_JSON).expect("resource.json parses");
288 assert_eq!(
289 resource["files"],
290 serde_json::json!(["config.json", "doPost.py"])
291 );
292 }
293
294 #[test]
298 fn tag_delete_template_has_one_write_call() {
299 assert!(
300 TAG_DELETE_TEMPLATE.contains(&format!("ROUTE_VERSION = '{}'", ROUTE_BUNDLE_VERSION))
301 );
302 assert!(TAG_DELETE_TEMPLATE.contains(&format!("MIN_CLI = '{}'", MIN_CLI)));
303 assert_eq!(
304 TAG_DELETE_TEMPLATE.matches("system.tag.deleteTags").count(),
305 1
306 );
307 for forbidden in [
308 "system.tag.configure",
309 "importTags",
310 "writeBlocking",
311 "writeAsync",
312 "exec ",
313 "eval(",
314 ] {
315 assert!(
316 !TAG_DELETE_TEMPLATE.contains(forbidden),
317 "tagDelete doPost.py must not hold {forbidden:?}"
318 );
319 }
320 assert!(!TAG_DELETE_TEMPLATE.contains("Bearer"));
322 }
323
324 #[test]
330 fn tag_delete_template_audits_around_the_write() {
331 let source = TAG_DELETE_TEMPLATE;
332 let at = |needle: &str| {
333 source
334 .find(needle)
335 .unwrap_or_else(|| panic!("doPost.py lacks {needle:?}"))
336 };
337 let begin = at("u'delete-begin ");
338 let write = at("system.tag.deleteTags(");
339 let finally = at("finally:");
340 let end = at("u'delete-end ");
341 assert!(begin < write, "the begin line precedes the write");
342 assert!(
343 write < finally && finally < end,
344 "the end line is in a finally"
345 );
346 assert!(
347 !source.contains("'existsAfter': bool(system.tag.exists("),
348 "an exists read must not raise out of the answer"
349 );
350 assert!(
351 source.contains("existsAfter = None"),
352 "a failed read is null"
353 );
354 let lines: Vec<&str> = source.lines().collect();
355 for (index, line) in lines.iter().enumerate() {
356 let bytes = line.as_bytes();
357 for i in 1..bytes.len().saturating_sub(1) {
358 let starts =
359 i == 1 || !(bytes[i - 2].is_ascii_alphanumeric() || bytes[i - 2] == b'_');
360 assert!(
361 !(bytes[i - 1] == b'f' && (bytes[i] == b'\'' || bytes[i] == b'"') && starts),
362 "doPost.py line {}: an f-string is not Jython 2.7: {line}",
363 index + 1
364 );
365 }
366 }
367 }
368
369 #[test]
371 fn always_on_bundle_carries_no_tag_delete_marker() {
372 for (name, contents) in ROUTE_FILES {
373 assert!(
374 !contents.contains("__IGN_TAG_DELETE_"),
375 "{name}: a tagDelete marker must not ship in the always-on bundle"
376 );
377 assert!(!name.contains("tagDelete"), "{name}");
378 }
379 }
380
381 #[test]
388 fn tagconfig_route_source_refuses_provider_roots() {
389 let (_, source) = ROUTE_FILES
390 .iter()
391 .find(|(name, _)| {
392 *name == "com.inductiveautomation.webdev/resources/cli/tagConfig/doPost.py"
393 })
394 .expect("tagConfig doPost.py in the manifest");
395 assert!(
396 source.contains("provider_root_unsupported"),
397 "the tagConfig route must keep its provider-root refusal"
398 );
399 assert!(
400 source.contains("def is_provider_root("),
401 "the bracket-form detector must stay nested inside doPost (byte-0 rule)"
402 );
403 assert!(
404 source.contains("'No RpcContext' in traceback.format_exc()"),
405 "the bare-form RpcContext translation must stay"
406 );
407 }
408}