From 9ddb6ebd8b3b4dddcde2c3aa423b9a6dc1fb9ceb Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Tue, 3 Mar 2026 12:43:35 +0100 Subject: [PATCH 1/4] test: integration tests for `ProxyRuntime` --- .gitignore | 2 + Cargo.lock | 344 +++++++++++++++++++++++++- Cargo.toml | 1 + examples/http_canister/Cargo.toml | 2 + examples/http_canister/src/lib.rs | 30 +++ examples/http_canister/src/main.rs | 60 +++-- examples/http_canister/tests/tests.rs | 32 ++- test_fixtures/Cargo.toml | 4 +- test_fixtures/src/lib.rs | 158 +++++++++++- test_fixtures/wasms/.gitkeep | 0 10 files changed, 603 insertions(+), 30 deletions(-) create mode 100644 examples/http_canister/src/lib.rs create mode 100644 test_fixtures/wasms/.gitkeep diff --git a/.gitignore b/.gitignore index 0a2786a..ba99397 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ target/ .vscode/* !.vscode/extensions.json .idea + +test_fixtures/wasms diff --git a/Cargo.lock b/Cargo.lock index 0feeffa..ed410c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,6 +137,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "backoff" version = "0.4.0" @@ -366,9 +388,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -408,6 +438,25 @@ dependencies = [ "half 2.7.1", ] +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -432,6 +481,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -666,6 +725,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -754,6 +819,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -876,6 +950,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -1128,6 +1208,7 @@ dependencies = [ name = "http_canister" version = "1.0.0" dependencies = [ + "assert_matches", "candid", "canhttp", "http", @@ -1205,9 +1286,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1245,7 +1328,7 @@ dependencies = [ "pkcs8", "rand 0.8.5", "rangemap", - "reqwest", + "reqwest 0.12.28", "sec1", "serde", "serde_bytes", @@ -1657,6 +1740,38 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -2055,7 +2170,7 @@ dependencies = [ "ic-certification", "ic-management-canister-types", "ic-transport-types 0.40.1", - "reqwest", + "reqwest 0.12.28", "schemars", "semver", "serde", @@ -2199,6 +2314,7 @@ version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -2394,6 +2510,44 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -2452,6 +2606,7 @@ version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -2482,12 +2637,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2517,6 +2700,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.28" @@ -2577,7 +2769,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2953,6 +3145,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tempfile" version = "3.26.0" @@ -2970,12 +3183,14 @@ dependencies = [ name = "test_fixtures" version = "1.0.0" dependencies = [ + "async-trait", "candid", "ic-canister-runtime", "ic-management-canister-types", "ic-pocket-canister-runtime", "ic-test-utilities-load-wasm", "pocket-ic", + "reqwest 0.13.2", "serde", ] @@ -3376,6 +3591,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3535,6 +3760,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.6" @@ -3544,12 +3778,59 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -3586,6 +3867,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -3619,6 +3915,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3631,6 +3933,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3643,6 +3951,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3667,6 +3981,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3679,6 +3999,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3691,6 +4017,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3703,6 +4035,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 228447b..dbcf788 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ pin-project = "1.1.11" pocket-ic = "12.0.0" proptest = "1.10.0" regex-lite = "0.1.9" +reqwest = "0.13.2" serde = "1.0" serde_bytes = "0.11.19" serde_json = "1.0" diff --git a/examples/http_canister/Cargo.toml b/examples/http_canister/Cargo.toml index f1dd604..204f6cf 100644 --- a/examples/http_canister/Cargo.toml +++ b/examples/http_canister/Cargo.toml @@ -12,9 +12,11 @@ candid = { workspace = true } canhttp = { path = "../../canhttp", features = ["http"] } http = { workspace = true } ic-cdk = { workspace = true } +serde = { workspace = true } tower = { workspace = true } [dev-dependencies] +assert_matches = { workspace = true } ic-management-canister-types = { workspace = true } ic-test-utilities-load-wasm = { workspace = true } pocket-ic = { workspace = true } diff --git a/examples/http_canister/src/lib.rs b/examples/http_canister/src/lib.rs new file mode 100644 index 0000000..f88f92f --- /dev/null +++ b/examples/http_canister/src/lib.rs @@ -0,0 +1,30 @@ +use candid::{CandidType, Deserialize}; +use canhttp::cycles::ChargeCallerError; +use tower::BoxError; + +#[derive(Debug, CandidType, Deserialize)] +pub struct InsufficientCyclesError { + pub expected: u128, + pub received: u128, +} + +impl TryFrom for InsufficientCyclesError { + type Error = BoxError; + + fn try_from(error: BoxError) -> Result { + match error.downcast::() { + Ok(error) => Ok(InsufficientCyclesError::from(*error)), + Err(error) => Err(error), + } + } +} + +impl From for InsufficientCyclesError { + fn from(error: ChargeCallerError) -> Self { + match error { + ChargeCallerError::InsufficientCyclesError { expected, received } => { + Self { expected, received } + } + } + } +} diff --git a/examples/http_canister/src/main.rs b/examples/http_canister/src/main.rs index e5e3434..372ff0c 100644 --- a/examples/http_canister/src/main.rs +++ b/examples/http_canister/src/main.rs @@ -1,23 +1,20 @@ //! Example of a canister using `canhttp` to issue HTTP requests. use canhttp::{ - cycles::{ChargeMyself, CyclesAccountingServiceBuilder}, + cycles::{ChargeCaller, ChargeMyself, CyclesAccountingServiceBuilder, CyclesChargingPolicy}, http::HttpConversionLayer, observability::ObservabilityLayer, CanisterReadyLayer, Client, MaxResponseBytesRequestExtension, }; -use http::Request; +use http_canister::InsufficientCyclesError; use ic_cdk::update; use tower::{BoxError, Service, ServiceBuilder, ServiceExt}; -/// Make an HTTP POST request. +/// Make an HTTP POST request and do not charge the user for it. #[update] -pub async fn make_http_post_request() -> String { - let response = http_client() - .ready() - .await - .expect("Client should be ready") - .call(request()) +pub async fn charge_canister_for_http_request() -> String { + // Use cycles from the canister to pay for HTTPs outcalls + let response = make_http_post_request(ChargeMyself::default()) .await .expect("Request should succeed"); @@ -26,13 +23,41 @@ pub async fn make_http_post_request() -> String { String::from_utf8_lossy(response.body()).to_string() } +/// Make an HTTP POST request and charge the user for it. +#[update] +pub async fn charge_caller_for_http_request() -> Result { + // Use cycles attached by the caller to pay for HTTPs outcalls and charge an additional fee. + match make_http_post_request(ChargeCaller::new(|_request, cost| cost + 1_000_000)).await { + Ok(response) => { + assert_eq!(response.status(), http::StatusCode::OK); + Ok(String::from_utf8_lossy(response.body()).to_string()) + } + Err(e) => Err(InsufficientCyclesError::try_from(e).expect("Request should succeed")), + } +} + +async fn make_http_post_request( + cycles_charging_policy: C, +) -> Result>, BoxError> +where + C: CyclesChargingPolicy + Clone, + ::Error: std::error::Error + Send + Sync + 'static, +{ + http_client(cycles_charging_policy) + .ready() + .await + .expect("Client should be ready") + .call(request()) + .await +} + /// Make multiple HTTP POST requests in a loop, /// ensuring via [`CanisterReadyLayer`] that the loop will stop if the canister is stopped. #[update] pub async fn infinite_loop_make_http_post_request() -> String { let mut client = ServiceBuilder::new() .layer(CanisterReadyLayer) - .service(http_client()); + .service(http_client(ChargeMyself::default())); loop { match client.ready().await { @@ -45,8 +70,13 @@ pub async fn infinite_loop_make_http_post_request() -> String { } } -fn http_client( -) -> impl Service>, Response = http::Response>, Error = BoxError> { +fn http_client( + cycles_charging_policy: C, +) -> impl Service>, Response = http::Response>, Error = BoxError> +where + C: CyclesChargingPolicy + Clone, + ::Error: std::error::Error + Send + Sync + 'static, +{ ServiceBuilder::new() // Print request, response and errors to the console .layer( @@ -61,13 +91,13 @@ fn http_client( ) // Only deal with types from the http crate. .layer(HttpConversionLayer) - // Use cycles from the canister to pay for HTTPs outcalls - .cycles_accounting(ChargeMyself::default()) + // The strategy to use to charge cycles for the request + .cycles_accounting(cycles_charging_policy) // The actual client .service(Client::new_with_box_error()) } -fn request() -> Request> { +fn request() -> http::Request> { fn httpbin_base_url() -> String { option_env!("HTTPBIN_URL") .unwrap_or_else(|| "https://httpbin.org") diff --git a/examples/http_canister/tests/tests.rs b/examples/http_canister/tests/tests.rs index 4f4b9ac..db5a8e8 100644 --- a/examples/http_canister/tests/tests.rs +++ b/examples/http_canister/tests/tests.rs @@ -1,4 +1,6 @@ +use assert_matches::assert_matches; use candid::{Decode, Encode, Principal}; +use http_canister::InsufficientCyclesError; use ic_management_canister_types::CanisterIdRecord; use ic_management_canister_types::CanisterSettings; use pocket_ic::common::rest::{ @@ -8,14 +10,40 @@ use pocket_ic::PocketIc; use test_fixtures::Setup; #[tokio::test] -async fn should_make_http_post_request() { +async fn should_make_http_post_request_with_cycles_attached() { let setup = Setup::new("http_canister").await; let http_request_result = setup .canister() - .update_call::<_, String>("make_http_post_request", ()) + .update_call::<_, String>("charge_canister_for_http_request", ()) .await; + assert!(http_request_result.contains("Hello, World!")); + assert!(http_request_result.contains("\"X-Id\": \"42\"")); +} +#[tokio::test] +async fn should_make_http_request_with_sufficient_cycles_attached() { + let setup = Setup::new("http_canister").await.with_proxy().await; + + let http_request_result = setup + .canister() + .update_call_with_cycles::<_, Result>( + "charge_caller_for_http_request", + (), + 1, + ) + .await; + assert_matches!(http_request_result, Err(InsufficientCyclesError { expected, received }) if expected == 1_000_000 && received == 1); + + let http_request_result = setup + .canister() + .update_call_with_cycles::<_, Result>( + "charge_caller_for_http_request", + (), + 1_000_000, + ) + .await + .expect("Call should succeed"); assert!(http_request_result.contains("Hello, World!")); assert!(http_request_result.contains("\"X-Id\": \"42\"")); } diff --git a/test_fixtures/Cargo.toml b/test_fixtures/Cargo.toml index c83a3fc..35e686b 100644 --- a/test_fixtures/Cargo.toml +++ b/test_fixtures/Cargo.toml @@ -4,10 +4,12 @@ version = "1.0.0" edition.workspace = true [dependencies] +async-trait = { workspace = true } candid = { workspace = true } -ic-canister-runtime = { workspace = true } +ic-canister-runtime = { workspace = true, features = ["proxy"] } ic-management-canister-types = { workspace = true } ic-pocket-canister-runtime = { workspace = true } ic-test-utilities-load-wasm = { workspace = true } pocket-ic = { workspace = true } +reqwest = { workspace = true } serde = { workspace = true } diff --git a/test_fixtures/src/lib.rs b/test_fixtures/src/lib.rs index dbb6bc9..e98cdce 100644 --- a/test_fixtures/src/lib.rs +++ b/test_fixtures/src/lib.rs @@ -1,17 +1,20 @@ +use async_trait::async_trait; use candid::{utils::ArgumentEncoder, CandidType, Encode, Principal}; -use ic_canister_runtime::Runtime; +use ic_canister_runtime::{IcError, ProxyRuntime, Runtime}; use ic_management_canister_types::{CanisterId, CanisterSettings}; use ic_pocket_canister_runtime::PocketIcRuntime; use pocket_ic::{nonblocking::PocketIc, PocketIcBuilder}; use serde::de::DeserializeOwned; -use std::{env::var, path::PathBuf, sync::Arc}; +use std::{env::var, fs, path::PathBuf, sync::Arc}; pub struct Setup { env: Arc, canister_id: CanisterId, + proxy_canister_id: Option, } impl Setup { + pub const DEFAULT_CALLER: Principal = Principal::from_slice(&[0x9d, 0xf7, 0x01]); pub const DEFAULT_CONTROLLER: Principal = Principal::from_slice(&[0x9d, 0xf7, 0x02]); pub async fn new(canister_binary_name: &str) -> Self { @@ -46,14 +49,53 @@ impl Setup { Self { env: Arc::new(env), canister_id, + proxy_canister_id: None, } } - pub fn runtime(&self) -> PocketIcRuntime<'_> { - PocketIcRuntime::new(self.env.as_ref(), Principal::anonymous()) + pub async fn with_proxy(self) -> Self { + let Setup { + env, + canister_id, + proxy_canister_id: _, + } = self; + + let proxy_canister_id = env + .create_canister_with_settings( + None, + Some(CanisterSettings { + // Only controllers have access to the proxy service, so we also allow + // the default caller + controllers: Some(vec![Self::DEFAULT_CONTROLLER, Setup::DEFAULT_CALLER]), + ..CanisterSettings::default() + }), + ) + .await; + env.add_cycles(proxy_canister_id, u64::MAX as u128).await; + + env.install_canister( + proxy_canister_id, + proxy_wasm().await, + Encode!().unwrap(), + Some(Self::DEFAULT_CONTROLLER), + ) + .await; + + Self { + env, + canister_id, + proxy_canister_id: Some(proxy_canister_id), + } } - pub fn canister(&self) -> Canister<'_> { + pub fn runtime(&self) -> MaybeProxyRuntime<'_> { + MaybeProxyRuntime { + pocket_ic_runtime: PocketIcRuntime::new(self.env.as_ref(), Self::DEFAULT_CALLER), + proxy_canister_id: self.proxy_canister_id, + } + } + + pub fn canister(&self) -> Canister> { Canister { runtime: self.runtime(), id: self.canister_id, @@ -61,19 +103,32 @@ impl Setup { } } -pub struct Canister<'a> { - runtime: PocketIcRuntime<'a>, +pub struct Canister { + runtime: R, id: CanisterId, } -impl Canister<'_> { +impl Canister { pub async fn update_call(&self, method: &str, args: In) -> Out + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, + { + self.update_call_with_cycles(method, args, 0).await + } + + pub async fn update_call_with_cycles( + &self, + method: &str, + args: In, + cycles: u128, + ) -> Out where In: ArgumentEncoder + Send, Out: CandidType + DeserializeOwned, { self.runtime - .update_call(self.id, method, args, 0) + .update_call(self.id, method, args, cycles) .await .expect("Update call failed") } @@ -86,3 +141,88 @@ pub fn canister_wasm(canister_binary_name: &str) -> Vec { &[], ) } + +async fn proxy_wasm() -> Vec { + const DOWNLOAD_PATH: &str = "../../test_fixtures/wasms/proxy.wasm"; + const DOWNLOAD_URL: &str = + "https://github.com/dfinity/proxy-canister/releases/download/v0.1.0/proxy.wasm"; + + let path = PathBuf::from(var("CARGO_MANIFEST_DIR").unwrap()).join(DOWNLOAD_PATH); + if let Ok(wasm) = fs::read(&path) { + return wasm; + } + + let bytes = reqwest::get(DOWNLOAD_URL) + .await + .unwrap_or_else(|e| panic!("Failed to fetch canister WASM: {e:?}")) + .bytes() + .await + .unwrap_or_else(|e| panic!("Failed to read bytes from canister WASM: {e:?}")) + .to_vec(); + + fs::write(&path, &bytes).expect("Failed to save downloaded file"); + + bytes +} + +pub struct MaybeProxyRuntime<'a> { + pocket_ic_runtime: PocketIcRuntime<'a>, + proxy_canister_id: Option, +} + +impl<'a> MaybeProxyRuntime<'a> { + pub fn without_proxy(pocket_ic_runtime: PocketIcRuntime<'a>) -> Self { + Self { + pocket_ic_runtime, + proxy_canister_id: None, + } + } + + pub fn with_proxy( + pocket_ic_runtime: PocketIcRuntime<'a>, + proxy_canister_id: CanisterId, + ) -> Self { + Self { + pocket_ic_runtime, + proxy_canister_id: Some(proxy_canister_id), + } + } +} + +#[async_trait] +impl Runtime for MaybeProxyRuntime<'_> { + async fn update_call( + &self, + id: Principal, + method: &str, + args: In, + cycles: u128, + ) -> Result + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, + { + if let Some(proxy_canister_id) = self.proxy_canister_id { + ProxyRuntime::new(&self.pocket_ic_runtime, proxy_canister_id) + .update_call(id, method, args, cycles) + .await + } else { + self.pocket_ic_runtime + .update_call(id, method, args, cycles) + .await + } + } + + async fn query_call( + &self, + id: Principal, + method: &str, + args: In, + ) -> Result + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, + { + self.pocket_ic_runtime.query_call(id, method, args).await + } +} diff --git a/test_fixtures/wasms/.gitkeep b/test_fixtures/wasms/.gitkeep new file mode 100644 index 0000000..e69de29 From 7bc6ea7efd44a199a1c15a6c4005fff9b7c704fb Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Fri, 6 Mar 2026 16:30:44 +0100 Subject: [PATCH 2/4] Make examples simpler --- examples/http_canister/src/main.rs | 40 ++++++++++++++---------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/examples/http_canister/src/main.rs b/examples/http_canister/src/main.rs index 372ff0c..2bbf779 100644 --- a/examples/http_canister/src/main.rs +++ b/examples/http_canister/src/main.rs @@ -10,11 +10,14 @@ use http_canister::InsufficientCyclesError; use ic_cdk::update; use tower::{BoxError, Service, ServiceBuilder, ServiceExt}; -/// Make an HTTP POST request and do not charge the user for it. +/// Make an HTTP POST request. #[update] -pub async fn charge_canister_for_http_request() -> String { - // Use cycles from the canister to pay for HTTPs outcalls - let response = make_http_post_request(ChargeMyself::default()) +pub async fn make_http_post_request() -> String { + let response = http_client(ChargeMyself::default()) + .ready() + .await + .expect("Client should be ready") + .call(request()) .await .expect("Request should succeed"); @@ -23,34 +26,27 @@ pub async fn charge_canister_for_http_request() -> String { String::from_utf8_lossy(response.body()).to_string() } -/// Make an HTTP POST request and charge the user for it. +/// Make an HTTP POST request and charge the user cycles for it. #[update] -pub async fn charge_caller_for_http_request() -> Result { +pub async fn make_http_post_request_and_charge_user_cycles( +) -> Result { // Use cycles attached by the caller to pay for HTTPs outcalls and charge an additional fee. - match make_http_post_request(ChargeCaller::new(|_request, cost| cost + 1_000_000)).await { + match http_client(ChargeCaller::new(|_request, cost| cost + 1_000_000)) + .ready() + .await + .expect("Client should be ready") + .call(request()) + .await + { Ok(response) => { assert_eq!(response.status(), http::StatusCode::OK); Ok(String::from_utf8_lossy(response.body()).to_string()) } + // Return an error if the call failed due to insufficient cycles, otherwise panic. Err(e) => Err(InsufficientCyclesError::try_from(e).expect("Request should succeed")), } } -async fn make_http_post_request( - cycles_charging_policy: C, -) -> Result>, BoxError> -where - C: CyclesChargingPolicy + Clone, - ::Error: std::error::Error + Send + Sync + 'static, -{ - http_client(cycles_charging_policy) - .ready() - .await - .expect("Client should be ready") - .call(request()) - .await -} - /// Make multiple HTTP POST requests in a loop, /// ensuring via [`CanisterReadyLayer`] that the loop will stop if the canister is stopped. #[update] From 9065eae9e8713511469c174f9812cf99cff5744b Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Fri, 6 Mar 2026 16:32:39 +0100 Subject: [PATCH 3/4] Add assert to ensure proxy not re-installed --- test_fixtures/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test_fixtures/src/lib.rs b/test_fixtures/src/lib.rs index e98cdce..7887a59 100644 --- a/test_fixtures/src/lib.rs +++ b/test_fixtures/src/lib.rs @@ -57,8 +57,9 @@ impl Setup { let Setup { env, canister_id, - proxy_canister_id: _, + proxy_canister_id, } = self; + assert!(proxy_canister_id.is_none(), "Proxy canister already setup"); let proxy_canister_id = env .create_canister_with_settings( From 8a0bbfe22930eadc28defda1eeff73784ed306b1 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Fri, 6 Mar 2026 16:42:07 +0100 Subject: [PATCH 4/4] Fix integration tests --- examples/http_canister/tests/tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/http_canister/tests/tests.rs b/examples/http_canister/tests/tests.rs index db5a8e8..b79abcf 100644 --- a/examples/http_canister/tests/tests.rs +++ b/examples/http_canister/tests/tests.rs @@ -15,7 +15,7 @@ async fn should_make_http_post_request_with_cycles_attached() { let http_request_result = setup .canister() - .update_call::<_, String>("charge_canister_for_http_request", ()) + .update_call::<_, String>("make_http_post_request", ()) .await; assert!(http_request_result.contains("Hello, World!")); assert!(http_request_result.contains("\"X-Id\": \"42\"")); @@ -28,7 +28,7 @@ async fn should_make_http_request_with_sufficient_cycles_attached() { let http_request_result = setup .canister() .update_call_with_cycles::<_, Result>( - "charge_caller_for_http_request", + "make_http_post_request_and_charge_user_cycles", (), 1, ) @@ -38,7 +38,7 @@ async fn should_make_http_request_with_sufficient_cycles_attached() { let http_request_result = setup .canister() .update_call_with_cycles::<_, Result>( - "charge_caller_for_http_request", + "make_http_post_request_and_charge_user_cycles", (), 1_000_000, )