Skip to main content

my_canister_dapp_test/
lib.rs

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
18// Synthetic principal bytes used to create deterministic test principals.
19const 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
23/// Minimum cycles required to create a canister on PocketIC.
24pub const MIN_CANISTER_CREATION_BALANCE: u128 = 500_000_000_000;
25
26// System canister IDs deployed by PocketIC's IcpFeatures (same as mainnet).
27/// ICP Ledger canister ID.
28pub const ICP_LEDGER_CANISTER_ID_TEXT: &str = "ryjl3-tyaaa-aaaaa-aaaba-cai";
29/// ICP Index canister ID.
30pub const ICP_INDEX_CANISTER_ID_TEXT: &str = "qhbym-qaaaa-aaaaa-aaafq-cai";
31/// Cycles Minting Canister (CMC) ID.
32pub const CMC_CANISTER_ID_TEXT: &str = "rkp4c-7iaaa-aaaaa-aaaca-cai";
33/// Internet Identity canister ID.
34pub const II_CANISTER_ID_TEXT: &str = "rdmx6-jaaaa-aaaaa-aaadq-cai";
35
36/// ICP ledger transfer fee in e8s (0.0001 ICP).
37pub const LEDGER_INIT_TRANSFER_FEE_E8S: u64 = 10_000;
38/// Amount of ICP (in e8s) pre-funded to the canister for top-up tests (200 ICP).
39pub const LEDGER_PREFUND_E8S: u64 = 20_000_000_000;
40
41/// Principal simulating the installer app (the initial controller that installs the dapp WASM).
42pub fn ii_principal_at_installer_app() -> Principal {
43    Principal::from_slice(&[II_PRINCIPAL_AT_INSTALLER_APP_BYTE; 29])
44}
45
46/// Principal simulating the dapp owner (the II-authenticated end user).
47pub fn ii_principal_at_user_controlled_dapp() -> Principal {
48    Principal::from_slice(&[II_PRINCIPAL_AT_USER_CONTROLLED_DAPP_BYTE; 29])
49}
50
51/// Principal simulating an unauthorized caller (should be rejected by all guards).
52pub fn stranger_principal() -> Principal {
53    Principal::from_slice(&[STRANGER_PRINCIPAL_BYTE; 29])
54}
55
56/// SHA-256 hash of `data`, returned as a lowercase hex string.
57pub fn compute_asset_hash(data: &[u8]) -> String {
58    hex::encode(Sha256::digest(data))
59}
60
61/// Hashes of the three dashboard frontend assets, for verification and debugging.
62#[derive(Debug, Clone)]
63pub struct AssetHashes {
64    pub html_hash: String,
65    pub js_hash: String,
66    pub css_hash: String,
67}
68
69/// Computes SHA-256 hashes for the three main dashboard assets.
70pub 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
82/// Validates that HTML content has the expected structure for a canister dashboard page.
83pub 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
105/// Validates that JavaScript content is non-empty, reasonably sized, and valid UTF-8.
106pub 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
123/// Validates that CSS content is non-empty, valid UTF-8, and contains at least one style rule.
124pub 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
139/// Validates all three main dashboard assets and returns their SHA-256 hashes.
140pub 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
154// ─── Asset hash verification ─────────────────────────────────────────────────
155
156use my_canister_dashboard::ASSET_HASHES_JSON;
157
158/// Verifies that the given asset hashes match at least one entry in `asset-hashes.json`.
159///
160/// Returns the matching dashboard version string on success, or an error with a
161/// detailed mismatch message listing all known versions and the served hashes.
162pub 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
213// ─── Acceptance suite ────────────────────────────────────────────────────────
214
215/// Runs the full acceptance suite against a single dapp WASM.
216///
217/// Installs the WASM into a fresh PocketIC canister and exercises every
218/// endpoint in the `my-canister-dashboard.did` interface:
219///
220/// - `wasm_status` — dapp metadata query
221/// - `manage_ii_principal` — Internet Identity principal CRUD
222/// - `http_request` — certified asset serving (dashboard + frontend)
223/// - `manage_alternative_origins` — II alternative origins CRUD
224/// - `manage_top_up_rule` — auto top-up rule CRUD + timer-driven cycle minting
225///
226/// Panics on any assertion failure.
227pub fn run_acceptance_suite(wasm_bytes: &[u8], wasm_label: &str) {
228    // ─── PocketIC setup ──────────────────────────────────────────────────
229    // Configure a PocketIC instance with ICP Ledger + CMC system canisters.
230    // The anonymous principal is pre-funded with 1B ICP for test transfers.
231    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    // Three test principals with distinct roles:
242    let user = ii_principal_at_installer_app(); // initial controller (installer)
243    let owner = ii_principal_at_user_controlled_dapp(); // dapp owner (II user)
244    let stranger = stranger_principal(); // unauthorized caller
245
246    println!("\n=== Acceptance suite: {wasm_label} ===");
247    println!("  installer: {user}");
248    println!("  owner:     {owner}");
249
250    // ─── Canister creation & WASM installation ───────────────────────────
251    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    // Verify the installer is the sole controller.
256    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    // Pre-fund the canister's ICP account so the top-up flow can transfer ICP.
264    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    // ─── wasm_status ─────────────────────────────────────────────────────
286    // Every conforming dapp must return a valid WasmStatus with a non-empty
287    // name and a positive version number.
288    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    // ─── manage_ii_principal ─────────────────────────────────────────────
306    // Get before Set → Err (no principal configured yet).
307    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    // Set → Ok(principal).
318    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    // Get after Set → Ok(principal).
329    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    // Transfer controller role to the owner (simulates post-install handoff).
340    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    // ─── http_request: dashboard asset serving ───────────────────────────
362
363    // Fetch the three dashboard assets and validate status codes.
364    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    // Validate response headers.
401    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    // Validate asset structure (HTML tags, JS size, CSS rules).
407    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    // SPA fallback: unknown paths under "/" serve index.html with 200 (client-side routing).
421    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    // ─── Security/privacy headers on frontend responses ─────────────────
436    // The frontend crate adds 6 security headers to every asset response.
437    // (X-XSS-Protection and Strict-Transport-Security are intentionally omitted:
438    //  X-XSS-Protection is a legacy IE/old-Chrome header ignored by modern browsers;
439    //  HSTS is redundant on ICP since the gateway enforces HTTPS.)
440    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    // ─── Gzip compression ───────────────────────────────────────────────
457    // Request with Accept-Encoding: gzip should return compressed content.
458    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    // ─── manage_alternative_origins ──────────────────────────────────────
483
484    // Verify the initial origins JSON is valid and doesn't contain our test origins.
485    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    // Add each test origin, verify it appears, then remove it and verify it's gone.
499    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    // Invalid origin (ftp://) should be rejected.
516    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    // ─── Guard tests: stranger rejection ─────────────────────────────────
531    // A non-controller caller must be rejected by every guarded endpoint.
532
533    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    // ─── manage_top_up_rule: CRUD ────────────────────────────────────────
598
599    // Get when empty → Ok(None).
600    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    // Add rule → Ok(Some(rule)).
611    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    // Get after Add → same rule.
634    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    // Clear → Ok(None).
652    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    // Get after Clear → Ok(None).
663    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    // ─── manage_top_up_rule: timer-driven cycle minting ──────────────────
674    // Set a rule with a threshold above the current balance to trigger an
675    // immediate top-up via ICP Ledger → CMC → deposit_cycles.
676
677    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    // The Add spawns an immediate tick. Process it.
695    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    // Advance time past the hourly interval and tick so the timer fires again.
703    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    // Verify cycles actually increased.
724    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
733// ─── Helpers ─────────────────────────────────────────────────────────────────
734
735/// Asserts that an HTTP response contains a header whose value includes `substring`.
736fn 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
748/// Fetches the II alternative origins JSON and returns the list of origin strings.
749fn 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
769/// Adds an alternative origin and asserts success.
770fn 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
790/// Removes an alternative origin and asserts success.
791fn 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
811/// Fetches canister logs and returns them as a single concatenated string.
812fn 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}