From d0919ce932e8aedd2d40a20a1790831e109ef9d9 Mon Sep 17 00:00:00 2001 From: Arshavir Ter-Gabrielyan Date: Wed, 15 Jul 2026 12:29:41 +0200 Subject: [PATCH 1/4] test(execution): reject mgmt canister /call under queries-only delegation Adds a system test that authenticates with a delegation chain whose `permissions` field is set to `queries`, then iterates over every management canister method and POSTs an ingress `/call` to `aaaaa-aa`. Every call must be rejected with `400 Bad Request` and a body mentioning that update calls are not permitted. The test is a self-contained raw-HTTP client because `ic-agent 0.47.3` does not expose the `permissions` field on `Delegation`; only the newer 0.48.x line does. Once the workspace bumps to 0.48+ this can migrate to `ic-agent::Delegation`/`DelegatedIdentity` directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- rs/tests/execution/BUILD.bazel | 18 +++ .../execution/queries_only_delegation_test.rs | 149 ++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 rs/tests/execution/queries_only_delegation_test.rs diff --git a/rs/tests/execution/BUILD.bazel b/rs/tests/execution/BUILD.bazel index 78029de033ca..b99ad3c8cbd1 100644 --- a/rs/tests/execution/BUILD.bazel +++ b/rs/tests/execution/BUILD.bazel @@ -176,6 +176,24 @@ system_test_nns( ], ) +system_test( + name = "queries_only_delegation_test", + cpus = MIN_LOCAL_CPUS + 1 * DEFAULT_VCPUS_PER_VM, # 1 IC Node VM * 6 vCPUs. + deps = [ + # Keep sorted. + "//rs/registry/subnet_type", + "//rs/tests/driver:ic-system-test-driver", + "//rs/types/management_canister_types", + "//rs/types/types", + "@crate_index//:anyhow", + "@crate_index//:ic-agent", + "@crate_index//:reqwest", + "@crate_index//:serde_cbor", + "@crate_index//:slog", + "@crate_index//:strum", + ], +) + system_test( name = "sender_info_test", cpus = MIN_LOCAL_CPUS + 1 * DEFAULT_VCPUS_PER_VM, # 1 IC Node VM with 6 vCPUs. diff --git a/rs/tests/execution/queries_only_delegation_test.rs b/rs/tests/execution/queries_only_delegation_test.rs new file mode 100644 index 000000000000..3226099da559 --- /dev/null +++ b/rs/tests/execution/queries_only_delegation_test.rs @@ -0,0 +1,149 @@ +/* tag::catalog[] +end::catalog[] */ + +//! System test for queries-only delegation permissions on the management +//! canister. +//! +//! When a caller authenticates via a delegation chain that carries +//! `permissions = queries`, the ingress validator rejects every request +//! sent to the `/call` endpoint. This test iterates over every method of +//! the management canister and verifies that a call to `aaaaa-aa` is +//! rejected with a `400 Bad Request` whose body mentions the delegation +//! restriction. + +use anyhow::Result; +use ic_agent::Identity; +use ic_management_canister_types_private::Method as Ic00Method; +use ic_registry_subnet_type::SubnetType; +use ic_system_test_driver::driver::group::SystemTestGroup; +use ic_system_test_driver::driver::ic::{InternetComputer, Subnet}; +use ic_system_test_driver::driver::test_env::TestEnv; +use ic_system_test_driver::driver::test_env_api::{GetFirstHealthyNodeSnapshot, HasPublicApiUrl}; +use ic_system_test_driver::systest; +use ic_system_test_driver::util::{block_on, expiry_time, random_ed25519_identity}; +use ic_types::CanisterId; +use ic_types::crypto::Signable; +use ic_types::messages::{ + Blob, Delegation, DelegationPermissions, HttpCallContent, HttpCanisterUpdate, + HttpRequestEnvelope, MessageId, SignedDelegation, +}; +use ic_types::time::Time; +use reqwest::StatusCode; +use slog::info; +use strum::IntoEnumIterator; + +const UPDATE_REJECTED_MSG: &str = "Update calls are not permitted"; + +fn main() -> Result<()> { + SystemTestGroup::new() + .with_setup(setup) + .add_test(systest!( + management_canister_calls_rejected_with_queries_only_delegation + )) + .execute_from_args()?; + Ok(()) +} + +pub fn setup(env: TestEnv) { + InternetComputer::new() + .add_subnet(Subnet::fast_single_node(SubnetType::Application)) + .setup_and_start(&env) + .expect("failed to setup IC under test"); +} + +pub fn management_canister_calls_rejected_with_queries_only_delegation(env: TestEnv) { + let logger = env.logger(); + let node = env.get_first_healthy_application_node_snapshot(); + block_on(async move { + let node_url = node.get_public_url(); + let client = reqwest::Client::new(); + + // Root identity of the delegation chain — this is the principal that + // shows up as `sender` on the ingress. + let sender = random_ed25519_identity(); + let sender_pubkey_der = sender.public_key().expect("sender has a public key"); + let sender_principal = sender.sender().expect("sender principal"); + + // Session key the sender delegates to, restricted to queries only. + let session = random_ed25519_identity(); + let session_pubkey_der = session.public_key().expect("session has a public key"); + + // The delegation covers all outgoing requests in this test; use a + // uniform ingress-expiry / delegation-expiration so both the + // delegation and the requests are valid concurrently. + let expiry_ns = expiry_time().as_nanos() as u64; + let expiration = Time::from_nanos_since_unix_epoch(expiry_ns); + + let delegation = Delegation::new(session_pubkey_der, expiration) + .with_permissions(DelegationPermissions::Queries); + let delegation_sig_bytes = sender + .sign_arbitrary(&delegation.as_signed_bytes()) + .expect("sender signs delegation") + .signature + .expect("delegation signature bytes"); + let signed_delegation = SignedDelegation::new(delegation, delegation_sig_bytes); + + let url = format!("{}api/v2/canister/{}/call", node_url, CanisterId::ic_00()); + + for method in Ic00Method::iter() { + let content = HttpCallContent::Call { + update: HttpCanisterUpdate { + canister_id: Blob(CanisterId::ic_00().get().to_vec()), + method_name: method.to_string(), + // The request is rejected during validation before the + // replica ever looks at these bytes, so an empty payload + // is enough. + arg: Blob(vec![]), + sender: Blob(sender_principal.as_slice().to_vec()), + ingress_expiry: expiry_ns, + nonce: None, + sender_info: None, + }, + }; + let message_id = MessageId::from(content.representation_independent_hash()); + let session_sig_bytes = session + .sign_arbitrary(&message_id.as_signed_bytes()) + .expect("session signs request id") + .signature + .expect("session signature bytes"); + + let envelope = HttpRequestEnvelope { + content, + sender_pubkey: Some(Blob(sender_pubkey_der.clone())), + sender_sig: Some(Blob(session_sig_bytes)), + sender_delegation: Some(vec![signed_delegation.clone()]), + }; + let body = serde_cbor::to_vec(&envelope).expect("serialize envelope"); + + let response = client + .post(&url) + .header("Content-Type", "application/cbor") + .body(body) + .send() + .await + .expect("HTTP POST failed"); + let status = response.status(); + let body_text = response.text().await.unwrap_or_default(); + info!( + logger, + "method={} status={} body={:?}", method, status, body_text + ); + + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "management canister method {} expected 400, got {}: {}", + method, + status, + body_text + ); + assert!( + body_text.contains(UPDATE_REJECTED_MSG), + "management canister method {} expected body to contain {:?}, got: {}", + method, + UPDATE_REJECTED_MSG, + body_text + ); + } + }); +} From 67c6beb4942dd9ac09c461a76fdd0051e5089210 Mon Sep 17 00:00:00 2001 From: Arshavir Ter-Gabrielyan Date: Wed, 15 Jul 2026 13:54:34 +0200 Subject: [PATCH 2/4] test(execution): tighten assertion to check the rejection reason The previous substring "Update calls are not permitted" is the prefix of the validator error; matching only it would also accept any future rejection whose message happens to start that way. Match the tail that identifies the queries-only delegation restriction explicitly: `a delegation restricts the sender to query calls (permissions = "queries")`. This anchors the test to the exact `RequestValidationError::UpdateCallNotPermittedByDelegation` message (see `rs/validator/ingress_message/src/lib.rs`). Co-Authored-By: Claude Opus 4.7 (1M context) --- rs/tests/execution/queries_only_delegation_test.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/rs/tests/execution/queries_only_delegation_test.rs b/rs/tests/execution/queries_only_delegation_test.rs index 3226099da559..caabb9203adf 100644 --- a/rs/tests/execution/queries_only_delegation_test.rs +++ b/rs/tests/execution/queries_only_delegation_test.rs @@ -32,7 +32,15 @@ use reqwest::StatusCode; use slog::info; use strum::IntoEnumIterator; -const UPDATE_REJECTED_MSG: &str = "Update calls are not permitted"; +// Substring of the validator error emitted by +// `RequestValidationError::UpdateCallNotPermittedByDelegation` (defined in +// `rs/validator/ingress_message/src/lib.rs`). Matching this string, rather +// than a generic prefix like "Update calls are not permitted", verifies +// that the rejection is specifically due to the queries-only delegation +// restriction and not some other reason (e.g. a bad signature or expired +// ingress). +const UPDATE_REJECTED_MSG: &str = + "a delegation restricts the sender to query calls (permissions = \"queries\")"; fn main() -> Result<()> { SystemTestGroup::new() From 96cf4ffccbbfe5766c944171e7dbac603bc6c786 Mon Sep 17 00:00:00 2001 From: Arshavir Ter-Gabrielyan Date: Wed, 22 Jul 2026 15:04:53 +0200 Subject: [PATCH 3/4] refactor(queries_only_delegation_test): use ic-agent DelegatedIdentity Workspace is now on `ic-agent 0.49.0`, which exposes the `permissions` field on `Delegation` and `DelegationPermissions::{Queries, All}` under `ic_agent::identity`. Rebuild the test on top of the standard agent: * construct the `Delegation` (with `permissions = Some(Queries)`) directly using `ic-agent`'s public types; * sign it with the sender's `Identity::sign_arbitrary`; * wrap `sender_pubkey` + inner session identity + signed delegation in `DelegatedIdentity::new_unchecked` and hand it to `agent_with_identity` (which internally calls `fetch_root_key`); * dispatch each `Ic00Method` through `agent.update(...).call_and_wait()` and assert the returned `AgentError::HttpError` carries a `400` and a body matching the queries-only-restriction reason. Drops the manual `HttpRequestEnvelope` / `SignedDelegation` construction and the reqwest / serde_cbor deps. Co-Authored-By: Claude Opus 4.7 (1M context) --- rs/tests/execution/BUILD.bazel | 3 - .../execution/queries_only_delegation_test.rs | 150 ++++++++---------- 2 files changed, 62 insertions(+), 91 deletions(-) diff --git a/rs/tests/execution/BUILD.bazel b/rs/tests/execution/BUILD.bazel index b99ad3c8cbd1..55088936a041 100644 --- a/rs/tests/execution/BUILD.bazel +++ b/rs/tests/execution/BUILD.bazel @@ -184,11 +184,8 @@ system_test( "//rs/registry/subnet_type", "//rs/tests/driver:ic-system-test-driver", "//rs/types/management_canister_types", - "//rs/types/types", "@crate_index//:anyhow", "@crate_index//:ic-agent", - "@crate_index//:reqwest", - "@crate_index//:serde_cbor", "@crate_index//:slog", "@crate_index//:strum", ], diff --git a/rs/tests/execution/queries_only_delegation_test.rs b/rs/tests/execution/queries_only_delegation_test.rs index caabb9203adf..8753db15bf62 100644 --- a/rs/tests/execution/queries_only_delegation_test.rs +++ b/rs/tests/execution/queries_only_delegation_test.rs @@ -8,32 +8,30 @@ end::catalog[] */ //! `permissions = queries`, the ingress validator rejects every request //! sent to the `/call` endpoint. This test iterates over every method of //! the management canister and verifies that a call to `aaaaa-aa` is -//! rejected with a `400 Bad Request` whose body mentions the delegation -//! restriction. +//! rejected with a `400 Bad Request` whose body identifies the +//! queries-only restriction. use anyhow::Result; +use ic_agent::AgentError; use ic_agent::Identity; -use ic_management_canister_types_private::Method as Ic00Method; +use ic_agent::agent::agent_error::HttpErrorPayload; +use ic_agent::export::Principal; +use ic_agent::identity::{DelegatedIdentity, Delegation, DelegationPermissions, SignedDelegation}; +use ic_management_canister_types_private::{IC_00, Method as Ic00Method}; use ic_registry_subnet_type::SubnetType; use ic_system_test_driver::driver::group::SystemTestGroup; use ic_system_test_driver::driver::ic::{InternetComputer, Subnet}; use ic_system_test_driver::driver::test_env::TestEnv; use ic_system_test_driver::driver::test_env_api::{GetFirstHealthyNodeSnapshot, HasPublicApiUrl}; use ic_system_test_driver::systest; -use ic_system_test_driver::util::{block_on, expiry_time, random_ed25519_identity}; -use ic_types::CanisterId; -use ic_types::crypto::Signable; -use ic_types::messages::{ - Blob, Delegation, DelegationPermissions, HttpCallContent, HttpCanisterUpdate, - HttpRequestEnvelope, MessageId, SignedDelegation, +use ic_system_test_driver::util::{ + agent_with_identity, block_on, expiry_time, random_ed25519_identity, }; -use ic_types::time::Time; -use reqwest::StatusCode; use slog::info; use strum::IntoEnumIterator; // Substring of the validator error emitted by -// `RequestValidationError::UpdateCallNotPermittedByDelegation` (defined in +// `RequestValidationError::UpdateCallNotPermittedByDelegation` (see // `rs/validator/ingress_message/src/lib.rs`). Matching this string, rather // than a generic prefix like "Update calls are not permitted", verifies // that the rejection is specifically due to the queries-only delegation @@ -63,95 +61,71 @@ pub fn management_canister_calls_rejected_with_queries_only_delegation(env: Test let logger = env.logger(); let node = env.get_first_healthy_application_node_snapshot(); block_on(async move { - let node_url = node.get_public_url(); - let client = reqwest::Client::new(); - - // Root identity of the delegation chain — this is the principal that - // shows up as `sender` on the ingress. + // Root identity of the delegation chain — this is the principal the + // ingress is authenticated as. let sender = random_ed25519_identity(); let sender_pubkey_der = sender.public_key().expect("sender has a public key"); - let sender_principal = sender.sender().expect("sender principal"); // Session key the sender delegates to, restricted to queries only. let session = random_ed25519_identity(); let session_pubkey_der = session.public_key().expect("session has a public key"); - // The delegation covers all outgoing requests in this test; use a - // uniform ingress-expiry / delegation-expiration so both the - // delegation and the requests are valid concurrently. - let expiry_ns = expiry_time().as_nanos() as u64; - let expiration = Time::from_nanos_since_unix_epoch(expiry_ns); - - let delegation = Delegation::new(session_pubkey_der, expiration) - .with_permissions(DelegationPermissions::Queries); - let delegation_sig_bytes = sender - .sign_arbitrary(&delegation.as_signed_bytes()) - .expect("sender signs delegation") + // Delegation from sender → session with permissions = queries. + let delegation = Delegation { + pubkey: session_pubkey_der, + expiration: expiry_time().as_nanos() as u64, + targets: None, + permissions: Some(DelegationPermissions::Queries), + }; + let signature = sender + .sign_arbitrary(&delegation.signable()) + .expect("sender signs the delegation") .signature .expect("delegation signature bytes"); - let signed_delegation = SignedDelegation::new(delegation, delegation_sig_bytes); + let signed_delegation = SignedDelegation { + delegation, + signature, + }; - let url = format!("{}api/v2/canister/{}/call", node_url, CanisterId::ic_00()); + let identity = DelegatedIdentity::new_unchecked( + sender_pubkey_der, + Box::new(session), + vec![signed_delegation], + ); + let agent = agent_with_identity(node.get_public_url().as_str(), identity) + .await + .expect("build agent with a queries-only DelegatedIdentity"); + let ic_00: Principal = IC_00.get().0; for method in Ic00Method::iter() { - let content = HttpCallContent::Call { - update: HttpCanisterUpdate { - canister_id: Blob(CanisterId::ic_00().get().to_vec()), - method_name: method.to_string(), - // The request is rejected during validation before the - // replica ever looks at these bytes, so an empty payload - // is enough. - arg: Blob(vec![]), - sender: Blob(sender_principal.as_slice().to_vec()), - ingress_expiry: expiry_ns, - nonce: None, - sender_info: None, - }, - }; - let message_id = MessageId::from(content.representation_independent_hash()); - let session_sig_bytes = session - .sign_arbitrary(&message_id.as_signed_bytes()) - .expect("session signs request id") - .signature - .expect("session signature bytes"); - - let envelope = HttpRequestEnvelope { - content, - sender_pubkey: Some(Blob(sender_pubkey_der.clone())), - sender_sig: Some(Blob(session_sig_bytes)), - sender_delegation: Some(vec![signed_delegation.clone()]), - }; - let body = serde_cbor::to_vec(&envelope).expect("serialize envelope"); - - let response = client - .post(&url) - .header("Content-Type", "application/cbor") - .body(body) - .send() - .await - .expect("HTTP POST failed"); - let status = response.status(); - let body_text = response.text().await.unwrap_or_default(); - info!( - logger, - "method={} status={} body={:?}", method, status, body_text - ); + let method_name = method.to_string(); + let outcome = agent + .update(&ic_00, &method_name) + .with_arg(vec![]) + .call_and_wait() + .await; + info!(logger, "method={} outcome={:?}", method_name, outcome); - assert_eq!( - status, - StatusCode::BAD_REQUEST, - "management canister method {} expected 400, got {}: {}", - method, - status, - body_text - ); - assert!( - body_text.contains(UPDATE_REJECTED_MSG), - "management canister method {} expected body to contain {:?}, got: {}", - method, - UPDATE_REJECTED_MSG, - body_text - ); + match outcome { + Err(AgentError::HttpError(HttpErrorPayload { + status, content, .. + })) => { + assert_eq!( + status, 400, + "management canister method {method_name} expected HTTP 400, got {status}" + ); + let body = String::from_utf8_lossy(&content); + assert!( + body.contains(UPDATE_REJECTED_MSG), + "management canister method {method_name} expected body to contain {:?}, got: {}", + UPDATE_REJECTED_MSG, + body, + ); + } + other => panic!( + "management canister method {method_name} expected 400 HttpError, got {other:?}" + ), + } } }); } From 042209c78e05cdd8592e8492922c85a27e9c4a7e Mon Sep 17 00:00:00 2001 From: Arshavir Ter-Gabrielyan Date: Wed, 22 Jul 2026 15:07:22 +0200 Subject: [PATCH 4/4] test(queries_only_delegation): also cover a regular canister call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR feedback: the queries-only delegation restriction applies to every `/call` endpoint, not just the management canister. Split the test into two `pub fn`s sharing the same `SystemTestGroup`: * `management_canister_calls_rejected_with_queries_only_delegation` keeps the existing per-`Ic00Method` sweep. * `regular_canister_call_rejected_with_queries_only_delegation` installs a UC (via the default agent — the queries-only agent cannot install code) and asserts that an ordinary `update` call to it is rejected with the same `400 Bad Request` and body. The delegation-building logic and the rejection assertion are factored into `build_queries_only_agent` and `assert_update_rejected` respectively. Co-Authored-By: Claude Opus 4.7 (1M context) --- rs/tests/execution/BUILD.bazel | 1 + .../execution/queries_only_delegation_test.rs | 200 +++++++++++------- 2 files changed, 127 insertions(+), 74 deletions(-) diff --git a/rs/tests/execution/BUILD.bazel b/rs/tests/execution/BUILD.bazel index 55088936a041..0edfd9a8d3aa 100644 --- a/rs/tests/execution/BUILD.bazel +++ b/rs/tests/execution/BUILD.bazel @@ -179,6 +179,7 @@ system_test_nns( system_test( name = "queries_only_delegation_test", cpus = MIN_LOCAL_CPUS + 1 * DEFAULT_VCPUS_PER_VM, # 1 IC Node VM * 6 vCPUs. + runtime_deps = UNIVERSAL_CANISTER_RUNTIME_DEPS, deps = [ # Keep sorted. "//rs/registry/subnet_type", diff --git a/rs/tests/execution/queries_only_delegation_test.rs b/rs/tests/execution/queries_only_delegation_test.rs index 8753db15bf62..d58e072345b4 100644 --- a/rs/tests/execution/queries_only_delegation_test.rs +++ b/rs/tests/execution/queries_only_delegation_test.rs @@ -1,15 +1,14 @@ /* tag::catalog[] end::catalog[] */ -//! System test for queries-only delegation permissions on the management -//! canister. +//! System test for queries-only delegation permissions. //! //! When a caller authenticates via a delegation chain that carries //! `permissions = queries`, the ingress validator rejects every request -//! sent to the `/call` endpoint. This test iterates over every method of -//! the management canister and verifies that a call to `aaaaa-aa` is -//! rejected with a `400 Bad Request` whose body identifies the -//! queries-only restriction. +//! sent to the `/call` endpoint. The tests here iterate over every method +//! of the management canister and additionally exercise a regular +//! (application) canister, asserting that each `/call` is rejected with a +//! `400 Bad Request` whose body identifies the queries-only restriction. use anyhow::Result; use ic_agent::AgentError; @@ -19,15 +18,15 @@ use ic_agent::export::Principal; use ic_agent::identity::{DelegatedIdentity, Delegation, DelegationPermissions, SignedDelegation}; use ic_management_canister_types_private::{IC_00, Method as Ic00Method}; use ic_registry_subnet_type::SubnetType; -use ic_system_test_driver::driver::group::SystemTestGroup; +use ic_system_test_driver::driver::group::{SystemTestGroup, SystemTestSubGroup}; use ic_system_test_driver::driver::ic::{InternetComputer, Subnet}; use ic_system_test_driver::driver::test_env::TestEnv; use ic_system_test_driver::driver::test_env_api::{GetFirstHealthyNodeSnapshot, HasPublicApiUrl}; use ic_system_test_driver::systest; use ic_system_test_driver::util::{ - agent_with_identity, block_on, expiry_time, random_ed25519_identity, + UniversalCanister, agent_with_identity, block_on, expiry_time, random_ed25519_identity, }; -use slog::info; +use slog::{Logger, info}; use strum::IntoEnumIterator; // Substring of the validator error emitted by @@ -43,9 +42,15 @@ const UPDATE_REJECTED_MSG: &str = fn main() -> Result<()> { SystemTestGroup::new() .with_setup(setup) - .add_test(systest!( - management_canister_calls_rejected_with_queries_only_delegation - )) + .add_parallel( + SystemTestSubGroup::new() + .add_test(systest!( + management_canister_calls_rejected_with_queries_only_delegation + )) + .add_test(systest!( + regular_canister_call_rejected_with_queries_only_delegation + )), + ) .execute_from_args()?; Ok(()) } @@ -57,75 +62,122 @@ pub fn setup(env: TestEnv) { .expect("failed to setup IC under test"); } +/// Builds an `ic-agent` authenticating via a fresh two-Ed25519-key +/// delegation chain whose top delegation carries `permissions = queries`. +async fn build_queries_only_agent(node_url: &str) -> ic_agent::Agent { + // Root identity of the delegation chain — this is the principal the + // ingress is authenticated as. + let sender = random_ed25519_identity(); + let sender_pubkey_der = sender.public_key().expect("sender has a public key"); + + // Session key the sender delegates to, restricted to queries only. + let session = random_ed25519_identity(); + let session_pubkey_der = session.public_key().expect("session has a public key"); + + let delegation = Delegation { + pubkey: session_pubkey_der, + expiration: expiry_time().as_nanos() as u64, + targets: None, + permissions: Some(DelegationPermissions::Queries), + }; + let signature = sender + .sign_arbitrary(&delegation.signable()) + .expect("sender signs the delegation") + .signature + .expect("delegation signature bytes"); + let signed_delegation = SignedDelegation { + delegation, + signature, + }; + + let identity = DelegatedIdentity::new_unchecked( + sender_pubkey_der, + Box::new(session), + vec![signed_delegation], + ); + agent_with_identity(node_url, identity) + .await + .expect("build agent with a queries-only DelegatedIdentity") +} + +/// Sends `agent.update(canister_id, method_name).with_arg([]).call_and_wait()` +/// and asserts the reply is `400 Bad Request` with a body matching the +/// queries-only delegation rejection reason. `target_desc` is prepended to +/// assertion messages so it's clear which invocation broke. +async fn assert_update_rejected( + logger: &Logger, + agent: &ic_agent::Agent, + canister_id: &Principal, + method_name: &str, + target_desc: &str, +) { + let outcome = agent + .update(canister_id, method_name) + .with_arg(vec![]) + .call_and_wait() + .await; + info!( + logger, + "{}: method={} outcome={:?}", target_desc, method_name, outcome + ); + + match outcome { + Err(AgentError::HttpError(HttpErrorPayload { + status, content, .. + })) => { + assert_eq!( + status, 400, + "{target_desc} method {method_name} expected HTTP 400, got {status}" + ); + let body = String::from_utf8_lossy(&content); + assert!( + body.contains(UPDATE_REJECTED_MSG), + "{target_desc} method {method_name} expected body to contain {:?}, got: {}", + UPDATE_REJECTED_MSG, + body, + ); + } + other => panic!("{target_desc} method {method_name} expected 400 HttpError, got {other:?}"), + } +} + pub fn management_canister_calls_rejected_with_queries_only_delegation(env: TestEnv) { let logger = env.logger(); let node = env.get_first_healthy_application_node_snapshot(); block_on(async move { - // Root identity of the delegation chain — this is the principal the - // ingress is authenticated as. - let sender = random_ed25519_identity(); - let sender_pubkey_der = sender.public_key().expect("sender has a public key"); - - // Session key the sender delegates to, restricted to queries only. - let session = random_ed25519_identity(); - let session_pubkey_der = session.public_key().expect("session has a public key"); - - // Delegation from sender → session with permissions = queries. - let delegation = Delegation { - pubkey: session_pubkey_der, - expiration: expiry_time().as_nanos() as u64, - targets: None, - permissions: Some(DelegationPermissions::Queries), - }; - let signature = sender - .sign_arbitrary(&delegation.signable()) - .expect("sender signs the delegation") - .signature - .expect("delegation signature bytes"); - let signed_delegation = SignedDelegation { - delegation, - signature, - }; - - let identity = DelegatedIdentity::new_unchecked( - sender_pubkey_der, - Box::new(session), - vec![signed_delegation], - ); - let agent = agent_with_identity(node.get_public_url().as_str(), identity) - .await - .expect("build agent with a queries-only DelegatedIdentity"); + let agent = build_queries_only_agent(node.get_public_url().as_str()).await; let ic_00: Principal = IC_00.get().0; for method in Ic00Method::iter() { - let method_name = method.to_string(); - let outcome = agent - .update(&ic_00, &method_name) - .with_arg(vec![]) - .call_and_wait() - .await; - info!(logger, "method={} outcome={:?}", method_name, outcome); - - match outcome { - Err(AgentError::HttpError(HttpErrorPayload { - status, content, .. - })) => { - assert_eq!( - status, 400, - "management canister method {method_name} expected HTTP 400, got {status}" - ); - let body = String::from_utf8_lossy(&content); - assert!( - body.contains(UPDATE_REJECTED_MSG), - "management canister method {method_name} expected body to contain {:?}, got: {}", - UPDATE_REJECTED_MSG, - body, - ); - } - other => panic!( - "management canister method {method_name} expected 400 HttpError, got {other:?}" - ), - } + assert_update_rejected( + &logger, + &agent, + &ic_00, + &method.to_string(), + "management canister", + ) + .await; } }); } + +pub fn regular_canister_call_rejected_with_queries_only_delegation(env: TestEnv) { + let logger = env.logger(); + let node = env.get_first_healthy_application_node_snapshot(); + block_on(async move { + // Install the target canister with a default agent — the + // queries-only delegated agent cannot itself install code. + let bootstrap_agent = node.build_default_agent_async().await; + let canister = UniversalCanister::new_with_retries( + &bootstrap_agent, + node.effective_canister_id(), + &logger, + ) + .await; + let canister_id = canister.canister_id(); + + let agent = build_queries_only_agent(node.get_public_url().as_str()).await; + + assert_update_rejected(&logger, &agent, &canister_id, "update", "regular canister").await; + }); +}