diff --git a/Cargo.lock b/Cargo.lock index 979fcd921782..7e0c0efe55fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1924,6 +1924,50 @@ dependencies = [ "tokio", ] +[[package]] +name = "canister_http_correctness" +version = "0.9.0" +dependencies = [ + "anyhow", + "assert_matches", + "candid", + "canister-test", + "canister_http", + "dfn_candid", + "ic-agent", + "ic-base-types", + "ic-config", + "ic-cycles-account-manager", + "ic-management-canister-types-private", + "ic-registry-subnet-type", + "ic-system-test-driver", + "ic-test-utilities", + "ic-test-utilities-types", + "ic-types", + "ic-types-cycles", + "proxy_canister", + "rand 0.8.6", + "serde", + "serde_json", + "slog", + "tokio", +] + +[[package]] +name = "canister_http_flexible" +version = "0.9.0" +dependencies = [ + "anyhow", + "candid", + "canister-test", + "canister_http", + "dfn_candid", + "ic-management-canister-types-private", + "ic-system-test-driver", + "proxy_canister", + "slog", +] + [[package]] name = "canlog" version = "0.2.0" @@ -18030,6 +18074,8 @@ dependencies = [ "candid", "canister-test", "canister_http", + "canister_http_correctness", + "canister_http_flexible", "cloner-canister-types", "dfn_candid", "futures", diff --git a/Cargo.toml b/Cargo.toml index 86dccbbf9550..df6756188b0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -500,6 +500,8 @@ members = [ "rs/tests/nested/nns_recovery", "rs/tests/networking", "rs/tests/networking/canister_http", + "rs/tests/networking/canister_http_correctness", + "rs/tests/networking/canister_http_flexible", "rs/tests/networking/canisters", "rs/tests/networking/firewall", "rs/tests/networking/subnet_update_workload", diff --git a/rs/config/src/execution_environment.rs b/rs/config/src/execution_environment.rs index a15a5515acb8..577a8f0a850d 100644 --- a/rs/config/src/execution_environment.rs +++ b/rs/config/src/execution_environment.rs @@ -13,7 +13,7 @@ const TIB: u64 = 1024 * GIB; const REPLICATED_INTER_CANISTER_LOG_FETCH_FEATURE: FlagStatus = FlagStatus::Enabled; -const FLEXIBLE_HTTP_REQUESTS_FEATURE: FlagStatus = FlagStatus::Disabled; +const FLEXIBLE_HTTP_REQUESTS_FEATURE: FlagStatus = FlagStatus::Enabled; // TODO(DSM-105): remove after the feature is enabled by default. pub const LOG_MEMORY_STORE_FEATURE_ENABLED: bool = true; diff --git a/rs/execution_environment/src/execution_environment/tests.rs b/rs/execution_environment/src/execution_environment/tests.rs index 1b3abc0d8597..dd38c33b65b1 100644 --- a/rs/execution_environment/src/execution_environment/tests.rs +++ b/rs/execution_environment/src/execution_environment/tests.rs @@ -3619,18 +3619,16 @@ fn execute_flexible_canister_http_request() { } #[test] -fn execute_flexible_canister_http_request_free_subnet_uses_legacy() { - // On a free subnet, flexible outcalls are available by default (without the - // `flexible_http_requests` feature flag) and fall back to legacy pricing, - // where pricing is moot because a free subnet charges nothing. +fn execute_flexible_canister_http_request_free_subnet_uses_pay_as_you_go() { + // Pay-as-you-go applies to every subnet, free ones included. Pricing is moot + // there — a free subnet charges nothing — but the request is still routed + // through the new pricing model rather than the legacy fallback. let own_subnet = subnet_test_id(1); let caller_canister = canister_test_id(10); let mut test = ExecutionTestBuilder::new() .with_own_subnet_id(own_subnet) .with_caller(own_subnet, caller_canister) .with_cost_schedule(CanisterCyclesCostSchedule::Free) - // The feature flag is deliberately left disabled: the free-subnet path - // does not depend on it. .build(); let args = flexible_http_request_args(caller_canister); @@ -3648,8 +3646,10 @@ fn execute_flexible_canister_http_request_free_subnet_uses_legacy() { let http_request_context = canister_http_request_contexts .get(&CallbackId::from(0)) .unwrap(); - // The request is routed through legacy pricing with flexible replication. - assert_eq!(http_request_context.pricing_version, PricingVersion::Legacy); + assert_eq!( + http_request_context.pricing_version, + PricingVersion::PayAsYouGo + ); assert!( matches!( http_request_context.replication, @@ -3659,9 +3659,9 @@ fn execute_flexible_canister_http_request_free_subnet_uses_legacy() { http_request_context.replication ); - // Legacy pricing on a free subnet charges nothing: the full payment is - // retained (to be refunded when the response is delivered) and nothing is - // marked refundable through the pay-as-you-go mechanism. + // A free subnet charges nothing whatever the pricing model: the full payment + // is retained (to be refunded when the response is delivered) and there is no + // allowance to spend, hence nothing to refund out of one. assert_eq!(http_request_context.request.payment, payment); assert_eq!( http_request_context.refund_status.refundable_cycles, @@ -3674,17 +3674,14 @@ fn execute_flexible_canister_http_request_free_subnet_uses_legacy() { } #[test] -fn execute_flexible_canister_http_request_system_subnet_uses_legacy() { +fn execute_flexible_canister_http_request_system_subnet_uses_pay_as_you_go() { // System subnets charge nothing for HTTP outcalls despite a normal cost - // schedule, so flexible outcalls are available there by default (without the - // feature flag) and fall back to legacy pricing. + // schedule. Like a free subnet, they are still routed through pay-as-you-go. let own_subnet = subnet_test_id(1); let caller_canister = canister_test_id(10); let mut test = ExecutionTestBuilder::new() .with_own_subnet_id(own_subnet) .with_caller(own_subnet, caller_canister) - // A system subnet keeps the default (normal) cost schedule but charges - // zero for HTTP outcalls; the feature flag is left disabled. .with_subnet_type(SubnetType::System) .build(); @@ -3703,9 +3700,10 @@ fn execute_flexible_canister_http_request_system_subnet_uses_legacy() { let http_request_context = canister_http_request_contexts .get(&CallbackId::from(0)) .unwrap(); - // Routed through legacy pricing with flexible replication, just like a - // free-cost-schedule subnet. - assert_eq!(http_request_context.pricing_version, PricingVersion::Legacy); + assert_eq!( + http_request_context.pricing_version, + PricingVersion::PayAsYouGo + ); assert!( matches!( http_request_context.replication, @@ -3718,8 +3716,8 @@ fn execute_flexible_canister_http_request_system_subnet_uses_legacy() { // A system subnet charges nothing for HTTP outcalls despite its normal cost // schedule, so `try_add_http_context_to_replicated_state` treats it as free // just like a free-cost-schedule subnet: the full payment is retained (to be - // refunded when the response is delivered) and nothing is marked refundable - // through the pay-as-you-go mechanism. + // refunded when the response is delivered) and there is no allowance to + // spend, hence nothing to refund out of one. assert_eq!(http_request_context.request.payment, payment); assert_eq!( http_request_context.refund_status.refundable_cycles, @@ -3823,13 +3821,14 @@ fn execute_flexible_canister_http_request_insufficient_payment() { #[test] fn execute_flexible_canister_http_request_disabled() { // On a paying subnet, flexible outcalls under pay-as-you-go pricing are - // gated behind the `flexible_http_requests` feature flag, which is disabled - // by default. + // gated behind the `flexible_http_requests` feature flag. The flag now + // defaults to enabled, so turning it back off must still shut them out. let own_subnet = subnet_test_id(1); let caller_canister = canister_test_id(10); let mut test = ExecutionTestBuilder::new() .with_own_subnet_id(own_subnet) .with_caller(own_subnet, caller_canister) + .with_flexible_http_requests_disabled() .build(); let args = flexible_http_request_args(caller_canister); @@ -3855,6 +3854,44 @@ fn execute_flexible_canister_http_request_disabled() { ); } +#[test] +fn execute_flexible_canister_http_request_disabled_falls_back_to_legacy_when_free() { + // Turning the flag off does not take flexible outcalls away from subnets + // where they are free: there they fall back to legacy pricing, which is moot + // when nothing is charged. This is the one path that still distinguishes the + // flag being off from it being on. + let own_subnet = subnet_test_id(1); + let caller_canister = canister_test_id(10); + let mut test = ExecutionTestBuilder::new() + .with_own_subnet_id(own_subnet) + .with_caller(own_subnet, caller_canister) + .with_cost_schedule(CanisterCyclesCostSchedule::Free) + .with_flexible_http_requests_disabled() + .build(); + + let args = flexible_http_request_args(caller_canister); + test.inject_call_to_ic00( + Method::FlexibleHttpRequest, + args.encode(), + Cycles::new(1_000_000_000), + ); + test.execute_all(); + + let canister_http_request_contexts = &test + .state() + .metadata + .subnet_call_context_manager + .canister_http_request_contexts; + assert_eq!(canister_http_request_contexts.len(), 1); + assert_eq!( + canister_http_request_contexts + .get(&CallbackId::from(0)) + .unwrap() + .pricing_version, + PricingVersion::Legacy + ); +} + fn get_reject_message(response: RequestOrResponse) -> String { match response { RequestOrResponse::Request(_) => panic!("Expected Response"), diff --git a/rs/https_outcalls/client/src/client.rs b/rs/https_outcalls/client/src/client.rs index 99f40262abe0..8ac9466f9195 100644 --- a/rs/https_outcalls/client/src/client.rs +++ b/rs/https_outcalls/client/src/client.rs @@ -154,36 +154,10 @@ impl NonBlockingChannel for CanisterHttpAdapterClientImpl { body: request_body, http_method: request_http_method, transform: request_transform, - pricing_version: request_pricing_version, replication: request_replication, .. } = request_context; - if request_pricing_version == ic_types::canister_http::PricingVersion::PayAsYouGo { - warn!( - log, - "Canister HTTP request with PayAsYouGo pricing is not supported yet: \ - request_id {}, sender {}, process_id: {}", - request_id, - request_sender, - std::process::id(), - ); - let _ = permit.send(( - CanisterHttpResponse { - id: request_id, - canister_id: request_sender, - content: CanisterHttpResponseContent::Reject(CanisterHttpReject { - reject_code: RejectCode::SysFatal, - message: - "Canister HTTP request with PayAsYouGo pricing is not supported" - .to_string(), - }), - }, - budget.create_payment_receipt(), - )); - return; - } - let mut payload = async { // Execute the HTTP request and get the adapter response. let (adapter_response, downloaded_bytes, elapsed) = execute_http_request( diff --git a/rs/rust_canisters/proxy_canister/src/main.rs b/rs/rust_canisters/proxy_canister/src/main.rs index 218ef70516b2..a8ce0eaecebd 100644 --- a/rs/rust_canisters/proxy_canister/src/main.rs +++ b/rs/rust_canisters/proxy_canister/src/main.rs @@ -218,6 +218,17 @@ async fn check_response( }) } +/// This canister's own cycle balance. +/// +/// Under pay-as-you-go pricing an HTTP outcall's payment is taken up front and +/// the unspent part is credited back to the balance afterwards, rather than +/// returned as `msg_cycles_refunded` on the reply. Tests therefore observe what +/// an outcall actually cost by watching this. +#[query] +fn cycle_balance() -> u128 { + ic_cdk::api::canister_cycle_balance() +} + #[query] fn transform(raw: TransformArgs) -> CanisterHttpResponsePayload { let (response, _) = (raw.response, raw.context); diff --git a/rs/test_utilities/execution_environment/src/lib.rs b/rs/test_utilities/execution_environment/src/lib.rs index 4fffd9477e91..9ec36fd5ef0e 100644 --- a/rs/test_utilities/execution_environment/src/lib.rs +++ b/rs/test_utilities/execution_environment/src/lib.rs @@ -2630,6 +2630,11 @@ impl ExecutionTestBuilder { self } + pub fn with_flexible_http_requests_disabled(mut self) -> Self { + self.execution_config.flexible_http_requests = FlagStatus::Disabled; + self + } + pub fn without_composite_queries(mut self) -> Self { self.execution_config.composite_queries = FlagStatus::Disabled; self diff --git a/rs/tests/networking/BUILD.bazel b/rs/tests/networking/BUILD.bazel index e1b590b35519..a919f6acddd6 100644 --- a/rs/tests/networking/BUILD.bazel +++ b/rs/tests/networking/BUILD.bazel @@ -21,6 +21,24 @@ CANISTER_HTTP_BASE_DEPS = [ "@crate_index//:slog", ] +# The flexible-outcall test binaries are thin: the scenarios they run live in +# the shared library, so they only need it and `anyhow`. +CANISTER_HTTP_FLEXIBLE_DEPS = [ + # Keep sorted. + "//rs/tests/networking/canister_http:canister_http", + "//rs/tests/networking/canister_http_flexible:canister_http_flexible", + "@crate_index//:anyhow", +] + +# The correctness test binaries are thin: the scenarios they run live in the +# shared library, so they only need it, `anyhow` and the pricing-version constants. +CANISTER_HTTP_CORRECTNESS_DEPS = [ + # Keep sorted. + "//rs/tests/networking/canister_http_correctness", + "//rs/types/management_canister_types", + "@crate_index//:anyhow", +] + COMMON_DEPS = [ # Keep sorted. "//rs/limits", @@ -63,6 +81,10 @@ system_test_nns( deps = CANISTER_HTTP_BASE_DEPS + ["//rs/rust_canisters/canister_test"], ) +# The flexible-outcall scenarios run twice: once on a subnet where HTTP outcalls +# are free and once where they are paid for under pay-as-you-go. Both binaries +# share their scenarios via //rs/tests/networking/canister_http_flexible; the +# paying one adds the scenarios that only exist because it is charged. system_test_nns( name = "canister_http_flexible_test", cpus = MIN_LOCAL_CPUS + 5 * DEFAULT_VCPUS_PER_VM + 1 * DEFAULT_VCPUS_PER_VM, # 5 IC Node VMs (1 system + 4 app) + 1 UVM (httpbin), 6 vCPUs each. @@ -73,9 +95,20 @@ system_test_nns( runtime_deps = CANISTER_HTTP_RUNTIME_DEPS | { "PROXY_WASM_PATH": "//rs/rust_canisters/proxy_canister:proxy_canister", }, - deps = CANISTER_HTTP_BASE_DEPS + [ - "//rs/rust_canisters/canister_test", + deps = CANISTER_HTTP_FLEXIBLE_DEPS, +) + +system_test_nns( + name = "canister_http_flexible_paying_test", + cpus = MIN_LOCAL_CPUS + 5 * DEFAULT_VCPUS_PER_VM + 1 * DEFAULT_VCPUS_PER_VM, # 5 IC Node VMs (1 system + 4 app) + 1 UVM (httpbin), 6 vCPUs each. + enable_uvm = True, + tags = [ + "long_test", # since it exercises many outcall scenarios. ], + runtime_deps = CANISTER_HTTP_RUNTIME_DEPS | { + "PROXY_WASM_PATH": "//rs/rust_canisters/proxy_canister:proxy_canister", + }, + deps = CANISTER_HTTP_FLEXIBLE_DEPS, ) system_test_nns( @@ -169,23 +202,20 @@ system_test_nns( runtime_deps = CANISTER_HTTP_RUNTIME_DEPS | { "PROXY_WASM_PATH": "//rs/rust_canisters/proxy_canister:proxy_canister", }, - deps = CANISTER_HTTP_BASE_DEPS + [ - "//rs/config", - "//rs/cycles_account_manager", - "//rs/registry/subnet_type", - "//rs/rust_canisters/canister_test", - "//rs/test_utilities", - "//rs/test_utilities/types", - "//rs/types/base_types", - "//rs/types/cycles", - "//rs/types/types", - "@crate_index//:assert_matches", - "@crate_index//:ic-agent", - "@crate_index//:rand", - "@crate_index//:serde", - "@crate_index//:serde_json", - "@crate_index//:tokio", - ], + deps = CANISTER_HTTP_CORRECTNESS_DEPS, +) + +# The same correctness scenarios under the other pricing model. Both binaries share +# them via //rs/tests/networking/canister_http_correctness; each adds the scenarios +# that only exist under the model it picked. +system_test_nns( + name = "canister_http_correctness_pay_as_you_go_test", + cpus = MIN_LOCAL_CPUS + 5 * DEFAULT_VCPUS_PER_VM + 1 * DEFAULT_VCPUS_PER_VM, # 5 IC Node VMs (1 system + 4 app) + 1 UVM (httpbin), 6 vCPUs each. + enable_uvm = True, + runtime_deps = CANISTER_HTTP_RUNTIME_DEPS | { + "PROXY_WASM_PATH": "//rs/rust_canisters/proxy_canister:proxy_canister", + }, + deps = CANISTER_HTTP_CORRECTNESS_DEPS, ) system_test_nns( diff --git a/rs/tests/networking/Cargo.toml b/rs/tests/networking/Cargo.toml index 6cee95819ea0..4d83f0f6cc90 100644 --- a/rs/tests/networking/Cargo.toml +++ b/rs/tests/networking/Cargo.toml @@ -12,6 +12,8 @@ assert_matches = { workspace = true } candid = { workspace = true } canister-test = { path = "../../rust_canisters/canister_test" } canister_http = { path = "./canister_http" } +canister_http_correctness = { path = "./canister_http_correctness" } +canister_http_flexible = { path = "./canister_http_flexible" } cloner-canister-types = { path = "./canisters" } dfn_candid = { path = "../../rust_canisters/dfn_candid" } futures = { workspace = true } @@ -69,6 +71,10 @@ wat = { workspace = true } name = "ic-systest-canister-http-correctness" path = "canister_http_correctness_test.rs" +[[bin]] +name = "ic-systest-canister-http-correctness-pay-as-you-go" +path = "canister_http_correctness_pay_as_you_go_test.rs" + [[bin]] name = "ic-systest-canister-http-fault-tolerance" path = "canister_http_fault_tolerance_test.rs" @@ -93,6 +99,10 @@ path = "canister_http_soak_test.rs" name = "ic-systest-canister-http-flexible" path = "canister_http_flexible_test.rs" +[[bin]] +name = "ic-systest-canister-http-flexible-paying" +path = "canister_http_flexible_paying_test.rs" + [[bin]] name = "ic-systest-canister-http" path = "canister_http_test.rs" diff --git a/rs/tests/networking/canister_http/canister_http.rs b/rs/tests/networking/canister_http/canister_http.rs index 119114582682..20a5c55c4511 100644 --- a/rs/tests/networking/canister_http/canister_http.rs +++ b/rs/tests/networking/canister_http/canister_http.rs @@ -71,10 +71,10 @@ pub fn setup(env: TestEnv) { ); } -/// Like [`setup`], but the application subnet uses a free cost schedule (enabling -/// flexible HTTP outcalls via the legacy pricing fallback) and the system subnet -/// also runs HTTP outcalls with its own proxy canister — system subnets are free -/// for outcalls too, despite a normal cost schedule. +/// Like [`setup`], but the application subnet uses a free cost schedule, so HTTP +/// outcalls cost nothing there, and the system subnet also runs HTTP outcalls with +/// its own proxy canister — system subnets are free for outcalls too, despite a +/// normal cost schedule. pub fn setup_with_free_cost_schedule(env: TestEnv) { setup_with_cost_schedule( env, @@ -83,6 +83,19 @@ pub fn setup_with_free_cost_schedule(env: TestEnv) { ); } +/// Like [`setup_with_free_cost_schedule`], but the application subnet is on a +/// normal cost schedule, so its HTTP outcalls are actually paid for under +/// pay-as-you-go. That is where the allowance, refund and out-of-cycles paths +/// are live, so a test suite worth running on a free subnet is generally worth +/// running here too. +pub fn setup_with_paying_cost_schedule(env: TestEnv) { + setup_with_cost_schedule( + env, + CanisterCyclesCostSchedule::Normal, + /*system_subnet_outcalls=*/ true, + ); +} + fn setup_with_cost_schedule( env: TestEnv, cost_schedule: CanisterCyclesCostSchedule, diff --git a/rs/tests/networking/canister_http_correctness/BUILD.bazel b/rs/tests/networking/canister_http_correctness/BUILD.bazel new file mode 100644 index 000000000000..186cf6e59068 --- /dev/null +++ b/rs/tests/networking/canister_http_correctness/BUILD.bazel @@ -0,0 +1,37 @@ +load("@rules_rust//rust:defs.bzl", "rust_library") + +package(default_visibility = ["//rs:system-tests-pkg"]) + +rust_library( + name = "canister_http_correctness", + testonly = True, + srcs = ["canister_http_correctness.rs"], + crate_name = "canister_http_correctness", + target_compatible_with = ["@platforms//os:linux"], + deps = [ + # Keep sorted. + "//rs/config", + "//rs/cycles_account_manager", + "//rs/registry/subnet_type", + "//rs/rust_canisters/canister_test", + "//rs/rust_canisters/dfn_candid", + "//rs/rust_canisters/proxy_canister:lib", + "//rs/test_utilities", + "//rs/test_utilities/types", + "//rs/tests/driver:ic-system-test-driver", + "//rs/tests/networking/canister_http", + "//rs/types/base_types", + "//rs/types/cycles", + "//rs/types/management_canister_types", + "//rs/types/types", + "@crate_index//:anyhow", + "@crate_index//:assert_matches", + "@crate_index//:candid", + "@crate_index//:ic-agent", + "@crate_index//:rand", + "@crate_index//:serde", + "@crate_index//:serde_json", + "@crate_index//:slog", + "@crate_index//:tokio", + ], +) diff --git a/rs/tests/networking/canister_http_correctness/Cargo.toml b/rs/tests/networking/canister_http_correctness/Cargo.toml new file mode 100644 index 000000000000..ae40d23403b8 --- /dev/null +++ b/rs/tests/networking/canister_http_correctness/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "canister_http_correctness" +version.workspace = true +authors.workspace = true +edition.workspace = true +description.workspace = true +documentation.workspace = true + +[dependencies] +anyhow = { workspace = true } +assert_matches = { workspace = true } +candid = { workspace = true } +canister-test = { path = "../../../rust_canisters/canister_test" } +canister_http = { path = "../canister_http" } +dfn_candid = { path = "../../../rust_canisters/dfn_candid" } +ic-agent = { workspace = true } +ic-base-types = { path = "../../../types/base_types" } +ic-config = { path = "../../../config" } +ic-cycles-account-manager = { path = "../../../cycles_account_manager" } +ic-management-canister-types-private = { path = "../../../types/management_canister_types" } +ic-registry-subnet-type = { path = "../../../registry/subnet_type" } +ic-system-test-driver = { path = "../../driver" } +ic-test-utilities = { path = "../../../test_utilities" } +ic-test-utilities-types = { path = "../../../test_utilities/types" } +ic-types = { path = "../../../types/types" } +ic-types-cycles = { path = "../../../types/cycles" } +proxy_canister = { path = "../../../rust_canisters/proxy_canister" } +rand = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +slog = { workspace = true } +tokio = { workspace = true } + +[lib] +name = "canister_http_correctness" +path = "canister_http_correctness.rs" diff --git a/rs/tests/networking/canister_http_correctness/canister_http_correctness.rs b/rs/tests/networking/canister_http_correctness/canister_http_correctness.rs new file mode 100644 index 000000000000..44e7c25d4cbf --- /dev/null +++ b/rs/tests/networking/canister_http_correctness/canister_http_correctness.rs @@ -0,0 +1,3015 @@ +//! The scenarios checking non-flexible HTTP outcalls against the spec, shared by +//! the system tests that run them under each pricing model. +//! +//! Which model applies is a property of the *request* (`pricing_version`), not of +//! the subnet, so both suites run against the same setup and differ only in what +//! [`shared_scenarios`] stamps onto every outcall. +//! +//! Almost everything here is pricing-agnostic, including the refund assertions: a +//! synchronously rejected request has its payment returned intact either way, and +//! a request that got as far as being charged refunds less than the full payment +//! either way (under pay-as-you-go the payment is consumed up front and the +//! unspent allowance is credited back to the balance instead). The scenarios that +//! do depend on the model live in [`add_legacy_pricing_scenarios`] and +//! [`add_pay_as_you_go_pricing_scenarios`]. +#![allow(deprecated)] + +use anyhow::Result; +use assert_matches::assert_matches; +use candid::{CandidType, Decode, Deserialize, Encode, Principal, decode_one}; +use canister_http::*; +use canister_test::{Canister, Runtime}; +use ic_agent::{ + Agent, AgentError, + agent::{CallResponse, RejectCode, RejectResponse}, +}; +use ic_base_types::{CanisterId, NumBytes, PrincipalId}; +use ic_config::subnet_config::DEFAULT_REFERENCE_SUBNET_SIZE; +use ic_cycles_account_manager::CyclesAccountManagerSubnetConfig; +use ic_management_canister_types_private::{ + BoundedHttpHeaders, FlexibleCanisterHttpRequestArgs, FlexibleHttpRequestResult, HttpHeader, + HttpMethod, TransformContext, TransformFunc, +}; +use ic_system_test_driver::{ + canister_agent::HasCanisterAgentCapability, + driver::{ + group::{SystemTestGroup, SystemTestSubGroup}, + test_env::TestEnv, + test_env_api::HasTopologySnapshot, + }, + retry_agent_on_transport_errors, systest, + util::{block_on, get_app_subnet_and_node}, +}; +use ic_test_utilities::cycles_account_manager::CyclesAccountManagerBuilder; +use ic_test_utilities_types::messages::RequestBuilder; +use ic_types::{ + RegistryVersion, + canister_http::{CanisterHttpRequestContext, MAX_CANISTER_HTTP_REQUEST_BYTES}, + time::UNIX_EPOCH, +}; +use ic_types_cycles::CanisterCyclesCostSchedule; +use proxy_canister::{ + FlexibleRemoteHttpRequest, RejectionCode, RemoteHttpRequest, RemoteHttpResponse, + ResponseWithRefundedCycles, UnvalidatedCanisterHttpRequestArgs, +}; +use serde_json::Value; +use std::collections::{BTreeSet, HashSet}; +use std::sync::OnceLock; + +const MAX_REQUEST_BYTES_LIMIT: usize = 2_000_000; +const MAX_MAX_RESPONSE_BYTES: usize = 2_000_000; +const DEFAULT_MAX_RESPONSE_BYTES: u64 = 2_000_000; +const MAX_CANISTER_HTTP_URL_SIZE: usize = 8 * 1024; +const MAX_HEADER_NAME_LENGTH: usize = 8 * 1024; +const MAX_HEADER_VALUE_LENGTH: usize = 8 * 1024; +const TOTAL_HEADER_NAME_AND_VALUE_LENGTH: usize = 48 * 1024; +const HTTP_HEADERS_MAX_NUMBER: usize = 64; +const HTTP_REQUEST_CYCLE_PAYMENT: u64 = 500_000_000_000; + +// httpbin-rs returns 5 headers in addition to the requested headers: +// content-type, access-control-allow-origin, access-control-allow-credentials, date, content-length. +const HTTPBIN_OVERHEAD_RESPONSE_HEADERS: usize = 5; + +/// The pricing model every outcall in this suite opts into, set once by +/// [`shared_scenarios`] and stamped onto each request by [`submit_outcall`]. +/// +/// This is per-binary configuration — each test target picks one model and runs +/// the whole suite under it — so it is read here rather than threaded through +/// every scenario. +static PRICING_VERSION: OnceLock = OnceLock::new(); + +fn pricing_version() -> u32 { + *PRICING_VERSION + .get() + .expect("the pricing version is set before any scenario runs") +} + +struct Handlers<'a> { + subnet_size: usize, + runtime: Runtime, + env: &'a TestEnv, +} + +impl<'a> Handlers<'a> { + fn new(env: &'a TestEnv) -> Handlers<'a> { + let subnet_size = get_node_snapshots(env).count(); + + let runtime = { + let mut nodes = get_node_snapshots(env); + let node = nodes.next().expect("there is no application node"); + get_runtime_from_node(&node) + }; + + Handlers { + runtime, + subnet_size, + env, + } + } + + fn proxy_canister(&self) -> Canister<'_> { + let principal_id = get_proxy_canister_id(self.env); + let canister_id = CanisterId::unchecked_from_principal(principal_id); + Canister::new(&self.runtime, canister_id) + } + + async fn agent(&self) -> Agent { + let topology_snapshot = self.env.topology_snapshot(); + let (_, app_node) = get_app_subnet_and_node(&topology_snapshot); + + app_node.build_canister_agent().await.agent + } +} + +/// The scenarios that hold under either pricing model, with every outcall opting +/// into `pricing_version`. +/// +/// The caller appends the scenarios specific to the model it picked — see +/// [`add_legacy_pricing_scenarios`] and [`add_pay_as_you_go_pricing_scenarios`]. +pub fn shared_scenarios(pricing_version: u32) -> SystemTestGroup { + PRICING_VERSION + .set(pricing_version) + .expect("the pricing version is set exactly once"); + + SystemTestGroup::new() + .with_setup(canister_http::setup) + .add_parallel( + SystemTestSubGroup::new() + .add_test(systest!(test_enforce_https)) + .add_test(systest!(test_no_cycles_attached)) + .add_test(systest!(test_post_request)) + .add_test(systest!( + test_http_endpoint_with_delayed_response_is_rejected + )) + .add_test(systest!(test_that_redirects_are_not_followed)) + .add_test(systest!(test_http_calls_to_ic_fails)) + .add_test(systest!(test_get_hello_world_call)) + .add_test(systest!(test_post_call)) + .add_test(systest!(test_head_call)) + .add_test(systest!(test_put_call)) + .add_test(systest!(test_put_without_non_replicated_rejected)) + .add_test(systest!(test_delete_call)) + .add_test(systest!(test_delete_without_non_replicated_rejected)) + .add_test(systest!(test_patch_call)) + .add_test(systest!(test_patch_without_non_replicated_rejected)) + .add_test(systest!(test_max_possible_request_size)) + .add_test(systest!(test_max_possible_request_size_exceeded)) + // This section tests the request headers limits scenarios + .add_test(systest!(test_request_header_name_and_value_within_limits)) + .add_test(systest!(test_request_header_name_too_long)) + .add_test(systest!(test_request_header_value_too_long)) + .add_test(systest!( + test_request_header_total_size_within_the_48_kib_limit + )) + .add_test(systest!( + test_request_header_total_size_over_the_48_kib_limit + )) + // This section tests the response headers limits scenarios + .add_test(systest!(test_response_header_name_within_limit)) + .add_test(systest!(test_response_header_name_over_limit)) + .add_test(systest!(test_response_header_value_within_limit)) + .add_test(systest!(test_response_header_value_over_limit)) + .add_test(systest!( + test_response_header_total_size_within_the_48_kib_limit + )) + .add_test(systest!( + test_response_header_total_size_over_the_48_kib_limit + )) + // This section tests the url and ip scenarios + .add_test(systest!(test_non_ascii_url_is_accepted)) + .add_test(systest!(test_invalid_ip)) + .add_test(systest!(test_invalid_domain_name)) + .add_test(systest!(test_max_url_length)) + .add_test(systest!(test_max_url_length_exceeded)) + // This section tests the transform function scenarios + .add_test(systest!(test_transform_function_is_executed)) + .add_test(systest!(no_data_certificate_in_transform_function)) + .add_test(systest!(test_composite_transform_function_is_not_allowed)) + .add_test(systest!(check_caller_id_on_transform_function)) + .add_test(systest!( + test_transform_that_bloats_response_above_2mb_limit + )) + .add_test(systest!(test_transform_that_bloats_on_the_2mb_limit)) + .add_test(systest!( + test_transform_that_bloats_on_the_2mb_limit_with_custom_max_response_bytes + )) + .add_test(systest!( + reference_transform_function_exposed_by_different_canister + )) + .add_test(systest!(test_non_existent_transform_function)) + // This section tests the max number of request or response headers scenarios + .add_test(systest!(test_max_number_of_request_headers)) + .add_test(systest!(test_max_number_of_request_headers_exceeded)) + .add_test(systest!(test_max_number_of_response_headers)) + .add_test(systest!(test_max_number_of_response_headers_exceeded)) + // This section tests the max_response_bytes scenarios + .add_test(systest!( + test_http_endpoint_response_is_too_large_with_custom_max_response_bytes + )) + .add_test(systest!( + test_http_endpoint_response_is_within_limits_with_custom_max_response_bytes + )) + .add_test(systest!( + test_http_endpoint_response_is_too_large_with_default_max_response_bytes + )) + .add_test(systest!( + test_http_endpoint_response_is_within_limits_with_default_max_response_bytes + )) + .add_test(systest!(test_only_headers_with_custom_max_response_bytes)) + .add_test(systest!( + test_only_headers_with_custom_max_response_bytes_exceeded + )) + .add_test(systest!(test_max_response_bytes_too_large)) + .add_test(systest!(test_max_response_bytes_2_mb_returns_ok)) + .add_test(systest!( + test_flexible_http_request_enabled_on_normal_subnet + )), + ) +} + +/// The scenarios that pin down legacy pricing specifically: it charges for the +/// whole of `max_response_bytes` up front, so the exact payment an outcall needs +/// is a function of that limit. +pub fn add_legacy_pricing_scenarios(group: SystemTestGroup) -> SystemTestGroup { + group.add_parallel( + SystemTestSubGroup::new() + .add_test(systest!(test_2mb_response_cycle_for_rejection_path)) + .add_test(systest!(test_4096_max_response_cycle_case_1)) + .add_test(systest!(test_4096_max_response_cycle_case_2)), + ) +} + +/// The scenarios that pin down pay-as-you-go pricing specifically: only a base +/// fee is charged up front, the rest of the payment becomes a per-replica +/// allowance, and what goes unspent is credited back to the caller's balance. +/// +/// These run sequentially: [`test_pay_as_you_go_charges_and_refunds`] measures the +/// proxy canister's balance across a single outcall, which any concurrent outcall +/// from the same canister would perturb. +pub fn add_pay_as_you_go_pricing_scenarios(group: SystemTestGroup) -> SystemTestGroup { + group + .add_test(systest!(test_pay_as_you_go_charges_and_refunds)) + .add_test(systest!(test_pay_as_you_go_out_of_cycles)) +} + +fn test_enforce_https(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("http://[{webserver_ipv6}]"), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "transform".to_string(), + }), + context: vec![0, 1, 2], + }), + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::SysFatal, + .. + }) + ); +} + +fn test_transform_function_is_executed(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let transform_context = "transform_context".as_bytes().to_vec(); + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]"), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "test_transform".to_string(), + }), + context: transform_context.clone(), + }), + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + let response = response.expect("Http call should succeed"); + + assert_eq!(response.headers.len(), 2, "Headers: {:?}", response.headers); + assert_eq!(response.headers[0].0, "hello"); + assert_eq!(response.headers[0].1, "bonjour"); + assert_eq!(response.headers[1].0, "caller"); + assert_eq!(response.headers[1].1, "aaaaa-aa"); + assert_eq!( + response.body.as_str(), + "transform_context", + "Transform function did not set the body to the provided context." + ); + assert_eq!(response.status, 202); +} + +fn no_data_certificate_in_transform_function(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]"), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "data_certificate_in_transform".to_string(), + }), + context: vec![], + }), + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + let response = response.expect("Http call should succeed"); + + assert_eq!(response.headers.len(), 2, "Headers: {:?}", response.headers); + assert_eq!(response.headers[0].0, "data_certificate_present"); + assert_eq!(response.headers[0].1, "false"); + assert_eq!(response.headers[1].0, "in_replicated_execution"); + assert_eq!(response.headers[1].1, "false"); +} + +fn test_non_existent_transform_function(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let transform_context = "transform_context".as_bytes().to_vec(); + + let (response, refunded_cycles) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]"), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "non_existent_transform_function".to_string(), + }), + context: transform_context.clone(), + }), + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::CanisterError, + .. + }) + ); + assert_ne!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); +} + +fn test_composite_transform_function_is_not_allowed(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]"), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "test_composite_transform".to_string(), + }), + context: vec![0, 1, 2], + }), + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + let err = response.unwrap_err(); + assert_eq!(err.reject_code, RejectCode::CanisterError); + assert!( + err.reject_message + .contains("Composite query cannot be used as transform in canister http outcalls.") + ); +} + +fn test_no_cycles_attached(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("http://[{webserver_ipv6}]"), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "transform".to_string(), + }), + context: vec![0, 1, 2], + }), + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: 0, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::CanisterReject, + .. + }) + ); +} + +fn test_max_possible_request_size(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + let headers_list = vec![ + ("name1".to_string(), "value1".to_string()), + ("name2".to_string(), "value2".to_string()), + ]; + + let header_list_size = headers_list + .iter() + .map(|(name, value)| name.len() + value.len()) + .sum::(); + + let headers = headers_list + .into_iter() + .map(|(name, value)| HttpHeader { name, value }) + .collect(); + + let body = vec![0; MAX_REQUEST_BYTES_LIMIT - header_list_size]; + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]/request_size"), + headers, + method: HttpMethod::POST, + body: Some(body), + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "transform".to_string(), + }), + context: vec![0, 1, 2], + }), + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!(response, Ok(r) if r.status==200); +} + +fn test_max_possible_request_size_exceeded(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + let headers_list = vec![ + ("name1".to_string(), "value1".to_string()), + ("name2".to_string(), "value2".to_string()), + ]; + + let header_list_size = headers_list + .iter() + .map(|(name, value)| name.len() + value.len()) + .sum::(); + + let headers = headers_list + .into_iter() + .map(|(name, value)| HttpHeader { name, value }) + .collect(); + + let body = vec![0; MAX_REQUEST_BYTES_LIMIT - header_list_size + 1]; + + let (response, refunded_cycles) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]/request_size"), + headers, + method: HttpMethod::POST, + body: Some(body), + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "transform".to_string(), + }), + context: vec![0, 1, 2], + }), + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::CanisterReject, + .. + }) + ); + assert_eq!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); +} + +fn test_2mb_response_cycle_for_rejection_path(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let request = UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]"), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "transform".to_string(), + }), + context: vec![0, 1, 2], + }), + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }; + + let (response, _) = block_on(async move { + submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: expected_cycle_cost( + handlers.proxy_canister().canister_id(), + request, + handlers.subnet_size, + ) - 1, + }, + ) + .await + }); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::CanisterReject, + .. + }) + ); +} + +fn test_4096_max_response_cycle_case_1(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let request = UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]"), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "transform".to_string(), + }), + context: vec![0, 1, 2], + }), + max_response_bytes: Some(16384), + is_replicated: None, + pricing_version: None, + }; + + let (response, _) = block_on(async move { + submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: expected_cycle_cost( + handlers.proxy_canister().canister_id(), + request.clone(), + handlers.subnet_size, + ), + }, + ) + .await + }); + + assert_matches!(response, Ok(r) if r.status==200); +} + +fn test_4096_max_response_cycle_case_2(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let request = UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]"), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "transform".to_string(), + }), + context: vec![0, 1, 2], + }), + max_response_bytes: Some(16384), + is_replicated: None, + pricing_version: None, + }; + + let (response, _) = block_on(async move { + submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: expected_cycle_cost( + handlers.proxy_canister().canister_id(), + request.clone(), + handlers.subnet_size, + ) - 1, + }, + ) + .await + }); + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::CanisterReject, + .. + }) + ); +} + +fn test_max_response_bytes_2_mb_returns_ok(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]"), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: None, + max_response_bytes: Some((MAX_MAX_RESPONSE_BYTES) as u64), + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!(response, Ok(r) if r.status==200); +} + +fn test_max_response_bytes_too_large(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let (response, refunded_cycles) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]"), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: None, + max_response_bytes: Some((MAX_MAX_RESPONSE_BYTES + 1) as u64), + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::CanisterReject, + .. + }) + ); + assert_eq!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); +} + +fn test_transform_that_bloats_on_the_2mb_limit(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]"), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "very_large_but_allowed_transform".to_string(), + }), + context: vec![0, 1, 2], + }), + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!(response, Ok(r) if r.status==200); +} + +fn test_transform_that_bloats_on_the_2mb_limit_with_custom_max_response_bytes(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let max_response_bytes = 1_000_000; + + let (response, refunded_cycles) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]"), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "very_large_but_allowed_transform".to_string(), + }), + context: vec![0, 1, 2], + }), + max_response_bytes: Some(max_response_bytes), + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::SysFatal, + .. + }) + ); + assert_ne!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); +} + +fn test_transform_that_bloats_response_above_2mb_limit(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let (response, refunded_cycles) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]"), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "bloat_transform".to_string(), + }), + context: vec![0, 1, 2], + }), + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::SysFatal, + .. + }) + ); + assert_ne!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); +} + +fn test_post_request(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]/post"), + headers: vec![HttpHeader { + name: "content-type".to_string(), + value: "application/x-www-form-urlencoded".to_string(), + }], + method: HttpMethod::POST, + body: Some("satoshi".as_bytes().to_vec()), + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "transform".to_string(), + }), + context: vec![0, 1, 2], + }), + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!(response, Ok(r) if r.body.contains("satoshi")); +} + +fn test_http_endpoint_response_is_within_limits_with_custom_max_response_bytes(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let n = 1_000_000; + + // { Response headers + // date: Jan 1 1970 00:00:00 GMT + // content-type: application/octet-stream + // content-length: 1xxxxxx + // access-control-allow-origin: * + // access-control-allow-credentials: true + // } + let header_size = 148; + let max_response_bytes: u64 = n + header_size; + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]/bytes/{n}"), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: None, + max_response_bytes: Some(max_response_bytes), + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + let response = response.expect("Request is successful."); + + assert_matches!(&response, RemoteHttpResponse { status: 200, .. }); +} + +fn test_http_endpoint_response_is_too_large_with_custom_max_response_bytes(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let n = 1_000_000; + + // { Response headers + // date: Jan 1 1970 00:00:00 GMT + // content-type: application/octet-stream + // content-length: 1xxxxxx + // access-control-allow-origin: * + // access-control-allow-credentials: true + // } + let header_size = 148; + let max_response_bytes = n + header_size; + + let const_transform = TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "transform".to_string(), + }), + context: vec![0, 1, 2], + }; + + for transform in [None, Some(const_transform)] { + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]/bytes/{}", n + 1), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform, + max_response_bytes: Some(max_response_bytes), + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::SysFatal, + .. + }) + ); + } +} + +fn test_http_endpoint_response_is_within_limits_with_default_max_response_bytes(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + // { Response headers + // date: Jan 1 1970 00:00:00 GMT + // content-type: application/octet-stream + // content-length: 1xxxxxx + // access-control-allow-origin: * + // access-control-allow-credentials: true + // } + let header_size = 148; + let n = DEFAULT_MAX_RESPONSE_BYTES - header_size; + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]/bytes/{n}"), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: None, + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + let response = response.expect("Request is successful."); + + assert_matches!(&response, RemoteHttpResponse { status: 200, .. }); +} + +fn test_http_endpoint_response_is_too_large_with_default_max_response_bytes(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + // { Response headers + // date: Jan 1 1970 00:00:00 GMT + // content-type: application/octet-stream + // content-length: 1xxxxxx + // access-control-allow-origin: * + // access-control-allow-credentials: true + // } + let header_size = 148; + let n = DEFAULT_MAX_RESPONSE_BYTES - header_size; + + let const_transform = TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "transform".to_string(), + }), + context: vec![0, 1, 2], + }; + + for transform in [None, Some(const_transform)] { + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]/bytes/{}", n + 1), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform, + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::SysFatal, + .. + }) + ); + } +} + +fn test_http_endpoint_with_delayed_response_is_rejected(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]/delay/40"), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "transform".to_string(), + }), + context: vec![0, 1, 2], + }), + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::SysFatal, + .. + }) + ); +} + +/// The adapter should not follow HTTP redirects. +fn test_that_redirects_are_not_followed(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]/redirect/10"), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "transform".to_string(), + }), + context: vec![0, 1, 2], + }), + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!(response, Ok(r) if r.status == 303); +} + +/// The adapter should reject HTTP calls that are made to other IC replicas' HTTPS endpoints. +fn test_http_calls_to_ic_fails(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]:9090"), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "transform".to_string(), + }), + context: vec![0, 1, 2], + }), + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + // Newer `hyper_util` versions embed the target socket address in the + // `ConnectError`, so we only check the stable prefix and suffix. + let expected_error_message_prefix = "Error(Connect, ConnectError(\"tcp connect error\", "; + let expected_error_message_suffix = + "Os { code: 111, kind: ConnectionRefused, message: \"Connection refused\" }))"; + let err_response = response.clone().unwrap_err(); + + assert_matches!(err_response.reject_code, RejectCode::SysTransient); + + assert!( + err_response + .reject_message + .contains(expected_error_message_prefix) + && err_response + .reject_message + .contains(expected_error_message_suffix), + "Expected error message to contain {} and {}, got: {}", + expected_error_message_prefix, + expected_error_message_suffix, + err_response.reject_message + ); +} + +fn test_invalid_domain_name(env: TestEnv) { + let handlers = Handlers::new(&env); + + let (response, refunded_cycles) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: "https://xwWPqqbNqxxHmLXdguF4DN9xGq22nczV.com".to_string(), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "transform".to_string(), + }), + context: vec![0, 1, 2], + }), + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::SysTransient, + .. + }) + ); + assert_ne!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); +} + +fn test_invalid_ip(env: TestEnv) { + let handlers = Handlers::new(&env); + + let (response, refunded_cycles) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + // `2001:db8::1` is a reserved ipv6 address used in documentation and example source code. + // See https://www.rfc-editor.org/rfc/rfc3849 + url: "https://[2001:db8::1]".to_string(), + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "transform".to_string(), + }), + context: vec![0, 1, 2], + }), + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::SysTransient, + .. + }) + ); + assert_ne!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); +} + +/// Test that the response body returned is the same as the requested path. +fn test_get_hello_world_call(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + let expected_body = "hello_world"; + + let url = format!("https://[{}]/{}/{}", webserver_ipv6, "ascii", expected_body); + + let max_response_bytes = 666; + + let request = UnvalidatedCanisterHttpRequestArgs { + url, + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: None, + max_response_bytes: Some(max_response_bytes), + is_replicated: None, + pricing_version: None, + }; + + let (response, refunded_cycles) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + let response = response.expect("Request is successful."); + + assert_matches!(&response, RemoteHttpResponse {body, status: 200, ..} if body == expected_body); + assert_ne!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); + assert_http_response(&response); +} + +fn test_request_header_total_size_within_the_48_kib_limit(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + // Header count is 3, as our current total limit is 48KiB and the tuple of header name and value is 16KiB. + let header_count = + TOTAL_HEADER_NAME_AND_VALUE_LENGTH / (MAX_HEADER_NAME_LENGTH + MAX_HEADER_VALUE_LENGTH); + let mut headers = vec![]; + + for i in 0..header_count { + headers.push(HttpHeader { + name: format!("{i}").repeat(MAX_HEADER_NAME_LENGTH), + value: "y".repeat(MAX_HEADER_VALUE_LENGTH), + }); + } + + let request = UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]"), + headers, + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: None, + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }; + + let (response, refunded_cycles) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + let response = response.expect("Request succeeds."); + + assert_matches!(&response, RemoteHttpResponse { status: 200, .. }); + assert_ne!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); +} + +fn test_request_header_total_size_over_the_48_kib_limit(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + // Header count is 3, as our current total limit is 48KiB and the tuple of header name and value is 16KiB. + let header_count = + TOTAL_HEADER_NAME_AND_VALUE_LENGTH / (MAX_HEADER_NAME_LENGTH + MAX_HEADER_VALUE_LENGTH); + let mut headers = vec![]; + + for i in 0..header_count { + headers.push(HttpHeader { + name: format!("{i}").repeat(MAX_HEADER_NAME_LENGTH), + value: "y".repeat(MAX_HEADER_VALUE_LENGTH), + }); + } + // The last header will push the total size over the limit. + headers.push(HttpHeader { + name: "x".to_string(), + value: "y".to_string(), + }); + + let request = UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]"), + headers, + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: None, + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }; + + let (response, refunded_cycles) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::CanisterReject, + .. + }) + ); + assert_eq!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); +} + +fn test_response_header_total_size_within_the_48_kib_limit(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + // We use the /large_response_headers_size endpoint which should return headers + // with the specified value length, after accounting also for the + // overhead headers (e.g. content-length, date, etc.) + let url = format!( + "https://[{webserver_ipv6}]/large_response_total_header_size/{MAX_HEADER_NAME_LENGTH}/{TOTAL_HEADER_NAME_AND_VALUE_LENGTH}", + ); + + let (response, refunded_cycles) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url, + headers: vec![], + method: HttpMethod::GET, + body: None, + transform: None, + max_response_bytes: Some(DEFAULT_MAX_RESPONSE_BYTES), + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!(&response, Ok(RemoteHttpResponse { status: 200, .. })); + assert_ne!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); + + // Compute exactly the size of the response headers to account also for overhead. + let total_header_size: usize = response + .unwrap() + .headers + .iter() + .map(|(name, value)| name.len() + value.len()) + .sum(); + + // Ensure that the successful response contains the expected response headers. + assert!( + total_header_size <= 48 * 1024, + "Total header size ({total_header_size} bytes) exceeds 48KiB limit" + ); +} + +fn test_response_header_total_size_over_the_48_kib_limit(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + // We use the /large_response_total_header_size endpoint which should return headers + // with the specified value length, after accounting also for the + // overhead headers (e.g. content-length, date, etc.) + let url = format!( + "https://[{}]/large_response_total_header_size/{}/{}", + webserver_ipv6, + MAX_HEADER_NAME_LENGTH, + TOTAL_HEADER_NAME_AND_VALUE_LENGTH + 1, + ); + + let (response, refunded_cycles) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url, + headers: vec![], + method: HttpMethod::GET, + body: None, + transform: None, + max_response_bytes: Some(DEFAULT_MAX_RESPONSE_BYTES), + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + &response, + Err(RejectResponse { + reject_code: RejectCode::SysFatal, + .. + }) + ); + assert_ne!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); +} + +fn test_request_header_name_and_value_within_limits(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let headers = vec![HttpHeader { + name: "x".repeat(MAX_HEADER_NAME_LENGTH), + value: "y".repeat(MAX_HEADER_VALUE_LENGTH), + }]; + + let request = UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]"), + headers, + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: None, + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }; + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + let response = response.expect("Request succeeds."); + + assert_matches!(&response, RemoteHttpResponse { status: 200, .. }); +} + +fn test_request_header_name_too_long(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let headers = vec![HttpHeader { + name: "x".repeat(MAX_HEADER_NAME_LENGTH + 1), + value: "value".to_string(), + }]; + + let request = UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]"), + headers, + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: None, + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }; + + let (response, refunded_cycles) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::CanisterReject, + .. + }) + ); + assert_eq!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); +} + +fn test_request_header_value_too_long(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let headers = vec![HttpHeader { + name: "name".to_string(), + value: "y".repeat(MAX_HEADER_VALUE_LENGTH + 1), + }]; + + let request = UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]"), + headers, + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: None, + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }; + + let (response, refunded_cycles) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::CanisterReject, + .. + }) + ); + assert_eq!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); +} + +fn test_response_header_name_within_limit(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let url = + format!("https://[{webserver_ipv6}]/long_response_header_name/{MAX_HEADER_NAME_LENGTH}",); + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url, + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: None, + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!(&response, Ok(RemoteHttpResponse { status: 200, .. })); +} + +fn test_response_header_name_over_limit(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let url = format!( + "https://[{}]/long_response_header_name/{}", + webserver_ipv6, + MAX_HEADER_NAME_LENGTH + 1, + ); + + let (response, refunded_cycles) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url, + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: None, + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::SysFatal, + .. + }) + ); + + assert_ne!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); +} + +fn test_response_header_value_within_limit(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let url = + format!("https://[{webserver_ipv6}]/long_response_header_value/{MAX_HEADER_VALUE_LENGTH}",); + + let request = UnvalidatedCanisterHttpRequestArgs { + url, + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: None, + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }; + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!(&response, Ok(RemoteHttpResponse { status: 200, .. })); +} + +fn test_response_header_value_over_limit(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let url = format!( + "https://[{}]/long_response_header_value/{}", + webserver_ipv6, + MAX_HEADER_VALUE_LENGTH + 1, + ); + + let request = UnvalidatedCanisterHttpRequestArgs { + url, + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: None, + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }; + + let (response, refunded_cycles) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::SysFatal, + .. + }) + ); + assert_ne!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); +} + +fn test_post_call(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + let expected_body = "POST"; + + let url = format!("https://[{}]/{}", webserver_ipv6, "anything"); + let body = Some("hello_world".as_bytes().to_vec()); + let headers = vec![ + HttpHeader { + name: "name1".to_string(), + value: "value1".to_string(), + }, + HttpHeader { + name: "name2".to_string(), + value: "value2".to_string(), + }, + ]; + let max_response_bytes = Some(666); + + let request = UnvalidatedCanisterHttpRequestArgs { + url, + headers, + method: HttpMethod::POST, + body, + transform: None, + max_response_bytes, + is_replicated: None, + pricing_version: None, + }; + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + let response = response.expect("Request succeeds."); + + assert_matches!(&response, RemoteHttpResponse {body, status: 200, ..} if body.contains(expected_body)); + assert_distinct_headers(&response); + assert_http_json_response(&request, &response); +} + +/// Send 6666 repeating `x` to /anything endpoint. +/// Use HEAD http method. It only asks for the head, not the body. +/// Set max response size to 666 (order of magnitude smaller) +fn test_head_call(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let long_x_string = "x".repeat(6666); + let url = format!( + "https://[{}]/{}/{}", + webserver_ipv6, "anything", long_x_string + ); + let body = Some("hello_world".as_bytes().to_vec()); + let headers = vec![ + HttpHeader { + name: "name1".to_string(), + value: "value1".to_string(), + }, + HttpHeader { + name: "name2".to_string(), + value: "value2".to_string(), + }, + ]; + let max_response_bytes = Some(666); + + let request = UnvalidatedCanisterHttpRequestArgs { + url, + headers, + method: HttpMethod::HEAD, + body, + transform: None, + max_response_bytes, + is_replicated: None, + pricing_version: None, + }; + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + let response = response.expect("Request succeeds."); + + assert_matches!(&response, RemoteHttpResponse { status: 200, .. }); + assert_distinct_headers(&response); + let header_size = response + .headers + .iter() + .map(|(header, value)| header.len() + value.len()) + .sum::(); + assert!(header_size <= 666); + assert!( + response.body.is_empty(), + "Head request does not return a body." + ); +} + +fn test_put_call(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let url = format!("https://[{webserver_ipv6}]/anything"); + let body = Some("put_request_body".as_bytes().to_vec()); + let headers = vec![HttpHeader { + name: "name1".to_string(), + value: "value1".to_string(), + }]; + + let request = UnvalidatedCanisterHttpRequestArgs { + url, + headers, + method: HttpMethod::PUT, + body, + transform: None, + max_response_bytes: None, + is_replicated: Some(false), + pricing_version: None, + }; + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!(response, Ok(response) => { + assert_matches!(response, RemoteHttpResponse { status: 200, .. }); + assert_distinct_headers(&response); + assert_http_json_response(&request, &response); + }); +} + +fn test_put_without_non_replicated_rejected(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let url = format!("https://[{webserver_ipv6}]/anything"); + let request = UnvalidatedCanisterHttpRequestArgs { + url, + headers: vec![], + method: HttpMethod::PUT, + body: Some(vec![]), + transform: None, + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }; + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + assert_matches!(response, Err(RejectResponse { reject_message, .. }) => { + assert!(reject_message.contains("only allowed for non-replicated requests")); + }); +} + +fn test_delete_call(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let url = format!("https://[{webserver_ipv6}]/anything"); + let headers = vec![HttpHeader { + name: "name1".to_string(), + value: "value1".to_string(), + }]; + + let request = UnvalidatedCanisterHttpRequestArgs { + url, + headers, + method: HttpMethod::DELETE, + body: None, + transform: None, + max_response_bytes: None, + is_replicated: Some(false), + pricing_version: None, + }; + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!(response, Ok(response) => { + assert_matches!(response, RemoteHttpResponse { status: 200, .. }); + assert_distinct_headers(&response); + assert_http_json_response(&request, &response); + }); +} + +fn test_delete_without_non_replicated_rejected(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let url = format!("https://[{}]/{}", webserver_ipv6, "anything"); + let request = UnvalidatedCanisterHttpRequestArgs { + url, + headers: vec![], + method: HttpMethod::DELETE, + body: None, + transform: None, + max_response_bytes: Some(1024), + is_replicated: None, + pricing_version: None, + }; + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!(response, Err(RejectResponse { reject_message, .. }) => { + assert!(reject_message.contains("only allowed for non-replicated requests")); + }); +} + +fn test_patch_call(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let url = format!("https://[{webserver_ipv6}]/anything"); + let body = Some("patch_request_body".as_bytes().to_vec()); + let headers = vec![HttpHeader { + name: "name1".to_string(), + value: "value1".to_string(), + }]; + + let request = UnvalidatedCanisterHttpRequestArgs { + url, + headers, + method: HttpMethod::PATCH, + body, + transform: None, + max_response_bytes: None, + is_replicated: Some(false), + pricing_version: None, + }; + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!(response, Ok(response) => { + assert_matches!(response, RemoteHttpResponse { status: 200, .. }); + assert_distinct_headers(&response); + assert_http_json_response(&request, &response); + }); +} + +fn test_patch_without_non_replicated_rejected(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let url = format!("https://[{}]/{}", webserver_ipv6, "anything"); + let request = UnvalidatedCanisterHttpRequestArgs { + url, + headers: vec![], + method: HttpMethod::PATCH, + body: Some(vec![]), + transform: None, + max_response_bytes: Some(1024), + is_replicated: None, + pricing_version: None, + }; + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!(response, Err(RejectResponse { reject_message, .. }) => { + assert!(reject_message.contains("only allowed for non-replicated requests")); + }); +} + +fn test_only_headers_with_custom_max_response_bytes(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let n = 0; + let url = format!("https://[{}]/{}/{}", webserver_ipv6, "equal_bytes", n); + + // { Response headers + // date: Jan 1 1970 00:00:00 GMT + // content-type: application/octet-stream + // content-length: 11 + // access-control-allow-origin: * + // access-control-allow-credentials: true + // } + + let header_size = 142; + let max_response_bytes = Some(header_size + n); + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url, + headers: vec![], + method: HttpMethod::GET, + body: None, + transform: None, + max_response_bytes, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + let response = response.expect("Request is successful."); + + assert_matches!(&response, RemoteHttpResponse { status: 200, .. }); + assert_http_response(&response); +} + +fn test_only_headers_with_custom_max_response_bytes_exceeded(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let n = 0; + let url = format!("https://[{}]/{}/{}", webserver_ipv6, "equal_bytes", n); + + // { Response headers + // date: Jan 1 1970 00:00:00 GMT + // content-type: application/octet-stream + // content-length: 0 + // access-control-allow-origin: * + // access-control-allow-credentials: true + // } + + let header_size = 142; + let max_response_bytes = Some(header_size + n - 1); + + let (response, refunded_cycles) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url, + headers: vec![], + method: HttpMethod::GET, + body: None, + transform: None, + max_response_bytes, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::SysFatal, + .. + }) + ); + assert_ne!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); +} + +fn test_non_ascii_url_is_accepted(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + let expected_body = "안녕하세요"; + + let url = format!("https://[{}]/{}/{}", webserver_ipv6, "ascii", expected_body); + + let max_response_bytes = 666; + + let request = UnvalidatedCanisterHttpRequestArgs { + url, + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: None, + max_response_bytes: Some(max_response_bytes), + is_replicated: None, + pricing_version: None, + }; + + let (response, refunded_cycles) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + let response = response.expect("Request is successful"); + + assert_matches!(&response, RemoteHttpResponse {body, status: 200, ..} if *body == expected_body); + assert_ne!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); +} + +fn test_max_url_length(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let base_url = format!("https://[{}]/{}/", webserver_ipv6, "ascii"); + let remaining_space = MAX_CANISTER_HTTP_URL_SIZE - base_url.len(); + let expected_body = "x".repeat(remaining_space); + + let url = format!("{base_url}{expected_body}"); + assert_eq!(url.len(), MAX_CANISTER_HTTP_URL_SIZE); + + let request = UnvalidatedCanisterHttpRequestArgs { + url, + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: None, + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }; + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + let response = response.expect("Request is successful."); + + assert_matches!(&response, RemoteHttpResponse {body, status: 200, ..} if *body == expected_body); + assert_http_response(&response); +} + +fn test_max_url_length_exceeded(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let base_url = format!("https://[{}]/{}/", webserver_ipv6, "ascii"); + let remaining_space = MAX_CANISTER_HTTP_URL_SIZE - base_url.len(); + // Add one more character to exceed the limit. + let expected_body = "x".repeat(remaining_space + 1); + + let url = format!("{base_url}{expected_body}"); + + let request = UnvalidatedCanisterHttpRequestArgs { + url, + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: None, + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }; + + let (response, refunded_cycles) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::CanisterReject, + .. + }) + ); + assert_eq!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); +} + +fn reference_transform_function_exposed_by_different_canister(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + let url = format!("https://[{}]/{}/{}", webserver_ipv6, "ascii", "hello_world"); + + let proxy_canister_id_1 = get_proxy_canister_id(&env); + // Create another proxy canister; + // Get application subnet node to deploy canister to. + let mut nodes = get_node_snapshots(&env); + let node = nodes.next().expect("there is no application node"); + let runtime = get_runtime_from_node(&node); + let _ = create_proxy_canister_with_name(&env, &runtime, &node, "proxy_canister_2"); + let proxy_canister_id_2 = get_proxy_canister_id_with_name(&env, "proxy_canister_2"); + + assert_ne!( + proxy_canister_id_1, proxy_canister_id_2, + "create_proxy_canister() should create a new proxy canister with a new canister id." + ); + + let request = UnvalidatedCanisterHttpRequestArgs { + url, + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: proxy_canister_id_2.into(), + method: "test_transform".to_string(), + }), + context: vec![], + }), + }; + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::CanisterReject, + .. + }) + ); +} + +fn test_max_number_of_response_headers(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let response_headers = HTTP_HEADERS_MAX_NUMBER - HTTPBIN_OVERHEAD_RESPONSE_HEADERS; + let url = format!( + "https://[{}]/{}/{}", + webserver_ipv6, "many_response_headers", response_headers + ); + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url, + headers: vec![], + method: HttpMethod::GET, + body: None, + transform: None, + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + let response = response.expect("Request is successful."); + + assert_matches!(&response, RemoteHttpResponse { status: 200, .. }); + assert_http_response(&response); + assert_eq!( + response.headers.len(), + HTTP_HEADERS_MAX_NUMBER, + "Expected {} headers, got {}", + response_headers, + response.headers.len() + ); +} + +fn test_max_number_of_response_headers_exceeded(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let response_headers = HTTP_HEADERS_MAX_NUMBER - HTTPBIN_OVERHEAD_RESPONSE_HEADERS + 1; + let url = format!( + "https://[{}]/{}/{}", + webserver_ipv6, "many_response_headers", response_headers + ); + + let (response, refunded_cycles) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url, + headers: vec![], + method: HttpMethod::GET, + body: None, + transform: None, + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::SysFatal, + .. + }) + ); + assert_ne!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); +} + +fn test_max_number_of_request_headers(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let headers = (0..HTTP_HEADERS_MAX_NUMBER) + .map(|i| HttpHeader { + name: format!("name{i}"), + value: format!("value{i}"), + }) + .collect(); + + let request = RemoteHttpRequest { + request: UnvalidatedCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]/anything"), + headers, + method: HttpMethod::POST, + body: None, + transform: None, + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }; + let (response, _) = block_on(submit_outcall(&handlers, request.clone())); + let response = response.expect("Request is successful."); + + assert_matches!(&response, RemoteHttpResponse { status: 200, .. }); + assert_http_response(&response); + assert_http_json_response(&request.request, &response); +} + +fn test_max_number_of_request_headers_exceeded(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + let url = format!("https://[{webserver_ipv6}]/anything"); + + let headers = (0..HTTP_HEADERS_MAX_NUMBER + 1) + .map(|i| HttpHeader { + name: format!("name{i}"), + value: format!("value{i}"), + }) + .collect(); + + #[derive(Clone, Debug, CandidType, Deserialize)] + struct TestRequest { + url: String, + headers: Vec, + method: HttpMethod, + } + + #[derive(Clone, Debug, CandidType, Deserialize)] + struct TestRemoteHttpRequest { + pub request: TestRequest, + pub cycles: u64, + } + + let (response, refunded_cycles) = block_on(submit_encoded_outcall( + &handlers, + TestRemoteHttpRequest { + request: TestRequest { + url, + headers, + method: HttpMethod::POST, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + + assert_matches!( + response, + Err(RejectResponse { + reject_code: RejectCode::CanisterReject, + .. + }) + ); + assert_eq!( + refunded_cycles, + RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) + ); +} + +fn check_caller_id_on_transform_function(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + let url = format!("https://[{}]/{}/{}", webserver_ipv6, "ascii", "hello_world"); + + let request = UnvalidatedCanisterHttpRequestArgs { + url, + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: get_proxy_canister_id(&env).into(), + method: "test_transform".to_string(), + }), + context: vec![], + }), + }; + + let (response, _) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: request.clone(), + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + )); + let response = response.expect("Request is successful."); + + // Check caller id injected into header. + let caller_id = &response + .headers + .iter() + .find(|(name, _)| name.to_lowercase() == "caller") + .expect("caller header is present after transformation.") + .1; + + assert_eq!(caller_id, "aaaaa-aa"); +} + +// ---- HELPER FUNCTIONS ------- + +/// Case insensitive header names are distinct. +fn assert_distinct_headers(http_response: &RemoteHttpResponse) { + let response_header_set: HashSet = http_response + .headers + .clone() + .iter() + .map(|(name, _)| name.to_lowercase()) + .collect(); + + assert_eq!( + response_header_set.len(), + http_response.headers.len(), + "Found duplicate headers: {:?}", + http_response.headers + ); +} + +/// Assert that content-length header matches the body length, and that the headers are distinct. +fn assert_http_response( + // http_request: &CanisterHttpRequestArgs, + http_response: &RemoteHttpResponse, +) { + assert_distinct_headers(http_response); + + let content_length_header = http_response + .headers + .iter() + .find(|(name, _)| name.to_lowercase() == "content-length") + .map(|(_, value)| value.parse::()) + .unwrap_or_else(|| { + panic!( + "HTTP response contains `content-length` header. Headers: {:?}", + http_response.headers + ) + }) + .expect("content-length is a number"); + + assert_eq!( + content_length_header, + http_response.body.len(), + "Content length header does not match the body length." + ); +} + +/// Checks if two sets of headers match according to specific rules: +/// 1. All headers in `outcall_headers` must exist in `http_bin_server_received_headers` +/// 2. All headers in `http_bin_server_received_headers` must exist in `outcall_headers`, unless they are special cases: +/// - "host" +/// - "content-length" +/// - "accept-encoding" +/// - "user-agent" with value "ic/1.0" +/// 3. Request method must match the method in the response. +/// 4. Request body must match the body in the response. +fn assert_http_json_response( + request: &UnvalidatedCanisterHttpRequestArgs, + http_response: &RemoteHttpResponse, +) { + let request_headers = request + .headers + .iter() + .map(|HttpHeader { name, value }| (name.clone(), value.clone())) + .collect::>(); + + let response_body: Value = + serde_json::from_str(&http_response.body).expect("Response body is JSON formatted."); + + let http_bin_server_received_headers: Vec<_> = response_body["headers"] + .as_array() + .expect("Headers is an array") + .iter() + .map(|name_value| { + let name_value_tuple = name_value + .as_array() + .expect("Headers is tuple of name and value."); + let name = name_value_tuple[0].as_str().unwrap().to_string(); + let value = name_value_tuple[1].as_str().unwrap().to_string(); + (name, value) + }) + .collect(); + + // Rule 1: Check that all left headers exist in right + let http_bin_server_received_all_outcall_headers = request_headers + .iter() + .all(|x| http_bin_server_received_headers.contains(x)); + + assert!( + http_bin_server_received_all_outcall_headers, + "1. HTTP bin server did not receive all headers specified in the outcall. Specified headers: {request_headers:?}, received headers: {http_bin_server_received_headers:?}" + ); + + // Rule 2: Check that all headers received by the server was specified in outcall. + let http_bin_server_only_received_headers_specified_by_outcall = + http_bin_server_received_headers + .iter() + .filter(|(name, value)| { + !matches!( + (name.as_str(), value.as_str()), + ("host", _) + | ("content-length", _) + | ("accept-encoding", _) + | ("user-agent", "ic/1.0") + ) + }) + .all(|(name, value)| request_headers.contains(&(name.clone(), value.clone()))); + + assert!( + http_bin_server_only_received_headers_specified_by_outcall, + "2. Http bin server received headers that were not specified in the outcall. Specified headers: {request_headers:?}, received headers: {http_bin_server_received_headers:?}" + ); + + // Rule 3: Request method must match the method in the response. + let request_method = match request.method { + HttpMethod::GET => "GET", + HttpMethod::POST => "POST", + HttpMethod::HEAD => "HEAD", + HttpMethod::PUT => "PUT", + HttpMethod::DELETE => "DELETE", + HttpMethod::PATCH => "PATCH", + }; + + assert_eq!( + request_method, + response_body["method"].as_str().unwrap(), + "3. Mismatch in HTTP method." + ); + + // Rule 4: Request body must match the body in the response. + let server_received_body = response_body["data"].as_str().unwrap(); + let outcall_sent_body = String::from_utf8(request.body.clone().unwrap_or_default()).unwrap(); + + assert_eq!( + server_received_body, &outcall_sent_body, + "4. HTTP bin server received body does not match the outcall sent body." + ); +} + +#[derive(Debug, Eq, PartialEq)] +enum RefundedCycles { + NotApplicable, + Cycles(u64), +} + +type ProxyCanisterResponseWithRefund = ResponseWithRefundedCycles; + +// This type represents the result of an IC http_request and the refunded cycles. +// The refund is returned regardless of whether the outcall succeeded (Ok) or failed (Err), +// allowing tests to verify proper cycle refund behavior in both success and error cases. +type OutcallsResponseWithRefund = (Result, RefundedCycles); + +/// Sends a non-flexible outcall through the proxy canister. +/// +/// Every well-formed request goes through here, so this is where the suite's +/// pricing model is applied — the scenarios themselves say nothing about it. +async fn submit_outcall( + handlers: &Handlers<'_>, + mut request: RemoteHttpRequest, +) -> OutcallsResponseWithRefund { + request.request.pricing_version = Some(pricing_version()); + submit_encoded_outcall(handlers, request).await +} + +/// Sends an arbitrarily shaped payload to the proxy canister, for the scenarios +/// that deliberately send something other than a well-formed request. +/// +/// Such a payload carries no pricing version of its own, so it decodes to the +/// default model rather than the suite's. That is immaterial to those scenarios, +/// which are rejected before pricing comes into play. +async fn submit_encoded_outcall( + handlers: &Handlers<'_>, + request: Request, +) -> OutcallsResponseWithRefund +where + Request: Clone + CandidType, +{ + let args = Encode!(&request).unwrap(); + let agent = handlers.agent().await; + + let principal_id: PrincipalId = handlers.proxy_canister().effective_canister_id(); + let principal: Principal = principal_id.into(); + + let log = handlers.env.logger(); + let canister_response = match retry_agent_on_transport_errors!( + "submit_outcall: call", + &log, + agent + .update(&principal, "send_request_with_refund_callback") + .with_arg(args.clone()) + .call() + ) + .await + .expect("submit_outcall retries exhausted") + { + Ok(CallResponse::Response(response)) => Ok(response), + Ok(CallResponse::Poll(request_id)) => retry_agent_on_transport_errors!( + "submit_outcall: wait", + &log, + agent.wait(&request_id, principal) + ) + .await + .expect("submit_outcall retries exhausted"), + Err(err) => Err(err), + }; + + match canister_response { + Err(agent_error) => { + let err_resp = match agent_error { + AgentError::CertifiedReject { + reject: response, .. + } + | AgentError::UncertifiedReject { + reject: response, .. + } => response, + _ => panic!("Unexpected error: {agent_error:?}"), + }; + // If an agent_error is returned then it means that the http_request failed before + // performing the outcall on the canister, therefore the refund is not applicable. + (Err(err_resp), RefundedCycles::NotApplicable) + } + Ok(serialized_bytes) => { + let response_with_refund = + decode_one::(&serialized_bytes.0) + .expect("Decoding the canister serialized response should succeed."); + + let refunded_cycles = response_with_refund.refunded_cycles; + let result = response_with_refund + .result + .map_err(|(reject_code, reject_message)| { + let reject_code = match reject_code { + RejectionCode::SysFatal => RejectCode::SysFatal, + RejectionCode::SysTransient => RejectCode::SysTransient, + RejectionCode::DestinationInvalid => RejectCode::DestinationInvalid, + RejectionCode::CanisterReject => RejectCode::CanisterReject, + RejectionCode::CanisterError => RejectCode::CanisterError, + RejectionCode::NoError | RejectionCode::Unknown => { + panic!("Invalid rejection code.") + } + }; + + RejectResponse { + reject_code, + reject_message, + error_code: None, + } + }); + (result, RefundedCycles::Cycles(refunded_cycles)) + } + } +} + +/// Submits a flexible HTTP outcall through the proxy canister and returns the +/// proxy's raw reply: `Ok(bytes)` (the Candid-encoded `FlexibleHttpRequestResult`) +/// on a handled outcall, or `Err((code, message))` when `flexible_http_request` +/// is rejected synchronously (e.g. because it is not enabled on this subnet). +async fn submit_flexible_outcall( + handlers: &Handlers<'_>, + request: FlexibleRemoteHttpRequest, +) -> Result, (RejectionCode, String)> { + let args = Encode!(&request).unwrap(); + let agent = handlers.agent().await; + + let principal_id: PrincipalId = handlers.proxy_canister().effective_canister_id(); + let principal: Principal = principal_id.into(); + + let log = handlers.env.logger(); + let canister_response = match retry_agent_on_transport_errors!( + "submit_flexible_outcall: call", + &log, + agent + .update(&principal, "send_flexible_request") + .with_arg(args.clone()) + .call() + ) + .await + .expect("submit_flexible_outcall retries exhausted") + { + Ok(CallResponse::Response(response)) => Ok(response), + Ok(CallResponse::Poll(request_id)) => retry_agent_on_transport_errors!( + "submit_flexible_outcall: wait", + &log, + agent.wait(&request_id, principal) + ) + .await + .expect("submit_flexible_outcall retries exhausted"), + Err(err) => Err(err), + }; + + let serialized_bytes = canister_response.expect("send_flexible_request should reply"); + decode_one::, (RejectionCode, String)>>(&serialized_bytes.0) + .expect("Decoding the send_flexible_request reply should succeed.") +} + +/// Flexible HTTP outcalls are priced with the pay-as-you-go pricing model, which +/// is enabled on every subnet, so they work on a normal (paying) subnet too. The +/// scenarios themselves are covered exhaustively by `canister_http_flexible_test` +/// and `canister_http_flexible_paying_test`; this just pins down that the +/// endpoint is reachable from the ordinary correctness setup. +fn test_flexible_http_request_enabled_on_normal_subnet(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let request = FlexibleRemoteHttpRequest { + request: FlexibleCanisterHttpRequestArgs { + url: format!("https://[{webserver_ipv6}]/ascii/hello"), + max_response_bytes: None, + headers: BoundedHttpHeaders::new(vec![]), + body: None, + method: HttpMethod::GET, + transform: None, + replication: None, + }, + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }; + + let result = block_on(submit_flexible_outcall(&handlers, request)); + + match result { + Ok(bytes) => { + let decoded = Decode!(&bytes, FlexibleHttpRequestResult) + .expect("failed to decode FlexibleHttpRequestResult"); + assert_matches!(decoded, FlexibleHttpRequestResult::Ok(_)); + } + Err((reject_code, message)) => panic!( + "expected the flexible outcall to succeed on a normal subnet, \ + got {reject_code:?}: '{message}'" + ), + } +} + +/// Queries the proxy canister's own cycle balance. +async fn proxy_cycle_balance(handlers: &Handlers<'_>) -> u128 { + let agent = handlers.agent().await; + let principal: Principal = handlers.proxy_canister().effective_canister_id().into(); + let log = handlers.env.logger(); + + let bytes = retry_agent_on_transport_errors!( + "proxy_cycle_balance: query", + &log, + agent + .query(&principal, "cycle_balance") + .with_arg(Encode!(&()).unwrap()) + .call() + ) + .await + .expect("proxy_cycle_balance retries exhausted") + .expect("querying the proxy canister's balance should succeed"); + + decode_one::(&bytes).expect("decoding the proxy canister's balance should succeed") +} + +/// Base arguments for a plain `GET` outcall. The pricing model is stamped on by +/// [`submit_outcall`], so it is deliberately left unset here. +fn plain_get_args(url: String) -> UnvalidatedCanisterHttpRequestArgs { + UnvalidatedCanisterHttpRequestArgs { + url, + headers: vec![], + method: HttpMethod::GET, + body: Some("".as_bytes().to_vec()), + transform: None, + max_response_bytes: None, + is_replicated: None, + pricing_version: None, + } +} + +/// A non-flexible outcall priced pay-as-you-go succeeds and is charged only what +/// it actually cost, far less than the payment it attached. +/// +/// Under pay-as-you-go the payment is taken up front and the unspent part of the +/// per-replica allowances is credited back to the caller's balance afterwards, +/// rather than returned as refunded cycles on the reply — so this watches the +/// balance, which is where the refund actually lands. +fn test_pay_as_you_go_charges_and_refunds(env: TestEnv) { + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + block_on(async { + let before = proxy_cycle_balance(&handlers).await; + + let (response, _refunded) = submit_outcall( + &handlers, + RemoteHttpRequest { + request: plain_get_args(format!("https://[{webserver_ipv6}]/ascii/priced")), + cycles: HTTP_REQUEST_CYCLE_PAYMENT, + }, + ) + .await; + assert_matches!(response, Ok(r) if r.status == 200); + + let after = proxy_cycle_balance(&handlers).await; + let charged = before + .checked_sub(after) + .unwrap_or_else(|| panic!("balance grew from {before} to {after} across an outcall")); + assert!( + charged > 0, + "a pay-as-you-go outcall on a normal cost schedule charged nothing" + ); + // A small outcall costs orders of magnitude less than the attached + // payment, so the refund has to have returned most of it. + assert!( + charged < u128::from(HTTP_REQUEST_CYCLE_PAYMENT) / 10, + "expected the refund to return most of the {HTTP_REQUEST_CYCLE_PAYMENT}-cycle \ + payment, but {charged} cycles were kept" + ); + }); +} + +/// A non-flexible outcall that can pay its replicas for fetching a response but +/// not for putting one into a block fails as out of cycles, rather than hanging +/// until it times out. +/// +/// Delivering a response costs roughly `N * (10N + 600)` cycles per byte against +/// ~50 cycles per byte to download it, so a payment sized to comfortably cover +/// the downloads still falls far short of the consensus cost for a large enough +/// response. +fn test_pay_as_you_go_out_of_cycles(env: TestEnv) { + const BODY_SIZE: usize = 500_000; + // The payment has to land in a window. Each replica spends ~50 cycles/byte to + // download (~25M here) and its receipt is discarded unless its allowance `A` + // covers that, so `A` must exceed ~25M. Delivering costs `N * (10N + 600)` + // cycles/byte — ~2_560 at `N = 4`, so ~1.28B here — and the outcall is only out + // of cycles if what is left of the collective allowance falls short of that: + // `4A - 4S < 2_560 * BODY_SIZE`. Writing `A = k * S` that is `k < ~13.8`, + // whatever the response size. `k = 4` sits in the middle of the window, giving + // ~4x margin on both sides. + // + // Note the direction: paying *more* buys a bigger allowance and so makes the + // outcall affordable, not less so. + const PAYMENT: u64 = 400_000_000; + + let handlers = Handlers::new(&env); + let webserver_ipv6 = get_universal_vm_address(&env); + + let (response, _refunded) = block_on(submit_outcall( + &handlers, + RemoteHttpRequest { + request: plain_get_args(format!("https://[{webserver_ipv6}]/bytes/{BODY_SIZE}")), + cycles: PAYMENT, + }, + )); + + match response { + Err(RejectResponse { + reject_code: RejectCode::SysTransient, + reject_message, + .. + }) => assert!( + reject_message.contains("Out of cycles"), + "unexpected rejection message: '{reject_message}'" + ), + other => panic!("expected an out-of-cycles rejection, got: {other:?}"), + } +} + +/// Pricing function of canister http requests. +fn expected_cycle_cost( + proxy_canister: CanisterId, + request: UnvalidatedCanisterHttpRequestArgs, + subnet_size: usize, +) -> u64 { + let cm = CyclesAccountManagerBuilder::new().build(); + let response_size = request + .max_response_bytes + .unwrap_or(MAX_CANISTER_HTTP_REQUEST_BYTES); + + let dummy_context = CanisterHttpRequestContext::generate_from_args( + UNIX_EPOCH, + &RequestBuilder::default() + .receiver(CanisterId::from(1)) + .sender(proxy_canister) + .build(), + request.into(), + &BTreeSet::from([PrincipalId::new_node_test_id(0).into()]), + RegistryVersion::from(1), + CanisterCyclesCostSchedule::Normal, + &mut rand::thread_rng(), + ) + .unwrap(); + let req_size = dummy_context.variable_parts_size(); + let cycle_fee = cm.http_request_fee( + req_size, + Some(NumBytes::from(response_size)), + CyclesAccountManagerSubnetConfig::new( + subnet_size, + CanisterCyclesCostSchedule::Normal, + DEFAULT_REFERENCE_SUBNET_SIZE, + ), + ); + cycle_fee.real().get().try_into().unwrap() +} diff --git a/rs/tests/networking/canister_http_correctness_pay_as_you_go_test.rs b/rs/tests/networking/canister_http_correctness_pay_as_you_go_test.rs new file mode 100644 index 000000000000..e99be2679f8b --- /dev/null +++ b/rs/tests/networking/canister_http_correctness_pay_as_you_go_test.rs @@ -0,0 +1,35 @@ +/* tag::catalog[] +Title:: Test correctness of feature according to spec, under pay-as-you-go pricing. + +Goal:: Ensure simple HTTP requests can be made from canisters, with outcalls +priced by the pay-as-you-go model: only a base fee is charged up front, the rest +of the payment becomes a per-replica allowance, and what goes unspent is credited +back to the caller. + +This runs the same scenarios as `canister_http_correctness_test` — what a caller +observes must not depend on how it was priced — plus the ones that only exist +under this model: the refund of the unspent allowance, and an outcall whose +payment cannot cover delivering a response. + +Runbook:: +0. Instantiate a universal VM with a webserver +1. Instantiate an IC with one application subnet with the HTTP feature enabled. +2. Install NNS canisters +3. Install the proxy canister +4. Make an update call to the proxy canister. + +Success:: +1. Received http response with status 200, and the caller's balance moves by the + expected amount. + +end::catalog[] */ + +use anyhow::Result; +use ic_management_canister_types_private::PRICING_VERSION_PAY_AS_YOU_GO; + +fn main() -> Result<()> { + let group = canister_http_correctness::shared_scenarios(PRICING_VERSION_PAY_AS_YOU_GO); + canister_http_correctness::add_pay_as_you_go_pricing_scenarios(group).execute_from_args()?; + + Ok(()) +} diff --git a/rs/tests/networking/canister_http_correctness_test.rs b/rs/tests/networking/canister_http_correctness_test.rs index a957e7ca3125..36595511d033 100644 --- a/rs/tests/networking/canister_http_correctness_test.rs +++ b/rs/tests/networking/canister_http_correctness_test.rs @@ -1,7 +1,13 @@ /* tag::catalog[] -Title:: Test correctness of feature according to spec. +Title:: Test correctness of feature according to spec, under legacy pricing. -Goal:: Ensure simple HTTP requests can be made from canisters. +Goal:: Ensure simple HTTP requests can be made from canisters, with outcalls +priced by the legacy model that charges for the whole of `max_response_bytes` +up front. + +The scenarios are shared with `canister_http_correctness_pay_as_you_go_test`, +which runs them under the pay-as-you-go model: what a caller observes must not +depend on how it was priced. Runbook:: 0. Instantiate a universal VM with a webserver @@ -14,2822 +20,13 @@ Success:: 1. Received http response with status 200. end::catalog[] */ -#![allow(deprecated)] use anyhow::Result; -use assert_matches::assert_matches; -use candid::{CandidType, Deserialize, Encode, Principal, decode_one}; -use canister_http::*; -use canister_test::{Canister, Runtime}; -use ic_agent::{ - Agent, AgentError, - agent::{CallResponse, RejectCode, RejectResponse}, -}; -use ic_base_types::{CanisterId, NumBytes, PrincipalId}; -use ic_config::subnet_config::DEFAULT_REFERENCE_SUBNET_SIZE; -use ic_cycles_account_manager::CyclesAccountManagerSubnetConfig; -use ic_management_canister_types_private::{ - BoundedHttpHeaders, FlexibleCanisterHttpRequestArgs, HttpHeader, HttpMethod, TransformContext, - TransformFunc, -}; -use ic_system_test_driver::{ - canister_agent::HasCanisterAgentCapability, - driver::{ - group::{SystemTestGroup, SystemTestSubGroup}, - test_env::TestEnv, - test_env_api::HasTopologySnapshot, - }, - retry_agent_on_transport_errors, systest, - util::{block_on, get_app_subnet_and_node}, -}; -use ic_test_utilities::cycles_account_manager::CyclesAccountManagerBuilder; -use ic_test_utilities_types::messages::RequestBuilder; -use ic_types::{ - RegistryVersion, - canister_http::{CanisterHttpRequestContext, MAX_CANISTER_HTTP_REQUEST_BYTES}, - time::UNIX_EPOCH, -}; -use ic_types_cycles::CanisterCyclesCostSchedule; -use proxy_canister::{ - FlexibleRemoteHttpRequest, RejectionCode, RemoteHttpRequest, RemoteHttpResponse, - ResponseWithRefundedCycles, UnvalidatedCanisterHttpRequestArgs, -}; -use serde_json::Value; -use std::collections::{BTreeSet, HashSet}; - -const MAX_REQUEST_BYTES_LIMIT: usize = 2_000_000; -const MAX_MAX_RESPONSE_BYTES: usize = 2_000_000; -const DEFAULT_MAX_RESPONSE_BYTES: u64 = 2_000_000; -const MAX_CANISTER_HTTP_URL_SIZE: usize = 8 * 1024; -const MAX_HEADER_NAME_LENGTH: usize = 8 * 1024; -const MAX_HEADER_VALUE_LENGTH: usize = 8 * 1024; -const TOTAL_HEADER_NAME_AND_VALUE_LENGTH: usize = 48 * 1024; -const HTTP_HEADERS_MAX_NUMBER: usize = 64; -const HTTP_REQUEST_CYCLE_PAYMENT: u64 = 500_000_000_000; - -// httpbin-rs returns 5 headers in addition to the requested headers: -// content-type, access-control-allow-origin, access-control-allow-credentials, date, content-length. -const HTTPBIN_OVERHEAD_RESPONSE_HEADERS: usize = 5; - -struct Handlers<'a> { - subnet_size: usize, - runtime: Runtime, - env: &'a TestEnv, -} - -impl<'a> Handlers<'a> { - fn new(env: &'a TestEnv) -> Handlers<'a> { - let subnet_size = get_node_snapshots(env).count(); - - let runtime = { - let mut nodes = get_node_snapshots(env); - let node = nodes.next().expect("there is no application node"); - get_runtime_from_node(&node) - }; - - Handlers { - runtime, - subnet_size, - env, - } - } - - fn proxy_canister(&self) -> Canister<'_> { - let principal_id = get_proxy_canister_id(self.env); - let canister_id = CanisterId::unchecked_from_principal(principal_id); - Canister::new(&self.runtime, canister_id) - } - - async fn agent(&self) -> Agent { - let topology_snapshot = self.env.topology_snapshot(); - let (_, app_node) = get_app_subnet_and_node(&topology_snapshot); - - app_node.build_canister_agent().await.agent - } -} +use ic_management_canister_types_private::PRICING_VERSION_LEGACY; fn main() -> Result<()> { - SystemTestGroup::new() - .with_setup(canister_http::setup) - .add_parallel( - SystemTestSubGroup::new() - .add_test(systest!(test_enforce_https)) - .add_test(systest!(test_no_cycles_attached)) - .add_test(systest!(test_2mb_response_cycle_for_rejection_path)) - .add_test(systest!(test_4096_max_response_cycle_case_1)) - .add_test(systest!(test_4096_max_response_cycle_case_2)) - .add_test(systest!(test_post_request)) - .add_test(systest!( - test_http_endpoint_with_delayed_response_is_rejected - )) - .add_test(systest!(test_that_redirects_are_not_followed)) - .add_test(systest!(test_http_calls_to_ic_fails)) - .add_test(systest!(test_get_hello_world_call)) - .add_test(systest!(test_post_call)) - .add_test(systest!(test_head_call)) - .add_test(systest!(test_put_call)) - .add_test(systest!(test_put_without_non_replicated_rejected)) - .add_test(systest!(test_delete_call)) - .add_test(systest!(test_delete_without_non_replicated_rejected)) - .add_test(systest!(test_patch_call)) - .add_test(systest!(test_patch_without_non_replicated_rejected)) - .add_test(systest!(test_max_possible_request_size)) - .add_test(systest!(test_max_possible_request_size_exceeded)) - // This section tests the request headers limits scenarios - .add_test(systest!(test_request_header_name_and_value_within_limits)) - .add_test(systest!(test_request_header_name_too_long)) - .add_test(systest!(test_request_header_value_too_long)) - .add_test(systest!( - test_request_header_total_size_within_the_48_kib_limit - )) - .add_test(systest!( - test_request_header_total_size_over_the_48_kib_limit - )) - // This section tests the response headers limits scenarios - .add_test(systest!(test_response_header_name_within_limit)) - .add_test(systest!(test_response_header_name_over_limit)) - .add_test(systest!(test_response_header_value_within_limit)) - .add_test(systest!(test_response_header_value_over_limit)) - .add_test(systest!( - test_response_header_total_size_within_the_48_kib_limit - )) - .add_test(systest!( - test_response_header_total_size_over_the_48_kib_limit - )) - // This section tests the url and ip scenarios - .add_test(systest!(test_non_ascii_url_is_accepted)) - .add_test(systest!(test_invalid_ip)) - .add_test(systest!(test_invalid_domain_name)) - .add_test(systest!(test_max_url_length)) - .add_test(systest!(test_max_url_length_exceeded)) - // This section tests the transform function scenarios - .add_test(systest!(test_transform_function_is_executed)) - .add_test(systest!(no_data_certificate_in_transform_function)) - .add_test(systest!(test_composite_transform_function_is_not_allowed)) - .add_test(systest!(check_caller_id_on_transform_function)) - .add_test(systest!( - test_transform_that_bloats_response_above_2mb_limit - )) - .add_test(systest!(test_transform_that_bloats_on_the_2mb_limit)) - .add_test(systest!( - test_transform_that_bloats_on_the_2mb_limit_with_custom_max_response_bytes - )) - .add_test(systest!( - reference_transform_function_exposed_by_different_canister - )) - .add_test(systest!(test_non_existent_transform_function)) - // This section tests the max number of request or response headers scenarios - .add_test(systest!(test_max_number_of_request_headers)) - .add_test(systest!(test_max_number_of_request_headers_exceeded)) - .add_test(systest!(test_max_number_of_response_headers)) - .add_test(systest!(test_max_number_of_response_headers_exceeded)) - // This section tests the max_response_bytes scenarios - .add_test(systest!( - test_http_endpoint_response_is_too_large_with_custom_max_response_bytes - )) - .add_test(systest!( - test_http_endpoint_response_is_within_limits_with_custom_max_response_bytes - )) - .add_test(systest!( - test_http_endpoint_response_is_too_large_with_default_max_response_bytes - )) - .add_test(systest!( - test_http_endpoint_response_is_within_limits_with_default_max_response_bytes - )) - .add_test(systest!(test_only_headers_with_custom_max_response_bytes)) - .add_test(systest!( - test_only_headers_with_custom_max_response_bytes_exceeded - )) - .add_test(systest!(test_max_response_bytes_too_large)) - .add_test(systest!(test_max_response_bytes_2_mb_returns_ok)) - // Flexible outcalls are not available on a normal (paying) subnet. - .add_test(systest!( - test_flexible_http_request_not_enabled_on_normal_subnet - )), - ) - .execute_from_args()?; + let group = canister_http_correctness::shared_scenarios(PRICING_VERSION_LEGACY); + canister_http_correctness::add_legacy_pricing_scenarios(group).execute_from_args()?; Ok(()) } - -fn test_enforce_https(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("http://[{webserver_ipv6}]"), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "transform".to_string(), - }), - context: vec![0, 1, 2], - }), - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::SysFatal, - .. - }) - ); -} - -fn test_transform_function_is_executed(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let transform_context = "transform_context".as_bytes().to_vec(); - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]"), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "test_transform".to_string(), - }), - context: transform_context.clone(), - }), - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - let response = response.expect("Http call should succeed"); - - assert_eq!(response.headers.len(), 2, "Headers: {:?}", response.headers); - assert_eq!(response.headers[0].0, "hello"); - assert_eq!(response.headers[0].1, "bonjour"); - assert_eq!(response.headers[1].0, "caller"); - assert_eq!(response.headers[1].1, "aaaaa-aa"); - assert_eq!( - response.body.as_str(), - "transform_context", - "Transform function did not set the body to the provided context." - ); - assert_eq!(response.status, 202); -} - -fn no_data_certificate_in_transform_function(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]"), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "data_certificate_in_transform".to_string(), - }), - context: vec![], - }), - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - let response = response.expect("Http call should succeed"); - - assert_eq!(response.headers.len(), 2, "Headers: {:?}", response.headers); - assert_eq!(response.headers[0].0, "data_certificate_present"); - assert_eq!(response.headers[0].1, "false"); - assert_eq!(response.headers[1].0, "in_replicated_execution"); - assert_eq!(response.headers[1].1, "false"); -} - -fn test_non_existent_transform_function(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let transform_context = "transform_context".as_bytes().to_vec(); - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]"), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "non_existent_transform_function".to_string(), - }), - context: transform_context.clone(), - }), - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::CanisterError, - .. - }) - ); - assert_ne!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); -} - -fn test_composite_transform_function_is_not_allowed(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]"), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "test_composite_transform".to_string(), - }), - context: vec![0, 1, 2], - }), - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - let err = response.unwrap_err(); - assert_eq!(err.reject_code, RejectCode::CanisterError); - assert!( - err.reject_message - .contains("Composite query cannot be used as transform in canister http outcalls.") - ); -} - -fn test_no_cycles_attached(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("http://[{webserver_ipv6}]"), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "transform".to_string(), - }), - context: vec![0, 1, 2], - }), - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: 0, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::CanisterReject, - .. - }) - ); -} - -fn test_max_possible_request_size(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - let headers_list = vec![ - ("name1".to_string(), "value1".to_string()), - ("name2".to_string(), "value2".to_string()), - ]; - - let header_list_size = headers_list - .iter() - .map(|(name, value)| name.len() + value.len()) - .sum::(); - - let headers = headers_list - .into_iter() - .map(|(name, value)| HttpHeader { name, value }) - .collect(); - - let body = vec![0; MAX_REQUEST_BYTES_LIMIT - header_list_size]; - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]/request_size"), - headers, - method: HttpMethod::POST, - body: Some(body), - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "transform".to_string(), - }), - context: vec![0, 1, 2], - }), - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!(response, Ok(r) if r.status==200); -} - -fn test_max_possible_request_size_exceeded(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - let headers_list = vec![ - ("name1".to_string(), "value1".to_string()), - ("name2".to_string(), "value2".to_string()), - ]; - - let header_list_size = headers_list - .iter() - .map(|(name, value)| name.len() + value.len()) - .sum::(); - - let headers = headers_list - .into_iter() - .map(|(name, value)| HttpHeader { name, value }) - .collect(); - - let body = vec![0; MAX_REQUEST_BYTES_LIMIT - header_list_size + 1]; - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]/request_size"), - headers, - method: HttpMethod::POST, - body: Some(body), - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "transform".to_string(), - }), - context: vec![0, 1, 2], - }), - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::CanisterReject, - .. - }) - ); - assert_eq!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); -} - -fn test_2mb_response_cycle_for_rejection_path(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let request = UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]"), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "transform".to_string(), - }), - context: vec![0, 1, 2], - }), - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }; - - let (response, _) = block_on(async move { - submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: expected_cycle_cost( - handlers.proxy_canister().canister_id(), - request, - handlers.subnet_size, - ) - 1, - }, - ) - .await - }); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::CanisterReject, - .. - }) - ); -} - -fn test_4096_max_response_cycle_case_1(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let request = UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]"), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "transform".to_string(), - }), - context: vec![0, 1, 2], - }), - max_response_bytes: Some(16384), - is_replicated: None, - pricing_version: None, - }; - - let (response, _) = block_on(async move { - submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: expected_cycle_cost( - handlers.proxy_canister().canister_id(), - request.clone(), - handlers.subnet_size, - ), - }, - ) - .await - }); - - assert_matches!(response, Ok(r) if r.status==200); -} - -fn test_4096_max_response_cycle_case_2(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let request = UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]"), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "transform".to_string(), - }), - context: vec![0, 1, 2], - }), - max_response_bytes: Some(16384), - is_replicated: None, - pricing_version: None, - }; - - let (response, _) = block_on(async move { - submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: expected_cycle_cost( - handlers.proxy_canister().canister_id(), - request.clone(), - handlers.subnet_size, - ) - 1, - }, - ) - .await - }); - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::CanisterReject, - .. - }) - ); -} - -fn test_max_response_bytes_2_mb_returns_ok(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]"), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: None, - max_response_bytes: Some((MAX_MAX_RESPONSE_BYTES) as u64), - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!(response, Ok(r) if r.status==200); -} - -fn test_max_response_bytes_too_large(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]"), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: None, - max_response_bytes: Some((MAX_MAX_RESPONSE_BYTES + 1) as u64), - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::CanisterReject, - .. - }) - ); - assert_eq!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); -} - -fn test_transform_that_bloats_on_the_2mb_limit(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]"), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "very_large_but_allowed_transform".to_string(), - }), - context: vec![0, 1, 2], - }), - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!(response, Ok(r) if r.status==200); -} - -fn test_transform_that_bloats_on_the_2mb_limit_with_custom_max_response_bytes(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let max_response_bytes = 1_000_000; - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]"), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "very_large_but_allowed_transform".to_string(), - }), - context: vec![0, 1, 2], - }), - max_response_bytes: Some(max_response_bytes), - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::SysFatal, - .. - }) - ); - assert_ne!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); -} - -fn test_transform_that_bloats_response_above_2mb_limit(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]"), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "bloat_transform".to_string(), - }), - context: vec![0, 1, 2], - }), - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::SysFatal, - .. - }) - ); - assert_ne!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); -} - -fn test_post_request(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]/post"), - headers: vec![HttpHeader { - name: "content-type".to_string(), - value: "application/x-www-form-urlencoded".to_string(), - }], - method: HttpMethod::POST, - body: Some("satoshi".as_bytes().to_vec()), - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "transform".to_string(), - }), - context: vec![0, 1, 2], - }), - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!(response, Ok(r) if r.body.contains("satoshi")); -} - -fn test_http_endpoint_response_is_within_limits_with_custom_max_response_bytes(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let n = 1_000_000; - - // { Response headers - // date: Jan 1 1970 00:00:00 GMT - // content-type: application/octet-stream - // content-length: 1xxxxxx - // access-control-allow-origin: * - // access-control-allow-credentials: true - // } - let header_size = 148; - let max_response_bytes: u64 = n + header_size; - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]/bytes/{n}"), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: None, - max_response_bytes: Some(max_response_bytes), - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - let response = response.expect("Request is successful."); - - assert_matches!(&response, RemoteHttpResponse { status: 200, .. }); -} - -fn test_http_endpoint_response_is_too_large_with_custom_max_response_bytes(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let n = 1_000_000; - - // { Response headers - // date: Jan 1 1970 00:00:00 GMT - // content-type: application/octet-stream - // content-length: 1xxxxxx - // access-control-allow-origin: * - // access-control-allow-credentials: true - // } - let header_size = 148; - let max_response_bytes = n + header_size; - - let const_transform = TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "transform".to_string(), - }), - context: vec![0, 1, 2], - }; - - for transform in [None, Some(const_transform)] { - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]/bytes/{}", n + 1), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform, - max_response_bytes: Some(max_response_bytes), - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::SysFatal, - .. - }) - ); - } -} - -fn test_http_endpoint_response_is_within_limits_with_default_max_response_bytes(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - // { Response headers - // date: Jan 1 1970 00:00:00 GMT - // content-type: application/octet-stream - // content-length: 1xxxxxx - // access-control-allow-origin: * - // access-control-allow-credentials: true - // } - let header_size = 148; - let n = DEFAULT_MAX_RESPONSE_BYTES - header_size; - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]/bytes/{n}"), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: None, - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - let response = response.expect("Request is successful."); - - assert_matches!(&response, RemoteHttpResponse { status: 200, .. }); -} - -fn test_http_endpoint_response_is_too_large_with_default_max_response_bytes(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - // { Response headers - // date: Jan 1 1970 00:00:00 GMT - // content-type: application/octet-stream - // content-length: 1xxxxxx - // access-control-allow-origin: * - // access-control-allow-credentials: true - // } - let header_size = 148; - let n = DEFAULT_MAX_RESPONSE_BYTES - header_size; - - let const_transform = TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "transform".to_string(), - }), - context: vec![0, 1, 2], - }; - - for transform in [None, Some(const_transform)] { - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]/bytes/{}", n + 1), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform, - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::SysFatal, - .. - }) - ); - } -} - -fn test_http_endpoint_with_delayed_response_is_rejected(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]/delay/40"), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "transform".to_string(), - }), - context: vec![0, 1, 2], - }), - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::SysFatal, - .. - }) - ); -} - -/// The adapter should not follow HTTP redirects. -fn test_that_redirects_are_not_followed(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]/redirect/10"), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "transform".to_string(), - }), - context: vec![0, 1, 2], - }), - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!(response, Ok(r) if r.status == 303); -} - -/// The adapter should reject HTTP calls that are made to other IC replicas' HTTPS endpoints. -fn test_http_calls_to_ic_fails(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]:9090"), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "transform".to_string(), - }), - context: vec![0, 1, 2], - }), - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - // Newer `hyper_util` versions embed the target socket address in the - // `ConnectError`, so we only check the stable prefix and suffix. - let expected_error_message_prefix = "Error(Connect, ConnectError(\"tcp connect error\", "; - let expected_error_message_suffix = - "Os { code: 111, kind: ConnectionRefused, message: \"Connection refused\" }))"; - let err_response = response.clone().unwrap_err(); - - assert_matches!(err_response.reject_code, RejectCode::SysTransient); - - assert!( - err_response - .reject_message - .contains(expected_error_message_prefix) - && err_response - .reject_message - .contains(expected_error_message_suffix), - "Expected error message to contain {} and {}, got: {}", - expected_error_message_prefix, - expected_error_message_suffix, - err_response.reject_message - ); -} - -fn test_invalid_domain_name(env: TestEnv) { - let handlers = Handlers::new(&env); - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: "https://xwWPqqbNqxxHmLXdguF4DN9xGq22nczV.com".to_string(), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "transform".to_string(), - }), - context: vec![0, 1, 2], - }), - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::SysTransient, - .. - }) - ); - assert_ne!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); -} - -fn test_invalid_ip(env: TestEnv) { - let handlers = Handlers::new(&env); - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - // `2001:db8::1` is a reserved ipv6 address used in documentation and example source code. - // See https://www.rfc-editor.org/rfc/rfc3849 - url: "https://[2001:db8::1]".to_string(), - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "transform".to_string(), - }), - context: vec![0, 1, 2], - }), - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::SysTransient, - .. - }) - ); - assert_ne!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); -} - -/// Test that the response body returned is the same as the requested path. -fn test_get_hello_world_call(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - let expected_body = "hello_world"; - - let url = format!("https://[{}]/{}/{}", webserver_ipv6, "ascii", expected_body); - - let max_response_bytes = 666; - - let request = UnvalidatedCanisterHttpRequestArgs { - url, - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: None, - max_response_bytes: Some(max_response_bytes), - is_replicated: None, - pricing_version: None, - }; - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - let response = response.expect("Request is successful."); - - assert_matches!(&response, RemoteHttpResponse {body, status: 200, ..} if body == expected_body); - assert_ne!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); - assert_http_response(&response); -} - -fn test_request_header_total_size_within_the_48_kib_limit(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - // Header count is 3, as our current total limit is 48KiB and the tuple of header name and value is 16KiB. - let header_count = - TOTAL_HEADER_NAME_AND_VALUE_LENGTH / (MAX_HEADER_NAME_LENGTH + MAX_HEADER_VALUE_LENGTH); - let mut headers = vec![]; - - for i in 0..header_count { - headers.push(HttpHeader { - name: format!("{i}").repeat(MAX_HEADER_NAME_LENGTH), - value: "y".repeat(MAX_HEADER_VALUE_LENGTH), - }); - } - - let request = UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]"), - headers, - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: None, - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }; - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - let response = response.expect("Request succeeds."); - - assert_matches!(&response, RemoteHttpResponse { status: 200, .. }); - assert_ne!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); -} - -fn test_request_header_total_size_over_the_48_kib_limit(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - // Header count is 3, as our current total limit is 48KiB and the tuple of header name and value is 16KiB. - let header_count = - TOTAL_HEADER_NAME_AND_VALUE_LENGTH / (MAX_HEADER_NAME_LENGTH + MAX_HEADER_VALUE_LENGTH); - let mut headers = vec![]; - - for i in 0..header_count { - headers.push(HttpHeader { - name: format!("{i}").repeat(MAX_HEADER_NAME_LENGTH), - value: "y".repeat(MAX_HEADER_VALUE_LENGTH), - }); - } - // The last header will push the total size over the limit. - headers.push(HttpHeader { - name: "x".to_string(), - value: "y".to_string(), - }); - - let request = UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]"), - headers, - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: None, - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }; - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::CanisterReject, - .. - }) - ); - assert_eq!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); -} - -fn test_response_header_total_size_within_the_48_kib_limit(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - // We use the /large_response_headers_size endpoint which should return headers - // with the specified value length, after accounting also for the - // overhead headers (e.g. content-length, date, etc.) - let url = format!( - "https://[{webserver_ipv6}]/large_response_total_header_size/{MAX_HEADER_NAME_LENGTH}/{TOTAL_HEADER_NAME_AND_VALUE_LENGTH}", - ); - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url, - headers: vec![], - method: HttpMethod::GET, - body: None, - transform: None, - max_response_bytes: Some(DEFAULT_MAX_RESPONSE_BYTES), - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!(&response, Ok(RemoteHttpResponse { status: 200, .. })); - assert_ne!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); - - // Compute exactly the size of the response headers to account also for overhead. - let total_header_size: usize = response - .unwrap() - .headers - .iter() - .map(|(name, value)| name.len() + value.len()) - .sum(); - - // Ensure that the successful response contains the expected response headers. - assert!( - total_header_size <= 48 * 1024, - "Total header size ({total_header_size} bytes) exceeds 48KiB limit" - ); -} - -fn test_response_header_total_size_over_the_48_kib_limit(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - // We use the /large_response_total_header_size endpoint which should return headers - // with the specified value length, after accounting also for the - // overhead headers (e.g. content-length, date, etc.) - let url = format!( - "https://[{}]/large_response_total_header_size/{}/{}", - webserver_ipv6, - MAX_HEADER_NAME_LENGTH, - TOTAL_HEADER_NAME_AND_VALUE_LENGTH + 1, - ); - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url, - headers: vec![], - method: HttpMethod::GET, - body: None, - transform: None, - max_response_bytes: Some(DEFAULT_MAX_RESPONSE_BYTES), - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - &response, - Err(RejectResponse { - reject_code: RejectCode::SysFatal, - .. - }) - ); - assert_ne!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); -} - -fn test_request_header_name_and_value_within_limits(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let headers = vec![HttpHeader { - name: "x".repeat(MAX_HEADER_NAME_LENGTH), - value: "y".repeat(MAX_HEADER_VALUE_LENGTH), - }]; - - let request = UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]"), - headers, - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: None, - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }; - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - let response = response.expect("Request succeeds."); - - assert_matches!(&response, RemoteHttpResponse { status: 200, .. }); -} - -fn test_request_header_name_too_long(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let headers = vec![HttpHeader { - name: "x".repeat(MAX_HEADER_NAME_LENGTH + 1), - value: "value".to_string(), - }]; - - let request = UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]"), - headers, - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: None, - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }; - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::CanisterReject, - .. - }) - ); - assert_eq!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); -} - -fn test_request_header_value_too_long(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let headers = vec![HttpHeader { - name: "name".to_string(), - value: "y".repeat(MAX_HEADER_VALUE_LENGTH + 1), - }]; - - let request = UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]"), - headers, - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: None, - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }; - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::CanisterReject, - .. - }) - ); - assert_eq!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); -} - -fn test_response_header_name_within_limit(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let url = - format!("https://[{webserver_ipv6}]/long_response_header_name/{MAX_HEADER_NAME_LENGTH}",); - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url, - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: None, - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!(&response, Ok(RemoteHttpResponse { status: 200, .. })); -} - -fn test_response_header_name_over_limit(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let url = format!( - "https://[{}]/long_response_header_name/{}", - webserver_ipv6, - MAX_HEADER_NAME_LENGTH + 1, - ); - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url, - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: None, - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::SysFatal, - .. - }) - ); - - assert_ne!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); -} - -fn test_response_header_value_within_limit(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let url = - format!("https://[{webserver_ipv6}]/long_response_header_value/{MAX_HEADER_VALUE_LENGTH}",); - - let request = UnvalidatedCanisterHttpRequestArgs { - url, - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: None, - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }; - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!(&response, Ok(RemoteHttpResponse { status: 200, .. })); -} - -fn test_response_header_value_over_limit(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let url = format!( - "https://[{}]/long_response_header_value/{}", - webserver_ipv6, - MAX_HEADER_VALUE_LENGTH + 1, - ); - - let request = UnvalidatedCanisterHttpRequestArgs { - url, - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: None, - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }; - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::SysFatal, - .. - }) - ); - assert_ne!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); -} - -fn test_post_call(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - let expected_body = "POST"; - - let url = format!("https://[{}]/{}", webserver_ipv6, "anything"); - let body = Some("hello_world".as_bytes().to_vec()); - let headers = vec![ - HttpHeader { - name: "name1".to_string(), - value: "value1".to_string(), - }, - HttpHeader { - name: "name2".to_string(), - value: "value2".to_string(), - }, - ]; - let max_response_bytes = Some(666); - - let request = UnvalidatedCanisterHttpRequestArgs { - url, - headers, - method: HttpMethod::POST, - body, - transform: None, - max_response_bytes, - is_replicated: None, - pricing_version: None, - }; - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - let response = response.expect("Request succeeds."); - - assert_matches!(&response, RemoteHttpResponse {body, status: 200, ..} if body.contains(expected_body)); - assert_distinct_headers(&response); - assert_http_json_response(&request, &response); -} - -/// Send 6666 repeating `x` to /anything endpoint. -/// Use HEAD http method. It only asks for the head, not the body. -/// Set max response size to 666 (order of magnitude smaller) -fn test_head_call(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let long_x_string = "x".repeat(6666); - let url = format!( - "https://[{}]/{}/{}", - webserver_ipv6, "anything", long_x_string - ); - let body = Some("hello_world".as_bytes().to_vec()); - let headers = vec![ - HttpHeader { - name: "name1".to_string(), - value: "value1".to_string(), - }, - HttpHeader { - name: "name2".to_string(), - value: "value2".to_string(), - }, - ]; - let max_response_bytes = Some(666); - - let request = UnvalidatedCanisterHttpRequestArgs { - url, - headers, - method: HttpMethod::HEAD, - body, - transform: None, - max_response_bytes, - is_replicated: None, - pricing_version: None, - }; - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - let response = response.expect("Request succeeds."); - - assert_matches!(&response, RemoteHttpResponse { status: 200, .. }); - assert_distinct_headers(&response); - let header_size = response - .headers - .iter() - .map(|(header, value)| header.len() + value.len()) - .sum::(); - assert!(header_size <= 666); - assert!( - response.body.is_empty(), - "Head request does not return a body." - ); -} - -fn test_put_call(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let url = format!("https://[{webserver_ipv6}]/anything"); - let body = Some("put_request_body".as_bytes().to_vec()); - let headers = vec![HttpHeader { - name: "name1".to_string(), - value: "value1".to_string(), - }]; - - let request = UnvalidatedCanisterHttpRequestArgs { - url, - headers, - method: HttpMethod::PUT, - body, - transform: None, - max_response_bytes: None, - is_replicated: Some(false), - pricing_version: None, - }; - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!(response, Ok(response) => { - assert_matches!(response, RemoteHttpResponse { status: 200, .. }); - assert_distinct_headers(&response); - assert_http_json_response(&request, &response); - }); -} - -fn test_put_without_non_replicated_rejected(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let url = format!("https://[{webserver_ipv6}]/anything"); - let request = UnvalidatedCanisterHttpRequestArgs { - url, - headers: vec![], - method: HttpMethod::PUT, - body: Some(vec![]), - transform: None, - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }; - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - assert_matches!(response, Err(RejectResponse { reject_message, .. }) => { - assert!(reject_message.contains("only allowed for non-replicated requests")); - }); -} - -fn test_delete_call(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let url = format!("https://[{webserver_ipv6}]/anything"); - let headers = vec![HttpHeader { - name: "name1".to_string(), - value: "value1".to_string(), - }]; - - let request = UnvalidatedCanisterHttpRequestArgs { - url, - headers, - method: HttpMethod::DELETE, - body: None, - transform: None, - max_response_bytes: None, - is_replicated: Some(false), - pricing_version: None, - }; - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!(response, Ok(response) => { - assert_matches!(response, RemoteHttpResponse { status: 200, .. }); - assert_distinct_headers(&response); - assert_http_json_response(&request, &response); - }); -} - -fn test_delete_without_non_replicated_rejected(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let url = format!("https://[{}]/{}", webserver_ipv6, "anything"); - let request = UnvalidatedCanisterHttpRequestArgs { - url, - headers: vec![], - method: HttpMethod::DELETE, - body: None, - transform: None, - max_response_bytes: Some(1024), - is_replicated: None, - pricing_version: None, - }; - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!(response, Err(RejectResponse { reject_message, .. }) => { - assert!(reject_message.contains("only allowed for non-replicated requests")); - }); -} - -fn test_patch_call(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let url = format!("https://[{webserver_ipv6}]/anything"); - let body = Some("patch_request_body".as_bytes().to_vec()); - let headers = vec![HttpHeader { - name: "name1".to_string(), - value: "value1".to_string(), - }]; - - let request = UnvalidatedCanisterHttpRequestArgs { - url, - headers, - method: HttpMethod::PATCH, - body, - transform: None, - max_response_bytes: None, - is_replicated: Some(false), - pricing_version: None, - }; - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!(response, Ok(response) => { - assert_matches!(response, RemoteHttpResponse { status: 200, .. }); - assert_distinct_headers(&response); - assert_http_json_response(&request, &response); - }); -} - -fn test_patch_without_non_replicated_rejected(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let url = format!("https://[{}]/{}", webserver_ipv6, "anything"); - let request = UnvalidatedCanisterHttpRequestArgs { - url, - headers: vec![], - method: HttpMethod::PATCH, - body: Some(vec![]), - transform: None, - max_response_bytes: Some(1024), - is_replicated: None, - pricing_version: None, - }; - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!(response, Err(RejectResponse { reject_message, .. }) => { - assert!(reject_message.contains("only allowed for non-replicated requests")); - }); -} - -fn test_only_headers_with_custom_max_response_bytes(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let n = 0; - let url = format!("https://[{}]/{}/{}", webserver_ipv6, "equal_bytes", n); - - // { Response headers - // date: Jan 1 1970 00:00:00 GMT - // content-type: application/octet-stream - // content-length: 11 - // access-control-allow-origin: * - // access-control-allow-credentials: true - // } - - let header_size = 142; - let max_response_bytes = Some(header_size + n); - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url, - headers: vec![], - method: HttpMethod::GET, - body: None, - transform: None, - max_response_bytes, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - let response = response.expect("Request is successful."); - - assert_matches!(&response, RemoteHttpResponse { status: 200, .. }); - assert_http_response(&response); -} - -fn test_only_headers_with_custom_max_response_bytes_exceeded(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let n = 0; - let url = format!("https://[{}]/{}/{}", webserver_ipv6, "equal_bytes", n); - - // { Response headers - // date: Jan 1 1970 00:00:00 GMT - // content-type: application/octet-stream - // content-length: 0 - // access-control-allow-origin: * - // access-control-allow-credentials: true - // } - - let header_size = 142; - let max_response_bytes = Some(header_size + n - 1); - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url, - headers: vec![], - method: HttpMethod::GET, - body: None, - transform: None, - max_response_bytes, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::SysFatal, - .. - }) - ); - assert_ne!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); -} - -fn test_non_ascii_url_is_accepted(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - let expected_body = "안녕하세요"; - - let url = format!("https://[{}]/{}/{}", webserver_ipv6, "ascii", expected_body); - - let max_response_bytes = 666; - - let request = UnvalidatedCanisterHttpRequestArgs { - url, - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: None, - max_response_bytes: Some(max_response_bytes), - is_replicated: None, - pricing_version: None, - }; - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - let response = response.expect("Request is successful"); - - assert_matches!(&response, RemoteHttpResponse {body, status: 200, ..} if *body == expected_body); - assert_ne!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); -} - -fn test_max_url_length(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let base_url = format!("https://[{}]/{}/", webserver_ipv6, "ascii"); - let remaining_space = MAX_CANISTER_HTTP_URL_SIZE - base_url.len(); - let expected_body = "x".repeat(remaining_space); - - let url = format!("{base_url}{expected_body}"); - assert_eq!(url.len(), MAX_CANISTER_HTTP_URL_SIZE); - - let request = UnvalidatedCanisterHttpRequestArgs { - url, - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: None, - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }; - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - let response = response.expect("Request is successful."); - - assert_matches!(&response, RemoteHttpResponse {body, status: 200, ..} if *body == expected_body); - assert_http_response(&response); -} - -fn test_max_url_length_exceeded(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let base_url = format!("https://[{}]/{}/", webserver_ipv6, "ascii"); - let remaining_space = MAX_CANISTER_HTTP_URL_SIZE - base_url.len(); - // Add one more character to exceed the limit. - let expected_body = "x".repeat(remaining_space + 1); - - let url = format!("{base_url}{expected_body}"); - - let request = UnvalidatedCanisterHttpRequestArgs { - url, - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - transform: None, - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }; - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::CanisterReject, - .. - }) - ); - assert_eq!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); -} - -fn reference_transform_function_exposed_by_different_canister(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - let url = format!("https://[{}]/{}/{}", webserver_ipv6, "ascii", "hello_world"); - - let proxy_canister_id_1 = get_proxy_canister_id(&env); - // Create another proxy canister; - // Get application subnet node to deploy canister to. - let mut nodes = get_node_snapshots(&env); - let node = nodes.next().expect("there is no application node"); - let runtime = get_runtime_from_node(&node); - let _ = create_proxy_canister_with_name(&env, &runtime, &node, "proxy_canister_2"); - let proxy_canister_id_2 = get_proxy_canister_id_with_name(&env, "proxy_canister_2"); - - assert_ne!( - proxy_canister_id_1, proxy_canister_id_2, - "create_proxy_canister() should create a new proxy canister with a new canister id." - ); - - let request = UnvalidatedCanisterHttpRequestArgs { - url, - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: proxy_canister_id_2.into(), - method: "test_transform".to_string(), - }), - context: vec![], - }), - }; - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::CanisterReject, - .. - }) - ); -} - -fn test_max_number_of_response_headers(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let response_headers = HTTP_HEADERS_MAX_NUMBER - HTTPBIN_OVERHEAD_RESPONSE_HEADERS; - let url = format!( - "https://[{}]/{}/{}", - webserver_ipv6, "many_response_headers", response_headers - ); - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url, - headers: vec![], - method: HttpMethod::GET, - body: None, - transform: None, - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - let response = response.expect("Request is successful."); - - assert_matches!(&response, RemoteHttpResponse { status: 200, .. }); - assert_http_response(&response); - assert_eq!( - response.headers.len(), - HTTP_HEADERS_MAX_NUMBER, - "Expected {} headers, got {}", - response_headers, - response.headers.len() - ); -} - -fn test_max_number_of_response_headers_exceeded(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let response_headers = HTTP_HEADERS_MAX_NUMBER - HTTPBIN_OVERHEAD_RESPONSE_HEADERS + 1; - let url = format!( - "https://[{}]/{}/{}", - webserver_ipv6, "many_response_headers", response_headers - ); - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url, - headers: vec![], - method: HttpMethod::GET, - body: None, - transform: None, - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::SysFatal, - .. - }) - ); - assert_ne!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); -} - -fn test_max_number_of_request_headers(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let headers = (0..HTTP_HEADERS_MAX_NUMBER) - .map(|i| HttpHeader { - name: format!("name{i}"), - value: format!("value{i}"), - }) - .collect(); - - let request = RemoteHttpRequest { - request: UnvalidatedCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]/anything"), - headers, - method: HttpMethod::POST, - body: None, - transform: None, - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }; - let (response, _) = block_on(submit_outcall(&handlers, request.clone())); - let response = response.expect("Request is successful."); - - assert_matches!(&response, RemoteHttpResponse { status: 200, .. }); - assert_http_response(&response); - assert_http_json_response(&request.request, &response); -} - -fn test_max_number_of_request_headers_exceeded(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - let url = format!("https://[{webserver_ipv6}]/anything"); - - let headers = (0..HTTP_HEADERS_MAX_NUMBER + 1) - .map(|i| HttpHeader { - name: format!("name{i}"), - value: format!("value{i}"), - }) - .collect(); - - #[derive(Clone, Debug, CandidType, Deserialize)] - struct TestRequest { - url: String, - headers: Vec, - method: HttpMethod, - } - - #[derive(Clone, Debug, CandidType, Deserialize)] - struct TestRemoteHttpRequest { - pub request: TestRequest, - pub cycles: u64, - } - - let (response, refunded_cycles) = block_on(submit_outcall( - &handlers, - TestRemoteHttpRequest { - request: TestRequest { - url, - headers, - method: HttpMethod::POST, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - - assert_matches!( - response, - Err(RejectResponse { - reject_code: RejectCode::CanisterReject, - .. - }) - ); - assert_eq!( - refunded_cycles, - RefundedCycles::Cycles(HTTP_REQUEST_CYCLE_PAYMENT) - ); -} - -fn check_caller_id_on_transform_function(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - let url = format!("https://[{}]/{}/{}", webserver_ipv6, "ascii", "hello_world"); - - let request = UnvalidatedCanisterHttpRequestArgs { - url, - headers: vec![], - method: HttpMethod::GET, - body: Some("".as_bytes().to_vec()), - max_response_bytes: None, - is_replicated: None, - pricing_version: None, - transform: Some(TransformContext { - function: TransformFunc(candid::Func { - principal: get_proxy_canister_id(&env).into(), - method: "test_transform".to_string(), - }), - context: vec![], - }), - }; - - let (response, _) = block_on(submit_outcall( - &handlers, - RemoteHttpRequest { - request: request.clone(), - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }, - )); - let response = response.expect("Request is successful."); - - // Check caller id injected into header. - let caller_id = &response - .headers - .iter() - .find(|(name, _)| name.to_lowercase() == "caller") - .expect("caller header is present after transformation.") - .1; - - assert_eq!(caller_id, "aaaaa-aa"); -} - -// ---- HELPER FUNCTIONS ------- - -/// Case insensitive header names are distinct. -fn assert_distinct_headers(http_response: &RemoteHttpResponse) { - let response_header_set: HashSet = http_response - .headers - .clone() - .iter() - .map(|(name, _)| name.to_lowercase()) - .collect(); - - assert_eq!( - response_header_set.len(), - http_response.headers.len(), - "Found duplicate headers: {:?}", - http_response.headers - ); -} - -/// Assert that content-length header matches the body length, and that the headers are distinct. -fn assert_http_response( - // http_request: &CanisterHttpRequestArgs, - http_response: &RemoteHttpResponse, -) { - assert_distinct_headers(http_response); - - let content_length_header = http_response - .headers - .iter() - .find(|(name, _)| name.to_lowercase() == "content-length") - .map(|(_, value)| value.parse::()) - .unwrap_or_else(|| { - panic!( - "HTTP response contains `content-length` header. Headers: {:?}", - http_response.headers - ) - }) - .expect("content-length is a number"); - - assert_eq!( - content_length_header, - http_response.body.len(), - "Content length header does not match the body length." - ); -} - -/// Checks if two sets of headers match according to specific rules: -/// 1. All headers in `outcall_headers` must exist in `http_bin_server_received_headers` -/// 2. All headers in `http_bin_server_received_headers` must exist in `outcall_headers`, unless they are special cases: -/// - "host" -/// - "content-length" -/// - "accept-encoding" -/// - "user-agent" with value "ic/1.0" -/// 3. Request method must match the method in the response. -/// 4. Request body must match the body in the response. -fn assert_http_json_response( - request: &UnvalidatedCanisterHttpRequestArgs, - http_response: &RemoteHttpResponse, -) { - let request_headers = request - .headers - .iter() - .map(|HttpHeader { name, value }| (name.clone(), value.clone())) - .collect::>(); - - let response_body: Value = - serde_json::from_str(&http_response.body).expect("Response body is JSON formatted."); - - let http_bin_server_received_headers: Vec<_> = response_body["headers"] - .as_array() - .expect("Headers is an array") - .iter() - .map(|name_value| { - let name_value_tuple = name_value - .as_array() - .expect("Headers is tuple of name and value."); - let name = name_value_tuple[0].as_str().unwrap().to_string(); - let value = name_value_tuple[1].as_str().unwrap().to_string(); - (name, value) - }) - .collect(); - - // Rule 1: Check that all left headers exist in right - let http_bin_server_received_all_outcall_headers = request_headers - .iter() - .all(|x| http_bin_server_received_headers.contains(x)); - - assert!( - http_bin_server_received_all_outcall_headers, - "1. HTTP bin server did not receive all headers specified in the outcall. Specified headers: {request_headers:?}, received headers: {http_bin_server_received_headers:?}" - ); - - // Rule 2: Check that all headers received by the server was specified in outcall. - let http_bin_server_only_received_headers_specified_by_outcall = - http_bin_server_received_headers - .iter() - .filter(|(name, value)| { - !matches!( - (name.as_str(), value.as_str()), - ("host", _) - | ("content-length", _) - | ("accept-encoding", _) - | ("user-agent", "ic/1.0") - ) - }) - .all(|(name, value)| request_headers.contains(&(name.clone(), value.clone()))); - - assert!( - http_bin_server_only_received_headers_specified_by_outcall, - "2. Http bin server received headers that were not specified in the outcall. Specified headers: {request_headers:?}, received headers: {http_bin_server_received_headers:?}" - ); - - // Rule 3: Request method must match the method in the response. - let request_method = match request.method { - HttpMethod::GET => "GET", - HttpMethod::POST => "POST", - HttpMethod::HEAD => "HEAD", - HttpMethod::PUT => "PUT", - HttpMethod::DELETE => "DELETE", - HttpMethod::PATCH => "PATCH", - }; - - assert_eq!( - request_method, - response_body["method"].as_str().unwrap(), - "3. Mismatch in HTTP method." - ); - - // Rule 4: Request body must match the body in the response. - let server_received_body = response_body["data"].as_str().unwrap(); - let outcall_sent_body = String::from_utf8(request.body.clone().unwrap_or_default()).unwrap(); - - assert_eq!( - server_received_body, &outcall_sent_body, - "4. HTTP bin server received body does not match the outcall sent body." - ); -} - -#[derive(Debug, Eq, PartialEq)] -enum RefundedCycles { - NotApplicable, - Cycles(u64), -} - -type ProxyCanisterResponseWithRefund = ResponseWithRefundedCycles; - -// This type represents the result of an IC http_request and the refunded cycles. -// The refund is returned regardless of whether the outcall succeeded (Ok) or failed (Err), -// allowing tests to verify proper cycle refund behavior in both success and error cases. -type OutcallsResponseWithRefund = (Result, RefundedCycles); - -async fn submit_outcall( - handlers: &Handlers<'_>, - request: Request, -) -> OutcallsResponseWithRefund -where - Request: Clone + CandidType, -{ - let args = Encode!(&request).unwrap(); - let agent = handlers.agent().await; - - let principal_id: PrincipalId = handlers.proxy_canister().effective_canister_id(); - let principal: Principal = principal_id.into(); - - let log = handlers.env.logger(); - let canister_response = match retry_agent_on_transport_errors!( - "submit_outcall: call", - &log, - agent - .update(&principal, "send_request_with_refund_callback") - .with_arg(args.clone()) - .call() - ) - .await - .expect("submit_outcall retries exhausted") - { - Ok(CallResponse::Response(response)) => Ok(response), - Ok(CallResponse::Poll(request_id)) => retry_agent_on_transport_errors!( - "submit_outcall: wait", - &log, - agent.wait(&request_id, principal) - ) - .await - .expect("submit_outcall retries exhausted"), - Err(err) => Err(err), - }; - - match canister_response { - Err(agent_error) => { - let err_resp = match agent_error { - AgentError::CertifiedReject { - reject: response, .. - } - | AgentError::UncertifiedReject { - reject: response, .. - } => response, - _ => panic!("Unexpected error: {agent_error:?}"), - }; - // If an agent_error is returned then it means that the http_request failed before - // performing the outcall on the canister, therefore the refund is not applicable. - (Err(err_resp), RefundedCycles::NotApplicable) - } - Ok(serialized_bytes) => { - let response_with_refund = - decode_one::(&serialized_bytes.0) - .expect("Decoding the canister serialized response should succeed."); - - let refunded_cycles = response_with_refund.refunded_cycles; - let result = response_with_refund - .result - .map_err(|(reject_code, reject_message)| { - let reject_code = match reject_code { - RejectionCode::SysFatal => RejectCode::SysFatal, - RejectionCode::SysTransient => RejectCode::SysTransient, - RejectionCode::DestinationInvalid => RejectCode::DestinationInvalid, - RejectionCode::CanisterReject => RejectCode::CanisterReject, - RejectionCode::CanisterError => RejectCode::CanisterError, - RejectionCode::NoError | RejectionCode::Unknown => { - panic!("Invalid rejection code.") - } - }; - - RejectResponse { - reject_code, - reject_message, - error_code: None, - } - }); - (result, RefundedCycles::Cycles(refunded_cycles)) - } - } -} - -/// Submits a flexible HTTP outcall through the proxy canister and returns the -/// proxy's raw reply: `Ok(bytes)` (the Candid-encoded `FlexibleHttpRequestResult`) -/// on a handled outcall, or `Err((code, message))` when `flexible_http_request` -/// is rejected synchronously (e.g. because it is not enabled on this subnet). -async fn submit_flexible_outcall( - handlers: &Handlers<'_>, - request: FlexibleRemoteHttpRequest, -) -> Result, (RejectionCode, String)> { - let args = Encode!(&request).unwrap(); - let agent = handlers.agent().await; - - let principal_id: PrincipalId = handlers.proxy_canister().effective_canister_id(); - let principal: Principal = principal_id.into(); - - let log = handlers.env.logger(); - let canister_response = match retry_agent_on_transport_errors!( - "submit_flexible_outcall: call", - &log, - agent - .update(&principal, "send_flexible_request") - .with_arg(args.clone()) - .call() - ) - .await - .expect("submit_flexible_outcall retries exhausted") - { - Ok(CallResponse::Response(response)) => Ok(response), - Ok(CallResponse::Poll(request_id)) => retry_agent_on_transport_errors!( - "submit_flexible_outcall: wait", - &log, - agent.wait(&request_id, principal) - ) - .await - .expect("submit_flexible_outcall retries exhausted"), - Err(err) => Err(err), - }; - - let serialized_bytes = canister_response.expect("send_flexible_request should reply"); - decode_one::, (RejectionCode, String)>>(&serialized_bytes.0) - .expect("Decoding the send_flexible_request reply should succeed.") -} - -/// Flexible HTTP outcalls are priced with the (not-yet-enabled) pay-as-you-go -/// pricing model, so they are rejected on a normal (paying) subnet. (On a free -/// subnet they are available via the legacy pricing fallback; that path is -/// covered by `canister_http_flexible_test`.) -fn test_flexible_http_request_not_enabled_on_normal_subnet(env: TestEnv) { - let handlers = Handlers::new(&env); - let webserver_ipv6 = get_universal_vm_address(&env); - - let request = FlexibleRemoteHttpRequest { - request: FlexibleCanisterHttpRequestArgs { - url: format!("https://[{webserver_ipv6}]/ascii/hello"), - max_response_bytes: None, - headers: BoundedHttpHeaders::new(vec![]), - body: None, - method: HttpMethod::GET, - transform: None, - replication: None, - }, - cycles: HTTP_REQUEST_CYCLE_PAYMENT, - }; - - let result = block_on(submit_flexible_outcall(&handlers, request)); - - match result { - Err((reject_code, message)) => { - // A `CanisterContractViolation` (5xx) surfaces as `CanisterError`. - assert_matches!(reject_code, RejectionCode::CanisterError); - assert!( - message.contains("This API is not enabled on this subnet"), - "unexpected rejection message: '{message}'" - ); - } - Ok(bytes) => panic!( - "expected the flexible outcall to be rejected on a normal subnet, \ - got a {}-byte reply", - bytes.len() - ), - } -} - -/// Pricing function of canister http requests. -fn expected_cycle_cost( - proxy_canister: CanisterId, - request: UnvalidatedCanisterHttpRequestArgs, - subnet_size: usize, -) -> u64 { - let cm = CyclesAccountManagerBuilder::new().build(); - let response_size = request - .max_response_bytes - .unwrap_or(MAX_CANISTER_HTTP_REQUEST_BYTES); - - let dummy_context = CanisterHttpRequestContext::generate_from_args( - UNIX_EPOCH, - &RequestBuilder::default() - .receiver(CanisterId::from(1)) - .sender(proxy_canister) - .build(), - request.into(), - &BTreeSet::from([PrincipalId::new_node_test_id(0).into()]), - RegistryVersion::from(1), - CanisterCyclesCostSchedule::Normal, - &mut rand::thread_rng(), - ) - .unwrap(); - let req_size = dummy_context.variable_parts_size(); - let cycle_fee = cm.http_request_fee( - req_size, - Some(NumBytes::from(response_size)), - CyclesAccountManagerSubnetConfig::new( - subnet_size, - CanisterCyclesCostSchedule::Normal, - DEFAULT_REFERENCE_SUBNET_SIZE, - ), - ); - cycle_fee.real().get().try_into().unwrap() -} diff --git a/rs/tests/networking/canister_http_flexible/BUILD.bazel b/rs/tests/networking/canister_http_flexible/BUILD.bazel new file mode 100644 index 000000000000..d85e51abb5a1 --- /dev/null +++ b/rs/tests/networking/canister_http_flexible/BUILD.bazel @@ -0,0 +1,23 @@ +load("@rules_rust//rust:defs.bzl", "rust_library") + +package(default_visibility = ["//rs:system-tests-pkg"]) + +rust_library( + name = "canister_http_flexible", + testonly = True, + srcs = ["canister_http_flexible.rs"], + crate_name = "canister_http_flexible", + target_compatible_with = ["@platforms//os:linux"], + deps = [ + # Keep sorted. + "//rs/rust_canisters/canister_test", + "//rs/rust_canisters/dfn_candid", + "//rs/rust_canisters/proxy_canister:lib", + "//rs/tests/driver:ic-system-test-driver", + "//rs/tests/networking/canister_http", + "//rs/types/management_canister_types", + "@crate_index//:anyhow", + "@crate_index//:candid", + "@crate_index//:slog", + ], +) diff --git a/rs/tests/networking/canister_http_flexible/Cargo.toml b/rs/tests/networking/canister_http_flexible/Cargo.toml new file mode 100644 index 000000000000..a74e5bf20b57 --- /dev/null +++ b/rs/tests/networking/canister_http_flexible/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "canister_http_flexible" +version.workspace = true +authors.workspace = true +edition.workspace = true +description.workspace = true +documentation.workspace = true + +[dependencies] +anyhow = { workspace = true } +candid = { workspace = true } +canister-test = { path = "../../../rust_canisters/canister_test" } +canister_http = { path = "../canister_http" } +dfn_candid = { path = "../../../rust_canisters/dfn_candid" } +ic-management-canister-types-private = { path = "../../../types/management_canister_types" } +ic-system-test-driver = { path = "../../driver" } +proxy_canister = { path = "../../../rust_canisters/proxy_canister" } +slog = { workspace = true } + +[lib] +name = "canister_http_flexible" +path = "canister_http_flexible.rs" diff --git a/rs/tests/networking/canister_http_flexible/canister_http_flexible.rs b/rs/tests/networking/canister_http_flexible/canister_http_flexible.rs new file mode 100644 index 000000000000..43a57fea929a --- /dev/null +++ b/rs/tests/networking/canister_http_flexible/canister_http_flexible.rs @@ -0,0 +1,1638 @@ +//! The scenarios exercising the `flexible_http_request` management canister +//! endpoint, shared by the system tests that run them against an application +//! subnet where HTTP outcalls are free and against one where they are paid for +//! under pay-as-you-go. +//! +//! Every scenario here has to hold either way: what a caller observes must not +//! depend on whether it was charged. Scenarios that are *about* being charged +//! live in [`add_pricing_scenarios`], which only the paying test runs. +#![allow(deprecated)] + +use anyhow::{Result, bail}; +use candid::{Decode, Principal}; +use canister_http::*; +use canister_test::{Canister, Runtime}; +use dfn_candid::candid_one; +use ic_management_canister_types_private::{ + BoundedHttpHeaders, CanisterHttpResponsePayload, FlexibleCanisterHttpRequestArgs, + FlexibleHttpGlobalError, FlexibleHttpRequestErr, FlexibleHttpRequestResult, HttpHeader, + HttpMethod, ReplicationCounts, TransformContext, TransformFunc, +}; +use ic_system_test_driver::driver::group::{SystemTestGroup, SystemTestSubGroup}; +use ic_system_test_driver::driver::{ + test_env::TestEnv, + test_env_api::{HasPublicApiUrl, HasVm, READY_WAIT_TIMEOUT, RETRY_BACKOFF}, +}; +use ic_system_test_driver::systest; +use ic_system_test_driver::util::block_on; +use proxy_canister::{FlexibleRemoteHttpRequest, RejectionCode}; +use slog::info; + +/// The cycles attached to each flexible outcall. +/// +/// Sized for the worst case in this suite — a 2 MiB response on a 4-node +/// committee, which costs on the order of 10^10 cycles under pay-as-you-go — with +/// ample headroom, while staying small enough that every test in the parallel +/// suite can have one in flight at once against the proxy canister's balance. On +/// a free subnet nothing is charged and the whole payment comes back. +const CYCLES: u64 = 500_000_000_000; + +/// The application subnet has 4 nodes (see `setup`). With the default +/// replication (`replication: None`) the committee is all `n` nodes, +/// `max_responses = n` and `min_responses = floor(2n/3) + 1`. +const SUBNET_NODES: u32 = 4; +const DEFAULT_MIN_RESPONSES: usize = 3; // floor(2*4/3) + 1 +const DEFAULT_MAX_RESPONSES: usize = SUBNET_NODES as usize; + +/// The minimum number of per-node reject details in a `TooManyRejects` error +/// under default replication: the error fires only once more nodes reject than +/// the slack (`total_requests - min_responses`) allows, i.e. at least this many. +const MIN_REJECT_DETAILS: usize = SUBNET_NODES as usize - DEFAULT_MIN_RESPONSES + 1; + +/// The scenarios that must hold whether or not outcalls are paid for, set up by +/// `setup` (which decides the application subnet's cost schedule). +/// +/// These all run in parallel. The caller appends its own scenarios and then +/// [`add_fault_tolerance`], which has to come last because it kills a node — +/// see [`add_pricing_scenarios`]. +pub fn shared_scenarios(setup: fn(TestEnv)) -> SystemTestGroup { + SystemTestGroup::new().with_setup(setup).add_parallel( + SystemTestSubGroup::new() + // Success across replication parameters and HTTP methods. + .add_test(systest!(test_default_replication)) + .add_test(systest!(test_all_nodes)) + .add_test(systest!(test_partial_responses)) + .add_test(systest!(test_intermediate_range)) + .add_test(systest!(test_post_with_body)) + .add_test(systest!(test_head_method)) + .add_test(systest!(test_put_with_deterministic_replication)) + .add_test(systest!(test_delete_with_deterministic_replication)) + .add_test(systest!(test_patch_with_deterministic_replication)) + .add_test(systest!(test_redirects_are_not_followed)) + .add_test(systest!(test_redirect_zero_no_content)) + .add_test(systest!(test_nondeterministic_responses)) + .add_test(systest!(test_single_request_nondeterministic)) + .add_test(systest!(test_min_responses_fit_max_would_exceed)) + .add_test(systest!(test_single_large_response_ok)) + .add_test(systest!(test_fire_and_forget)) + // System subnet (free for outcalls despite a normal cost schedule). + .add_test(systest!(test_system_subnet_outcall)) + // Transform behavior. + .add_test(systest!(test_transform_appends_context)) + .add_test(systest!(test_transform_sets_status_and_headers)) + .add_test(systest!(test_deterministic_transform_normalizes)) + // Synchronous validation rejections. + .add_test(systest!(test_reject_total_requests_zero)) + .add_test(systest!(test_reject_total_requests_exceed_nodes)) + .add_test(systest!(test_reject_min_exceeds_max)) + .add_test(systest!(test_reject_max_exceeds_total)) + .add_test(systest!(test_reject_put_requires_deterministic)) + .add_test(systest!(test_reject_delete_non_deterministic)) + .add_test(systest!(test_reject_url_too_long)) + .add_test(systest!(test_reject_invalid_transform_principal)) + .add_test(systest!(test_reject_header_name_too_long)) + .add_test(systest!(test_reject_header_value_too_long)) + .add_test(systest!(test_reject_request_too_large)) + // Runtime errors and adapter-level per-node failures. + .add_test(systest!(test_too_many_rejects_connection_refused)) + .add_test(systest!(test_too_many_rejects_invalid_domain)) + .add_test(systest!(test_too_many_rejects_non_https)) + .add_test(systest!(test_too_many_rejects_response_over_node_limit)) + .add_test(systest!(test_too_many_rejects_transform_over_node_limit)) + .add_test(systest!(test_too_many_rejects_composite_transform)) + .add_test(systest!(test_responses_too_large)) + // Caller-supplied per-node response size cap. + .add_test(systest!(test_custom_max_response_bytes_exceeded)) + .add_test(systest!(test_custom_max_response_bytes_within_limits)), + ) +} + +/// Appends the fault-tolerance scenario, which kills a subnet node and so has to +/// be the last thing any suite runs. +pub fn add_fault_tolerance(group: SystemTestGroup) -> SystemTestGroup { + group.add_test(systest!(test_fault_tolerance)) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Returns a runtime for one of the application-subnet nodes. +fn app_runtime(env: &TestEnv) -> Runtime { + let node = get_node_snapshots(env) + .next() + .expect("there is no application node"); + get_runtime_from_node(&node) +} + +/// Returns the proxy canister installed during setup. +fn proxy_canister<'a>(env: &TestEnv, runtime: &'a Runtime) -> Canister<'a> { + let principal_id = get_proxy_canister_id(env); + Canister::new(runtime, CanisterId::unchecked_from_principal(principal_id)) +} + +/// Returns a runtime for the (single) system-subnet node. +fn system_runtime(env: &TestEnv) -> Runtime { + let node = get_system_subnet_node_snapshots(env) + .next() + .expect("there is no system-subnet node"); + get_runtime_from_node(&node) +} + +/// Returns the proxy canister installed on the system subnet during setup. +fn system_proxy_canister<'a>(env: &TestEnv, runtime: &'a Runtime) -> Canister<'a> { + let principal_id = get_system_proxy_canister_id(env); + Canister::new(runtime, CanisterId::unchecked_from_principal(principal_id)) +} + +/// The principal of the proxy canister (the sender of the outcalls), used as the +/// valid transform principal. +fn proxy_principal(env: &TestEnv) -> Principal { + get_proxy_canister_id(env).0 +} + +fn webserver_base(env: &TestEnv) -> String { + format!("https://[{}]", get_universal_vm_address(env)) +} + +/// Base flexible request arguments: a `GET` with no headers, body, transform, or +/// explicit replication. +fn get_args(url: String) -> FlexibleCanisterHttpRequestArgs { + FlexibleCanisterHttpRequestArgs { + url, + max_response_bytes: None, + headers: BoundedHttpHeaders::new(vec![]), + body: None, + method: HttpMethod::GET, + transform: None, + replication: None, + } +} + +/// Sends a flexible outcall through the proxy canister. The outer `Result` is +/// `Err` on a (retryable) transport failure of the proxy call itself; the inner +/// `Result` is the outcall outcome — the decoded [`FlexibleHttpRequestResult`] +/// on a handled outcall, or the synchronous rejection on a validation failure. +async fn send_flexible( + proxy: &Canister<'_>, + args: FlexibleCanisterHttpRequestArgs, + cycles: u64, +) -> Result> { + // A failure here is a transport-level error talking to the proxy canister, + // not an outcall outcome; return it so the retry loop can absorb blips. + let res = proxy + .update_( + "send_flexible_request", + candid_one::, (RejectionCode, String)>, FlexibleRemoteHttpRequest>, + FlexibleRemoteHttpRequest { + request: args, + cycles, + }, + ) + .await + .map_err(|err| anyhow::anyhow!("update call to proxy canister failed: {err}"))?; + + Ok(res.map(|bytes| { + Decode!(&bytes, FlexibleHttpRequestResult) + .expect("Failed to decode FlexibleHttpRequestResult") + })) +} + +/// Runs `assert_result` against the outcome of the flexible outcall built by +/// `make_args`, retrying (to absorb transient startup/network flakiness) until +/// the expected outcome is observed or the retry budget is exhausted. +fn run_flexible_test(env: TestEnv, description: &str, make_args: M, assert_result: A) +where + M: Fn(&TestEnv) -> FlexibleCanisterHttpRequestArgs, + A: Fn(Result) -> Result<()>, +{ + run_flexible_test_with_cycles(env, description, CYCLES, make_args, assert_result) +} + +/// Like [`run_flexible_test`], but attaches `cycles` to the outcall instead of +/// the default payment — for scenarios that are about the payment itself. +fn run_flexible_test_with_cycles( + env: TestEnv, + description: &str, + cycles: u64, + make_args: M, + assert_result: A, +) where + M: Fn(&TestEnv) -> FlexibleCanisterHttpRequestArgs, + A: Fn(Result) -> Result<()>, +{ + let logger = env.logger(); + let runtime = app_runtime(&env); + let proxy = proxy_canister(&env, &runtime); + + block_on(async { + ic_system_test_driver::retry_with_msg_async!( + description.to_string(), + &logger, + READY_WAIT_TIMEOUT, + RETRY_BACKOFF, + || async { + let args = make_args(&env); + let result = send_flexible(&proxy, args, cycles).await?; + assert_result(result) + } + ) + .await + .unwrap_or_else(|err| panic!("'{description}' did not reach the expected outcome: {err}")); + }); +} + +/// Returns the value of the (case-insensitive) response header `name`, if present. +fn header_value<'a>(payload: &'a CanisterHttpResponsePayload, name: &str) -> Option<&'a str> { + payload + .headers + .iter() + .find(|header| header.name.eq_ignore_ascii_case(name)) + .map(|header| header.value.as_str()) +} + +/// Asserts the result is `Ok` with a payload count in `[min, max]` and returns +/// the payloads for further inspection. +fn expect_ok( + result: Result, + min: usize, + max: usize, +) -> Result> { + match result { + Ok(FlexibleHttpRequestResult::Ok(payloads)) => { + if payloads.len() < min || payloads.len() > max { + bail!( + "expected between {min} and {max} response payloads, got {}", + payloads.len() + ); + } + Ok(payloads) + } + other => bail!("expected Ok response payloads, got: {other:?}"), + } +} + +/// Asserts every payload has the given HTTP status. +fn expect_all_status(payloads: &[CanisterHttpResponsePayload], status: u128) -> Result<()> { + for payload in payloads { + if payload.status != status { + bail!( + "expected status {status} for every payload, got {}", + payload.status + ); + } + } + Ok(()) +} + +/// Asserts every payload body equals `expected`. +fn expect_all_bodies(payloads: &[CanisterHttpResponsePayload], expected: &[u8]) -> Result<()> { + for payload in payloads { + if payload.body.as_slice() != expected { + bail!( + "expected body {:?}, got {:?}", + String::from_utf8_lossy(expected), + String::from_utf8_lossy(&payload.body) + ); + } + } + Ok(()) +} + +/// Asserts every payload body (interpreted as UTF-8) contains `needle`. +fn expect_all_bodies_contain(payloads: &[CanisterHttpResponsePayload], needle: &str) -> Result<()> { + for payload in payloads { + let body = String::from_utf8_lossy(&payload.body); + if !body.contains(needle) { + bail!("expected body to contain '{needle}', got {body:?}"); + } + } + Ok(()) +} + +/// Asserts every payload has no response headers (e.g. after a header-stripping +/// transform). +fn expect_all_headers_empty(payloads: &[CanisterHttpResponsePayload]) -> Result<()> { + for payload in payloads { + if !payload.headers.is_empty() { + bail!("expected no headers, got {:?}", payload.headers); + } + } + Ok(()) +} + +/// Asserts the result is a synchronous rejection with reject code +/// `CanisterReject` (argument validation fails with `CanisterRejectedMessage`, +/// a 4xx error code) and a message containing `expected_substring`. +fn expect_rejection( + result: Result, + expected_substring: &str, +) -> Result<()> { + match result { + Err((code, message)) => { + if !matches!(code, RejectionCode::CanisterReject) { + bail!("expected reject code CanisterReject, got {code:?} (message: '{message}')"); + } + if !message.contains(expected_substring) { + bail!("rejection message '{message}' does not contain '{expected_substring}'"); + } + Ok(()) + } + other => bail!("expected a synchronous rejection, got: {other:?}"), + } +} + +/// Asserts the result is a runtime `FlexibleHttpRequestResult::Err` with the +/// given global error and a message containing `expected_substring`, and returns +/// the error for further inspection. +fn expect_global_error( + result: Result, + expected: &FlexibleHttpGlobalError, + expected_substring: &str, +) -> Result { + match result { + Ok(FlexibleHttpRequestResult::Err(err)) => { + if err.global_error.as_ref() != Some(expected) { + bail!( + "expected global error {expected:?}, got {:?} (message: '{}')", + err.global_error, + err.message + ); + } + if !err.message.contains(expected_substring) { + bail!( + "error message '{}' does not contain '{expected_substring}'", + err.message + ); + } + Ok(err) + } + other => bail!("expected a FlexibleHttpRequestResult::Err, got: {other:?}"), + } +} + +/// Asserts a runtime error carries at least `min_details` per-node details, each +/// with the given `code` and a message containing `message_substring`. +fn expect_all_node_errors( + err: &FlexibleHttpRequestErr, + min_details: usize, + code: &str, + message_substring: &str, +) -> Result<()> { + if err.node_details.len() < min_details { + bail!( + "expected at least {min_details} per-node error details, got {}", + err.node_details.len() + ); + } + for detail in &err.node_details { + match &detail.error { + Some(node_error) + if node_error.code == code && node_error.message.contains(message_substring) => {} + other => bail!( + "node error {other:?} does not match code '{code}' / message '{message_substring}'" + ), + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Success: replication parameters and HTTP methods +// --------------------------------------------------------------------------- + +/// Default replication (`None`) returns between `min_responses` and +/// `max_responses` identical payloads for a deterministic endpoint. +fn test_default_replication(env: TestEnv) { + let logger = env.logger(); + run_flexible_test( + env, + "default replication returns min..=max identical payloads", + |env| get_args(format!("{}/ascii/hello_world", webserver_base(env))), + move |result| { + let payloads = expect_ok(result, DEFAULT_MIN_RESPONSES, DEFAULT_MAX_RESPONSES)?; + expect_all_status(&payloads, 200)?; + expect_all_bodies(&payloads, b"hello_world")?; + info!( + logger, + "default replication returned {} payloads", + payloads.len() + ); + Ok(()) + }, + ); +} + +/// Requiring all nodes (`min == max == total == n`) returns exactly `n` payloads. +fn test_all_nodes(env: TestEnv) { + run_flexible_test( + env, + "all-nodes replication returns exactly n payloads", + |env| { + let mut args = get_args(format!("{}/ascii/all", webserver_base(env))); + args.replication = Some(ReplicationCounts { + total_requests: SUBNET_NODES, + min_responses: SUBNET_NODES, + max_responses: SUBNET_NODES, + }); + args + }, + |result| { + let payloads = expect_ok(result, SUBNET_NODES as usize, SUBNET_NODES as usize)?; + expect_all_status(&payloads, 200)?; + expect_all_bodies(&payloads, b"all")?; + Ok(()) + }, + ); +} + +/// A partial range (`min < max < total`) returns between `min` and `max` payloads. +fn test_partial_responses(env: TestEnv) { + run_flexible_test( + env, + "partial replication returns min..=max payloads", + |env| { + let mut args = get_args(format!("{}/ascii/partial", webserver_base(env))); + args.replication = Some(ReplicationCounts { + total_requests: 4, + min_responses: 2, + max_responses: 3, + }); + args + }, + |result| { + let payloads = expect_ok(result, 2, 3)?; + expect_all_status(&payloads, 200)?; + expect_all_bodies(&payloads, b"partial")?; + Ok(()) + }, + ); +} + +/// A `POST` with a body succeeds. +fn test_post_with_body(env: TestEnv) { + run_flexible_test( + env, + "POST with a body succeeds", + |env| { + let mut args = get_args(format!("{}/post", webserver_base(env))); + args.method = HttpMethod::POST; + args.body = Some(b"flexible-body".to_vec()); + args + }, + |result| { + let payloads = expect_ok(result, DEFAULT_MIN_RESPONSES, DEFAULT_MAX_RESPONSES)?; + expect_all_status(&payloads, 200)?; + // The endpoint echoes the request method and body as JSON. + expect_all_bodies_contain(&payloads, "\"method\":\"POST\"")?; + expect_all_bodies_contain(&payloads, "flexible-body")?; + Ok(()) + }, + ); +} + +/// The transform function is applied to each response (here it appends the +/// context to the body and strips headers). +fn test_transform_appends_context(env: TestEnv) { + run_flexible_test( + env, + "transform is applied to each response", + |env| { + let mut args = get_args(format!("{}/ascii/base", webserver_base(env))); + args.transform = Some(TransformContext { + function: TransformFunc(candid::Func { + principal: proxy_principal(env), + method: "transform_with_context".to_string(), + }), + context: b"-ctx".to_vec(), + }); + args + }, + |result| { + let payloads = expect_ok(result, DEFAULT_MIN_RESPONSES, DEFAULT_MAX_RESPONSES)?; + expect_all_status(&payloads, 200)?; + // The transform appends the context to the body and strips headers. + expect_all_bodies(&payloads, b"base-ctx")?; + expect_all_headers_empty(&payloads)?; + Ok(()) + }, + ); +} + +/// `PUT` (like `DELETE`/`PATCH`) is only allowed with deterministic replication +/// (`min == max == total`); this exercises the allowed case. +fn test_put_with_deterministic_replication(env: TestEnv) { + run_flexible_test( + env, + "PUT with deterministic replication succeeds", + |env| { + let mut args = get_args(format!("{}/anything", webserver_base(env))); + args.method = HttpMethod::PUT; + args.replication = Some(ReplicationCounts { + total_requests: SUBNET_NODES, + min_responses: SUBNET_NODES, + max_responses: SUBNET_NODES, + }); + // Strip the echoed request headers. + args.transform = Some(TransformContext { + function: TransformFunc(candid::Func { + principal: proxy_principal(env), + method: "transform".to_string(), + }), + context: vec![], + }); + args + }, + |result| { + let payloads = expect_ok(result, SUBNET_NODES as usize, SUBNET_NODES as usize)?; + expect_all_status(&payloads, 200)?; + expect_all_bodies_contain(&payloads, "\"method\":\"PUT\"")?; + Ok(()) + }, + ); +} + +/// The adapter does not follow redirects: a redirecting endpoint yields a 303. +fn test_redirects_are_not_followed(env: TestEnv) { + run_flexible_test( + env, + "redirects are not followed (status 303)", + |env| get_args(format!("{}/redirect/10", webserver_base(env))), + |result| { + let payloads = expect_ok(result, DEFAULT_MIN_RESPONSES, DEFAULT_MAX_RESPONSES)?; + expect_all_status(&payloads, 303)?; + // The redirect target is returned in the location header, not followed. + for payload in &payloads { + match header_value(payload, "location") { + Some(location) if location.contains("relative-redirect") => {} + other => bail!("expected a redirect location header, got {other:?}"), + } + } + Ok(()) + }, + ); +} + +/// Flexible outcalls can aggregate differing responses: a non-deterministic +/// endpoint returns several (possibly different) payloads without diverging. +fn test_nondeterministic_responses(env: TestEnv) { + run_flexible_test( + env, + "non-deterministic responses are aggregated", + |env| { + let mut args = get_args(format!("{}/random", webserver_base(env))); + args.replication = Some(ReplicationCounts { + total_requests: SUBNET_NODES, + min_responses: 2, + max_responses: SUBNET_NODES, + }); + args + }, + |result| { + let payloads = expect_ok(result, 2, SUBNET_NODES as usize)?; + expect_all_status(&payloads, 200)?; + // Each body is a numeric string. + for payload in &payloads { + if payload.body.is_empty() || !payload.body.iter().all(|b| b.is_ascii_digit()) { + bail!( + "expected a numeric random body, got {:?}", + String::from_utf8_lossy(&payload.body) + ); + } + } + // Flexible outcalls keep the differing per-node responses rather than + // reconciling them into a single agreed value: collect the bodies + // into a set and confirm they are all distinct. + let unique_bodies: std::collections::HashSet<_> = + payloads.iter().map(|p| &p.body).collect(); + if unique_bodies.len() != payloads.len() { + bail!( + "expected all {} random bodies to be distinct, got {} distinct", + payloads.len(), + unique_bodies.len() + ); + } + Ok(()) + }, + ); +} + +/// A single-node request to a non-deterministic endpoint succeeds: with one +/// response there is nothing to reconcile. (The flexible replacement for the +/// legacy non-replicated mode.) +fn test_single_request_nondeterministic(env: TestEnv) { + run_flexible_test( + env, + "a single-node request to a non-deterministic endpoint succeeds", + |env| { + let mut args = get_args(format!("{}/random", webserver_base(env))); + args.replication = Some(ReplicationCounts { + total_requests: 1, + min_responses: 1, + max_responses: 1, + }); + args + }, + |result| { + let payloads = expect_ok(result, 1, 1)?; + expect_all_status(&payloads, 200)?; + if payloads[0].body.is_empty() || !payloads[0].body.iter().all(|b| b.is_ascii_digit()) { + bail!( + "expected a numeric random body, got {:?}", + String::from_utf8_lossy(&payloads[0].body) + ); + } + Ok(()) + }, + ); +} + +/// The response count is capped by the block payload limit: `min_responses` +/// responses fit within the ~2 MiB `MAX_CANISTER_HTTP_PAYLOAD_SIZE`, but +/// `max_responses` of them would exceed it, so the outcall succeeds with exactly +/// `min_responses` responses. +fn test_min_responses_fit_max_would_exceed(env: TestEnv) { + // Each node returns a 1 MB body: 2 bodies (2.0 MB) fit within the ~2 MiB + // (2_097_152 B) payload limit, but 3 (3.0 MB) exceed it. + const BODY_SIZE: usize = 1_000_000; + run_flexible_test( + env, + "response count is capped at min_responses by the payload limit", + |env| { + let mut args = get_args(format!("{}/bytes/{BODY_SIZE}", webserver_base(env))); + args.replication = Some(ReplicationCounts { + total_requests: SUBNET_NODES, + min_responses: 2, + max_responses: SUBNET_NODES, + }); + args + }, + |result| { + // Exactly min_responses (2) come back, even though max_responses (4) + // was requested. + let payloads = expect_ok(result, 2, 2)?; + expect_all_status(&payloads, 200)?; + for payload in &payloads { + if payload.body.len() != BODY_SIZE { + bail!( + "expected a {BODY_SIZE}-byte body, got {} bytes", + payload.body.len() + ); + } + } + Ok(()) + }, + ); +} + +/// A "fire-and-forget" outcall (`min_responses = max_responses = 0`) dispatches +/// the request but requires no responses, so it succeeds immediately with an +/// empty result. +fn test_fire_and_forget(env: TestEnv) { + run_flexible_test( + env, + "min = max = 0 fire-and-forget returns an empty result", + |env| { + let mut args = get_args(format!("{}/ascii/ignored", webserver_base(env))); + args.replication = Some(ReplicationCounts { + total_requests: 1, + min_responses: 0, + max_responses: 0, + }); + args + }, + |result| { + // No responses are collected or returned. + expect_ok(result, 0, 0)?; + Ok(()) + }, + ); +} + +/// A single response just under the 2 MB per-node limit succeeds. This is the +/// positive counterpart to `test_too_many_rejects_response_over_node_limit`, +/// where a response over the limit is rejected. +fn test_single_large_response_ok(env: TestEnv) { + const BODY_SIZE: usize = 1_900_000; + run_flexible_test( + env, + "a single response just under the 2 MB per-node limit succeeds", + |env| { + let mut args = get_args(format!("{}/bytes/{BODY_SIZE}", webserver_base(env))); + args.replication = Some(ReplicationCounts { + total_requests: 1, + min_responses: 1, + max_responses: 1, + }); + args + }, + |result| { + let payloads = expect_ok(result, 1, 1)?; + expect_all_status(&payloads, 200)?; + if payloads[0].body.len() != BODY_SIZE { + bail!( + "expected a {BODY_SIZE}-byte body, got {} bytes", + payloads[0].body.len() + ); + } + Ok(()) + }, + ); +} + +/// An intermediate range (`min < max == total`) returns between `min` and `max` +/// payloads. +fn test_intermediate_range(env: TestEnv) { + run_flexible_test( + env, + "intermediate replication returns min..=max payloads", + |env| { + let mut args = get_args(format!("{}/ascii/range", webserver_base(env))); + args.replication = Some(ReplicationCounts { + total_requests: 4, + min_responses: 2, + max_responses: 4, + }); + args + }, + |result| { + let payloads = expect_ok(result, 2, 4)?; + expect_all_status(&payloads, 200)?; + expect_all_bodies(&payloads, b"range")?; + Ok(()) + }, + ); +} + +/// A `HEAD` request succeeds. `HEAD` is not restricted to deterministic +/// replication (unlike `PUT`/`DELETE`/`PATCH`). +fn test_head_method(env: TestEnv) { + run_flexible_test( + env, + "HEAD request succeeds", + |env| { + let mut args = get_args(format!("{}/anything", webserver_base(env))); + args.method = HttpMethod::HEAD; + // Strip the echoed request headers. + args.transform = Some(TransformContext { + function: TransformFunc(candid::Func { + principal: proxy_principal(env), + method: "transform".to_string(), + }), + context: vec![], + }); + args + }, + |result| { + let payloads = expect_ok(result, DEFAULT_MIN_RESPONSES, DEFAULT_MAX_RESPONSES)?; + expect_all_status(&payloads, 200)?; + // A HEAD response carries no body. + expect_all_bodies(&payloads, b"")?; + Ok(()) + }, + ); +} + +/// `DELETE` with deterministic replication (`min == max == total`) succeeds. +fn test_delete_with_deterministic_replication(env: TestEnv) { + run_flexible_test( + env, + "DELETE with deterministic replication succeeds", + |env| { + let mut args = get_args(format!("{}/anything", webserver_base(env))); + args.method = HttpMethod::DELETE; + args.replication = Some(ReplicationCounts { + total_requests: 2, + min_responses: 2, + max_responses: 2, + }); + args.transform = Some(TransformContext { + function: TransformFunc(candid::Func { + principal: proxy_principal(env), + method: "transform".to_string(), + }), + context: vec![], + }); + args + }, + |result| { + let payloads = expect_ok(result, 2, 2)?; + expect_all_status(&payloads, 200)?; + expect_all_bodies_contain(&payloads, "\"method\":\"DELETE\"")?; + Ok(()) + }, + ); +} + +/// `PATCH` with deterministic replication over a sub-committee succeeds. +fn test_patch_with_deterministic_replication(env: TestEnv) { + run_flexible_test( + env, + "PATCH with deterministic replication succeeds", + |env| { + let mut args = get_args(format!("{}/anything", webserver_base(env))); + args.method = HttpMethod::PATCH; + args.replication = Some(ReplicationCounts { + total_requests: 2, + min_responses: 2, + max_responses: 2, + }); + args.transform = Some(TransformContext { + function: TransformFunc(candid::Func { + principal: proxy_principal(env), + method: "transform".to_string(), + }), + context: vec![], + }); + args + }, + |result| { + let payloads = expect_ok(result, 2, 2)?; + expect_all_status(&payloads, 200)?; + expect_all_bodies_contain(&payloads, "\"method\":\"PATCH\"")?; + Ok(()) + }, + ); +} + +/// A `redirect/0` endpoint returns a 204 (No Content) that is not followed. +fn test_redirect_zero_no_content(env: TestEnv) { + run_flexible_test( + env, + "redirect/0 returns 204", + |env| get_args(format!("{}/redirect/0", webserver_base(env))), + |result| { + let payloads = expect_ok(result, DEFAULT_MIN_RESPONSES, DEFAULT_MAX_RESPONSES)?; + expect_all_status(&payloads, 204)?; + // 204 No Content carries no body. + expect_all_bodies(&payloads, b"")?; + Ok(()) + }, + ); +} + +/// A transform can set the status, headers, and body of every response. +fn test_transform_sets_status_and_headers(env: TestEnv) { + run_flexible_test( + env, + "transform can set status, headers and body", + |env| { + let mut args = get_args(format!("{}/ascii/ignored", webserver_base(env))); + args.transform = Some(TransformContext { + function: TransformFunc(candid::Func { + principal: proxy_principal(env), + method: "test_transform".to_string(), + }), + context: b"transform_context".to_vec(), + }); + args + }, + |result| { + let payloads = expect_ok(result, DEFAULT_MIN_RESPONSES, DEFAULT_MAX_RESPONSES)?; + expect_all_status(&payloads, 202)?; + // The transform replaces the body with the context and sets a fixed + // pair of headers (the caller is the management canister). + expect_all_bodies(&payloads, b"transform_context")?; + for payload in &payloads { + if header_value(payload, "hello") != Some("bonjour") { + bail!( + "expected header hello=bonjour, got {:?}", + header_value(payload, "hello") + ); + } + if header_value(payload, "caller") != Some("aaaaa-aa") { + bail!( + "expected header caller=aaaaa-aa, got {:?}", + header_value(payload, "caller") + ); + } + } + Ok(()) + }, + ); +} + +/// A deterministic transform normalizes a non-deterministic endpoint so every +/// node agrees on an identical response. +fn test_deterministic_transform_normalizes(env: TestEnv) { + run_flexible_test( + env, + "a deterministic transform normalizes a non-deterministic endpoint", + |env| { + let mut args = get_args(format!("{}/random", webserver_base(env))); + args.replication = Some(ReplicationCounts { + total_requests: SUBNET_NODES, + min_responses: SUBNET_NODES, + max_responses: SUBNET_NODES, + }); + args.transform = Some(TransformContext { + function: TransformFunc(candid::Func { + principal: proxy_principal(env), + method: "deterministic_transform".to_string(), + }), + context: vec![], + }); + args + }, + |result| { + let payloads = expect_ok(result, SUBNET_NODES as usize, SUBNET_NODES as usize)?; + expect_all_status(&payloads, 200)?; + // Every node is normalized to the same body with no headers. + expect_all_bodies(&payloads, b"deterministic")?; + expect_all_headers_empty(&payloads)?; + Ok(()) + }, + ); +} + +// --------------------------------------------------------------------------- +// Synchronous validation rejections +// --------------------------------------------------------------------------- + +fn test_reject_total_requests_zero(env: TestEnv) { + run_flexible_test( + env, + "total_requests = 0 is rejected", + |env| { + let mut args = get_args(format!("{}/ascii/x", webserver_base(env))); + args.replication = Some(ReplicationCounts { + total_requests: 0, + min_responses: 0, + max_responses: 0, + }); + args + }, + |result| expect_rejection(result, "total_requests (0) must be at least 1"), + ); +} + +fn test_reject_total_requests_exceed_nodes(env: TestEnv) { + run_flexible_test( + env, + "total_requests > number of nodes is rejected", + |env| { + let mut args = get_args(format!("{}/ascii/x", webserver_base(env))); + args.replication = Some(ReplicationCounts { + total_requests: SUBNET_NODES + 1, + min_responses: 1, + max_responses: 1, + }); + args + }, + |result| expect_rejection(result, "must not exceed the number of available nodes (4)"), + ); +} + +fn test_reject_min_exceeds_max(env: TestEnv) { + run_flexible_test( + env, + "min_responses > max_responses is rejected", + |env| { + let mut args = get_args(format!("{}/ascii/x", webserver_base(env))); + args.replication = Some(ReplicationCounts { + total_requests: 4, + min_responses: 3, + max_responses: 2, + }); + args + }, + |result| { + expect_rejection( + result, + "min_responses (3) must not exceed max_responses (2)", + ) + }, + ); +} + +fn test_reject_max_exceeds_total(env: TestEnv) { + run_flexible_test( + env, + "max_responses > total_requests is rejected", + |env| { + let mut args = get_args(format!("{}/ascii/x", webserver_base(env))); + args.replication = Some(ReplicationCounts { + total_requests: 2, + min_responses: 1, + max_responses: 3, + }); + args + }, + |result| { + expect_rejection( + result, + "max_responses (3) must not exceed total_requests (2)", + ) + }, + ); +} + +fn test_reject_put_requires_deterministic(env: TestEnv) { + run_flexible_test( + env, + "PUT with non-deterministic replication is rejected", + |env| { + // Default replication has min < total, which is not allowed for PUT. + let mut args = get_args(format!("{}/anything", webserver_base(env))); + args.method = HttpMethod::PUT; + args + }, + |result| expect_rejection(result, "min_responses = max_responses = total_requests"), + ); +} + +fn test_reject_url_too_long(env: TestEnv) { + run_flexible_test( + env, + "an over-long url is rejected", + |env| { + // MAX_CANISTER_HTTP_URL_SIZE is 8192. + let long_path = "a".repeat(8200); + get_args(format!("{}/ascii/{long_path}", webserver_base(env))) + }, + |result| expect_rejection(result, "exceeds 8192"), + ); +} + +fn test_reject_invalid_transform_principal(env: TestEnv) { + run_flexible_test( + env, + "a transform referencing another principal is rejected", + |env| { + let mut args = get_args(format!("{}/ascii/x", webserver_base(env))); + // The transform must reference the calling (proxy) canister; the + // management canister principal does not. + args.transform = Some(TransformContext { + function: TransformFunc(candid::Func { + principal: Principal::management_canister(), + method: "transform".to_string(), + }), + context: vec![], + }); + args + }, + |result| expect_rejection(result, "transform principal id expected to be"), + ); +} + +/// `DELETE` (like `PUT`/`PATCH`) with explicit but non-equal replication counts +/// is rejected (distinct from the default-replication case). +fn test_reject_delete_non_deterministic(env: TestEnv) { + run_flexible_test( + env, + "DELETE with non-equal replication counts is rejected", + |env| { + let mut args = get_args(format!("{}/anything", webserver_base(env))); + args.method = HttpMethod::DELETE; + args.replication = Some(ReplicationCounts { + total_requests: 4, + min_responses: 3, + max_responses: 4, + }); + args + }, + |result| expect_rejection(result, "min_responses = max_responses = total_requests"), + ); +} + +fn test_reject_header_name_too_long(env: TestEnv) { + run_flexible_test( + env, + "an over-long header name is rejected", + |env| { + let mut args = get_args(format!("{}/ascii/x", webserver_base(env))); + // Name of 8193 bytes: the element (8193) is within the candid bound + // (16384) so it decodes, but exceeds the 8192 header name/value limit. + args.headers = BoundedHttpHeaders::new(vec![HttpHeader { + name: "a".repeat(8193), + value: String::new(), + }]); + args + }, + |result| { + expect_rejection( + result, + "number of bytes to represent some http header name 8193 exceeds 8192", + ) + }, + ); +} + +fn test_reject_header_value_too_long(env: TestEnv) { + run_flexible_test( + env, + "an over-long header value is rejected", + |env| { + let mut args = get_args(format!("{}/ascii/x", webserver_base(env))); + args.headers = BoundedHttpHeaders::new(vec![HttpHeader { + name: "name".to_string(), + value: "b".repeat(8193), + }]); + args + }, + |result| { + expect_rejection( + result, + "number of bytes to represent some http header value 8193 exceeds 8192", + ) + }, + ); +} + +/// A request whose headers plus body exceed the 2 MB request-size limit +/// (`MAX_CANISTER_HTTP_REQUEST_BYTES`) is rejected. +fn test_reject_request_too_large(env: TestEnv) { + run_flexible_test( + env, + "a request exceeding the 2 MB size limit is rejected", + |env| { + let mut args = get_args(format!("{}/ascii/x", webserver_base(env))); + // One byte over the 2_000_000-byte limit (no headers). + args.body = Some(vec![0_u8; 2_000_001]); + args + }, + |result| expect_rejection(result, "exceeds 2000000"), + ); +} + +// --------------------------------------------------------------------------- +// Runtime errors and adapter-level per-node failures +// --------------------------------------------------------------------------- + +/// When enough nodes fail to reach the endpoint (here: connection refused on a +/// closed port) `min_responses` cannot be met and the outcall reports +/// `too_many_rejects` with per-node details. +fn test_too_many_rejects_connection_refused(env: TestEnv) { + run_flexible_test( + env, + "connection refused on all nodes yields too_many_rejects", + |env| { + // Port 9090 on the webserver is closed => connection refused. + get_args(format!("https://[{}]:9090", get_universal_vm_address(env))) + }, + |result| { + let err = expect_global_error( + result, + &FlexibleHttpGlobalError::TooManyRejects(candid::Reserved), + "Too many rejects", + )?; + // Every node reports a transient connection failure whose message + // carries the refused connection. + expect_all_node_errors( + &err, + MIN_REJECT_DETAILS, + "SysTransient", + "Connection refused", + )?; + Ok(()) + }, + ); +} + +/// An unresolvable domain fails at the adapter on every node, again yielding +/// `too_many_rejects`. +fn test_too_many_rejects_invalid_domain(env: TestEnv) { + run_flexible_test( + env, + "an invalid domain yields too_many_rejects", + |_env| get_args("https://xwWPqqbNqxxHmLXdguF4DN9xGq22nczV.invalid".to_string()), + |result| { + let err = expect_global_error( + result, + &FlexibleHttpGlobalError::TooManyRejects(candid::Reserved), + "Too many rejects", + )?; + // DNS resolution fails on every node during connection setup. + expect_all_node_errors(&err, MIN_REJECT_DETAILS, "SysTransient", "Connecting to")?; + Ok(()) + }, + ); +} + +/// The adapter enforces HTTPS: a non-`https` url is rejected on every node, so +/// the outcall reports `too_many_rejects`. +fn test_too_many_rejects_non_https(env: TestEnv) { + run_flexible_test( + env, + "a non-https url is rejected on every node", + |env| get_args(format!("http://[{}]", get_universal_vm_address(env))), + |result| { + let err = expect_global_error( + result, + &FlexibleHttpGlobalError::TooManyRejects(candid::Reserved), + "Too many rejects", + )?; + expect_all_node_errors( + &err, + MIN_REJECT_DETAILS, + "SysFatal", + "Url need to specify https scheme", + )?; + Ok(()) + }, + ); +} + +/// When the aggregated responses are too large to fit in a block, the outcall +/// reports `responses_too_large`. Each node returns a ~1 MB body (below the +/// per-node 2 MB limit), but `min_responses` (3) of them exceed the ~2 MiB +/// block payload limit. +fn test_responses_too_large(env: TestEnv) { + run_flexible_test( + env, + "oversized aggregated responses yield responses_too_large", + |env| get_args(format!("{}/bytes/1000000", webserver_base(env))), + |result| { + let err = expect_global_error( + result, + &FlexibleHttpGlobalError::ResponsesTooLarge(candid::Reserved), + "Responses too large", + )?; + // Each node returned an OK response; the details report their sizes. + expect_all_node_errors(&err, DEFAULT_MIN_RESPONSES, "ok", "bytes")?; + Ok(()) + }, + ); +} + +/// A single per-node response that exceeds the 2 MB per-node limit is rejected +/// by the adapter (download limit), so every node rejects and the outcall +/// reports `too_many_rejects`. +fn test_too_many_rejects_response_over_node_limit(env: TestEnv) { + run_flexible_test( + env, + "a per-node response over the 2 MB limit yields too_many_rejects", + |env| get_args(format!("{}/bytes/2100000", webserver_base(env))), + |result| { + let err = expect_global_error( + result, + &FlexibleHttpGlobalError::TooManyRejects(candid::Reserved), + "Too many rejects", + )?; + expect_all_node_errors( + &err, + MIN_REJECT_DETAILS, + "SysFatal", + "Http body exceeds size limit of 2000000 bytes", + )?; + Ok(()) + }, + ); +} + +/// A transform whose output exceeds the 2 MB per-node limit is rejected by the +/// adapter (transform-output limit) on every node, again yielding +/// `too_many_rejects`. +fn test_too_many_rejects_transform_over_node_limit(env: TestEnv) { + run_flexible_test( + env, + "a transform output over the 2 MB limit yields too_many_rejects", + |env| { + let mut args = get_args(format!("{}/bytes/16", webserver_base(env))); + args.transform = Some(TransformContext { + function: TransformFunc(candid::Func { + principal: proxy_principal(env), + method: "bloat_transform".to_string(), + }), + context: vec![], + }); + args + }, + |result| { + let err = expect_global_error( + result, + &FlexibleHttpGlobalError::TooManyRejects(candid::Reserved), + "Too many rejects", + )?; + expect_all_node_errors( + &err, + MIN_REJECT_DETAILS, + "SysFatal", + "Transformed http response exceeds limit: 2000000", + )?; + Ok(()) + }, + ); +} + +/// A composite query cannot be used as a transform: it fails per node, so every +/// node rejects and the outcall reports `too_many_rejects`. +fn test_too_many_rejects_composite_transform(env: TestEnv) { + run_flexible_test( + env, + "a composite-query transform yields too_many_rejects", + |env| { + let mut args = get_args(format!("{}/ascii/x", webserver_base(env))); + args.transform = Some(TransformContext { + function: TransformFunc(candid::Func { + principal: proxy_principal(env), + method: "test_composite_transform".to_string(), + }), + context: vec![], + }); + args + }, + |result| { + let err = expect_global_error( + result, + &FlexibleHttpGlobalError::TooManyRejects(candid::Reserved), + "Too many rejects", + )?; + // The transform query is rejected on every node. + expect_all_node_errors( + &err, + MIN_REJECT_DETAILS, + "CanisterError", + "Composite query cannot be used as transform", + )?; + Ok(()) + }, + ); +} + +// --------------------------------------------------------------------------- +// Custom `max_response_bytes` (per-node response size cap) +// --------------------------------------------------------------------------- + +/// A caller-supplied `max_response_bytes` caps each node's response size. A +/// response larger than a small custom cap is rejected by the adapter on every +/// node, yielding `too_many_rejects` — proving the caller's cap (not just the +/// 2 MB default) is plumbed through per node. +fn test_custom_max_response_bytes_exceeded(env: TestEnv) { + const MAX_RESPONSE_BYTES: u64 = 1_000; + run_flexible_test( + env, + "a response over a small custom max_response_bytes yields too_many_rejects", + |env| { + let mut args = get_args(format!("{}/bytes/2000", webserver_base(env))); + args.max_response_bytes = Some(MAX_RESPONSE_BYTES); + args + }, + |result| { + let err = expect_global_error( + result, + &FlexibleHttpGlobalError::TooManyRejects(candid::Reserved), + "Too many rejects", + )?; + expect_all_node_errors( + &err, + MIN_REJECT_DETAILS, + "SysFatal", + &format!("Http body exceeds size limit of {MAX_RESPONSE_BYTES} bytes"), + )?; + Ok(()) + }, + ); +} + +/// A response that fits within a caller-supplied `max_response_bytes` (but that +/// would be rejected under a smaller cap) succeeds normally. +fn test_custom_max_response_bytes_within_limits(env: TestEnv) { + const BODY_SIZE: usize = 50_000; + run_flexible_test( + env, + "a response within a custom max_response_bytes succeeds", + |env| { + let mut args = get_args(format!("{}/bytes/{BODY_SIZE}", webserver_base(env))); + // Comfortably above the response size, but well below the 2 MB max. + args.max_response_bytes = Some(100_000); + args + }, + |result| { + let payloads = expect_ok(result, DEFAULT_MIN_RESPONSES, DEFAULT_MAX_RESPONSES)?; + expect_all_status(&payloads, 200)?; + for payload in &payloads { + if payload.body.len() != BODY_SIZE { + bail!( + "expected a {BODY_SIZE}-byte body, got {} bytes", + payload.body.len() + ); + } + } + Ok(()) + }, + ); +} + +// --------------------------------------------------------------------------- +// System subnet +// --------------------------------------------------------------------------- + +/// Flexible outcalls work on a system subnet too: system subnets are free for +/// HTTP outcalls (despite a normal cost schedule), so the request is routed +/// through legacy pricing. The system subnet has a single node, so exactly one +/// response comes back. +fn test_system_subnet_outcall(env: TestEnv) { + let logger = env.logger(); + let runtime = system_runtime(&env); + let proxy = system_proxy_canister(&env, &runtime); + + block_on(async { + ic_system_test_driver::retry_with_msg_async!( + "flexible outcall on a system subnet succeeds".to_string(), + &logger, + READY_WAIT_TIMEOUT, + RETRY_BACKOFF, + || async { + let args = get_args(format!("{}/ascii/system", webserver_base(&env))); + let result = send_flexible(&proxy, args, CYCLES).await?; + // A single-node subnet returns exactly one response. + let payloads = expect_ok(result, 1, 1)?; + expect_all_status(&payloads, 200)?; + expect_all_bodies(&payloads, b"system")?; + Ok(()) + } + ) + .await + .expect("flexible outcall on the system subnet did not succeed"); + }); +} + +// --------------------------------------------------------------------------- +// Fault tolerance (destructive: runs sequentially after the parallel suite) +// --------------------------------------------------------------------------- + +/// A flexible outcall with `min_responses < total_requests` still succeeds when +/// one of the committee's nodes is down — the defining reliability property of +/// flexible outcalls. This test kills a node and leaves it down, so it is +/// registered as a trailing sequential test rather than in the parallel suite +/// (nothing must run on the crippled subnet afterwards). +fn test_fault_tolerance(env: TestEnv) { + let logger = env.logger(); + + let mut nodes = get_node_snapshots(&env); + let killed_node = nodes.next().expect("no application nodes"); + let healthy_node = nodes.next().expect("need at least two application nodes"); + + // The proxy canister lives on the subnet, so reach it through a node that + // stays up. + let runtime = get_runtime_from_node(&healthy_node); + let proxy = proxy_canister(&env, &runtime); + + info!(logger, "Killing one application node."); + killed_node.vm().kill(); + killed_node + .await_status_is_unavailable() + .expect("the killed node did not become unavailable"); + info!( + logger, + "Node is down; a flexible outcall requiring fewer responses than nodes must still succeed." + ); + + block_on(async { + ic_system_test_driver::retry_with_msg_async!( + "flexible outcall succeeds with a node down".to_string(), + &logger, + READY_WAIT_TIMEOUT, + RETRY_BACKOFF, + || async { + let mut args = get_args(format!("{}/ascii/tolerate", webserver_base(&env))); + // Target all nodes but require only 2 responses: the surviving + // nodes are enough to meet min_responses. + args.replication = Some(ReplicationCounts { + total_requests: SUBNET_NODES, + min_responses: 2, + max_responses: SUBNET_NODES, + }); + let result = send_flexible(&proxy, args, CYCLES).await?; + // At most the surviving nodes (n - 1) can respond. + let payloads = expect_ok(result, 2, (SUBNET_NODES - 1) as usize)?; + expect_all_status(&payloads, 200)?; + expect_all_bodies(&payloads, b"tolerate")?; + Ok(()) + } + ) + .await + .expect("the flexible outcall did not succeed while a node was down"); + }); +} + +// --------------------------------------------------------------------------- +// Pay-as-you-go pricing +// --------------------------------------------------------------------------- + +/// The scenarios that only exist because the outcall is paid for, and so only +/// run against an application subnet on a normal cost schedule. +/// +/// These run sequentially: [`test_charged_and_refunded`] measures the proxy +/// canister's balance across a single outcall, which any concurrent outcall from +/// the same canister would perturb. +pub fn add_pricing_scenarios(group: SystemTestGroup) -> SystemTestGroup { + group + .add_test(systest!(test_charged_and_refunded)) + .add_test(systest!(test_out_of_cycles)) +} + +/// Reads the proxy canister's own cycle balance. +async fn cycle_balance(proxy: &Canister<'_>) -> Result { + proxy + .query_("cycle_balance", candid_one::, ()) + .await + .map_err(|err| anyhow::anyhow!("querying the proxy canister's balance failed: {err}")) +} + +/// A successful outcall costs the caller something, but nowhere near the payment +/// it attached: the unspent part of the per-replica allowances is refunded. +/// +/// Under pay-as-you-go the whole payment leaves the balance up front and the +/// refund is credited afterwards, so this watches the balance rather than the +/// reply's refunded cycles. +fn test_charged_and_refunded(env: TestEnv) { + let logger = env.logger(); + let runtime = app_runtime(&env); + let proxy = proxy_canister(&env, &runtime); + + block_on(async { + ic_system_test_driver::retry_with_msg_async!( + "a paid outcall charges its cost and refunds the rest".to_string(), + &logger, + READY_WAIT_TIMEOUT, + RETRY_BACKOFF, + || async { + let before = cycle_balance(&proxy).await?; + let args = get_args(format!("{}/ascii/priced", webserver_base(&env))); + let payloads = expect_ok( + send_flexible(&proxy, args, CYCLES).await?, + DEFAULT_MIN_RESPONSES, + DEFAULT_MAX_RESPONSES, + )?; + expect_all_bodies(&payloads, b"priced")?; + let after = cycle_balance(&proxy).await?; + + // The refund is credited after the response is delivered, so the + // balance may still be settling; the retry loop absorbs that. + let Some(charged) = before.checked_sub(after) else { + bail!("balance grew from {before} to {after} across a paid outcall"); + }; + if charged == 0 { + bail!("a paid outcall on a normal cost schedule charged nothing"); + } + // A small outcall costs orders of magnitude less than the payment + // that was attached, so most of it has to have come back. + if charged >= u128::from(CYCLES) / 10 { + bail!( + "expected the refund to return most of the {CYCLES}-cycle payment, \ + but {charged} cycles were kept" + ); + } + Ok(()) + } + ) + .await + .expect("the outcall was not charged and refunded as expected"); + }); +} + +/// An outcall that can pay its replicas for fetching a response, but cannot pay +/// for putting one into a block, fails as out of cycles rather than hanging until +/// it times out. +/// +/// Delivering a response costs roughly `N * (10N + 600)` cycles per byte +/// (~2_560 at `N = 4`) against ~50 cycles per byte to download it, so a payment +/// sized to comfortably cover the downloads still falls far short of the +/// consensus cost for a large enough response. +fn test_out_of_cycles(env: TestEnv) { + const BODY_SIZE: usize = 500_000; + // The payment has to land in a window. Each replica spends ~50 cycles/byte to + // download (~25M here) and its receipt is discarded unless its allowance `A` + // covers that, so `A` must exceed ~25M. Delivering `min_responses` responses + // costs at least `min_responses * N * (10N + 600)` cycles/byte — upwards of + // 3.8B here — and the outcall is only out of cycles if what is left of the + // collective allowance falls short of that. Writing `A = k * S`, anything from + // `k` a little above 1 up to well past 10 qualifies; `k = 4` gives ~4x margin + // on the receipt side and ~13x on the affordability side. + // + // Note the direction: paying *more* buys a bigger allowance and so makes the + // outcall affordable, not less so. + const PAYMENT: u64 = 400_000_000; + + run_flexible_test_with_cycles( + env, + "an outcall that cannot pay to deliver a response is out of cycles", + PAYMENT, + |env| { + let mut args = get_args(format!("{}/bytes/{BODY_SIZE}", webserver_base(env))); + args.replication = Some(ReplicationCounts { + total_requests: SUBNET_NODES, + min_responses: DEFAULT_MIN_RESPONSES as u32, + max_responses: SUBNET_NODES, + }); + args + }, + |result| match result { + Ok(FlexibleHttpRequestResult::Err(FlexibleHttpRequestErr { + global_error: Some(FlexibleHttpGlobalError::OutOfCycles(_)), + message, + .. + })) => { + // The caller is told what it had and what a response would cost, + // so it can tell how much more to attach. + if !message.contains("Out of cycles") { + bail!("unexpected out-of-cycles message: '{message}'"); + } + Ok(()) + } + other => bail!("expected an OutOfCycles error, got: {other:?}"), + }, + ); +} diff --git a/rs/tests/networking/canister_http_flexible_paying_test.rs b/rs/tests/networking/canister_http_flexible_paying_test.rs new file mode 100644 index 000000000000..c48dac50a729 --- /dev/null +++ b/rs/tests/networking/canister_http_flexible_paying_test.rs @@ -0,0 +1,40 @@ +/* tag::catalog[] +Title:: Flexible HTTP outcalls where they are paid for. + +Goal:: Exercise the `flexible_http_request` management canister endpoint on an +application subnet with a normal cost schedule, where outcalls are actually paid +for under pay-as-you-go. + +This runs the same scenarios as `canister_http_flexible_test` — what a caller +observes must not depend on whether it was charged — plus the ones that only +exist because it was charged: refunds of the unspent per-replica allowance, and +outcalls whose payment cannot cover a response. + +Runbook:: +0. Instantiate a universal VM with a webserver (httpbin). +1. Instantiate an IC with the HTTP feature enabled on both a 4-node application + subnet (normal cost schedule) and the 1-node system subnet. +2. Install NNS canisters. +3. Install a proxy canister on each of the two subnets. +4. Make flexible HTTP outcalls through the proxy canisters covering everything + `canister_http_flexible_test` covers, and additionally: + - the caller is charged what the outcall cost and refunded the rest, + - an outcall paid too little to cover a response fails as out of cycles. + +Success:: +1. Each scenario returns the expected `FlexibleHttpRequestResult` (or rejection), + and the caller's balance moves by the expected amount. + +end::catalog[] */ + +use anyhow::Result; + +fn main() -> Result<()> { + let group = + canister_http_flexible::shared_scenarios(canister_http::setup_with_paying_cost_schedule); + // Pricing scenarios before fault tolerance, which leaves a node dead behind it. + let group = canister_http_flexible::add_pricing_scenarios(group); + canister_http_flexible::add_fault_tolerance(group).execute_from_args()?; + + Ok(()) +} diff --git a/rs/tests/networking/canister_http_flexible_test.rs b/rs/tests/networking/canister_http_flexible_test.rs index f00a0338624c..90fd27b3a50e 100644 --- a/rs/tests/networking/canister_http_flexible_test.rs +++ b/rs/tests/networking/canister_http_flexible_test.rs @@ -1,10 +1,13 @@ /* tag::catalog[] -Title:: Flexible HTTP outcalls. +Title:: Flexible HTTP outcalls where they are free. Goal:: Exhaustively exercise the `flexible_http_request` management canister -endpoint on subnets where HTTP outcalls are free (a free-cost-schedule -application subnet and a system subnet), where flexible outcalls fall back to -legacy pricing. +endpoint on an application subnet with a free cost schedule, where HTTP outcalls +cost nothing. + +The scenarios are shared with `canister_http_flexible_paying_test`, which runs +them against a subnet that charges for its outcalls: what a caller observes must +not depend on whether it was charged. Runbook:: 0. Instantiate a universal VM with a webserver (httpbin). @@ -25,1483 +28,13 @@ Success:: 1. Each scenario returns the expected `FlexibleHttpRequestResult` (or rejection). end::catalog[] */ -#![allow(deprecated)] - -use anyhow::{Result, bail}; -use candid::{Decode, Principal}; -use canister_http::*; -use canister_test::{Canister, Runtime}; -use dfn_candid::candid_one; -use ic_management_canister_types_private::{ - BoundedHttpHeaders, CanisterHttpResponsePayload, FlexibleCanisterHttpRequestArgs, - FlexibleHttpGlobalError, FlexibleHttpRequestErr, FlexibleHttpRequestResult, HttpHeader, - HttpMethod, ReplicationCounts, TransformContext, TransformFunc, -}; -use ic_system_test_driver::driver::group::{SystemTestGroup, SystemTestSubGroup}; -use ic_system_test_driver::driver::{ - test_env::TestEnv, - test_env_api::{HasPublicApiUrl, HasVm, READY_WAIT_TIMEOUT, RETRY_BACKOFF}, -}; -use ic_system_test_driver::systest; -use ic_system_test_driver::util::block_on; -use proxy_canister::{FlexibleRemoteHttpRequest, RejectionCode}; -use slog::info; - -/// The cycles attached to each flexible outcall. On a free subnet nothing is -/// charged. -const CYCLES: u64 = 0; -/// The application subnet has 4 nodes (see `setup`). With the default -/// replication (`replication: None`) the committee is all `n` nodes, -/// `max_responses = n` and `min_responses = floor(2n/3) + 1`. -const SUBNET_NODES: u32 = 4; -const DEFAULT_MIN_RESPONSES: usize = 3; // floor(2*4/3) + 1 -const DEFAULT_MAX_RESPONSES: usize = SUBNET_NODES as usize; - -/// The minimum number of per-node reject details in a `TooManyRejects` error -/// under default replication: the error fires only once more nodes reject than -/// the slack (`total_requests - min_responses`) allows, i.e. at least this many. -const MIN_REJECT_DETAILS: usize = SUBNET_NODES as usize - DEFAULT_MIN_RESPONSES + 1; +use anyhow::Result; fn main() -> Result<()> { - SystemTestGroup::new() - // Flexible outcalls require the pay-as-you-go pricing model, which is - // still gated. On a free subnet they are available via the legacy - // pricing fallback, so the test runs on a free-cost-schedule subnet. - .with_setup(canister_http::setup_with_free_cost_schedule) - .add_parallel( - SystemTestSubGroup::new() - // Success across replication parameters and HTTP methods. - .add_test(systest!(test_default_replication)) - .add_test(systest!(test_all_nodes)) - .add_test(systest!(test_partial_responses)) - .add_test(systest!(test_intermediate_range)) - .add_test(systest!(test_post_with_body)) - .add_test(systest!(test_head_method)) - .add_test(systest!(test_put_with_deterministic_replication)) - .add_test(systest!(test_delete_with_deterministic_replication)) - .add_test(systest!(test_patch_with_deterministic_replication)) - .add_test(systest!(test_redirects_are_not_followed)) - .add_test(systest!(test_redirect_zero_no_content)) - .add_test(systest!(test_nondeterministic_responses)) - .add_test(systest!(test_single_request_nondeterministic)) - .add_test(systest!(test_min_responses_fit_max_would_exceed)) - .add_test(systest!(test_single_large_response_ok)) - .add_test(systest!(test_fire_and_forget)) - // System subnet (free for outcalls despite a normal cost schedule). - .add_test(systest!(test_system_subnet_outcall)) - // Transform behavior. - .add_test(systest!(test_transform_appends_context)) - .add_test(systest!(test_transform_sets_status_and_headers)) - .add_test(systest!(test_deterministic_transform_normalizes)) - // Synchronous validation rejections. - .add_test(systest!(test_reject_total_requests_zero)) - .add_test(systest!(test_reject_total_requests_exceed_nodes)) - .add_test(systest!(test_reject_min_exceeds_max)) - .add_test(systest!(test_reject_max_exceeds_total)) - .add_test(systest!(test_reject_put_requires_deterministic)) - .add_test(systest!(test_reject_delete_non_deterministic)) - .add_test(systest!(test_reject_url_too_long)) - .add_test(systest!(test_reject_invalid_transform_principal)) - .add_test(systest!(test_reject_header_name_too_long)) - .add_test(systest!(test_reject_header_value_too_long)) - .add_test(systest!(test_reject_request_too_large)) - // Runtime errors and adapter-level per-node failures. - .add_test(systest!(test_too_many_rejects_connection_refused)) - .add_test(systest!(test_too_many_rejects_invalid_domain)) - .add_test(systest!(test_too_many_rejects_non_https)) - .add_test(systest!(test_too_many_rejects_response_over_node_limit)) - .add_test(systest!(test_too_many_rejects_transform_over_node_limit)) - .add_test(systest!(test_too_many_rejects_composite_transform)) - .add_test(systest!(test_responses_too_large)) - // Caller-supplied per-node response size cap. - .add_test(systest!(test_custom_max_response_bytes_exceeded)) - .add_test(systest!(test_custom_max_response_bytes_within_limits)), - ) - // Fault tolerance kills a node, so it must run sequentially AFTER the - // parallel suite. - .add_test(systest!(test_fault_tolerance)) - .execute_from_args()?; - - Ok(()) -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/// Returns a runtime for one of the application-subnet nodes. -fn app_runtime(env: &TestEnv) -> Runtime { - let node = get_node_snapshots(env) - .next() - .expect("there is no application node"); - get_runtime_from_node(&node) -} - -/// Returns the proxy canister installed during setup. -fn proxy_canister<'a>(env: &TestEnv, runtime: &'a Runtime) -> Canister<'a> { - let principal_id = get_proxy_canister_id(env); - Canister::new(runtime, CanisterId::unchecked_from_principal(principal_id)) -} - -/// Returns a runtime for the (single) system-subnet node. -fn system_runtime(env: &TestEnv) -> Runtime { - let node = get_system_subnet_node_snapshots(env) - .next() - .expect("there is no system-subnet node"); - get_runtime_from_node(&node) -} - -/// Returns the proxy canister installed on the system subnet during setup. -fn system_proxy_canister<'a>(env: &TestEnv, runtime: &'a Runtime) -> Canister<'a> { - let principal_id = get_system_proxy_canister_id(env); - Canister::new(runtime, CanisterId::unchecked_from_principal(principal_id)) -} - -/// The principal of the proxy canister (the sender of the outcalls), used as the -/// valid transform principal. -fn proxy_principal(env: &TestEnv) -> Principal { - get_proxy_canister_id(env).0 -} + let group = + canister_http_flexible::shared_scenarios(canister_http::setup_with_free_cost_schedule); + canister_http_flexible::add_fault_tolerance(group).execute_from_args()?; -fn webserver_base(env: &TestEnv) -> String { - format!("https://[{}]", get_universal_vm_address(env)) -} - -/// Base flexible request arguments: a `GET` with no headers, body, transform, or -/// explicit replication. -fn get_args(url: String) -> FlexibleCanisterHttpRequestArgs { - FlexibleCanisterHttpRequestArgs { - url, - max_response_bytes: None, - headers: BoundedHttpHeaders::new(vec![]), - body: None, - method: HttpMethod::GET, - transform: None, - replication: None, - } -} - -/// Sends a flexible outcall through the proxy canister. The outer `Result` is -/// `Err` on a (retryable) transport failure of the proxy call itself; the inner -/// `Result` is the outcall outcome — the decoded [`FlexibleHttpRequestResult`] -/// on a handled outcall, or the synchronous rejection on a validation failure. -async fn send_flexible( - proxy: &Canister<'_>, - args: FlexibleCanisterHttpRequestArgs, - cycles: u64, -) -> Result> { - // A failure here is a transport-level error talking to the proxy canister, - // not an outcall outcome; return it so the retry loop can absorb blips. - let res = proxy - .update_( - "send_flexible_request", - candid_one::, (RejectionCode, String)>, FlexibleRemoteHttpRequest>, - FlexibleRemoteHttpRequest { - request: args, - cycles, - }, - ) - .await - .map_err(|err| anyhow::anyhow!("update call to proxy canister failed: {err}"))?; - - Ok(res.map(|bytes| { - Decode!(&bytes, FlexibleHttpRequestResult) - .expect("Failed to decode FlexibleHttpRequestResult") - })) -} - -/// Runs `assert_result` against the outcome of the flexible outcall built by -/// `make_args`, retrying (to absorb transient startup/network flakiness) until -/// the expected outcome is observed or the retry budget is exhausted. -fn run_flexible_test(env: TestEnv, description: &str, make_args: M, assert_result: A) -where - M: Fn(&TestEnv) -> FlexibleCanisterHttpRequestArgs, - A: Fn(Result) -> Result<()>, -{ - let logger = env.logger(); - let runtime = app_runtime(&env); - let proxy = proxy_canister(&env, &runtime); - - block_on(async { - ic_system_test_driver::retry_with_msg_async!( - description.to_string(), - &logger, - READY_WAIT_TIMEOUT, - RETRY_BACKOFF, - || async { - let args = make_args(&env); - let result = send_flexible(&proxy, args, CYCLES).await?; - assert_result(result) - } - ) - .await - .unwrap_or_else(|err| panic!("'{description}' did not reach the expected outcome: {err}")); - }); -} - -/// Returns the value of the (case-insensitive) response header `name`, if present. -fn header_value<'a>(payload: &'a CanisterHttpResponsePayload, name: &str) -> Option<&'a str> { - payload - .headers - .iter() - .find(|header| header.name.eq_ignore_ascii_case(name)) - .map(|header| header.value.as_str()) -} - -/// Asserts the result is `Ok` with a payload count in `[min, max]` and returns -/// the payloads for further inspection. -fn expect_ok( - result: Result, - min: usize, - max: usize, -) -> Result> { - match result { - Ok(FlexibleHttpRequestResult::Ok(payloads)) => { - if payloads.len() < min || payloads.len() > max { - bail!( - "expected between {min} and {max} response payloads, got {}", - payloads.len() - ); - } - Ok(payloads) - } - other => bail!("expected Ok response payloads, got: {other:?}"), - } -} - -/// Asserts every payload has the given HTTP status. -fn expect_all_status(payloads: &[CanisterHttpResponsePayload], status: u128) -> Result<()> { - for payload in payloads { - if payload.status != status { - bail!( - "expected status {status} for every payload, got {}", - payload.status - ); - } - } Ok(()) } - -/// Asserts every payload body equals `expected`. -fn expect_all_bodies(payloads: &[CanisterHttpResponsePayload], expected: &[u8]) -> Result<()> { - for payload in payloads { - if payload.body.as_slice() != expected { - bail!( - "expected body {:?}, got {:?}", - String::from_utf8_lossy(expected), - String::from_utf8_lossy(&payload.body) - ); - } - } - Ok(()) -} - -/// Asserts every payload body (interpreted as UTF-8) contains `needle`. -fn expect_all_bodies_contain(payloads: &[CanisterHttpResponsePayload], needle: &str) -> Result<()> { - for payload in payloads { - let body = String::from_utf8_lossy(&payload.body); - if !body.contains(needle) { - bail!("expected body to contain '{needle}', got {body:?}"); - } - } - Ok(()) -} - -/// Asserts every payload has no response headers (e.g. after a header-stripping -/// transform). -fn expect_all_headers_empty(payloads: &[CanisterHttpResponsePayload]) -> Result<()> { - for payload in payloads { - if !payload.headers.is_empty() { - bail!("expected no headers, got {:?}", payload.headers); - } - } - Ok(()) -} - -/// Asserts the result is a synchronous rejection with reject code -/// `CanisterReject` (argument validation fails with `CanisterRejectedMessage`, -/// a 4xx error code) and a message containing `expected_substring`. -fn expect_rejection( - result: Result, - expected_substring: &str, -) -> Result<()> { - match result { - Err((code, message)) => { - if !matches!(code, RejectionCode::CanisterReject) { - bail!("expected reject code CanisterReject, got {code:?} (message: '{message}')"); - } - if !message.contains(expected_substring) { - bail!("rejection message '{message}' does not contain '{expected_substring}'"); - } - Ok(()) - } - other => bail!("expected a synchronous rejection, got: {other:?}"), - } -} - -/// Asserts the result is a runtime `FlexibleHttpRequestResult::Err` with the -/// given global error and a message containing `expected_substring`, and returns -/// the error for further inspection. -fn expect_global_error( - result: Result, - expected: &FlexibleHttpGlobalError, - expected_substring: &str, -) -> Result { - match result { - Ok(FlexibleHttpRequestResult::Err(err)) => { - if err.global_error.as_ref() != Some(expected) { - bail!( - "expected global error {expected:?}, got {:?} (message: '{}')", - err.global_error, - err.message - ); - } - if !err.message.contains(expected_substring) { - bail!( - "error message '{}' does not contain '{expected_substring}'", - err.message - ); - } - Ok(err) - } - other => bail!("expected a FlexibleHttpRequestResult::Err, got: {other:?}"), - } -} - -/// Asserts a runtime error carries at least `min_details` per-node details, each -/// with the given `code` and a message containing `message_substring`. -fn expect_all_node_errors( - err: &FlexibleHttpRequestErr, - min_details: usize, - code: &str, - message_substring: &str, -) -> Result<()> { - if err.node_details.len() < min_details { - bail!( - "expected at least {min_details} per-node error details, got {}", - err.node_details.len() - ); - } - for detail in &err.node_details { - match &detail.error { - Some(node_error) - if node_error.code == code && node_error.message.contains(message_substring) => {} - other => bail!( - "node error {other:?} does not match code '{code}' / message '{message_substring}'" - ), - } - } - Ok(()) -} - -// --------------------------------------------------------------------------- -// Success: replication parameters and HTTP methods -// --------------------------------------------------------------------------- - -/// Default replication (`None`) returns between `min_responses` and -/// `max_responses` identical payloads for a deterministic endpoint. -fn test_default_replication(env: TestEnv) { - let logger = env.logger(); - run_flexible_test( - env, - "default replication returns min..=max identical payloads", - |env| get_args(format!("{}/ascii/hello_world", webserver_base(env))), - move |result| { - let payloads = expect_ok(result, DEFAULT_MIN_RESPONSES, DEFAULT_MAX_RESPONSES)?; - expect_all_status(&payloads, 200)?; - expect_all_bodies(&payloads, b"hello_world")?; - info!( - logger, - "default replication returned {} payloads", - payloads.len() - ); - Ok(()) - }, - ); -} - -/// Requiring all nodes (`min == max == total == n`) returns exactly `n` payloads. -fn test_all_nodes(env: TestEnv) { - run_flexible_test( - env, - "all-nodes replication returns exactly n payloads", - |env| { - let mut args = get_args(format!("{}/ascii/all", webserver_base(env))); - args.replication = Some(ReplicationCounts { - total_requests: SUBNET_NODES, - min_responses: SUBNET_NODES, - max_responses: SUBNET_NODES, - }); - args - }, - |result| { - let payloads = expect_ok(result, SUBNET_NODES as usize, SUBNET_NODES as usize)?; - expect_all_status(&payloads, 200)?; - expect_all_bodies(&payloads, b"all")?; - Ok(()) - }, - ); -} - -/// A partial range (`min < max < total`) returns between `min` and `max` payloads. -fn test_partial_responses(env: TestEnv) { - run_flexible_test( - env, - "partial replication returns min..=max payloads", - |env| { - let mut args = get_args(format!("{}/ascii/partial", webserver_base(env))); - args.replication = Some(ReplicationCounts { - total_requests: 4, - min_responses: 2, - max_responses: 3, - }); - args - }, - |result| { - let payloads = expect_ok(result, 2, 3)?; - expect_all_status(&payloads, 200)?; - expect_all_bodies(&payloads, b"partial")?; - Ok(()) - }, - ); -} - -/// A `POST` with a body succeeds. -fn test_post_with_body(env: TestEnv) { - run_flexible_test( - env, - "POST with a body succeeds", - |env| { - let mut args = get_args(format!("{}/post", webserver_base(env))); - args.method = HttpMethod::POST; - args.body = Some(b"flexible-body".to_vec()); - args - }, - |result| { - let payloads = expect_ok(result, DEFAULT_MIN_RESPONSES, DEFAULT_MAX_RESPONSES)?; - expect_all_status(&payloads, 200)?; - // The endpoint echoes the request method and body as JSON. - expect_all_bodies_contain(&payloads, "\"method\":\"POST\"")?; - expect_all_bodies_contain(&payloads, "flexible-body")?; - Ok(()) - }, - ); -} - -/// The transform function is applied to each response (here it appends the -/// context to the body and strips headers). -fn test_transform_appends_context(env: TestEnv) { - run_flexible_test( - env, - "transform is applied to each response", - |env| { - let mut args = get_args(format!("{}/ascii/base", webserver_base(env))); - args.transform = Some(TransformContext { - function: TransformFunc(candid::Func { - principal: proxy_principal(env), - method: "transform_with_context".to_string(), - }), - context: b"-ctx".to_vec(), - }); - args - }, - |result| { - let payloads = expect_ok(result, DEFAULT_MIN_RESPONSES, DEFAULT_MAX_RESPONSES)?; - expect_all_status(&payloads, 200)?; - // The transform appends the context to the body and strips headers. - expect_all_bodies(&payloads, b"base-ctx")?; - expect_all_headers_empty(&payloads)?; - Ok(()) - }, - ); -} - -/// `PUT` (like `DELETE`/`PATCH`) is only allowed with deterministic replication -/// (`min == max == total`); this exercises the allowed case. -fn test_put_with_deterministic_replication(env: TestEnv) { - run_flexible_test( - env, - "PUT with deterministic replication succeeds", - |env| { - let mut args = get_args(format!("{}/anything", webserver_base(env))); - args.method = HttpMethod::PUT; - args.replication = Some(ReplicationCounts { - total_requests: SUBNET_NODES, - min_responses: SUBNET_NODES, - max_responses: SUBNET_NODES, - }); - // Strip the echoed request headers. - args.transform = Some(TransformContext { - function: TransformFunc(candid::Func { - principal: proxy_principal(env), - method: "transform".to_string(), - }), - context: vec![], - }); - args - }, - |result| { - let payloads = expect_ok(result, SUBNET_NODES as usize, SUBNET_NODES as usize)?; - expect_all_status(&payloads, 200)?; - expect_all_bodies_contain(&payloads, "\"method\":\"PUT\"")?; - Ok(()) - }, - ); -} - -/// The adapter does not follow redirects: a redirecting endpoint yields a 303. -fn test_redirects_are_not_followed(env: TestEnv) { - run_flexible_test( - env, - "redirects are not followed (status 303)", - |env| get_args(format!("{}/redirect/10", webserver_base(env))), - |result| { - let payloads = expect_ok(result, DEFAULT_MIN_RESPONSES, DEFAULT_MAX_RESPONSES)?; - expect_all_status(&payloads, 303)?; - // The redirect target is returned in the location header, not followed. - for payload in &payloads { - match header_value(payload, "location") { - Some(location) if location.contains("relative-redirect") => {} - other => bail!("expected a redirect location header, got {other:?}"), - } - } - Ok(()) - }, - ); -} - -/// Flexible outcalls can aggregate differing responses: a non-deterministic -/// endpoint returns several (possibly different) payloads without diverging. -fn test_nondeterministic_responses(env: TestEnv) { - run_flexible_test( - env, - "non-deterministic responses are aggregated", - |env| { - let mut args = get_args(format!("{}/random", webserver_base(env))); - args.replication = Some(ReplicationCounts { - total_requests: SUBNET_NODES, - min_responses: 2, - max_responses: SUBNET_NODES, - }); - args - }, - |result| { - let payloads = expect_ok(result, 2, SUBNET_NODES as usize)?; - expect_all_status(&payloads, 200)?; - // Each body is a numeric string. - for payload in &payloads { - if payload.body.is_empty() || !payload.body.iter().all(|b| b.is_ascii_digit()) { - bail!( - "expected a numeric random body, got {:?}", - String::from_utf8_lossy(&payload.body) - ); - } - } - // Flexible outcalls keep the differing per-node responses rather than - // reconciling them into a single agreed value: collect the bodies - // into a set and confirm they are all distinct. - let unique_bodies: std::collections::HashSet<_> = - payloads.iter().map(|p| &p.body).collect(); - if unique_bodies.len() != payloads.len() { - bail!( - "expected all {} random bodies to be distinct, got {} distinct", - payloads.len(), - unique_bodies.len() - ); - } - Ok(()) - }, - ); -} - -/// A single-node request to a non-deterministic endpoint succeeds: with one -/// response there is nothing to reconcile. (The flexible replacement for the -/// legacy non-replicated mode.) -fn test_single_request_nondeterministic(env: TestEnv) { - run_flexible_test( - env, - "a single-node request to a non-deterministic endpoint succeeds", - |env| { - let mut args = get_args(format!("{}/random", webserver_base(env))); - args.replication = Some(ReplicationCounts { - total_requests: 1, - min_responses: 1, - max_responses: 1, - }); - args - }, - |result| { - let payloads = expect_ok(result, 1, 1)?; - expect_all_status(&payloads, 200)?; - if payloads[0].body.is_empty() || !payloads[0].body.iter().all(|b| b.is_ascii_digit()) { - bail!( - "expected a numeric random body, got {:?}", - String::from_utf8_lossy(&payloads[0].body) - ); - } - Ok(()) - }, - ); -} - -/// The response count is capped by the block payload limit: `min_responses` -/// responses fit within the ~2 MiB `MAX_CANISTER_HTTP_PAYLOAD_SIZE`, but -/// `max_responses` of them would exceed it, so the outcall succeeds with exactly -/// `min_responses` responses. -fn test_min_responses_fit_max_would_exceed(env: TestEnv) { - // Each node returns a 1 MB body: 2 bodies (2.0 MB) fit within the ~2 MiB - // (2_097_152 B) payload limit, but 3 (3.0 MB) exceed it. - const BODY_SIZE: usize = 1_000_000; - run_flexible_test( - env, - "response count is capped at min_responses by the payload limit", - |env| { - let mut args = get_args(format!("{}/bytes/{BODY_SIZE}", webserver_base(env))); - args.replication = Some(ReplicationCounts { - total_requests: SUBNET_NODES, - min_responses: 2, - max_responses: SUBNET_NODES, - }); - args - }, - |result| { - // Exactly min_responses (2) come back, even though max_responses (4) - // was requested. - let payloads = expect_ok(result, 2, 2)?; - expect_all_status(&payloads, 200)?; - for payload in &payloads { - if payload.body.len() != BODY_SIZE { - bail!( - "expected a {BODY_SIZE}-byte body, got {} bytes", - payload.body.len() - ); - } - } - Ok(()) - }, - ); -} - -/// A "fire-and-forget" outcall (`min_responses = max_responses = 0`) dispatches -/// the request but requires no responses, so it succeeds immediately with an -/// empty result. -fn test_fire_and_forget(env: TestEnv) { - run_flexible_test( - env, - "min = max = 0 fire-and-forget returns an empty result", - |env| { - let mut args = get_args(format!("{}/ascii/ignored", webserver_base(env))); - args.replication = Some(ReplicationCounts { - total_requests: 1, - min_responses: 0, - max_responses: 0, - }); - args - }, - |result| { - // No responses are collected or returned. - expect_ok(result, 0, 0)?; - Ok(()) - }, - ); -} - -/// A single response just under the 2 MB per-node limit succeeds. This is the -/// positive counterpart to `test_too_many_rejects_response_over_node_limit`, -/// where a response over the limit is rejected. -fn test_single_large_response_ok(env: TestEnv) { - const BODY_SIZE: usize = 1_900_000; - run_flexible_test( - env, - "a single response just under the 2 MB per-node limit succeeds", - |env| { - let mut args = get_args(format!("{}/bytes/{BODY_SIZE}", webserver_base(env))); - args.replication = Some(ReplicationCounts { - total_requests: 1, - min_responses: 1, - max_responses: 1, - }); - args - }, - |result| { - let payloads = expect_ok(result, 1, 1)?; - expect_all_status(&payloads, 200)?; - if payloads[0].body.len() != BODY_SIZE { - bail!( - "expected a {BODY_SIZE}-byte body, got {} bytes", - payloads[0].body.len() - ); - } - Ok(()) - }, - ); -} - -/// An intermediate range (`min < max == total`) returns between `min` and `max` -/// payloads. -fn test_intermediate_range(env: TestEnv) { - run_flexible_test( - env, - "intermediate replication returns min..=max payloads", - |env| { - let mut args = get_args(format!("{}/ascii/range", webserver_base(env))); - args.replication = Some(ReplicationCounts { - total_requests: 4, - min_responses: 2, - max_responses: 4, - }); - args - }, - |result| { - let payloads = expect_ok(result, 2, 4)?; - expect_all_status(&payloads, 200)?; - expect_all_bodies(&payloads, b"range")?; - Ok(()) - }, - ); -} - -/// A `HEAD` request succeeds. `HEAD` is not restricted to deterministic -/// replication (unlike `PUT`/`DELETE`/`PATCH`). -fn test_head_method(env: TestEnv) { - run_flexible_test( - env, - "HEAD request succeeds", - |env| { - let mut args = get_args(format!("{}/anything", webserver_base(env))); - args.method = HttpMethod::HEAD; - // Strip the echoed request headers. - args.transform = Some(TransformContext { - function: TransformFunc(candid::Func { - principal: proxy_principal(env), - method: "transform".to_string(), - }), - context: vec![], - }); - args - }, - |result| { - let payloads = expect_ok(result, DEFAULT_MIN_RESPONSES, DEFAULT_MAX_RESPONSES)?; - expect_all_status(&payloads, 200)?; - // A HEAD response carries no body. - expect_all_bodies(&payloads, b"")?; - Ok(()) - }, - ); -} - -/// `DELETE` with deterministic replication (`min == max == total`) succeeds. -fn test_delete_with_deterministic_replication(env: TestEnv) { - run_flexible_test( - env, - "DELETE with deterministic replication succeeds", - |env| { - let mut args = get_args(format!("{}/anything", webserver_base(env))); - args.method = HttpMethod::DELETE; - args.replication = Some(ReplicationCounts { - total_requests: 2, - min_responses: 2, - max_responses: 2, - }); - args.transform = Some(TransformContext { - function: TransformFunc(candid::Func { - principal: proxy_principal(env), - method: "transform".to_string(), - }), - context: vec![], - }); - args - }, - |result| { - let payloads = expect_ok(result, 2, 2)?; - expect_all_status(&payloads, 200)?; - expect_all_bodies_contain(&payloads, "\"method\":\"DELETE\"")?; - Ok(()) - }, - ); -} - -/// `PATCH` with deterministic replication over a sub-committee succeeds. -fn test_patch_with_deterministic_replication(env: TestEnv) { - run_flexible_test( - env, - "PATCH with deterministic replication succeeds", - |env| { - let mut args = get_args(format!("{}/anything", webserver_base(env))); - args.method = HttpMethod::PATCH; - args.replication = Some(ReplicationCounts { - total_requests: 2, - min_responses: 2, - max_responses: 2, - }); - args.transform = Some(TransformContext { - function: TransformFunc(candid::Func { - principal: proxy_principal(env), - method: "transform".to_string(), - }), - context: vec![], - }); - args - }, - |result| { - let payloads = expect_ok(result, 2, 2)?; - expect_all_status(&payloads, 200)?; - expect_all_bodies_contain(&payloads, "\"method\":\"PATCH\"")?; - Ok(()) - }, - ); -} - -/// A `redirect/0` endpoint returns a 204 (No Content) that is not followed. -fn test_redirect_zero_no_content(env: TestEnv) { - run_flexible_test( - env, - "redirect/0 returns 204", - |env| get_args(format!("{}/redirect/0", webserver_base(env))), - |result| { - let payloads = expect_ok(result, DEFAULT_MIN_RESPONSES, DEFAULT_MAX_RESPONSES)?; - expect_all_status(&payloads, 204)?; - // 204 No Content carries no body. - expect_all_bodies(&payloads, b"")?; - Ok(()) - }, - ); -} - -/// A transform can set the status, headers, and body of every response. -fn test_transform_sets_status_and_headers(env: TestEnv) { - run_flexible_test( - env, - "transform can set status, headers and body", - |env| { - let mut args = get_args(format!("{}/ascii/ignored", webserver_base(env))); - args.transform = Some(TransformContext { - function: TransformFunc(candid::Func { - principal: proxy_principal(env), - method: "test_transform".to_string(), - }), - context: b"transform_context".to_vec(), - }); - args - }, - |result| { - let payloads = expect_ok(result, DEFAULT_MIN_RESPONSES, DEFAULT_MAX_RESPONSES)?; - expect_all_status(&payloads, 202)?; - // The transform replaces the body with the context and sets a fixed - // pair of headers (the caller is the management canister). - expect_all_bodies(&payloads, b"transform_context")?; - for payload in &payloads { - if header_value(payload, "hello") != Some("bonjour") { - bail!( - "expected header hello=bonjour, got {:?}", - header_value(payload, "hello") - ); - } - if header_value(payload, "caller") != Some("aaaaa-aa") { - bail!( - "expected header caller=aaaaa-aa, got {:?}", - header_value(payload, "caller") - ); - } - } - Ok(()) - }, - ); -} - -/// A deterministic transform normalizes a non-deterministic endpoint so every -/// node agrees on an identical response. -fn test_deterministic_transform_normalizes(env: TestEnv) { - run_flexible_test( - env, - "a deterministic transform normalizes a non-deterministic endpoint", - |env| { - let mut args = get_args(format!("{}/random", webserver_base(env))); - args.replication = Some(ReplicationCounts { - total_requests: SUBNET_NODES, - min_responses: SUBNET_NODES, - max_responses: SUBNET_NODES, - }); - args.transform = Some(TransformContext { - function: TransformFunc(candid::Func { - principal: proxy_principal(env), - method: "deterministic_transform".to_string(), - }), - context: vec![], - }); - args - }, - |result| { - let payloads = expect_ok(result, SUBNET_NODES as usize, SUBNET_NODES as usize)?; - expect_all_status(&payloads, 200)?; - // Every node is normalized to the same body with no headers. - expect_all_bodies(&payloads, b"deterministic")?; - expect_all_headers_empty(&payloads)?; - Ok(()) - }, - ); -} - -// --------------------------------------------------------------------------- -// Synchronous validation rejections -// --------------------------------------------------------------------------- - -fn test_reject_total_requests_zero(env: TestEnv) { - run_flexible_test( - env, - "total_requests = 0 is rejected", - |env| { - let mut args = get_args(format!("{}/ascii/x", webserver_base(env))); - args.replication = Some(ReplicationCounts { - total_requests: 0, - min_responses: 0, - max_responses: 0, - }); - args - }, - |result| expect_rejection(result, "total_requests (0) must be at least 1"), - ); -} - -fn test_reject_total_requests_exceed_nodes(env: TestEnv) { - run_flexible_test( - env, - "total_requests > number of nodes is rejected", - |env| { - let mut args = get_args(format!("{}/ascii/x", webserver_base(env))); - args.replication = Some(ReplicationCounts { - total_requests: SUBNET_NODES + 1, - min_responses: 1, - max_responses: 1, - }); - args - }, - |result| expect_rejection(result, "must not exceed the number of available nodes (4)"), - ); -} - -fn test_reject_min_exceeds_max(env: TestEnv) { - run_flexible_test( - env, - "min_responses > max_responses is rejected", - |env| { - let mut args = get_args(format!("{}/ascii/x", webserver_base(env))); - args.replication = Some(ReplicationCounts { - total_requests: 4, - min_responses: 3, - max_responses: 2, - }); - args - }, - |result| { - expect_rejection( - result, - "min_responses (3) must not exceed max_responses (2)", - ) - }, - ); -} - -fn test_reject_max_exceeds_total(env: TestEnv) { - run_flexible_test( - env, - "max_responses > total_requests is rejected", - |env| { - let mut args = get_args(format!("{}/ascii/x", webserver_base(env))); - args.replication = Some(ReplicationCounts { - total_requests: 2, - min_responses: 1, - max_responses: 3, - }); - args - }, - |result| { - expect_rejection( - result, - "max_responses (3) must not exceed total_requests (2)", - ) - }, - ); -} - -fn test_reject_put_requires_deterministic(env: TestEnv) { - run_flexible_test( - env, - "PUT with non-deterministic replication is rejected", - |env| { - // Default replication has min < total, which is not allowed for PUT. - let mut args = get_args(format!("{}/anything", webserver_base(env))); - args.method = HttpMethod::PUT; - args - }, - |result| expect_rejection(result, "min_responses = max_responses = total_requests"), - ); -} - -fn test_reject_url_too_long(env: TestEnv) { - run_flexible_test( - env, - "an over-long url is rejected", - |env| { - // MAX_CANISTER_HTTP_URL_SIZE is 8192. - let long_path = "a".repeat(8200); - get_args(format!("{}/ascii/{long_path}", webserver_base(env))) - }, - |result| expect_rejection(result, "exceeds 8192"), - ); -} - -fn test_reject_invalid_transform_principal(env: TestEnv) { - run_flexible_test( - env, - "a transform referencing another principal is rejected", - |env| { - let mut args = get_args(format!("{}/ascii/x", webserver_base(env))); - // The transform must reference the calling (proxy) canister; the - // management canister principal does not. - args.transform = Some(TransformContext { - function: TransformFunc(candid::Func { - principal: Principal::management_canister(), - method: "transform".to_string(), - }), - context: vec![], - }); - args - }, - |result| expect_rejection(result, "transform principal id expected to be"), - ); -} - -/// `DELETE` (like `PUT`/`PATCH`) with explicit but non-equal replication counts -/// is rejected (distinct from the default-replication case). -fn test_reject_delete_non_deterministic(env: TestEnv) { - run_flexible_test( - env, - "DELETE with non-equal replication counts is rejected", - |env| { - let mut args = get_args(format!("{}/anything", webserver_base(env))); - args.method = HttpMethod::DELETE; - args.replication = Some(ReplicationCounts { - total_requests: 4, - min_responses: 3, - max_responses: 4, - }); - args - }, - |result| expect_rejection(result, "min_responses = max_responses = total_requests"), - ); -} - -fn test_reject_header_name_too_long(env: TestEnv) { - run_flexible_test( - env, - "an over-long header name is rejected", - |env| { - let mut args = get_args(format!("{}/ascii/x", webserver_base(env))); - // Name of 8193 bytes: the element (8193) is within the candid bound - // (16384) so it decodes, but exceeds the 8192 header name/value limit. - args.headers = BoundedHttpHeaders::new(vec![HttpHeader { - name: "a".repeat(8193), - value: String::new(), - }]); - args - }, - |result| { - expect_rejection( - result, - "number of bytes to represent some http header name 8193 exceeds 8192", - ) - }, - ); -} - -fn test_reject_header_value_too_long(env: TestEnv) { - run_flexible_test( - env, - "an over-long header value is rejected", - |env| { - let mut args = get_args(format!("{}/ascii/x", webserver_base(env))); - args.headers = BoundedHttpHeaders::new(vec![HttpHeader { - name: "name".to_string(), - value: "b".repeat(8193), - }]); - args - }, - |result| { - expect_rejection( - result, - "number of bytes to represent some http header value 8193 exceeds 8192", - ) - }, - ); -} - -/// A request whose headers plus body exceed the 2 MB request-size limit -/// (`MAX_CANISTER_HTTP_REQUEST_BYTES`) is rejected. -fn test_reject_request_too_large(env: TestEnv) { - run_flexible_test( - env, - "a request exceeding the 2 MB size limit is rejected", - |env| { - let mut args = get_args(format!("{}/ascii/x", webserver_base(env))); - // One byte over the 2_000_000-byte limit (no headers). - args.body = Some(vec![0_u8; 2_000_001]); - args - }, - |result| expect_rejection(result, "exceeds 2000000"), - ); -} - -// --------------------------------------------------------------------------- -// Runtime errors and adapter-level per-node failures -// --------------------------------------------------------------------------- - -/// When enough nodes fail to reach the endpoint (here: connection refused on a -/// closed port) `min_responses` cannot be met and the outcall reports -/// `too_many_rejects` with per-node details. -fn test_too_many_rejects_connection_refused(env: TestEnv) { - run_flexible_test( - env, - "connection refused on all nodes yields too_many_rejects", - |env| { - // Port 9090 on the webserver is closed => connection refused. - get_args(format!("https://[{}]:9090", get_universal_vm_address(env))) - }, - |result| { - let err = expect_global_error( - result, - &FlexibleHttpGlobalError::TooManyRejects(candid::Reserved), - "Too many rejects", - )?; - // Every node reports a transient connection failure whose message - // carries the refused connection. - expect_all_node_errors( - &err, - MIN_REJECT_DETAILS, - "SysTransient", - "Connection refused", - )?; - Ok(()) - }, - ); -} - -/// An unresolvable domain fails at the adapter on every node, again yielding -/// `too_many_rejects`. -fn test_too_many_rejects_invalid_domain(env: TestEnv) { - run_flexible_test( - env, - "an invalid domain yields too_many_rejects", - |_env| get_args("https://xwWPqqbNqxxHmLXdguF4DN9xGq22nczV.invalid".to_string()), - |result| { - let err = expect_global_error( - result, - &FlexibleHttpGlobalError::TooManyRejects(candid::Reserved), - "Too many rejects", - )?; - // DNS resolution fails on every node during connection setup. - expect_all_node_errors(&err, MIN_REJECT_DETAILS, "SysTransient", "Connecting to")?; - Ok(()) - }, - ); -} - -/// The adapter enforces HTTPS: a non-`https` url is rejected on every node, so -/// the outcall reports `too_many_rejects`. -fn test_too_many_rejects_non_https(env: TestEnv) { - run_flexible_test( - env, - "a non-https url is rejected on every node", - |env| get_args(format!("http://[{}]", get_universal_vm_address(env))), - |result| { - let err = expect_global_error( - result, - &FlexibleHttpGlobalError::TooManyRejects(candid::Reserved), - "Too many rejects", - )?; - expect_all_node_errors( - &err, - MIN_REJECT_DETAILS, - "SysFatal", - "Url need to specify https scheme", - )?; - Ok(()) - }, - ); -} - -/// When the aggregated responses are too large to fit in a block, the outcall -/// reports `responses_too_large`. Each node returns a ~1 MB body (below the -/// per-node 2 MB limit), but `min_responses` (3) of them exceed the ~2 MiB -/// block payload limit. -fn test_responses_too_large(env: TestEnv) { - run_flexible_test( - env, - "oversized aggregated responses yield responses_too_large", - |env| get_args(format!("{}/bytes/1000000", webserver_base(env))), - |result| { - let err = expect_global_error( - result, - &FlexibleHttpGlobalError::ResponsesTooLarge(candid::Reserved), - "Responses too large", - )?; - // Each node returned an OK response; the details report their sizes. - expect_all_node_errors(&err, DEFAULT_MIN_RESPONSES, "ok", "bytes")?; - Ok(()) - }, - ); -} - -/// A single per-node response that exceeds the 2 MB per-node limit is rejected -/// by the adapter (download limit), so every node rejects and the outcall -/// reports `too_many_rejects`. -fn test_too_many_rejects_response_over_node_limit(env: TestEnv) { - run_flexible_test( - env, - "a per-node response over the 2 MB limit yields too_many_rejects", - |env| get_args(format!("{}/bytes/2100000", webserver_base(env))), - |result| { - let err = expect_global_error( - result, - &FlexibleHttpGlobalError::TooManyRejects(candid::Reserved), - "Too many rejects", - )?; - expect_all_node_errors( - &err, - MIN_REJECT_DETAILS, - "SysFatal", - "Http body exceeds size limit of 2000000 bytes", - )?; - Ok(()) - }, - ); -} - -/// A transform whose output exceeds the 2 MB per-node limit is rejected by the -/// adapter (transform-output limit) on every node, again yielding -/// `too_many_rejects`. -fn test_too_many_rejects_transform_over_node_limit(env: TestEnv) { - run_flexible_test( - env, - "a transform output over the 2 MB limit yields too_many_rejects", - |env| { - let mut args = get_args(format!("{}/bytes/16", webserver_base(env))); - args.transform = Some(TransformContext { - function: TransformFunc(candid::Func { - principal: proxy_principal(env), - method: "bloat_transform".to_string(), - }), - context: vec![], - }); - args - }, - |result| { - let err = expect_global_error( - result, - &FlexibleHttpGlobalError::TooManyRejects(candid::Reserved), - "Too many rejects", - )?; - expect_all_node_errors( - &err, - MIN_REJECT_DETAILS, - "SysFatal", - "Transformed http response exceeds limit: 2000000", - )?; - Ok(()) - }, - ); -} - -/// A composite query cannot be used as a transform: it fails per node, so every -/// node rejects and the outcall reports `too_many_rejects`. -fn test_too_many_rejects_composite_transform(env: TestEnv) { - run_flexible_test( - env, - "a composite-query transform yields too_many_rejects", - |env| { - let mut args = get_args(format!("{}/ascii/x", webserver_base(env))); - args.transform = Some(TransformContext { - function: TransformFunc(candid::Func { - principal: proxy_principal(env), - method: "test_composite_transform".to_string(), - }), - context: vec![], - }); - args - }, - |result| { - let err = expect_global_error( - result, - &FlexibleHttpGlobalError::TooManyRejects(candid::Reserved), - "Too many rejects", - )?; - // The transform query is rejected on every node. - expect_all_node_errors( - &err, - MIN_REJECT_DETAILS, - "CanisterError", - "Composite query cannot be used as transform", - )?; - Ok(()) - }, - ); -} - -// --------------------------------------------------------------------------- -// Custom `max_response_bytes` (per-node response size cap) -// --------------------------------------------------------------------------- - -/// A caller-supplied `max_response_bytes` caps each node's response size. A -/// response larger than a small custom cap is rejected by the adapter on every -/// node, yielding `too_many_rejects` — proving the caller's cap (not just the -/// 2 MB default) is plumbed through per node. -fn test_custom_max_response_bytes_exceeded(env: TestEnv) { - const MAX_RESPONSE_BYTES: u64 = 1_000; - run_flexible_test( - env, - "a response over a small custom max_response_bytes yields too_many_rejects", - |env| { - let mut args = get_args(format!("{}/bytes/2000", webserver_base(env))); - args.max_response_bytes = Some(MAX_RESPONSE_BYTES); - args - }, - |result| { - let err = expect_global_error( - result, - &FlexibleHttpGlobalError::TooManyRejects(candid::Reserved), - "Too many rejects", - )?; - expect_all_node_errors( - &err, - MIN_REJECT_DETAILS, - "SysFatal", - &format!("Http body exceeds size limit of {MAX_RESPONSE_BYTES} bytes"), - )?; - Ok(()) - }, - ); -} - -/// A response that fits within a caller-supplied `max_response_bytes` (but that -/// would be rejected under a smaller cap) succeeds normally. -fn test_custom_max_response_bytes_within_limits(env: TestEnv) { - const BODY_SIZE: usize = 50_000; - run_flexible_test( - env, - "a response within a custom max_response_bytes succeeds", - |env| { - let mut args = get_args(format!("{}/bytes/{BODY_SIZE}", webserver_base(env))); - // Comfortably above the response size, but well below the 2 MB max. - args.max_response_bytes = Some(100_000); - args - }, - |result| { - let payloads = expect_ok(result, DEFAULT_MIN_RESPONSES, DEFAULT_MAX_RESPONSES)?; - expect_all_status(&payloads, 200)?; - for payload in &payloads { - if payload.body.len() != BODY_SIZE { - bail!( - "expected a {BODY_SIZE}-byte body, got {} bytes", - payload.body.len() - ); - } - } - Ok(()) - }, - ); -} - -// --------------------------------------------------------------------------- -// System subnet -// --------------------------------------------------------------------------- - -/// Flexible outcalls work on a system subnet too: system subnets are free for -/// HTTP outcalls (despite a normal cost schedule), so the request is routed -/// through legacy pricing. The system subnet has a single node, so exactly one -/// response comes back. -fn test_system_subnet_outcall(env: TestEnv) { - let logger = env.logger(); - let runtime = system_runtime(&env); - let proxy = system_proxy_canister(&env, &runtime); - - block_on(async { - ic_system_test_driver::retry_with_msg_async!( - "flexible outcall on a system subnet succeeds".to_string(), - &logger, - READY_WAIT_TIMEOUT, - RETRY_BACKOFF, - || async { - let args = get_args(format!("{}/ascii/system", webserver_base(&env))); - let result = send_flexible(&proxy, args, CYCLES).await?; - // A single-node subnet returns exactly one response. - let payloads = expect_ok(result, 1, 1)?; - expect_all_status(&payloads, 200)?; - expect_all_bodies(&payloads, b"system")?; - Ok(()) - } - ) - .await - .expect("flexible outcall on the system subnet did not succeed"); - }); -} - -// --------------------------------------------------------------------------- -// Fault tolerance (destructive: runs sequentially after the parallel suite) -// --------------------------------------------------------------------------- - -/// A flexible outcall with `min_responses < total_requests` still succeeds when -/// one of the committee's nodes is down — the defining reliability property of -/// flexible outcalls. This test kills a node and leaves it down, so it is -/// registered as a trailing sequential test rather than in the parallel suite -/// (nothing must run on the crippled subnet afterwards). -fn test_fault_tolerance(env: TestEnv) { - let logger = env.logger(); - - let mut nodes = get_node_snapshots(&env); - let killed_node = nodes.next().expect("no application nodes"); - let healthy_node = nodes.next().expect("need at least two application nodes"); - - // The proxy canister lives on the subnet, so reach it through a node that - // stays up. - let runtime = get_runtime_from_node(&healthy_node); - let proxy = proxy_canister(&env, &runtime); - - info!(logger, "Killing one application node."); - killed_node.vm().kill(); - killed_node - .await_status_is_unavailable() - .expect("the killed node did not become unavailable"); - info!( - logger, - "Node is down; a flexible outcall requiring fewer responses than nodes must still succeed." - ); - - block_on(async { - ic_system_test_driver::retry_with_msg_async!( - "flexible outcall succeeds with a node down".to_string(), - &logger, - READY_WAIT_TIMEOUT, - RETRY_BACKOFF, - || async { - let mut args = get_args(format!("{}/ascii/tolerate", webserver_base(&env))); - // Target all nodes but require only 2 responses: the surviving - // nodes are enough to meet min_responses. - args.replication = Some(ReplicationCounts { - total_requests: SUBNET_NODES, - min_responses: 2, - max_responses: SUBNET_NODES, - }); - // Attaching cycles should be possible, even on free subnets. - let result = send_flexible(&proxy, args, 1000).await?; - // At most the surviving nodes (n - 1) can respond. - let payloads = expect_ok(result, 2, (SUBNET_NODES - 1) as usize)?; - expect_all_status(&payloads, 200)?; - expect_all_bodies(&payloads, b"tolerate")?; - Ok(()) - } - ) - .await - .expect("the flexible outcall did not succeed while a node was down"); - }); -} diff --git a/rs/types/management_canister_types/src/http.rs b/rs/types/management_canister_types/src/http.rs index 2ee699a273e9..3869c67f3c58 100644 --- a/rs/types/management_canister_types/src/http.rs +++ b/rs/types/management_canister_types/src/http.rs @@ -69,7 +69,8 @@ pub const DEFAULT_HTTP_OUTCALLS_PRICING_VERSION: u32 = PRICING_VERSION_LEGACY; /// A set of all allowed pricing versions for HTTP outcalls. /// /// If the pricing version provided in the request is not in this set, the request will use the default pricing version. -pub const ALLOWED_HTTP_OUTCALLS_PRICING_VERSIONS: &[u32] = &[PRICING_VERSION_LEGACY]; +pub const ALLOWED_HTTP_OUTCALLS_PRICING_VERSIONS: &[u32] = + &[PRICING_VERSION_LEGACY, PRICING_VERSION_PAY_AS_YOU_GO]; /// HTTP headers bounded by total size. pub type BoundedHttpHeaders = BoundedVec<