taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

commit d87dfa3dac50ac6ccef95f10c261aee92bd6aff1
parent abee8126c9f52b8b5b600cbf14a6bcef9ace8a25
Author: Antoine A <>
Date:   Tue, 25 Mar 2025 18:05:04 +0100

common: simpler test server and fix body parsing

Diffstat:
MCargo.lock | 224+++++++++----------------------------------------------------------------------
MCargo.toml | 2++
Mcommon/taler-api/Cargo.toml | 4++--
Mcommon/taler-api/src/json.rs | 46+++++++++++++++++++++++-----------------------
Mcommon/taler-api/src/lib.rs | 2+-
Mcommon/taler-api/tests/api.rs | 18+++---------------
Mcommon/taler-api/tests/common/mod.rs | 9+++++++++
Acommon/taler-api/tests/security.rs | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/taler-common/src/api_wire.rs | 2+-
Mcommon/taler-test-utils/Cargo.toml | 10+++++++---
Dcommon/taler-test-utils/src/helpers.rs | 99-------------------------------------------------------------------------------
Mcommon/taler-test-utils/src/lib.rs | 5+++--
Mcommon/taler-test-utils/src/routine.rs | 34++++++++++++++++------------------
Acommon/taler-test-utils/src/server.rs | 244+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtaler-magnet-bank/tests/api.rs | 8+++-----
15 files changed, 448 insertions(+), 368 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -95,16 +95,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" [[package]] -name = "assert-json-diff" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] name = "atoi" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -114,12 +104,6 @@ dependencies = [ ] [[package]] -name = "auto-future" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" - -[[package]] name = "autocfg" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -180,36 +164,6 @@ dependencies = [ ] [[package]] -name = "axum-test" -version = "17.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317c1f4ecc1e68e0ad5decb78478421055c963ce215e736ed97463fa609cd196" -dependencies = [ - "anyhow", - "assert-json-diff", - "auto-future", - "axum", - "bytes", - "bytesize", - "cookie", - "http", - "http-body-util", - "hyper", - "hyper-util", - "mime", - "pretty_assertions", - "reserve-port", - "rust-multipart-rfc7578_2", - "serde", - "serde_json", - "serde_urlencoded", - "smallvec", - "tokio", - "tower", - "url", -] - -[[package]] name = "backtrace" version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -276,12 +230,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] -name = "bytesize" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2c12f985c78475a6b8d629afd0c360260ef34cfef52efccdcfd31972f81c2e" - -[[package]] name = "cast" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -289,9 +237,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.16" +version = "1.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" +checksum = "1fcb57c740ae1daf453ae85f16e37396f672b039e00d9d866e07ddb24e328e3a" dependencies = [ "shlex", ] @@ -397,16 +345,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "time", - "version_check", -] - -[[package]] name = "cpufeatures" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -594,21 +532,6 @@ dependencies = [ ] [[package]] -name = "deranged" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - -[[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1283,9 +1206,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jiff" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d699bc6dfc879fb1bf9bdff0d4c56f0884fc6f0d0eb0fba397a6d00cd9a6b85e" +checksum = "c102670231191d07d37a35af3eb77f1f0dbf7a71be51a962dcd57ea607be7260" dependencies = [ "jiff-static", "log", @@ -1297,9 +1220,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d16e75759ee0aa64c57a56acbf43916987b20c77373cb7e808979e02b93c9f9" +checksum = "4cdde31a9d349f1b1f51a0b3714a5940ac022976f4b49485fc04be052b183b4c" dependencies = [ "proc-macro2", "quote", @@ -1381,9 +1304,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.26" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "matchit" @@ -1414,16 +1337,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - -[[package]] name = "miniz_oxide" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1454,12 +1367,6 @@ dependencies = [ ] [[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - -[[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1598,12 +1505,6 @@ dependencies = [ ] [[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] name = "ppv-lite86" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1613,16 +1514,6 @@ dependencies = [ ] [[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - -[[package]] name = "primeorder" version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1682,9 +1573,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.10" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e46f3055866785f6b92bc6164b76be02ca8f2eb4b002c0354b28cf4c119e5944" +checksum = "541d0f57c6ec747a90738a52741d3221f7960e8ac2f0ff4b1a63680e033b4ab5" dependencies = [ "cfg_aliases", "libc", @@ -1851,16 +1742,6 @@ dependencies = [ ] [[package]] -name = "reserve-port" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "359fc315ed556eb0e42ce74e76f4b1cd807b50fa6307f3de4e51f92dbe86e2d5" -dependencies = [ - "lazy_static", - "thiserror", -] - -[[package]] name = "rfc6979" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1885,22 +1766,6 @@ dependencies = [ ] [[package]] -name = "rust-multipart-rfc7578_2" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc4bb9e7c9abe5fa5f30c2d8f8fefb9e0080a2c1e3c2e567318d2907054b35d3" -dependencies = [ - "bytes", - "futures-core", - "futures-util", - "http", - "mime", - "mime_guess", - "rand 0.9.0", - "thiserror", -] - -[[package]] name = "rustc-demangle" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1968,9 +1833,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.0" +version = "0.103.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa4eeac2588ffff23e9d7a7e9b3f971c5fb5b7ebc9452745e0c232c64f83b2f" +checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03" dependencies = [ "ring", "rustls-pki-types", @@ -2460,22 +2325,26 @@ name = "taler-test-utils" version = "0.0.0" dependencies = [ "axum", - "axum-test", + "http-body-util", + "libdeflater", "serde", "serde_json", + "serde_urlencoded", "sqlx", "taler-api", "taler-common", "tokio", + "tower", "tracing", "tracing-subscriber", + "url", ] [[package]] name = "tempfile" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488960f40a3fd53d72c2a29a58722561dee8afdd175bd88e3db4677d7b2ba600" +checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" dependencies = [ "fastrand", "getrandom 0.3.2", @@ -2515,37 +2384,6 @@ dependencies = [ ] [[package]] -name = "time" -version = "0.3.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d9c75b47bdff86fa3334a3db91356b8d7d86a9b839dab7d0bdc5c3d3a077618" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" - -[[package]] -name = "time-macros" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29aa485584182073ed57fd5004aa09c371f021325014694e432313345865fd04" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] name = "tinystr" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2728,12 +2566,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" [[package]] -name = "unicase" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" - -[[package]] name = "unicode-bidi" version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2956,9 +2788,9 @@ dependencies = [ [[package]] name = "whoami" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d" +checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" dependencies = [ "redox_syscall", "wasite", @@ -3264,12 +3096,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" [[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] name = "yoke" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3295,18 +3121,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.23" +version = "0.8.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd97444d05a4328b90e75e503a34bad781f14e28a823ad3557f0750df1ebcbc6" +checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.23" +version = "0.8.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6352c01d0edd5db859a63e2605f4ea3183ddbd15e2c4a9e7d32184df75e4f154" +checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml @@ -42,3 +42,5 @@ taler-common = { path = "common/taler-common" } taler-api = { path = "common/taler-api" } taler-test-utils = { path = "common/taler-test-utils" } anyhow = "1" +http-body-util = "0.1.2" +libdeflater = "1.22.0" diff --git a/common/taler-api/Cargo.toml b/common/taler-api/Cargo.toml @@ -10,8 +10,8 @@ license-file.workspace = true [dependencies] listenfd = "1.0.0" dashmap = "6.1" -http-body-util = "0.1.2" -libdeflater = "1.22.0" +http-body-util.workspace = true +libdeflater.workspace = true ed25519-dalek = { version = "2.1.1", default-features = false } tokio = { workspace = true, features = ["signal"] } serde.workspace = true diff --git a/common/taler-api/src/json.rs b/common/taler-api/src/json.rs @@ -17,7 +17,7 @@ use axum::{ body::Bytes, extract::{FromRequest, Request}, - http::header, + http::{StatusCode, header}, }; use http_body_util::BodyExt as _; use serde::de::DeserializeOwned; @@ -25,7 +25,7 @@ use taler_common::error_code::ErrorCode; use crate::{ constants::MAX_BODY_LENGTH, - error::{ApiError, failure}, + error::{ApiError, failure, failure_status}, }; #[derive(Debug, Clone, Copy, Default)] @@ -40,24 +40,23 @@ where type Rejection = ApiError; async fn from_request(req: Request, _state: &S) -> Result<Self, Self::Rejection> { - // TODO UNSUPPORTED_MEDIA_TYPE & UNPROCESSABLE_ENTITY // Check content type - match req - .headers() - .get(header::CONTENT_TYPE) - .map(|it| it == "application/json") - { - Some(true) => {} - Some(false) => { - return Err(failure( - ErrorCode::GENERIC_JSON_INVALID, - "Bad Content-Type header", - )); + println!("{:?}", req.headers()); + match req.headers().get(header::CONTENT_TYPE) { + Some(header) => { + if header != "application/json" { + return Err(failure_status( + ErrorCode::GENERIC_HTTP_HEADERS_MALFORMED, + "Bad Content-Type header", + StatusCode::UNSUPPORTED_MEDIA_TYPE, + )); + } } None => { - return Err(failure( - ErrorCode::GENERIC_JSON_INVALID, + return Err(failure_status( + ErrorCode::GENERIC_HTTP_HEADERS_MALFORMED, "Missing Content-Type header", + StatusCode::UNSUPPORTED_MEDIA_TYPE, )); } } @@ -71,7 +70,7 @@ where { if length > MAX_BODY_LENGTH { return Err(failure( - ErrorCode::GENERIC_JSON_INVALID, + ErrorCode::GENERIC_UPLOAD_EXCEEDS_LIMIT, format!("Body is suspiciously big > {MAX_BODY_LENGTH}B"), )); } @@ -82,12 +81,13 @@ where if encoding == "deflate" { true } else { - return Err(failure( - ErrorCode::GENERIC_COMPRESSION_INVALID, + return Err(failure_status( + ErrorCode::GENERIC_HTTP_HEADERS_MALFORMED, format!( "Unsupported encoding '{}'", String::from_utf8_lossy(encoding.as_bytes()) ), + StatusCode::UNSUPPORTED_MEDIA_TYPE, )); } } else { @@ -102,7 +102,7 @@ where Err(it) => match it.downcast::<http_body_util::LengthLimitError>() { Ok(_) => { return Err(failure( - ErrorCode::GENERIC_JSON_INVALID, + ErrorCode::GENERIC_UPLOAD_EXCEEDS_LIMIT, format!("Body is suspiciously big > {MAX_BODY_LENGTH}B"), )); } @@ -117,7 +117,7 @@ where let bytes = if compressed { let mut buf = vec![0; MAX_BODY_LENGTH]; - match libdeflater::Decompressor::new().zlib_decompress(&bytes, &mut buf) { + match libdeflater::Decompressor::new().deflate_decompress(&bytes, &mut buf) { Ok(it) => Bytes::copy_from_slice(&buf[..it]), Err(it) => match it { libdeflater::DecompressionError::BadData => { @@ -128,8 +128,8 @@ where } libdeflater::DecompressionError::InsufficientSpace => { return Err(failure( - ErrorCode::GENERIC_JSON_INVALID, - format!("Body is suspiciously big > {MAX_BODY_LENGTH}B"), + ErrorCode::GENERIC_UPLOAD_EXCEEDS_LIMIT, + format!("Decompressed body is suspiciously big > {MAX_BODY_LENGTH}B"), )); } }, diff --git a/common/taler-api/src/lib.rs b/common/taler-api/src/lib.rs @@ -22,7 +22,7 @@ use tracing::info; pub mod api; pub mod auth; -mod constants; +pub mod constants; pub mod db; pub mod error; pub mod json; diff --git a/common/taler-api/tests/api.rs b/common/taler-api/tests/api.rs @@ -15,8 +15,7 @@ */ use axum::http::StatusCode; -use common::test_api; -use sqlx::PgPool; +use common::setup; use taler_common::{ api_common::{HashCode, ShortHashCode}, api_revenue::RevenueConfig, @@ -25,24 +24,13 @@ use taler_common::{ types::{amount::amount, payto::payto, url}, }; use taler_test_utils::{ - axum_test::TestServer, - db_test_setup, - helpers::TestResponseHelper, json, routine::{admin_add_incoming_routine, revenue_routine, routine_pagination, transfer_routine}, + server::TestServer as _, }; mod common; -async fn setup() -> (TestServer, PgPool) { - let pool = db_test_setup("taler-api").await; - let api = test_api(pool.clone(), "EUR".to_string()).await; - - let server = TestServer::new(api.finalize()).unwrap(); - - (server, pool) -} - #[tokio::test] async fn errors() { let (server, _) = setup().await; @@ -121,7 +109,7 @@ async fn account_check() { let (server, _) = setup().await; server .get("/taler-wire-gateway/account/check") - .add_query_param("account", "payto://test") + .query("account", "payto://test") .await .assert_status(StatusCode::NOT_IMPLEMENTED); } diff --git a/common/taler-api/tests/common/mod.rs b/common/taler-api/tests/common/mod.rs @@ -16,6 +16,7 @@ use std::sync::Arc; +use axum::Router; use db::notification_listener; use sqlx::PgPool; use taler_api::{ @@ -35,6 +36,7 @@ use taler_common::{ error_code::ErrorCode, types::{payto::payto, timestamp::Timestamp}, }; +use taler_test_utils::db_test_setup; use tokio::sync::watch::Sender; pub mod db; @@ -196,3 +198,10 @@ pub async fn test_api(pool: PgPool, currency: String) -> TalerApiBuilder { .wire_gateway(state.clone(), AuthMethod::None) .revenue(state, AuthMethod::None) } + +pub async fn setup() -> (Router, PgPool) { + let pool = db_test_setup("taler-api").await; + let api = test_api(pool.clone(), "EUR".to_string()).await; + + (api.finalize(), pool) +} diff --git a/common/taler-api/tests/security.rs b/common/taler-api/tests/security.rs @@ -0,0 +1,109 @@ +/* + This file is part of TALER + Copyright (C) 2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use axum::http::{StatusCode, header}; +use common::setup; +use taler_api::constants::MAX_BODY_LENGTH; +use taler_common::{ + api_wire::{TransferRequest, TransferResponse}, + error_code::ErrorCode, + types::{amount::Amount, base32::Base32, payto::payto, url}, +}; +use taler_test_utils::server::TestServer as _; + +mod common; + +#[tokio::test] +async fn body_parsing() { + let (server, _) = setup().await; + let normal_body = TransferRequest { + request_uid: Base32::rand(), + amount: Amount::zero("EUR"), + exchange_base_url: url("https://test.com"), + wtid: Base32::rand(), + credit_account: payto("payto:://test"), + }; + + // Check OK + server + .post("/taler-wire-gateway/transfer") + .json(&normal_body) + .deflate() + .await + .assert_ok_json::<TransferResponse>(); + + // Headers check + server + .post("/taler-wire-gateway/transfer") + .json(&normal_body) + .remove(header::CONTENT_TYPE) + .await + .assert_error_status( + ErrorCode::GENERIC_HTTP_HEADERS_MALFORMED, + StatusCode::UNSUPPORTED_MEDIA_TYPE, + ); + server + .post("/taler-wire-gateway/transfer") + .json(&normal_body) + .deflate() + .remove(header::CONTENT_ENCODING) + .await + .assert_error(ErrorCode::GENERIC_JSON_INVALID); + server + .post("/taler-wire-gateway/transfer") + .json(&normal_body) + .header(header::CONTENT_TYPE, "invalid") + .await + .assert_error_status( + ErrorCode::GENERIC_HTTP_HEADERS_MALFORMED, + StatusCode::UNSUPPORTED_MEDIA_TYPE, + ); + server + .post("/taler-wire-gateway/transfer") + .json(&normal_body) + .header(header::CONTENT_ENCODING, "deflate") + .await + .assert_error(ErrorCode::GENERIC_COMPRESSION_INVALID); + server + .post("/taler-wire-gateway/transfer") + .json(&normal_body) + .header(header::CONTENT_ENCODING, "invalid") + .await + .assert_error_status( + ErrorCode::GENERIC_HTTP_HEADERS_MALFORMED, + StatusCode::UNSUPPORTED_MEDIA_TYPE, + ); + + // Body size limit + let huge_body = TransferRequest { + credit_account: payto(format!( + "payto:://test?message={:A<1$}", + "payout", MAX_BODY_LENGTH + )), + ..normal_body + }; + server + .post("/taler-wire-gateway/transfer") + .json(&huge_body) + .await + .assert_error(ErrorCode::GENERIC_UPLOAD_EXCEEDS_LIMIT); + server + .post("/taler-wire-gateway/transfer") + .json(&huge_body) + .deflate() + .await + .assert_error(ErrorCode::GENERIC_UPLOAD_EXCEEDS_LIMIT); +} diff --git a/common/taler-common/src/api_wire.rs b/common/taler-common/src/api_wire.rs @@ -30,7 +30,7 @@ pub struct WireConfig<'a> { pub version: &'a str, pub currency: &'a str, pub implementation: Option<&'a str>, - pub support_account_check: bool + pub support_account_check: bool, } /// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransferResponse> diff --git a/common/taler-test-utils/Cargo.toml b/common/taler-test-utils/Cargo.toml @@ -8,13 +8,17 @@ repository.workspace = true license-file.workspace = true [dependencies] -axum-test = "17.0" +tower = "0.5" axum.workspace = true tokio.workspace = true serde_json.workspace = true +serde_urlencoded.workspace = true serde.workspace = true taler-common.workspace = true taler-api.workspace = true tracing.workspace = true tracing-subscriber.workspace = true -sqlx.workspace = true -\ No newline at end of file +sqlx.workspace = true +http-body-util.workspace = true +url.workspace = true +libdeflater.workspace = true +\ No newline at end of file diff --git a/common/taler-test-utils/src/helpers.rs b/common/taler-test-utils/src/helpers.rs @@ -1,99 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2024-2025 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use axum::http::StatusCode; -use axum_test::TestResponse; -use serde::Deserialize; -use serde_json::Value; -use taler_common::{api_common::ErrorDetail, error_code::ErrorCode}; - -pub trait TestResponseHelper { - fn json_parse<'de, T: Deserialize<'de>>(&'de self) -> T; - fn assert_ok_json<'de, T: Deserialize<'de>>(&'de self) -> T; - fn assert_ok(&self); - fn assert_no_content(&self); - fn assert_error(&self, error_code: ErrorCode); -} - -#[track_caller] -pub fn assert_status(resp: &TestResponse, status: StatusCode) { - if resp.status_code() != status { - let body = resp.as_bytes(); - if resp.status_code().is_success() || body.is_empty() { - panic!( - "{} {} expected {status} got {}", - resp.request_method(), - resp.request_url(), - resp.status_code() - ); - } else { - let err: ErrorDetail = resp.json_parse(); - let description = err.hint.unwrap_or_default(); - panic!( - "{} {} expected {status} got {}: {} {description}", - resp.request_method(), - resp.request_url(), - resp.status_code(), - err.code - ); - } - } -} - -impl TestResponseHelper for TestResponse { - #[track_caller] - fn assert_ok_json<'de, T: Deserialize<'de>>(&'de self) -> T { - self.assert_ok(); - self.json_parse() - } - #[track_caller] - fn assert_ok(&self) { - assert_status(self, StatusCode::OK); - } - #[track_caller] - fn assert_no_content(&self) { - assert_status(self, StatusCode::NO_CONTENT); - } - - #[track_caller] - fn assert_error(&self, error_code: ErrorCode) { - let (status_code, _) = error_code.metadata(); - assert_status(self, StatusCode::from_u16(status_code).unwrap()); - let err: ErrorDetail = self.json_parse(); - assert_eq!(error_code as u32, err.code); - } - #[track_caller] - fn json_parse<'de, T: Deserialize<'de>>(&'de self) -> T { - match serde_json::from_slice(self.as_bytes()) { - Ok(body) => body, - Err(err) => match serde_json::from_slice::<Value>(self.as_bytes()) { - Ok(raw) => panic!( - "{} {} {} invalid JSON schema: {err}\n{raw}", - self.request_method(), - self.request_url(), - self.status_code() - ), - Err(err) => panic!( - "{} {} {} invalid JSON body: {err}\n{}", - self.request_method(), - self.request_url(), - self.status_code(), - String::from_utf8_lossy(self.as_bytes()) - ), - }, - } - } -} diff --git a/common/taler-test-utils/src/lib.rs b/common/taler-test-utils/src/lib.rs @@ -28,13 +28,14 @@ use sqlx::{ postgres::{PgConnectOptions, PgPoolOptions}, }; -pub use axum_test; use taler_common::db::{dbinit, pool}; use tracing::Level; use tracing_subscriber::{FmtSubscriber, util::SubscriberInitExt}; -pub mod helpers; + +pub use axum::Router; pub mod json; pub mod routine; +pub mod server; pub async fn db_test_setup(prefix: &str) -> PgPool { let schema = prefix.replace("-", "_"); diff --git a/common/taler-test-utils/src/routine.rs b/common/taler-test-utils/src/routine.rs @@ -18,12 +18,10 @@ use std::{ borrow::Cow, fmt::Debug, future::Future, - str::FromStr, time::{Duration, Instant}, }; -use axum::{extract::Query, http::Uri}; -use axum_test::{TestResponse, TestServer}; +use axum::Router; use serde::{Deserialize, de::DeserializeOwned}; use taler_api::db::IncomingType; use taler_common::{ @@ -39,13 +37,16 @@ use taler_common::{ }; use tokio::time::sleep; -use crate::{helpers::TestResponseHelper, json}; +use crate::{ + json, + server::{TestResponse, TestServer as _}, +}; pub async fn routine_pagination<'a, T: DeserializeOwned, F: Future<Output = ()>>( - server: &'a TestServer, + server: &'a Router, url: &str, ids: fn(T) -> Vec<i64>, - mut register: impl FnMut(&'a TestServer, usize) -> F, + mut register: impl FnMut(&'a Router, usize) -> F, ) { // Check history is following specs let assert_history = |args: Cow<'static, str>, size: usize| async move { @@ -88,13 +89,13 @@ pub async fn routine_history< FR: Future<Output = ()>, FI: Future<Output = ()>, >( - server: &'a TestServer, + server: &'a Router, url: &str, ids: fn(T) -> Vec<i64>, nb_register: usize, - mut register: impl FnMut(&'a TestServer, usize) -> FR, + mut register: impl FnMut(&'a Router, usize) -> FR, nb_ignore: usize, - mut ignore: impl FnMut(&'a TestServer, usize) -> FI, + mut ignore: impl FnMut(&'a Router, usize) -> FI, ) { // Check history is following specs macro_rules! assert_history { @@ -218,10 +219,7 @@ fn assert_history_ids<'de, T: Deserialize<'de>>( } let body = resp.assert_ok_json::<T>(); let history: Vec<_> = ids(body); - let Query(raw) = - <Query<PageParams>>::try_from_uri(&Uri::from_str(resp.request_url().as_str()).unwrap()) - .unwrap(); - let params = raw.check(1024).unwrap(); + let params = resp.query::<PageParams>().check(1024).unwrap(); // testing the size is like expected assert_eq!(size, history.len(), "bad history length: {history:?}"); @@ -258,7 +256,7 @@ fn assert_history_ids<'de, T: Deserialize<'de>>( } // Get currency from config -async fn get_currency(server: &TestServer) -> String { +async fn get_currency(server: &Router) -> String { let config = server .get("/taler-wire-gateway/config") .await @@ -269,7 +267,7 @@ async fn get_currency(server: &TestServer) -> String { /// Test standard behavior of the transfer endpoints pub async fn transfer_routine( - server: &TestServer, + server: &Router, default_status: TransferState, credit_account: &PaytoURI, ) { @@ -429,7 +427,7 @@ pub async fn transfer_routine( } async fn add_incoming_routine( - server: &TestServer, + server: &Router, currency: &str, kind: IncomingType, debit_acount: &PaytoURI, @@ -503,7 +501,7 @@ async fn add_incoming_routine( } /// Test standard behavior of the revenue endpoints -pub async fn revenue_routine(server: &TestServer, debit_acount: &PaytoURI) { +pub async fn revenue_routine(server: &Router, debit_acount: &PaytoURI) { let currency = &get_currency(server).await; routine_history( @@ -546,7 +544,7 @@ pub async fn revenue_routine(server: &TestServer, debit_acount: &PaytoURI) { } /// Test standard behavior of the admin add incoming endpoints -pub async fn admin_add_incoming_routine(server: &TestServer, debit_acount: &PaytoURI) { +pub async fn admin_add_incoming_routine(server: &Router, debit_acount: &PaytoURI) { let currency = &get_currency(server).await; // History diff --git a/common/taler-test-utils/src/server.rs b/common/taler-test-utils/src/server.rs @@ -0,0 +1,244 @@ +/* + This file is part of TALER + Copyright (C) 2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::{fmt::Debug, pin::Pin}; + +use axum::{ + Router, + body::{Body, Bytes}, + extract::Query, + http::{ + HeaderMap, HeaderValue, Method, StatusCode, Uri, + header::{self, AsHeaderName, IntoHeaderName}, + }, +}; +use http_body_util::BodyExt as _; +use libdeflater::CompressionLvl; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use taler_common::{api_common::ErrorDetail, error_code::ErrorCode}; +use tower::ServiceExt as _; +use url::Url; + +pub trait TestServer { + fn method(&self, method: Method, path: &str) -> TestRequest; + + fn get(&self, path: &str) -> TestRequest { + self.method(Method::GET, path) + } + + fn post(&self, path: &str) -> TestRequest { + self.method(Method::POST, path) + } +} + +impl TestServer for Router { + fn method(&self, method: Method, path: &str) -> TestRequest { + let url = format!("https://example{path}"); + TestRequest { + router: self.clone(), + method, + url: url.parse().unwrap(), + body: None, + headers: HeaderMap::new(), + } + } +} + +pub struct TestRequest { + router: Router, + method: Method, + url: Url, + body: Option<Vec<u8>>, + headers: HeaderMap, +} + +impl TestRequest { + #[track_caller] + pub fn query<T: Serialize>(mut self, k: &str, v: T) -> Self { + let mut pairs = self.url.query_pairs_mut(); + let serializer = serde_urlencoded::Serializer::new(&mut pairs); + [(k, v)].serialize(serializer).unwrap(); + drop(pairs); + self + } + + pub fn json<T: Serialize>(mut self, body: &T) -> Self { + assert!(self.body.is_none()); + let bytes = serde_json::to_vec(body).unwrap(); + self.body = Some(bytes); + self.headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + self + } + + pub fn deflate(mut self) -> Self { + let body = self.body.unwrap(); + let mut compressor = libdeflater::Compressor::new(CompressionLvl::fastest()); + let mut compressed = vec![0; compressor.deflate_compress_bound(body.len())]; + let nb = compressor.deflate_compress(&body, &mut compressed).unwrap(); + compressed.truncate(nb); + self.body = Some(compressed); + self.headers.insert( + header::CONTENT_ENCODING, + HeaderValue::from_static("deflate"), + ); + self + } + + pub fn remove(mut self, k: impl AsHeaderName) -> Self { + self.headers.remove(k); + self + } + + pub fn header<V>(mut self, k: impl IntoHeaderName, v: V) -> Self + where + V: TryInto<HeaderValue>, + V::Error: Debug, + { + self.headers.insert(k, v.try_into().unwrap()); + self + } + + async fn send(self) -> TestResponse { + let TestRequest { + router, + method, + url: uri, + body, + headers, + } = self; + let uri = Uri::try_from(uri.as_str()).unwrap(); + let mut builder = axum::http::request::Builder::new() + .method(&method) + .uri(&uri); + for (k, v) in headers { + if let Some(k) = k { + builder = builder.header(k, v); + } else { + builder = builder.header("", v); + } + } + + let resp = router + .oneshot(builder.body(Body::from(body.unwrap_or_default())).unwrap()) + .await + .unwrap(); + let (parts, body) = resp.into_parts(); + let bytes = body.collect().await.unwrap(); + TestResponse { + bytes: bytes.to_bytes(), + method, + status: parts.status, + uri, + } + } +} + +impl IntoFuture for TestRequest { + type Output = TestResponse; + type IntoFuture = Pin<Box<dyn Future<Output = Self::Output>>>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(self.send()) + } +} + +pub struct TestResponse { + bytes: Bytes, + method: Method, + uri: Uri, + status: StatusCode, +} + +impl TestResponse { + #[track_caller] + pub fn json_parse<'de, T: Deserialize<'de>>(&'de self) -> T { + let TestResponse { + status, + bytes, + method, + uri, + } = self; + match serde_json::from_slice(bytes) { + Ok(body) => body, + Err(err) => match serde_json::from_slice::<serde_json::Value>(bytes) { + Ok(raw) => panic!("{method} {uri} {status} invalid JSON schema: {err}\n{raw}"), + Err(err) => panic!( + "{method} {uri} {status} invalid JSON body: {err}\n{}", + String::from_utf8_lossy(bytes) + ), + }, + } + } + + #[track_caller] + pub fn assert_status(&self, expected: StatusCode) { + let TestResponse { + status, + bytes, + method, + uri, + } = self; + if expected != *status { + if status.is_success() || bytes.is_empty() { + panic!("{method} {uri} expected {expected} got {status}"); + } else { + let err: ErrorDetail = self.json_parse(); + let description = err.hint.unwrap_or_default(); + panic!( + "{method} {uri} expected {expected} got {status}: {} {description}", + err.code + ); + } + } + } + + #[track_caller] + pub fn assert_ok_json<'de, T: Deserialize<'de>>(&'de self) -> T { + self.assert_ok(); + self.json_parse() + } + + #[track_caller] + pub fn assert_ok(&self) { + self.assert_status(StatusCode::OK); + } + + #[track_caller] + pub fn assert_no_content(&self) { + self.assert_status(StatusCode::NO_CONTENT); + } + + #[track_caller] + pub fn assert_error(&self, error_code: ErrorCode) { + let (status_code, _) = error_code.metadata(); + self.assert_error_status(error_code, StatusCode::from_u16(status_code).unwrap()); + } + + #[track_caller] + pub fn assert_error_status(&self, error_code: ErrorCode, status: StatusCode) { + self.assert_status(status); + let err: ErrorDetail = self.json_parse(); + assert_eq!(error_code as u32, err.code); + } + + #[track_caller] + pub fn query<T: DeserializeOwned>(&self) -> T { + Query::try_from_uri(&self.uri).unwrap().0 + } +} diff --git a/taler-magnet-bank/tests/api.rs b/taler-magnet-bank/tests/api.rs @@ -25,12 +25,11 @@ use taler_common::{ }; use taler_magnet_bank::{adapter::MagnetApi, db, magnet_payto}; use taler_test_utils::{ - axum_test::TestServer, - db_test_setup, + Router, db_test_setup, routine::{admin_add_incoming_routine, revenue_routine, routine_pagination, transfer_routine}, }; -async fn setup() -> (TestServer, PgPool) { +async fn setup() -> (Router, PgPool) { let pool = db_test_setup("magnet-bank").await; let api = Arc::new( MagnetApi::start( @@ -39,11 +38,10 @@ async fn setup() -> (TestServer, PgPool) { ) .await, ); - let builder = TalerApiBuilder::new() + let server = TalerApiBuilder::new() .wire_gateway(api.clone(), AuthMethod::None) .revenue(api, AuthMethod::None) .finalize(); - let server = TestServer::new(builder).unwrap(); (server, pool) }