taler-rust

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

commit ee271578e49912f80d5a458f1a5473c53d9819a0
parent 124cee60a5900413da674278ab1668a66f1c9a7a
Author: Antoine A <>
Date:   Tue, 31 Mar 2026 15:50:53 +0200

common: add new wire gateway test endpoint and improve routines

Diffstat:
Mcommon/taler-api/src/api/wire.rs | 21+++++++++++++++++----
Mcommon/taler-api/tests/api.rs | 71++++++++++-------------------------------------------------------------
Mcommon/taler-api/tests/common/mod.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mcommon/taler-common/src/api_wire.rs | 11++++++++---
Mcommon/taler-common/src/error_code.rs | 38++++++++++++++++++++++++++++++++++++++
Mcommon/taler-test-utils/src/routine.rs | 332+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mcommon/taler-test-utils/src/server.rs | 1+
Mtaler-cyclos/db/cyclos-procedures.sql | 3+--
Mtaler-cyclos/src/api.rs | 105+++++++++++++++++++++++++++++++++++--------------------------------------------
Mtaler-cyclos/src/db.rs | 12------------
Mtaler-magnet-bank/db/magnet-bank-procedures.sql | 5++---
Mtaler-magnet-bank/src/api.rs | 99+++++++++++++++++++++++++++++++++++++------------------------------------------
Mtaler-magnet-bank/src/db.rs | 12------------
13 files changed, 426 insertions(+), 354 deletions(-)

