commit 793ad8fc05059918de7d7010a8bf28dca4d425cf
parent c052c27629a09fbe2ba15744d43fdbf60e42157b
Author: Antoine A <>
Date: Wed, 6 May 2026 10:25:48 +0200
taler-api: make all tests unit tests
Diffstat:
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, ¶ms, &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, ¶ms, &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, ¶ms, &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, ¶ms, &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, ¶ms, &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, ¶ms, &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);
-}