taler-rust

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

commit 793ad8fc05059918de7d7010a8bf28dca4d425cf
parent c052c27629a09fbe2ba15744d43fdbf60e42157b
Author: Antoine A <>
Date:   Wed,  6 May 2026 10:25:48 +0200

taler-api: make all tests unit tests

Diffstat:
Mcommon/http-client/Cargo.toml | 1+
Mcommon/taler-api/src/lib.rs | 2++
Mcommon/taler-api/src/notification.rs | 2+-
Acommon/taler-api/src/test.rs | 281+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/taler-api/src/test/api.rs | 253+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/taler-api/src/test/db.rs | 402+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dcommon/taler-api/tests/api.rs | 172-------------------------------------------------------------------------------
Dcommon/taler-api/tests/common/db.rs | 401-------------------------------------------------------------------------------
Dcommon/taler-api/tests/common/mod.rs | 292-------------------------------------------------------------------------------
Dcommon/taler-api/tests/security.rs | 115-------------------------------------------------------------------------------
10 files changed, 940 insertions(+), 981 deletions(-)

diff --git a/common/http-client/Cargo.toml b/common/http-client/Cargo.toml @@ -9,6 +9,7 @@ license-file.workspace = true [lib] doctest = false +test = false [dependencies] serde_json = { workspace = true, features = ["raw_value"] } diff --git a/common/taler-api/src/lib.rs b/common/taler-api/src/lib.rs @@ -30,6 +30,8 @@ pub mod error; pub mod extract; pub mod notification; pub mod subject; +#[cfg(test)] +pub mod test; pub enum Serve { Tcp(SocketAddr), diff --git a/common/taler-api/src/notification.rs b/common/taler-api/src/notification.rs @@ -38,7 +38,7 @@ macro_rules! notification_listener { match notification.channel() { $($channel => { let ($($arg,)*): ($($type,)*) = - ::taler_api::notification::de::from_str(notification.payload()).unwrap();// TODO error handling + $crate::notification::de::from_str(notification.payload()).unwrap();// TODO error handling $lambda }),* unknown => unreachable!("{}", unknown), diff --git a/common/taler-api/src/test.rs b/common/taler-api/src/test.rs @@ -0,0 +1,281 @@ +use std::sync::{Arc, LazyLock}; + +use axum::{ + Router, + http::{StatusCode, header}, +}; +use serde_json::json; +use sqlx::{PgPool, Row as _, postgres::PgRow}; +use taler_common::{ + api_common::{HashCode, ShortHashCode}, + api_revenue::RevenueConfig, + api_transfer::PreparedTransferConfig, + api_wire::{OutgoingHistory, TransferRequest, TransferResponse, TransferState, WireConfig}, + db::IncomingType, + error_code::ErrorCode, + types::{ + amount::{Amount, Currency, amount}, + base32::Base32, + payto::{PaytoURI, payto}, + url, + }, +}; +use taler_test_utils::{ + db::db_test_setup_manual, + routine::{ + Status, admin_add_incoming_routine, in_history_routine, registration_routine, + revenue_routine, routine_pagination, transfer_routine, + }, + server::TestServer, +}; +use tokio::sync::watch::Sender; + +use crate::{ + api::TalerRouter, + auth::AuthMethod, + constants::MAX_BODY_LENGTH, + db::TypeHelper as _, + test::{api::TestApi, db::notification_listener}, +}; + +mod api; +mod db; + +fn test_api(pool: PgPool, currency: Currency) -> Router { + let outgoing_channel = Sender::new(0); + let incoming_channel = Sender::new(0); + let wg = TestApi { + currency, + pool: pool.clone(), + outgoing_channel: outgoing_channel.clone(), + incoming_channel: incoming_channel.clone(), + }; + tokio::spawn(notification_listener( + pool, + outgoing_channel, + incoming_channel, + )); + let state = Arc::new(wg); + Router::new() + .wire_gateway(state.clone(), AuthMethod::None) + .prepared_transfer(state.clone()) + .revenue(state, AuthMethod::None) +} + +async fn setup() -> (Router, PgPool) { + let (_, pool) = db_test_setup_manual("db".as_ref(), "taler-api").await; + ( + test_api(pool.clone(), "EUR".parse().unwrap()).finalize(), + pool, + ) +} + +#[tokio::test] +async fn body_parsing() { + let (server, _) = setup().await; + let normal_body = TransferRequest { + request_uid: Base32::rand(), + amount: Amount::zero(&Currency::EUR), + exchange_base_url: url("https://test.com"), + wtid: Base32::rand(), + credit_account: payto("payto:://test?receiver-name=lol"), + metadata: None, + }; + + // 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); +} + +static PAYTO: LazyLock<PaytoURI> = LazyLock::new(|| payto("payto://test?receiver-name=Test")); + +#[tokio::test] +async fn errors() { + let (server, _) = setup().await; + server + .get("/unknown") + .await + .assert_error(ErrorCode::GENERIC_ENDPOINT_UNKNOWN); + server + .post("/taler-revenue/config") + .await + .assert_error(ErrorCode::GENERIC_METHOD_INVALID); +} + +#[tokio::test] +async fn config() { + let (server, _) = setup().await; + server + .get("/taler-wire-gateway/config") + .await + .assert_ok_json::<WireConfig>(); + server + .get("/taler-prepared-transfer/config") + .await + .assert_ok_json::<PreparedTransferConfig>(); + server + .get("/taler-revenue/config") + .await + .assert_ok_json::<RevenueConfig>(); +} + +#[tokio::test] +async fn transfer() { + let (server, _) = setup().await; + transfer_routine(&server, TransferState::success, &PAYTO).await; +} + +#[tokio::test] +async fn outgoing_history() { + let (server, _) = setup().await; + routine_pagination::<OutgoingHistory>( + &server, + "/taler-wire-gateway/history/outgoing", + async |i| { + server + .post("/taler-wire-gateway/transfer") + .json(json!({ + "request_uid": HashCode::rand(), + "amount": amount(format!("EUR:0.0{i}")), + "exchange_base_url": url("http://exchange.taler"), + "wtid": ShortHashCode::rand(), + "credit_account": PAYTO.clone(), + })) + .await + .assert_ok_json::<TransferResponse>(); + }, + ) + .await; +} + +#[tokio::test] +async fn admin_add_incoming() { + let (server, _) = setup().await; + admin_add_incoming_routine(&server, &PAYTO, true).await; +} + +#[tokio::test] +async fn in_history() { + let (server, _) = setup().await; + in_history_routine(&server, &PAYTO, true).await; +} + +#[tokio::test] +async fn revenue() { + let (server, _) = setup().await; + revenue_routine(&server, &PAYTO, true).await; +} + +#[tokio::test] +async fn account_check() { + let (server, _) = setup().await; + server + .get("/taler-wire-gateway/account/check") + .query("account", "payto://test") + .await + .assert_status(StatusCode::NOT_IMPLEMENTED); +} + +async fn check_in(pool: &PgPool) -> Vec<Status> { + sqlx::query( + " + SELECT pending_recurrent_in.authorization_pub IS NOT NULL, bounced.tx_in_id IS NOT NULL, type, taler_in.account_pub + FROM tx_in + LEFT JOIN taler_in USING (tx_in_id) + LEFT JOIN pending_recurrent_in USING (tx_in_id) + LEFT JOIN bounced USING (tx_in_id) + ORDER BY tx_in.tx_in_id + ", + ) + .try_map(|r: PgRow| { + Ok( + if r.try_get_flag(0)? { + Status::Pending + } else if r.try_get_flag(1)? { + Status::Bounced + } else { + match r.try_get(2)? { + None => Status::Simple, + Some(IncomingType::reserve) => Status::Reserve(r.try_get(3)?), + Some(IncomingType::kyc) => Status::Kyc(r.try_get(3)?), + Some(e) => unreachable!("{e:?}") + } + } + ) + }) + .fetch_all(pool) + .await + .unwrap() +} + +#[tokio::test] +async fn registration() { + let (server, pool) = setup().await; + registration_routine(&server, &PAYTO, || check_in(&pool)).await; +} diff --git a/common/taler-api/src/test/api.rs b/common/taler-api/src/test/api.rs @@ -0,0 +1,253 @@ +/* + This file is part of TALER + Copyright (C) 2024, 2025, 2026 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 jiff::Timestamp; +use sqlx::PgPool; +use taler_common::{ + api_params::{History, Page}, + api_revenue::RevenueIncomingHistory, + api_transfer::{ + RegistrationRequest, RegistrationResponse, SubjectFormat, TransferSubject, Unregistration, + }, + api_wire::{ + AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest, + IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse, + TransferState, TransferStatus, + }, + db::IncomingType, + error_code::ErrorCode, + types::{ + amount::Currency, + payto::{FullQuery, payto}, + timestamp::TalerTimestamp, + }, +}; +use tokio::sync::watch::Sender; + +use crate::{ + api::{TalerApi, revenue::Revenue, transfer::PreparedTransfer, wire::WireGateway}, + error::{ApiResult, failure, failure_code}, + subject::fmt_in_subject, + test::db::{self, AddIncomingResult}, +}; + +/// Taler API implementation for tests +pub struct TestApi { + pub currency: Currency, + pub pool: PgPool, + pub outgoing_channel: Sender<i64>, + pub incoming_channel: Sender<i64>, +} + +impl TalerApi for TestApi { + fn currency(&self) -> &str { + self.currency.as_ref() + } + + fn implementation(&self) -> &'static str { + "urn:net:taler:specs:taler-test-api:taler-rust" + } +} + +impl WireGateway for TestApi { + async fn transfer(&self, req: TransferRequest) -> ApiResult<TransferResponse> { + req.credit_account.query::<FullQuery>()?; + let result = db::transfer(&self.pool, &req).await?; + match result { + db::TransferResult::Success(transfer_response) => Ok(transfer_response), + db::TransferResult::RequestUidReuse => { + Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED)) + } + db::TransferResult::WtidReuse => { + Err(failure_code(ErrorCode::BANK_TRANSFER_WTID_REUSED)) + } + } + } + + async fn transfer_page( + &self, + page: Page, + status: Option<TransferState>, + ) -> ApiResult<TransferList> { + Ok(TransferList { + transfers: db::transfer_page(&self.pool, &status, &page, &self.currency).await?, + debit_account: payto("payto://test"), + }) + } + + async fn transfer_by_id(&self, id: u64) -> ApiResult<Option<TransferStatus>> { + Ok(db::transfer_by_id(&self.pool, id, &self.currency).await?) + } + + async fn outgoing_history(&self, params: History) -> ApiResult<OutgoingHistory> { + let txs = db::outgoing_revenue(&self.pool, &params, &self.currency, || { + self.outgoing_channel.subscribe() + }) + .await?; + Ok(OutgoingHistory { + outgoing_transactions: txs, + debit_account: payto("payto://test"), + }) + } + + async fn incoming_history(&self, params: History) -> ApiResult<IncomingHistory> { + let txs = db::incoming_history(&self.pool, &params, &self.currency, || { + self.incoming_channel.subscribe() + }) + .await?; + Ok(IncomingHistory { + incoming_transactions: txs, + credit_account: payto("payto://test"), + }) + } + + async fn add_incoming_reserve( + &self, + req: AddIncomingRequest, + ) -> ApiResult<AddIncomingResponse> { + let res = db::add_incoming( + &self.pool, + &req.amount, + &req.debit_account, + "", + &Timestamp::now(), + IncomingType::reserve, + &req.reserve_pub, + ) + .await?; + match res { + AddIncomingResult::Success { id, created_at } => Ok(AddIncomingResponse { + timestamp: created_at.into(), + row_id: id, + }), + AddIncomingResult::ReservePubReuse => { + Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)) + } + AddIncomingResult::UnknownMapping | AddIncomingResult::MappingReuse => { + unreachable!("mapping not used") + } + } + } + + async fn add_incoming_kyc(&self, req: AddKycauthRequest) -> ApiResult<AddIncomingResponse> { + let res = db::add_incoming( + &self.pool, + &req.amount, + &req.debit_account, + "", + &Timestamp::now(), + IncomingType::kyc, + &req.account_pub, + ) + .await?; + match res { + AddIncomingResult::Success { id, created_at } => Ok(AddIncomingResponse { + timestamp: created_at.into(), + row_id: id, + }), + AddIncomingResult::ReservePubReuse => { + Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)) + } + AddIncomingResult::UnknownMapping | AddIncomingResult::MappingReuse => { + unreachable!("mapping not used") + } + } + } + + async fn add_incoming_mapped(&self, req: AddMappedRequest) -> ApiResult<AddIncomingResponse> { + let res = db::add_incoming( + &self.pool, + &req.amount, + &req.debit_account, + "", + &Timestamp::now(), + IncomingType::map, + &req.authorization_pub, + ) + .await?; + match res { + AddIncomingResult::Success { id, created_at } => Ok(AddIncomingResponse { + timestamp: created_at.into(), + row_id: id, + }), + AddIncomingResult::ReservePubReuse => { + Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)) + } + AddIncomingResult::UnknownMapping => { + Err(failure_code(ErrorCode::BANK_TRANSFER_MAPPING_UNKNOWN)) + } + AddIncomingResult::MappingReuse => { + Err(failure_code(ErrorCode::BANK_TRANSFER_MAPPING_REUSED)) + } + } + } + + fn support_account_check(&self) -> bool { + false + } +} + +impl Revenue for TestApi { + async fn history(&self, params: History) -> ApiResult<RevenueIncomingHistory> { + let txs = db::revenue_history(&self.pool, &params, &self.currency, || { + self.incoming_channel.subscribe() + }) + .await?; + Ok(RevenueIncomingHistory { + incoming_transactions: txs, + credit_account: payto("payto://test"), + }) + } +} + +impl PreparedTransfer for TestApi { + fn supported_formats(&self) -> &[SubjectFormat] { + &[SubjectFormat::SIMPLE] + } + + async fn registration(&self, req: RegistrationRequest) -> ApiResult<RegistrationResponse> { + match db::transfer_register(&self.pool, &req).await? { + db::RegistrationResult::Success => { + let simple = TransferSubject::Simple { + credit_amount: req.credit_amount, + subject: if req.authorization_pub == req.account_pub && !req.recurrent { + fmt_in_subject(req.r#type.into(), &req.account_pub) + } else { + fmt_in_subject(IncomingType::map, &req.authorization_pub) + }, + }; + ApiResult::Ok(RegistrationResponse { + subjects: vec![simple], + expiration: TalerTimestamp::Never, + }) + } + db::RegistrationResult::ReservePubReuse => { + ApiResult::Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)) + } + } + } + + async fn unregistration(&self, req: Unregistration) -> ApiResult<()> { + if !db::transfer_unregister(&self.pool, &req).await? { + Err(failure( + ErrorCode::BANK_TRANSACTION_NOT_FOUND, + format!("Prepared transfer '{}' not found", req.authorization_pub), + )) + } else { + Ok(()) + } + } +} diff --git a/common/taler-api/src/test/db.rs b/common/taler-api/src/test/db.rs @@ -0,0 +1,402 @@ +/* + This file is part of TALER + Copyright (C) 2024, 2025, 2026 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 jiff::Timestamp; +use sqlx::{PgPool, QueryBuilder, Row, postgres::PgRow}; +use taler_common::{ + api_common::{EddsaPublicKey, SafeU64}, + api_params::{History, Page}, + api_revenue::RevenueIncomingBankTransaction, + api_transfer::{RegistrationRequest, Unregistration}, + api_wire::{ + IncomingBankTransaction, OutgoingBankTransaction, TransferListStatus, TransferRequest, + TransferResponse, TransferState, TransferStatus, + }, + db::IncomingType, + types::{ + amount::{Amount, Currency}, + payto::PaytoURI, + }, +}; +use tokio::sync::watch::{Receiver, Sender}; + +use crate::{ + db::{BindHelper as _, TypeHelper as _, history, page}, + notification_listener, serialized, + subject::fmt_out_subject, +}; + +pub async fn notification_listener( + pool: PgPool, + outgoing_channel: Sender<i64>, + incoming_channel: Sender<i64>, +) -> sqlx::Result<()> { + notification_listener!(&pool, + "outgoing_tx" => (row_id: i64) { + outgoing_channel.send_replace(row_id); + }, + "incoming_tx" => (row_id: i64) { + incoming_channel.send_replace(row_id); + } + ) +} + +pub enum TransferResult { + Success(TransferResponse), + RequestUidReuse, + WtidReuse, +} + +pub async fn transfer(db: &PgPool, req: &TransferRequest) -> sqlx::Result<TransferResult> { + let subject = fmt_out_subject(&req.wtid, &req.exchange_base_url, req.metadata.as_deref()); + serialized!( + sqlx::query( + " + SELECT out_request_uid_reuse, out_wtid_reuse, out_transfer_row_id, out_created_at + FROM taler_transfer($1,$2,$3,$4,$5,$6,$7,$8) + ", + ) + .bind(req.amount) + .bind(req.exchange_base_url.as_str()) + .bind(&req.metadata) + .bind(&subject) + .bind(req.credit_account.raw()) + .bind(&req.request_uid) + .bind(&req.wtid) + .bind_timestamp(&Timestamp::now()) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_request_uid_reuse")? { + TransferResult::RequestUidReuse + } else if r.try_get_flag("out_wtid_reuse")? { + TransferResult::WtidReuse + } else { + TransferResult::Success(TransferResponse { + row_id: r.try_get_safeu64("out_transfer_row_id")?, + timestamp: r.try_get_timestamp("out_created_at")?.into(), + }) + }) + }) + .fetch_one(db) + ) +} + +pub async fn transfer_page( + db: &PgPool, + status: &Option<TransferState>, + params: &Page, + currency: &Currency, +) -> sqlx::Result<Vec<TransferListStatus>> { + page( + db, + params, + "transfer_id", + || { + let mut builder = QueryBuilder::new( + " + SELECT + transfer_id, + status, + amount, + credit_payto, + created_at + FROM transfer + JOIN tx_out USING (tx_out_id) + WHERE + ", + ); + if let Some(status) = status { + builder.push(" status = ").push_bind(status).push(" AND "); + } + builder + }, + |r: PgRow| { + Ok(TransferListStatus { + row_id: r.try_get_safeu64("transfer_id")?, + status: r.try_get("status")?, + amount: r.try_get_amount("amount", currency)?, + credit_account: r.try_get_payto("credit_payto")?, + timestamp: r.try_get_timestamp("created_at")?.into(), + }) + }, + ) + .await +} + +pub async fn transfer_by_id( + db: &PgPool, + id: u64, + currency: &Currency, +) -> sqlx::Result<Option<TransferStatus>> { + serialized!( + sqlx::query( + " + SELECT + status, + status_msg, + amount, + exchange_base_url, + metadata, + wtid, + credit_payto, + created_at + FROM transfer + JOIN tx_out USING (tx_out_id) + WHERE transfer_id = $1 + ", + ) + .bind(id as i64) + .try_map(|r: PgRow| { + Ok(TransferStatus { + status: r.try_get("status")?, + status_msg: r.try_get("status_msg")?, + amount: r.try_get_amount("amount", currency)?, + origin_exchange_url: r.try_get("exchange_base_url")?, + metadata: r.try_get("metadata")?, + wtid: r.try_get("wtid")?, + credit_account: r.try_get_payto("credit_payto")?, + timestamp: r.try_get_timestamp("created_at")?.into(), + }) + }) + .fetch_optional(db) + ) +} + +pub async fn outgoing_revenue( + db: &PgPool, + params: &History, + currency: &Currency, + listen: impl FnOnce() -> Receiver<i64>, +) -> sqlx::Result<Vec<OutgoingBankTransaction>> { + history( + db, + "transfer_id", + params, + listen, + || { + QueryBuilder::new( + " + SELECT + transfer_id, + amount, + exchange_base_url, + metadata, + wtid, + credit_payto, + created_at + FROM transfer + JOIN tx_out USING (tx_out_id) + WHERE status = 'success' AND + ", + ) + }, + |r| { + Ok(OutgoingBankTransaction { + amount: r.try_get_amount("amount", currency)?, + debit_fee: None, + wtid: r.try_get("wtid")?, + credit_account: r.try_get_payto("credit_payto")?, + row_id: r.try_get_safeu64("transfer_id")?, + date: r.try_get_timestamp("created_at")?.into(), + exchange_base_url: r.try_get_url("exchange_base_url")?, + metadata: r.try_get("metadata")?, + }) + }, + ) + .await +} + +pub enum AddIncomingResult { + Success { id: SafeU64, created_at: Timestamp }, + ReservePubReuse, + UnknownMapping, + MappingReuse, +} + +pub async fn add_incoming( + db: &PgPool, + amount: &Amount, + debit_account: &PaytoURI, + subject: &str, + timestamp: &Timestamp, + ty: IncomingType, + account_pub: &EddsaPublicKey, +) -> sqlx::Result<AddIncomingResult> { + serialized!( + sqlx::query( + "SELECT out_reserve_pub_reuse, out_mapping_reuse, out_unknown_mapping, out_tx_row_id, out_created_at + FROM add_incoming($1,$2,$3,$4,$5,$6)", + ) + .bind(amount) + .bind(subject) + .bind(debit_account.raw()) + .bind(ty) + .bind(account_pub) + .bind_timestamp(timestamp) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_reserve_pub_reuse")? { + AddIncomingResult::ReservePubReuse + } else if r.try_get_flag("out_mapping_reuse")? { + AddIncomingResult::MappingReuse + } else if r.try_get_flag("out_unknown_mapping")? { + AddIncomingResult::UnknownMapping + } else{ + AddIncomingResult::Success { + id: r.try_get_safeu64("out_tx_row_id")?, + created_at: r.try_get_timestamp("out_created_at")?, + } + }) + }) + .fetch_one(db) + ) +} + +pub async fn incoming_history( + db: &PgPool, + params: &History, + currency: &Currency, + listen: impl FnOnce() -> Receiver<i64>, +) -> sqlx::Result<Vec<IncomingBankTransaction>> { + history( + db, + "tx_in_id", + params, + listen, + || { + QueryBuilder::new( + " + SELECT + type, + tx_in_id, + amount, + created_at, + debit_payto, + account_pub, + authorization_pub, + authorization_sig + FROM tx_in + JOIN taler_in USING (tx_in_id) + WHERE + ", + ) + }, + |r: PgRow| { + Ok(match r.try_get("type")? { + IncomingType::reserve => IncomingBankTransaction::Reserve { + row_id: r.try_get_safeu64("tx_in_id")?, + date: r.try_get_timestamp("created_at")?.into(), + amount: r.try_get_amount("amount", currency)?, + credit_fee: None, + debit_account: r.try_get_payto("debit_payto")?, + reserve_pub: r.try_get("account_pub")?, + authorization_pub: r.try_get("authorization_pub")?, + authorization_sig: r.try_get("authorization_sig")?, + }, + IncomingType::kyc => IncomingBankTransaction::Kyc { + row_id: r.try_get_safeu64("tx_in_id")?, + date: r.try_get_timestamp("created_at")?.into(), + amount: r.try_get_amount("amount", currency)?, + credit_fee: None, + debit_account: r.try_get_payto("debit_payto")?, + account_pub: r.try_get("account_pub")?, + authorization_pub: r.try_get("authorization_pub")?, + authorization_sig: r.try_get("authorization_sig")?, + }, + IncomingType::map => unimplemented!("MAP are never listed in the history"), + }) + }, + ) + .await +} + +pub async fn revenue_history( + db: &PgPool, + params: &History, + currency: &Currency, + listen: impl FnOnce() -> Receiver<i64>, +) -> sqlx::Result<Vec<RevenueIncomingBankTransaction>> { + history( + db, + "tx_in_id", + params, + listen, + || { + QueryBuilder::new( + " + SELECT + tx_in_id, + amount, + created_at, + debit_payto, + subject + FROM tx_in + WHERE + ", + ) + }, + |r: PgRow| { + Ok(RevenueIncomingBankTransaction { + row_id: r.try_get_safeu64("tx_in_id")?, + date: r.try_get_timestamp("created_at")?.into(), + amount: r.try_get_amount("amount", currency)?, + credit_fee: None, + debit_account: r.try_get_payto("debit_payto")?, + subject: r.try_get("subject")?, + }) + }, + ) + .await +} + +pub enum RegistrationResult { + Success, + ReservePubReuse, +} + +pub async fn transfer_register( + db: &PgPool, + req: &RegistrationRequest, +) -> sqlx::Result<RegistrationResult> { + let ty: IncomingType = req.r#type.into(); + serialized!( + sqlx::query( + "SELECT out_reserve_pub_reuse FROM register_prepared_transfers($1,$2,$3,$4,$5,$6)" + ) + .bind(ty) + .bind(&req.account_pub) + .bind(&req.authorization_pub) + .bind(&req.authorization_sig) + .bind(req.recurrent) + .bind_timestamp(&Timestamp::now()) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_reserve_pub_reuse")? { + RegistrationResult::ReservePubReuse + } else { + RegistrationResult::Success + }) + }) + .fetch_one(db) + ) +} + +pub async fn transfer_unregister(db: &PgPool, req: &Unregistration) -> sqlx::Result<bool> { + serialized!( + sqlx::query("SELECT out_found FROM delete_prepared_transfers($1,$2)") + .bind(&req.authorization_pub) + .bind_timestamp(&Timestamp::now()) + .try_map(|r: PgRow| r.try_get_flag("out_found")) + .fetch_one(db) + ) +} diff --git a/common/taler-api/tests/api.rs b/common/taler-api/tests/api.rs @@ -1,172 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2024, 2025, 2026 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::sync::LazyLock; - -use axum::http::StatusCode; -use common::setup; -use sqlx::{PgPool, Row, postgres::PgRow}; -use taler_api::db::TypeHelper as _; -use taler_common::{ - api_common::{HashCode, ShortHashCode}, - api_revenue::RevenueConfig, - api_transfer::PreparedTransferConfig, - api_wire::{OutgoingHistory, TransferResponse, TransferState, WireConfig}, - db::IncomingType, - error_code::ErrorCode, - types::{ - amount::amount, - payto::{PaytoURI, payto}, - url, - }, -}; -use taler_test_utils::{ - json, - routine::{ - Status, admin_add_incoming_routine, in_history_routine, registration_routine, - revenue_routine, routine_pagination, transfer_routine, - }, - server::TestServer as _, -}; - -mod common; - -static PAYTO: LazyLock<PaytoURI> = LazyLock::new(|| payto("payto://test?receiver-name=Test")); - -#[tokio::test] -async fn errors() { - let (server, _) = setup().await; - server - .get("/unknown") - .await - .assert_error(ErrorCode::GENERIC_ENDPOINT_UNKNOWN); - server - .post("/taler-revenue/config") - .await - .assert_error(ErrorCode::GENERIC_METHOD_INVALID); -} - -#[tokio::test] -async fn config() { - let (server, _) = setup().await; - server - .get("/taler-wire-gateway/config") - .await - .assert_ok_json::<WireConfig>(); - server - .get("/taler-prepared-transfer/config") - .await - .assert_ok_json::<PreparedTransferConfig>(); - server - .get("/taler-revenue/config") - .await - .assert_ok_json::<RevenueConfig>(); -} - -#[tokio::test] -async fn transfer() { - let (server, _) = setup().await; - transfer_routine(&server, TransferState::success, &PAYTO).await; -} - -#[tokio::test] -async fn outgoing_history() { - let (server, _) = setup().await; - routine_pagination::<OutgoingHistory>( - &server, - "/taler-wire-gateway/history/outgoing", - async |i| { - server - .post("/taler-wire-gateway/transfer") - .json(json!({ - "request_uid": HashCode::rand(), - "amount": amount(format!("EUR:0.0{i}")), - "exchange_base_url": url("http://exchange.taler"), - "wtid": ShortHashCode::rand(), - "credit_account": PAYTO.clone(), - })) - .await - .assert_ok_json::<TransferResponse>(); - }, - ) - .await; -} - -#[tokio::test] -async fn admin_add_incoming() { - let (server, _) = setup().await; - admin_add_incoming_routine(&server, &PAYTO, true).await; -} - -#[tokio::test] -async fn in_history() { - let (server, _) = setup().await; - in_history_routine(&server, &PAYTO, true).await; -} - -#[tokio::test] -async fn revenue() { - let (server, _) = setup().await; - revenue_routine(&server, &PAYTO, true).await; -} - -#[tokio::test] -async fn account_check() { - let (server, _) = setup().await; - server - .get("/taler-wire-gateway/account/check") - .query("account", "payto://test") - .await - .assert_status(StatusCode::NOT_IMPLEMENTED); -} - -async fn check_in(pool: &PgPool) -> Vec<Status> { - sqlx::query( - " - SELECT pending_recurrent_in.authorization_pub IS NOT NULL, bounced.tx_in_id IS NOT NULL, type, taler_in.account_pub - FROM tx_in - LEFT JOIN taler_in USING (tx_in_id) - LEFT JOIN pending_recurrent_in USING (tx_in_id) - LEFT JOIN bounced USING (tx_in_id) - ORDER BY tx_in.tx_in_id - ", - ) - .try_map(|r: PgRow| { - Ok( - if r.try_get_flag(0)? { - Status::Pending - } else if r.try_get_flag(1)? { - Status::Bounced - } else { - match r.try_get(2)? { - None => Status::Simple, - Some(IncomingType::reserve) => Status::Reserve(r.try_get(3)?), - Some(IncomingType::kyc) => Status::Kyc(r.try_get(3)?), - Some(e) => unreachable!("{e:?}") - } - } - ) - }) - .fetch_all(pool) - .await - .unwrap() -} - -#[tokio::test] -async fn registration() { - let (server, pool) = setup().await; - registration_routine(&server, &PAYTO, || check_in(&pool)).await; -} diff --git a/common/taler-api/tests/common/db.rs b/common/taler-api/tests/common/db.rs @@ -1,401 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2024, 2025, 2026 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 jiff::Timestamp; -use sqlx::{PgPool, QueryBuilder, Row, postgres::PgRow}; -use taler_api::{ - db::{BindHelper, TypeHelper, history, page}, - serialized, - subject::fmt_out_subject, -}; -use taler_common::{ - api_common::{EddsaPublicKey, SafeU64}, - api_params::{History, Page}, - api_revenue::RevenueIncomingBankTransaction, - api_transfer::{RegistrationRequest, Unregistration}, - api_wire::{ - IncomingBankTransaction, OutgoingBankTransaction, TransferListStatus, TransferRequest, - TransferResponse, TransferState, TransferStatus, - }, - db::IncomingType, - types::{ - amount::{Amount, Currency}, - payto::PaytoURI, - }, -}; -use tokio::sync::watch::{Receiver, Sender}; - -pub async fn notification_listener( - pool: PgPool, - outgoing_channel: Sender<i64>, - incoming_channel: Sender<i64>, -) -> sqlx::Result<()> { - taler_api::notification::notification_listener!(&pool, - "outgoing_tx" => (row_id: i64) { - outgoing_channel.send_replace(row_id); - }, - "incoming_tx" => (row_id: i64) { - incoming_channel.send_replace(row_id); - } - ) -} - -pub enum TransferResult { - Success(TransferResponse), - RequestUidReuse, - WtidReuse, -} - -pub async fn transfer(db: &PgPool, req: &TransferRequest) -> sqlx::Result<TransferResult> { - let subject = fmt_out_subject(&req.wtid, &req.exchange_base_url, req.metadata.as_deref()); - serialized!( - sqlx::query( - " - SELECT out_request_uid_reuse, out_wtid_reuse, out_transfer_row_id, out_created_at - FROM taler_transfer($1,$2,$3,$4,$5,$6,$7,$8) - ", - ) - .bind(req.amount) - .bind(req.exchange_base_url.as_str()) - .bind(&req.metadata) - .bind(&subject) - .bind(req.credit_account.raw()) - .bind(&req.request_uid) - .bind(&req.wtid) - .bind_timestamp(&Timestamp::now()) - .try_map(|r: PgRow| { - Ok(if r.try_get_flag("out_request_uid_reuse")? { - TransferResult::RequestUidReuse - } else if r.try_get_flag("out_wtid_reuse")? { - TransferResult::WtidReuse - } else { - TransferResult::Success(TransferResponse { - row_id: r.try_get_safeu64("out_transfer_row_id")?, - timestamp: r.try_get_timestamp("out_created_at")?.into(), - }) - }) - }) - .fetch_one(db) - ) -} - -pub async fn transfer_page( - db: &PgPool, - status: &Option<TransferState>, - params: &Page, - currency: &Currency, -) -> sqlx::Result<Vec<TransferListStatus>> { - page( - db, - params, - "transfer_id", - || { - let mut builder = QueryBuilder::new( - " - SELECT - transfer_id, - status, - amount, - credit_payto, - created_at - FROM transfer - JOIN tx_out USING (tx_out_id) - WHERE - ", - ); - if let Some(status) = status { - builder.push(" status = ").push_bind(status).push(" AND "); - } - builder - }, - |r: PgRow| { - Ok(TransferListStatus { - row_id: r.try_get_safeu64("transfer_id")?, - status: r.try_get("status")?, - amount: r.try_get_amount("amount", currency)?, - credit_account: r.try_get_payto("credit_payto")?, - timestamp: r.try_get_timestamp("created_at")?.into(), - }) - }, - ) - .await -} - -pub async fn transfer_by_id( - db: &PgPool, - id: u64, - currency: &Currency, -) -> sqlx::Result<Option<TransferStatus>> { - serialized!( - sqlx::query( - " - SELECT - status, - status_msg, - amount, - exchange_base_url, - metadata, - wtid, - credit_payto, - created_at - FROM transfer - JOIN tx_out USING (tx_out_id) - WHERE transfer_id = $1 - ", - ) - .bind(id as i64) - .try_map(|r: PgRow| { - Ok(TransferStatus { - status: r.try_get("status")?, - status_msg: r.try_get("status_msg")?, - amount: r.try_get_amount("amount", currency)?, - origin_exchange_url: r.try_get("exchange_base_url")?, - metadata: r.try_get("metadata")?, - wtid: r.try_get("wtid")?, - credit_account: r.try_get_payto("credit_payto")?, - timestamp: r.try_get_timestamp("created_at")?.into(), - }) - }) - .fetch_optional(db) - ) -} - -pub async fn outgoing_revenue( - db: &PgPool, - params: &History, - currency: &Currency, - listen: impl FnOnce() -> Receiver<i64>, -) -> sqlx::Result<Vec<OutgoingBankTransaction>> { - history( - db, - "transfer_id", - params, - listen, - || { - QueryBuilder::new( - " - SELECT - transfer_id, - amount, - exchange_base_url, - metadata, - wtid, - credit_payto, - created_at - FROM transfer - JOIN tx_out USING (tx_out_id) - WHERE status = 'success' AND - ", - ) - }, - |r| { - Ok(OutgoingBankTransaction { - amount: r.try_get_amount("amount", currency)?, - debit_fee: None, - wtid: r.try_get("wtid")?, - credit_account: r.try_get_payto("credit_payto")?, - row_id: r.try_get_safeu64("transfer_id")?, - date: r.try_get_timestamp("created_at")?.into(), - exchange_base_url: r.try_get_url("exchange_base_url")?, - metadata: r.try_get("metadata")?, - }) - }, - ) - .await -} - -pub enum AddIncomingResult { - Success { id: SafeU64, created_at: Timestamp }, - ReservePubReuse, - UnknownMapping, - MappingReuse, -} - -pub async fn add_incoming( - db: &PgPool, - amount: &Amount, - debit_account: &PaytoURI, - subject: &str, - timestamp: &Timestamp, - ty: IncomingType, - account_pub: &EddsaPublicKey, -) -> sqlx::Result<AddIncomingResult> { - serialized!( - sqlx::query( - "SELECT out_reserve_pub_reuse, out_mapping_reuse, out_unknown_mapping, out_tx_row_id, out_created_at - FROM add_incoming($1,$2,$3,$4,$5,$6)", - ) - .bind(amount) - .bind(subject) - .bind(debit_account.raw()) - .bind(ty) - .bind(account_pub) - .bind_timestamp(timestamp) - .try_map(|r: PgRow| { - Ok(if r.try_get_flag("out_reserve_pub_reuse")? { - AddIncomingResult::ReservePubReuse - } else if r.try_get_flag("out_mapping_reuse")? { - AddIncomingResult::MappingReuse - } else if r.try_get_flag("out_unknown_mapping")? { - AddIncomingResult::UnknownMapping - } else{ - AddIncomingResult::Success { - id: r.try_get_safeu64("out_tx_row_id")?, - created_at: r.try_get_timestamp("out_created_at")?, - } - }) - }) - .fetch_one(db) - ) -} - -pub async fn incoming_history( - db: &PgPool, - params: &History, - currency: &Currency, - listen: impl FnOnce() -> Receiver<i64>, -) -> sqlx::Result<Vec<IncomingBankTransaction>> { - history( - db, - "tx_in_id", - params, - listen, - || { - QueryBuilder::new( - " - SELECT - type, - tx_in_id, - amount, - created_at, - debit_payto, - account_pub, - authorization_pub, - authorization_sig - FROM tx_in - JOIN taler_in USING (tx_in_id) - WHERE - ", - ) - }, - |r: PgRow| { - Ok(match r.try_get("type")? { - IncomingType::reserve => IncomingBankTransaction::Reserve { - row_id: r.try_get_safeu64("tx_in_id")?, - date: r.try_get_timestamp("created_at")?.into(), - amount: r.try_get_amount("amount", currency)?, - credit_fee: None, - debit_account: r.try_get_payto("debit_payto")?, - reserve_pub: r.try_get("account_pub")?, - authorization_pub: r.try_get("authorization_pub")?, - authorization_sig: r.try_get("authorization_sig")?, - }, - IncomingType::kyc => IncomingBankTransaction::Kyc { - row_id: r.try_get_safeu64("tx_in_id")?, - date: r.try_get_timestamp("created_at")?.into(), - amount: r.try_get_amount("amount", currency)?, - credit_fee: None, - debit_account: r.try_get_payto("debit_payto")?, - account_pub: r.try_get("account_pub")?, - authorization_pub: r.try_get("authorization_pub")?, - authorization_sig: r.try_get("authorization_sig")?, - }, - IncomingType::map => unimplemented!("MAP are never listed in the history"), - }) - }, - ) - .await -} - -pub async fn revenue_history( - db: &PgPool, - params: &History, - currency: &Currency, - listen: impl FnOnce() -> Receiver<i64>, -) -> sqlx::Result<Vec<RevenueIncomingBankTransaction>> { - history( - db, - "tx_in_id", - params, - listen, - || { - QueryBuilder::new( - " - SELECT - tx_in_id, - amount, - created_at, - debit_payto, - subject - FROM tx_in - WHERE - ", - ) - }, - |r: PgRow| { - Ok(RevenueIncomingBankTransaction { - row_id: r.try_get_safeu64("tx_in_id")?, - date: r.try_get_timestamp("created_at")?.into(), - amount: r.try_get_amount("amount", currency)?, - credit_fee: None, - debit_account: r.try_get_payto("debit_payto")?, - subject: r.try_get("subject")?, - }) - }, - ) - .await -} - -pub enum RegistrationResult { - Success, - ReservePubReuse, -} - -pub async fn transfer_register( - db: &PgPool, - req: &RegistrationRequest, -) -> sqlx::Result<RegistrationResult> { - let ty: IncomingType = req.r#type.into(); - serialized!( - sqlx::query( - "SELECT out_reserve_pub_reuse FROM register_prepared_transfers($1,$2,$3,$4,$5,$6)" - ) - .bind(ty) - .bind(&req.account_pub) - .bind(&req.authorization_pub) - .bind(&req.authorization_sig) - .bind(req.recurrent) - .bind_timestamp(&Timestamp::now()) - .try_map(|r: PgRow| { - Ok(if r.try_get_flag("out_reserve_pub_reuse")? { - RegistrationResult::ReservePubReuse - } else { - RegistrationResult::Success - }) - }) - .fetch_one(db) - ) -} - -pub async fn transfer_unregister(db: &PgPool, req: &Unregistration) -> sqlx::Result<bool> { - serialized!( - sqlx::query("SELECT out_found FROM delete_prepared_transfers($1,$2)") - .bind(&req.authorization_pub) - .bind_timestamp(&Timestamp::now()) - .try_map(|r: PgRow| r.try_get_flag("out_found")) - .fetch_one(db) - ) -} diff --git a/common/taler-api/tests/common/mod.rs b/common/taler-api/tests/common/mod.rs @@ -1,292 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2024, 2025, 2026 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::sync::Arc; - -use db::notification_listener; -use jiff::Timestamp; -use sqlx::PgPool; -use taler_api::{ - api::{ - Router, TalerApi, TalerRouter as _, revenue::Revenue, transfer::PreparedTransfer, - wire::WireGateway, - }, - auth::AuthMethod, - error::{ApiResult, failure, failure_code}, - subject::fmt_in_subject, -}; -use taler_common::{ - api_params::{History, Page}, - api_revenue::RevenueIncomingHistory, - api_transfer::{ - RegistrationRequest, RegistrationResponse, SubjectFormat, TransferSubject, Unregistration, - }, - api_wire::{ - AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest, - IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse, - TransferState, TransferStatus, - }, - db::IncomingType, - error_code::ErrorCode, - types::{ - amount::Currency, - payto::{FullQuery, payto}, - timestamp::TalerTimestamp, - }, -}; -use taler_test_utils::db::db_test_setup_manual; -use tokio::sync::watch::Sender; - -use crate::common::db::AddIncomingResult; - -pub mod db; - -/// Taler API implementation for tests -pub struct TestApi { - currency: Currency, - pool: PgPool, - outgoing_channel: Sender<i64>, - incoming_channel: Sender<i64>, -} - -impl TalerApi for TestApi { - fn currency(&self) -> &str { - self.currency.as_ref() - } - - fn implementation(&self) -> &'static str { - "urn:net:taler:specs:taler-test-api:taler-rust" - } -} - -impl WireGateway for TestApi { - async fn transfer(&self, req: TransferRequest) -> ApiResult<TransferResponse> { - req.credit_account.query::<FullQuery>()?; - let result = db::transfer(&self.pool, &req).await?; - match result { - db::TransferResult::Success(transfer_response) => Ok(transfer_response), - db::TransferResult::RequestUidReuse => { - Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED)) - } - db::TransferResult::WtidReuse => { - Err(failure_code(ErrorCode::BANK_TRANSFER_WTID_REUSED)) - } - } - } - - async fn transfer_page( - &self, - page: Page, - status: Option<TransferState>, - ) -> ApiResult<TransferList> { - Ok(TransferList { - transfers: db::transfer_page(&self.pool, &status, &page, &self.currency).await?, - debit_account: payto("payto://test"), - }) - } - - async fn transfer_by_id(&self, id: u64) -> ApiResult<Option<TransferStatus>> { - Ok(db::transfer_by_id(&self.pool, id, &self.currency).await?) - } - - async fn outgoing_history(&self, params: History) -> ApiResult<OutgoingHistory> { - let txs = db::outgoing_revenue(&self.pool, &params, &self.currency, || { - self.outgoing_channel.subscribe() - }) - .await?; - Ok(OutgoingHistory { - outgoing_transactions: txs, - debit_account: payto("payto://test"), - }) - } - - async fn incoming_history(&self, params: History) -> ApiResult<IncomingHistory> { - let txs = db::incoming_history(&self.pool, &params, &self.currency, || { - self.incoming_channel.subscribe() - }) - .await?; - Ok(IncomingHistory { - incoming_transactions: txs, - credit_account: payto("payto://test"), - }) - } - - async fn add_incoming_reserve( - &self, - req: AddIncomingRequest, - ) -> ApiResult<AddIncomingResponse> { - let res = db::add_incoming( - &self.pool, - &req.amount, - &req.debit_account, - "", - &Timestamp::now(), - IncomingType::reserve, - &req.reserve_pub, - ) - .await?; - match res { - AddIncomingResult::Success { id, created_at } => Ok(AddIncomingResponse { - timestamp: created_at.into(), - row_id: id, - }), - AddIncomingResult::ReservePubReuse => { - Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)) - } - AddIncomingResult::UnknownMapping | AddIncomingResult::MappingReuse => { - unreachable!("mapping not used") - } - } - } - - async fn add_incoming_kyc(&self, req: AddKycauthRequest) -> ApiResult<AddIncomingResponse> { - let res = db::add_incoming( - &self.pool, - &req.amount, - &req.debit_account, - "", - &Timestamp::now(), - IncomingType::kyc, - &req.account_pub, - ) - .await?; - match res { - AddIncomingResult::Success { id, created_at } => Ok(AddIncomingResponse { - timestamp: created_at.into(), - row_id: id, - }), - AddIncomingResult::ReservePubReuse => { - Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)) - } - AddIncomingResult::UnknownMapping | AddIncomingResult::MappingReuse => { - unreachable!("mapping not used") - } - } - } - - async fn add_incoming_mapped(&self, req: AddMappedRequest) -> ApiResult<AddIncomingResponse> { - let res = db::add_incoming( - &self.pool, - &req.amount, - &req.debit_account, - "", - &Timestamp::now(), - IncomingType::map, - &req.authorization_pub, - ) - .await?; - match res { - AddIncomingResult::Success { id, created_at } => Ok(AddIncomingResponse { - timestamp: created_at.into(), - row_id: id, - }), - AddIncomingResult::ReservePubReuse => { - Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)) - } - AddIncomingResult::UnknownMapping => { - Err(failure_code(ErrorCode::BANK_TRANSFER_MAPPING_UNKNOWN)) - } - AddIncomingResult::MappingReuse => { - Err(failure_code(ErrorCode::BANK_TRANSFER_MAPPING_REUSED)) - } - } - } - - fn support_account_check(&self) -> bool { - false - } -} - -impl Revenue for TestApi { - async fn history(&self, params: History) -> ApiResult<RevenueIncomingHistory> { - let txs = db::revenue_history(&self.pool, &params, &self.currency, || { - self.incoming_channel.subscribe() - }) - .await?; - Ok(RevenueIncomingHistory { - incoming_transactions: txs, - credit_account: payto("payto://test"), - }) - } -} - -impl PreparedTransfer for TestApi { - fn supported_formats(&self) -> &[SubjectFormat] { - &[SubjectFormat::SIMPLE] - } - - async fn registration(&self, req: RegistrationRequest) -> ApiResult<RegistrationResponse> { - match db::transfer_register(&self.pool, &req).await? { - db::RegistrationResult::Success => { - let simple = TransferSubject::Simple { - credit_amount: req.credit_amount, - subject: if req.authorization_pub == req.account_pub && !req.recurrent { - fmt_in_subject(req.r#type.into(), &req.account_pub) - } else { - fmt_in_subject(IncomingType::map, &req.authorization_pub) - }, - }; - ApiResult::Ok(RegistrationResponse { - subjects: vec![simple], - expiration: TalerTimestamp::Never, - }) - } - db::RegistrationResult::ReservePubReuse => { - ApiResult::Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)) - } - } - } - - async fn unregistration(&self, req: Unregistration) -> ApiResult<()> { - if !db::transfer_unregister(&self.pool, &req).await? { - Err(failure( - ErrorCode::BANK_TRANSACTION_NOT_FOUND, - format!("Prepared transfer '{}' not found", req.authorization_pub), - )) - } else { - Ok(()) - } - } -} - -pub fn test_api(pool: PgPool, currency: Currency) -> Router { - let outgoing_channel = Sender::new(0); - let incoming_channel = Sender::new(0); - let wg = TestApi { - currency, - pool: pool.clone(), - outgoing_channel: outgoing_channel.clone(), - incoming_channel: incoming_channel.clone(), - }; - tokio::spawn(notification_listener( - pool, - outgoing_channel, - incoming_channel, - )); - let state = Arc::new(wg); - Router::new() - .wire_gateway(state.clone(), AuthMethod::None) - .prepared_transfer(state.clone()) - .revenue(state, AuthMethod::None) -} - -pub async fn setup() -> (Router, PgPool) { - let (_, pool) = db_test_setup_manual("db".as_ref(), "taler-api").await; - ( - test_api(pool.clone(), "EUR".parse().unwrap()).finalize(), - pool, - ) -} diff --git a/common/taler-api/tests/security.rs b/common/taler-api/tests/security.rs @@ -1,115 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2025, 2026 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, Currency}, - 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(&Currency::EUR), - exchange_base_url: url("https://test.com"), - wtid: Base32::rand(), - credit_account: payto("payto:://test?receiver-name=lol"), - metadata: None, - }; - - // 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); -}