diff --git a/examples/how-to/perform-http-requests/tests/http_requests.rs b/examples/how-to/perform-http-requests/tests/http_requests.rs index 7abb3637f242..62d89dc121f0 100644 --- a/examples/how-to/perform-http-requests/tests/http_requests.rs +++ b/examples/how-to/perform-http-requests/tests/http_requests.rs @@ -5,9 +5,12 @@ #![cfg(not(target_arch = "wasm32"))] +use assert_matches::assert_matches; use axum::{routing::get, Router}; use how_to_perform_http_requests::Abi; -use linera_sdk::test::{HttpServer, QueryOutcome, TestValidator}; +use linera_sdk::test::{ + ExecutionError, HttpServer, QueryOutcome, TestValidator, WasmExecutionError, +}; /// Tests if service query performs HTTP request to allowed host. #[test_log::test(tokio::test)] @@ -58,16 +61,22 @@ async fn service_query_performs_http_request() -> anyhow::Result<()> { /// Tests if service query can't perform HTTP requests to hosts that aren't allowed. #[test_log::test(tokio::test)] -#[should_panic(expected = "UnauthorizedHttpRequest")] async fn service_query_cant_send_http_request_to_unauthorized_host() { - let url = "http://localhost/".to_owned(); + let url = "http://localhost/"; let (_validator, application_id, chain) = - TestValidator::with_current_application::(url, ()).await; - - chain - .graphql_query(application_id, "query { performHttpRequest }") - .await; + TestValidator::with_current_application::(url.to_owned(), ()).await; + + let error = chain + .try_graphql_query(application_id, "query { performHttpRequest }") + .await + .expect_err("Expected GraphQL query to fail"); + + assert_matches!( + error.expect_execution_error(), + ExecutionError::UnauthorizedHttpRequest(attempted_url) + if attempted_url.to_string() == url + ); } /// Tests if the service sends a valid HTTP response to the contract. @@ -100,7 +109,6 @@ async fn service_sends_valid_http_response_to_contract() -> anyhow::Result<()> { /// Tests if the contract rejects an invalid HTTP response sent by the service. #[test_log::test(tokio::test)] -#[should_panic(expected = "Failed to execute block")] async fn contract_rejects_invalid_http_response_from_service() { const HTTP_RESPONSE_BODY: &str = "Untrusted response"; @@ -112,7 +120,7 @@ async fn contract_rejects_invalid_http_response_from_service() { let url = format!("http://localhost:{port}/"); let (validator, application_id, chain) = - TestValidator::with_current_application::(url, ()).await; + TestValidator::with_current_application::(url.clone(), ()).await; validator .change_resource_control_policy(|policy| { @@ -122,9 +130,15 @@ async fn contract_rejects_invalid_http_response_from_service() { }) .await; - chain - .graphql_mutation(application_id, "mutation { performHttpRequest }") - .await; + let error = chain + .try_graphql_mutation(application_id, "mutation { performHttpRequest }") + .await + .expect_err("Expected GraphQL mutation to fail"); + + assert_matches!( + error.expect_proposal_execution_error(0), + ExecutionError::WasmError(WasmExecutionError::ExecuteModule(_)) + ); } /// Tests if the contract accepts a valid HTTP response it obtains by itself. @@ -157,7 +171,6 @@ async fn contract_accepts_valid_http_response_it_obtains_by_itself() -> anyhow:: /// Tests if the contract rejects an invalid HTTP response it obtains by itself. #[test_log::test(tokio::test)] -#[should_panic(expected = "Failed to execute block")] async fn contract_rejects_invalid_http_response_it_obtains_by_itself() { const HTTP_RESPONSE_BODY: &str = "Untrusted response"; @@ -179,9 +192,15 @@ async fn contract_rejects_invalid_http_response_it_obtains_by_itself() { }) .await; - chain - .graphql_mutation(application_id, "mutation { performHttpRequestInContract }") - .await; + let error = chain + .try_graphql_mutation(application_id, "mutation { performHttpRequestInContract }") + .await + .expect_err("Expected GraphQL mutation to fail"); + + assert_matches!( + error.expect_proposal_execution_error(0), + ExecutionError::WasmError(WasmExecutionError::ExecuteModule(_)) + ); } /// Tests if the contract accepts a valid HTTP response it obtains from the service acting as an @@ -216,7 +235,6 @@ async fn contract_accepts_valid_http_response_from_oracle() -> anyhow::Result<() /// Tests if the contract rejects an invalid HTTP response it obtains from the service acting as an /// oracle. #[test_log::test(tokio::test)] -#[should_panic(expected = "Failed to execute block")] async fn contract_rejects_invalid_http_response_from_oracle() { const HTTP_RESPONSE_BODY: &str = "Invalid response"; @@ -238,7 +256,13 @@ async fn contract_rejects_invalid_http_response_from_oracle() { }) .await; - chain - .graphql_mutation(application_id, "mutation { performHttpRequestAsOracle }") - .await; + let error = chain + .try_graphql_mutation(application_id, "mutation { performHttpRequestAsOracle }") + .await + .expect_err("Expected GraphQL mutation to fail"); + + assert_matches!( + error.expect_proposal_execution_error(0), + ExecutionError::WasmError(WasmExecutionError::ExecuteModule(_)) + ); } diff --git a/linera-chain/src/lib.rs b/linera-chain/src/lib.rs index b978b66ff756..25da37a453de 100644 --- a/linera-chain/src/lib.rs +++ b/linera-chain/src/lib.rs @@ -179,6 +179,7 @@ impl From for ChainError { } #[derive(Copy, Clone, Debug)] +#[cfg_attr(with_testing, derive(Eq, PartialEq))] pub enum ChainExecutionContext { Query, DescribeApplication, diff --git a/linera-core/src/worker.rs b/linera-core/src/worker.rs index 71a6345e5125..aeb6bf1c5d0b 100644 --- a/linera-core/src/worker.rs +++ b/linera-core/src/worker.rs @@ -20,6 +20,8 @@ use linera_base::{ identifiers::{AccountOwner, ApplicationId, BlobId, ChainId}, time::timer::{sleep, timeout}, }; +#[cfg(with_testing)] +use linera_chain::ChainExecutionContext; use linera_chain::{ data_types::{ BlockExecutionOutcome, BlockProposal, MessageBundle, Origin, ProposedBlock, Target, @@ -248,6 +250,28 @@ impl From for WorkerError { } } +#[cfg(with_testing)] +impl WorkerError { + /// Returns the inner [`ExecutionError`] in this error. + /// + /// # Panics + /// + /// If this is not caused by an [`ExecutionError`]. + pub fn expect_execution_error(self, expected_context: ChainExecutionContext) -> ExecutionError { + let WorkerError::ChainError(chain_error) = self else { + panic!("Expected an `ExecutionError`. Got: {self:#?}"); + }; + + let ChainError::ExecutionError(execution_error, context) = *chain_error else { + panic!("Expected an `ExecutionError`. Got: {chain_error:#?}"); + }; + + assert_eq!(context, expected_context); + + *execution_error + } +} + /// State of a worker in a validator or a local node. #[derive(Clone)] pub struct WorkerState diff --git a/linera-sdk/src/test/block.rs b/linera-sdk/src/test/block.rs index c606572635e2..fb8c2991c0c6 100644 --- a/linera-sdk/src/test/block.rs +++ b/linera-sdk/src/test/block.rs @@ -18,6 +18,7 @@ use linera_chain::{ }, types::{ConfirmedBlock, ConfirmedBlockCertificate}, }; +use linera_core::worker::WorkerError; use linera_execution::{ committee::Epoch, system::{Recipient, SystemOperation}, @@ -203,7 +204,7 @@ impl BlockBuilder { pub(crate) async fn try_sign( self, blobs: &[Blob], - ) -> anyhow::Result { + ) -> Result { let published_blobs = self .block .published_blob_ids() diff --git a/linera-sdk/src/test/chain.rs b/linera-sdk/src/test/chain.rs index e8ee58540b80..497223320de0 100644 --- a/linera-sdk/src/test/chain.rs +++ b/linera-sdk/src/test/chain.rs @@ -19,12 +19,12 @@ use linera_base::{ identifiers::{AccountOwner, ApplicationId, ChainDescription, ChainId, ModuleId}, vm::VmRuntime, }; -use linera_chain::types::ConfirmedBlockCertificate; +use linera_chain::{types::ConfirmedBlockCertificate, ChainExecutionContext}; use linera_core::{data_types::ChainInfoQuery, worker::WorkerError}; use linera_execution::{ committee::Epoch, system::{SystemOperation, SystemQuery, SystemResponse}, - Operation, Query, QueryOutcome, QueryResponse, + ExecutionError, Operation, Query, QueryOutcome, QueryResponse, }; use linera_storage::Storage as _; use serde::Serialize; @@ -236,7 +236,7 @@ impl ActiveChain { pub async fn try_add_block( &self, block_builder: impl FnOnce(&mut BlockBuilder), - ) -> anyhow::Result { + ) -> Result { self.try_add_block_with_blobs(block_builder, vec![]).await } @@ -251,7 +251,7 @@ impl ActiveChain { &self, block_builder: impl FnOnce(&mut BlockBuilder), blobs: Vec, - ) -> anyhow::Result { + ) -> Result { let mut tip = self.tip.lock().await; let mut block = BlockBuilder::new( self.description.into(), @@ -537,7 +537,23 @@ impl ActiveChain { where Abi: ServiceAbi, { - let query_bytes = serde_json::to_vec(&query).expect("Failed to serialize query"); + self.try_query(application_id, query) + .await + .expect("Failed to execute application service query") + } + + /// Attempts to execute a `query` on an `application`'s state on this microchain. + /// + /// Returns the deserialized response from the `application`. + pub async fn try_query( + &self, + application_id: ApplicationId, + query: Abi::Query, + ) -> Result, TryQueryError> + where + Abi: ServiceAbi, + { + let query_bytes = serde_json::to_vec(&query)?; let QueryOutcome { response, @@ -552,8 +568,7 @@ impl ActiveChain { bytes: query_bytes, }, ) - .await - .expect("Failed to query application"); + .await?; let deserialized_response = match response { QueryResponse::User(bytes) => { @@ -564,10 +579,10 @@ impl ActiveChain { } }; - QueryOutcome { + Ok(QueryOutcome { response: deserialized_response, operations, - } + }) } /// Executes a GraphQL `query` on an `application`'s state on this microchain. @@ -583,25 +598,38 @@ impl ActiveChain { { let query = query.into(); let query_str = query.query.clone(); + + self.try_graphql_query(application_id, query) + .await + .unwrap_or_else(|error| panic!("Service query {query_str:?} failed: {error}")) + } + + /// Attempts to execute a GraphQL `query` on an `application`'s state on this microchain. + /// + /// Returns the deserialized GraphQL JSON response from the `application`. + pub async fn try_graphql_query( + &self, + application_id: ApplicationId, + query: impl Into, + ) -> Result, TryGraphQLQueryError> + where + Abi: ServiceAbi, + { + let query = query.into(); let QueryOutcome { response, operations, - } = self.query(application_id, query).await; + } = self.try_query(application_id, query).await?; + if !response.errors.is_empty() { - panic!( - "GraphQL query:\n{}\nyielded errors:\n{:#?}", - query_str, response.errors - ); + return Err(TryGraphQLQueryError::Service(response.errors)); } - let json_response = response - .data - .into_json() - .expect("Unexpected non-JSON query response"); + let json_response = response.data.into_json()?; - QueryOutcome { + Ok(QueryOutcome { response: json_response, operations, - } + }) } /// Executes a GraphQL `mutation` on an `application` and proposes a block with the resulting @@ -616,23 +644,128 @@ impl ActiveChain { where Abi: ServiceAbi, { - let QueryOutcome { operations, .. } = self.graphql_query(application_id, query).await; + self.try_graphql_mutation(application_id, query) + .await + .expect("Failed to execute service GraphQL mutation") + } - self.add_block(|block| { - for operation in operations { - match operation { - Operation::User { - application_id, - bytes, - } => { - block.with_raw_operation(application_id, bytes); - } - Operation::System(system_operation) => { - block.with_system_operation(*system_operation); + /// Attempts to execute a GraphQL `mutation` on an `application` and proposes a block with the + /// resulting scheduled operations. + /// + /// Returns the certificate of the new block. + pub async fn try_graphql_mutation( + &self, + application_id: ApplicationId, + query: impl Into, + ) -> Result + where + Abi: ServiceAbi, + { + let QueryOutcome { operations, .. } = self.try_graphql_query(application_id, query).await?; + + let certificate = self + .try_add_block(|block| { + for operation in operations { + match operation { + Operation::User { + application_id, + bytes, + } => { + block.with_raw_operation(application_id, bytes); + } + Operation::System(system_operation) => { + block.with_system_operation(*system_operation); + } } } + }) + .await?; + + Ok(certificate) + } +} + +/// Failure to query an application's service on a chain. +#[derive(Debug, thiserror::Error)] +pub enum TryQueryError { + /// The query request failed to serialize to JSON. + #[error("Failed to serialize query request")] + Serialization(#[from] serde_json::Error), + + /// Executing the service to handle the query failed. + #[error("Failed to execute service query")] + Execution(#[from] WorkerError), +} + +/// Failure to perform a GraphQL query on an application on a chain. +#[derive(Debug, thiserror::Error)] +pub enum TryGraphQLQueryError { + /// The [`async_graphql::Request`] failed to serialize to JSON. + #[error("Failed to serialize GraphQL query request")] + RequestSerialization(#[source] serde_json::Error), + + /// Execution of the service failed. + #[error("Failed to execute service query")] + Execution(#[from] WorkerError), + + /// The response returned from the service was not valid JSON. + #[error("Unexpected non-JSON service query response")] + ResponseDeserialization(#[from] serde_json::Error), + + /// The service reported some errors. + #[error("Service returned errors: {_0:#?}")] + Service(Vec), +} + +impl From for TryGraphQLQueryError { + fn from(query_error: TryQueryError) -> Self { + match query_error { + TryQueryError::Serialization(error) => { + TryGraphQLQueryError::RequestSerialization(error) } - }) - .await + TryQueryError::Execution(error) => TryGraphQLQueryError::Execution(error), + } + } +} + +impl TryGraphQLQueryError { + /// Returns the inner [`ExecutionError`] in this error. + /// + /// # Panics + /// + /// If this is not caused by an [`ExecutionError`]. + pub fn expect_execution_error(self) -> ExecutionError { + let TryGraphQLQueryError::Execution(worker_error) = self else { + panic!("Expected an `ExecutionError`. Got: {self:#?}"); + }; + + worker_error.expect_execution_error(ChainExecutionContext::Query) + } +} + +/// Failure to perform a GraphQL mutation on an application on a chain. +#[derive(Debug, thiserror::Error)] +pub enum TryGraphQLMutationError { + /// The GraphQL query for the mutation failed. + #[error(transparent)] + Query(#[from] TryGraphQLQueryError), + + /// The block with the mutation's scheduled operations failed to be proposed. + #[error("Failed to propose block with operations scheduled by the GraphQL mutation")] + Proposal(#[from] WorkerError), +} + +impl TryGraphQLMutationError { + /// Returns the inner [`ExecutionError`] in this [`TryGraphQLMutationError::Proposal`] error. + /// + /// # Panics + /// + /// If this is not caused by an [`ExecutionError`] during a block proposal. + pub fn expect_proposal_execution_error(self, transaction_index: u32) -> ExecutionError { + let TryGraphQLMutationError::Proposal(proposal_error) = self else { + panic!("Expected an `ExecutionError` during the block proposal. Got: {self:#?}"); + }; + + proposal_error.expect_execution_error(ChainExecutionContext::Operation(transaction_index)) } } diff --git a/linera-sdk/src/test/mod.rs b/linera-sdk/src/test/mod.rs index fd3b1f451bf6..b50073178207 100644 --- a/linera-sdk/src/test/mod.rs +++ b/linera-sdk/src/test/mod.rs @@ -23,14 +23,20 @@ pub use { linera_chain::{ data_types::{Medium, MessageAction}, test::HttpServer, + ChainError, ChainExecutionContext, }, - linera_execution::{system::Recipient, QueryOutcome}, + linera_core::worker::WorkerError, + linera_execution::{system::Recipient, ExecutionError, QueryOutcome, WasmExecutionError}, }; #[cfg(with_testing)] pub use self::mock_stubs::*; #[cfg(with_integration_testing)] -pub use self::{block::BlockBuilder, chain::ActiveChain, validator::TestValidator}; +pub use self::{ + block::BlockBuilder, + chain::{ActiveChain, TryGraphQLMutationError, TryGraphQLQueryError, TryQueryError}, + validator::TestValidator, +}; use crate::{Contract, ContractRuntime, Service, ServiceRuntime}; /// Creates a [`ContractRuntime`] to use in tests.