diff --git a/common/taler-api/src/api/wire.rs b/common/taler-api/src/api/wire.rs @@ -27,9 +27,9 @@ use regex::Regex; use taler_common::{ api_params::{AccountParams, History, HistoryParams, Page, TransferParams}, api_wire::{ - AccountInfo, AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, - AddKycauthResponse, IncomingHistory, OutgoingHistory, TransferList, TransferRequest, - TransferResponse, TransferState, TransferStatus, WireConfig, + AccountInfo, AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest, + IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse, + TransferState, TransferStatus, WireConfig, }, error_code::ErrorCode, }; @@ -73,7 +73,11 @@ pub trait WireGateway: TalerApi { fn add_incoming_kyc( &self, req: AddKycauthRequest, - ) -> impl std::future::Future<Output = ApiResult<AddKycauthResponse>> + Send; + ) -> impl std::future::Future<Output = ApiResult<AddIncomingResponse>> + Send; + fn add_incoming_mapped( + &self, + req: AddMappedRequest, + ) -> impl std::future::Future<Output = ApiResult<AddIncomingResponse>> + Send; fn support_account_check(&self) -> bool; @@ -189,6 +193,15 @@ pub fn router<I: WireGateway>(state: Arc<I>, auth: AuthMethod) -> Router { ), ) .route( + "/admin/add-mapped", + post( + async |State(state): State<Arc<I>>, Req(req): Req<AddMappedRequest>| { + state.check_currency(&req.amount)?; + ApiResult::Ok(Json(state.add_incoming_mapped(req).await?)) + }, + ), + ) + .route( "/account/check", get( async |State(state): State<Arc<I>>, Query(params): Query<AccountParams>| match state diff --git a/common/taler-api/tests/api.rs b/common/taler-api/tests/api.rs @@ -18,11 +18,10 @@ use std::sync::LazyLock; use axum::http::StatusCode; use common::setup; -use jiff::Timestamp; use sqlx::{PgPool, Row, postgres::PgRow}; use taler_api::db::TypeHelper as _; use taler_common::{ - api_common::{EddsaPublicKey, HashCode, ShortHashCode}, + api_common::{HashCode, ShortHashCode}, api_revenue::RevenueConfig, api_transfer::PreparedTransferConfig, api_wire::{OutgoingHistory, TransferResponse, TransferState, WireConfig}, @@ -37,14 +36,11 @@ use taler_common::{ use taler_test_utils::{ json, routine::{ - Status, admin_add_incoming_routine, registration_routine, revenue_routine, - routine_pagination, transfer_routine, + Status, admin_add_incoming_routine, in_history_routine, registration_routine, + revenue_routine, routine_pagination, transfer_routine, }, server::TestServer as _, }; -use tracing::warn; - -use crate::common::db::{AddIncomingResult, add_incoming}; mod common; @@ -116,6 +112,12 @@ async fn admin_add_incoming() { } #[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; @@ -163,61 +165,8 @@ async fn check_in(pool: &PgPool) -> Vec<Status> { .unwrap() } -async fn register_mapped(pool: &PgPool, account_pub: &EddsaPublicKey) { - let reason = match add_incoming( - pool, - &amount("EUR:42"), - &PAYTO, - "lol", - &Timestamp::now(), - IncomingType::map, - account_pub, - ) - .await - .unwrap() - { - AddIncomingResult::Success { .. } => return, - AddIncomingResult::ReservePubReuse => "reserve pub reuse", - AddIncomingResult::UnknownMapping => "unknown mapping", - AddIncomingResult::MappingReuse => "mapping reuse", - }; - warn!("Bounce {reason}"); - sqlx::query( - " - WITH tx_in AS ( - INSERT INTO tx_in ( - amount, - debit_payto, - created_at, - subject - ) VALUES ( - (32, 0), - 'payto', - 0, - 'subject' - ) RETURNING tx_in_id - ) - INSERT INTO bounced (tx_in_id) - SELECT tx_in_id FROM tx_in - ", - ) - .execute(pool) - .await - .unwrap(); -} - #[tokio::test] async fn registration() { let (server, pool) = setup().await; - registration_routine( - &server, - &PAYTO, - || check_in(&pool), - |account_pub| { - let account_pub = account_pub.clone(); - let pool = &pool; - async move { register_mapped(pool, &account_pub).await } - }, - ) - .await; + registration_routine(&server, &PAYTO, || check_in(&pool)).await; } diff --git a/common/taler-api/tests/common/mod.rs b/common/taler-api/tests/common/mod.rs @@ -35,7 +35,7 @@ use taler_common::{ RegistrationRequest, RegistrationResponse, SubjectFormat, TransferSubject, Unregistration, }, api_wire::{ - AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddKycauthResponse, + AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest, IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse, TransferState, TransferStatus, }, @@ -50,6 +50,8 @@ use taler_common::{ 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 @@ -76,14 +78,12 @@ impl WireGateway for TestApi { let result = db::transfer(&self.pool, &req).await?; match result { db::TransferResult::Success(transfer_response) => Ok(transfer_response), - db::TransferResult::RequestUidReuse => Err(failure( - ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED, - "request_uid used already", - )), - db::TransferResult::WtidReuse => Err(failure( - ErrorCode::BANK_TRANSFER_WTID_REUSED, - "wtid used already", - )), + db::TransferResult::RequestUidReuse => { + Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED)) + } + db::TransferResult::WtidReuse => { + Err(failure_code(ErrorCode::BANK_TRANSFER_WTID_REUSED)) + } } } @@ -139,21 +139,20 @@ impl WireGateway for TestApi { ) .await?; match res { - db::AddIncomingResult::Success { id, created_at } => Ok(AddIncomingResponse { + AddIncomingResult::Success { id, created_at } => Ok(AddIncomingResponse { timestamp: created_at.into(), row_id: id, }), - db::AddIncomingResult::ReservePubReuse => Err(failure( - ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT, - "reserve_pub used already".to_owned(), - )), - db::AddIncomingResult::UnknownMapping | db::AddIncomingResult::MappingReuse => { + 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<AddKycauthResponse> { + async fn add_incoming_kyc(&self, req: AddKycauthRequest) -> ApiResult<AddIncomingResponse> { let res = db::add_incoming( &self.pool, &req.amount, @@ -165,20 +164,47 @@ impl WireGateway for TestApi { ) .await?; match res { - db::AddIncomingResult::Success { id, created_at } => Ok(AddKycauthResponse { + AddIncomingResult::Success { id, created_at } => Ok(AddIncomingResponse { timestamp: created_at.into(), row_id: id, }), - db::AddIncomingResult::ReservePubReuse => Err(failure( - ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT, - "reserve_pub used already".to_owned(), - )), - db::AddIncomingResult::UnknownMapping | db::AddIncomingResult::MappingReuse => { + 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 } diff --git a/common/taler-common/src/api_wire.rs b/common/taler-common/src/api_wire.rs @@ -172,13 +172,18 @@ pub struct AddKycauthRequest { pub debit_account: PaytoURI, } +/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-AddMappedRequest> +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddMappedRequest { + pub amount: Amount, + pub authorization_pub: EddsaPublicKey, + pub debit_account: PaytoURI, +} + /// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-AccountInfo> #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AccountInfo {} -/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-AddKycauthResponse> -pub type AddKycauthResponse = AddIncomingResponse; - #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, sqlx::Type)] #[allow(non_camel_case_types)] #[sqlx(type_name = "transfer_status")] diff --git a/common/taler-common/src/error_code.rs b/common/taler-common/src/error_code.rs @@ -700,6 +700,12 @@ pub enum ErrorCode { MERCHANT_GENERIC_DONAU_CHARITY_UNKNOWN = 2039, /// The merchant does not expect any transfer with the given ID and can thus not return any details about it. MERCHANT_GENERIC_EXPECTED_TRANSFER_UNKNOWN = 2040, + /// The Donau is not known to the backend. + MERCHANT_GENERIC_DONAU_UNKNOWN = 2041, + /// The access token is not known to the backend. + MERCHANT_GENERIC_ACCESS_TOKEN_UNKNOWN = 2042, + /// One of the binaries needed to generate the PDF is not installed. If this feature is required, the system administrator should make sure Typst and pdftk are both installed. + MERCHANT_GENERIC_NO_TYPST_OR_PDFTK = 2048, /// The exchange failed to provide a valid answer to the tracking request, thus those details are not in the response. MERCHANT_GET_ORDERS_EXCHANGE_TRACKING_FAILURE = 2100, /// The merchant backend failed to construct the request for tracking to the exchange, thus tracking details are not in the response. @@ -844,6 +850,8 @@ pub enum ErrorCode { MERCHANT_POST_ORDERS_ID_CLAIM_ALREADY_CLAIMED = 2301, /// The client-side experienced an internal failure. MERCHANT_POST_ORDERS_ID_CLAIM_CLIENT_INTERNAL_FAILURE = 2302, + /// The unclaim signature of the wallet is not valid for the given contract hash. + MERCHANT_POST_ORDERS_UNCLAIM_SIGNATURE_INVALID = 2303, /// The backend failed to sign the refund request. MERCHANT_POST_ORDERS_ID_REFUND_SIGNATURE_FAILED = 2350, /// The client failed to unblind the signature returned by the merchant. @@ -892,6 +900,8 @@ pub enum ErrorCode { MERCHANT_PRIVATE_DELETE_ORDERS_AWAITING_PAYMENT = 2520, /// The order provided to the backend could not be deleted as the order was already paid. MERCHANT_PRIVATE_DELETE_ORDERS_ALREADY_PAID = 2521, + /// The client requested a report granularity that is not available at the backend. Possible solutions include extending the backend code and/or the database statistic triggers to support the desired data granularity. Alternatively, the client could request a different granularity. + MERCHANT_PRIVATE_GET_STATISTICS_REPORT_GRANULARITY_UNAVAILABLE = 2525, /// The amount to be refunded is inconsistent: either is lower than the previous amount being awarded, or it exceeds the original price paid by the customer. MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_INCONSISTENT_AMOUNT = 2530, /// Only paid orders can be refunded, and the frontend specified an unpaid order to issue a refund for. @@ -1134,6 +1144,10 @@ pub enum ErrorCode { BANK_BAD_SIGNATURE = 5160, /// The provided timestamp is too old. BANK_OLD_TIMESTAMP = 5161, + /// The authorization_pub for a request to transfer funds has already been used for another non recurrent transfer. + BANK_TRANSFER_MAPPING_REUSED = 5162, + /// The authorization_pub for a request to transfer funds is not currently registered. + BANK_TRANSFER_MAPPING_UNKNOWN = 5163, /// The sync service failed find the account in its database. SYNC_ACCOUNT_UNKNOWN = 6100, /// The SHA-512 hash provided in the If-None-Match header is malformed. @@ -2730,6 +2744,14 @@ impl ErrorCode { 404, "The merchant does not expect any transfer with the given ID and can thus not return any details about it.", ), + MERCHANT_GENERIC_DONAU_UNKNOWN => (404, "The Donau is not known to the backend."), + MERCHANT_GENERIC_ACCESS_TOKEN_UNKNOWN => { + (404, "The access token is not known to the backend.") + } + MERCHANT_GENERIC_NO_TYPST_OR_PDFTK => ( + 501, + "One of the binaries needed to generate the PDF is not installed. If this feature is required, the system administrator should make sure Typst and pdftk are both installed.", + ), MERCHANT_GET_ORDERS_EXCHANGE_TRACKING_FAILURE => ( 200, "The exchange failed to provide a valid answer to the tracking request, thus those details are not in the response.", @@ -3008,6 +3030,10 @@ impl ErrorCode { MERCHANT_POST_ORDERS_ID_CLAIM_CLIENT_INTERNAL_FAILURE => { (0, "The client-side experienced an internal failure.") } + MERCHANT_POST_ORDERS_UNCLAIM_SIGNATURE_INVALID => ( + 403, + "The unclaim signature of the wallet is not valid for the given contract hash.", + ), MERCHANT_POST_ORDERS_ID_REFUND_SIGNATURE_FAILED => { (0, "The backend failed to sign the refund request.") } @@ -3099,6 +3125,10 @@ impl ErrorCode { 409, "The order provided to the backend could not be deleted as the order was already paid.", ), + MERCHANT_PRIVATE_GET_STATISTICS_REPORT_GRANULARITY_UNAVAILABLE => ( + 410, + "The client requested a report granularity that is not available at the backend. Possible solutions include extending the backend code and/or the database statistic triggers to support the desired data granularity. Alternatively, the client could request a different granularity.", + ), MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_INCONSISTENT_AMOUNT => ( 409, "The amount to be refunded is inconsistent: either is lower than the previous amount being awarded, or it exceeds the original price paid by the customer.", @@ -3519,6 +3549,14 @@ impl ErrorCode { BANK_DERIVATION_REUSE => (409, "The derived subject is already used."), BANK_BAD_SIGNATURE => (409, "The provided signature is invalid."), BANK_OLD_TIMESTAMP => (409, "The provided timestamp is too old."), + BANK_TRANSFER_MAPPING_REUSED => ( + 409, + "The authorization_pub for a request to transfer funds has already been used for another non recurrent transfer.", + ), + BANK_TRANSFER_MAPPING_UNKNOWN => ( + 409, + "The authorization_pub for a request to transfer funds is not currently registered.", + ), SYNC_ACCOUNT_UNKNOWN => ( 404, "The sync service failed find the account in its database.", diff --git a/common/taler-test-utils/src/routine.rs b/common/taler-test-utils/src/routine.rs @@ -145,56 +145,6 @@ async fn assert_time<R: Debug>(range: std::ops::Range<u128>, task: impl Future<O } } -async fn check_history_trigger<T: Page>( - server: &Router, - url: &str, - lambda: impl AsyncFnOnce() -> (), -) { - // Check history is following specs - macro_rules! assert_history { - ($args:expr, $size:expr) => { - async { - server - .get(&format!("{url}?{}", $args)) - .await - .assert_ids::<T>($size) - } - }; - } - // Get latest registered id - let latest_id = latest_id::<T>(server, url).await; - tokio::join!( - // Check polling succeed - assert_time( - 100..400, - assert_history!( - format_args!("limit=2&offset={latest_id}&timeout_ms=1000"), - 1 - ) - ), - assert_time( - 200..500, - assert_history!( - format_args!("limit=1&offset={}&timeout_ms=200", latest_id + 3), - 0 - ) - ), - async { - sleep(Duration::from_millis(100)).await; - lambda().await - } - ); -} - -async fn check_history_in_trigger(server: &Router, lambda: impl AsyncFnOnce() -> ()) { - check_history_trigger::<IncomingHistory>( - server, - "/taler-wire-gateway/history/incoming", - lambda, - ) - .await; -} - pub async fn routine_history<T: Page>( server: &Router, url: &str, @@ -359,7 +309,7 @@ impl TestResponse { } // Get currency from config -async fn get_wire_currency(server: &Router) -> String { +async fn get_currency(server: &Router) -> String { let config = server .get("/taler-wire-gateway/config") .await @@ -374,7 +324,7 @@ pub async fn transfer_routine( default_status: TransferState, credit_account: &PaytoURI, ) { - let currency = &get_wire_currency(server).await; + let currency = &get_currency(server).await; let default_amount = amount(format!("{currency}:42")); let request_uid = HashCode::rand(); let wtid = ShortHashCode::rand(); @@ -625,11 +575,27 @@ async fn add_incoming_routine( let (path, key) = match kind { IncomingType::reserve => ("/taler-wire-gateway/admin/add-incoming", "reserve_pub"), IncomingType::kyc => ("/taler-wire-gateway/admin/add-kycauth", "account_pub"), - IncomingType::map => unreachable!(), + IncomingType::map => ("/taler-wire-gateway/admin/add-mapped", "authorization_pub"), }; + let key_pair = Ed25519KeyPair::generate().unwrap(); + let pub_key = EddsaPublicKey::try_from(key_pair.public_key().as_ref()).unwrap(); + // Valid + server + .post("/taler-prepared-transfer/registration") + .json(&json!({ + "type": "reserve", + "credit_amount": format!("{currency}:44"), + "alg": "EdDSA", + "account_pub": pub_key, + "authorization_pub": pub_key, + "authorization_sig": eddsa_sign(&key_pair, pub_key.as_ref()), + "recurrent": false + })) + .await + .assert_ok_json::<RegistrationResponse>(); let valid_req = json!({ "amount": format!("{currency}:44"), - key: EddsaPublicKey::rand(), + key: pub_key, "debit_account": debit_acount, }); @@ -651,7 +617,22 @@ async fn add_incoming_routine( // Non conflict on reuse server.post(path).json(&valid_req).await.assert_ok(); } - IncomingType::map => unreachable!(), + IncomingType::map => { + // Trigger conflict due to reused authorization_pub + server + .post(path) + .json(&valid_req) + .await + .assert_error(ErrorCode::BANK_TRANSFER_MAPPING_REUSED); + // Trigger conflict due to unknown authorization_pub + server + .post(path) + .json(&json!(valid_req + { + key: EddsaPublicKey::rand() + })) + .await + .assert_error(ErrorCode::BANK_TRANSFER_MAPPING_UNKNOWN); + } } // Currency mismatch @@ -692,7 +673,7 @@ async fn add_incoming_routine( /// Test standard behavior of the revenue endpoints pub async fn revenue_routine(server: &Router, debit_acount: &PaytoURI, kyc: bool) { - let currency = &get_wire_currency(server).await; + let currency = &get_currency(server).await; routine_history::<RevenueIncomingHistory>( server, @@ -727,18 +708,19 @@ pub async fn revenue_routine(server: &Router, debit_acount: &PaytoURI, kyc: bool .await; } -/// Test standard behavior of the admin add incoming endpoints -pub async fn admin_add_incoming_routine(server: &Router, debit_acount: &PaytoURI, kyc: bool) { - let currency = &get_wire_currency(server).await; - +/// Test standard behavior of the incoming history endpoint +pub async fn in_history_routine(server: &Router, debit_acount: &PaytoURI, kyc: bool) { + let currency = &get_currency(server).await; // History // TODO check non taler some are ignored + let mut key_pair = Ed25519KeyPair::generate().unwrap(); + let len = if kyc { 6 } else { 3 }; routine_history::<IncomingHistory>( server, "/taler-wire-gateway/history/incoming", - 2, - async |i| { - if i % 2 == 0 || !kyc { + len, + async |i| match i % len { + 0 => { server .post("/taler-wire-gateway/admin/add-incoming") .json(&json!({ @@ -748,7 +730,62 @@ pub async fn admin_add_incoming_routine(server: &Router, debit_acount: &PaytoURI })) .await .assert_ok_json::<TransferResponse>(); - } else { + } + 1 => { + key_pair = Ed25519KeyPair::generate().unwrap(); + let auth_pub = EddsaPublicKey::try_from(key_pair.public_key().as_ref()).unwrap(); + let reserve_pub = EddsaPublicKey::rand(); + let amount = format!("{currency}:0.0{i}"); + server + .post("/taler-prepared-transfer/registration") + .json(&json!({ + "credit_amount": amount, + "type": "reserve", + "alg": "EdDSA", + "account_pub": reserve_pub, + "authorization_pub": auth_pub, + "authorization_sig": eddsa_sign(&key_pair, reserve_pub.as_ref()), + "recurrent": true + })) + .await + .assert_ok_json::<RegistrationResponse>(); + server + .post("/taler-wire-gateway/admin/add-mapped") + .json(&json!({ + "amount": amount, + "authorization_pub": auth_pub, + "debit_account": debit_acount, + })) + .await + .assert_ok_json::<TransferResponse>(); + server + .post("/taler-wire-gateway/admin/add-mapped") + .json(&json!({ + "amount": amount, + "authorization_pub": auth_pub, + "debit_account": debit_acount, + })) + .await + .assert_ok_json::<TransferResponse>(); + } + 2 => { + let auth_pub = EddsaPublicKey::try_from(key_pair.public_key().as_ref()).unwrap(); + let reserve_pub = EddsaPublicKey::rand(); + server + .post("/taler-prepared-transfer/registration") + .json(&json!({ + "credit_amount": format!("{currency}:0.0{i}"), + "type": "reserve", + "alg": "EdDSA", + "account_pub": reserve_pub, + "authorization_pub": auth_pub, + "authorization_sig": eddsa_sign(&key_pair, reserve_pub.as_ref()), + "recurrent": true + })) + .await + .assert_ok_json::<RegistrationResponse>(); + } + 3 => { server .post("/taler-wire-gateway/admin/add-kycauth") .json(&json!({ @@ -759,29 +796,78 @@ pub async fn admin_add_incoming_routine(server: &Router, debit_acount: &PaytoURI .await .assert_ok_json::<TransferResponse>(); } + 4 => { + key_pair = Ed25519KeyPair::generate().unwrap(); + let auth_pub = EddsaPublicKey::try_from(key_pair.public_key().as_ref()).unwrap(); + let account_pub = EddsaPublicKey::rand(); + let amount = format!("{currency}:0.0{i}"); + server + .post("/taler-prepared-transfer/registration") + .json(&json!({ + "credit_amount": amount, + "type": "kyc", + "alg": "EdDSA", + "account_pub": account_pub, + "authorization_pub": auth_pub, + "authorization_sig": eddsa_sign(&key_pair, account_pub.as_ref()), + "recurrent": true + })) + .await + .assert_ok_json::<RegistrationResponse>(); + server + .post("/taler-wire-gateway/admin/add-mapped") + .json(&json!({ + "amount": amount, + "authorization_pub": auth_pub, + "debit_account": debit_acount, + })) + .await + .assert_ok_json::<TransferResponse>(); + server + .post("/taler-wire-gateway/admin/add-mapped") + .json(&json!({ + "amount": amount, + "authorization_pub": auth_pub, + "debit_account": debit_acount, + })) + .await + .assert_ok_json::<TransferResponse>(); + } + 5 => { + let auth_pub = EddsaPublicKey::try_from(key_pair.public_key().as_ref()).unwrap(); + let account_pub = EddsaPublicKey::rand(); + server + .post("/taler-prepared-transfer/registration") + .json(&json!({ + "credit_amount": format!("{currency}:0.0{i}"), + "type": "kyc", + "alg": "EdDSA", + "account_pub": account_pub, + "authorization_pub": auth_pub, + "authorization_sig": eddsa_sign(&key_pair, account_pub.as_ref()), + "recurrent": true + })) + .await + .assert_ok_json::<RegistrationResponse>(); + } + nb => unreachable!("unexpected state {nb}"), }, 0, async |_| {}, ) .await; - // Add incoming reserve +} + +/// Test standard behavior of the admin add incoming endpoints +pub async fn admin_add_incoming_routine(server: &Router, debit_acount: &PaytoURI, kyc: bool) { + let currency = &get_currency(server).await; add_incoming_routine(server, currency, IncomingType::reserve, debit_acount).await; + add_incoming_routine(server, currency, IncomingType::map, debit_acount).await; if kyc { - // Add incoming kyc add_incoming_routine(server, currency, IncomingType::kyc, debit_acount).await; } } -// Get currency from config -async fn get_transfer_currency(server: &Router) -> String { - let config = server - .get("/taler-prepared-transfer/config") - .await - .assert_ok_json::<serde_json::Value>(); - let currency = config["currency"].as_str().unwrap(); - currency.to_owned() -} - #[derive(Debug, PartialEq, Eq)] pub enum Status { Simple, @@ -793,11 +879,10 @@ pub enum Status { } /// Test standard registration behavior of the registration endpoints -pub async fn registration_routine<F1: Future<Output = Vec<Status>>, F2: Future<Output = ()>>( +pub async fn registration_routine<F1: Future<Output = Vec<Status>>>( server: &Router, account: &PaytoURI, mut in_status: impl FnMut() -> F1, - mut register: impl FnMut(&EddsaPublicKey) -> F2, ) { pub use Status::*; let mut check_in = async |state: &[Status]| { @@ -805,7 +890,7 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>, F2: Future<O assert_eq!(state, current); }; - let currency = &get_transfer_currency(server).await; + let currency = &get_currency(server).await; let amount = amount(format!("{currency}:42")); let key_pair1 = Ed25519KeyPair::generate().unwrap(); let auth_pub1 = EddsaPublicKey::try_from(key_pair1.public_key().as_ref()).unwrap(); @@ -819,6 +904,17 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>, F2: Future<O "recurrent": false }); + let register = async |auth_pub: &EddsaPublicKey| { + server + .post("/taler-wire-gateway/admin/add-mapped") + .json(&json!({ + "amount": format!("{currency}:42"), + "authorization_pub": auth_pub, + "debit_account": account, + })) + .await + }; + /* ----- Registration ----- */ let routine = async |ty: TransferType, account_pub: &EddsaPublicKey, @@ -908,10 +1004,13 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>, F2: Future<O })) .await .assert_ok_json::<RegistrationResponse>(); - check_history_in_trigger(server, async || register(&auth_pub1).await).await; + register(&auth_pub1) + .await + .assert_ok_json::<TransferResponse>(); check_in(&[Reserve(acc_pub1.clone())]).await; - register(&auth_pub1).await; - check_in(&[Reserve(acc_pub1.clone()), Bounced]).await; + register(&auth_pub1) + .await + .assert_error(ErrorCode::BANK_TRANSFER_MAPPING_REUSED); // Again without using mapping let acc_pub2 = EddsaPublicKey::rand(); @@ -932,14 +1031,10 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>, F2: Future<O })) .await .assert_ok(); - register(&auth_pub1).await; - check_in(&[ - Reserve(acc_pub1.clone()), - Bounced, - Reserve(acc_pub2.clone()), - Bounced, - ]) - .await; + register(&auth_pub1) + .await + .assert_error(ErrorCode::BANK_TRANSFER_MAPPING_REUSED); + check_in(&[Reserve(acc_pub1.clone()), Reserve(acc_pub2.clone())]).await; // Recurrent accept one and delay others let acc_pub3 = EddsaPublicKey::rand(); @@ -953,13 +1048,13 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>, F2: Future<O .await .assert_ok_json::<RegistrationResponse>(); for _ in 0..5 { - register(&auth_pub1).await; + register(&auth_pub1) + .await + .assert_ok_json::<TransferResponse>(); } check_in(&[ Reserve(acc_pub1.clone()), - Bounced, Reserve(acc_pub2.clone()), - Bounced, Reserve(acc_pub3.clone()), Pending, Pending, @@ -980,23 +1075,18 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>, F2: Future<O })) .await .assert_ok_json::<RegistrationResponse>(); - check_history_in_trigger(server, async || { - server - .post("/taler-prepared-transfer/registration") - .json(&json!(req + { - "account_pub": acc_pub4, - "authorization_sig": eddsa_sign(&key_pair1, acc_pub4.as_ref()), - "recurrent": true - })) - .await - .assert_ok_json::<RegistrationResponse>(); - }) - .await; + server + .post("/taler-prepared-transfer/registration") + .json(&json!(req + { + "account_pub": acc_pub4, + "authorization_sig": eddsa_sign(&key_pair1, acc_pub4.as_ref()), + "recurrent": true + })) + .await + .assert_ok_json::<RegistrationResponse>(); check_in(&[ Reserve(acc_pub1.clone()), - Bounced, Reserve(acc_pub2.clone()), - Bounced, Reserve(acc_pub3.clone()), Kyc(acc_pub4.clone()), Reserve(acc_pub4.clone()), @@ -1017,9 +1107,7 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>, F2: Future<O .assert_ok_json::<TransferResponse>(); check_in(&[ Reserve(acc_pub1.clone()), - Bounced, Reserve(acc_pub2.clone()), - Bounced, Reserve(acc_pub3.clone()), Kyc(acc_pub4.clone()), Reserve(acc_pub4.clone()), @@ -1043,13 +1131,13 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>, F2: Future<O .await .assert_ok_json::<RegistrationResponse>(); for _ in 0..3 { - register(&auth_pub2).await; + register(&auth_pub2) + .await + .assert_ok_json::<TransferResponse>(); } check_in(&[ Reserve(acc_pub1.clone()), - Bounced, Reserve(acc_pub2.clone()), - Bounced, Reserve(acc_pub3.clone()), Kyc(acc_pub4.clone()), Reserve(acc_pub4.clone()), @@ -1074,9 +1162,7 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>, F2: Future<O .assert_ok_json::<RegistrationResponse>(); check_in(&[ Reserve(acc_pub1.clone()), - Bounced, Reserve(acc_pub2.clone()), - Bounced, Reserve(acc_pub3.clone()), Kyc(acc_pub4.clone()), Reserve(acc_pub4.clone()), @@ -1111,12 +1197,12 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>, F2: Future<O })) .await .assert_ok(); - register(&auth_pub2).await; + register(&auth_pub2) + .await + .assert_ok_json::<TransferResponse>(); check_in(&[ Reserve(acc_pub1.clone()), - Bounced, Reserve(acc_pub2.clone()), - Bounced, Reserve(acc_pub3.clone()), Kyc(acc_pub4.clone()), Reserve(acc_pub4.clone()), @@ -1163,13 +1249,14 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>, F2: Future<O })) .await .assert_ok(); - register(&auth_pub2).await; - register(&auth_pub2).await; + for _ in 0..2 { + register(&auth_pub2) + .await + .assert_ok_json::<TransferResponse>(); + } check_in(&[ Reserve(acc_pub1.clone()), - Bounced, Reserve(acc_pub2.clone()), - Bounced, Reserve(acc_pub3.clone()), Kyc(acc_pub4.clone()), Reserve(acc_pub4.clone()), @@ -1206,9 +1293,7 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>, F2: Future<O check_in(&[ Reserve(acc_pub1.clone()), - Bounced, Reserve(acc_pub2.clone()), - Bounced, Reserve(acc_pub3.clone()), Kyc(acc_pub4.clone()), Reserve(acc_pub4.clone()), @@ -1290,7 +1375,6 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>, F2: Future<O (acc_pub, auth_pub) }) .collect(); - dbg!(&history); assert_eq!( history, [ diff --git a/common/taler-test-utils/src/server.rs b/common/taler-test-utils/src/server.rs @@ -154,6 +154,7 @@ impl IntoFuture for TestRequest { } } +#[must_use] pub struct TestResponse { bytes: Bytes, method: Method, diff --git a/taler-cyclos/db/cyclos-procedures.sql b/taler-cyclos/db/cyclos-procedures.sql @@ -69,8 +69,7 @@ out_pending=false; SELECT tx_in_id, valued_at INTO out_tx_row_id, out_valued_at FROM tx_in -WHERE (in_transfer_id IS NOT NULL AND transfer_id = in_transfer_id) -- Cyclos transaction - OR (in_transfer_id IS NULL AND amount = in_amount AND debit_account = in_debit_account AND subject = in_subject); -- Admin transaction +WHERE transfer_id = in_transfer_id; out_new = NOT found; IF NOT out_new THEN RETURN; diff --git a/taler-cyclos/src/api.rs b/taler-cyclos/src/api.rs @@ -29,7 +29,7 @@ use taler_common::{ RegistrationRequest, RegistrationResponse, SubjectFormat, TransferSubject, Unregistration, }, api_wire::{ - AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddKycauthResponse, + AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest, IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse, TransferState, TransferStatus, }, @@ -205,7 +205,7 @@ impl WireGateway for CyclosApi { } } - async fn add_incoming_kyc(&self, req: AddKycauthRequest) -> ApiResult<AddKycauthResponse> { + async fn add_incoming_kyc(&self, req: AddKycauthRequest) -> ApiResult<AddIncomingResponse> { let debtor = FullCyclosPayto::try_from(&req.debit_account)?; let res = db::register_tx_in_admin( &self.pool, @@ -222,7 +222,7 @@ impl WireGateway for CyclosApi { match res { AddIncomingResult::Success { row_id, valued_at, .. - } => Ok(AddKycauthResponse { + } => Ok(AddIncomingResponse { row_id: safe_u64(row_id), timestamp: valued_at.into(), }), @@ -235,6 +235,39 @@ impl WireGateway for CyclosApi { } } + async fn add_incoming_mapped(&self, req: AddMappedRequest) -> ApiResult<AddIncomingResponse> { + let debtor = FullCyclosPayto::try_from(&req.debit_account)?; + let res = db::register_tx_in_admin( + &self.pool, + &TxInAdmin { + amount: req.amount.decimal(), + subject: format!("Admin incoming MAP:{}", req.authorization_pub), + debtor_id: *debtor.id, + debtor_name: debtor.name, + metadata: IncomingSubject::Map(req.authorization_pub), + }, + &Timestamp::now(), + ) + .await?; + match res { + AddIncomingResult::Success { + row_id, valued_at, .. + } => Ok(AddIncomingResponse { + row_id: safe_u64(row_id), + timestamp: valued_at.into(), + }), + 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 } @@ -306,13 +339,9 @@ mod test { use jiff::Timestamp; use sqlx::{PgPool, Row as _, postgres::PgRow}; use taler_api::{ - api::TalerRouter as _, - auth::AuthMethod, - db::TypeHelper as _, - subject::{IncomingSubject, OutgoingSubject}, + api::TalerRouter as _, auth::AuthMethod, db::TypeHelper as _, subject::OutgoingSubject, }; use taler_common::{ - api_common::EddsaPublicKey, api_revenue::RevenueConfig, api_transfer::PreparedTransferConfig, api_wire::{OutgoingHistory, TransferState, WireConfig}, @@ -326,8 +355,8 @@ mod test { Router, db::db_test_setup, routine::{ - Status, admin_add_incoming_routine, registration_routine, revenue_routine, - routine_pagination, transfer_routine, + Status, admin_add_incoming_routine, in_history_routine, registration_routine, + revenue_routine, routine_pagination, transfer_routine, }, server::TestServer as _, }; @@ -335,7 +364,7 @@ mod test { use crate::{ api::CyclosApi, constants::CONFIG_SOURCE, - db::{self, AddIncomingResult, TxIn, TxOutKind}, + db::{self, TxOutKind}, }; static ACCOUNT: LazyLock<PaytoURI> = @@ -423,6 +452,12 @@ mod test { } #[tokio::test] + async fn in_history() { + let (server, _) = setup().await; + in_history_routine(&server, &ACCOUNT, true).await; + } + + #[tokio::test] async fn revenue() { let (server, _) = setup().await; revenue_routine(&server, &ACCOUNT, true).await; @@ -460,55 +495,9 @@ mod test { .unwrap() } - pub async fn test_in(pool: &PgPool, key: EddsaPublicKey) { - let tx = TxIn { - transfer_id: rand::random_range(10..10000), - tx_id: None, - amount: decimal("12"), - subject: String::new(), - debtor_id: rand::random_range(10..10000), - debtor_name: "Name".into(), - valued_at: Timestamp::now(), - }; - let mut db = pool.acquire().await.unwrap(); - let reason = match db::register_tx_in( - &mut db, - &tx, - &Some(IncomingSubject::Map(key)), - &Timestamp::now(), - ) - .await - .unwrap() - { - AddIncomingResult::Success { .. } => return, - AddIncomingResult::ReservePubReuse => "reserve pub reuse", - AddIncomingResult::UnknownMapping => "unknown mapping", - AddIncomingResult::MappingReuse => "mapping reuse", - }; - db::register_bounced_tx_in( - &mut db, - &tx, - rand::random_range(10..10000), - reason, - &Timestamp::now(), - ) - .await - .unwrap(); - } - #[tokio::test] async fn registration() { let (server, pool) = setup().await; - registration_routine( - &server, - &ACCOUNT, - || check_in(&pool), - |account_pub| { - let account_pub = account_pub.clone(); - let pool = &pool; - async move { test_in(pool, account_pub).await } - }, - ) - .await; + registration_routine(&server, &ACCOUNT, || check_in(&pool)).await; } } diff --git a/taler-cyclos/src/db.rs b/taler-cyclos/src/db.rs @@ -1131,18 +1131,6 @@ mod test { valued_at: now } ); - // Idempotent - assert_eq!( - db::register_tx_in_admin(&pool, &tx, &later) - .await - .expect("register tx in"), - AddIncomingResult::Success { - new: false, - pending: false, - row_id: 1, - valued_at: now - } - ); // Many assert_eq!( db::register_tx_in_admin( diff --git a/taler-magnet-bank/db/magnet-bank-procedures.sql b/taler-magnet-bank/db/magnet-bank-procedures.sql @@ -68,8 +68,7 @@ out_pending=false; SELECT tx_in_id, valued_at INTO out_tx_row_id, out_valued_at FROM tx_in -WHERE (in_code IS NOT NULL AND magnet_code = in_code) -- Magnet transaction - OR (in_code IS NULL AND amount = in_amount AND debit_account = in_debit_account AND subject = in_subject); -- Admin transaction +WHERE magnet_code = in_code; out_new = NOT found; IF NOT out_new THEN RETURN; @@ -449,7 +448,7 @@ LOOP ) SELECT amount, - 'bounce: ' || magnet_code, + CONCAT('bounce: ', magnet_code), debit_account, debit_name, in_timestamp diff --git a/taler-magnet-bank/src/api.rs b/taler-magnet-bank/src/api.rs @@ -28,7 +28,7 @@ use taler_common::{ RegistrationRequest, RegistrationResponse, SubjectFormat, TransferSubject, Unregistration, }, api_wire::{ - AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddKycauthResponse, + AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest, IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse, TransferState, TransferStatus, }, @@ -185,7 +185,7 @@ impl WireGateway for MagnetApi { } } - async fn add_incoming_kyc(&self, req: AddKycauthRequest) -> ApiResult<AddKycauthResponse> { + async fn add_incoming_kyc(&self, req: AddKycauthRequest) -> ApiResult<AddIncomingResponse> { let debtor = FullHuPayto::try_from(&req.debit_account)?; let res = db::register_tx_in_admin( &self.pool, @@ -201,15 +201,45 @@ impl WireGateway for MagnetApi { match res { AddIncomingResult::Success { row_id, valued_at, .. - } => Ok(AddKycauthResponse { + } => Ok(AddIncomingResponse { + row_id: safe_u64(row_id), + timestamp: date_to_utc_ts(&valued_at).into(), + }), + AddIncomingResult::ReservePubReuse => unreachable!("kyc"), + AddIncomingResult::UnknownMapping | AddIncomingResult::MappingReuse => { + unreachable!("mapping not used") + } + } + } + + async fn add_incoming_mapped(&self, req: AddMappedRequest) -> ApiResult<AddIncomingResponse> { + let debtor = FullHuPayto::try_from(&req.debit_account)?; + let res = db::register_tx_in_admin( + &self.pool, + &TxInAdmin { + amount: req.amount, + subject: format!("Admin incoming MAP:{}", req.authorization_pub), + debtor, + metadata: IncomingSubject::Map(req.authorization_pub), + }, + &Timestamp::now(), + ) + .await?; + match res { + AddIncomingResult::Success { + row_id, valued_at, .. + } => Ok(AddIncomingResponse { row_id: safe_u64(row_id), timestamp: date_to_utc_ts(&valued_at).into(), }), AddIncomingResult::ReservePubReuse => { Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)) } - AddIncomingResult::UnknownMapping | AddIncomingResult::MappingReuse => { - unreachable!("mapping not used") + AddIncomingResult::UnknownMapping => { + Err(failure_code(ErrorCode::BANK_TRANSFER_MAPPING_UNKNOWN)) + } + AddIncomingResult::MappingReuse => { + Err(failure_code(ErrorCode::BANK_TRANSFER_MAPPING_REUSED)) } } } @@ -279,7 +309,7 @@ mod test { FullHuPayto, api::MagnetApi, constants::CONFIG_SOURCE, - db::{self, AddIncomingResult, TxIn, TxOutKind}, + db::{self, TxOutKind}, magnet_api::types::TxStatus, magnet_payto, }; @@ -287,13 +317,9 @@ mod test { use jiff::{Timestamp, Zoned}; use sqlx::{PgPool, Row as _, postgres::PgRow}; use taler_api::{ - api::TalerRouter as _, - auth::AuthMethod, - db::TypeHelper as _, - subject::{IncomingSubject, OutgoingSubject}, + api::TalerRouter as _, auth::AuthMethod, db::TypeHelper as _, subject::OutgoingSubject, }; use taler_common::{ - api_common::EddsaPublicKey, api_revenue::RevenueConfig, api_transfer::PreparedTransferConfig, api_wire::{OutgoingHistory, TransferState, WireConfig}, @@ -307,8 +333,8 @@ mod test { Router, db::db_test_setup, routine::{ - Status, admin_add_incoming_routine, registration_routine, revenue_routine, - routine_pagination, transfer_routine, + Status, admin_add_incoming_routine, in_history_routine, registration_routine, + revenue_routine, routine_pagination, transfer_routine, }, server::TestServer, }; @@ -394,6 +420,12 @@ mod test { } #[tokio::test] + async fn in_history() { + let (server, _) = setup().await; + in_history_routine(&server, &ACCOUNT, true).await; + } + + #[tokio::test] async fn revenue() { let (server, _) = setup().await; revenue_routine(&server, &ACCOUNT, true).await; @@ -431,48 +463,9 @@ mod test { .unwrap() } - pub async fn test_in(pool: &PgPool, key: EddsaPublicKey) { - let tx = TxIn { - code: rand::random_range(10..10000), - amount: amount("EUR:12"), - subject: Box::default(), - debtor: PAYTO.clone(), - value_date: Zoned::now().date(), - status: TxStatus::Completed, - }; - let mut db = pool.acquire().await.unwrap(); - let reason = match db::register_tx_in( - &mut db, - &tx, - &Some(IncomingSubject::Map(key)), - &Timestamp::now(), - ) - .await - .unwrap() - { - AddIncomingResult::Success { .. } => return, - AddIncomingResult::ReservePubReuse => "reserve pub reuse", - AddIncomingResult::UnknownMapping => "unknown mapping", - AddIncomingResult::MappingReuse => "mapping reuse", - }; - db::register_bounce_tx_in(&mut db, &tx, reason, &Timestamp::now()) - .await - .unwrap(); - } - #[tokio::test] async fn registration() { let (server, pool) = setup().await; - registration_routine( - &server, - &ACCOUNT, - || check_in(&pool), - |account_pub| { - let account_pub = account_pub.clone(); - let pool = &pool; - async move { test_in(pool, account_pub).await } - }, - ) - .await; + registration_routine(&server, &ACCOUNT, || check_in(&pool)).await; } } diff --git a/taler-magnet-bank/src/db.rs b/taler-magnet-bank/src/db.rs @@ -1095,18 +1095,6 @@ mod test { valued_at: date } ); - // Idempotent - assert_eq!( - register_tx_in_admin(&pool, &tx, &later) - .await - .expect("register tx in"), - AddIncomingResult::Success { - new: false, - pending: false, - row_id: 1, - valued_at: date - } - ); // Many assert_eq!( register_tx_in_admin(