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:
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)
}