1#![doc = include_str!("../README.md")]
2
3use candid::Principal;
4use ic_cdk::management_canister::CanisterSettings;
5use ic_http_certification::{HttpRequest, HttpResponse};
6use ic_ledger_types::{AccountIdentifier, Memo, Subaccount, Tokens, TransferArgs};
7use my_canister_dashboard::{
8 ALTERNATIVE_ORIGINS_PATH, CANISTER_DASHBOARD_CSS_PATH, CANISTER_DASHBOARD_HTML_PATH,
9 CANISTER_DASHBOARD_JS_PATH, CyclesAmount, ManageAlternativeOriginsArg,
10 ManageAlternativeOriginsResult, ManageIIPrincipalArg, ManageIIPrincipalResult,
11 ManageTopUpRuleArg, ManageTopUpRuleResult, TopUpInterval, TopUpRule, WasmStatus,
12};
13use pocket_ic::common::rest::{IcpFeatures, IcpFeaturesConfig};
14use pocket_ic::{query_candid, update_candid_as};
15use sha2::{Digest, Sha256};
16use std::time::Duration;
17
18const II_PRINCIPAL_AT_INSTALLER_APP_BYTE: u8 = 255;
20const II_PRINCIPAL_AT_USER_CONTROLLED_DAPP_BYTE: u8 = 254;
21const STRANGER_PRINCIPAL_BYTE: u8 = 253;
22
23pub const MIN_CANISTER_CREATION_BALANCE: u128 = 500_000_000_000;
25
26pub const ICP_LEDGER_CANISTER_ID_TEXT: &str = "ryjl3-tyaaa-aaaaa-aaaba-cai";
29pub const ICP_INDEX_CANISTER_ID_TEXT: &str = "qhbym-qaaaa-aaaaa-aaafq-cai";
31pub const CMC_CANISTER_ID_TEXT: &str = "rkp4c-7iaaa-aaaaa-aaaca-cai";
33pub const II_CANISTER_ID_TEXT: &str = "rdmx6-jaaaa-aaaaa-aaadq-cai";
35
36pub const LEDGER_INIT_TRANSFER_FEE_E8S: u64 = 10_000;
38pub const LEDGER_PREFUND_E8S: u64 = 20_000_000_000;
40
41pub fn ii_principal_at_installer_app() -> Principal {
43 Principal::from_slice(&[II_PRINCIPAL_AT_INSTALLER_APP_BYTE; 29])
44}
45
46pub fn ii_principal_at_user_controlled_dapp() -> Principal {
48 Principal::from_slice(&[II_PRINCIPAL_AT_USER_CONTROLLED_DAPP_BYTE; 29])
49}
50
51pub fn stranger_principal() -> Principal {
53 Principal::from_slice(&[STRANGER_PRINCIPAL_BYTE; 29])
54}
55
56pub fn compute_asset_hash(data: &[u8]) -> String {
58 hex::encode(Sha256::digest(data))
59}
60
61#[derive(Debug, Clone)]
63pub struct AssetHashes {
64 pub html_hash: String,
65 pub js_hash: String,
66 pub css_hash: String,
67}
68
69pub fn compute_frontend_asset_hashes(
71 index_html: &[u8],
72 index_js: &[u8],
73 style_css: &[u8],
74) -> AssetHashes {
75 AssetHashes {
76 html_hash: compute_asset_hash(index_html),
77 js_hash: compute_asset_hash(index_js),
78 css_hash: compute_asset_hash(style_css),
79 }
80}
81
82pub fn validate_html_structure(html: &[u8]) -> Result<(), String> {
84 let content = String::from_utf8_lossy(html);
85
86 if !content.contains("<!DOCTYPE html>") && !content.contains("<!doctype html>") {
87 return Err("HTML missing DOCTYPE declaration".to_string());
88 }
89 if !content.contains("<html") {
90 return Err("HTML missing <html> tag".to_string());
91 }
92 if !content.contains("</html>") {
93 return Err("HTML missing closing </html> tag".to_string());
94 }
95 if !content.contains("<head") || !content.contains("</head>") {
96 return Err("HTML missing <head> section".to_string());
97 }
98 if !content.contains("<body") || !content.contains("</body>") {
99 return Err("HTML missing <body> section".to_string());
100 }
101
102 Ok(())
103}
104
105pub fn validate_js_structure(js: &[u8]) -> Result<(), String> {
107 if js.is_empty() {
108 return Err("JavaScript file is empty".to_string());
109 }
110 if js.len() < 100 {
111 return Err(format!(
112 "JavaScript file is suspiciously small: {} bytes",
113 js.len()
114 ));
115 }
116 if String::from_utf8(js.to_vec()).is_err() {
117 return Err("JavaScript file contains invalid UTF-8".to_string());
118 }
119
120 Ok(())
121}
122
123pub fn validate_css_structure(css: &[u8]) -> Result<(), String> {
125 if css.is_empty() {
126 return Err("CSS file is empty".to_string());
127 }
128
129 let content = String::from_utf8(css.to_vec())
130 .map_err(|_| "CSS file contains invalid UTF-8".to_string())?;
131
132 if !content.contains('{') || !content.contains('}') {
133 return Err("CSS file doesn't appear to contain any style rules".to_string());
134 }
135
136 Ok(())
137}
138
139pub fn validate_frontend_assets(
141 index_html: &[u8],
142 index_js: &[u8],
143 style_css: &[u8],
144) -> Result<AssetHashes, String> {
145 validate_html_structure(index_html)?;
146 validate_js_structure(index_js)?;
147 validate_css_structure(style_css)?;
148
149 Ok(compute_frontend_asset_hashes(
150 index_html, index_js, style_css,
151 ))
152}
153
154use my_canister_dashboard::ASSET_HASHES_JSON;
157
158pub fn verify_asset_hashes_match_known_version(hashes: &AssetHashes) -> Result<String, String> {
163 let entries: serde_json::Value = serde_json::from_str(ASSET_HASHES_JSON)
164 .expect("Failed to parse embedded asset-hashes.json");
165
166 let entries = entries
167 .as_array()
168 .expect("asset-hashes.json must be a JSON array");
169
170 for entry in entries {
171 let version = entry["version"].as_str().unwrap_or("unknown");
172 let html = entry["html"].as_str().unwrap_or("");
173 let js = entry["js"].as_str().unwrap_or("");
174 let css = entry["css"].as_str().unwrap_or("");
175
176 if hashes.html_hash == html && hashes.js_hash == js && hashes.css_hash == css {
177 return Ok(version.to_string());
178 }
179 }
180
181 let mut msg = String::from(
182 "Dashboard asset hash mismatch: served assets do not match any known dashboard version.\n\n",
183 );
184 msg.push_str(" Served hashes:\n");
185 msg.push_str(&format!(" html: {}\n", hashes.html_hash));
186 msg.push_str(&format!(" js: {}\n", hashes.js_hash));
187 msg.push_str(&format!(" css: {}\n\n", hashes.css_hash));
188
189 if entries.is_empty() {
190 msg.push_str(" No known versions in asset-hashes.json (empty).\n");
191 } else {
192 msg.push_str(" Known versions:\n");
193 for entry in entries {
194 let v = entry["version"].as_str().unwrap_or("?");
195 let h = &entry["html"].as_str().unwrap_or("?");
196 let j = &entry["js"].as_str().unwrap_or("?");
197 let c = &entry["css"].as_str().unwrap_or("?");
198 msg.push_str(&format!(
199 " {v}: html={}... js={}... css={}...\n",
200 &h[..h.len().min(16)],
201 &j[..j.len().min(16)],
202 &c[..c.len().min(16)],
203 ));
204 }
205 }
206
207 msg.push_str(
208 "\nRebuild with a released dashboard version or record hashes via:\n ./scripts/pre-release-mcd.sh\n",
209 );
210 Err(msg)
211}
212
213pub fn run_acceptance_suite(wasm_bytes: &[u8], wasm_label: &str) {
228 let pic = pocket_ic::PocketIcBuilder::new()
232 .with_icp_features(IcpFeatures {
233 cycles_minting: Some(IcpFeaturesConfig::DefaultConfig),
234 icp_token: Some(IcpFeaturesConfig::DefaultConfig),
235 ..Default::default()
236 })
237 .build();
238
239 let ledger_id = Principal::from_text(ICP_LEDGER_CANISTER_ID_TEXT).unwrap();
240
241 let user = ii_principal_at_installer_app(); let owner = ii_principal_at_user_controlled_dapp(); let stranger = stranger_principal(); println!("\n=== Acceptance suite: {wasm_label} ===");
247 println!(" installer: {user}");
248 println!(" owner: {owner}");
249
250 let canister_id = pic.create_canister_with_settings(Some(user), None);
252 pic.add_cycles(canister_id, MIN_CANISTER_CREATION_BALANCE);
253 println!(" canister: {canister_id}\n");
254
255 let status = pic
257 .canister_status(canister_id, Some(user))
258 .expect("Failed to get canister status");
259 assert_eq!(status.settings.controllers, vec![user]);
260
261 pic.install_canister(canister_id, wasm_bytes.to_vec(), vec![], Some(user));
262
263 let canister_ai = AccountIdentifier::new(&canister_id, &Subaccount([0; 32]));
265 let transfer_args = TransferArgs {
266 memo: Memo(0),
267 amount: Tokens::from_e8s(LEDGER_PREFUND_E8S),
268 fee: Tokens::from_e8s(LEDGER_INIT_TRANSFER_FEE_E8S),
269 from_subaccount: None,
270 to: canister_ai,
271 created_at_time: None,
272 };
273 let block_height = update_candid_as::<_, (Result<u64, ic_ledger_types::TransferError>,)>(
274 &pic,
275 ledger_id,
276 Principal::anonymous(),
277 "transfer",
278 (transfer_args,),
279 )
280 .expect("Transfer call failed")
281 .0
282 .expect("Transfer failed");
283 println!("Pre-funded canister with {LEDGER_PREFUND_E8S} e8s (block {block_height})");
284
285 let (wasm_status,) = query_candid::<(), (WasmStatus,)>(&pic, canister_id, "wasm_status", ())
289 .expect("wasm_status query failed");
290
291 assert!(
292 !wasm_status.name.is_empty(),
293 "wasm_status.name must not be empty"
294 );
295 assert!(
296 wasm_status.version > 0,
297 "wasm_status.version must be > 0, got {}",
298 wasm_status.version
299 );
300 println!(
301 "wasm_status: name={}, version={}, memo={:?}",
302 wasm_status.name, wasm_status.version, wasm_status.memo
303 );
304
305 let (get_before_set,) = update_candid_as::<_, (ManageIIPrincipalResult,)>(
308 &pic,
309 canister_id,
310 user,
311 "manage_ii_principal",
312 (ManageIIPrincipalArg::Get,),
313 )
314 .expect("manage_ii_principal Get failed");
315 assert!(matches!(get_before_set, ManageIIPrincipalResult::Err(_)));
316
317 let (set_result,) = update_candid_as::<_, (ManageIIPrincipalResult,)>(
319 &pic,
320 canister_id,
321 user,
322 "manage_ii_principal",
323 (ManageIIPrincipalArg::Set(owner),),
324 )
325 .expect("manage_ii_principal Set failed");
326 assert!(matches!(set_result, ManageIIPrincipalResult::Ok(p) if p == owner));
327
328 let (get_after_set,) = update_candid_as::<_, (ManageIIPrincipalResult,)>(
330 &pic,
331 canister_id,
332 user,
333 "manage_ii_principal",
334 (ManageIIPrincipalArg::Get,),
335 )
336 .expect("manage_ii_principal Get after Set failed");
337 assert!(matches!(get_after_set, ManageIIPrincipalResult::Ok(p) if p == owner));
338
339 let new_settings = CanisterSettings {
341 controllers: Some(vec![canister_id, owner]),
342 compute_allocation: None,
343 memory_allocation: None,
344 freezing_threshold: None,
345 reserved_cycles_limit: None,
346 log_visibility: None,
347 wasm_memory_limit: None,
348 wasm_memory_threshold: None,
349 environment_variables: None,
350 };
351 pic.update_canister_settings(canister_id, Some(user), new_settings)
352 .expect("Failed to update canister settings");
353
354 let updated_status = pic
355 .canister_status(canister_id, Some(owner))
356 .expect("Failed to get updated canister status");
357 assert_eq!(updated_status.settings.controllers.len(), 2);
358 assert!(updated_status.settings.controllers.contains(&canister_id));
359 assert!(updated_status.settings.controllers.contains(&owner));
360
361 let (html_resp,) = query_candid::<_, (HttpResponse,)>(
365 &pic,
366 canister_id,
367 "http_request",
368 (HttpRequest::get(CANISTER_DASHBOARD_HTML_PATH).build(),),
369 )
370 .expect("http_request for dashboard HTML failed");
371
372 let (js_resp,) = query_candid::<_, (HttpResponse,)>(
373 &pic,
374 canister_id,
375 "http_request",
376 (HttpRequest::get(CANISTER_DASHBOARD_JS_PATH).build(),),
377 )
378 .expect("http_request for dashboard JS failed");
379
380 let (css_resp,) = query_candid::<_, (HttpResponse,)>(
381 &pic,
382 canister_id,
383 "http_request",
384 (HttpRequest::get(CANISTER_DASHBOARD_CSS_PATH).build(),),
385 )
386 .expect("http_request for dashboard CSS failed");
387
388 assert_eq!(
389 html_resp.status_code(),
390 200,
391 "Dashboard HTML should return 200"
392 );
393 assert_eq!(js_resp.status_code(), 200, "Dashboard JS should return 200");
394 assert_eq!(
395 css_resp.status_code(),
396 200,
397 "Dashboard CSS should return 200"
398 );
399
400 assert_header_contains(&html_resp, "content-type", "text/html");
402 assert_header_contains(&js_resp, "content-type", "javascript");
403 assert_header_contains(&css_resp, "content-type", "css");
404 assert_header_contains(&html_resp, "content-security-policy", "default-src");
405
406 let asset_hashes = validate_frontend_assets(html_resp.body(), js_resp.body(), css_resp.body())
408 .expect("Dashboard asset validation failed");
409 println!(
410 "Dashboard assets OK: html={}, js={}, css={}",
411 &asset_hashes.html_hash[..12],
412 &asset_hashes.js_hash[..12],
413 &asset_hashes.css_hash[..12],
414 );
415
416 let matched_version = verify_asset_hashes_match_known_version(&asset_hashes)
417 .expect("Asset hash verification failed");
418 println!("Dashboard assets match version: {matched_version}");
419
420 let (fallback_resp,) = query_candid::<_, (HttpResponse,)>(
422 &pic,
423 canister_id,
424 "http_request",
425 (HttpRequest::get("/this-path-does-not-exist").build(),),
426 )
427 .expect("http_request for unknown path failed");
428 assert_eq!(
429 fallback_resp.status_code(),
430 200,
431 "Unknown path should fall back to index.html (SPA routing)"
432 );
433 assert_header_contains(&fallback_resp, "content-type", "text/html");
434
435 assert_header_contains(&fallback_resp, "x-content-type-options", "nosniff");
441 assert_header_contains(&fallback_resp, "x-frame-options", "deny");
442 assert_header_contains(&fallback_resp, "referrer-policy", "no-referrer");
443 assert_header_contains(&fallback_resp, "permissions-policy", "accelerometer=()");
444 assert_header_contains(
445 &fallback_resp,
446 "cross-origin-opener-policy",
447 "same-origin-allow-popups",
448 );
449 assert_header_contains(
450 &fallback_resp,
451 "cross-origin-resource-policy",
452 "same-origin",
453 );
454 println!("Frontend security headers OK (6/6 verified on fallback response)");
455
456 let (gzip_resp,) = query_candid::<_, (HttpResponse,)>(
459 &pic,
460 canister_id,
461 "http_request",
462 (HttpRequest::get("/this-path-does-not-exist")
463 .with_headers(vec![("Accept-Encoding".into(), "gzip".into())])
464 .build(),),
465 )
466 .expect("http_request with gzip encoding failed");
467 assert_eq!(
468 gzip_resp.status_code(),
469 200,
470 "Gzip fallback should return 200"
471 );
472 let body = gzip_resp.body();
473 assert!(
474 body.len() >= 2 && body[0] == 0x1f && body[1] == 0x8b,
475 "Response body should be gzip-compressed (expected magic bytes 0x1f 0x8b, got 0x{:02x} 0x{:02x})",
476 body.first().unwrap_or(&0),
477 body.get(1).unwrap_or(&0),
478 );
479 assert_header_contains(&gzip_resp, "content-encoding", "gzip");
480 println!("Gzip compression OK (verified on fallback response)");
481
482 let initial_origins = fetch_alternative_origins(&pic, canister_id);
486 let test_origins = vec![
487 format!("https://{canister_id}.icp0.io"),
488 "http://localhost:8080".to_string(),
489 "http://22ajg-aqaaa-aaaap-adukq-cai.localhost:8080".to_string(),
490 ];
491 for origin in &test_origins {
492 assert!(
493 !initial_origins.contains(origin),
494 "Test origin {origin} should not be present initially"
495 );
496 }
497
498 for origin in &test_origins {
500 assert_add_origin(&pic, canister_id, owner, origin);
501 let after_add = fetch_alternative_origins(&pic, canister_id);
502 assert!(
503 after_add.contains(origin),
504 "Origin {origin} should be present after Add"
505 );
506
507 assert_remove_origin(&pic, canister_id, owner, origin);
508 let after_remove = fetch_alternative_origins(&pic, canister_id);
509 assert!(
510 !after_remove.contains(origin),
511 "Origin {origin} should be gone after Remove"
512 );
513 }
514
515 let invalid_origin = "ftp://invalid-protocol.com".to_string();
517 let (invalid_result,) = update_candid_as::<_, (ManageAlternativeOriginsResult,)>(
518 &pic,
519 canister_id,
520 owner,
521 "manage_alternative_origins",
522 (ManageAlternativeOriginsArg::Add(invalid_origin),),
523 )
524 .expect("manage_alternative_origins Add (invalid) call failed");
525 assert!(
526 matches!(invalid_result, ManageAlternativeOriginsResult::Err(_)),
527 "Invalid origin should be rejected"
528 );
529
530 assert!(
534 update_candid_as::<_, (ManageIIPrincipalResult,)>(
535 &pic,
536 canister_id,
537 stranger,
538 "manage_ii_principal",
539 (ManageIIPrincipalArg::Get,),
540 )
541 .is_err(),
542 "Stranger should be rejected by manage_ii_principal guard"
543 );
544
545 assert!(
546 update_candid_as::<_, (ManageIIPrincipalResult,)>(
547 &pic,
548 canister_id,
549 stranger,
550 "manage_ii_principal",
551 (ManageIIPrincipalArg::Set(owner),),
552 )
553 .is_err(),
554 "Stranger should be rejected by manage_ii_principal guard (Set)"
555 );
556
557 assert!(
558 update_candid_as::<_, (ManageAlternativeOriginsResult,)>(
559 &pic,
560 canister_id,
561 stranger,
562 "manage_alternative_origins",
563 (ManageAlternativeOriginsArg::Add(
564 "http://localhost:9999".to_string()
565 ),),
566 )
567 .is_err(),
568 "Stranger should be rejected by manage_alternative_origins guard (Add)"
569 );
570
571 assert!(
572 update_candid_as::<_, (ManageAlternativeOriginsResult,)>(
573 &pic,
574 canister_id,
575 stranger,
576 "manage_alternative_origins",
577 (ManageAlternativeOriginsArg::Remove(
578 "http://localhost:9999".to_string()
579 ),),
580 )
581 .is_err(),
582 "Stranger should be rejected by manage_alternative_origins guard (Remove)"
583 );
584
585 assert!(
586 update_candid_as::<_, (ManageTopUpRuleResult,)>(
587 &pic,
588 canister_id,
589 stranger,
590 "manage_top_up_rule",
591 (ManageTopUpRuleArg::Get,),
592 )
593 .is_err(),
594 "Stranger should be rejected by manage_top_up_rule guard"
595 );
596
597 let (get_empty,) = update_candid_as::<_, (ManageTopUpRuleResult,)>(
601 &pic,
602 canister_id,
603 owner,
604 "manage_top_up_rule",
605 (ManageTopUpRuleArg::Get,),
606 )
607 .expect("manage_top_up_rule Get failed");
608 assert!(matches!(get_empty, ManageTopUpRuleResult::Ok(None)));
609
610 let rule = TopUpRule {
612 interval: TopUpInterval::Hourly,
613 cycles_threshold: CyclesAmount::_0_5T,
614 cycles_amount: CyclesAmount::_1T,
615 };
616 let (add_result,) = update_candid_as::<_, (ManageTopUpRuleResult,)>(
617 &pic,
618 canister_id,
619 owner,
620 "manage_top_up_rule",
621 (ManageTopUpRuleArg::Add(rule.clone()),),
622 )
623 .expect("manage_top_up_rule Add failed");
624 match add_result {
625 ManageTopUpRuleResult::Ok(Some(r)) => {
626 assert!(matches!(r.interval, TopUpInterval::Hourly));
627 assert_eq!(r.cycles_threshold, rule.cycles_threshold);
628 assert_eq!(r.cycles_amount, rule.cycles_amount);
629 }
630 other => panic!("Expected Ok(Some(rule)), got {other:?}"),
631 }
632
633 let (get_after_add,) = update_candid_as::<_, (ManageTopUpRuleResult,)>(
635 &pic,
636 canister_id,
637 owner,
638 "manage_top_up_rule",
639 (ManageTopUpRuleArg::Get,),
640 )
641 .expect("manage_top_up_rule Get after Add failed");
642 match get_after_add {
643 ManageTopUpRuleResult::Ok(Some(r)) => {
644 assert!(matches!(r.interval, TopUpInterval::Hourly));
645 assert_eq!(r.cycles_threshold, rule.cycles_threshold);
646 assert_eq!(r.cycles_amount, rule.cycles_amount);
647 }
648 other => panic!("Expected Ok(Some(rule)), got {other:?}"),
649 }
650
651 let (clear_result,) = update_candid_as::<_, (ManageTopUpRuleResult,)>(
653 &pic,
654 canister_id,
655 owner,
656 "manage_top_up_rule",
657 (ManageTopUpRuleArg::Clear,),
658 )
659 .expect("manage_top_up_rule Clear failed");
660 assert!(matches!(clear_result, ManageTopUpRuleResult::Ok(None)));
661
662 let (get_after_clear,) = update_candid_as::<_, (ManageTopUpRuleResult,)>(
664 &pic,
665 canister_id,
666 owner,
667 "manage_top_up_rule",
668 (ManageTopUpRuleArg::Get,),
669 )
670 .expect("manage_top_up_rule Get after Clear failed");
671 assert!(matches!(get_after_clear, ManageTopUpRuleResult::Ok(None)));
672
673 let cycles_before = pic.cycle_balance(canister_id);
678
679 let trigger_rule = TopUpRule {
680 interval: TopUpInterval::Hourly,
681 cycles_threshold: CyclesAmount::_2T,
682 cycles_amount: CyclesAmount::_1T,
683 };
684 let (add_trigger,) = update_candid_as::<_, (ManageTopUpRuleResult,)>(
685 &pic,
686 canister_id,
687 owner,
688 "manage_top_up_rule",
689 (ManageTopUpRuleArg::Add(trigger_rule),),
690 )
691 .expect("manage_top_up_rule Add (trigger) failed");
692 assert!(matches!(add_trigger, ManageTopUpRuleResult::Ok(Some(_))));
693
694 pic.tick();
696 let logs_immediate = collect_canister_logs(&pic, canister_id, owner);
697 assert!(
698 logs_immediate.contains("top-up: tick"),
699 "Missing immediate tick log; logs: {logs_immediate}",
700 );
701
702 pic.advance_time(Duration::from_secs(3601));
704 pic.tick();
705 pic.tick();
706 pic.tick();
707
708 let logs = collect_canister_logs(&pic, canister_id, owner);
709 assert!(
710 logs.contains("top-up: timer set every 3600s"),
711 "Missing timer set log; logs: {logs}",
712 );
713 assert!(
714 logs.contains("top-up: active rule") || logs.contains("top-up: below threshold"),
715 "Missing rule evaluation log; logs: {logs}",
716 );
717 assert!(
718 logs.contains("top-up: transfer ok") && logs.contains("top-up: notify succeeded")
719 || logs.contains("top-up: flow completed"),
720 "Missing top-up success log; logs: {logs}",
721 );
722
723 let cycles_after = pic.cycle_balance(canister_id);
725 assert!(
726 cycles_after > cycles_before,
727 "Cycles should increase after top-up; before={cycles_before}, after={cycles_after}",
728 );
729
730 println!("=== PASS: {wasm_label} ===\n");
731}
732
733fn assert_header_contains(response: &HttpResponse, header_name: &str, substring: &str) {
737 let found = response
738 .headers()
739 .iter()
740 .any(|(k, v)| k.eq_ignore_ascii_case(header_name) && v.to_lowercase().contains(substring));
741 assert!(
742 found,
743 "Expected header '{header_name}' containing '{substring}' in response (status {})",
744 response.status_code(),
745 );
746}
747
748fn fetch_alternative_origins(pic: &pocket_ic::PocketIc, canister_id: Principal) -> Vec<String> {
750 let (resp,) = query_candid::<_, (HttpResponse,)>(
751 pic,
752 canister_id,
753 "http_request",
754 (HttpRequest::get(ALTERNATIVE_ORIGINS_PATH).build(),),
755 )
756 .expect("Failed to fetch alternative origins");
757
758 let json: serde_json::Value =
759 serde_json::from_slice(resp.body()).expect("Failed to parse alternative origins JSON");
760
761 json["alternativeOrigins"]
762 .as_array()
763 .expect("alternativeOrigins should be an array")
764 .iter()
765 .filter_map(|v| v.as_str().map(String::from))
766 .collect()
767}
768
769fn assert_add_origin(
771 pic: &pocket_ic::PocketIc,
772 canister_id: Principal,
773 caller: Principal,
774 origin: &str,
775) {
776 let (result,) = update_candid_as::<_, (ManageAlternativeOriginsResult,)>(
777 pic,
778 canister_id,
779 caller,
780 "manage_alternative_origins",
781 (ManageAlternativeOriginsArg::Add(origin.to_string()),),
782 )
783 .unwrap_or_else(|e| panic!("Add origin '{origin}' call failed: {e:?}"));
784 assert!(
785 matches!(result, ManageAlternativeOriginsResult::Ok),
786 "Add origin '{origin}' should succeed",
787 );
788}
789
790fn assert_remove_origin(
792 pic: &pocket_ic::PocketIc,
793 canister_id: Principal,
794 caller: Principal,
795 origin: &str,
796) {
797 let (result,) = update_candid_as::<_, (ManageAlternativeOriginsResult,)>(
798 pic,
799 canister_id,
800 caller,
801 "manage_alternative_origins",
802 (ManageAlternativeOriginsArg::Remove(origin.to_string()),),
803 )
804 .unwrap_or_else(|e| panic!("Remove origin '{origin}' call failed: {e:?}"));
805 assert!(
806 matches!(result, ManageAlternativeOriginsResult::Ok),
807 "Remove origin '{origin}' should succeed",
808 );
809}
810
811fn collect_canister_logs(
813 pic: &pocket_ic::PocketIc,
814 canister_id: Principal,
815 caller: Principal,
816) -> String {
817 let logs = pic
818 .fetch_canister_logs(canister_id, caller)
819 .expect("Failed to fetch canister logs");
820 let mut body = String::new();
821 for rec in logs {
822 if let Ok(s) = String::from_utf8(rec.content) {
823 body.push_str(&s);
824 body.push('\n');
825 }
826 }
827 body
828}