exchange

Base system with REST service to issue digital coins, run by the payment service provider
Log | Files | Refs | Submodules | README | LICENSE

commit 9825912290ea866bfc1c7fd38430ca527af00ca0
parent 9e40d0077404c225a81cba01a4d9669a4f2da1a9
Author: Christian Grothoff <grothoff@gnunet.org>
Date:   Sun, 16 Aug 2026 23:26:34 +0200

add DB tests organized by primary table

Diffstat:
Msrc/exchangedb/meson.build | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_account_merges.c | 728+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_aggregation_deferrals.c | 281+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_aggregation_tracking.c | 729+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_aggregation_transient.c | 356+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_aml_history.c | 788+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_aml_staff.c | 517+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_auditor_denom_sigs.c | 338+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_auditors.c | 344+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_batch_deposits.c | 612+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_close_requests.c | 448+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_coin_deposits.c | 499+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_coin_history.c | 714+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_common.c | 866+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_common.h | 538+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_contracts.c | 335+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_denomination_revocations.c | 259+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_denominations.c | 384+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_exchange_sign_keys.c | 291++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_global_fee.c | 419+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_known_coins.c | 436+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_kyc_alerts.c | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_kyc_attributes.c | 956+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_kyc_events.c | 438+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_kyc_targets.c | 489+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_kycauths_in.c | 622+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_legitimization_measures.c | 838+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_legitimization_outcomes.c | 542+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_legitimization_processes.c | 612+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_misc.c | 656+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_partners.c | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_prewire.c | 323+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_profit_drains.c | 330+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_purse_decision.c | 441+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_purse_deletion.c | 410+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_purse_deposits.c | 697+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_purse_merges.c | 759+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_purse_requests.c | 477+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_recoup.c | 531+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_recoup_refresh.c | 527+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_refresh.c | 781+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_refunds.c | 707+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_replication.c | 480+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_reserve_history.c | 388+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_reserves.c | 624+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_reserves_close.c | 590+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_reserves_in.c | 695+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_reserves_open_deposits.c | 381+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_reserves_open_requests.c | 621+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_revolving_work_shards.c | 269+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_signkey_revocations.c | 275+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_table.sh | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_wire_accounts.c | 406+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_wire_fee.c | 407+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_wire_out.c | 632+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_withdraw.c | 825+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/test_work_shards.c | 374+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
57 files changed, 28581 insertions(+), 0 deletions(-)

diff --git a/src/exchangedb/meson.build b/src/exchangedb/meson.build @@ -327,6 +327,108 @@ test( ) +# One test binary per database table, each covering the exported functions +# whose primary test table it is. The '@brief' comment of every function in +# src/include/exchange-database/ names the table it belongs to. They all +# share test_common.c and the test_table.sh driver, which gives each of them +# a scratch database of its own. + +test_table_sh = configure_file( + input: 'test_table.sh', + output: 'test_table.sh', + copy: true, +) + +exchangedb_table_tests = [ + 'account_merges', + 'aggregation_deferrals', + 'aggregation_tracking', + 'aggregation_transient', + 'aml_history', + 'aml_staff', + 'auditor_denom_sigs', + 'auditors', + 'batch_deposits', + 'close_requests', + 'coin_deposits', + 'coin_history', + 'contracts', + 'denomination_revocations', + 'denominations', + 'exchange_sign_keys', + 'global_fee', + 'known_coins', + 'legitimization_measures', + 'legitimization_outcomes', + 'legitimization_processes', + 'kyc_alerts', + 'kyc_attributes', + 'kyc_events', + 'kyc_targets', + 'kycauths_in', + 'misc', + 'partners', + 'prewire', + 'profit_drains', + 'recoup', + 'refunds', + 'recoup_refresh', + 'refresh', + 'replication', + 'reserves', + 'purse_decision', + 'purse_deletion', + 'purse_deposits', + 'purse_merges', + 'purse_requests', + 'reserve_history', + 'reserves_close', + 'reserves_in', + 'reserves_open_deposits', + 'reserves_open_requests', + 'revolving_work_shards', + 'signkey_revocations', + 'wire_accounts', + 'withdraw', + 'wire_fee', + 'wire_out', + 'work_shards', +] + +foreach t : exchangedb_table_tests + test_bin = executable( + 'test_' + t, + ['test_' + t + '.c', 'test_common.c'], + install_rpath: rpath_option, + dependencies: [ + libtalerexchangedb_dep, + libtalerkyclogic_dep, + libtalerutil_dep, + libtalerjson_dep, + libtalerpq_dep, + gnunetutil_dep, + gnunetjson_dep, + gnunetpq_dep, + pq_dep, + json_dep, + ], + include_directories: [incdir, configuration_inc], + install: false, + ) + test( + 'test_' + t, + test_table_sh, + args: ['test_' + t], + workdir: meson.current_build_dir(), + suite: ['exchangedb'], + depends: [test_bin, exchangedb_sql_targets], + env: {'TALER_BUILD_ROOT': meson.project_build_root()}, + is_parallel: false, + timeout: 300, + ) +endforeach + + # [oec 20250430] disable test for now # check_PROGRAMS = \ # test-exchangedb diff --git a/src/exchangedb/test_account_merges.c b/src/exchangedb/test_account_merges.c @@ -0,0 +1,728 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_account_merges.c + * @brief tests for the exchangedb functions whose primary table is + * `account_merges` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_do_reserve_purse(), + * #TALER_EXCHANGEDB_iterate_account_merges_above_serial_id() and + * #TALER_EXCHANGEDB_iterate_merge_amounts_for_kyc_check(). + * + * `account_merges` is the account holder's side of a merge: the request + * the wallet signs to have a purse land in its reserve. do_reserve_purse() + * writes it (together with the `purse_merges` row) and declines when the + * reserve is unknown, when its purse quota is used up, and when the purse + * was already merged into a different reserve. + */ +#include "test_common.h" +#include "exchange-database/do_reserve_purse.h" +#include "exchange-database/insert_purse_request.h" +#include "exchange-database/iterate_account_merges_above_serial_id.h" +#include "exchange-database/iterate_merge_amounts_for_kyc_check.h" +#include "exchange-database/rollback.h" +#include "exchange-database/start.h" + + +/** + * Wallet account the reserves of the checks belong to. + */ +static struct TDB_Account account; + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Closure for #merge_cb(). + */ +struct MergeContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Stop after this many rows; 0 for no limit. + */ + unsigned int stop_after; + + /** + * Purse we are looking for, NULL to match nothing. + */ + const struct TALER_PurseContractPublicKeyP *purse_pub; + + /** + * How many times did we see it? + */ + unsigned int matched; + + /** + * Reserve reported for it. + */ + struct TALER_ReservePublicKeyP reserve_pub; + + /** + * Amount reported for it. + */ + struct TALER_Amount amount; + + /** + * Purse fee reported for it. + */ + struct TALER_Amount purse_fee; +}; + + +/** + * Callback for + * #TALER_EXCHANGEDB_iterate_account_merges_above_serial_id(). + * + * @param cls a `struct MergeContext *` + * @param rowid row of the request + * @param reserve_pub reserve the purse is to land in + * @param purse_pub the purse + * @param h_contract_terms contract of the purse + * @param purse_expiration when the purse expires + * @param amount target amount of the purse + * @param min_age age limit of the purse + * @param flags flags of the purse + * @param purse_fee fee charged to the reserve for the purse + * @param merge_timestamp when the request was made + * @param reserve_sig signature of the account holder + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +merge_cb (void *cls, + uint64_t rowid, + const struct TALER_ReservePublicKeyP *reserve_pub, + const struct TALER_PurseContractPublicKeyP *purse_pub, + const struct TALER_PrivateContractHashP *h_contract_terms, + struct GNUNET_TIME_Timestamp purse_expiration, + const struct TALER_Amount *amount, + uint32_t min_age, + enum TALER_WalletAccountMergeFlags flags, + const struct TALER_Amount *purse_fee, + struct GNUNET_TIME_Timestamp merge_timestamp, + const struct TALER_ReserveSignatureP *reserve_sig) +{ + struct MergeContext *ctx = cls; + + (void) rowid; + (void) h_contract_terms; + (void) purse_expiration; + (void) min_age; + (void) flags; + (void) merge_timestamp; + (void) reserve_sig; + ctx->total++; + if ( (NULL != ctx->purse_pub) && + (0 == GNUNET_memcmp (purse_pub, + ctx->purse_pub)) ) + { + ctx->matched++; + ctx->reserve_pub = *reserve_pub; + ctx->amount = *amount; + ctx->purse_fee = *purse_fee; + } + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * Closure for #amount_cb(). + */ +struct AmountContext +{ + /** + * How many amounts did the callback see? + */ + unsigned int total; + + /** + * Sum of the whole-unit parts of the amounts seen. + */ + uint64_t value_sum; + + /** + * Return this from the callback. + */ + enum GNUNET_GenericReturnValue ret; +}; + + +/** + * Callback for + * #TALER_EXCHANGEDB_iterate_merge_amounts_for_kyc_check(). + * + * @param cls a `struct AmountContext *` + * @param amount how much was merged + * @param date when it was merged + * @return what @e ret of the closure says + */ +static enum GNUNET_GenericReturnValue +amount_cb (void *cls, + const struct TALER_Amount *amount, + struct GNUNET_TIME_Absolute date) +{ + struct AmountContext *ctx = cls; + + (void) date; + ctx->total++; + ctx->value_sum += amount->value; + return ctx->ret; +} + + +/** + * Outcome of a reserve-purse request. + */ +struct PurseStatus +{ + /** + * Was the purse already merged into another reserve? + */ + bool in_conflict; + + /** + * Is the reserve unknown? + */ + bool no_reserve; + + /** + * Has the reserve run out of purses? + */ + bool insufficient_funds; +}; + + +/** + * Ask for a purse to be merged into a reserve. + * + * @param pg the database context + * @param purse purse to merge + * @param reserve_pub reserve to merge it into + * @param seed seed for the signatures + * @param purse_fee fee to charge the reserve, NULL to use the quota + * @param[out] st set to the outcome + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +run_reserve_purse (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TDB_Purse *purse, + const struct TALER_ReservePublicKeyP *reserve_pub, + uint32_t seed, + const struct TALER_Amount *purse_fee, + struct PurseStatus *st) +{ + struct TALER_PurseMergeSignatureP merge_sig; + struct TALER_ReserveSignatureP reserve_sig; + + TDB_fill (&merge_sig, + sizeof (merge_sig), + seed); + TDB_fill (&reserve_sig, + sizeof (reserve_sig), + seed); + memset (st, + 0, + sizeof (*st)); + return TALER_EXCHANGEDB_do_reserve_purse (pg, + &purse->purse_pub, + &merge_sig, + ts (1600000000), + &reserve_sig, + purse_fee, + reserve_pub, + &st->in_conflict, + &st->no_reserve, + &st->insufficient_funds); +} + + +/** + * Create a purse that records a purse fee, the way the /reserves/$RP/purse + * endpoint does when the account holder offers to pay for the purse. + * TDB_purse() always records a zero fee, and the auditor's view reports + * the fee the *purse* recorded, not the one do_reserve_purse() charged. + * + * @param pg the database context + * @param seed seed for the purse's keys and signature + * @param amount target amount of the purse, e.g. "5" + * @param fee purse fee the account holder offers, e.g. "1" + * @param[out] purse set to the purse + */ +static void +make_purse (struct TALER_EXCHANGEDB_PostgresContext *pg, + uint32_t seed, + const char *amount, + const char *fee, + struct TDB_Purse *purse) +{ + struct TALER_Amount purse_fee = TDB_amount (fee); + enum GNUNET_DB_QueryStatus qs; + bool in_conflict = false; + + memset (purse, + 0, + sizeof (*purse)); + TDB_fill (&purse->purse_pub, + sizeof (purse->purse_pub), + seed); + TDB_fill (&purse->merge_pub, + sizeof (purse->merge_pub), + seed); + TDB_fill (&purse->h_contract_terms, + sizeof (purse->h_contract_terms), + seed); + TDB_fill (&purse->purse_sig, + sizeof (purse->purse_sig), + seed); + purse->purse_expiration = ts (1700000000); + purse->amount = TDB_amount (amount); + qs = TALER_EXCHANGEDB_insert_purse_request ( + pg, + &purse->purse_pub, + &purse->merge_pub, + purse->purse_expiration, + &purse->h_contract_terms, + 0, + TALER_WAMF_MODE_CREATE_WITH_PURSE_FEE, + &purse_fee, + &purse->amount, + &purse->purse_sig, + &in_conflict); + GNUNET_assert (0 <= qs); + GNUNET_assert (! in_conflict); +} + + +/** + * Nothing is reported while the table is empty. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct MergeContext ctx = { 0 }; + struct AmountContext actx = { 0 }; + + TDB_account (pg, + 10, + &account); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_account_merges_above_serial_id ( + pg, + 0, + &merge_cb, + &ctx)); + actx.ret = GNUNET_OK; + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_merge_amounts_for_kyc_check ( + pg, + &account.h_normalized, + GNUNET_TIME_UNIT_ZERO_ABS, + &amount_cb, + &actx)); + FAILIF (0 != ctx.total); + FAILIF (0 != actx.total); + return 0; +} + + +/** + * A reserve that does not exist cannot take a purse. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_no_reserve (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Purse purse; + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_Amount purse_fee = TDB_amount ("1"); + struct PurseStatus st; + + TDB_purse (pg, + 9, + "5", + ts (1700000000), + &purse); + TDB_FILL (reserve_pub, + 99); + /* A non-zero purse fee is what makes an unknown reserve fatal: with a + zero fee the function creates the reserve instead. The `purse_merges` + row goes in before the reserve is looked at, so the caller has to roll + back -- which is what the /purses/.../merge handler does. */ + FAILIF (GNUNET_OK != + TALER_EXCHANGEDB_start (pg, + "test-account-merges-no-reserve")); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_reserve_purse (pg, + &purse, + &reserve_pub, + 9, + &purse_fee, + &st), + TALER_EXCHANGEDB_rollback (pg)); + TALER_EXCHANGEDB_rollback (pg); + FAILIF (! st.no_reserve); + FAILIF (! st.insufficient_funds); + FAILIF (0 != TDB_count (pg, + "FROM account_merges")); + FAILIF (0 != TDB_count (pg, + "FROM purse_merges")); + return 0; +} + + +/** + * Paying the purse fee from the reserve creates the merge request. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_reserve_purse (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Purse purse; + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_Amount purse_fee = TDB_amount ("1"); + struct TALER_Amount expect_balance = TDB_amount ("9"); + struct MergeContext ctx; + struct PurseStatus st; + char *hex; + + make_purse (pg, + 10, + "5", + "1", + &purse); + TDB_reserve (pg, + 10, + "10", + &reserve_pub); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_reserve_purse (pg, + &purse, + &reserve_pub, + 10, + &purse_fee, + &st)); + FAILIF (st.no_reserve); + FAILIF (st.in_conflict); + FAILIF (st.insufficient_funds); + FAILIF (1 != TDB_count (pg, + "FROM account_merges")); + /* the merge is also recorded from the purse's side */ + FAILIF (1 != TDB_count (pg, + "FROM purse_merges")); + /* the reserve paid the purse fee */ + hex = TDB_hex (&reserve_pub, + sizeof (reserve_pub)); + FAILIF_C (1 != TDB_count (pg, + "FROM reserves" + " WHERE reserve_pub=decode('%s','hex')" + " AND current_balance=ROW(9,0)::taler_amount", + hex), + GNUNET_free (hex)); + GNUNET_free (hex); + (void) expect_balance; + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.purse_pub = &purse.purse_pub; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_account_merges_above_serial_id ( + pg, + 0, + &merge_cb, + &ctx)); + FAILIF (1 != ctx.matched); + FAILIF (0 != GNUNET_memcmp (&ctx.reserve_pub, + &reserve_pub)); + FAILIF (0 != TALER_amount_cmp (&ctx.amount, + &purse.amount)); + FAILIF (0 != TALER_amount_cmp (&ctx.purse_fee, + &purse_fee)); + + /* the same purse into a different reserve is a conflict */ + { + struct TALER_ReservePublicKeyP other; + + TDB_reserve (pg, + 11, + "10", + &other); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_reserve_purse (pg, + &purse, + &other, + 11, + &purse_fee, + &st)); + FAILIF (! st.in_conflict); + FAILIF (1 != TDB_count (pg, + "FROM account_merges")); + } + return 0; +} + + +/** + * A reserve that cannot pay the purse fee is refused. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insufficient_funds (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Purse purse; + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_Amount purse_fee = TDB_amount ("5"); + struct PurseStatus st; + + TDB_purse (pg, + 12, + "5", + ts (1700000000), + &purse); + TDB_reserve (pg, + 12, + "1", + &reserve_pub); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_reserve_purse (pg, + &purse, + &reserve_pub, + 12, + &purse_fee, + &st)); + FAILIF (! st.insufficient_funds); + FAILIF (1 != TDB_count (pg, + "FROM account_merges")); + return 0; +} + + +/** + * The wallet's KYC view sums the merges that landed in its account. + * + * The account in question is the reserve's own `taler-reserve://` account, + * which is what do_reserve_purse() records in `wallet_h_payto` -- not a + * bank account. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_kyc_amounts (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct AmountContext ctx; + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_NormalizedPayto payto; + struct TALER_NormalizedPaytoHashP h_payto; + struct TALER_NormalizedPaytoHashP other; + + TDB_fill (&reserve_pub, + sizeof (reserve_pub), + 10); + payto = TALER_reserve_make_payto (pg->exchange_url, + &reserve_pub); + TALER_normalized_payto_hash (payto, + &h_payto); + GNUNET_free (payto.normalized_payto); + + /* the merge of check_reserve_purse() is charged to our account, but the + purse has not been decided yet, so the KYC view has nothing */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.ret = GNUNET_OK; + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_merge_amounts_for_kyc_check ( + pg, + &h_payto, + GNUNET_TIME_UNIT_ZERO_ABS, + &amount_cb, + &ctx)); + FAILIF (0 != ctx.total); + + /* once the purse is decided in favour of the reserve, it counts */ + FAILIF (GNUNET_OK != + TDB_exec (pg, + "INSERT INTO purse_decision" + " (purse_pub,action_timestamp,refunded)" + " SELECT purse_pub,1000000,FALSE" + " FROM account_merges;")); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.ret = GNUNET_OK; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_merge_amounts_for_kyc_check ( + pg, + &h_payto, + GNUNET_TIME_UNIT_ZERO_ABS, + &amount_cb, + &ctx)); + FAILIF (0 == ctx.total); + FAILIF (0 == ctx.value_sum); + + /* an account nobody merged into has nothing */ + TDB_FILL (other, + 97); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.ret = GNUNET_OK; + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_merge_amounts_for_kyc_check ( + pg, + &other, + GNUNET_TIME_UNIT_ZERO_ABS, + &amount_cb, + &ctx)); + FAILIF (0 != ctx.total); + + /* a time limit past the merges hides them */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.ret = GNUNET_OK; + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_merge_amounts_for_kyc_check ( + pg, + &h_payto, + ts (1700000000).abs_time, + &amount_cb, + &ctx)); + FAILIF (0 != ctx.total); + + /* and an aborting callback is not an error */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.ret = GNUNET_NO; + FAILIF (0 > + TALER_EXCHANGEDB_iterate_merge_amounts_for_kyc_check ( + pg, + &h_payto, + GNUNET_TIME_UNIT_ZERO_ABS, + &amount_cb, + &ctx)); + FAILIF (1 != ctx.total); + return 0; +} + + +/** + * The iterator's bound and abort return behave as documented. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct MergeContext ctx; + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_account_merges_above_serial_id ( + pg, + 1000, + &merge_cb, + &ctx)); + FAILIF (0 != ctx.total); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.stop_after = 1; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_account_merges_above_serial_id ( + pg, + 0, + &merge_cb, + &ctx)); + FAILIF (1 != ctx.total); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "account-merges-empty", + &check_empty }, + { "account-merges-no-reserve", + &check_no_reserve }, + { "account-merges-reserve-purse", + &check_reserve_purse }, + { "account-merges-insufficient-funds", + &check_insufficient_funds }, + { "account-merges-kyc-amounts", + &check_kyc_amounts }, + { "account-merges-iterate", + &check_iterate }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-account-merges", + "Tests for the exchangedb `account_merges' table", + tests); + TDB_account_free (&account); + return ret; +} + + +/* end of test_account_merges.c */ diff --git a/src/exchangedb/test_aggregation_deferrals.c b/src/exchangedb/test_aggregation_deferrals.c @@ -0,0 +1,281 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_aggregation_deferrals.c + * @brief tests for the exchangedb functions whose primary table is + * `aggregation_deferrals` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_aggregation_deferral() and + * #TALER_EXCHANGEDB_get_aggregation_deferral_by_wtid(). + * + * The table is the exchange's append-only account of why it did not make a + * wire transfer it had already decided on. Each row hangs off the deposit + * of the aggregate that is last to reach its wire deadline, so the insert + * finds nothing to attach to until `aggregation_tracking` has been written + * -- which is the first thing the checks pin down. The lookup returns the + * most recent claim, so the checks defer twice. + */ +#include "test_common.h" +#include "exchange-database/do_aggregate.h" +#include "exchange-database/get_aggregation_deferral_by_wtid.h" +#include "exchange-database/insert_aggregation_deferral.h" + + +/** + * Account the merchants of the checks are paid at. + */ +static struct TDB_Account account; + + +/** + * Denomination the checks deposit. + */ +static struct TDB_Denom denom; + + +/** + * The wire transfer the checks aggregate into. + */ +static struct TALER_WireTransferIdentifierRawP wtid; + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Without an aggregation to hang off, nothing is recorded and nothing is + * found. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_no_aggregation (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_Amount amount = TDB_amount ("1"); + struct TALER_Amount got; + struct GNUNET_TIME_Timestamp deferral_time; + enum TALER_EXCHANGEDB_DeferralReason reason; + uint64_t requirement_row; + + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + TDB_account (pg, + 10, + &account); + TDB_FILL (wtid, + 10); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_aggregation_deferral_by_wtid (pg, + &wtid, + &got, + &reason, + &requirement_row, + &deferral_time)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_insert_aggregation_deferral ( + pg, + &wtid, + &account.h_full, + &amount, + TALER_EXCHANGEDB_DR_AMOUNT_TOO_SMALL, + 0, + ts (1600000000))); + FAILIF (0 != TDB_count (pg, + "FROM aggregation_deferrals")); + return 0; +} + + +/** + * With an aggregation on file, the deferral is recorded and read back. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_defer (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo coin; + struct TDB_Deposit dep; + struct TALER_Amount total; + struct TALER_Amount amount = TDB_amount ("0.9"); + struct TALER_Amount got; + struct GNUNET_TIME_Timestamp deferral_time; + enum TALER_EXCHANGEDB_DeferralReason reason; + uint64_t requirement_row = 42; + + TDB_coin (pg, + &denom, + 20, + &coin, + NULL); + TDB_deposit (pg, + &account, + &coin, + 20, + "1", + "0.1", + ts (1600000000), + ts (1600000000), + &dep); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_aggregate (pg, + &account.h_full, + &dep.merchant_pub, + &wtid, + &total), + TDB_coin_free (&coin)); + TDB_coin_free (&coin); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_aggregation_deferral ( + pg, + &wtid, + &account.h_full, + &amount, + TALER_EXCHANGEDB_DR_AMOUNT_TOO_SMALL, + 0, + ts (1600000000))); + FAILIF (1 != TDB_count (pg, + "FROM aggregation_deferrals")); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_aggregation_deferral_by_wtid (pg, + &wtid, + &got, + &reason, + &requirement_row, + &deferral_time)); + FAILIF (0 != TALER_amount_cmp (&got, + &amount)); + FAILIF (TALER_EXCHANGEDB_DR_AMOUNT_TOO_SMALL != reason); + FAILIF (0 != requirement_row); + FAILIF (GNUNET_TIME_timestamp_cmp (deferral_time, + !=, + ts (1600000000))); + + /* a wire transfer nobody deferred is still unknown */ + { + struct TALER_WireTransferIdentifierRawP other; + + TDB_FILL (other, + 99); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_aggregation_deferral_by_wtid ( + pg, + &other, + &got, + &reason, + &requirement_row, + &deferral_time)); + } + return 0; +} + + +/** + * The table is append-only: a second deferral is a second row, and the + * lookup returns the newer one. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_append_only (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_Amount amount = TDB_amount ("0.9"); + struct TALER_Amount got; + struct GNUNET_TIME_Timestamp deferral_time; + enum TALER_EXCHANGEDB_DeferralReason reason; + uint64_t requirement_row = 0; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_aggregation_deferral ( + pg, + &wtid, + &account.h_full, + &amount, + TALER_EXCHANGEDB_DR_KYC, + 7, + ts (1600003600))); + FAILIF (2 != TDB_count (pg, + "FROM aggregation_deferrals")); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_aggregation_deferral_by_wtid (pg, + &wtid, + &got, + &reason, + &requirement_row, + &deferral_time)); + FAILIF (TALER_EXCHANGEDB_DR_KYC != reason); + FAILIF (7 != requirement_row); + FAILIF (GNUNET_TIME_timestamp_cmp (deferral_time, + !=, + ts (1600003600))); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "aggregation-deferrals-no-aggregation", + &check_no_aggregation }, + { "aggregation-deferrals-defer", + &check_defer }, + { "aggregation-deferrals-append-only", + &check_append_only }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-aggregation-deferrals", + "Tests for the exchangedb `aggregation_deferrals' table", + tests); + TDB_account_free (&account); + TDB_denom_free (&denom); + return ret; +} + + +/* end of test_aggregation_deferrals.c */ diff --git a/src/exchangedb/test_aggregation_tracking.c b/src/exchangedb/test_aggregation_tracking.c @@ -0,0 +1,729 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_aggregation_tracking.c + * @brief tests for the exchangedb functions whose primary table is + * `aggregation_tracking` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_do_aggregate(), + * #TALER_EXCHANGEDB_get_pending_aggregation(), + * #TALER_EXCHANGEDB_get_transfer_by_deposit(), + * #TALER_EXCHANGEDB_iterate_aggregation_wtids_above_serial_id(), + * #TALER_EXCHANGEDB_iterate_aggregations_above_serial_id() and + * #TALER_EXCHANGEDB_iterate_wire_transfers(). + * + * `aggregation_tracking` records which deposit went into which wire + * transfer. do_aggregate() writes it, netting deposits against refunds and + * charging the deposit fee only for coins that were not refunded in full -- + * the checks below pin that arithmetic down. + */ +#include "test_common.h" +#include "exchange-database/do_aggregate.h" +#include "exchange-database/do_refund.h" +#include "exchange-database/get_pending_aggregation.h" +#include "exchange-database/get_transfer_by_deposit.h" +#include "exchange-database/insert_wire_out.h" +#include "exchange-database/iterate_aggregation_wtids_above_serial_id.h" +#include "exchange-database/iterate_aggregations_above_serial_id.h" +#include "exchange-database/iterate_wire_transfers.h" + + +/** + * Account the merchants of the checks are paid at. + */ +static struct TDB_Account account; + + +/** + * Denomination the checks deposit. + */ +static struct TDB_Denom denom; + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Closure for #wtid_cb(). + */ +struct WtidContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Wire transfer we are looking for, NULL to match nothing. + */ + const struct TALER_WireTransferIdentifierRawP *wtid; + + /** + * How many times did we see it? + */ + unsigned int matched; + + /** + * Was the last matching row reported as pending? + */ + bool pending; +}; + + +/** + * Callback for + * #TALER_EXCHANGEDB_iterate_aggregation_wtids_above_serial_id(). + * + * @param cls a `struct WtidContext *` + * @param rowid row of the tracking entry + * @param wtid wire transfer the deposit was aggregated into + * @param wire_target_h_payto where the transfer goes + * @param pending whether the transfer has not been made yet + * @return #GNUNET_OK + */ +static enum GNUNET_GenericReturnValue +wtid_cb (void *cls, + uint64_t rowid, + const struct TALER_WireTransferIdentifierRawP *wtid, + const struct TALER_FullPaytoHashP *wire_target_h_payto, + bool pending) +{ + struct WtidContext *ctx = cls; + + (void) rowid; + (void) wire_target_h_payto; + ctx->total++; + if ( (NULL != ctx->wtid) && + (0 == GNUNET_memcmp (wtid, + ctx->wtid)) ) + { + ctx->matched++; + ctx->pending = pending; + } + return GNUNET_OK; +} + + +/** + * Closure for #aggregation_cb() and #transfer_cb(). + */ +struct SumContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Sum of the whole-unit parts of the amounts seen. + */ + uint64_t value_sum; + + /** + * Row of the first entry seen. + */ + uint64_t first_row; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_aggregations_above_serial_id(). + * + * @param cls a `struct SumContext *` + * @param amount how much was aggregated + * @param tracking_serial_id row of the tracking entry + * @param batch_deposit_serial_id deposit that was aggregated + */ +static void +aggregation_cb (void *cls, + const struct TALER_Amount *amount, + uint64_t tracking_serial_id, + uint64_t batch_deposit_serial_id) +{ + struct SumContext *ctx = cls; + + (void) batch_deposit_serial_id; + if (0 == ctx->total++) + ctx->first_row = tracking_serial_id; + ctx->value_sum += amount->value; +} + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_wire_transfers(). + * + * @param cls a `struct SumContext *` + * @param rowid row of the tracking entry + * @param merchant_pub merchant that was paid + * @param account_payto_uri account of the merchant + * @param h_payto hash of that account + * @param exchange_payto_uri account of the exchange + * @param exec_time when the transfer was made + * @param h_contract_terms contract that was paid + * @param denom_pub denomination of the coin + * @param coin_pub the coin that was deposited + * @param coin_value how much the coin contributed + * @param coin_fee how much the exchange charged for it + */ +static void +transfer_cb (void *cls, + uint64_t rowid, + const struct TALER_MerchantPublicKeyP *merchant_pub, + const struct TALER_FullPayto account_payto_uri, + const struct TALER_FullPaytoHashP *h_payto, + const struct TALER_FullPayto exchange_payto_uri, + struct GNUNET_TIME_Timestamp exec_time, + const struct TALER_PrivateContractHashP *h_contract_terms, + const struct TALER_DenominationPublicKey *denom_pub, + const struct TALER_CoinSpendPublicKeyP *coin_pub, + const struct TALER_Amount *coin_value, + const struct TALER_Amount *coin_fee) +{ + struct SumContext *ctx = cls; + + (void) merchant_pub; + (void) account_payto_uri; + (void) h_payto; + (void) exchange_payto_uri; + (void) exec_time; + (void) h_contract_terms; + (void) denom_pub; + (void) coin_pub; + (void) coin_fee; + if (0 == ctx->total++) + ctx->first_row = rowid; + ctx->value_sum += coin_value->value; +} + + +/** + * Nothing is reported while the table is empty, and aggregating an + * account with no deposits comes out at zero. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_MerchantPublicKeyP merchant_pub; + struct TALER_WireTransferIdentifierRawP wtid; + struct TALER_FullPaytoHashP h_payto; + struct TALER_FullPayto payto_uri = { NULL }; + struct TALER_Amount total; + struct TALER_Amount zero = TDB_amount ("0"); + struct TALER_Amount deposited; + struct TALER_Amount refunded; + struct TALER_Amount fee; + struct WtidContext ctx = { 0 }; + struct SumContext sctx = { 0 }; + + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + TDB_account (pg, + 10, + &account); + TDB_FILL (merchant_pub, + 1); + TDB_FILL (wtid, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_aggregate (pg, + &account.h_full, + &merchant_pub, + &wtid, + &total)); + FAILIF (0 != TALER_amount_cmp (&total, + &zero)); + FAILIF (0 != TDB_count (pg, + "FROM aggregation_tracking")); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_pending_aggregation (pg, + &wtid, + &h_payto, + &payto_uri, + &deposited, + &refunded, + &fee)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_aggregation_wtids_above_serial_id ( + pg, + 0, + &wtid_cb, + &ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_aggregations_above_serial_id ( + pg, + 0, + &aggregation_cb, + &sctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_wire_transfers (pg, + &wtid, + &transfer_cb, + &sctx)); + FAILIF (0 != ctx.total); + FAILIF (0 != sctx.total); + return 0; +} + + +/** + * Aggregating a deposit records it and nets out the deposit fee. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_aggregate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo coin; + struct TDB_Deposit dep; + struct TALER_WireTransferIdentifierRawP wtid; + struct TALER_FullPaytoHashP h_payto; + struct TALER_FullPayto payto_uri = { NULL }; + struct TALER_Amount total; + struct TALER_Amount deposited; + struct TALER_Amount refunded; + struct TALER_Amount fee; + struct TALER_Amount expect_total = TDB_amount ("0.9"); + struct TALER_Amount expect_deposited = TDB_amount ("1"); + struct TALER_Amount expect_refunded = TDB_amount ("0"); + struct TALER_Amount expect_fee = TDB_amount ("0.1"); + struct WtidContext ctx; + + TDB_coin (pg, + &denom, + 20, + &coin, + NULL); + TDB_deposit (pg, + &account, + &coin, + 20, + "1", + "0.1", + ts (1600000000), + ts (1600000000), + &dep); + TDB_FILL (wtid, + 20); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_aggregate (pg, + &account.h_full, + &dep.merchant_pub, + &wtid, + &total), + TDB_coin_free (&coin)); + FAILIF_C (0 != TALER_amount_cmp (&total, + &expect_total), + TDB_coin_free (&coin)); + FAILIF_C (1 != TDB_count (pg, + "FROM aggregation_tracking"), + TDB_coin_free (&coin)); + /* the deposit is now marked as done */ + FAILIF_C (1 != TDB_count (pg, + "FROM batch_deposits WHERE done"), + TDB_coin_free (&coin)); + + /* the transfer has not been made yet, so it is pending */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_pending_aggregation (pg, + &wtid, + &h_payto, + &payto_uri, + &deposited, + &refunded, + &fee), + TDB_coin_free (&coin)); + FAILIF_C (0 != GNUNET_memcmp (&h_payto, + &account.h_full), + GNUNET_free (payto_uri.full_payto); TDB_coin_free (&coin)); + FAILIF_C (0 != strcmp (payto_uri.full_payto, + account.payto.full_payto), + GNUNET_free (payto_uri.full_payto); TDB_coin_free (&coin)); + GNUNET_free (payto_uri.full_payto); + FAILIF_C (0 != TALER_amount_cmp (&deposited, + &expect_deposited), + TDB_coin_free (&coin)); + FAILIF_C (0 != TALER_amount_cmp (&refunded, + &expect_refunded), + TDB_coin_free (&coin)); + FAILIF_C (0 != TALER_amount_cmp (&fee, + &expect_fee), + TDB_coin_free (&coin)); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.wtid = &wtid; + FAILIF_C (0 >= + TALER_EXCHANGEDB_iterate_aggregation_wtids_above_serial_id ( + pg, + 0, + &wtid_cb, + &ctx), + TDB_coin_free (&coin)); + FAILIF_C (1 != ctx.matched, + TDB_coin_free (&coin)); + FAILIF_C (! ctx.pending, + TDB_coin_free (&coin)); + TDB_coin_free (&coin); + return 0; +} + + +/** + * A deposit that was refunded in full is aggregated at zero and is not + * charged a deposit fee. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_full_refund (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo coin; + struct TDB_Deposit dep; + struct TALER_EXCHANGEDB_Refund refund; + struct TALER_Amount deposit_fee = TDB_amount ("0.1"); + struct TALER_WireTransferIdentifierRawP wtid; + struct TALER_Amount total; + struct TALER_Amount zero = TDB_amount ("0"); + bool not_found; + bool refund_ok; + bool gone; + bool conflict; + + TDB_coin (pg, + &denom, + 21, + &coin, + NULL); + TDB_deposit (pg, + &account, + &coin, + 21, + "1", + "0.1", + ts (1600000000), + ts (1600000000), + &dep); + memset (&refund, + 0, + sizeof (refund)); + refund.coin = coin; + refund.details.merchant_pub = dep.merchant_pub; + TDB_FILL (refund.details.merchant_sig, + 21); + refund.details.h_contract_terms = dep.h_contract_terms; + refund.details.rtransaction_id = 1; + refund.details.refund_amount = TDB_amount ("1"); + refund.details.refund_fee = TDB_amount ("0"); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_refund (pg, + &refund, + &deposit_fee, + 0, + &not_found, + &refund_ok, + &gone, + &conflict), + TDB_coin_free (&coin)); + FAILIF_C (! refund_ok, + TDB_coin_free (&coin)); + + TDB_FILL (wtid, + 21); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_aggregate (pg, + &account.h_full, + &dep.merchant_pub, + &wtid, + &total), + TDB_coin_free (&coin)); + /* deposit EUR:1 minus refund EUR:1, and no deposit fee on a coin that + was refunded in full */ + FAILIF_C (0 != TALER_amount_cmp (&total, + &zero), + TDB_coin_free (&coin)); + TDB_coin_free (&coin); + return 0; +} + + +/** + * Executing the wire transfer takes the aggregation out of "pending". + * + * @param pg the database context + * @return 0 on success + */ +static int +check_executed (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_WireTransferIdentifierRawP wtid; + struct TALER_FullPaytoHashP h_payto; + struct TALER_FullPayto payto_uri = { NULL }; + struct TALER_FullPayto exchange_payto = { + .full_payto = (char *) "payto://x-taler-bank/localhost/exchange" + }; + struct TALER_Amount amount = TDB_amount ("0.9"); + struct TALER_Amount deposited; + struct TALER_Amount refunded; + struct TALER_Amount fee; + struct WtidContext ctx; + + TDB_FILL (wtid, + 20); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_wire_out (pg, + ts (1600003600), + &wtid, + &account.h_full, + "exchange-account-1", + exchange_payto, + &amount, + NULL)); + /* the aggregation is no longer pending... */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_pending_aggregation (pg, + &wtid, + &h_payto, + &payto_uri, + &deposited, + &refunded, + &fee)); + /* ...and the wtid iterator says so too */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.wtid = &wtid; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_aggregation_wtids_above_serial_id ( + pg, + 0, + &wtid_cb, + &ctx)); + FAILIF (1 != ctx.matched); + FAILIF (ctx.pending); + return 0; +} + + +/** + * The deposit can be traced to the wire transfer it went into, and the + * transfer back to the deposits it paid. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_transfer_by_deposit (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinSpendPublicKeyP coin_pub; + struct TDB_Deposit dep; + struct TALER_WireTransferIdentifierRawP wtid; + struct TALER_WireTransferIdentifierRawP got_wtid; + struct GNUNET_TIME_Timestamp exec_time; + struct TALER_Amount amount_with_fee; + struct TALER_Amount deposit_fee; + struct TALER_Amount expect_amount = TDB_amount ("1"); + struct TALER_EXCHANGEDB_KycStatus kyc; + union TALER_AccountPublicKeyP account_pub; + struct SumContext sctx; + bool pending = true; + + /* the deposit of check_aggregate() */ + TDB_FILL (coin_pub, + 20); + TDB_FILL (wtid, + 20); + memset (&dep, + 0, + sizeof (dep)); + TDB_FILL (dep.merchant_pub, + 20); + TDB_FILL (dep.h_contract_terms, + 20); + TDB_FILL (dep.wire_salt, + 20); + TALER_merchant_wire_signature_hash (account.payto, + &dep.wire_salt, + &dep.h_wire); + memset (&kyc, + 0, + sizeof (kyc)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_transfer_by_deposit (pg, + &dep.h_contract_terms, + &dep.h_wire, + &coin_pub, + &dep.merchant_pub, + &pending, + &got_wtid, + &exec_time, + &amount_with_fee, + &deposit_fee, + &kyc, + &account_pub)); + FAILIF (pending); + FAILIF (0 != GNUNET_memcmp (&got_wtid, + &wtid)); + FAILIF (0 != TALER_amount_cmp (&amount_with_fee, + &expect_amount)); + + /* a contract nobody deposited is not found */ + { + struct TALER_PrivateContractHashP other; + + TDB_FILL (other, + 97); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_transfer_by_deposit (pg, + &other, + &dep.h_wire, + &coin_pub, + &dep.merchant_pub, + &pending, + &got_wtid, + &exec_time, + &amount_with_fee, + &deposit_fee, + &kyc, + &account_pub)); + } + + /* and the transfer lists the coins it paid for */ + memset (&sctx, + 0, + sizeof (sctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_wire_transfers (pg, + &wtid, + &transfer_cb, + &sctx)); + FAILIF (1 != sctx.total); + FAILIF (1 != sctx.value_sum); + { + struct TALER_WireTransferIdentifierRawP other; + + TDB_FILL (other, + 96); + memset (&sctx, + 0, + sizeof (sctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_wire_transfers (pg, + &other, + &transfer_cb, + &sctx)); + FAILIF (0 != sctx.total); + } + return 0; +} + + +/** + * The aggregation iterator walks the table by serial. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate_aggregations (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct SumContext ctx; + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (2 != + TALER_EXCHANGEDB_iterate_aggregations_above_serial_id ( + pg, + 0, + &aggregation_cb, + &ctx)); + FAILIF (2 != ctx.total); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_aggregations_above_serial_id ( + pg, + 1000, + &aggregation_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "aggregation-tracking-empty", + &check_empty }, + { "aggregation-tracking-aggregate", + &check_aggregate }, + { "aggregation-tracking-full-refund", + &check_full_refund }, + { "aggregation-tracking-executed", + &check_executed }, + { "aggregation-tracking-transfer-by-deposit", + &check_transfer_by_deposit }, + { "aggregation-tracking-iterate-aggregations", + &check_iterate_aggregations }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-aggregation-tracking", + "Tests for the exchangedb `aggregation_tracking' table", + tests); + TDB_account_free (&account); + TDB_denom_free (&denom); + return ret; +} + + +/* end of test_aggregation_tracking.c */ diff --git a/src/exchangedb/test_aggregation_transient.c b/src/exchangedb/test_aggregation_transient.c @@ -0,0 +1,356 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_aggregation_transient.c + * @brief tests for the exchangedb functions whose primary table is + * `aggregation_transient` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_aggregation_transient(), + * #TALER_EXCHANGEDB_update_aggregation_transient(), + * #TALER_EXCHANGEDB_delete_aggregation_transient(), + * #TALER_EXCHANGEDB_get_aggregation_transient(), + * #TALER_EXCHANGEDB_get_aggregation_transient_by_wtid() and + * #TALER_EXCHANGEDB_get_aggregation_transient_by_normalized_payto(). + * + * The table holds what the aggregator decided to hold back for later; it + * is updated in place and deleted on payout, so it has no serial ID. It + * references `wire_targets`, which TDB_account() creates. The three + * lookups reach the row by three different keys, which is what the checks + * exercise. + */ +#include "test_common.h" +#include "exchange-database/delete_aggregation_transient.h" +#include "exchange-database/get_aggregation_transient.h" +#include "exchange-database/get_aggregation_transient_by_normalized_payto.h" +#include "exchange-database/get_aggregation_transient_by_wtid.h" +#include "exchange-database/insert_aggregation_transient.h" +#include "exchange-database/update_aggregation_transient.h" + + +/** + * Exchange bank account the checks aggregate for. + */ +#define SECTION "exchange-account-1" + + +/** + * Account the merchants of the checks are paid at. + */ +static struct TDB_Account account; + + +/** + * Nothing is found while the table is empty, and updating or deleting a + * row that is not there does nothing. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_MerchantPublicKeyP merchant_pub; + struct TALER_WireTransferIdentifierRawP wtid; + struct TALER_Amount total; + struct TALER_Amount one = TDB_amount ("1"); + struct TALER_FullPayto payto_uri = { NULL }; + uint64_t requirement_row; + + TDB_account (pg, + 10, + &account); + TDB_FILL (merchant_pub, + 1); + TDB_FILL (wtid, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_aggregation_transient (pg, + &account.h_full, + &merchant_pub, + SECTION, + &wtid, + &total)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_aggregation_transient_by_wtid ( + pg, + &account.h_full, + &wtid, + &total, + &requirement_row)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_aggregation_transient_by_normalized_payto ( + pg, + &account.h_normalized, + &payto_uri, + &wtid, + &merchant_pub, + &total)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_update_aggregation_transient (pg, + &account.h_full, + &wtid, + 0, + &one)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_delete_aggregation_transient (pg, + &account.h_full, + &wtid)); + FAILIF (0 != TDB_count (pg, + "FROM aggregation_transient")); + return 0; +} + + +/** + * An inserted row is found by all three lookups. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insert_and_lookup (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_MerchantPublicKeyP merchant_pub; + struct TALER_MerchantPublicKeyP got_merchant; + struct TALER_WireTransferIdentifierRawP wtid; + struct TALER_WireTransferIdentifierRawP got_wtid; + struct TALER_Amount total = TDB_amount ("3"); + struct TALER_Amount got; + struct TALER_FullPayto payto_uri = { NULL }; + uint64_t requirement_row = 42; + + TDB_FILL (merchant_pub, + 10); + TDB_FILL (wtid, + 10); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_aggregation_transient (pg, + &account.h_full, + SECTION, + &merchant_pub, + &wtid, + 0, + &total)); + FAILIF (1 != TDB_count (pg, + "FROM aggregation_transient")); + + /* by (account, merchant, exchange account) */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_aggregation_transient (pg, + &account.h_full, + &merchant_pub, + SECTION, + &got_wtid, + &got)); + FAILIF (0 != GNUNET_memcmp (&got_wtid, + &wtid)); + FAILIF (0 != TALER_amount_cmp (&got, + &total)); + + /* by (account, wtid) */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_aggregation_transient_by_wtid ( + pg, + &account.h_full, + &wtid, + &got, + &requirement_row)); + FAILIF (0 != TALER_amount_cmp (&got, + &total)); + /* no legitimization reason was recorded */ + FAILIF (0 != requirement_row); + + /* by normalized account */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_aggregation_transient_by_normalized_payto ( + pg, + &account.h_normalized, + &payto_uri, + &got_wtid, + &got_merchant, + &got)); + FAILIF_C (0 != strcmp (payto_uri.full_payto, + account.payto.full_payto), + GNUNET_free (payto_uri.full_payto)); + GNUNET_free (payto_uri.full_payto); + FAILIF (0 != GNUNET_memcmp (&got_merchant, + &merchant_pub)); + FAILIF (0 != GNUNET_memcmp (&got_wtid, + &wtid)); + + /* a different merchant on the same account has nothing */ + { + struct TALER_MerchantPublicKeyP other; + + TDB_FILL (other, + 99); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_aggregation_transient (pg, + &account.h_full, + &other, + SECTION, + &got_wtid, + &got)); + } + /* and neither does a different exchange bank account */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_aggregation_transient (pg, + &account.h_full, + &merchant_pub, + "exchange-account-2", + &got_wtid, + &got)); + return 0; +} + + +/** + * Updating a row changes the amount and the legitimization reason. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_update (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_MerchantPublicKeyP merchant_pub; + struct TALER_WireTransferIdentifierRawP wtid; + struct TALER_WireTransferIdentifierRawP got_wtid; + struct TALER_Amount total = TDB_amount ("7"); + struct TALER_Amount got; + uint64_t requirement_row = 0; + + TDB_FILL (merchant_pub, + 10); + TDB_FILL (wtid, + 10); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_aggregation_transient (pg, + &account.h_full, + &wtid, + 5, + &total)); + FAILIF (1 != TDB_count (pg, + "FROM aggregation_transient")); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_aggregation_transient (pg, + &account.h_full, + &merchant_pub, + SECTION, + &got_wtid, + &got)); + FAILIF (0 != TALER_amount_cmp (&got, + &total)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_aggregation_transient_by_wtid ( + pg, + &account.h_full, + &wtid, + &got, + &requirement_row)); + FAILIF (5 != requirement_row); + + /* a wire transfer identifier that is not on file is not created */ + { + struct TALER_WireTransferIdentifierRawP other; + + TDB_FILL (other, + 98); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_update_aggregation_transient (pg, + &account.h_full, + &other, + 0, + &total)); + FAILIF (1 != TDB_count (pg, + "FROM aggregation_transient")); + } + return 0; +} + + +/** + * Deleting a row removes it, and a second delete does nothing. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_delete (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_MerchantPublicKeyP merchant_pub; + struct TALER_WireTransferIdentifierRawP wtid; + struct TALER_Amount got; + + TDB_FILL (merchant_pub, + 10); + TDB_FILL (wtid, + 10); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_delete_aggregation_transient (pg, + &account.h_full, + &wtid)); + FAILIF (0 != TDB_count (pg, + "FROM aggregation_transient")); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_delete_aggregation_transient (pg, + &account.h_full, + &wtid)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_aggregation_transient (pg, + &account.h_full, + &merchant_pub, + SECTION, + &wtid, + &got)); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "aggregation-transient-empty", + &check_empty }, + { "aggregation-transient-insert-and-lookup", + &check_insert_and_lookup }, + { "aggregation-transient-update", + &check_update }, + { "aggregation-transient-delete", + &check_delete }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-aggregation-transient", + "Tests for the exchangedb `aggregation_transient' table", + tests); + TDB_account_free (&account); + return ret; +} + + +/* end of test_aggregation_transient.c */ diff --git a/src/exchangedb/test_aml_history.c b/src/exchangedb/test_aml_history.c @@ -0,0 +1,788 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_aml_history.c + * @brief tests for the exchangedb functions whose primary table is + * `aml_history` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_aml_decision(), + * #TALER_EXCHANGEDB_iterate_aml_history(), + * #TALER_EXCHANGEDB_iterate_aml_history_above_serial_id(), + * #TALER_EXCHANGEDB_iterate_aml_decisions() and + * #TALER_EXCHANGEDB_aml_history_builder(). + * + * `aml_history` is what an AML officer signed: one row per decision, each + * pointing at the `legitimization_outcomes` row it produced. The insert + * refuses an officer who may not decide and a decision older than the one + * already on file, and both of those are checked here. + */ +#include "test_common.h" +#include "exchange-database/account_history.h" +#include "exchange-database/insert_aml_decision.h" +#include "exchange-database/insert_aml_officer.h" +#include "exchange-database/iterate_aml_decisions.h" +#include "exchange-database/iterate_aml_history.h" +#include "exchange-database/iterate_aml_history_above_serial_id.h" + + +/** + * Account the checks decide about. + */ +static struct TDB_Account account; + + +/** + * Officer who takes the decisions. + */ +static struct TALER_AmlOfficerPublicKeyP officer_pub; + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Build a rule set naming @a name as its only rule. + * + * @param name name of the rule + * @return the rule set, to be freed with json_decref() + */ +static json_t * +make_rules (const char *name) +{ + json_t *j; + + j = GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("rule_name", + name), + GNUNET_JSON_pack_array_steal ("rules", + json_array ())); + GNUNET_assert (NULL != j); + return j; +} + + +/** + * Closure for #history_cb() and #decision_cb(). + */ +struct HistoryContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Stop after this many rows; 0 for no limit. + */ + unsigned int stop_after; + + /** + * Justification of the last row seen, owned by this struct. + */ + char *justification; + + /** + * Whether the last row asked for an investigation. + */ + bool to_investigate; + + /** + * Whether the last row is the active decision. + */ + bool is_active; + + /** + * Whether the last row named an officer. + */ + bool have_officer; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_aml_history(). + * + * @param cls a `struct HistoryContext *` + * @param outcome_serial_id row of the outcome the decision produced + * @param decision_time when the decision was taken + * @param justification why it was taken + * @param decider_pub which officer took it + * @param jproperties new account properties + * @param jnew_rules new account rules + * @param to_investigate whether staff should investigate + * @param is_active whether this is the active decision + */ +static void +history_cb (void *cls, + uint64_t outcome_serial_id, + struct GNUNET_TIME_Timestamp decision_time, + const char *justification, + const struct TALER_AmlOfficerPublicKeyP *decider_pub, + const json_t *jproperties, + const json_t *jnew_rules, + bool to_investigate, + bool is_active) +{ + struct HistoryContext *ctx = cls; + + (void) outcome_serial_id; + (void) decision_time; + (void) jproperties; + (void) jnew_rules; + ctx->total++; + GNUNET_free (ctx->justification); + ctx->justification = (NULL == justification) + ? NULL + : GNUNET_strdup (justification); + ctx->to_investigate = to_investigate; + ctx->is_active = is_active; + ctx->have_officer = (NULL != decider_pub); +} + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_aml_decisions(). + * + * @param cls a `struct HistoryContext *` + * @param row_id row of the decision + * @param justification why it was taken + * @param h_payto account it is about + * @param decision_time when it was taken + * @param expiration_time when the rules expire + * @param jproperties new account properties + * @param to_investigate whether staff should investigate + * @param is_active whether this is the active decision + * @param is_wallet whether the account is a wallet + * @param payto the account's payto URI + * @param account_rules the account's rules + */ +static void +decision_cb (void *cls, + uint64_t row_id, + const char *justification, + const struct TALER_NormalizedPaytoHashP *h_payto, + struct GNUNET_TIME_Timestamp decision_time, + struct GNUNET_TIME_Absolute expiration_time, + const json_t *jproperties, + bool to_investigate, + bool is_active, + bool is_wallet, + struct TALER_FullPayto payto, + const json_t *account_rules) +{ + struct HistoryContext *ctx = cls; + + (void) row_id; + (void) h_payto; + (void) decision_time; + (void) expiration_time; + (void) jproperties; + (void) is_wallet; + (void) payto; + (void) account_rules; + ctx->total++; + GNUNET_free (ctx->justification); + ctx->justification = (NULL == justification) + ? NULL + : GNUNET_strdup (justification); + ctx->to_investigate = to_investigate; + ctx->is_active = is_active; +} + + +/** + * Callback for + * #TALER_EXCHANGEDB_iterate_aml_history_above_serial_id(). + * + * @param cls a `struct HistoryContext *` + * @param rowid row of the decision + * @param h_payto account it is about + * @param justification why it was taken + * @param decider_pub officer who took it + * @param decider_sig the officer's signature + * @param decision_time when it was taken + * @param jproperties new account properties + * @param jnew_rules new account rules + * @param new_measure_name measure to apply immediately + * @param to_investigate whether staff should investigate + * @param attributes_expiration when the attributes expire + * @param h_attributes hash of the attributes + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +serial_cb (void *cls, + uint64_t rowid, + const struct TALER_NormalizedPaytoHashP *h_payto, + const char *justification, + const struct TALER_AmlOfficerPublicKeyP *decider_pub, + const struct TALER_AmlOfficerSignatureP *decider_sig, + struct GNUNET_TIME_Timestamp decision_time, + const json_t *jproperties, + const json_t *jnew_rules, + const char *new_measure_name, + bool to_investigate, + struct GNUNET_TIME_Timestamp attributes_expiration, + const struct GNUNET_HashCode *h_attributes) +{ + struct HistoryContext *ctx = cls; + + (void) rowid; + (void) h_payto; + (void) decider_sig; + (void) decision_time; + (void) jproperties; + (void) jnew_rules; + (void) new_measure_name; + (void) attributes_expiration; + (void) h_attributes; + ctx->total++; + GNUNET_free (ctx->justification); + ctx->justification = (NULL == justification) + ? NULL + : GNUNET_strdup (justification); + ctx->to_investigate = to_investigate; + ctx->have_officer = (NULL != decider_pub); + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * Outcome of an AML decision. + */ +struct DecisionStatus +{ + /** + * May the officer not decide right now? + */ + bool invalid_officer; + + /** + * Is the account unknown? + */ + bool unknown_account; + + /** + * Time of the decision that was already on file. + */ + struct GNUNET_TIME_Timestamp last_date; + + /** + * Row of the measures the decision put in place. + */ + uint64_t legitimization_measure_serial_id; + + /** + * Is the account a wallet? + */ + bool is_wallet; +}; + + +/** + * Take an AML decision about our account. + * + * @param pg the database context + * @param decider officer taking the decision, NULL for none + * @param seed seed for the officer's signature + * @param when when the decision is taken, in seconds since the epoch + * @param justification why + * @param to_investigate whether staff should investigate + * @param[out] st set to the outcome + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +decide (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TALER_AmlOfficerPublicKeyP *decider, + uint32_t seed, + uint64_t when, + const char *justification, + bool to_investigate, + struct DecisionStatus *st) +{ + struct TALER_AmlOfficerSignatureP decider_sig; + struct TALER_FullPayto null_payto = { NULL }; + json_t *new_rules = make_rules ("decided"); + /* the argument is declared `const char *[static 0]', so it must not + be NULL even though no event is to be triggered */ + const char *no_events[1] = { NULL }; + enum GNUNET_DB_QueryStatus qs; + + TDB_fill (&decider_sig, + sizeof (decider_sig), + seed); + memset (st, + 0, + sizeof (*st)); + qs = TALER_EXCHANGEDB_insert_aml_decision ( + pg, + null_payto, + &account.h_normalized, + ts (when), + GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS), + NULL, + new_rules, + to_investigate, + NULL, + NULL, + justification, + decider, + (NULL == decider) ? NULL : &decider_sig, + 0, + no_events, + NULL, + 0, + NULL, + NULL, + GNUNET_TIME_UNIT_ZERO_TS, + &st->invalid_officer, + &st->unknown_account, + &st->last_date, + &st->legitimization_measure_serial_id, + &st->is_wallet); + json_decref (new_rules); + return qs; +} + + +/** + * Nothing is known while the table is empty. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct HistoryContext ctx = { 0 }; + + TDB_account (pg, + 10, + &account); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_aml_history (pg, + &account.h_normalized, + 0, + 10, + &history_cb, + &ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_aml_history_above_serial_id ( + pg, + 0, + &serial_cb, + &ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_aml_decisions (pg, + &account.h_normalized, + TALER_EXCHANGE_YNA_ALL, + TALER_EXCHANGE_YNA_ALL, + 0, + 10, + &decision_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * An officer who is not on file may not decide. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_invalid_officer (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct DecisionStatus st; + + TDB_FILL (officer_pub, + 20); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + decide (pg, + &officer_pub, + 20, + 1600000000, + "no such officer", + false, + &st)); + FAILIF (! st.invalid_officer); + FAILIF (0 != TDB_count (pg, + "FROM aml_history")); + return 0; +} + + +/** + * An appointed officer's decision is recorded and becomes the active + * outcome. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_decide (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_MasterSignatureP master_sig; + struct GNUNET_TIME_Timestamp previous_change; + struct DecisionStatus st; + struct HistoryContext ctx; + + TDB_FILL (master_sig, + 20); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_aml_officer (pg, + &officer_pub, + &master_sig, + "Alex Officer", + true, + false, + ts (1500000000), + &previous_change)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + decide (pg, + &officer_pub, + 20, + 1600000000, + "looks fine", + false, + &st)); + FAILIF (st.invalid_officer); + FAILIF (st.unknown_account); + FAILIF (1 != TDB_count (pg, + "FROM aml_history")); + FAILIF (1 != TDB_count (pg, + "FROM legitimization_outcomes")); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_aml_history (pg, + &account.h_normalized, + 0, + 10, + &history_cb, + &ctx)); + FAILIF_C (1 != ctx.total, + GNUNET_free (ctx.justification)); + FAILIF_C (0 != strcmp (ctx.justification, + "looks fine"), + GNUNET_free (ctx.justification)); + FAILIF_C (! ctx.is_active, + GNUNET_free (ctx.justification)); + FAILIF_C (! ctx.have_officer, + GNUNET_free (ctx.justification)); + GNUNET_free (ctx.justification); + return 0; +} + + +/** + * A decision older than the one on file is refused; a newer one + * supersedes it. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_supersede (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct DecisionStatus st; + struct HistoryContext ctx; + + /* an older decision is refused (signalled by NO_RESULTS), and the + decision that is already on file is reported back */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + decide (pg, + &officer_pub, + 21, + 1500000000, + "too late", + false, + &st)); + FAILIF (GNUNET_TIME_timestamp_cmp (st.last_date, + !=, + ts (1600000000))); + FAILIF (1 != TDB_count (pg, + "FROM aml_history")); + + /* a newer one is taken and deactivates the previous outcome */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + decide (pg, + &officer_pub, + 22, + 1600003600, + "investigate this", + true, + &st)); + FAILIF (2 != TDB_count (pg, + "FROM aml_history")); + FAILIF (2 != TDB_count (pg, + "FROM legitimization_outcomes")); + FAILIF (1 != TDB_count (pg, + "FROM legitimization_outcomes" + " WHERE is_active")); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_aml_history (pg, + &account.h_normalized, + 0, + 10, + &history_cb, + &ctx)); + FAILIF_C (2 != ctx.total, + GNUNET_free (ctx.justification)); + GNUNET_free (ctx.justification); + return 0; +} + + +/** + * The AML officer's view filters by investigation and activity. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_decisions (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct HistoryContext ctx; + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_aml_decisions (pg, + &account.h_normalized, + TALER_EXCHANGE_YNA_ALL, + TALER_EXCHANGE_YNA_ALL, + 0, + 10, + &decision_cb, + &ctx)); + FAILIF_C (2 != ctx.total, + GNUNET_free (ctx.justification)); + GNUNET_free (ctx.justification); + + /* only the newer decision asked for an investigation */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_aml_decisions (pg, + &account.h_normalized, + TALER_EXCHANGE_YNA_YES, + TALER_EXCHANGE_YNA_ALL, + 0, + 10, + &decision_cb, + &ctx)); + FAILIF_C (1 != ctx.total, + GNUNET_free (ctx.justification)); + FAILIF_C (0 != strcmp (ctx.justification, + "investigate this"), + GNUNET_free (ctx.justification)); + GNUNET_free (ctx.justification); + + /* and only one of them is the active outcome */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_aml_decisions (pg, + &account.h_normalized, + TALER_EXCHANGE_YNA_ALL, + TALER_EXCHANGE_YNA_YES, + 0, + 10, + &decision_cb, + &ctx)); + FAILIF_C (1 != ctx.total, + GNUNET_free (ctx.justification)); + FAILIF_C (! ctx.is_active, + GNUNET_free (ctx.justification)); + GNUNET_free (ctx.justification); + + /* an account nobody decided about has nothing */ + { + struct TALER_NormalizedPaytoHashP other; + + TDB_FILL (other, + 98); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_aml_decisions (pg, + &other, + TALER_EXCHANGE_YNA_ALL, + TALER_EXCHANGE_YNA_ALL, + 0, + 10, + &decision_cb, + &ctx)); + FAILIF (0 != ctx.total); + } + return 0; +} + + +/** + * The auditor's view walks the decisions by serial and honours an + * aborting callback. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_serial (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct HistoryContext ctx; + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_aml_history_above_serial_id ( + pg, + 0, + &serial_cb, + &ctx)); + FAILIF_C (2 != ctx.total, + GNUNET_free (ctx.justification)); + FAILIF_C (! ctx.have_officer, + GNUNET_free (ctx.justification)); + GNUNET_free (ctx.justification); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_aml_history_above_serial_id ( + pg, + 1000, + &serial_cb, + &ctx)); + FAILIF (0 != ctx.total); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.stop_after = 1; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_aml_history_above_serial_id ( + pg, + 0, + &serial_cb, + &ctx)); + FAILIF_C (1 != ctx.total, + GNUNET_free (ctx.justification)); + GNUNET_free (ctx.justification); + return 0; +} + + +/** + * The history builder renders the account's AML history as JSON. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_history_builder (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_AttributeEncryptionKeyP attribute_key; + struct TALER_EXCHANGEDB_HistoryBuilderContext hbc = { + .account = &account.h_normalized, + .pg = pg, + .attribute_key = &attribute_key, + .is_wallet = false + }; + json_t *j; + + TDB_FILL (attribute_key, + 1); + j = TALER_EXCHANGEDB_aml_history_builder (&hbc); + FAILIF (NULL == j); + FAILIF_C (! json_is_array (j), + json_decref (j)); + FAILIF_C (2 != json_array_size (j), + json_decref (j)); + json_decref (j); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "aml-history-empty", + &check_empty }, + { "aml-history-invalid-officer", + &check_invalid_officer }, + { "aml-history-decide", + &check_decide }, + { "aml-history-supersede", + &check_supersede }, + { "aml-history-decisions", + &check_decisions }, + { "aml-history-serial", + &check_serial }, + { "aml-history-history-builder", + &check_history_builder }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-aml-history", + "Tests for the exchangedb `aml_history' table", + tests); + TDB_account_free (&account); + return ret; +} + + +/* end of test_aml_history.c */ diff --git a/src/exchangedb/test_aml_staff.c b/src/exchangedb/test_aml_staff.c @@ -0,0 +1,517 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_aml_staff.c + * @brief tests for the exchangedb functions whose primary table is + * `aml_staff` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_aml_officer(), + * #TALER_EXCHANGEDB_get_aml_officer(), + * #TALER_EXCHANGEDB_get_exists_aml_officer() and + * #TALER_EXCHANGEDB_iterate_aml_staff_above_serial_id(). + * + * `aml_staff` has no foreign keys and is append-only: every status change + * is a new row, and the current status is the newest one. A change dated + * before the one already on file is refused, which is what makes the + * "previous_change" output of the insert worth having. + */ +#include "test_common.h" +#include "exchange-database/get_aml_officer.h" +#include "exchange-database/get_exists_aml_officer.h" +#include "exchange-database/insert_aml_officer.h" +#include "exchange-database/iterate_aml_staff_above_serial_id.h" + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Closure for #staff_cb(). + */ +struct StaffContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Stop after this many rows; 0 for no limit. + */ + unsigned int stop_after; + + /** + * Officer we are looking for, NULL to match nothing. + */ + const struct TALER_AmlOfficerPublicKeyP *decider_pub; + + /** + * How many times did we see them? + */ + unsigned int matched; + + /** + * Active flag of the last matching row. + */ + bool is_active; + + /** + * Read-only flag of the last matching row. + */ + bool read_only; + + /** + * Name of the last matching row, owned by this struct. + */ + char *name; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_aml_staff_above_serial_id(). + * + * @param cls a `struct StaffContext *` + * @param rowid row of the status change + * @param decider_pub the officer + * @param master_sig signature affirming the status + * @param decider_name name of the officer + * @param is_active whether they could act from then on + * @param read_only whether their access was read-only + * @param last_change when the change took effect + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +staff_cb (void *cls, + uint64_t rowid, + const struct TALER_AmlOfficerPublicKeyP *decider_pub, + const struct TALER_MasterSignatureP *master_sig, + const char *decider_name, + bool is_active, + bool read_only, + struct GNUNET_TIME_Timestamp last_change) +{ + struct StaffContext *ctx = cls; + + (void) rowid; + (void) master_sig; + (void) last_change; + ctx->total++; + if ( (NULL != ctx->decider_pub) && + (0 == GNUNET_memcmp (decider_pub, + ctx->decider_pub)) ) + { + ctx->matched++; + ctx->is_active = is_active; + ctx->read_only = read_only; + GNUNET_free (ctx->name); + ctx->name = GNUNET_strdup (decider_name); + } + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * Record a status change for an officer. + * + * @param pg the database context + * @param seed seed for the officer's key and the master signature + * @param name name of the officer + * @param is_active whether they may act + * @param read_only whether their access is read-only + * @param when when the change takes effect, in seconds since the epoch + * @param[out] decider_pub set to the officer's key + * @param[out] previous_change set to the time of the previous change + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +add_officer (struct TALER_EXCHANGEDB_PostgresContext *pg, + uint32_t seed, + const char *name, + bool is_active, + bool read_only, + uint64_t when, + struct TALER_AmlOfficerPublicKeyP *decider_pub, + struct GNUNET_TIME_Timestamp *previous_change) +{ + struct TALER_MasterSignatureP master_sig; + + TDB_fill (decider_pub, + sizeof (*decider_pub), + seed); + TDB_FILL (master_sig, + seed); + return TALER_EXCHANGEDB_insert_aml_officer (pg, + decider_pub, + &master_sig, + name, + is_active, + read_only, + ts (when), + previous_change); +} + + +/** + * Nothing is known about an officer who was never appointed. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_AmlOfficerPublicKeyP decider_pub; + struct TALER_MasterSignatureP master_sig; + struct GNUNET_TIME_Absolute last_change; + struct StaffContext ctx = { 0 }; + char *decider_name = NULL; + bool is_active; + bool read_only; + + TDB_FILL (decider_pub, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_aml_officer (pg, + &decider_pub, + &master_sig, + &decider_name, + &is_active, + &read_only, + &last_change)); + FAILIF (NULL != decider_name); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_exists_aml_officer (pg, + &decider_pub, + &read_only)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_aml_staff_above_serial_id (pg, + 0, + &staff_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * Appointing an officer records them and makes them active. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_appoint (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_AmlOfficerPublicKeyP decider_pub; + struct TALER_MasterSignatureP master_sig; + struct TALER_MasterSignatureP expect_sig; + struct GNUNET_TIME_Timestamp previous_change; + struct GNUNET_TIME_Absolute last_change; + struct StaffContext ctx; + char *decider_name = NULL; + bool is_active = false; + bool read_only = true; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_officer (pg, + 10, + "Alex Officer", + true, + false, + 1600000000, + &decider_pub, + &previous_change)); + FAILIF (1 != TDB_count (pg, + "FROM aml_staff")); + + TDB_FILL (expect_sig, + 10); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_aml_officer (pg, + &decider_pub, + &master_sig, + &decider_name, + &is_active, + &read_only, + &last_change)); + FAILIF_C (0 != strcmp (decider_name, + "Alex Officer"), + GNUNET_free (decider_name)); + GNUNET_free (decider_name); + FAILIF (0 != GNUNET_memcmp (&master_sig, + &expect_sig)); + FAILIF (! is_active); + FAILIF (read_only); + FAILIF (last_change.abs_value_us != + ts (1600000000).abs_time.abs_value_us); + + read_only = true; + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_exists_aml_officer (pg, + &decider_pub, + &read_only)); + FAILIF (read_only); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.decider_pub = &decider_pub; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_aml_staff_above_serial_id (pg, + 0, + &staff_cb, + &ctx)); + FAILIF_C (1 != ctx.matched, + GNUNET_free (ctx.name)); + FAILIF_C (0 != strcmp (ctx.name, + "Alex Officer"), + GNUNET_free (ctx.name)); + GNUNET_free (ctx.name); + return 0; +} + + +/** + * A later change supersedes the earlier one; an earlier one is refused. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_history (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_AmlOfficerPublicKeyP decider_pub; + struct TALER_MasterSignatureP master_sig; + struct GNUNET_TIME_Timestamp previous_change; + struct GNUNET_TIME_Absolute last_change; + struct StaffContext ctx; + char *decider_name = NULL; + bool is_active = true; + bool read_only = false; + + /* the officer is demoted to read-only */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_officer (pg, + 10, + "Alex Officer", + true, + true, + 1600003600, + &decider_pub, + &previous_change)); + FAILIF (GNUNET_TIME_timestamp_cmp (previous_change, + !=, + ts (1600000000))); + FAILIF (2 != TDB_count (pg, + "FROM aml_staff")); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_aml_officer (pg, + &decider_pub, + &master_sig, + &decider_name, + &is_active, + &read_only, + &last_change)); + GNUNET_free (decider_name); + FAILIF (! read_only); + read_only = false; + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_exists_aml_officer (pg, + &decider_pub, + &read_only)); + FAILIF (! read_only); + + /* A change dated before the current status is refused. The call still + reports ONE_RESULT -- the stored procedure always returns its row -- + and the refusal shows in @e previous_change, which comes back as the + status that stayed in force rather than as the one being replaced. */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_officer (pg, + 10, + "Alex Officer", + true, + false, + 1600001800, + &decider_pub, + &previous_change)); + FAILIF (GNUNET_TIME_timestamp_cmp (previous_change, + !=, + ts (1600003600))); + FAILIF (2 != TDB_count (pg, + "FROM aml_staff")); + + /* both rows are that officer's history */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.decider_pub = &decider_pub; + FAILIF (2 != + TALER_EXCHANGEDB_iterate_aml_staff_above_serial_id (pg, + 0, + &staff_cb, + &ctx)); + FAILIF_C (2 != ctx.matched, + GNUNET_free (ctx.name)); + GNUNET_free (ctx.name); + return 0; +} + + +/** + * A deactivated officer is no longer active. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_deactivate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_AmlOfficerPublicKeyP decider_pub; + struct GNUNET_TIME_Timestamp previous_change; + bool read_only = false; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_officer (pg, + 10, + "Alex Officer", + false, + false, + 1600007200, + &decider_pub, + &previous_change)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_exists_aml_officer (pg, + &decider_pub, + &read_only)); + FAILIF (3 != TDB_count (pg, + "FROM aml_staff")); + return 0; +} + + +/** + * The iterator's serial bound and abort return behave as documented. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_AmlOfficerPublicKeyP other; + struct GNUNET_TIME_Timestamp previous_change; + struct StaffContext ctx; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_officer (pg, + 11, + "Bo Officer", + true, + true, + 1600000000, + &other, + &previous_change)); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.decider_pub = &other; + FAILIF (4 != + TALER_EXCHANGEDB_iterate_aml_staff_above_serial_id (pg, + 0, + &staff_cb, + &ctx)); + FAILIF_C (1 != ctx.matched, + GNUNET_free (ctx.name)); + FAILIF_C (! ctx.read_only, + GNUNET_free (ctx.name)); + GNUNET_free (ctx.name); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_aml_staff_above_serial_id (pg, + 1000, + &staff_cb, + &ctx)); + FAILIF (0 != ctx.total); + + /* A callback that gives up stops the iteration. Unlike most of the + other iterators, this one reports how many rows the callback was + actually shown rather than how many the query matched. */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.stop_after = 1; + FAILIF (1 != + TALER_EXCHANGEDB_iterate_aml_staff_above_serial_id (pg, + 0, + &staff_cb, + &ctx)); + FAILIF (1 != ctx.total); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "aml-staff-empty", + &check_empty }, + { "aml-staff-appoint", + &check_appoint }, + { "aml-staff-history", + &check_history }, + { "aml-staff-deactivate", + &check_deactivate }, + { "aml-staff-iterate", + &check_iterate }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-aml-staff", + "Tests for the exchangedb `aml_staff' table", + tests); +} + + +/* end of test_aml_staff.c */ diff --git a/src/exchangedb/test_auditor_denom_sigs.c b/src/exchangedb/test_auditor_denom_sigs.c @@ -0,0 +1,338 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_auditor_denom_sigs.c + * @brief tests for the exchangedb functions whose primary table is + * `auditor_denom_sigs` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_auditor_denom_sig(), + * #TALER_EXCHANGEDB_get_auditor_denom_sig() and + * #TALER_EXCHANGEDB_iterate_auditor_denominations(). + * + * The table references `auditors` and `denominations`, and the insert + * resolves both by their natural keys inside the statement -- so an unknown + * auditor or denomination has to come back as "nothing inserted". Fixtures + * come from #TALER_EXCHANGEDB_insert_auditor() and TDB_denom(), whose own + * tables are covered by test_auditors.c and test_denominations.c. + */ +#include "test_common.h" +#include "exchange-database/insert_auditor.h" +#include "exchange-database/update_auditor.h" +#include "exchange-database/insert_auditor_denom_sig.h" +#include "exchange-database/get_auditor_denom_sig.h" +#include "exchange-database/iterate_auditor_denominations.h" + + +/** + * Closure for #count_cb(). + */ +struct CountContext +{ + /** + * Auditor we are looking for. + */ + const struct TALER_AuditorPublicKeyP *auditor_pub; + + /** + * Denomination we are looking for. + */ + const struct TALER_DenominationHashP *h_denom_pub; + + /** + * How many rows did the callback see in total? + */ + unsigned int total; + + /** + * How many times did we see the pair we are after? + */ + unsigned int matched; + + /** + * Signature reported for that pair. + */ + struct TALER_AuditorSignatureP auditor_sig; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_auditor_denominations(). + * + * @param cls a `struct CountContext *` + * @param auditor_pub public key of the auditor + * @param h_denom_pub denomination audited by @a auditor_pub + * @param auditor_sig the auditor's signature + */ +static void +count_cb (void *cls, + const struct TALER_AuditorPublicKeyP *auditor_pub, + const struct TALER_DenominationHashP *h_denom_pub, + const struct TALER_AuditorSignatureP *auditor_sig) +{ + struct CountContext *ctx = cls; + + ctx->total++; + if ( (0 != GNUNET_memcmp (auditor_pub, + ctx->auditor_pub)) || + (0 != GNUNET_memcmp (h_denom_pub, + ctx->h_denom_pub)) ) + return; + ctx->matched++; + ctx->auditor_sig = *auditor_sig; +} + + +/** + * Neither half of the pair existing means nothing is inserted and nothing + * is found. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_unknown_references (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_AuditorPublicKeyP auditor_pub; + struct TALER_DenominationHashP h_denom_pub; + struct TALER_AuditorSignatureP auditor_sig; + struct TALER_AuditorSignatureP got; + struct TDB_Denom denom; + + TDB_FILL (auditor_pub, + 1); + TDB_FILL (h_denom_pub, + 1); + TDB_FILL (auditor_sig, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_auditor_denom_sig (pg, + &h_denom_pub, + &auditor_pub, + &got)); + /* neither the auditor nor the denomination exist */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_insert_auditor_denom_sig (pg, + &h_denom_pub, + &auditor_pub, + &auditor_sig)); + /* the denomination exists, the auditor does not */ + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_insert_auditor_denom_sig (pg, + &denom.h_denom_pub, + &auditor_pub, + &auditor_sig), + TDB_denom_free (&denom)); + /* the auditor exists, the denomination does not */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_auditor (pg, + &auditor_pub, + "https://auditor.example/", + "The Auditor", + GNUNET_TIME_timestamp_get ()), + TDB_denom_free (&denom)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_insert_auditor_denom_sig (pg, + &h_denom_pub, + &auditor_pub, + &auditor_sig), + TDB_denom_free (&denom)); + FAILIF_C (0 != TDB_count (pg, + "FROM auditor_denom_sigs"), + TDB_denom_free (&denom)); + TDB_denom_free (&denom); + return 0; +} + + +/** + * With both halves present the signature is stored, found and iterated; + * a repeated insert is a no-op. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insert_and_lookup (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_AuditorPublicKeyP auditor_pub; + struct TALER_AuditorSignatureP auditor_sig; + struct TALER_AuditorSignatureP other_sig; + struct TALER_AuditorSignatureP got; + struct TDB_Denom denom; + struct CountContext ctx; + + /* the auditor and denomination from the previous check */ + TDB_FILL (auditor_pub, + 1); + TDB_FILL (auditor_sig, + 1); + TDB_FILL (other_sig, + 77); + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + /* TDB_denom() with the same seed builds the same key, so the row is the + one the previous check created; that is exactly what we want here. */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_auditor_denom_sig (pg, + &denom.h_denom_pub, + &auditor_pub, + &auditor_sig), + TDB_denom_free (&denom)); + memset (&got, + 0, + sizeof (got)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_auditor_denom_sig (pg, + &denom.h_denom_pub, + &auditor_pub, + &got), + TDB_denom_free (&denom)); + FAILIF_C (0 != GNUNET_memcmp (&got, + &auditor_sig), + TDB_denom_free (&denom)); + + /* re-inserting with a different signature does nothing (ON CONFLICT DO + NOTHING), and in particular does not overwrite the stored one */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_insert_auditor_denom_sig (pg, + &denom.h_denom_pub, + &auditor_pub, + &other_sig), + TDB_denom_free (&denom)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_auditor_denom_sig (pg, + &denom.h_denom_pub, + &auditor_pub, + &got), + TDB_denom_free (&denom)); + FAILIF_C (0 != GNUNET_memcmp (&got, + &auditor_sig), + TDB_denom_free (&denom)); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.auditor_pub = &auditor_pub; + ctx.h_denom_pub = &denom.h_denom_pub; + FAILIF_C (0 >= + TALER_EXCHANGEDB_iterate_auditor_denominations (pg, + &count_cb, + &ctx), + TDB_denom_free (&denom)); + FAILIF_C (1 != ctx.matched, + TDB_denom_free (&denom)); + FAILIF_C (0 != GNUNET_memcmp (&ctx.auditor_sig, + &auditor_sig), + TDB_denom_free (&denom)); + TDB_denom_free (&denom); + return 0; +} + + +/** + * A disabled auditor keeps its rows but drops out of the iterator. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_disabled_auditor (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_AuditorPublicKeyP auditor_pub; + struct TALER_AuditorSignatureP got; + struct TDB_Denom denom; + struct CountContext ctx; + + TDB_FILL (auditor_pub, + 1); + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_auditor (pg, + &auditor_pub, + "https://auditor.example/", + "The Auditor", + GNUNET_TIME_timestamp_get (), + false), + TDB_denom_free (&denom)); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.auditor_pub = &auditor_pub; + ctx.h_denom_pub = &denom.h_denom_pub; + FAILIF_C (0 > + TALER_EXCHANGEDB_iterate_auditor_denominations (pg, + &count_cb, + &ctx), + TDB_denom_free (&denom)); + FAILIF_C (0 != ctx.matched, + TDB_denom_free (&denom)); + FAILIF_C (0 != ctx.total, + TDB_denom_free (&denom)); + /* the row itself is untouched, and the direct lookup ignores is_active */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_auditor_denom_sig (pg, + &denom.h_denom_pub, + &auditor_pub, + &got), + TDB_denom_free (&denom)); + FAILIF_C (1 != TDB_count (pg, + "FROM auditor_denom_sigs"), + TDB_denom_free (&denom)); + TDB_denom_free (&denom); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "auditor-denom-sigs-unknown-references", + &check_unknown_references }, + { "auditor-denom-sigs-insert-and-lookup", + &check_insert_and_lookup }, + { "auditor-denom-sigs-disabled-auditor", + &check_disabled_auditor }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-auditor-denom-sigs", + "Tests for the exchangedb `auditor_denom_sigs' table", + tests); +} + + +/* end of test_auditor_denom_sigs.c */ diff --git a/src/exchangedb/test_auditors.c b/src/exchangedb/test_auditors.c @@ -0,0 +1,344 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_auditors.c + * @brief tests for the exchangedb functions whose primary table is `auditors` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_auditor(), + * #TALER_EXCHANGEDB_update_auditor(), + * #TALER_EXCHANGEDB_get_auditor_status(), + * #TALER_EXCHANGEDB_get_auditor_timestamp() and + * #TALER_EXCHANGEDB_iterate_active_auditors(). + * + * `auditors` has no foreign keys, so the checks need no fixtures. + */ +#include "test_common.h" +#include "exchange-database/insert_auditor.h" +#include "exchange-database/update_auditor.h" +#include "exchange-database/get_auditor_status.h" +#include "exchange-database/get_auditor_timestamp.h" +#include "exchange-database/iterate_active_auditors.h" + + +/** + * Nothing is known about an auditor that was never inserted. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_AuditorPublicKeyP auditor_pub; + struct GNUNET_TIME_Timestamp last_date; + char *url = NULL; + bool enabled = true; + + TDB_FILL (auditor_pub, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_auditor_status (pg, + &auditor_pub, + &url, + &enabled)); + FAILIF (NULL != url); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_auditor_timestamp (pg, + &auditor_pub, + &last_date)); + /* updating an auditor that is not on file does not create one */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_update_auditor (pg, + &auditor_pub, + "https://auditor.example/", + "Nobody", + GNUNET_TIME_timestamp_get (), + true)); + FAILIF (0 != TDB_count (pg, + "FROM auditors")); + return 0; +} + + +/** + * Closure for #count_auditors_cb(). + */ +struct CountContext +{ + /** + * Auditor we are looking for. + */ + const struct TALER_AuditorPublicKeyP *auditor_pub; + + /** + * How many auditors did the callback see in total? + */ + unsigned int total; + + /** + * How many times did we see @e auditor_pub? + */ + unsigned int matched; + + /** + * URL reported for @e auditor_pub, owned by this struct. + */ + char *url; + + /** + * Name reported for @e auditor_pub, owned by this struct. + */ + char *name; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_active_auditors(). + * + * @param cls a `struct CountContext *` + * @param auditor_pub public key of the auditor + * @param auditor_url base URL of the auditor + * @param auditor_name human-readable name of the auditor + */ +static void +count_auditors_cb (void *cls, + const struct TALER_AuditorPublicKeyP *auditor_pub, + const char *auditor_url, + const char *auditor_name) +{ + struct CountContext *ctx = cls; + + ctx->total++; + if (0 != GNUNET_memcmp (auditor_pub, + ctx->auditor_pub)) + return; + ctx->matched++; + GNUNET_free (ctx->url); + GNUNET_free (ctx->name); + ctx->url = GNUNET_strdup (auditor_url); + ctx->name = GNUNET_strdup (auditor_name); +} + + +/** + * Insert an auditor, read it back, then disable and re-enable it. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insert_update (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_AuditorPublicKeyP auditor_pub; + struct GNUNET_TIME_Timestamp start; + struct GNUNET_TIME_Timestamp later; + struct GNUNET_TIME_Timestamp last_date; + struct CountContext ctx; + char *url = NULL; + bool enabled = false; + + TDB_FILL (auditor_pub, + 2); + start = GNUNET_TIME_timestamp_get (); + later = GNUNET_TIME_absolute_to_timestamp ( + GNUNET_TIME_absolute_add (start.abs_time, + GNUNET_TIME_UNIT_HOURS)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_auditor (pg, + &auditor_pub, + "https://auditor.example/", + "The Auditor", + start)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_auditor_status (pg, + &auditor_pub, + &url, + &enabled)); + FAILIF (NULL == url); + FAILIF_C (0 != strcmp (url, + "https://auditor.example/"), + GNUNET_free (url)); + GNUNET_free (url); + /* a fresh auditor is active */ + FAILIF (! enabled); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_auditor_timestamp (pg, + &auditor_pub, + &last_date)); + FAILIF (GNUNET_TIME_timestamp_cmp (last_date, + !=, + start)); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.auditor_pub = &auditor_pub; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_active_auditors (pg, + &count_auditors_cb, + &ctx)); + FAILIF_C (1 != ctx.matched, + GNUNET_free (ctx.url); GNUNET_free (ctx.name)); + FAILIF_C (0 != strcmp (ctx.name, + "The Auditor"), + GNUNET_free (ctx.url); GNUNET_free (ctx.name)); + GNUNET_free (ctx.url); + GNUNET_free (ctx.name); + + /* disabling drops the auditor from the active iterator */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_auditor (pg, + &auditor_pub, + "https://auditor2.example/", + "The Other Auditor", + later, + false)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_auditor_status (pg, + &auditor_pub, + &url, + &enabled)); + FAILIF_C (0 != strcmp (url, + "https://auditor2.example/"), + GNUNET_free (url)); + GNUNET_free (url); + FAILIF (enabled); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_auditor_timestamp (pg, + &auditor_pub, + &last_date)); + FAILIF (GNUNET_TIME_timestamp_cmp (last_date, + !=, + later)); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.auditor_pub = &auditor_pub; + FAILIF (0 > + TALER_EXCHANGEDB_iterate_active_auditors (pg, + &count_auditors_cb, + &ctx)); + FAILIF (0 != ctx.matched); + + /* ...and re-enabling brings it back */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_auditor (pg, + &auditor_pub, + "https://auditor2.example/", + "The Other Auditor", + later, + true)); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.auditor_pub = &auditor_pub; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_active_auditors (pg, + &count_auditors_cb, + &ctx)); + FAILIF_C (1 != ctx.matched, + GNUNET_free (ctx.url); GNUNET_free (ctx.name)); + GNUNET_free (ctx.url); + GNUNET_free (ctx.name); + return 0; +} + + +/** + * A second auditor is independent of the first, and only the active ones + * are iterated. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_two_auditors (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_AuditorPublicKeyP a2; + struct TALER_AuditorPublicKeyP a3; + struct GNUNET_TIME_Timestamp now = GNUNET_TIME_timestamp_get (); + struct CountContext ctx; + + TDB_FILL (a2, + 3); + TDB_FILL (a3, + 4); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_auditor (pg, + &a2, + "https://a2.example/", + "A2", + now)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_auditor (pg, + &a3, + "https://a3.example/", + "A3", + now)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_auditor (pg, + &a3, + "https://a3.example/", + "A3", + now, + false)); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.auditor_pub = &a3; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_active_auditors (pg, + &count_auditors_cb, + &ctx)); + FAILIF (0 != ctx.matched); + FAILIF (ctx.total != + TDB_count (pg, + "FROM auditors WHERE is_active")); + FAILIF (ctx.total == + TDB_count (pg, + "FROM auditors")); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "auditors-empty", + &check_empty }, + { "auditors-insert-update", + &check_insert_update }, + { "auditors-two-auditors", + &check_two_auditors }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-auditors", + "Tests for the exchangedb `auditors' table", + tests); +} + + +/* end of test_auditors.c */ diff --git a/src/exchangedb/test_batch_deposits.c b/src/exchangedb/test_batch_deposits.c @@ -0,0 +1,612 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_batch_deposits.c + * @brief tests for the exchangedb functions whose primary table is + * `batch_deposits` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_do_deposit(), + * #TALER_EXCHANGEDB_do_check_deposit_idempotent(), + * #TALER_EXCHANGEDB_get_exists_deposit(), + * #TALER_EXCHANGEDB_get_ready_deposit(), + * #TALER_EXCHANGEDB_iterate_batch_deposits_missing_wire() and + * #TALER_EXCHANGEDB_get_wire_hash_for_contract(). + * + * `batch_deposits` references `wire_targets` and, through `coin_deposits`, + * `known_coins`. A deposit is "ready" only once its wire deadline has + * passed *and* its refund deadline is behind the aggregation shift + * boundary, so the checks use deadlines in the past for that. + */ +#include "test_common.h" +#include "exchange-database/do_check_deposit_idempotent.h" +#include "exchange-database/do_deposit.h" +#include "exchange-database/get_exists_deposit.h" +#include "exchange-database/get_ready_deposit.h" +#include "exchange-database/get_wire_hash_for_contract.h" +#include "exchange-database/iterate_batch_deposits_missing_wire.h" +#include "exchange-database/rollback.h" +#include "exchange-database/start.h" + + +/** + * Account the merchants of the checks are paid at. + */ +static struct TDB_Account account; + + +/** + * Denomination the checks deposit. + */ +static struct TDB_Denom denom; + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Closure for #missing_cb(). + */ +struct MissingContext +{ + /** + * How many deposits did the callback see? + */ + unsigned int total; + + /** + * Row of the first deposit seen. + */ + uint64_t first_row; + + /** + * Sum of the whole-unit parts of the amounts seen. + */ + uint64_t value_sum; + + /** + * Account of the last deposit seen. + */ + struct TALER_FullPaytoHashP h_payto; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_batch_deposits_missing_wire(). + * + * @param cls a `struct MissingContext *` + * @param batch_deposit_serial_id row of the deposit + * @param total_amount how much is owed + * @param wire_target_h_payto where it is owed + * @param deadline when it was due + */ +static void +missing_cb (void *cls, + uint64_t batch_deposit_serial_id, + const struct TALER_Amount *total_amount, + const struct TALER_FullPaytoHashP *wire_target_h_payto, + struct GNUNET_TIME_Timestamp deadline) +{ + struct MissingContext *ctx = cls; + + (void) deadline; + if (0 == ctx->total++) + ctx->first_row = batch_deposit_serial_id; + ctx->value_sum += total_amount->value; + ctx->h_payto = *wire_target_h_payto; +} + + +/** + * Nothing is known while the table is empty. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_MerchantPublicKeyP merchant_pub; + struct TALER_PrivateContractHashP h_contract_terms; + struct TALER_MerchantWireHashP h_wire; + struct TALER_CoinSpendPublicKeyP coin_pub; + struct TALER_Amount deposit_fee; + struct GNUNET_TIME_Timestamp exchange_timestamp; + struct TALER_FullPayto payto_uri = { NULL }; + struct MissingContext ctx = { 0 }; + char *metadata = NULL; + + TDB_FILL (merchant_pub, + 1); + TDB_FILL (h_contract_terms, + 1); + TDB_FILL (h_wire, + 1); + TDB_FILL (coin_pub, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_wire_hash_for_contract (pg, + &merchant_pub, + &h_contract_terms, + &h_wire)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_exists_deposit (pg, + &h_contract_terms, + &h_wire, + &coin_pub, + &merchant_pub, + ts (1600000000), + &deposit_fee, + &exchange_timestamp)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_ready_deposit (pg, + 0, + UINT32_MAX, + &merchant_pub, + &payto_uri, + &metadata)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_batch_deposits_missing_wire (pg, + 0, + &missing_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * A deposit of more than the coin holds is refused. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insufficient_balance (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo coin; + struct TALER_EXCHANGEDB_CoinDepositInformation cdi; + struct TALER_EXCHANGEDB_BatchDeposit bd; + struct TALER_Amount fees[1]; + struct TALER_Amount total; + struct GNUNET_TIME_Timestamp now = GNUNET_TIME_timestamp_get (); + uint32_t bad_balance_index = 42; + bool balance_ok = true; + bool in_conflict = true; + + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + TDB_account (pg, + 10, + &account); + TDB_coin (pg, + &denom, + 20, + &coin, + NULL); + memset (&cdi, + 0, + sizeof (cdi)); + cdi.coin = coin; + TDB_FILL (cdi.csig, + 20); + cdi.amount_with_fee = TDB_amount ("6"); + fees[0] = TDB_amount ("0.1"); + memset (&bd, + 0, + sizeof (bd)); + TDB_FILL (bd.merchant_pub, + 20); + TDB_FILL (bd.merchant_sig, + 20); + TDB_FILL (bd.h_contract_terms, + 20); + bd.no_wallet_data_hash = true; + bd.wire_target_h_payto = account.h_full; + TDB_FILL (bd.wire_salt, + 20); + bd.wallet_timestamp = now; + bd.refund_deadline = now; + bd.wire_deadline = now; + bd.receiver_wire_account = account.payto; + bd.cdis = &cdi; + bd.num_cdis = 1; + /* The batch row goes in before the per-coin balance is checked, so this + is how the /deposit handler uses the function: inside a transaction it + can roll back. */ + FAILIF_C (GNUNET_OK != + TALER_EXCHANGEDB_start (pg, + "test-deposit-insufficient"), + TDB_coin_free (&coin)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_deposit (pg, + &bd, + fees, + &now, + &total, + &balance_ok, + &bad_balance_index, + &in_conflict), + TALER_EXCHANGEDB_rollback (pg); TDB_coin_free (&coin)); + TALER_EXCHANGEDB_rollback (pg); + TDB_coin_free (&coin); + FAILIF (balance_ok); + FAILIF (in_conflict); + FAILIF (0 != bad_balance_index); + FAILIF (0 != TDB_count (pg, + "FROM batch_deposits")); + return 0; +} + + +/** + * A deposit within the coin's balance is recorded and can be found again. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_deposit (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo coin; + struct TDB_Deposit dep; + struct TALER_MerchantWireHashP h_wire; + struct TALER_Amount deposit_fee; + struct TALER_Amount expect_fee = TDB_amount ("0.1"); + struct GNUNET_TIME_Timestamp exchange_timestamp; + + TDB_coin (pg, + &denom, + 21, + &coin, + NULL); + TDB_deposit (pg, + &account, + &coin, + 21, + "1", + "0.1", + ts (1600000000), + ts (1600000000), + &dep); + FAILIF_C (1 != TDB_count (pg, + "FROM batch_deposits"), + TDB_coin_free (&coin)); + FAILIF_C (1 != TDB_count (pg, + "FROM coin_deposits"), + TDB_coin_free (&coin)); + + /* the salted account hash comes back from the contract */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_wire_hash_for_contract (pg, + &dep.merchant_pub, + &dep.h_contract_terms, + &h_wire), + TDB_coin_free (&coin)); + FAILIF_C (0 != GNUNET_memcmp (&h_wire, + &dep.h_wire), + TDB_coin_free (&coin)); + + /* ...and the deposit itself, with its fee */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_exists_deposit (pg, + &dep.h_contract_terms, + &dep.h_wire, + &coin.coin_pub, + &dep.merchant_pub, + dep.refund_deadline, + &deposit_fee, + &exchange_timestamp), + TDB_coin_free (&coin)); + FAILIF_C (0 != TALER_amount_cmp (&deposit_fee, + &expect_fee), + TDB_coin_free (&coin)); + + /* a different refund deadline makes it a different deposit */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_exists_deposit (pg, + &dep.h_contract_terms, + &dep.h_wire, + &coin.coin_pub, + &dep.merchant_pub, + ts (1700000000), + &deposit_fee, + &exchange_timestamp), + TDB_coin_free (&coin)); + /* ...and so does a different account hash */ + { + struct TALER_MerchantWireHashP other; + + TDB_FILL (other, + 99); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_exists_deposit (pg, + &dep.h_contract_terms, + &other, + &coin.coin_pub, + &dep.merchant_pub, + dep.refund_deadline, + &deposit_fee, + &exchange_timestamp), + TDB_coin_free (&coin)); + } + TDB_coin_free (&coin); + return 0; +} + + +/** + * A replayed deposit is recognised as idempotent; a different one is not. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_idempotent (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo coin; + struct TALER_EXCHANGEDB_CoinDepositInformation cdi; + struct TALER_EXCHANGEDB_BatchDeposit bd; + struct GNUNET_TIME_Timestamp exchange_timestamp; + bool is_idempotent = false; + + TDB_coin (pg, + &denom, + 21, + &coin, + NULL); + memset (&cdi, + 0, + sizeof (cdi)); + cdi.coin = coin; + TDB_FILL (cdi.csig, + 21); + cdi.amount_with_fee = TDB_amount ("1"); + memset (&bd, + 0, + sizeof (bd)); + TDB_FILL (bd.merchant_pub, + 21); + TDB_FILL (bd.merchant_sig, + 21); + TDB_FILL (bd.h_contract_terms, + 21); + bd.no_wallet_data_hash = true; + bd.wire_target_h_payto = account.h_full; + TDB_FILL (bd.wire_salt, + 21); + bd.wallet_timestamp = ts (1600000000); + bd.refund_deadline = ts (1600000000); + bd.wire_deadline = ts (1600000000); + bd.receiver_wire_account = account.payto; + bd.cdis = &cdi; + bd.num_cdis = 1; + exchange_timestamp = GNUNET_TIME_timestamp_get (); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_check_deposit_idempotent ( + pg, + &bd, + &exchange_timestamp, + &is_idempotent), + TDB_coin_free (&coin)); + FAILIF_C (! is_idempotent, + TDB_coin_free (&coin)); + + /* a different amount for the same contract is not the same request */ + cdi.amount_with_fee = TDB_amount ("2"); + is_idempotent = true; + FAILIF_C (0 > + TALER_EXCHANGEDB_do_check_deposit_idempotent ( + pg, + &bd, + &exchange_timestamp, + &is_idempotent), + TDB_coin_free (&coin)); + FAILIF_C (is_idempotent, + TDB_coin_free (&coin)); + + /* neither is a contract we never saw */ + cdi.amount_with_fee = TDB_amount ("1"); + TDB_FILL (bd.h_contract_terms, + 98); + is_idempotent = true; + FAILIF_C (0 > + TALER_EXCHANGEDB_do_check_deposit_idempotent ( + pg, + &bd, + &exchange_timestamp, + &is_idempotent), + TDB_coin_free (&coin)); + FAILIF_C (is_idempotent, + TDB_coin_free (&coin)); + TDB_coin_free (&coin); + return 0; +} + + +/** + * A deposit whose deadlines have passed becomes ready for the aggregator, + * and shows up as missing a wire transfer. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_ready (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_MerchantPublicKeyP merchant_pub; + struct TALER_FullPayto payto_uri = { NULL }; + struct MissingContext ctx; + char *metadata = NULL; + + /* the deposit from check_deposit() has deadlines in 2020 */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_ready_deposit (pg, + 0, + UINT32_MAX, + &merchant_pub, + &payto_uri, + &metadata)); + FAILIF_C (0 != strcmp (payto_uri.full_payto, + account.payto.full_payto), + GNUNET_free (payto_uri.full_payto); GNUNET_free (metadata)); + GNUNET_free (payto_uri.full_payto); + GNUNET_free (metadata); + /* no extra metadata was set */ + FAILIF (NULL != metadata); + + /* a shard range the merchant is not in has nothing ready */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_ready_deposit (pg, + 0, + 0, + &merchant_pub, + &payto_uri, + &metadata)); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_batch_deposits_missing_wire (pg, + 0, + &missing_cb, + &ctx)); + FAILIF (1 != ctx.total); + FAILIF (0 != GNUNET_memcmp (&ctx.h_payto, + &account.h_full)); + /* the amount reported is the gross deposit, fee included */ + FAILIF (1 != ctx.value_sum); + + /* the serial bound excludes it again */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_batch_deposits_missing_wire (pg, + 1000, + &missing_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * A deposit that is done or policy-blocked is not ready. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_not_ready (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_MerchantPublicKeyP merchant_pub; + struct TALER_FullPayto payto_uri = { NULL }; + char *metadata = NULL; + + FAILIF (GNUNET_OK != + TDB_exec (pg, + "UPDATE batch_deposits SET done=TRUE;")); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_ready_deposit (pg, + 0, + UINT32_MAX, + &merchant_pub, + &payto_uri, + &metadata)); + FAILIF (GNUNET_OK != + TDB_exec (pg, + "UPDATE batch_deposits" + " SET done=FALSE, policy_blocked=TRUE;")); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_ready_deposit (pg, + 0, + UINT32_MAX, + &merchant_pub, + &payto_uri, + &metadata)); + FAILIF (GNUNET_OK != + TDB_exec (pg, + "UPDATE batch_deposits SET policy_blocked=FALSE;")); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_ready_deposit (pg, + 0, + UINT32_MAX, + &merchant_pub, + &payto_uri, + &metadata)); + GNUNET_free (payto_uri.full_payto); + GNUNET_free (metadata); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "batch-deposits-empty", + &check_empty }, + { "batch-deposits-insufficient-balance", + &check_insufficient_balance }, + { "batch-deposits-deposit", + &check_deposit }, + { "batch-deposits-idempotent", + &check_idempotent }, + { "batch-deposits-ready", + &check_ready }, + { "batch-deposits-not-ready", + &check_not_ready }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-batch-deposits", + "Tests for the exchangedb `batch_deposits' table", + tests); + TDB_account_free (&account); + TDB_denom_free (&denom); + return ret; +} + + +/* end of test_batch_deposits.c */ diff --git a/src/exchangedb/test_close_requests.c b/src/exchangedb/test_close_requests.c @@ -0,0 +1,448 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_close_requests.c + * @brief tests for the exchangedb functions whose primary table is + * `close_requests` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_close_request(), + * #TALER_EXCHANGEDB_get_reserve_close_request_info() and + * #TALER_EXCHANGEDB_iterate_unfinished_close_requests(). + * + * The table references `reserves`, which TDB_reserve() creates. The + * iterator is destructive -- it marks everything it returns as done -- so + * the checks that read the table run before it. + */ +#include "test_common.h" +#include "exchange-database/insert_close_request.h" +#include "exchange-database/get_reserve_close_request_info.h" +#include "exchange-database/iterate_unfinished_close_requests.h" + + +/** + * Account the checks ask to be paid out to. + */ +#define PAYTO "payto://x-taler-bank/localhost/cr?receiver-name=CR" + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Closure for #expired_cb(). + */ +struct ExpiredContext +{ + /** + * How many requests did the callback see? + */ + unsigned int total; + + /** + * Reserve of the last request seen. + */ + struct TALER_ReservePublicKeyP reserve_pub; + + /** + * Balance of the last request seen. + */ + struct TALER_Amount left; + + /** + * Row of the last request seen. + */ + uint64_t close_request_row; + + /** + * Account of the last request seen, owned by this struct. + */ + char *account; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_unfinished_close_requests(). + * + * @param cls a `struct ExpiredContext *` + * @param reserve_pub the reserve to close + * @param left balance to pay out + * @param account_details where to pay it + * @param expiration_date when the request was made + * @param close_request_row row of the request + * @return #GNUNET_OK + */ +static enum GNUNET_GenericReturnValue +expired_cb (void *cls, + const struct TALER_ReservePublicKeyP *reserve_pub, + const struct TALER_Amount *left, + const struct TALER_FullPayto account_details, + struct GNUNET_TIME_Timestamp expiration_date, + uint64_t close_request_row) +{ + struct ExpiredContext *ctx = cls; + + (void) expiration_date; + ctx->total++; + ctx->reserve_pub = *reserve_pub; + ctx->left = *left; + ctx->close_request_row = close_request_row; + GNUNET_free (ctx->account); + ctx->account = (NULL == account_details.full_payto) + ? NULL + : GNUNET_strdup (account_details.full_payto); + return GNUNET_OK; +} + + +/** + * Look up the row of the close request of a reserve. + * + * @param pg the database context + * @param reserve_pub reserve to look up + * @return the row + */ +static uint64_t +request_row (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TALER_ReservePublicKeyP *reserve_pub) +{ + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_auto_from_type (reserve_pub), + GNUNET_PQ_query_param_end + }; + uint64_t row; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_uint64 ("close_request_serial_id", + &row), + GNUNET_PQ_result_spec_end + }; + + GNUNET_assert (GNUNET_OK == + GNUNET_PQ_prepare_anon (pg->conn, + "SELECT close_request_serial_id" + " FROM close_requests" + " WHERE reserve_pub=$1;")); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "", + params, + rs)); + return row; +} + + +/** + * Ask for a reserve to be closed. + * + * @param pg the database context + * @param reserve_pub reserve to close + * @param seed seed for the reserve signature + * @param when when the request was made, in seconds since the epoch + * @param balance balance to pay out, e.g. "10" + * @param fee closing fee, e.g. "0.5" + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +add_request (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TALER_ReservePublicKeyP *reserve_pub, + uint32_t seed, + uint64_t when, + const char *balance, + const char *fee) +{ + struct TALER_FullPayto payto = { + .full_payto = (char *) PAYTO + }; + struct TALER_ReserveSignatureP reserve_sig; + struct TALER_Amount b = TDB_amount (balance); + struct TALER_Amount f = TDB_amount (fee); + + TDB_fill (&reserve_sig, + sizeof (reserve_sig), + seed); + return TALER_EXCHANGEDB_insert_close_request (pg, + reserve_pub, + payto, + &reserve_sig, + ts (when), + &b, + &f); +} + + +/** + * Nothing is known while the table is empty. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_ReserveSignatureP reserve_sig; + struct GNUNET_TIME_Timestamp request_timestamp; + struct TALER_Amount close_balance; + struct TALER_Amount close_fee; + struct TALER_FullPayto payto = { NULL }; + struct ExpiredContext ctx = { 0 }; + + TDB_FILL (reserve_pub, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_reserve_close_request_info (pg, + &reserve_pub, + 1, + &reserve_sig, + &request_timestamp, + &close_balance, + &close_fee, + &payto)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_unfinished_close_requests (pg, + &expired_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * A close request is stored and found by (reserve, row). + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insert_and_lookup (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_ReserveSignatureP reserve_sig; + struct TALER_ReserveSignatureP expect_sig; + struct GNUNET_TIME_Timestamp request_timestamp; + struct TALER_Amount close_balance; + struct TALER_Amount close_fee; + struct TALER_Amount expect_balance = TDB_amount ("10"); + struct TALER_Amount expect_fee = TDB_amount ("0.5"); + struct TALER_FullPayto payto = { NULL }; + uint64_t row; + + TDB_reserve (pg, + 10, + "10", + &reserve_pub); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_request (pg, + &reserve_pub, + 10, + 1600000000, + "10", + "0.5")); + row = request_row (pg, + &reserve_pub); + TDB_FILL (expect_sig, + 10); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve_close_request_info (pg, + &reserve_pub, + row, + &reserve_sig, + &request_timestamp, + &close_balance, + &close_fee, + &payto)); + FAILIF (0 != GNUNET_memcmp (&reserve_sig, + &expect_sig)); + FAILIF (GNUNET_TIME_timestamp_cmp (request_timestamp, + !=, + ts (1600000000))); + FAILIF (0 != TALER_amount_cmp (&close_balance, + &expect_balance)); + FAILIF (0 != TALER_amount_cmp (&close_fee, + &expect_fee)); + FAILIF (NULL == payto.full_payto); + FAILIF_C (0 != strcmp (payto.full_payto, + PAYTO), + GNUNET_free (payto.full_payto)); + GNUNET_free (payto.full_payto); + + /* the row has to belong to the reserve that is asked about */ + payto.full_payto = NULL; + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_reserve_close_request_info (pg, + &reserve_pub, + row + 1000, + &reserve_sig, + &request_timestamp, + &close_balance, + &close_fee, + &payto)); + { + struct TALER_ReservePublicKeyP other; + + TDB_FILL (other, + 99); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_reserve_close_request_info (pg, + &other, + row, + &reserve_sig, + &request_timestamp, + &close_balance, + &close_fee, + &payto)); + } + + /* a second request at the same timestamp is absorbed */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + add_request (pg, + &reserve_pub, + 11, + 1600000000, + "10", + "0.5")); + FAILIF (1 != TDB_count (pg, + "FROM close_requests")); + /* ...but one at another timestamp is a second request */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_request (pg, + &reserve_pub, + 11, + 1600003600, + "10", + "0.5")); + FAILIF (2 != TDB_count (pg, + "FROM close_requests")); + return 0; +} + + +/** + * The iterator hands over every unfinished request exactly once, marking + * it done as it goes. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate_unfinished (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct ExpiredContext ctx; + + FAILIF (2 != TDB_count (pg, + "FROM close_requests WHERE NOT done")); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (2 != + TALER_EXCHANGEDB_iterate_unfinished_close_requests (pg, + &expired_cb, + &ctx)); + FAILIF_C (2 != ctx.total, + GNUNET_free (ctx.account)); + FAILIF_C (NULL == ctx.account, + GNUNET_free (ctx.account)); + FAILIF_C (0 != strcmp (ctx.account, + PAYTO), + GNUNET_free (ctx.account)); + FAILIF_C (0 == ctx.close_request_row, + GNUNET_free (ctx.account)); + GNUNET_free (ctx.account); + FAILIF (0 != TDB_count (pg, + "FROM close_requests WHERE NOT done")); + + /* everything is done now, so a second sweep finds nothing */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_unfinished_close_requests (pg, + &expired_cb, + &ctx)); + FAILIF (0 != ctx.total); + + /* a fresh request is picked up again */ + { + struct TALER_ReservePublicKeyP reserve_pub; + + TDB_reserve (pg, + 12, + "3", + &reserve_pub); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_request (pg, + &reserve_pub, + 12, + 1600007200, + "3", + "0.5")); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (1 != + TALER_EXCHANGEDB_iterate_unfinished_close_requests (pg, + &expired_cb, + &ctx)); + FAILIF_C (0 != GNUNET_memcmp (&ctx.reserve_pub, + &reserve_pub), + GNUNET_free (ctx.account)); + GNUNET_free (ctx.account); + } + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "close-requests-empty", + &check_empty }, + { "close-requests-insert-and-lookup", + &check_insert_and_lookup }, + { "close-requests-iterate-unfinished", + &check_iterate_unfinished }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-close-requests", + "Tests for the exchangedb `close_requests' table", + tests); +} + + +/* end of test_close_requests.c */ diff --git a/src/exchangedb/test_coin_deposits.c b/src/exchangedb/test_coin_deposits.c @@ -0,0 +1,499 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_coin_deposits.c + * @brief tests for the exchangedb functions whose primary table is + * `coin_deposits` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_iterate_coin_deposits_above_serial_id() and + * #TALER_EXCHANGEDB_iterate_deposit_amounts_for_kyc_check(). + * + * The rows themselves are written by #TALER_EXCHANGEDB_do_deposit(), whose + * primary table is `batch_deposits` -- TDB_deposit() calls it. What is + * checked here is the two views onto the coins of a deposit: the auditor's + * serial walk and the KYC sum per merchant account. + */ +#include "test_common.h" +#include "exchange-database/iterate_coin_deposits_above_serial_id.h" +#include "exchange-database/iterate_deposit_amounts_for_kyc_check.h" + + +/** + * Account the merchants of the checks are paid at. + */ +static struct TDB_Account account; + + +/** + * A second account, to show the KYC view is per account. + */ +static struct TDB_Account other; + + +/** + * Denomination the checks deposit. + */ +static struct TDB_Denom denom; + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Closure for #deposit_cb(). + */ +struct DepositContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Stop after this many rows; 0 for no limit. + */ + unsigned int stop_after; + + /** + * Coin we are looking for, NULL to match nothing. + */ + const struct TALER_CoinSpendPublicKeyP *coin_pub; + + /** + * How many times did we see it? + */ + unsigned int matched; + + /** + * Amount reported for it. + */ + struct TALER_Amount amount; + + /** + * Merchant reported for it. + */ + struct TALER_MerchantPublicKeyP merchant_pub; + + /** + * Was it reported as executed? + */ + bool done; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_coin_deposits_above_serial_id(). + * + * @param cls a `struct DepositContext *` + * @param rowid row of the coin deposit + * @param exchange_timestamp when the deposit was made + * @param deposit details of the deposit + * @param denom_pub denomination of the coin + * @param done whether the deposit was already wired out + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +deposit_cb (void *cls, + uint64_t rowid, + struct GNUNET_TIME_Timestamp exchange_timestamp, + const struct TALER_EXCHANGEDB_Deposit *deposit, + const struct TALER_DenominationPublicKey *denom_pub, + bool done) +{ + struct DepositContext *ctx = cls; + + (void) rowid; + (void) exchange_timestamp; + (void) denom_pub; + ctx->total++; + if ( (NULL != ctx->coin_pub) && + (0 == GNUNET_memcmp (&deposit->coin.coin_pub, + ctx->coin_pub)) ) + { + ctx->matched++; + ctx->amount = deposit->amount_with_fee; + ctx->merchant_pub = deposit->merchant_pub; + ctx->done = done; + } + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * Closure for #amount_cb(). + */ +struct AmountContext +{ + /** + * How many amounts did the callback see? + */ + unsigned int total; + + /** + * Sum of the whole-unit parts of the amounts seen. + */ + uint64_t value_sum; + + /** + * Return this from the callback. + */ + enum GNUNET_GenericReturnValue ret; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_deposit_amounts_for_kyc_check(). + * + * @param cls a `struct AmountContext *` + * @param amount the deposited amount + * @param date when it was deposited + * @return what @e ret of the closure says + */ +static enum GNUNET_GenericReturnValue +amount_cb (void *cls, + const struct TALER_Amount *amount, + struct GNUNET_TIME_Absolute date) +{ + struct AmountContext *ctx = cls; + + (void) date; + ctx->total++; + ctx->value_sum += amount->value; + return ctx->ret; +} + + +/** + * Nothing is reported while the table is empty. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct DepositContext ctx = { 0 }; + struct AmountContext actx = { 0 }; + struct TALER_NormalizedPaytoHashP h_payto; + + TDB_FILL (h_payto, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_coin_deposits_above_serial_id ( + pg, + 0, + &deposit_cb, + &ctx)); + actx.ret = GNUNET_OK; + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_deposit_amounts_for_kyc_check ( + pg, + &h_payto, + GNUNET_TIME_UNIT_ZERO_ABS, + &amount_cb, + &actx)); + FAILIF (0 != ctx.total); + FAILIF (0 != actx.total); + return 0; +} + + +/** + * A deposited coin is reported with the merchant and amount it went to. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo coin; + struct TDB_Deposit dep; + struct DepositContext ctx; + struct TALER_Amount expect = TDB_amount ("1"); + + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + TDB_account (pg, + 10, + &account); + TDB_coin (pg, + &denom, + 20, + &coin, + NULL); + TDB_deposit (pg, + &account, + &coin, + 20, + "1", + "0.1", + ts (1600000000), + ts (1600000000), + &dep); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.coin_pub = &coin.coin_pub; + FAILIF_C (1 != + TALER_EXCHANGEDB_iterate_coin_deposits_above_serial_id ( + pg, + 0, + &deposit_cb, + &ctx), + TDB_coin_free (&coin)); + FAILIF_C (1 != ctx.matched, + TDB_coin_free (&coin)); + FAILIF_C (0 != TALER_amount_cmp (&ctx.amount, + &expect), + TDB_coin_free (&coin)); + FAILIF_C (0 != GNUNET_memcmp (&ctx.merchant_pub, + &dep.merchant_pub), + TDB_coin_free (&coin)); + FAILIF_C (ctx.done, + TDB_coin_free (&coin)); + + /* the serial bound is exclusive */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_coin_deposits_above_serial_id ( + pg, + 1000, + &deposit_cb, + &ctx), + TDB_coin_free (&coin)); + FAILIF_C (0 != ctx.total, + TDB_coin_free (&coin)); + TDB_coin_free (&coin); + return 0; +} + + +/** + * The KYC view sums the deposits per merchant account, and honours the + * time limit and the callback's abort. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_kyc_amounts (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo c2; + struct TALER_CoinPublicInfo c3; + struct TDB_Deposit dep; + struct AmountContext ctx; + + TDB_account (pg, + 11, + &other); + /* a second deposit to the same account, and one to another */ + TDB_coin (pg, + &denom, + 21, + &c2, + NULL); + TDB_deposit (pg, + &account, + &c2, + 21, + "2", + "0.1", + ts (1600003600), + ts (1600003600), + &dep); + TDB_coin (pg, + &denom, + 22, + &c3, + NULL); + TDB_deposit (pg, + &other, + &c3, + 22, + "4", + "0.1", + ts (1600007200), + ts (1600007200), + &dep); + TDB_coin_free (&c2); + TDB_coin_free (&c3); + FAILIF (3 != TDB_count (pg, + "FROM coin_deposits")); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.ret = GNUNET_OK; + FAILIF (2 != + TALER_EXCHANGEDB_iterate_deposit_amounts_for_kyc_check ( + pg, + &account.h_normalized, + GNUNET_TIME_UNIT_ZERO_ABS, + &amount_cb, + &ctx)); + FAILIF (1 + 2 != ctx.value_sum); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.ret = GNUNET_OK; + FAILIF (1 != + TALER_EXCHANGEDB_iterate_deposit_amounts_for_kyc_check ( + pg, + &other.h_normalized, + GNUNET_TIME_UNIT_ZERO_ABS, + &amount_cb, + &ctx)); + FAILIF (4 != ctx.value_sum); + + /* a limit in the future hides everything */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.ret = GNUNET_OK; + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_deposit_amounts_for_kyc_check ( + pg, + &account.h_normalized, + GNUNET_TIME_relative_to_absolute (GNUNET_TIME_UNIT_HOURS), + &amount_cb, + &ctx)); + FAILIF (0 != ctx.total); + + /* a callback that aborts with GNUNET_NO is not an error */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.ret = GNUNET_NO; + FAILIF (0 > + TALER_EXCHANGEDB_iterate_deposit_amounts_for_kyc_check ( + pg, + &account.h_normalized, + GNUNET_TIME_UNIT_ZERO_ABS, + &amount_cb, + &ctx)); + FAILIF (1 != ctx.total); + return 0; +} + + +/** + * A deposit marked done is reported as such. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_done_flag (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct DepositContext ctx; + + FAILIF (GNUNET_OK != + TDB_exec (pg, + "UPDATE batch_deposits SET done=TRUE;")); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.stop_after = 1; + FAILIF (3 != + TALER_EXCHANGEDB_iterate_coin_deposits_above_serial_id ( + pg, + 0, + &deposit_cb, + &ctx)); + FAILIF (1 != ctx.total); + + { + struct TALER_CoinSpendPublicKeyP coin_pub; + + TDB_FILL (coin_pub, + 20); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.coin_pub = &coin_pub; + FAILIF (3 != + TALER_EXCHANGEDB_iterate_coin_deposits_above_serial_id ( + pg, + 0, + &deposit_cb, + &ctx)); + FAILIF (1 != ctx.matched); + FAILIF (! ctx.done); + } + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "coin-deposits-empty", + &check_empty }, + { "coin-deposits-iterate", + &check_iterate }, + { "coin-deposits-kyc-amounts", + &check_kyc_amounts }, + { "coin-deposits-done-flag", + &check_done_flag }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-coin-deposits", + "Tests for the exchangedb `coin_deposits' table", + tests); + TDB_account_free (&account); + TDB_account_free (&other); + TDB_denom_free (&denom); + return ret; +} + + +/* end of test_coin_deposits.c */ diff --git a/src/exchangedb/test_coin_history.c b/src/exchangedb/test_coin_history.c @@ -0,0 +1,714 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_coin_history.c + * @brief tests for the exchangedb functions whose primary table is + * `coin_history` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_get_coin_transactions(). + * + * Nothing writes to `coin_history` directly: every table that can spend or + * credit a coin has an INSERT trigger that appends a row naming itself and + * its own serial. get_coin_transactions() walks those rows and looks each + * one up in the table it names. So the interesting part of this test is + * spending one coin in as many different ways as possible and then checking + * that every one of them comes back with the right type and amount. + */ +#include "test_common.h" +#include "exchange-database/do_purse_deposit.h" +#include "exchange-database/rollback.h" +#include "exchange-database/start.h" +#include "exchange-database/do_recoup.h" +#include "exchange-database/do_refresh.h" +#include "exchange-database/do_refund.h" +#include "exchange-database/get_coin_transactions.h" +#include "exchange-database/insert_reserve_open_deposit.h" + + +/** + * Account the checks fund their reserves from. + */ +static struct TDB_Account account; + + +/** + * Denomination the checks use. + */ +static struct TDB_Denom denom; + + +/** + * The coin whose history we build up. + */ +static struct TALER_CoinPublicInfo coin; + + +/** + * Row of @e coin in `known_coins`. + */ +static uint64_t known_coin_id; + + +/** + * Reserve @e coin was withdrawn from. + */ +static struct TALER_ReservePublicKeyP reserve_pub; + + +/** + * Row of the withdraw that created @e coin. + */ +static uint64_t withdraw_id; + + +/** + * The deposit we later refund. + */ +static struct TDB_Deposit deposit; + + +/** + * ETag of the history right after the deposit was refunded. + */ +static uint64_t etag_after_refund; + + +/** + * Count the entries of @a tl and remember which types occurred. + * + * @param tl transaction list to walk + * @param[out] types set to the bitmask of the types seen + * @return number of entries in @a tl + */ +static unsigned int +summarize (const struct TALER_EXCHANGEDB_TransactionList *tl, + unsigned int *types) +{ + unsigned int cnt = 0; + + *types = 0; + for (const struct TALER_EXCHANGEDB_TransactionList *pos = tl; + NULL != pos; + pos = pos->next) + { + cnt++; + *types |= 1U << pos->type; + } + return cnt; +} + + +/** + * Ask for the full history of @e coin. + * + * @param pg the database context + * @param start_off offset to start from + * @param etag_in ETag the caller already has + * @param[out] etag_out set to the current ETag + * @param[out] tlp set to the history + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +history (struct TALER_EXCHANGEDB_PostgresContext *pg, + uint64_t start_off, + uint64_t etag_in, + uint64_t *etag_out, + struct TALER_EXCHANGEDB_TransactionList **tlp) +{ + struct TALER_Amount balance; + struct TALER_DenominationHashP h_denom_pub; + + return TALER_EXCHANGEDB_get_coin_transactions (pg, + true, + &coin.coin_pub, + start_off, + etag_in, + etag_out, + &balance, + &h_denom_pub, + tlp); +} + + +/** + * A coin the exchange never saw has no history. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_unknown_coin (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_EXCHANGEDB_TransactionList *tl = (void *) 1; + struct TALER_CoinSpendPublicKeyP unknown; + struct TALER_Amount balance; + struct TALER_DenominationHashP h_denom_pub; + uint64_t etag = 42; + + TDB_account (pg, + 10, + &account); + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + TDB_FILL (unknown, + 99); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_coin_transactions (pg, + true, + &unknown, + 0, + 0, + &etag, + &balance, + &h_denom_pub, + &tl)); + FAILIF (NULL != tl); + return 0; +} + + +/** + * A coin that was withdrawn but never spent has no history either: the + * `withdraw` table has no coin history trigger. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_fresh_coin (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_EXCHANGEDB_TransactionList *tl = (void *) 1; + uint64_t etag = 42; + + TDB_reserve_in (pg, + &account, + 11, + "10", + &reserve_pub); + withdraw_id = TDB_withdraw (pg, + &denom, + &reserve_pub, + 11, + "5"); + TDB_coin (pg, + &denom, + 20, + &coin, + &known_coin_id); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + history (pg, + 0, + 0, + &etag, + &tl)); + FAILIF (NULL != tl); + FAILIF (0 != TDB_count (pg, + "FROM coin_history")); + return 0; +} + + +/** + * A deposit and its refund show up as two entries. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_deposit_and_refund (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_EXCHANGEDB_TransactionList *tl = NULL; + struct TALER_EXCHANGEDB_Refund refund; + struct TALER_Amount deposit_fee = TDB_amount ("0.1"); + struct TALER_Amount balance; + struct TALER_Amount expect = TDB_amount ("4.5"); + struct TALER_DenominationHashP h_denom_pub; + unsigned int types; + uint64_t etag = 0; + bool not_found; + bool refund_ok; + bool gone; + bool conflict; + + TDB_deposit (pg, + &account, + &coin, + 30, + "1", + "0.1", + GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS), + GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS), + &deposit); + FAILIF (1 != TDB_count (pg, + "FROM coin_history")); + + memset (&refund, + 0, + sizeof (refund)); + refund.coin = coin; + refund.details.merchant_pub = deposit.merchant_pub; + TDB_FILL (refund.details.merchant_sig, + 31); + refund.details.h_contract_terms = deposit.h_contract_terms; + refund.details.rtransaction_id = 1; + refund.details.refund_amount = TDB_amount ("0.5"); + refund.details.refund_fee = TDB_amount ("0"); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_refund (pg, + &refund, + &deposit_fee, + 0, + &not_found, + &refund_ok, + &gone, + &conflict)); + FAILIF (not_found); + FAILIF (! refund_ok); + FAILIF (2 != TDB_count (pg, + "FROM coin_history")); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_coin_transactions (pg, + true, + &coin.coin_pub, + 0, + 0, + &etag, + &balance, + &h_denom_pub, + &tl)); + FAILIF_C (2 != summarize (tl, + &types), + TALER_EXCHANGEDB_free_coin_transaction_list (tl)); + FAILIF_C (0 == (types & (1U << TALER_EXCHANGEDB_TT_DEPOSIT)), + TALER_EXCHANGEDB_free_coin_transaction_list (tl)); + FAILIF_C (0 == (types & (1U << TALER_EXCHANGEDB_TT_REFUND)), + TALER_EXCHANGEDB_free_coin_transaction_list (tl)); + /* 5 - 1 + 0.5 */ + FAILIF_C (0 != TALER_amount_cmp (&balance, + &expect), + TALER_EXCHANGEDB_free_coin_transaction_list (tl)); + FAILIF_C (0 != GNUNET_memcmp (&h_denom_pub, + &denom.h_denom_pub), + TALER_EXCHANGEDB_free_coin_transaction_list (tl)); + TALER_EXCHANGEDB_free_coin_transaction_list (tl); + FAILIF (0 == etag); + etag_after_refund = etag; + return 0; +} + + +/** + * A caller that is already up to date gets no list back, and the + * transaction the lookup opened is not left dangling. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_etag (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_EXCHANGEDB_TransactionList *tl = (void *) 1; + uint64_t etag = 0; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + history (pg, + 0, + etag_after_refund, + &etag, + &tl)); + FAILIF (NULL != tl); + FAILIF (etag != etag_after_refund); + /* the lookup must not leave a transaction open (it starts one of its + own when begin_transaction is true) */ + FAILIF (NULL != pg->transaction_name); + + /* an ETag that is not the current one still returns the history */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + history (pg, + 0, + etag_after_refund - 1, + &etag, + &tl)); + FAILIF (NULL == tl); + TALER_EXCHANGEDB_free_coin_transaction_list (tl); + FAILIF (NULL != pg->transaction_name); + return 0; +} + + +/** + * The offset skips everything up to and including it. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_offset (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_EXCHANGEDB_TransactionList *tl = NULL; + unsigned int types; + uint64_t etag = 0; + + /* everything after the deposit: only the refund is left */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + history (pg, + etag_after_refund - 1, + 0, + &etag, + &tl)); + FAILIF_C (1 != summarize (tl, + &types), + TALER_EXCHANGEDB_free_coin_transaction_list (tl)); + FAILIF_C (0 == (types & (1U << TALER_EXCHANGEDB_TT_REFUND)), + TALER_EXCHANGEDB_free_coin_transaction_list (tl)); + TALER_EXCHANGEDB_free_coin_transaction_list (tl); + + /* everything after the last entry: nothing, but the ETag is still + reported */ + tl = (void *) 1; + etag = 0; + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + history (pg, + etag_after_refund, + 0, + &etag, + &tl)); + FAILIF (NULL != tl); + FAILIF (etag != etag_after_refund); + return 0; +} + + +/** + * Spending the coin into a purse, on a reserve and in a melt adds one + * entry each. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_other_spends (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_EXCHANGEDB_TransactionList *tl = NULL; + struct TDB_Purse purse; + struct TALER_CoinSpendSignatureP coin_sig; + struct TALER_ReserveSignatureP reserve_sig; + struct TALER_Amount one = TDB_amount ("1"); + struct TALER_Amount zero = TDB_amount ("0"); + unsigned int types; + uint64_t etag = 0; + bool balance_ok; + bool too_late; + bool conflict; + bool insufficient_funds; + + /* into a purse */ + TDB_purse (pg, + 40, + "1", + GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS), + &purse); + TDB_FILL (coin_sig, + 41); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_purse_deposit (pg, + &purse.purse_pub, + &coin.coin_pub, + &one, + &coin_sig, + &one, + &balance_ok, + &too_late, + &conflict)); + FAILIF (! balance_ok); + FAILIF (conflict); + + /* to keep a reserve open */ + TDB_FILL (coin_sig, + 42); + TDB_FILL (reserve_sig, + 42); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_reserve_open_deposit (pg, + &coin, + &coin_sig, + known_coin_id, + &one, + &reserve_sig, + &reserve_pub, + &insufficient_funds)); + FAILIF (insufficient_funds); + + /* and into a melt */ + { + struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS rf; + struct GNUNET_TIME_Timestamp now = GNUNET_TIME_timestamp_get (); + struct TALER_Amount coin_balance; + bool found; + bool zombie_required = false; + bool nonce_reuse; + bool melt_balance_ok; + uint32_t noreveal_index; + enum GNUNET_DB_QueryStatus qs; + + memset (&rf, + 0, + sizeof (rf)); + rf.coin.coin_pub = coin.coin_pub; + rf.coin.denom_pub_hash = coin.denom_pub_hash; + rf.coin.no_age_commitment = coin.no_age_commitment; + TDB_FILL (rf.coin_sig, + 43); + TDB_FILL (rf.rc, + 43); + TDB_FILL (rf.refresh_seed, + 43); + TDB_FILL (rf.planchets_h, + 43); + TDB_FILL (rf.selected_h, + 44); + rf.amount_with_fee = one; + rf.num_coins = 1; + rf.denom_serials = GNUNET_new (uint64_t); + rf.denom_serials[0] = denom.serial; + rf.denom_sigs = GNUNET_new (struct TALER_BlindedDenominationSignature); + TDB_blinded_denom_sig (43, + &rf.denom_sigs[0]); + rf.noreveal_index = 1; + rf.is_v27_refresh = true; + rf.no_blinding_seed = true; + qs = TALER_EXCHANGEDB_do_refresh (pg, + &rf, + &now, + &found, + &noreveal_index, + &zombie_required, + &nonce_reuse, + &melt_balance_ok, + &coin_balance); + TALER_blinded_denom_sig_free (&rf.denom_sigs[0]); + GNUNET_free (rf.denom_sigs); + GNUNET_free (rf.denom_serials); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs); + FAILIF (! melt_balance_ok); + } + + FAILIF (5 != TDB_count (pg, + "FROM coin_history")); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + history (pg, + 0, + 0, + &etag, + &tl)); + FAILIF_C (5 != summarize (tl, + &types), + TALER_EXCHANGEDB_free_coin_transaction_list (tl)); + FAILIF_C (0 == (types & (1U << TALER_EXCHANGEDB_TT_PURSE_DEPOSIT)), + TALER_EXCHANGEDB_free_coin_transaction_list (tl)); + FAILIF_C (0 == (types & (1U << TALER_EXCHANGEDB_TT_RESERVE_OPEN)), + TALER_EXCHANGEDB_free_coin_transaction_list (tl)); + FAILIF_C (0 == (types & (1U << TALER_EXCHANGEDB_TT_MELT)), + TALER_EXCHANGEDB_free_coin_transaction_list (tl)); + + /* the ETag is the highest coin history row of the list */ + { + uint64_t max = 0; + + for (const struct TALER_EXCHANGEDB_TransactionList *pos = tl; + NULL != pos; + pos = pos->next) + max = GNUNET_MAX (max, + pos->coin_history_id); + FAILIF_C (etag != max, + TALER_EXCHANGEDB_free_coin_transaction_list (tl)); + } + + /* the spends add up to what the coin no longer has */ + { + struct TALER_Amount total; + struct TALER_Amount expect = TDB_amount ("3.5"); + + FAILIF_C (GNUNET_OK != + TALER_EXCHANGEDB_calculate_transaction_list_totals (tl, + &zero, + &total), + TALER_EXCHANGEDB_free_coin_transaction_list (tl)); + FAILIF_C (0 != TALER_amount_cmp (&total, + &expect), + TALER_EXCHANGEDB_free_coin_transaction_list (tl)); + } + TALER_EXCHANGEDB_free_coin_transaction_list (tl); + return 0; +} + + +/** + * Recouping the coin adds the last entry and empties it. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_recoup (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_EXCHANGEDB_TransactionList *tl = NULL; + struct TALER_CoinSpendSignatureP coin_sig; + union GNUNET_CRYPTO_BlindingSecretP coin_bks; + struct GNUNET_TIME_Timestamp recoup_timestamp; + struct TALER_Amount balance; + struct TALER_Amount zero = TDB_amount ("0"); + struct TALER_DenominationHashP h_denom_pub; + unsigned int types; + uint64_t etag = 0; + bool recoup_ok; + bool internal_failure; + + TDB_FILL (coin_sig, + 50); + TDB_FILL (coin_bks, + 50); + /* in/out: the caller picks the time, the callee only overwrites it if + the coin was recouped before */ + recoup_timestamp = GNUNET_TIME_timestamp_get (); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_recoup (pg, + &reserve_pub, + withdraw_id, + &coin_bks, + &coin.coin_pub, + known_coin_id, + &coin_sig, + &recoup_timestamp, + &recoup_ok, + &internal_failure)); + FAILIF (internal_failure); + FAILIF (! recoup_ok); + FAILIF (6 != TDB_count (pg, + "FROM coin_history")); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_coin_transactions (pg, + true, + &coin.coin_pub, + 0, + 0, + &etag, + &balance, + &h_denom_pub, + &tl)); + FAILIF_C (6 != summarize (tl, + &types), + TALER_EXCHANGEDB_free_coin_transaction_list (tl)); + FAILIF_C (0 == (types & (1U << TALER_EXCHANGEDB_TT_RECOUP_WITHDRAW)), + TALER_EXCHANGEDB_free_coin_transaction_list (tl)); + TALER_EXCHANGEDB_free_coin_transaction_list (tl); + FAILIF (0 != TALER_amount_cmp (&balance, + &zero)); + return 0; +} + + +/** + * With begin_transaction false the caller's transaction is used and left + * open. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_in_transaction (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_EXCHANGEDB_TransactionList *tl = NULL; + struct TALER_Amount balance; + struct TALER_DenominationHashP h_denom_pub; + unsigned int types; + uint64_t etag = 0; + + FAILIF (GNUNET_OK != + TALER_EXCHANGEDB_start (pg, + "coin-history")); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_coin_transactions (pg, + false, + &coin.coin_pub, + 0, + 0, + &etag, + &balance, + &h_denom_pub, + &tl), + TALER_EXCHANGEDB_rollback (pg)); + FAILIF_C (6 != summarize (tl, + &types), + TALER_EXCHANGEDB_free_coin_transaction_list (tl); + TALER_EXCHANGEDB_rollback (pg)); + TALER_EXCHANGEDB_free_coin_transaction_list (tl); + FAILIF_C (NULL == pg->transaction_name, + TALER_EXCHANGEDB_rollback (pg)); + TALER_EXCHANGEDB_rollback (pg); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "coin-history-unknown-coin", + &check_unknown_coin }, + { "coin-history-fresh-coin", + &check_fresh_coin }, + { "coin-history-deposit-and-refund", + &check_deposit_and_refund }, + { "coin-history-etag", + &check_etag }, + { "coin-history-offset", + &check_offset }, + { "coin-history-other-spends", + &check_other_spends }, + { "coin-history-recoup", + &check_recoup }, + { "coin-history-in-transaction", + &check_in_transaction }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-coin-history", + "Tests for the exchangedb `coin_history' table", + tests); + TDB_coin_free (&coin); + TDB_denom_free (&denom); + TDB_account_free (&account); + return ret; +} + + +/* end of test_coin_history.c */ diff --git a/src/exchangedb/test_common.c b/src/exchangedb/test_common.c @@ -0,0 +1,866 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_common.c + * @brief shared scaffolding for the per-table exchangedb tests + * @author Christian Grothoff + */ +#include "test_common.h" +#include "exchange-database/create_tables.h" +#include "exchange-database/do_insert_known_coin.h" +#include "exchange-database/insert_denomination_info.h" +#include "exchange-database/get_denomination_info.h" +#include "exchange-database/do_deposit.h" +#include "exchange-database/insert_purse_request.h" +#include "exchange-database/do_withdraw.h" + + +/** + * Name of the single check to run, NULL to run all of them. + */ +static char *only; + +/** + * Return value of the process. + */ +static int result; + +/** + * Checks the current binary wants to run. + */ +static const struct TDB_Test *tdb_tests; + + +enum GNUNET_GenericReturnValue +TDB_exec (struct TALER_EXCHANGEDB_PostgresContext *pg, + const char *fmt, + ...) +{ + enum GNUNET_GenericReturnValue ret; + va_list ap; + char *sql; + int len; + + va_start (ap, + fmt); + len = vsnprintf (NULL, + 0, + fmt, + ap); + va_end (ap); + GNUNET_assert (len >= 0); + sql = GNUNET_malloc ((size_t) len + 1); + va_start (ap, + fmt); + GNUNET_assert (len == + vsnprintf (sql, + (size_t) len + 1, + fmt, + ap)); + va_end (ap); + { + struct GNUNET_PQ_ExecuteStatement es[] = { + GNUNET_PQ_make_execute (sql), + GNUNET_PQ_EXECUTE_STATEMENT_END + }; + + ret = GNUNET_PQ_exec_statements (pg->conn, + es); + } + GNUNET_free (sql); + return ret; +} + + +uint64_t +TDB_count (struct TALER_EXCHANGEDB_PostgresContext *pg, + const char *fmt, + ...) +{ + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_end + }; + uint64_t n; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_uint64 ("n", + &n), + GNUNET_PQ_result_spec_end + }; + va_list ap; + char *frag; + char *sql; + int len; + + va_start (ap, + fmt); + len = vsnprintf (NULL, + 0, + fmt, + ap); + va_end (ap); + GNUNET_assert (len >= 0); + frag = GNUNET_malloc ((size_t) len + 1); + va_start (ap, + fmt); + GNUNET_assert (len == + vsnprintf (frag, + (size_t) len + 1, + fmt, + ap)); + va_end (ap); + GNUNET_asprintf (&sql, + "SELECT COUNT(*) AS n %s;", + frag); + GNUNET_free (frag); + GNUNET_assert (GNUNET_OK == + GNUNET_PQ_prepare_anon (pg->conn, + sql)); + GNUNET_free (sql); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "", + params, + rs)); + return n; +} + + +struct TALER_Amount +TDB_amount (const char *str) +{ + struct TALER_Amount amount; + char *s; + + GNUNET_asprintf (&s, + CURRENCY ":%s", + str); + GNUNET_assert (GNUNET_OK == + TALER_string_to_amount (s, + &amount)); + GNUNET_free (s); + return amount; +} + + +void +TDB_fill (void *ptr, + size_t size, + uint32_t seed) +{ + unsigned char *buf = ptr; + uint32_t state = seed * 2654435761U + 1; + + for (size_t i = 0; i<size; i++) + { + state = state * 1103515245U + 12345U; + buf[i] = (unsigned char) (state >> 16); + } +} + + +char * +TDB_hex (const void *data, + size_t size) +{ + const unsigned char *raw = data; + char *hex; + + hex = GNUNET_malloc (size * 2 + 1); + for (size_t i = 0; i<size; i++) + GNUNET_snprintf (&hex[i * 2], + 3, + "%02x", + raw[i]); + return hex; +} + + +void +TDB_denom_sig (uint32_t seed, + struct TALER_DenominationSignature *sig) +{ + struct GNUNET_CRYPTO_UnblindedSignature *ubs; + + ubs = GNUNET_new (struct GNUNET_CRYPTO_UnblindedSignature); + ubs->cipher = GNUNET_CRYPTO_BSA_CS; + ubs->rc = 1; + TDB_fill (&ubs->details.cs_signature, + sizeof (ubs->details.cs_signature), + seed); + sig->unblinded_sig = ubs; +} + + +void +TDB_blinded_denom_sig (uint32_t seed, + struct TALER_BlindedDenominationSignature *sig) +{ + struct GNUNET_CRYPTO_BlindedSignature *bs; + + bs = GNUNET_new (struct GNUNET_CRYPTO_BlindedSignature); + bs->cipher = GNUNET_CRYPTO_BSA_CS; + bs->rc = 1; + TDB_fill (&bs->details.blinded_cs_answer, + sizeof (bs->details.blinded_cs_answer), + seed); + sig->blinded_sig = bs; +} + + +void +TDB_denom (struct TALER_EXCHANGEDB_PostgresContext *pg, + uint32_t seed, + const char *value, + const char *fee, + struct TDB_Denom *denom) +{ + struct GNUNET_TIME_Timestamp now; + struct TALER_Amount f; + + memset (denom, + 0, + sizeof (*denom)); + /* Build the Clause-Schnorr key pair from @a seed instead of at random, so + that two calls with the same seed name the same denomination -- checks + rely on that to refer to a row an earlier check created. The top nibble + of the scalar is cleared to keep it below the group order. */ + { + struct GNUNET_CRYPTO_BlindSignPrivateKey *bpriv; + struct GNUNET_CRYPTO_BlindSignPublicKey *bpub; + unsigned char *d; + + bpriv = GNUNET_new (struct GNUNET_CRYPTO_BlindSignPrivateKey); + bpriv->cipher = GNUNET_CRYPTO_BSA_CS; + bpriv->rc = 1; + d = bpriv->details.cs_private_key.scalar.d; + TDB_fill (d, + sizeof (bpriv->details.cs_private_key.scalar.d), + seed); + d[sizeof (bpriv->details.cs_private_key.scalar.d) - 1] &= 0x0f; + bpub = GNUNET_new (struct GNUNET_CRYPTO_BlindSignPublicKey); + bpub->cipher = GNUNET_CRYPTO_BSA_CS; + bpub->rc = 1; + GNUNET_CRYPTO_cs_private_key_get_public ( + &bpriv->details.cs_private_key, + &bpub->details.cs_public_key); + GNUNET_CRYPTO_hash (&bpub->details.cs_public_key, + sizeof (bpub->details.cs_public_key), + &bpub->pub_key_hash); + denom->priv.bsign_priv_key = bpriv; + denom->pub.bsign_pub_key = bpub; + } + TALER_denom_pub_hash (&denom->pub, + &denom->h_denom_pub); + /* The same seed may be used by more than one check to name the same + denomination; the second call then only has to load it. */ + if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_EXCHANGEDB_get_denomination_info (pg, + &denom->h_denom_pub, + &denom->serial, + &denom->issue)) + return; + now = GNUNET_TIME_timestamp_get (); + f = TDB_amount (fee); + denom->issue.start = now; + denom->issue.expire_withdraw + = GNUNET_TIME_absolute_to_timestamp ( + GNUNET_TIME_absolute_add (now.abs_time, + GNUNET_TIME_UNIT_HOURS)); + denom->issue.expire_deposit + = GNUNET_TIME_absolute_to_timestamp ( + GNUNET_TIME_absolute_add (now.abs_time, + GNUNET_TIME_relative_multiply ( + GNUNET_TIME_UNIT_HOURS, + 2))); + denom->issue.expire_legal + = GNUNET_TIME_absolute_to_timestamp ( + GNUNET_TIME_absolute_add (now.abs_time, + GNUNET_TIME_relative_multiply ( + GNUNET_TIME_UNIT_HOURS, + 3))); + denom->issue.value = TDB_amount (value); + denom->issue.fees.withdraw = f; + denom->issue.fees.deposit = f; + denom->issue.fees.refresh = f; + denom->issue.fees.refund = f; + denom->issue.denom_hash = denom->h_denom_pub; + TDB_fill (&denom->issue.signature, + sizeof (denom->issue.signature), + seed); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_EXCHANGEDB_insert_denomination_info (pg, + &denom->pub, + &denom->issue)); + { + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_auto_from_type (&denom->h_denom_pub), + GNUNET_PQ_query_param_end + }; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_uint64 ("denominations_serial", + &denom->serial), + GNUNET_PQ_result_spec_end + }; + + GNUNET_assert (GNUNET_OK == + GNUNET_PQ_prepare_anon ( + pg->conn, + "SELECT denominations_serial" + " FROM denominations" + " WHERE denom_pub_hash=$1;")); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + GNUNET_PQ_eval_prepared_singleton_select ( + pg->conn, + "", + params, + rs)); + } +} + + +void +TDB_denom_free (struct TDB_Denom *denom) +{ + TALER_denom_pub_free (&denom->pub); + TALER_denom_priv_free (&denom->priv); +} + + +void +TDB_coin (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TDB_Denom *denom, + uint32_t seed, + struct TALER_CoinPublicInfo *coin, + uint64_t *known_coin_id) +{ + struct TALER_DenominationHashP dh; + struct TALER_AgeCommitmentHashP hac; + enum TALER_EXCHANGEDB_CoinKnownStatus cks; + uint64_t kci; + + memset (coin, + 0, + sizeof (*coin)); + TDB_FILL (coin->coin_pub, + seed); + coin->denom_pub_hash = denom->h_denom_pub; + coin->no_age_commitment = true; + TDB_denom_sig (seed, + &coin->denom_sig); + cks = TALER_EXCHANGEDB_do_insert_known_coin (pg, + coin, + &kci, + &dh, + &hac); + /* CKS_PRESENT is fine: the same seed may name a coin an earlier check + already made known. */ + GNUNET_assert ( (TALER_EXCHANGEDB_CKS_ADDED == cks) || + (TALER_EXCHANGEDB_CKS_PRESENT == cks) ); + if (NULL != known_coin_id) + *known_coin_id = kci; +} + + +void +TDB_coin_free (struct TALER_CoinPublicInfo *coin) +{ + TALER_denom_sig_free (&coin->denom_sig); +} + + +void +TDB_account (struct TALER_EXCHANGEDB_PostgresContext *pg, + uint32_t seed, + struct TDB_Account *account) +{ + struct TALER_NormalizedPayto npayto; + char *fhex; + char *nhex; + + GNUNET_asprintf (&account->payto.full_payto, + "payto://x-taler-bank/localhost/tdb%u?receiver-name=tdb%u", + (unsigned int) seed, + (unsigned int) seed); + TALER_full_payto_hash (account->payto, + &account->h_full); + npayto = TALER_payto_normalize (account->payto); + GNUNET_assert (NULL != npayto.normalized_payto); + TALER_normalized_payto_hash (npayto, + &account->h_normalized); + GNUNET_free (npayto.normalized_payto); + fhex = TDB_hex (&account->h_full, + sizeof (account->h_full)); + nhex = TDB_hex (&account->h_normalized, + sizeof (account->h_normalized)); + GNUNET_assert (GNUNET_OK == + TDB_exec (pg, + "INSERT INTO kyc_targets" + " (h_normalized_payto,is_wallet)" + " VALUES (decode('%s','hex'),FALSE)" + " ON CONFLICT DO NOTHING;" + "INSERT INTO wire_targets" + " (wire_target_h_payto,payto_uri" + " ,h_normalized_payto)" + " VALUES (decode('%s','hex'),'%s'" + " ,decode('%s','hex'))" + " ON CONFLICT DO NOTHING;", + nhex, + fhex, + account->payto.full_payto, + nhex)); + GNUNET_free (fhex); + GNUNET_free (nhex); +} + + +void +TDB_account_free (struct TDB_Account *account) +{ + GNUNET_free (account->payto.full_payto); +} + + +void +TDB_reserve (struct TALER_EXCHANGEDB_PostgresContext *pg, + uint32_t seed, + const char *balance, + struct TALER_ReservePublicKeyP *reserve_pub) +{ + struct TALER_Amount b = TDB_amount (balance); + char *hex; + + TDB_fill (reserve_pub, + sizeof (*reserve_pub), + seed); + hex = TDB_hex (reserve_pub, + sizeof (*reserve_pub)); + GNUNET_assert (GNUNET_OK == + TDB_exec (pg, + "INSERT INTO reserves" + " (reserve_pub,current_balance" + " ,expiration_date,gc_date)" + " VALUES (decode('%s','hex')" + " ,ROW(%llu,%u)::taler_amount" + " ,%llu,%llu)" + " ON CONFLICT DO NOTHING;", + hex, + (unsigned long long) b.value, + (unsigned int) b.fraction, + (unsigned long long) 4000000000000000LLU, + (unsigned long long) 5000000000000000LLU)); + GNUNET_free (hex); +} + + +void +TDB_purse (struct TALER_EXCHANGEDB_PostgresContext *pg, + uint32_t seed, + const char *amount, + struct GNUNET_TIME_Timestamp purse_expiration, + struct TDB_Purse *purse) +{ + struct TALER_Amount purse_fee = TDB_amount ("0"); + enum GNUNET_DB_QueryStatus qs; + bool in_conflict = false; + + memset (purse, + 0, + sizeof (*purse)); + TDB_fill (&purse->purse_pub, + sizeof (purse->purse_pub), + seed); + TDB_fill (&purse->merge_pub, + sizeof (purse->merge_pub), + seed); + TDB_fill (&purse->h_contract_terms, + sizeof (purse->h_contract_terms), + seed); + TDB_fill (&purse->purse_sig, + sizeof (purse->purse_sig), + seed); + purse->purse_expiration = purse_expiration; + purse->amount = TDB_amount (amount); + qs = TALER_EXCHANGEDB_insert_purse_request ( + pg, + &purse->purse_pub, + &purse->merge_pub, + purse_expiration, + &purse->h_contract_terms, + 0, + TALER_WAMF_MODE_CREATE_FROM_PURSE_QUOTA, + &purse_fee, + &purse->amount, + &purse->purse_sig, + &in_conflict); + /* SUCCESS_NO_RESULTS is the answer for an identical replay, which is + what a second call with the same seed is. */ + GNUNET_assert (0 <= qs); + GNUNET_assert (! in_conflict); +} + + +void +TDB_deposit (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TDB_Account *account, + const struct TALER_CoinPublicInfo *coin, + uint32_t seed, + const char *amount, + const char *fee, + struct GNUNET_TIME_Timestamp wire_deadline, + struct GNUNET_TIME_Timestamp refund_deadline, + struct TDB_Deposit *dep) +{ + struct TALER_EXCHANGEDB_CoinDepositInformation cdi; + struct TALER_EXCHANGEDB_BatchDeposit bd; + struct TALER_Amount fees[1]; + struct TALER_Amount total; + uint32_t bad_balance_index; + bool balance_ok; + bool in_conflict; + + memset (dep, + 0, + sizeof (*dep)); + TDB_fill (&dep->merchant_pub, + sizeof (dep->merchant_pub), + seed); + TDB_fill (&dep->merchant_sig, + sizeof (dep->merchant_sig), + seed); + TDB_fill (&dep->h_contract_terms, + sizeof (dep->h_contract_terms), + seed); + TDB_fill (&dep->wire_salt, + sizeof (dep->wire_salt), + seed); + TALER_merchant_wire_signature_hash (account->payto, + &dep->wire_salt, + &dep->h_wire); + dep->wire_deadline = wire_deadline; + dep->refund_deadline = refund_deadline; + memset (&cdi, + 0, + sizeof (cdi)); + cdi.coin = *coin; + TDB_fill (&cdi.csig, + sizeof (cdi.csig), + seed); + cdi.amount_with_fee = TDB_amount (amount); + fees[0] = TDB_amount (fee); + memset (&bd, + 0, + sizeof (bd)); + bd.merchant_pub = dep->merchant_pub; + bd.merchant_sig = dep->merchant_sig; + bd.h_contract_terms = dep->h_contract_terms; + bd.no_wallet_data_hash = true; + bd.wire_target_h_payto = account->h_full; + bd.wire_salt = dep->wire_salt; + bd.wallet_timestamp = wire_deadline; + bd.refund_deadline = refund_deadline; + bd.wire_deadline = wire_deadline; + bd.receiver_wire_account = account->payto; + bd.cdis = &cdi; + bd.num_cdis = 1; + dep->exchange_timestamp = GNUNET_TIME_timestamp_get (); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_EXCHANGEDB_do_deposit (pg, + &bd, + fees, + &dep->exchange_timestamp, + &total, + &balance_ok, + &bad_balance_index, + &in_conflict)); + GNUNET_assert (balance_ok); + GNUNET_assert (! in_conflict); + { + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_auto_from_type (&dep->merchant_pub), + GNUNET_PQ_query_param_auto_from_type (&dep->h_contract_terms), + GNUNET_PQ_query_param_end + }; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_uint64 ("batch_deposit_serial_id", + &dep->serial), + GNUNET_PQ_result_spec_end + }; + + GNUNET_assert (GNUNET_OK == + GNUNET_PQ_prepare_anon (pg->conn, + "SELECT batch_deposit_serial_id" + " FROM batch_deposits" + " WHERE merchant_pub=$1" + " AND h_contract_terms=$2;")); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "", + params, + rs)); + } +} + + +uint64_t +TDB_withdraw (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TDB_Denom *denom, + const struct TALER_ReservePublicKeyP *reserve_pub, + uint32_t seed, + const char *amount) +{ + struct TALER_EXCHANGEDB_Withdraw wd; + struct GNUNET_TIME_Timestamp now = GNUNET_TIME_timestamp_get (); + struct TALER_Amount reserve_balance; + struct TALER_BlindedDenominationSignature denom_sig; + uint64_t denom_serial = denom->serial; + uint64_t withdraw_id; + uint16_t allowed_maximum_age; + uint16_t noreveal_index; + uint32_t reserve_birthday; + bool balance_ok; + bool age_ok; + bool idempotent; + bool nonce_reuse; + + memset (&wd, + 0, + sizeof (wd)); + wd.amount_with_fee = TDB_amount (amount); + wd.reserve_pub = *reserve_pub; + TDB_fill (&wd.planchets_h, + sizeof (wd.planchets_h), + seed); + TDB_fill (&wd.reserve_sig, + sizeof (wd.reserve_sig), + seed); + wd.num_coins = 1; + wd.denom_serials = &denom_serial; + TDB_blinded_denom_sig (seed, + &denom_sig); + wd.denom_sigs = &denom_sig; + wd.no_blinding_seed = true; + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_EXCHANGEDB_do_withdraw (pg, + &wd, + &now, + &balance_ok, + &reserve_balance, + &age_ok, + &allowed_maximum_age, + &reserve_birthday, + &idempotent, + &noreveal_index, + &nonce_reuse)); + GNUNET_assert (balance_ok); + GNUNET_assert (age_ok); + GNUNET_assert (! nonce_reuse); + TALER_blinded_denom_sig_free (&denom_sig); + { + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_auto_from_type (&wd.planchets_h), + GNUNET_PQ_query_param_end + }; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_uint64 ("withdraw_id", + &withdraw_id), + GNUNET_PQ_result_spec_end + }; + + GNUNET_assert (GNUNET_OK == + GNUNET_PQ_prepare_anon (pg->conn, + "SELECT withdraw_id" + " FROM withdraw" + " WHERE planchets_h=$1;")); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "", + params, + rs)); + } + return withdraw_id; +} + + +uint64_t +TDB_reserve_in (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TDB_Account *account, + uint32_t seed, + const char *amount, + struct TALER_ReservePublicKeyP *reserve_pub) +{ + struct TALER_Amount a = TDB_amount (amount); + uint64_t serial; + char *rhex; + char *ahex; + + TDB_reserve (pg, + seed, + amount, + reserve_pub); + rhex = TDB_hex (reserve_pub, + sizeof (*reserve_pub)); + ahex = TDB_hex (&account->h_full, + sizeof (account->h_full)); + GNUNET_assert (GNUNET_OK == + TDB_exec (pg, + "INSERT INTO reserves_in" + " (reserve_pub,wire_reference,credit" + " ,wire_source_h_payto" + " ,exchange_account_section,execution_date)" + " VALUES (decode('%s','hex'),%u" + " ,ROW(%llu,%u)::taler_amount" + " ,decode('%s','hex'),'exchange-account-1'" + " ,%llu);", + rhex, + (unsigned int) seed, + (unsigned long long) a.value, + (unsigned int) a.fraction, + ahex, + (unsigned long long) 1600000000000000LLU)); + { + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_auto_from_type (reserve_pub), + GNUNET_PQ_query_param_end + }; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_uint64 ("reserve_in_serial_id", + &serial), + GNUNET_PQ_result_spec_end + }; + + GNUNET_assert (GNUNET_OK == + GNUNET_PQ_prepare_anon ( + pg->conn, + "SELECT reserve_in_serial_id" + " FROM reserves_in" + " WHERE reserve_pub=$1;")); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + GNUNET_PQ_eval_prepared_singleton_select ( + pg->conn, + "", + params, + rs)); + } + GNUNET_free (rhex); + GNUNET_free (ahex); + return serial; +} + + +/** + * Main function that runs the checks. + * + * @param cls closure + * @param args remaining command-line arguments + * @param cfgfile name of the configuration file used + * @param cfg configuration + */ +static void +run (void *cls, + char *const *args, + const char *cfgfile, + const struct GNUNET_CONFIGURATION_Handle *cfg) +{ + struct TALER_EXCHANGEDB_PostgresContext *pg; + unsigned int ran = 0; + + (void) cls; + (void) args; + (void) cfgfile; + pg = TALER_EXCHANGEDB_connect_admin (cfg); + if (NULL == pg) + { + fprintf (stderr, + "Failed to connect to the database\n"); + result = 77; + return; + } + if (GNUNET_OK != + TALER_EXCHANGEDB_create_tables (pg, + false, + 0)) + { + fprintf (stderr, + "Failed to create the database schema\n"); + result = 77; + goto cleanup; + } + for (unsigned int i = 0; NULL != tdb_tests[i].name; i++) + { + if ( (NULL != only) && + (0 != strcmp (only, + tdb_tests[i].name)) ) + continue; + fprintf (stderr, + "Running check `%s'\n", + tdb_tests[i].name); + ran++; + if (0 != tdb_tests[i].fn (pg)) + { + fprintf (stderr, + "Check `%s' FAILED\n", + tdb_tests[i].name); + result = 1; + } + } + if (0 == ran) + { + fprintf (stderr, + "No check matched `%s'\n", + only); + result = 1; + } +cleanup: + TALER_EXCHANGEDB_disconnect (pg); +} + + +int +TDB_main (int argc, + char *const *argv, + const char *binary, + const char *description, + const struct TDB_Test *tests) +{ + struct GNUNET_GETOPT_CommandLineOption options[] = { + GNUNET_GETOPT_option_string ('t', + "test", + "NAME", + "only run the check called NAME", + &only), + GNUNET_GETOPT_OPTION_END + }; + enum GNUNET_GenericReturnValue ret; + + result = 0; + tdb_tests = tests; + ret = GNUNET_PROGRAM_run (TALER_EXCHANGE_project_data (), + argc, + argv, + binary, + description, + options, + &run, + NULL); + if (GNUNET_SYSERR == ret) + return 3; + if (GNUNET_NO == ret) + return 0; + return result; +} + + +/* end of test_common.c */ diff --git a/src/exchangedb/test_common.h b/src/exchangedb/test_common.h @@ -0,0 +1,538 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_common.h + * @brief shared scaffolding for the per-table exchangedb tests + * @author Christian Grothoff + * + * Every `test_$TABLE.c` in this directory is a stand-alone binary that runs + * the exported database functions whose primary test table is `$TABLE` + * against a scratch database. This header holds the bits all of them need: + * the main loop, a raw-SQL escape hatch and fixtures for the rows that the + * functions under test reference by foreign key. + * + * The test data is deliberately bogus: keys are derived from a seed byte and + * signatures are never verifiable. The database layer does not check any of + * that, and building real signatures would only make the tests slower and + * harder to read. + */ +#ifndef TEST_COMMON_H +#define TEST_COMMON_H + +#include "exchangedb_lib.h" +#include "helper.h" +#include "taler/taler_json_lib.h" + + +/** + * Currency the scratch database is configured for. Must match + * test_table.sh. + */ +#define CURRENCY "EUR" + + +/** + * Report a failed expectation and return 1 from the calling check. + */ +#define FAILIF(cond) \ + do { \ + if (! (cond)) break; \ + GNUNET_break (0); \ + fprintf (stderr, \ + "FAILED: %s at %s:%u\n", \ + # cond, __FILE__, __LINE__); \ + return 1; \ + } while (0) + + +/** + * Like #FAILIF(), but runs @a cleanup before returning. + */ +#define FAILIF_C(cond,cleanup) \ + do { \ + if (! (cond)) break; \ + GNUNET_break (0); \ + fprintf (stderr, \ + "FAILED: %s at %s:%u\n", \ + # cond, __FILE__, __LINE__); \ + cleanup; \ + return 1; \ + } while (0) + + +/** + * One check in a `test_$TABLE.c` binary. + */ +struct TDB_Test +{ + /** + * Name of the check, used for the `-t` command-line option. NULL + * terminates the array. + */ + const char *name; + + /** + * Function to run. Returns 0 on success. + */ + int (*fn)(struct TALER_EXCHANGEDB_PostgresContext *pg); +}; + + +/** + * Run the checks in @a tests. Connects to the database, creates the + * schema and then runs every check (or only the one named with `-t`). + * + * @param argc number of arguments in @a argv + * @param argv command line + * @param binary name of the binary, for the usage message + * @param description one-line description of the binary + * @param tests NULL-terminated array of checks to run + * @return 0 on success, 1 if a check failed, 77 if the database was + * unusable, 3 on a command-line error + */ +int +TDB_main (int argc, + char *const *argv, + const char *binary, + const char *description, + const struct TDB_Test *tests); + + +/** + * Run @a fmt on the database connection, outside of any transaction. + * Used to set up rows that no exported function can create, and to + * assert over the database from the outside. + * + * @param pg the database context + * @param fmt printf-style SQL statement(s) + * @return #GNUNET_OK on success + */ +enum GNUNET_GenericReturnValue +TDB_exec (struct TALER_EXCHANGEDB_PostgresContext *pg, + const char *fmt, + ...) +__attribute__ ((format (printf, 2, 3))); + + +/** + * Count the rows the SQL fragment @a fmt selects. The fragment is + * appended to "SELECT COUNT(*) AS n ", so it starts with FROM. + * + * @param pg the database context + * @param fmt printf-style SQL fragment, e.g. "FROM reserves WHERE ..." + * @return number of matching rows + */ +uint64_t +TDB_count (struct TALER_EXCHANGEDB_PostgresContext *pg, + const char *fmt, + ...) +__attribute__ ((format (printf, 2, 3))); + + +/** + * Convert @a str to an amount in our currency. + * + * @param str amount without the currency prefix, e.g. "10.5" + * @return the parsed amount + */ +struct TALER_Amount +TDB_amount (const char *str); + + +/** + * Fill @a size bytes at @a ptr with data derived from @a seed. Different + * seeds give different data, the same seed always gives the same data. + * + * @param[out] ptr buffer to fill + * @param size number of bytes to write to @a ptr + * @param seed seed to derive the data from + */ +void +TDB_fill (void *ptr, + size_t size, + uint32_t seed); + + +/** + * Fill the object @a x with data derived from @a seed. + */ +#define TDB_FILL(x,seed) TDB_fill (&(x), sizeof (x), seed) + + +/** + * Render @a size bytes at @a data as a lowercase hex string for use in an + * SQL literal. + * + * @param data binary data + * @param size number of bytes in @a data + * @return hex string, to be freed by the caller + */ +char * +TDB_hex (const void *data, + size_t size); + + +/** + * A denomination that exists in the `denominations` table. + */ +struct TDB_Denom +{ + /** + * Private key; never used to sign anything meaningful. + */ + struct TALER_DenominationPrivateKey priv; + + /** + * Public key, as stored in the database. + */ + struct TALER_DenominationPublicKey pub; + + /** + * Hash of @e pub, the primary key of the denomination. + */ + struct TALER_DenominationHashP h_denom_pub; + + /** + * Row of the denomination in the `denominations` table. + */ + uint64_t serial; + + /** + * Value, fees and validity period as inserted. + */ + struct TALER_EXCHANGEDB_DenominationKeyInformation issue; +}; + + +/** + * Create a denomination and insert it into the `denominations` table. + * Fails hard if the insert does not work: a test that cannot set up its + * fixtures has nothing to say about the function under test. + * + * @param pg the database context + * @param seed seed for the key material and the master signature + * @param value value of a coin of this denomination, e.g. "5" + * @param fee fee charged for every operation on such a coin, e.g. "0.1" + * @param[out] denom set to the denomination + */ +void +TDB_denom (struct TALER_EXCHANGEDB_PostgresContext *pg, + uint32_t seed, + const char *value, + const char *fee, + struct TDB_Denom *denom); + + +/** + * Release the key material of @a denom. The row stays in the database. + * + * @param[in,out] denom denomination to clean up + */ +void +TDB_denom_free (struct TDB_Denom *denom); + + +/** + * Build an (invalid) denomination signature that the database layer will + * happily store and read back. + * + * @param seed seed to derive the signature from + * @param[out] sig set to the signature, to be freed with + * TALER_denom_sig_free() + */ +void +TDB_denom_sig (uint32_t seed, + struct TALER_DenominationSignature *sig); + + +/** + * Build an (invalid) blinded denomination signature that the database + * layer will happily store and read back. + * + * @param seed seed to derive the signature from + * @param[out] sig set to the signature, to be freed with + * TALER_blinded_denom_sig_free() + */ +void +TDB_blinded_denom_sig (uint32_t seed, + struct TALER_BlindedDenominationSignature *sig); + + +/** + * Create a coin of denomination @a denom and insert it into the + * `known_coins` table. Fails hard if the insert does not work. + * + * @param pg the database context + * @param denom denomination of the coin + * @param seed seed for the coin's key and signature + * @param[out] coin set to the coin, to be cleaned up with TDB_coin_free() + * @param[out] known_coin_id set to the row of the coin, may be NULL + */ +void +TDB_coin (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TDB_Denom *denom, + uint32_t seed, + struct TALER_CoinPublicInfo *coin, + uint64_t *known_coin_id); + + +/** + * Release the signature of @a coin. The row stays in the database. + * + * @param[in,out] coin coin to clean up + */ +void +TDB_coin_free (struct TALER_CoinPublicInfo *coin); + + +/** + * A bank account known to the exchange. + */ +struct TDB_Account +{ + /** + * Full payto URI of the account. + */ + struct TALER_FullPayto payto; + + /** + * Hash of @e payto, the primary key in `wire_targets`. + */ + struct TALER_FullPaytoHashP h_full; + + /** + * Hash of the normalized form of @e payto, the primary key in + * `kyc_targets`. + */ + struct TALER_NormalizedPaytoHashP h_normalized; +}; + + +/** + * Create a `kyc_targets` and a `wire_targets` row for a fresh bank + * account. Both tables are referenced by foreign keys all over the + * schema, but no exported function creates a row in them on its own. + * + * @param pg the database context + * @param seed seed for the account name + * @param[out] account set to the account, to be cleaned up with + * TDB_account_free() + */ +void +TDB_account (struct TALER_EXCHANGEDB_PostgresContext *pg, + uint32_t seed, + struct TDB_Account *account); + + +/** + * Release the payto URI of @a account. The rows stay in the database. + * + * @param[in,out] account account to clean up + */ +void +TDB_account_free (struct TDB_Account *account); + + +/** + * Create a reserve with the given @a balance, as + * exchange_do_purse_merge() does: without a `reserves_in` row, so the + * reserve has no origin account. + * + * @param pg the database context + * @param seed seed for the reserve's public key + * @param balance balance to give the reserve, e.g. "10" + * @param[out] reserve_pub set to the public key of the reserve + */ +void +TDB_reserve (struct TALER_EXCHANGEDB_PostgresContext *pg, + uint32_t seed, + const char *balance, + struct TALER_ReservePublicKeyP *reserve_pub); + + +/** + * A purse, as TDB_purse() created it. + */ +struct TDB_Purse +{ + /** + * Public key of the purse. + */ + struct TALER_PurseContractPublicKeyP purse_pub; + + /** + * Key that allows merging the purse. + */ + struct TALER_PurseMergePublicKeyP merge_pub; + + /** + * Contract the purse is for. + */ + struct TALER_PrivateContractHashP h_contract_terms; + + /** + * Signature over the purse's meta data. + */ + struct TALER_PurseContractSignatureP purse_sig; + + /** + * When the purse expires. + */ + struct GNUNET_TIME_Timestamp purse_expiration; + + /** + * Target amount of the purse. + */ + struct TALER_Amount amount; +}; + + +/** + * Create a purse with #TALER_EXCHANGEDB_insert_purse_request(). Fails + * hard if the purse cannot be created. + * + * @param pg the database context + * @param seed seed for the purse's keys, contract and signature + * @param amount target amount of the purse, e.g. "5" + * @param purse_expiration when the purse expires + * @param[out] purse set to the purse + */ +void +TDB_purse (struct TALER_EXCHANGEDB_PostgresContext *pg, + uint32_t seed, + const char *amount, + struct GNUNET_TIME_Timestamp purse_expiration, + struct TDB_Purse *purse); + + +/** + * A batch deposit of one coin, as TDB_deposit() made it. + */ +struct TDB_Deposit +{ + /** + * Merchant the coin was deposited with. + */ + struct TALER_MerchantPublicKeyP merchant_pub; + + /** + * Merchant signature over the contract. + */ + struct TALER_MerchantSignatureP merchant_sig; + + /** + * Contract the deposit is for. + */ + struct TALER_PrivateContractHashP h_contract_terms; + + /** + * Salt the merchant used for @e h_wire. + */ + struct TALER_WireSaltP wire_salt; + + /** + * Salted hash of the merchant's account. + */ + struct TALER_MerchantWireHashP h_wire; + + /** + * When the merchant wants to be paid. + */ + struct GNUNET_TIME_Timestamp wire_deadline; + + /** + * Until when the merchant may refund. + */ + struct GNUNET_TIME_Timestamp refund_deadline; + + /** + * Time the exchange recorded for the deposit. + */ + struct GNUNET_TIME_Timestamp exchange_timestamp; + + /** + * Row of the deposit in `batch_deposits`. + */ + uint64_t serial; +}; + + +/** + * Deposit @a coin with a fresh merchant, creating the `batch_deposits` + * and `coin_deposits` rows for it. Fails hard if the deposit does not go + * through. + * + * @param pg the database context + * @param account account the merchant wants to be paid at + * @param coin coin to deposit + * @param seed seed for the merchant key, the contract and the salt + * @param amount amount to deposit, e.g. "1" + * @param fee deposit fee of the coin's denomination, e.g. "0.1" + * @param wire_deadline when the merchant wants to be paid + * @param refund_deadline until when the merchant may refund + * @param[out] dep set to the deposit + */ +void +TDB_deposit (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TDB_Account *account, + const struct TALER_CoinPublicInfo *coin, + uint32_t seed, + const char *amount, + const char *fee, + struct GNUNET_TIME_Timestamp wire_deadline, + struct GNUNET_TIME_Timestamp refund_deadline, + struct TDB_Deposit *dep); + + +/** + * Withdraw one coin of @a denom from @a reserve_pub, creating a `withdraw` + * row for it. The reserve must hold at least @a amount. + * + * @param pg the database context + * @param denom denomination of the coin to withdraw + * @param reserve_pub reserve to withdraw from + * @param seed seed for the planchet hash and the signatures + * @param amount amount to withdraw, e.g. "5" + * @return row of the new entry in `withdraw` + */ +uint64_t +TDB_withdraw (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TDB_Denom *denom, + const struct TALER_ReservePublicKeyP *reserve_pub, + uint32_t seed, + const char *amount); + + +/** + * Wire @a amount from @a account into a fresh reserve, creating a + * `reserves_in` row for it. + * + * @param pg the database context + * @param account account the money comes from + * @param seed seed for the reserve's public key and the wire subject + * @param amount amount that was wired, e.g. "10" + * @param[out] reserve_pub set to the public key of the reserve + * @return row of the new entry in `reserves_in` + */ +uint64_t +TDB_reserve_in (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TDB_Account *account, + uint32_t seed, + const char *amount, + struct TALER_ReservePublicKeyP *reserve_pub); + + +#endif diff --git a/src/exchangedb/test_contracts.c b/src/exchangedb/test_contracts.c @@ -0,0 +1,335 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_contracts.c + * @brief tests for the exchangedb functions whose primary table is + * `contracts` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_contract(), + * #TALER_EXCHANGEDB_get_contract() and + * #TALER_EXCHANGEDB_get_contract_by_purse(). + * + * `contracts` references `purse_requests`: the encrypted contract belongs + * to a purse. It is fetched either by the purse or by the Diffie-Hellman + * public key the client derived, which is why there are two getters. A + * second, different contract for the same purse is reported through + * @e in_conflict rather than as an error. + */ +#include "test_common.h" +#include "exchange-database/get_contract.h" +#include "exchange-database/get_contract_by_purse.h" +#include "exchange-database/insert_contract.h" + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Build an encrypted contract. + * + * @param seed seed for the key, signature and payload + * @param payload contents of the "encrypted" contract + * @param[out] econtract set to the contract; free @e econtract with + * GNUNET_free() + */ +static void +make_econtract (uint32_t seed, + const char *payload, + struct TALER_EncryptedContract *econtract) +{ + memset (econtract, + 0, + sizeof (*econtract)); + TDB_fill (&econtract->contract_pub, + sizeof (econtract->contract_pub), + seed); + TDB_fill (&econtract->econtract_sig, + sizeof (econtract->econtract_sig), + seed); + econtract->econtract_size = strlen (payload) + 1; + econtract->econtract = GNUNET_memdup (payload, + econtract->econtract_size); +} + + +/** + * Nothing is known while the table is empty, and a contract for a purse + * that does not exist is not stored. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_PurseContractPublicKeyP purse_pub; + struct TALER_PurseContractSignatureP econtract_sig; + struct TALER_ContractDiffiePublicP pub_ckey; + struct TALER_EncryptedContract econtract; + size_t econtract_size; + void *econtract_data = NULL; + + TDB_FILL (purse_pub, + 1); + TDB_FILL (pub_ckey, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_contract (pg, + &pub_ckey, + &purse_pub, + &econtract_sig, + &econtract_size, + &econtract_data)); + FAILIF (NULL != econtract_data); + memset (&econtract, + 0, + sizeof (econtract)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_contract_by_purse (pg, + &purse_pub, + &econtract)); + return 0; +} + + +/** + * A contract is stored and found by both keys. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insert_and_lookup (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Purse purse; + struct TALER_EncryptedContract econtract; + struct TALER_EncryptedContract got; + struct TALER_PurseContractPublicKeyP got_purse; + struct TALER_PurseContractSignatureP got_sig; + size_t got_size; + void *got_data = NULL; + bool in_conflict = true; + + TDB_purse (pg, + 10, + "5", + ts (1700000000), + &purse); + make_econtract (10, + "the-contract", + &econtract); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_contract (pg, + &purse.purse_pub, + &econtract, + &in_conflict), + GNUNET_free (econtract.econtract)); + FAILIF_C (in_conflict, + GNUNET_free (econtract.econtract)); + FAILIF_C (1 != TDB_count (pg, + "FROM contracts"), + GNUNET_free (econtract.econtract)); + + /* by the Diffie-Hellman key the client derived */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_contract (pg, + &econtract.contract_pub, + &got_purse, + &got_sig, + &got_size, + &got_data), + GNUNET_free (econtract.econtract)); + FAILIF_C (0 != GNUNET_memcmp (&got_purse, + &purse.purse_pub), + GNUNET_free (got_data); GNUNET_free (econtract.econtract)); + FAILIF_C (0 != GNUNET_memcmp (&got_sig, + &econtract.econtract_sig), + GNUNET_free (got_data); GNUNET_free (econtract.econtract)); + FAILIF_C (got_size != econtract.econtract_size, + GNUNET_free (got_data); GNUNET_free (econtract.econtract)); + FAILIF_C (0 != memcmp (got_data, + econtract.econtract, + got_size), + GNUNET_free (got_data); GNUNET_free (econtract.econtract)); + GNUNET_free (got_data); + + /* and by the purse */ + memset (&got, + 0, + sizeof (got)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_contract_by_purse (pg, + &purse.purse_pub, + &got), + GNUNET_free (econtract.econtract)); + FAILIF_C (0 != GNUNET_memcmp (&got.contract_pub, + &econtract.contract_pub), + GNUNET_free (got.econtract); GNUNET_free (econtract.econtract)); + FAILIF_C (got.econtract_size != econtract.econtract_size, + GNUNET_free (got.econtract); GNUNET_free (econtract.econtract)); + FAILIF_C (0 != memcmp (got.econtract, + econtract.econtract, + got.econtract_size), + GNUNET_free (got.econtract); GNUNET_free (econtract.econtract)); + GNUNET_free (got.econtract); + + /* re-inserting the identical contract is a no-op */ + in_conflict = true; + FAILIF_C (0 > + TALER_EXCHANGEDB_insert_contract (pg, + &purse.purse_pub, + &econtract, + &in_conflict), + GNUNET_free (econtract.econtract)); + FAILIF_C (1 != TDB_count (pg, + "FROM contracts"), + GNUNET_free (econtract.econtract)); + GNUNET_free (econtract.econtract); + return 0; +} + + +/** + * A different contract for the same purse is a conflict. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_conflict (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Purse purse; + struct TALER_EncryptedContract econtract; + bool in_conflict = false; + + TDB_purse (pg, + 10, + "5", + ts (1700000000), + &purse); + make_econtract (11, + "another-contract", + &econtract); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_contract (pg, + &purse.purse_pub, + &econtract, + &in_conflict), + GNUNET_free (econtract.econtract)); + FAILIF_C (! in_conflict, + GNUNET_free (econtract.econtract)); + FAILIF_C (1 != TDB_count (pg, + "FROM contracts"), + GNUNET_free (econtract.econtract)); + GNUNET_free (econtract.econtract); + return 0; +} + + +/** + * A second purse can have its own contract, and the two do not mix. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_second_purse (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Purse purse; + struct TALER_EncryptedContract econtract; + struct TALER_EncryptedContract got; + bool in_conflict = true; + + TDB_purse (pg, + 12, + "3", + ts (1700000000), + &purse); + make_econtract (12, + "second-contract", + &econtract); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_contract (pg, + &purse.purse_pub, + &econtract, + &in_conflict), + GNUNET_free (econtract.econtract)); + FAILIF_C (in_conflict, + GNUNET_free (econtract.econtract)); + FAILIF_C (2 != TDB_count (pg, + "FROM contracts"), + GNUNET_free (econtract.econtract)); + memset (&got, + 0, + sizeof (got)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_contract_by_purse (pg, + &purse.purse_pub, + &got), + GNUNET_free (econtract.econtract)); + FAILIF_C (0 != GNUNET_memcmp (&got.contract_pub, + &econtract.contract_pub), + GNUNET_free (got.econtract); GNUNET_free (econtract.econtract)); + GNUNET_free (got.econtract); + GNUNET_free (econtract.econtract); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "contracts-empty", + &check_empty }, + { "contracts-insert-and-lookup", + &check_insert_and_lookup }, + { "contracts-conflict", + &check_conflict }, + { "contracts-second-purse", + &check_second_purse }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-contracts", + "Tests for the exchangedb `contracts' table", + tests); +} + + +/* end of test_contracts.c */ diff --git a/src/exchangedb/test_denomination_revocations.c b/src/exchangedb/test_denomination_revocations.c @@ -0,0 +1,259 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_denomination_revocations.c + * @brief tests for the exchangedb functions whose primary table is + * `denomination_revocations` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_denomination_revocation() and + * #TALER_EXCHANGEDB_get_denomination_revocation(). + * + * `denomination_revocations` references `denominations`, so a denomination + * has to exist first; that is what TDB_denom() is for. The check for the + * `recoup_possible` flag of #TALER_EXCHANGEDB_iterate_denominations() lives + * here too, since that flag is what the revocation row is *for*. + */ +#include "test_common.h" +#include "exchange-database/insert_denomination_revocation.h" +#include "exchange-database/get_denomination_revocation.h" +#include "exchange-database/iterate_denominations.h" + + +/** + * Closure for #recoup_cb(). + */ +struct RecoupContext +{ + /** + * Denomination we are looking for. + */ + const struct TALER_DenominationHashP *h_denom_pub; + + /** + * How many times did we see it? + */ + unsigned int matched; + + /** + * Recoup flag reported for it. + */ + bool recoup_possible; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_denominations(). + * + * @param cls a `struct RecoupContext *` + * @param denom_pub public key of the denomination + * @param h_denom_pub hash of @a denom_pub + * @param meta meta data of the denomination + * @param master_sig master signature over the denomination + * @param recoup_possible true if the denomination was revoked + */ +static void +recoup_cb (void *cls, + const struct TALER_DenominationPublicKey *denom_pub, + const struct TALER_DenominationHashP *h_denom_pub, + const struct TALER_EXCHANGEDB_DenominationKeyMetaData *meta, + const struct TALER_MasterSignatureP *master_sig, + bool recoup_possible) +{ + struct RecoupContext *ctx = cls; + + (void) denom_pub; + (void) meta; + (void) master_sig; + if (0 != GNUNET_memcmp (h_denom_pub, + ctx->h_denom_pub)) + return; + ctx->matched++; + ctx->recoup_possible = recoup_possible; +} + + +/** + * A denomination that does not exist cannot be revoked, and is reported as + * not revoked. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_unknown_denomination (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_DenominationHashP h_denom_pub; + struct TALER_MasterSignatureP master_sig; + struct TALER_MasterSignatureP got; + uint64_t rowid; + + TDB_FILL (h_denom_pub, + 1); + TDB_FILL (master_sig, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_denomination_revocation (pg, + &h_denom_pub, + &got, + &rowid)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_insert_denomination_revocation (pg, + &h_denom_pub, + &master_sig)); + FAILIF (0 != TDB_count (pg, + "FROM denomination_revocations")); + return 0; +} + + +/** + * Revoking a denomination records the signature and flips + * `recoup_possible`. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_revoke (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Denom revoked; + struct TDB_Denom intact; + struct TALER_MasterSignatureP master_sig; + struct TALER_MasterSignatureP got; + struct RecoupContext ctx; + uint64_t rowid = 0; + + TDB_denom (pg, + 10, + "5", + "0.1", + &revoked); + TDB_denom (pg, + 11, + "5", + "0.1", + &intact); + TDB_FILL (master_sig, + 42); + + /* before the revocation, recoup is not possible */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.h_denom_pub = &revoked.h_denom_pub; + FAILIF_C (0 >= + TALER_EXCHANGEDB_iterate_denominations (pg, + &recoup_cb, + &ctx), + TDB_denom_free (&revoked); TDB_denom_free (&intact)); + FAILIF_C (1 != ctx.matched, + TDB_denom_free (&revoked); TDB_denom_free (&intact)); + FAILIF_C (ctx.recoup_possible, + TDB_denom_free (&revoked); TDB_denom_free (&intact)); + + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_denomination_revocation ( + pg, + &revoked.h_denom_pub, + &master_sig), + TDB_denom_free (&revoked); TDB_denom_free (&intact)); + memset (&got, + 0, + sizeof (got)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_denomination_revocation ( + pg, + &revoked.h_denom_pub, + &got, + &rowid), + TDB_denom_free (&revoked); TDB_denom_free (&intact)); + FAILIF_C (0 != GNUNET_memcmp (&got, + &master_sig), + TDB_denom_free (&revoked); TDB_denom_free (&intact)); + FAILIF_C (0 == rowid, + TDB_denom_free (&revoked); TDB_denom_free (&intact)); + + /* now recoup is possible for the revoked denomination... */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.h_denom_pub = &revoked.h_denom_pub; + FAILIF_C (0 >= + TALER_EXCHANGEDB_iterate_denominations (pg, + &recoup_cb, + &ctx), + TDB_denom_free (&revoked); TDB_denom_free (&intact)); + FAILIF_C (1 != ctx.matched, + TDB_denom_free (&revoked); TDB_denom_free (&intact)); + FAILIF_C (! ctx.recoup_possible, + TDB_denom_free (&revoked); TDB_denom_free (&intact)); + + /* ...and only for that one */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.h_denom_pub = &intact.h_denom_pub; + FAILIF_C (0 >= + TALER_EXCHANGEDB_iterate_denominations (pg, + &recoup_cb, + &ctx), + TDB_denom_free (&revoked); TDB_denom_free (&intact)); + FAILIF_C (1 != ctx.matched, + TDB_denom_free (&revoked); TDB_denom_free (&intact)); + FAILIF_C (ctx.recoup_possible, + TDB_denom_free (&revoked); TDB_denom_free (&intact)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_denomination_revocation ( + pg, + &intact.h_denom_pub, + &got, + &rowid), + TDB_denom_free (&revoked); TDB_denom_free (&intact)); + FAILIF_C (1 != TDB_count (pg, + "FROM denomination_revocations"), + TDB_denom_free (&revoked); TDB_denom_free (&intact)); + TDB_denom_free (&revoked); + TDB_denom_free (&intact); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "denomination-revocations-unknown-denomination", + &check_unknown_denomination }, + { "denomination-revocations-revoke", + &check_revoke }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-denomination-revocations", + "Tests for the exchangedb `denomination_revocations' table", + tests); +} + + +/* end of test_denomination_revocations.c */ diff --git a/src/exchangedb/test_denominations.c b/src/exchangedb/test_denominations.c @@ -0,0 +1,384 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_denominations.c + * @brief tests for the exchangedb functions whose primary table is + * `denominations` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_denomination_info(), + * #TALER_EXCHANGEDB_get_denomination_info(), + * #TALER_EXCHANGEDB_get_denomination_by_serial(), + * #TALER_EXCHANGEDB_get_denomination_meta(), + * #TALER_EXCHANGEDB_iterate_denomination_info() and + * #TALER_EXCHANGEDB_iterate_denominations(). + * + * `denominations` has no foreign keys, so the checks need no fixtures. The + * empty-table checks run first, before any denomination exists. + */ +#include "test_common.h" +#include "exchange-database/insert_denomination_info.h" +#include "exchange-database/get_denomination_info.h" +#include "exchange-database/get_denomination_by_serial.h" +#include "exchange-database/get_denomination_meta.h" +#include "exchange-database/iterate_denomination_info.h" +#include "exchange-database/iterate_denominations.h" + + +/** + * Check that nothing is found while the table is still empty. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_DenominationHashP h_denom_pub; + struct TALER_EXCHANGEDB_DenominationKeyInformation issue; + struct TALER_EXCHANGEDB_DenominationKeyMetaData meta; + uint64_t serial; + + TDB_FILL (h_denom_pub, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_denomination_info (pg, + &h_denom_pub, + &serial, + &issue)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_denomination_by_serial (pg, + 42, + &issue)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_denomination_meta (pg, + &h_denom_pub, + &meta)); + return 0; +} + + +/** + * Closure for #count_info_cb() and #count_denoms_cb(). + */ +struct CountContext +{ + /** + * Denomination we are looking for. + */ + const struct TDB_Denom *denom; + + /** + * How many rows did the callback see in total? + */ + unsigned int total; + + /** + * How many times did we see @e denom? + */ + unsigned int matched; + + /** + * Set to true if @e denom was reported with data that does not match + * what we inserted. + */ + bool mismatch; + + /** + * Recoup flag reported for @e denom. + */ + bool recoup_possible; + + /** + * Row reported for @e denom. + */ + uint64_t serial; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_denomination_info(). + * + * @param cls a `struct CountContext *` + * @param denom_serial table row of the denomination + * @param denom_pub public key of the denomination + * @param issue information about the denomination + */ +static void +count_info_cb (void *cls, + uint64_t denom_serial, + const struct TALER_DenominationPublicKey *denom_pub, + const struct TALER_EXCHANGEDB_DenominationKeyInformation *issue) +{ + struct CountContext *ctx = cls; + + ctx->total++; + if (0 != GNUNET_memcmp (&issue->denom_hash, + &ctx->denom->h_denom_pub)) + return; + ctx->matched++; + ctx->serial = denom_serial; + if ( (0 != TALER_denom_pub_cmp (denom_pub, + &ctx->denom->pub)) || + (0 != TALER_amount_cmp (&issue->value, + &ctx->denom->issue.value)) || + (0 != TALER_amount_cmp (&issue->fees.deposit, + &ctx->denom->issue.fees.deposit)) ) + ctx->mismatch = true; +} + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_denominations(). + * + * @param cls a `struct CountContext *` + * @param denom_pub public key of the denomination + * @param h_denom_pub hash of @a denom_pub + * @param meta meta data of the denomination + * @param master_sig master signature over the denomination + * @param recoup_possible true if the denomination was revoked + */ +static void +count_denoms_cb (void *cls, + const struct TALER_DenominationPublicKey *denom_pub, + const struct TALER_DenominationHashP *h_denom_pub, + const struct TALER_EXCHANGEDB_DenominationKeyMetaData *meta, + const struct TALER_MasterSignatureP *master_sig, + bool recoup_possible) +{ + struct CountContext *ctx = cls; + + ctx->total++; + if (0 != GNUNET_memcmp (h_denom_pub, + &ctx->denom->h_denom_pub)) + return; + ctx->matched++; + ctx->serial = meta->serial; + ctx->recoup_possible = recoup_possible; + if ( (0 != TALER_denom_pub_cmp (denom_pub, + &ctx->denom->pub)) || + (0 != GNUNET_memcmp (master_sig, + &ctx->denom->issue.signature)) || + (0 != TALER_amount_cmp (&meta->value, + &ctx->denom->issue.value)) || + (GNUNET_TIME_timestamp_cmp (meta->start, + !=, + ctx->denom->issue.start)) ) + ctx->mismatch = true; +} + + +/** + * Insert a denomination and read it back through every lookup the + * table offers. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insert_and_lookup (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Denom denom; + struct TALER_EXCHANGEDB_DenominationKeyInformation issue; + struct TALER_EXCHANGEDB_DenominationKeyMetaData meta; + struct TALER_DenominationHashP other; + uint64_t serial; + + /* TDB_denom() inserts and looks up the row it created. */ + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + memset (&issue, + 0, + sizeof (issue)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_denomination_info (pg, + &denom.h_denom_pub, + &serial, + &issue), + TDB_denom_free (&denom)); + FAILIF_C (0 != GNUNET_memcmp (&issue, + &denom.issue), + TDB_denom_free (&denom)); + FAILIF_C (serial != denom.serial, + TDB_denom_free (&denom)); + + /* the denom_serial output is optional */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_denomination_info (pg, + &denom.h_denom_pub, + NULL, + &issue), + TDB_denom_free (&denom)); + + memset (&issue, + 0, + sizeof (issue)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_denomination_by_serial (pg, + serial, + &issue), + TDB_denom_free (&denom)); + FAILIF_C (0 != GNUNET_memcmp (&issue, + &denom.issue), + TDB_denom_free (&denom)); + + memset (&meta, + 0, + sizeof (meta)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_denomination_meta (pg, + &denom.h_denom_pub, + &meta), + TDB_denom_free (&denom)); + FAILIF_C (meta.serial != serial, + TDB_denom_free (&denom)); + FAILIF_C (0 != TALER_amount_cmp (&meta.value, + &denom.issue.value), + TDB_denom_free (&denom)); + FAILIF_C (0 != TALER_amount_cmp (&meta.fees.refund, + &denom.issue.fees.refund), + TDB_denom_free (&denom)); + FAILIF_C (GNUNET_TIME_timestamp_cmp (meta.expire_legal, + !=, + denom.issue.expire_legal), + TDB_denom_free (&denom)); + FAILIF_C (meta.age_mask.bits != denom.issue.age_mask.bits, + TDB_denom_free (&denom)); + + /* ...and a denomination that was never inserted is still not found. */ + TDB_FILL (other, + 99); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_denomination_info (pg, + &other, + &serial, + &issue), + TDB_denom_free (&denom)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_denomination_meta (pg, + &other, + &meta), + TDB_denom_free (&denom)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_denomination_by_serial (pg, + denom.serial + 1000, + &issue), + TDB_denom_free (&denom)); + TDB_denom_free (&denom); + return 0; +} + + +/** + * Both iterators must report every denomination in the table. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Denom d1; + struct TDB_Denom d2; + struct CountContext ctx; + unsigned int seen; + + TDB_denom (pg, + 20, + "1", + "0.01", + &d1); + TDB_denom (pg, + 21, + "2", + "0.02", + &d2); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.denom = &d2; + FAILIF_C (0 >= + TALER_EXCHANGEDB_iterate_denomination_info (pg, + &count_info_cb, + &ctx), + TDB_denom_free (&d1); TDB_denom_free (&d2)); + FAILIF_C (1 != ctx.matched, + TDB_denom_free (&d1); TDB_denom_free (&d2)); + FAILIF_C (ctx.mismatch, + TDB_denom_free (&d1); TDB_denom_free (&d2)); + FAILIF_C (ctx.serial != d2.serial, + TDB_denom_free (&d1); TDB_denom_free (&d2)); + seen = ctx.total; + FAILIF_C (seen < 2, + TDB_denom_free (&d1); TDB_denom_free (&d2)); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.denom = &d1; + FAILIF_C (0 >= + TALER_EXCHANGEDB_iterate_denominations (pg, + &count_denoms_cb, + &ctx), + TDB_denom_free (&d1); TDB_denom_free (&d2)); + FAILIF_C (1 != ctx.matched, + TDB_denom_free (&d1); TDB_denom_free (&d2)); + FAILIF_C (ctx.mismatch, + TDB_denom_free (&d1); TDB_denom_free (&d2)); + FAILIF_C (ctx.serial != d1.serial, + TDB_denom_free (&d1); TDB_denom_free (&d2)); + /* nothing was revoked in this binary */ + FAILIF_C (ctx.recoup_possible, + TDB_denom_free (&d1); TDB_denom_free (&d2)); + /* both iterators walk the same table, so they must agree on its size */ + FAILIF_C (ctx.total != seen, + TDB_denom_free (&d1); TDB_denom_free (&d2)); + TDB_denom_free (&d1); + TDB_denom_free (&d2); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "denominations-empty", + &check_empty }, + { "denominations-insert-and-lookup", + &check_insert_and_lookup }, + { "denominations-iterate", + &check_iterate }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-denominations", + "Tests for the exchangedb `denominations' table", + tests); +} + + +/* end of test_denominations.c */ diff --git a/src/exchangedb/test_exchange_sign_keys.c b/src/exchangedb/test_exchange_sign_keys.c @@ -0,0 +1,291 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_exchange_sign_keys.c + * @brief tests for the exchangedb functions whose primary table is + * `exchange_sign_keys` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_signkey(), + * #TALER_EXCHANGEDB_get_signkey() and + * #TALER_EXCHANGEDB_iterate_active_signkeys(). + * + * `exchange_sign_keys` has no foreign keys. The interesting part is what + * "active" means for the iterator: not expired for signing, and not + * revoked. The revocation half is covered by test_signkey_revocations.c. + */ +#include "test_common.h" +#include "exchange-database/insert_signkey.h" +#include "exchange-database/get_signkey.h" +#include "exchange-database/iterate_active_signkeys.h" + + +/** + * Build meta data for a signing key that is valid from an hour ago until + * @a sign_offset from now. + * + * @param sign_offset how long the key remains valid for signing; may be + * negative to build an already expired key + * @return the meta data + */ +static struct TALER_EXCHANGEDB_SignkeyMetaData +make_meta (struct GNUNET_TIME_Relative sign_offset) +{ + struct GNUNET_TIME_Absolute now = GNUNET_TIME_absolute_get (); + struct TALER_EXCHANGEDB_SignkeyMetaData meta; + + meta.start + = GNUNET_TIME_absolute_to_timestamp ( + GNUNET_TIME_absolute_subtract (now, + GNUNET_TIME_UNIT_HOURS)); + meta.expire_sign + = GNUNET_TIME_absolute_to_timestamp ( + GNUNET_TIME_absolute_add (now, + sign_offset)); + meta.expire_legal + = GNUNET_TIME_absolute_to_timestamp ( + GNUNET_TIME_absolute_add (now, + GNUNET_TIME_relative_multiply ( + GNUNET_TIME_UNIT_HOURS, + 24))); + return meta; +} + + +/** + * Nothing is known about a signing key that was never inserted. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ExchangePublicKeyP exchange_pub; + struct TALER_EXCHANGEDB_SignkeyMetaData meta; + + TDB_FILL (exchange_pub, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_signkey (pg, + &exchange_pub, + &meta)); + FAILIF (0 != TDB_count (pg, + "FROM exchange_sign_keys")); + return 0; +} + + +/** + * Closure for #count_signkeys_cb(). + */ +struct CountContext +{ + /** + * Key we are looking for. + */ + const struct TALER_ExchangePublicKeyP *exchange_pub; + + /** + * How many keys did the callback see in total? + */ + unsigned int total; + + /** + * How many times did we see @e exchange_pub? + */ + unsigned int matched; + + /** + * Set if @e exchange_pub was reported with unexpected data. + */ + bool mismatch; + + /** + * Meta data reported for @e exchange_pub. + */ + struct TALER_EXCHANGEDB_SignkeyMetaData meta; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_active_signkeys(). + * + * @param cls a `struct CountContext *` + * @param exchange_pub public key of the exchange + * @param meta meta data of @a exchange_pub + * @param master_sig master signature over @a exchange_pub + */ +static void +count_signkeys_cb (void *cls, + const struct TALER_ExchangePublicKeyP *exchange_pub, + const struct TALER_EXCHANGEDB_SignkeyMetaData *meta, + const struct TALER_MasterSignatureP *master_sig) +{ + struct CountContext *ctx = cls; + struct TALER_MasterSignatureP expected; + + ctx->total++; + if (0 != GNUNET_memcmp (exchange_pub, + ctx->exchange_pub)) + return; + ctx->matched++; + ctx->meta = *meta; + TDB_FILL (expected, + 2); + if (0 != GNUNET_memcmp (master_sig, + &expected)) + ctx->mismatch = true; +} + + +/** + * Insert a signing key and read it back. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insert_and_lookup (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ExchangePublicKeyP exchange_pub; + struct TALER_MasterSignatureP master_sig; + struct TALER_EXCHANGEDB_SignkeyMetaData meta; + struct TALER_EXCHANGEDB_SignkeyMetaData got; + struct CountContext ctx; + + TDB_FILL (exchange_pub, + 2); + TDB_FILL (master_sig, + 2); + meta = make_meta (GNUNET_TIME_UNIT_HOURS); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_signkey (pg, + &exchange_pub, + &meta, + &master_sig)); + memset (&got, + 0, + sizeof (got)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_signkey (pg, + &exchange_pub, + &got)); + FAILIF (0 != GNUNET_memcmp (&got, + &meta)); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.exchange_pub = &exchange_pub; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_active_signkeys (pg, + &count_signkeys_cb, + &ctx)); + FAILIF (1 != ctx.matched); + FAILIF (ctx.mismatch); + FAILIF (0 != GNUNET_memcmp (&ctx.meta, + &meta)); + return 0; +} + + +/** + * A key whose signing period is over is on file but not active. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_expired_is_inactive (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ExchangePublicKeyP exchange_pub; + struct TALER_MasterSignatureP master_sig; + struct TALER_EXCHANGEDB_SignkeyMetaData meta; + struct TALER_EXCHANGEDB_SignkeyMetaData got; + struct CountContext ctx; + + TDB_FILL (exchange_pub, + 3); + TDB_FILL (master_sig, + 3); + /* expired for signing half an hour ago */ + meta = make_meta (GNUNET_TIME_UNIT_ZERO); + meta.expire_sign + = GNUNET_TIME_absolute_to_timestamp ( + GNUNET_TIME_absolute_subtract ( + GNUNET_TIME_absolute_get (), + GNUNET_TIME_relative_multiply (GNUNET_TIME_UNIT_MINUTES, + 30))); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_signkey (pg, + &exchange_pub, + &meta, + &master_sig)); + /* the row is there... */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_signkey (pg, + &exchange_pub, + &got)); + /* ...but the iterator skips it */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.exchange_pub = &exchange_pub; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_active_signkeys (pg, + &count_signkeys_cb, + &ctx)); + FAILIF (0 != ctx.matched); + FAILIF (ctx.total != + TDB_count (pg, + "FROM exchange_sign_keys" + " WHERE expire_sign > %llu", + (unsigned long long) + GNUNET_TIME_absolute_get ().abs_value_us)); + FAILIF (2 != TDB_count (pg, + "FROM exchange_sign_keys")); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "exchange-sign-keys-empty", + &check_empty }, + { "exchange-sign-keys-insert-and-lookup", + &check_insert_and_lookup }, + { "exchange-sign-keys-expired-is-inactive", + &check_expired_is_inactive }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-exchange-sign-keys", + "Tests for the exchangedb `exchange_sign_keys' table", + tests); +} + + +/* end of test_exchange_sign_keys.c */ diff --git a/src/exchangedb/test_global_fee.c b/src/exchangedb/test_global_fee.c @@ -0,0 +1,419 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_global_fee.c + * @brief tests for the exchangedb functions whose primary table is + * `global_fee` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_global_fee(), + * #TALER_EXCHANGEDB_get_global_fee(), + * #TALER_EXCHANGEDB_get_global_fee_by_time() and + * #TALER_EXCHANGEDB_iterate_global_fees(). + * + * `global_fee` has no foreign keys and, unlike `wire_fee`, no wire method + * to key on -- the validity interval is all there is. As with the wire + * fees, the by-time lookup returns an invalid amount when the range covers + * two different fee sets. + */ +#include "test_common.h" +#include "exchange-database/insert_global_fee.h" +#include "exchange-database/get_global_fee.h" +#include "exchange-database/get_global_fee_by_time.h" +#include "exchange-database/iterate_global_fees.h" + + +/** + * One hour, in seconds. + */ +#define HOUR 3600 + + +/** + * Start of the first fee period, in seconds since the epoch. It has to be + * recent: #TALER_EXCHANGEDB_iterate_global_fees() only reports periods + * that started within the last year. + * + * @return an hour ago, rounded down to a whole second + */ +static uint64_t +t0 (void) +{ + static uint64_t cached; + + if (0 == cached) + cached = GNUNET_TIME_absolute_get ().abs_value_us + / (1000LLU * 1000LLU) - HOUR; + return cached; +} + + +/** + * Convenience alias for the start of the first fee period. + */ +#define T0 (t0 ()) + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Closure for #global_fee_cb(). + */ +struct FeeContext +{ + /** + * How many periods did the callback see? + */ + unsigned int total; + + /** + * Sum of the whole-unit parts of the history fees seen. + */ + uint64_t value_sum; + + /** + * Purse account limit of the last period seen. + */ + uint32_t purse_account_limit; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_global_fees(). + * + * @param cls a `struct FeeContext *` + * @param fees the fees of this period + * @param purse_timeout when purses time out + * @param history_expiration how long histories are kept + * @param purse_account_limit free purses per account + * @param start_date start of the period + * @param end_date end of the period + * @param master_sig signature over the period + */ +static void +global_fee_cb (void *cls, + const struct TALER_GlobalFeeSet *fees, + struct GNUNET_TIME_Relative purse_timeout, + struct GNUNET_TIME_Relative history_expiration, + uint32_t purse_account_limit, + struct GNUNET_TIME_Timestamp start_date, + struct GNUNET_TIME_Timestamp end_date, + const struct TALER_MasterSignatureP *master_sig) +{ + struct FeeContext *ctx = cls; + + (void) purse_timeout; + (void) history_expiration; + (void) start_date; + (void) end_date; + (void) master_sig; + ctx->total++; + ctx->value_sum += fees->history.value; + ctx->purse_account_limit = purse_account_limit; +} + + +/** + * Insert a fee period. + * + * @param pg the database context + * @param from start of the period + * @param until end of the period + * @param history history fee, e.g. "1" + * @param limit free purses per account + * @param seed seed for the master signature + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +add_fee (struct TALER_EXCHANGEDB_PostgresContext *pg, + uint64_t from, + uint64_t until, + const char *history, + uint32_t limit, + uint32_t seed) +{ + struct TALER_GlobalFeeSet fees; + struct TALER_MasterSignatureP master_sig; + + fees.history = TDB_amount (history); + fees.account = TDB_amount ("0.5"); + fees.purse = TDB_amount ("0.25"); + TDB_FILL (master_sig, + seed); + return TALER_EXCHANGEDB_insert_global_fee (pg, + ts (from), + ts (until), + &fees, + GNUNET_TIME_UNIT_HOURS, + GNUNET_TIME_UNIT_DAYS, + limit, + &master_sig); +} + + +/** + * No fees means no lookups succeed and the iterator sees nothing. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct GNUNET_TIME_Timestamp start_date; + struct GNUNET_TIME_Timestamp end_date; + struct GNUNET_TIME_Relative purse_timeout; + struct GNUNET_TIME_Relative history_expiration; + struct TALER_GlobalFeeSet fees; + struct TALER_MasterSignatureP master_sig; + struct FeeContext ctx = { 0 }; + uint32_t purse_account_limit; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_global_fee (pg, + ts (T0), + &start_date, + &end_date, + &fees, + &purse_timeout, + &history_expiration, + &purse_account_limit, + &master_sig)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_global_fee_by_time (pg, + ts (T0), + ts (T0 + HOUR), + &fees, + &purse_timeout, + &history_expiration, + &purse_account_limit)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_global_fees (pg, + &global_fee_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * A single fee period is found inside its interval and nowhere else. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_single_period (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct GNUNET_TIME_Timestamp start_date; + struct GNUNET_TIME_Timestamp end_date; + struct GNUNET_TIME_Relative purse_timeout; + struct GNUNET_TIME_Relative history_expiration; + struct TALER_GlobalFeeSet fees; + struct TALER_Amount expect_history = TDB_amount ("1"); + struct TALER_MasterSignatureP master_sig; + struct TALER_MasterSignatureP expect_sig; + uint32_t purse_account_limit = 0; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_fee (pg, + T0, + T0 + HOUR, + "1", + 4, + 20)); + TDB_FILL (expect_sig, + 20); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_global_fee (pg, + ts (T0), + &start_date, + &end_date, + &fees, + &purse_timeout, + &history_expiration, + &purse_account_limit, + &master_sig)); + FAILIF (GNUNET_TIME_timestamp_cmp (start_date, + !=, + ts (T0))); + FAILIF (GNUNET_TIME_timestamp_cmp (end_date, + !=, + ts (T0 + HOUR))); + FAILIF (0 != TALER_amount_cmp (&fees.history, + &expect_history)); + FAILIF (4 != purse_account_limit); + FAILIF (GNUNET_TIME_relative_cmp (purse_timeout, + !=, + GNUNET_TIME_UNIT_HOURS)); + FAILIF (GNUNET_TIME_relative_cmp (history_expiration, + !=, + GNUNET_TIME_UNIT_DAYS)); + FAILIF (0 != GNUNET_memcmp (&master_sig, + &expect_sig)); + + /* the interval is half-open */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_global_fee (pg, + ts (T0 + HOUR), + &start_date, + &end_date, + &fees, + &purse_timeout, + &history_expiration, + &purse_account_limit, + &master_sig)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_global_fee (pg, + ts (T0 - 1), + &start_date, + &end_date, + &fees, + &purse_timeout, + &history_expiration, + &purse_account_limit, + &master_sig)); + return 0; +} + + +/** + * Over a range with one fee set, get_global_fee_by_time() returns it; over + * a range spanning two different ones it returns an invalid amount. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_by_time (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct GNUNET_TIME_Relative purse_timeout; + struct GNUNET_TIME_Relative history_expiration; + struct TALER_GlobalFeeSet fees; + struct TALER_Amount expect_history = TDB_amount ("1"); + struct FeeContext ctx; + uint32_t purse_account_limit; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_fee (pg, + T0 + HOUR, + T0 + 2 * HOUR, + "1", + 4, + 21)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_fee (pg, + T0 + 2 * HOUR, + T0 + 3 * HOUR, + "9", + 4, + 22)); + + /* Two periods that agree: still the fee. The status is the number of + rows the range covered, so it is 2 rather than ONE_RESULT. */ + memset (&fees, + 0, + sizeof (fees)); + FAILIF (2 != + TALER_EXCHANGEDB_get_global_fee_by_time (pg, + ts (T0), + ts (T0 + 2 * HOUR), + &fees, + &purse_timeout, + &history_expiration, + &purse_account_limit)); + FAILIF (0 != TALER_amount_cmp (&fees.history, + &expect_history)); + FAILIF (4 != purse_account_limit); + + memset (&fees, + 0, + sizeof (fees)); + FAILIF (0 > + TALER_EXCHANGEDB_get_global_fee_by_time (pg, + ts (T0), + ts (T0 + 3 * HOUR), + &fees, + &purse_timeout, + &history_expiration, + &purse_account_limit)); + FAILIF (GNUNET_OK == + TALER_amount_is_valid (&fees.history)); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_global_fee_by_time (pg, + ts (T0 + 10 * HOUR), + ts (T0 + 11 * HOUR), + &fees, + &purse_timeout, + &history_expiration, + &purse_account_limit)); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_global_fees (pg, + &global_fee_cb, + &ctx)); + FAILIF (3 != ctx.total); + FAILIF (1 + 1 + 9 != ctx.value_sum); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "global-fee-empty", + &check_empty }, + { "global-fee-single-period", + &check_single_period }, + { "global-fee-by-time", + &check_by_time }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-global-fee", + "Tests for the exchangedb `global_fee' table", + tests); +} + + +/* end of test_global_fee.c */ diff --git a/src/exchangedb/test_known_coins.c b/src/exchangedb/test_known_coins.c @@ -0,0 +1,436 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_known_coins.c + * @brief tests for the exchangedb functions whose primary table is + * `known_coins` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_do_insert_known_coin(), + * #TALER_EXCHANGEDB_get_known_coin(), + * #TALER_EXCHANGEDB_get_coin_denomination(), + * #TALER_EXCHANGEDB_get_signature_for_known_coin() and + * #TALER_EXCHANGEDB_get_count_known_coins(). + * + * `known_coins` references `denominations`, so a denomination is created + * first with TDB_denom(). do_insert_known_coin() is the interesting one: + * it is idempotent, but only for a coin that comes back with the *same* + * denomination and age commitment -- the conflicting cases are what its + * negative status codes are for. + */ +#include "test_common.h" +#include "exchange-database/do_insert_known_coin.h" +#include "exchange-database/get_known_coin.h" +#include "exchange-database/get_coin_denomination.h" +#include "exchange-database/get_signature_for_known_coin.h" +#include "exchange-database/get_count_known_coins.h" + + +/** + * Nothing is known about a coin that was never inserted. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinSpendPublicKeyP coin_pub; + struct TALER_CoinPublicInfo info; + struct TALER_DenominationHashP h_denom_pub; + struct TALER_DenominationPublicKey denom_pub = { 0 }; + struct TALER_DenominationSignature denom_sig = { 0 }; + uint64_t known_coin_id; + + TDB_FILL (coin_pub, + 1); + TDB_FILL (h_denom_pub, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_known_coin (pg, + &coin_pub, + &info)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_coin_denomination (pg, + &coin_pub, + &known_coin_id, + &h_denom_pub)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_signature_for_known_coin (pg, + &coin_pub, + &denom_pub, + &denom_sig)); + FAILIF (NULL != denom_pub.bsign_pub_key); + FAILIF (NULL != denom_sig.unblinded_sig); + /* a denomination that does not exist has no coins */ + FAILIF (0 != TALER_EXCHANGEDB_get_count_known_coins (pg, + &h_denom_pub)); + return 0; +} + + +/** + * A coin of an unknown denomination cannot be made known. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_unknown_denomination (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo coin; + struct TALER_DenominationHashP dh; + struct TALER_AgeCommitmentHashP hac; + uint64_t known_coin_id; + + memset (&coin, + 0, + sizeof (coin)); + TDB_FILL (coin.coin_pub, + 2); + TDB_FILL (coin.denom_pub_hash, + 2); + coin.no_age_commitment = true; + TDB_denom_sig (2, + &coin.denom_sig); + /* the "dd" CTE finds no denomination, so nothing is inserted and the + UNION's second branch finds no coin either */ + FAILIF_C (TALER_EXCHANGEDB_CKS_HARD_FAIL != + TALER_EXCHANGEDB_do_insert_known_coin (pg, + &coin, + &known_coin_id, + &dh, + &hac), + TALER_denom_sig_free (&coin.denom_sig)); + TALER_denom_sig_free (&coin.denom_sig); + FAILIF (0 != TDB_count (pg, + "FROM known_coins")); + return 0; +} + + +/** + * Making a coin known stores it, and every lookup finds it. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insert_and_lookup (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Denom denom; + struct TALER_CoinPublicInfo coin; + struct TALER_CoinPublicInfo got; + struct TALER_DenominationHashP h_denom_pub; + struct TALER_DenominationPublicKey denom_pub = { 0 }; + struct TALER_DenominationSignature denom_sig = { 0 }; + uint64_t known_coin_id = 0; + uint64_t id2 = 0; + + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + TDB_coin (pg, + &denom, + 20, + &coin, + &known_coin_id); + FAILIF_C (0 == known_coin_id, + TDB_coin_free (&coin); TDB_denom_free (&denom)); + + memset (&got, + 0, + sizeof (got)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_known_coin (pg, + &coin.coin_pub, + &got), + TDB_coin_free (&coin); TDB_denom_free (&denom)); + FAILIF_C (0 != GNUNET_memcmp (&got.denom_pub_hash, + &denom.h_denom_pub), + TALER_denom_sig_free (&got.denom_sig); + TDB_coin_free (&coin); TDB_denom_free (&denom)); + FAILIF_C (! got.no_age_commitment, + TALER_denom_sig_free (&got.denom_sig); + TDB_coin_free (&coin); TDB_denom_free (&denom)); + FAILIF_C (0 != TALER_denom_sig_cmp (&got.denom_sig, + &coin.denom_sig), + TALER_denom_sig_free (&got.denom_sig); + TDB_coin_free (&coin); TDB_denom_free (&denom)); + TALER_denom_sig_free (&got.denom_sig); + + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_coin_denomination (pg, + &coin.coin_pub, + &id2, + &h_denom_pub), + TDB_coin_free (&coin); TDB_denom_free (&denom)); + FAILIF_C (id2 != known_coin_id, + TDB_coin_free (&coin); TDB_denom_free (&denom)); + FAILIF_C (0 != GNUNET_memcmp (&h_denom_pub, + &denom.h_denom_pub), + TDB_coin_free (&coin); TDB_denom_free (&denom)); + + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_signature_for_known_coin (pg, + &coin.coin_pub, + &denom_pub, + &denom_sig), + TDB_coin_free (&coin); TDB_denom_free (&denom)); + FAILIF_C (0 != TALER_denom_pub_cmp (&denom_pub, + &denom.pub), + TALER_denom_pub_free (&denom_pub); + TALER_denom_sig_free (&denom_sig); + TDB_coin_free (&coin); TDB_denom_free (&denom)); + FAILIF_C (0 != TALER_denom_sig_cmp (&denom_sig, + &coin.denom_sig), + TALER_denom_pub_free (&denom_pub); + TALER_denom_sig_free (&denom_sig); + TDB_coin_free (&coin); TDB_denom_free (&denom)); + TALER_denom_pub_free (&denom_pub); + TALER_denom_sig_free (&denom_sig); + + FAILIF_C (1 != TALER_EXCHANGEDB_get_count_known_coins (pg, + &denom.h_denom_pub), + TDB_coin_free (&coin); TDB_denom_free (&denom)); + TDB_coin_free (&coin); + TDB_denom_free (&denom); + return 0; +} + + +/** + * Re-inserting the same coin is a no-op; re-inserting it under a different + * denomination or with an age commitment is a conflict. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_conflicts (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Denom denom; + struct TDB_Denom other; + struct TALER_CoinPublicInfo coin; + struct TALER_DenominationHashP dh; + struct TALER_AgeCommitmentHashP hac; + uint64_t known_coin_id = 0; + uint64_t id2 = 0; + + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + TDB_denom (pg, + 11, + "5", + "0.1", + &other); + memset (&coin, + 0, + sizeof (coin)); + TDB_FILL (coin.coin_pub, + 20); + coin.denom_pub_hash = denom.h_denom_pub; + coin.no_age_commitment = true; + TDB_denom_sig (20, + &coin.denom_sig); + + /* the coin from the previous check: already present, no conflict */ + FAILIF_C (TALER_EXCHANGEDB_CKS_PRESENT != + TALER_EXCHANGEDB_do_insert_known_coin (pg, + &coin, + &known_coin_id, + &dh, + &hac), + TALER_denom_sig_free (&coin.denom_sig); + TDB_denom_free (&denom); TDB_denom_free (&other)); + FAILIF_C (0 == known_coin_id, + TALER_denom_sig_free (&coin.denom_sig); + TDB_denom_free (&denom); TDB_denom_free (&other)); + + /* same coin key, different denomination: conflict, and the stored + denomination is handed back so the caller can report it */ + coin.denom_pub_hash = other.h_denom_pub; + FAILIF_C (TALER_EXCHANGEDB_CKS_DENOM_CONFLICT != + TALER_EXCHANGEDB_do_insert_known_coin (pg, + &coin, + &id2, + &dh, + &hac), + TALER_denom_sig_free (&coin.denom_sig); + TDB_denom_free (&denom); TDB_denom_free (&other)); + FAILIF_C (0 != GNUNET_memcmp (&dh, + &denom.h_denom_pub), + TALER_denom_sig_free (&coin.denom_sig); + TDB_denom_free (&denom); TDB_denom_free (&other)); + + /* same coin key and denomination, but now with an age commitment where + the stored row has none: the caller should have passed NULL */ + coin.denom_pub_hash = denom.h_denom_pub; + coin.no_age_commitment = false; + TDB_FILL (coin.h_age_commitment, + 21); + FAILIF_C (TALER_EXCHANGEDB_CKS_AGE_CONFLICT_EXPECTED_NULL != + TALER_EXCHANGEDB_do_insert_known_coin (pg, + &coin, + &id2, + &dh, + &hac), + TALER_denom_sig_free (&coin.denom_sig); + TDB_denom_free (&denom); TDB_denom_free (&other)); + TALER_denom_sig_free (&coin.denom_sig); + + /* the conflicting attempts did not add anything */ + FAILIF_C (1 != TALER_EXCHANGEDB_get_count_known_coins (pg, + &denom.h_denom_pub), + TDB_denom_free (&denom); TDB_denom_free (&other)); + FAILIF_C (0 != TALER_EXCHANGEDB_get_count_known_coins (pg, + &other.h_denom_pub), + TDB_denom_free (&denom); TDB_denom_free (&other)); + TDB_denom_free (&denom); + TDB_denom_free (&other); + return 0; +} + + +/** + * A coin with an age commitment round-trips, and the mirror-image + * conflict is reported. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_age_commitment (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Denom denom; + struct TALER_CoinPublicInfo coin; + struct TALER_CoinPublicInfo got; + struct TALER_DenominationHashP dh; + struct TALER_AgeCommitmentHashP hac; + uint64_t known_coin_id = 0; + + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + memset (&coin, + 0, + sizeof (coin)); + TDB_FILL (coin.coin_pub, + 30); + coin.denom_pub_hash = denom.h_denom_pub; + coin.no_age_commitment = false; + TDB_FILL (coin.h_age_commitment, + 31); + TDB_denom_sig (30, + &coin.denom_sig); + FAILIF_C (TALER_EXCHANGEDB_CKS_ADDED != + TALER_EXCHANGEDB_do_insert_known_coin (pg, + &coin, + &known_coin_id, + &dh, + &hac), + TALER_denom_sig_free (&coin.denom_sig); + TDB_denom_free (&denom)); + memset (&got, + 0, + sizeof (got)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_known_coin (pg, + &coin.coin_pub, + &got), + TALER_denom_sig_free (&coin.denom_sig); + TDB_denom_free (&denom)); + FAILIF_C (got.no_age_commitment, + TALER_denom_sig_free (&got.denom_sig); + TALER_denom_sig_free (&coin.denom_sig); + TDB_denom_free (&denom)); + FAILIF_C (0 != GNUNET_memcmp (&got.h_age_commitment, + &coin.h_age_commitment), + TALER_denom_sig_free (&got.denom_sig); + TALER_denom_sig_free (&coin.denom_sig); + TDB_denom_free (&denom)); + TALER_denom_sig_free (&got.denom_sig); + + /* a different age commitment for the same coin is a conflict... */ + TDB_FILL (coin.h_age_commitment, + 32); + FAILIF_C (TALER_EXCHANGEDB_CKS_AGE_CONFLICT_VALUE_DIFFERS != + TALER_EXCHANGEDB_do_insert_known_coin (pg, + &coin, + &known_coin_id, + &dh, + &hac), + TALER_denom_sig_free (&coin.denom_sig); + TDB_denom_free (&denom)); + /* ...and so is no age commitment at all, where one is on file */ + coin.no_age_commitment = true; + FAILIF_C (TALER_EXCHANGEDB_CKS_AGE_CONFLICT_EXPECTED_NON_NULL != + TALER_EXCHANGEDB_do_insert_known_coin (pg, + &coin, + &known_coin_id, + &dh, + &hac), + TALER_denom_sig_free (&coin.denom_sig); + TDB_denom_free (&denom)); + TALER_denom_sig_free (&coin.denom_sig); + + /* two coins of this denomination by now */ + FAILIF_C (2 != TALER_EXCHANGEDB_get_count_known_coins (pg, + &denom.h_denom_pub), + TDB_denom_free (&denom)); + TDB_denom_free (&denom); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "known-coins-empty", + &check_empty }, + { "known-coins-unknown-denomination", + &check_unknown_denomination }, + { "known-coins-insert-and-lookup", + &check_insert_and_lookup }, + { "known-coins-conflicts", + &check_conflicts }, + { "known-coins-age-commitment", + &check_age_commitment }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-known-coins", + "Tests for the exchangedb `known_coins' table", + tests); +} + + +/* end of test_known_coins.c */ diff --git a/src/exchangedb/test_kyc_alerts.c b/src/exchangedb/test_kyc_alerts.c @@ -0,0 +1,235 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_kyc_alerts.c + * @brief tests for the exchangedb functions whose primary table is + * `kyc_alerts` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_do_drain_kyc_alert(). + * + * `kyc_alerts` is a work queue: the stored procedures that change the KYC + * state of an account append a row, and taler-exchange-aggregator drains + * it one row at a time. Nothing exported writes to it directly, so the + * rows here are produced through + * #TALER_EXCHANGEDB_insert_aml_program_failure(), the cheapest of the + * three producers. + */ +#include "test_common.h" +#include "exchange-database/do_drain_kyc_alert.h" +#include "exchange-database/insert_aml_program_failure.h" + + +/** + * Trigger type the aggregator drains, and the only one any stored + * procedure ever writes. + */ +#define AGGREGATOR_TRIGGER 1 + + +/** + * The two accounts we raise alerts for. + */ +static struct TDB_Account account[2]; + + +/** + * Nothing to drain while the queue is empty. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_NormalizedPaytoHashP h_payto; + + for (unsigned int i = 0; i < 2; i++) + TDB_account (pg, + 10 + i, + &account[i]); + memset (&h_payto, + 0, + sizeof (h_payto)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_do_drain_kyc_alert (pg, + AGGREGATOR_TRIGGER, + &h_payto)); + return 0; +} + + +/** + * A KYC state change queues exactly one alert per account. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_raise (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + for (unsigned int i = 0; i < 2; i++) + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_aml_program_failure ( + pg, + 0, + &account[i].h_normalized, + "no such AML program", + TALER_EC_EXCHANGE_GENERIC_KYC_FAILED)); + FAILIF (2 != TDB_count (pg, + "FROM kyc_alerts" + " WHERE trigger_type=%d", + AGGREGATOR_TRIGGER)); + + /* a second state change on an account that is already queued does not + queue it twice */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_aml_program_failure ( + pg, + 0, + &account[0].h_normalized, + "still no such AML program", + TALER_EC_EXCHANGE_GENERIC_KYC_FAILED)); + FAILIF (2 != TDB_count (pg, + "FROM kyc_alerts")); + return 0; +} + + +/** + * Draining returns the queued accounts one by one and removes them. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_drain (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_NormalizedPaytoHashP first; + struct TALER_NormalizedPaytoHashP second; + + /* nobody listens for this trigger type, so the queue looks empty */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_do_drain_kyc_alert (pg, + AGGREGATOR_TRIGGER + 1, + &first)); + FAILIF (2 != TDB_count (pg, + "FROM kyc_alerts")); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_drain_kyc_alert (pg, + AGGREGATOR_TRIGGER, + &first)); + FAILIF (1 != TDB_count (pg, + "FROM kyc_alerts")); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_drain_kyc_alert (pg, + AGGREGATOR_TRIGGER, + &second)); + FAILIF (0 != TDB_count (pg, + "FROM kyc_alerts")); + + /* both of our accounts came back, each exactly once */ + FAILIF (0 == GNUNET_memcmp (&first, + &second)); + for (unsigned int i = 0; i < 2; i++) + { + unsigned int seen = 0; + + if (0 == GNUNET_memcmp (&first, + &account[i].h_normalized)) + seen++; + if (0 == GNUNET_memcmp (&second, + &account[i].h_normalized)) + seen++; + FAILIF (1 != seen); + } + + /* and the queue is empty again */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_do_drain_kyc_alert (pg, + AGGREGATOR_TRIGGER, + &first)); + return 0; +} + + +/** + * An account that was drained can be queued again. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_requeue (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_NormalizedPaytoHashP h_payto; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_aml_program_failure ( + pg, + 0, + &account[1].h_normalized, + "and again", + TALER_EC_EXCHANGE_GENERIC_KYC_FAILED)); + FAILIF (1 != TDB_count (pg, + "FROM kyc_alerts")); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_drain_kyc_alert (pg, + AGGREGATOR_TRIGGER, + &h_payto)); + FAILIF (0 != GNUNET_memcmp (&h_payto, + &account[1].h_normalized)); + FAILIF (0 != TDB_count (pg, + "FROM kyc_alerts")); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "kyc-alerts-empty", + &check_empty }, + { "kyc-alerts-raise", + &check_raise }, + { "kyc-alerts-drain", + &check_drain }, + { "kyc-alerts-requeue", + &check_requeue }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-kyc-alerts", + "Tests for the exchangedb `kyc_alerts' table", + tests); + for (unsigned int i = 0; i < 2; i++) + TDB_account_free (&account[i]); + return ret; +} + + +/* end of test_kyc_alerts.c */ diff --git a/src/exchangedb/test_kyc_attributes.c b/src/exchangedb/test_kyc_attributes.c @@ -0,0 +1,956 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_kyc_attributes.c + * @brief tests for the exchangedb functions whose primary table is + * `kyc_attributes` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_do_insert_kyc_attributes(), + * #TALER_EXCHANGEDB_iterate_kyc_attributes(), + * #TALER_EXCHANGEDB_iterate_all_kyc_attributes(), + * #TALER_EXCHANGEDB_iterate_aml_attributes(), + * #TALER_EXCHANGEDB_iterate_kyc_history(), + * #TALER_EXCHANGEDB_kyc_history_builder() and + * #TALER_EXCHANGEDB_current_attributes_builder(). + * + * A `kyc_attributes` row is the (encrypted) personal data one KYC process + * collected, so every row needs a `legitimization_processes` row to hang + * off; those in turn need a legitimization measure on a known account. + * That is what setup_account() builds before the first insert. The AML + * officer path is exercised too, because it is the only way to get a row + * with `by_aml_officer` set, which is what the AML attribute iterator + * reports on. + */ +#include "test_common.h" +#include "exchange-database/account_history.h" +#include "exchange-database/do_insert_kyc_attributes.h" +#include "exchange-database/do_trigger_kyc_rule_for_account.h" +#include "exchange-database/insert_aml_decision.h" +#include "exchange-database/insert_aml_officer.h" +#include "exchange-database/insert_legitimization_process.h" +#include "exchange-database/iterate_all_kyc_attributes.h" +#include "exchange-database/iterate_aml_attributes.h" +#include "exchange-database/iterate_kyc_attributes.h" +#include "exchange-database/iterate_kyc_history.h" + + +/** + * Account the attributes are about. + */ +static struct TDB_Account account; + + +/** + * Row of the legitimization measures on @e account. + */ +static uint64_t measure_row; + + +/** + * Rows of the two KYC processes we run on @e account. + */ +static uint64_t process_row[2]; + + +/** + * Key the attributes are encrypted with. + */ +static struct TALER_AttributeEncryptionKeyP attribute_key; + + +/** + * Name the AML officer is on file under. + */ +#define OFFICER_NAME "Alex Officer" + + +/** + * Build the attribute set of @a name for storage. + * + * @param name value of the NAME attribute + * @param[out] enc set to the encrypted attributes, to be freed by the + * caller + * @param[out] enc_size set to the number of bytes in @a enc + */ +static void +encrypt_attributes (const char *name, + void **enc, + size_t *enc_size) +{ + json_t *attrs; + + attrs = GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("FORM_ID", + "test-form"), + GNUNET_JSON_pack_string ("NAME", + name)); + GNUNET_assert (NULL != attrs); + TALER_CRYPTO_kyc_attributes_encrypt (&attribute_key, + attrs, + enc, + enc_size); + json_decref (attrs); +} + + +/** + * Closure for the various attribute callbacks. + */ +struct AttributeContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Stop after this many rows; 0 for no limit. + */ + unsigned int stop_after; + + /** + * Value of the NAME attribute of the last row seen, owned by this + * struct. + */ + char *name; + + /** + * Provider of the last row seen, owned by this struct. + */ + char *provider; + + /** + * Officer of the last row seen, owned by this struct. + */ + char *officer; + + /** + * Row ID of the last row seen. + */ + uint64_t row_id; + + /** + * Was the last row filed by an AML officer? + */ + bool by_aml_officer; + + /** + * Was the KYC process of the last row finished? + */ + bool finished; +}; + + +/** + * Release what a `struct AttributeContext` owns. + * + * @param[in,out] ctx context to clean up + */ +static void +ac_free (struct AttributeContext *ctx) +{ + GNUNET_free (ctx->name); + GNUNET_free (ctx->provider); + GNUNET_free (ctx->officer); +} + + +/** + * Decrypt @a enc and remember its NAME attribute in @a ctx. + * + * @param[in,out] ctx context to update + * @param enc_size number of bytes in @a enc + * @param enc encrypted attributes + */ +static void +remember_name (struct AttributeContext *ctx, + size_t enc_size, + const void *enc) +{ + json_t *attrs; + const char *name; + + GNUNET_free (ctx->name); + ctx->name = NULL; + attrs = TALER_CRYPTO_kyc_attributes_decrypt (&attribute_key, + enc, + enc_size); + if (NULL == attrs) + return; + name = json_string_value (json_object_get (attrs, + "NAME")); + if (NULL != name) + ctx->name = GNUNET_strdup (name); + json_decref (attrs); +} + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_kyc_attributes(). + * + * @param cls a `struct AttributeContext *` + * @param h_payto account the attributes are about + * @param provider_name provider that collected them + * @param collection_time when they were collected + * @param expiration_time when they expire + * @param enc_attributes_size number of bytes in @a enc_attributes + * @param enc_attributes the encrypted attributes + */ +static void +attribute_cb (void *cls, + const struct TALER_NormalizedPaytoHashP *h_payto, + const char *provider_name, + struct GNUNET_TIME_Timestamp collection_time, + struct GNUNET_TIME_Timestamp expiration_time, + size_t enc_attributes_size, + const void *enc_attributes) +{ + struct AttributeContext *ctx = cls; + + (void) h_payto; + (void) collection_time; + (void) expiration_time; + ctx->total++; + GNUNET_free (ctx->provider); + ctx->provider = (NULL == provider_name) + ? NULL + : GNUNET_strdup (provider_name); + remember_name (ctx, + enc_attributes_size, + enc_attributes); +} + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_all_kyc_attributes(). + * + * @param cls a `struct AttributeContext *` + * @param row_id row of the attributes + * @param h_payto account the attributes are about + * @param provider_name provider that collected them + * @param collection_time when they were collected + * @param expiration_time when they expire + * @param properties properties set for the account + * @param enc_attributes_size number of bytes in @a enc_attributes + * @param enc_attributes the encrypted attributes + * @return true to continue iterating + */ +static bool +all_attributes_cb (void *cls, + uint64_t row_id, + const struct TALER_NormalizedPaytoHashP *h_payto, + const char *provider_name, + struct GNUNET_TIME_Timestamp collection_time, + struct GNUNET_TIME_Timestamp expiration_time, + const json_t *properties, + size_t enc_attributes_size, + const void *enc_attributes) +{ + struct AttributeContext *ctx = cls; + + (void) h_payto; + (void) collection_time; + (void) expiration_time; + (void) properties; + ctx->total++; + ctx->row_id = row_id; + GNUNET_free (ctx->provider); + ctx->provider = (NULL == provider_name) + ? NULL + : GNUNET_strdup (provider_name); + remember_name (ctx, + enc_attributes_size, + enc_attributes); + return ! ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ); +} + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_aml_attributes(). + * + * @param cls a `struct AttributeContext *` + * @param row_id row of the attributes + * @param collection_time when they were collected + * @param by_aml_officer were they filed by an AML officer + * @param officer_name name of that officer, NULL if none + * @param enc_attributes_size number of bytes in @a enc_attributes + * @param enc_attributes the encrypted attributes + */ +static void +aml_attribute_cb (void *cls, + uint64_t row_id, + struct GNUNET_TIME_Timestamp collection_time, + bool by_aml_officer, + const char *officer_name, + size_t enc_attributes_size, + const void *enc_attributes) +{ + struct AttributeContext *ctx = cls; + + (void) collection_time; + ctx->total++; + ctx->row_id = row_id; + ctx->by_aml_officer = by_aml_officer; + GNUNET_free (ctx->officer); + ctx->officer = (NULL == officer_name) + ? NULL + : GNUNET_strdup (officer_name); + remember_name (ctx, + enc_attributes_size, + enc_attributes); +} + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_kyc_history(). + * + * @param cls a `struct AttributeContext *` + * @param provider_name provider that ran the process + * @param finished did the process finish + * @param error_code error the process ran into + * @param error_message message for @a error_code + * @param provider_user_id account ID at the provider + * @param provider_legitimization_id process ID at the provider + * @param collection_time when the data was collected + * @param expiration_time when the data expires + * @param encrypted_attributes_len number of bytes in @a encrypted_attributes + * @param encrypted_attributes the encrypted attributes + */ +static void +kyc_history_cb (void *cls, + const char *provider_name, + bool finished, + enum TALER_ErrorCode error_code, + const char *error_message, + const char *provider_user_id, + const char *provider_legitimization_id, + struct GNUNET_TIME_Timestamp collection_time, + struct GNUNET_TIME_Absolute expiration_time, + size_t encrypted_attributes_len, + const void *encrypted_attributes) +{ + struct AttributeContext *ctx = cls; + + (void) error_code; + (void) error_message; + (void) provider_user_id; + (void) provider_legitimization_id; + (void) collection_time; + (void) expiration_time; + ctx->total++; + ctx->finished = finished; + GNUNET_free (ctx->provider); + ctx->provider = (NULL == provider_name) + ? NULL + : GNUNET_strdup (provider_name); + remember_name (ctx, + encrypted_attributes_len, + encrypted_attributes); +} + + +/** + * Create the account, put a legitimization measure on it and start the + * two KYC processes the attributes will belong to. + * + * @param pg the database context + */ +static void +setup_account (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + json_t *jmeasures; + bool bad_kyc_auth; + + if (0 != measure_row) + return; + TDB_FILL (attribute_key, + 42); + TDB_account (pg, + 10, + &account); + jmeasures = GNUNET_JSON_PACK ( + GNUNET_JSON_pack_array_steal ( + "measures", + json_pack ("[{s:s,s:s,s:s}]", + "check_name", + "verify-id", + "prog_name", + "skip", + "context", + "none")), + GNUNET_JSON_pack_bool ("is_and_combinator", + true)); + GNUNET_assert (NULL != jmeasures); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_EXCHANGEDB_do_trigger_kyc_rule_for_account ( + pg, + account.payto, + &account.h_normalized, + NULL, + NULL, + jmeasures, + 1, + &measure_row, + &bad_kyc_auth)); + json_decref (jmeasures); + GNUNET_assert (0 != measure_row); + for (unsigned int i = 0; i < 2; i++) + { + char *legi_id; + + GNUNET_asprintf (&legi_id, + "legi-%u", + i); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_EXCHANGEDB_insert_legitimization_process ( + pg, + &account.h_normalized, + i, + measure_row, + (0 == i) ? "provider-a" : "provider-b", + "provider-account", + legi_id, + &process_row[i])); + GNUNET_free (legi_id); + GNUNET_assert (0 != process_row[i]); + } +} + + +/** + * Nothing is known while the table is empty. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct AttributeContext ctx = { 0 }; + struct TALER_EXCHANGEDB_HistoryBuilderContext hbc; + json_t *j; + + setup_account (pg); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_kyc_attributes (pg, + &account.h_normalized, + &attribute_cb, + &ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_all_kyc_attributes (pg, + 0, + &all_attributes_cb, + &ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_aml_attributes (pg, + &account.h_normalized, + 0, + 16, + &aml_attribute_cb, + &ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_kyc_history (pg, + &account.h_normalized, + &kyc_history_cb, + &ctx)); + FAILIF (0 != ctx.total); + + hbc.account = &account.h_normalized; + hbc.pg = pg; + hbc.attribute_key = &attribute_key; + hbc.is_wallet = false; + j = TALER_EXCHANGEDB_kyc_history_builder (&hbc); + FAILIF (NULL == j); + FAILIF_C (0 != json_array_size (j), + json_decref (j)); + json_decref (j); + /* with no attributes at all, the "current" ones are an empty object */ + j = TALER_EXCHANGEDB_current_attributes_builder (&hbc); + FAILIF (NULL == j); + FAILIF_C (! json_is_object (j), + json_decref (j)); + FAILIF_C (0 != json_object_size (j), + json_decref (j)); + json_decref (j); + return 0; +} + + +/** + * Storing attributes creates the row and finishes the KYC process they + * were collected by. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insert (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + void *enc; + size_t enc_size; + enum GNUNET_DB_QueryStatus qs; + + encrypt_attributes ("Alice", + &enc, + &enc_size); + qs = TALER_EXCHANGEDB_do_insert_kyc_attributes ( + pg, + process_row[0], + &account.h_normalized, + "provider-a", + "provider-account", + "legi-0", + 0, + GNUNET_TIME_relative_to_absolute (GNUNET_TIME_UNIT_DAYS), + "test-form", + enc_size, + enc); + GNUNET_free (enc); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs); + FAILIF (1 != TDB_count (pg, + "FROM kyc_attributes")); + FAILIF (1 != TDB_count (pg, + "FROM kyc_attributes" + " WHERE NOT by_aml_officer" + " AND form_name='test-form'")); + /* the KYC process the attributes came from is now finished */ + FAILIF (1 != TDB_count (pg, + "FROM legitimization_processes" + " WHERE legitimization_process_serial_id=%llu" + " AND finished", + (unsigned long long) process_row[0])); + /* ... and the aggregator was told to have another look */ + FAILIF (0 == TDB_count (pg, + "FROM kyc_alerts")); + return 0; +} + + +/** + * Naming a provider that does not own the KYC process stores the + * attributes but leaves the process unfinished. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insert_wrong_provider (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + void *enc; + size_t enc_size; + enum GNUNET_DB_QueryStatus qs; + + encrypt_attributes ("Bob", + &enc, + &enc_size); + qs = TALER_EXCHANGEDB_do_insert_kyc_attributes ( + pg, + process_row[1], + &account.h_normalized, + /* process_row[1] belongs to provider-b */ + "provider-a", + "provider-account", + "legi-1", + 0, + GNUNET_TIME_relative_to_absolute (GNUNET_TIME_UNIT_DAYS), + NULL, + enc_size, + enc); + GNUNET_free (enc); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs); + FAILIF (2 != TDB_count (pg, + "FROM kyc_attributes")); + /* the attributes were stored, but the process was not finished; the + stored procedure reports that through out_ok, which the C wrapper + drops on the floor (EDBT-16) */ + FAILIF (0 != TDB_count (pg, + "FROM legitimization_processes" + " WHERE legitimization_process_serial_id=%llu" + " AND finished", + (unsigned long long) process_row[1])); + FAILIF (1 != TDB_count (pg, + "FROM kyc_attributes" + " WHERE form_name IS NULL")); + return 0; +} + + +/** + * The per-account and the global iterator both find the stored rows. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct AttributeContext ctx; + struct TALER_NormalizedPaytoHashP other; + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_kyc_attributes (pg, + &account.h_normalized, + &attribute_cb, + &ctx)); + FAILIF_C (2 != ctx.total, + ac_free (&ctx)); + FAILIF_C (NULL == ctx.name, + ac_free (&ctx)); + ac_free (&ctx); + + /* an account without attributes has none */ + TDB_FILL (other, + 99); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_kyc_attributes (pg, + &other, + &attribute_cb, + &ctx)); + FAILIF_C (0 != ctx.total, + ac_free (&ctx)); + ac_free (&ctx); + + /* the global iterator sees both rows, in ascending row order */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_all_kyc_attributes (pg, + 0, + &all_attributes_cb, + &ctx)); + FAILIF_C (2 != ctx.total, + ac_free (&ctx)); + FAILIF_C (0 != strcmp (ctx.name, + "Bob"), + ac_free (&ctx)); + { + uint64_t first = ctx.row_id - 1; + + ac_free (&ctx); + /* ... and honours the minimum row ID */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_all_kyc_attributes (pg, + first, + &all_attributes_cb, + &ctx)); + FAILIF_C (1 != ctx.total, + ac_free (&ctx)); + ac_free (&ctx); + } + + /* a callback that says stop is not called again */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.stop_after = 1; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_all_kyc_attributes (pg, + 0, + &all_attributes_cb, + &ctx)); + FAILIF_C (1 != ctx.total, + ac_free (&ctx)); + FAILIF_C (0 != strcmp (ctx.name, + "Alice"), + ac_free (&ctx)); + ac_free (&ctx); + return 0; +} + + +/** + * The KYC history reports the state of the process each set of + * attributes came from. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_kyc_history (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct AttributeContext ctx; + struct TALER_EXCHANGEDB_HistoryBuilderContext hbc = { + .account = &account.h_normalized, + .pg = pg, + .attribute_key = &attribute_key, + .is_wallet = false + }; + json_t *j; + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_kyc_history (pg, + &account.h_normalized, + &kyc_history_cb, + &ctx)); + FAILIF_C (2 != ctx.total, + ac_free (&ctx)); + ac_free (&ctx); + + j = TALER_EXCHANGEDB_kyc_history_builder (&hbc); + FAILIF (NULL == j); + FAILIF_C (2 != json_array_size (j), + json_decref (j)); + { + json_t *e = json_array_get (j, + 0); + const json_t *attrs = json_object_get (e, + "attributes"); + + FAILIF_C (NULL == attrs, + json_decref (j)); + FAILIF_C (NULL == json_object_get (attrs, + "NAME"), + json_decref (j)); + } + json_decref (j); + return 0; +} + + +/** + * Attributes filed by an AML officer are reported as such, together with + * the officer's name. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_aml_attributes (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_AmlOfficerPublicKeyP officer_pub; + struct TALER_AmlOfficerSignatureP officer_sig; + struct TALER_MasterSignatureP master_sig; + struct GNUNET_TIME_Timestamp previous_change; + struct TALER_FullPayto null_payto = { NULL }; + struct GNUNET_HashCode attributes_hash; + const char *no_events[1] = { NULL }; + struct AttributeContext ctx; + json_t *new_rules; + void *enc; + size_t enc_size; + bool invalid_officer; + bool unknown_account; + bool is_wallet; + uint64_t lms; + struct GNUNET_TIME_Timestamp last_date; + enum GNUNET_DB_QueryStatus qs; + + TDB_FILL (officer_pub, + 20); + TDB_FILL (officer_sig, + 21); + TDB_FILL (master_sig, + 22); + TDB_FILL (attributes_hash, + 23); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_aml_officer (pg, + &officer_pub, + &master_sig, + OFFICER_NAME, + true, + false, + GNUNET_TIME_timestamp_get (), + &previous_change)); + encrypt_attributes ("Carol", + &enc, + &enc_size); + new_rules = GNUNET_JSON_PACK ( + GNUNET_JSON_pack_array_steal ("rules", + json_array ())); + GNUNET_assert (NULL != new_rules); + qs = TALER_EXCHANGEDB_insert_aml_decision ( + pg, + null_payto, + &account.h_normalized, + GNUNET_TIME_timestamp_get (), + GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS), + NULL, + new_rules, + false, + NULL, + NULL, + "collected in person", + &officer_pub, + &officer_sig, + 0, + no_events, + "test-form", + enc_size, + enc, + &attributes_hash, + GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_DAYS), + &invalid_officer, + &unknown_account, + &last_date, + &lms, + &is_wallet); + json_decref (new_rules); + GNUNET_free (enc); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs); + FAILIF (invalid_officer); + FAILIF (3 != TDB_count (pg, + "FROM kyc_attributes")); + FAILIF (1 != TDB_count (pg, + "FROM kyc_attributes" + " WHERE by_aml_officer")); + + /* ascending from the start: all three rows, the officer's last */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_aml_attributes (pg, + &account.h_normalized, + 0, + 16, + &aml_attribute_cb, + &ctx)); + FAILIF_C (3 != ctx.total, + ac_free (&ctx)); + FAILIF_C (! ctx.by_aml_officer, + ac_free (&ctx)); + FAILIF_C (NULL == ctx.officer, + ac_free (&ctx)); + FAILIF_C (0 != strcmp (ctx.officer, + OFFICER_NAME), + ac_free (&ctx)); + FAILIF_C (0 != strcmp (ctx.name, + "Carol"), + ac_free (&ctx)); + ac_free (&ctx); + + /* descending from the end: only the newest row */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_aml_attributes (pg, + &account.h_normalized, + INT64_MAX, + -1, + &aml_attribute_cb, + &ctx)); + FAILIF_C (1 != ctx.total, + ac_free (&ctx)); + FAILIF_C (! ctx.by_aml_officer, + ac_free (&ctx)); + ac_free (&ctx); + + /* ascending past the last row: nothing */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_aml_attributes (pg, + &account.h_normalized, + INT64_MAX, + 16, + &aml_attribute_cb, + &ctx)); + FAILIF_C (0 != ctx.total, + ac_free (&ctx)); + ac_free (&ctx); + return 0; +} + + +/** + * The "current attributes" builder returns the newest set, decrypted. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_current_attributes (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_EXCHANGEDB_HistoryBuilderContext hbc = { + .account = &account.h_normalized, + .pg = pg, + .attribute_key = &attribute_key, + .is_wallet = false + }; + json_t *j; + const char *name; + + j = TALER_EXCHANGEDB_current_attributes_builder (&hbc); + FAILIF (NULL == j); + name = json_string_value (json_object_get (j, + "NAME")); + FAILIF_C (NULL == name, + json_decref (j)); + FAILIF_C (0 != strcmp (name, + "Carol"), + json_decref (j)); + json_decref (j); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "kyc-attributes-empty", + &check_empty }, + { "kyc-attributes-insert", + &check_insert }, + { "kyc-attributes-insert-wrong-provider", + &check_insert_wrong_provider }, + { "kyc-attributes-iterate", + &check_iterate }, + { "kyc-attributes-kyc-history", + &check_kyc_history }, + { "kyc-attributes-aml-attributes", + &check_aml_attributes }, + { "kyc-attributes-current-attributes", + &check_current_attributes }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-kyc-attributes", + "Tests for the exchangedb `kyc_attributes' table", + tests); + TDB_account_free (&account); + return ret; +} + + +/* end of test_kyc_attributes.c */ diff --git a/src/exchangedb/test_kyc_events.c b/src/exchangedb/test_kyc_events.c @@ -0,0 +1,438 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_kyc_events.c + * @brief tests for the exchangedb functions whose primary table is + * `kyc_events` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_iterate_aml_statistics(). + * + * `kyc_events` is a plain counter log: AML decisions may name events, + * each of which becomes one row, and the statistics query groups those + * rows by name over a time range. Two of the names are special -- + * ACCOUNT_OPEN and ACCOUNT_IDLE also move the account's open/close time + * in `kyc_targets` -- so those are checked as well. + */ +#include "test_common.h" +#include "exchange-database/insert_aml_decision.h" +#include "exchange-database/insert_aml_officer.h" +#include "exchange-database/iterate_aml_statistics.h" + + +/** + * One second, in microseconds. + */ +#define SECOND 1000000LLU + + +/** + * Times at which we take our three decisions, in seconds since the + * epoch. + */ +#define T1 1600000000 +#define T2 1600003600 +#define T3 1600007200 + + +/** + * Account the decisions are about. + */ +static struct TDB_Account account; + + +/** + * Officer taking the decisions. + */ +static struct TALER_AmlOfficerPublicKeyP officer_pub; + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * SECOND + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Closure for #statistics_cb(). + */ +struct StatsContext +{ + /** + * How many counters did the callback see? + */ + unsigned int total; + + /** + * Name we are interested in. + */ + const char *name; + + /** + * Count reported for @e name, UINT64_MAX if it was not reported. + */ + uint64_t cnt; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_aml_statistics(). + * + * @param cls a `struct StatsContext *` + * @param name name of the counter + * @param cnt value of the counter + */ +static void +statistics_cb (void *cls, + const char *name, + uint64_t cnt) +{ + struct StatsContext *ctx = cls; + + ctx->total++; + if ( (NULL != ctx->name) && + (0 == strcmp (name, + ctx->name)) ) + ctx->cnt = cnt; +} + + +/** + * Take an AML decision that triggers @a events. + * + * @param pg the database context + * @param seed seed for the officer's signature + * @param when when the decision is taken, in seconds since the epoch + * @param num_events length of @a events + * @param events the events to trigger + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +decide (struct TALER_EXCHANGEDB_PostgresContext *pg, + uint32_t seed, + uint64_t when, + size_t num_events, + const char *events[static num_events]) +{ + struct TALER_AmlOfficerSignatureP decider_sig; + struct TALER_FullPayto null_payto = { NULL }; + struct GNUNET_TIME_Timestamp last_date; + json_t *new_rules; + bool invalid_officer; + bool unknown_account; + bool is_wallet; + uint64_t lms; + enum GNUNET_DB_QueryStatus qs; + + TDB_fill (&decider_sig, + sizeof (decider_sig), + seed); + new_rules = GNUNET_JSON_PACK ( + GNUNET_JSON_pack_array_steal ("rules", + json_array ())); + GNUNET_assert (NULL != new_rules); + qs = TALER_EXCHANGEDB_insert_aml_decision ( + pg, + null_payto, + &account.h_normalized, + ts (when), + GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS), + NULL, + new_rules, + false, + NULL, + NULL, + "statistics", + &officer_pub, + &decider_sig, + num_events, + events, + NULL, + 0, + NULL, + NULL, + GNUNET_TIME_UNIT_ZERO_TS, + &invalid_officer, + &unknown_account, + &last_date, + &lms, + &is_wallet); + json_decref (new_rules); + GNUNET_assert (! invalid_officer); + return qs; +} + + +/** + * No counters while the table is empty. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + const char *names[] = { "ACCOUNT_OPEN", "sar-filed" }; + struct TALER_MasterSignatureP master_sig; + struct GNUNET_TIME_Timestamp previous_change; + struct StatsContext ctx = { 0 }; + + TDB_account (pg, + 10, + &account); + TDB_FILL (officer_pub, + 20); + TDB_FILL (master_sig, + 21); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_EXCHANGEDB_insert_aml_officer (pg, + &officer_pub, + &master_sig, + "Alex Officer", + true, + false, + ts (T1 - 1), + &previous_change)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_aml_statistics (pg, + 2, + names, + ts (0), + ts (T3 + 1), + &statistics_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * Decisions record their events, and the two account-lifecycle events + * also move the account's open and close time. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_record (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + const char *open_and_sar[] = { "ACCOUNT_OPEN", "sar-filed" }; + const char *sar[] = { "sar-filed" }; + const char *idle[] = { "ACCOUNT_IDLE" }; + const char *none[1] = { NULL }; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + decide (pg, + 30, + T1, + 2, + open_and_sar)); + FAILIF (2 != TDB_count (pg, + "FROM kyc_events")); + FAILIF (1 != TDB_count (pg, + "FROM kyc_targets" + " WHERE open_time=%llu" + " AND close_time IS NULL", + (unsigned long long) (T1 * SECOND))); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + decide (pg, + 31, + T2, + 1, + sar)); + FAILIF (3 != TDB_count (pg, + "FROM kyc_events")); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + decide (pg, + 32, + T3, + 1, + idle)); + FAILIF (4 != TDB_count (pg, + "FROM kyc_events")); + FAILIF (1 != TDB_count (pg, + "FROM kyc_targets" + " WHERE close_time=%llu", + (unsigned long long) (T3 * SECOND))); + + /* a decision without events records none */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + decide (pg, + 33, + T3 + 1, + 0, + none)); + FAILIF (4 != TDB_count (pg, + "FROM kyc_events")); + return 0; +} + + +/** + * The statistics group the events by name over the requested range. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_statistics (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + const char *all[] = { "ACCOUNT_OPEN", "ACCOUNT_IDLE", "sar-filed" }; + const char *sar[] = { "sar-filed" }; + const char *unknown[] = { "no-such-event" }; + struct StatsContext ctx; + + /* over the whole range every requested name that occurred is + reported, once */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.name = "sar-filed"; + ctx.cnt = UINT64_MAX; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_aml_statistics (pg, + 3, + all, + ts (0), + ts (T3 + 2), + &statistics_cb, + &ctx)); + FAILIF (3 != ctx.total); + FAILIF (2 != ctx.cnt); + + /* a name that was not asked for is not reported, even though it + happened */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.name = "sar-filed"; + ctx.cnt = UINT64_MAX; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_aml_statistics (pg, + 1, + sar, + ts (0), + ts (T3 + 2), + &statistics_cb, + &ctx)); + FAILIF (1 != ctx.total); + FAILIF (2 != ctx.cnt); + + /* the start of the range is inclusive, the end is exclusive */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.name = "sar-filed"; + ctx.cnt = UINT64_MAX; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_aml_statistics (pg, + 1, + sar, + ts (T2), + ts (T3), + &statistics_cb, + &ctx)); + FAILIF (1 != ctx.total); + FAILIF (1 != ctx.cnt); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_aml_statistics (pg, + 1, + sar, + ts (T1), + ts (T1), + &statistics_cb, + &ctx)); + FAILIF (0 != ctx.total); + + /* a range after everything that happened is empty */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_aml_statistics (pg, + 3, + all, + ts (T3 + 2), + ts (T3 + 100), + &statistics_cb, + &ctx)); + FAILIF (0 != ctx.total); + + /* and so is a name nobody ever recorded */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_aml_statistics (pg, + 1, + unknown, + ts (0), + ts (T3 + 2), + &statistics_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "kyc-events-empty", + &check_empty }, + { "kyc-events-record", + &check_record }, + { "kyc-events-statistics", + &check_statistics }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-kyc-events", + "Tests for the exchangedb `kyc_events' table", + tests); + TDB_account_free (&account); + return ret; +} + + +/* end of test_kyc_events.c */ diff --git a/src/exchangedb/test_kyc_targets.c b/src/exchangedb/test_kyc_targets.c @@ -0,0 +1,489 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_kyc_targets.c + * @brief tests for the exchangedb functions whose primary table is + * `kyc_targets` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_get_aml_file_number(), + * #TALER_EXCHANGEDB_get_h_payto_by_access_token(), + * #TALER_EXCHANGEDB_update_to_aml_locked(), + * #TALER_EXCHANGEDB_update_to_aml_unlocked() and + * #TALER_EXCHANGEDB_iterate_kyc_accounts(). + * + * `kyc_targets` is the exchange's list of accounts it knows in a KYC + * sense, keyed by the hash of the *normalized* payto URI. No exported + * function creates a row -- the deposit and credit paths do, as a side + * effect -- so the checks use TDB_account() for that. The access token + * is generated by the database itself, so the checks read it back out. + */ +#include "test_common.h" +#include "exchange-database/get_aml_file_number.h" +#include "exchange-database/get_h_payto_by_access_token.h" +#include "exchange-database/iterate_kyc_accounts.h" +#include "exchange-database/update_to_aml_locked.h" +#include "exchange-database/update_to_aml_unlocked.h" + + +/** + * Accounts the checks work on. + */ +static struct TDB_Account account; + +/** + * A second account. + */ +static struct TDB_Account other; + + +/** + * Read the access token the database generated for an account. + * + * @param pg the database context + * @param acc account to look up + * @param[out] access_token set to the account's access token + */ +static void +get_access_token (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TDB_Account *acc, + struct TALER_AccountAccessTokenP *access_token) +{ + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_auto_from_type (&acc->h_normalized), + GNUNET_PQ_query_param_end + }; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_auto_from_type ("access_token", + access_token), + GNUNET_PQ_result_spec_end + }; + + GNUNET_assert (GNUNET_OK == + GNUNET_PQ_prepare_anon (pg->conn, + "SELECT access_token" + " FROM kyc_targets" + " WHERE h_normalized_payto=$1;")); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "", + params, + rs)); +} + + +/** + * Closure for #account_cb(). + */ +struct AccountContext +{ + /** + * How many accounts did the callback see? + */ + unsigned int total; + + /** + * Account we are looking for, NULL to match nothing. + */ + const struct TALER_NormalizedPaytoHashP *h_payto; + + /** + * How many times did we see it? + */ + unsigned int matched; + + /** + * Row of the first account seen. + */ + uint64_t first_row; + + /** + * Was the matching account flagged for investigation? + */ + bool to_investigate; + + /** + * Was the matching account flagged high risk? + */ + bool high_risk; + + /** + * Was a payto URI reported for it? + */ + bool have_payto; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_kyc_accounts(). + * + * @param cls a `struct AccountContext *` + * @param row_id row of the account + * @param h_payto the account + * @param open_time when the account was opened + * @param close_time when it was closed + * @param comments file note on the account + * @param high_risk whether it is a high-risk relationship + * @param to_investigate whether it is flagged for investigation + * @param payto the account's payto URI + */ +static void +account_cb (void *cls, + uint64_t row_id, + const struct TALER_NormalizedPaytoHashP *h_payto, + struct GNUNET_TIME_Timestamp open_time, + struct GNUNET_TIME_Timestamp close_time, + const char *comments, + bool high_risk, + bool to_investigate, + struct TALER_FullPayto payto) +{ + struct AccountContext *ctx = cls; + + (void) open_time; + (void) close_time; + (void) comments; + if (0 == ctx->total++) + ctx->first_row = row_id; + if ( (NULL != ctx->h_payto) && + (0 == GNUNET_memcmp (h_payto, + ctx->h_payto)) ) + { + ctx->matched++; + ctx->to_investigate = to_investigate; + ctx->high_risk = high_risk; + ctx->have_payto = (NULL != payto.full_payto); + } +} + + +/** + * Nothing is known about an account the exchange never saw. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_NormalizedPaytoHashP h_payto; + struct TALER_AccountAccessTokenP access_token; + struct GNUNET_TIME_Absolute existing_lock; + struct AccountContext ctx = { 0 }; + uint64_t kyc_target_row; + bool is_wallet; + + TDB_FILL (h_payto, + 1); + TDB_FILL (access_token, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_aml_file_number (pg, + &h_payto, + &kyc_target_row, + &is_wallet)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_h_payto_by_access_token (pg, + &access_token, + &h_payto, + &is_wallet)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_update_to_aml_locked (pg, + &h_payto, + GNUNET_TIME_UNIT_HOURS, + &existing_lock)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_update_to_aml_unlocked (pg, + &h_payto)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_kyc_accounts (pg, + TALER_EXCHANGE_YNA_ALL, + TALER_EXCHANGE_YNA_ALL, + TALER_EXCHANGE_YNA_ALL, + 0, + 10, + &account_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * A known account is found by its hash and by its access token. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_lookup (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_AccountAccessTokenP access_token; + struct TALER_NormalizedPaytoHashP h_payto; + uint64_t kyc_target_row = 0; + bool is_wallet = true; + + TDB_account (pg, + 10, + &account); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_aml_file_number (pg, + &account.h_normalized, + &kyc_target_row, + &is_wallet)); + FAILIF (0 == kyc_target_row); + /* TDB_account() creates bank accounts, not wallet accounts */ + FAILIF (is_wallet); + + get_access_token (pg, + &account, + &access_token); + is_wallet = true; + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_h_payto_by_access_token (pg, + &access_token, + &h_payto, + &is_wallet)); + FAILIF (0 != GNUNET_memcmp (&h_payto, + &account.h_normalized)); + FAILIF (is_wallet); + + /* an access token nobody was given finds nothing */ + { + struct TALER_AccountAccessTokenP other_token; + + TDB_FILL (other_token, + 99); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_h_payto_by_access_token (pg, + &other_token, + &h_payto, + &is_wallet)); + } + return 0; +} + + +/** + * Locking an account reports the lock to whoever tries next, and + * unlocking clears it. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_lock (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct GNUNET_TIME_Absolute existing_lock; + + /* no lock yet */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_to_aml_locked (pg, + &account.h_normalized, + GNUNET_TIME_UNIT_HOURS, + &existing_lock)); + FAILIF (! GNUNET_TIME_absolute_is_zero (existing_lock)); + + /* a second attempt is told when the first lock runs out */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_to_aml_locked (pg, + &account.h_normalized, + GNUNET_TIME_UNIT_HOURS, + &existing_lock)); + FAILIF (GNUNET_TIME_absolute_is_zero (existing_lock)); + FAILIF (GNUNET_TIME_absolute_is_past (existing_lock)); + + /* unlocking clears it */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_to_aml_unlocked (pg, + &account.h_normalized)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_to_aml_locked (pg, + &account.h_normalized, + GNUNET_TIME_UNIT_HOURS, + &existing_lock)); + FAILIF (! GNUNET_TIME_absolute_is_zero (existing_lock)); + /* unlocking an account that is not locked is fine */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_to_aml_unlocked (pg, + &account.h_normalized)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_to_aml_unlocked (pg, + &account.h_normalized)); + return 0; +} + + +/** + * The account listing reports the accounts, with the filters applied. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct AccountContext ctx; + uint64_t lowest; + + TDB_account (pg, + 11, + &other); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.h_payto = &account.h_normalized; + FAILIF (2 != + TALER_EXCHANGEDB_iterate_kyc_accounts (pg, + TALER_EXCHANGE_YNA_ALL, + TALER_EXCHANGE_YNA_ALL, + TALER_EXCHANGE_YNA_ALL, + 0, + 10, + &account_cb, + &ctx)); + FAILIF (1 != ctx.matched); + FAILIF (! ctx.have_payto); + /* no AML decision has been taken, so neither flag is set */ + FAILIF (ctx.to_investigate); + FAILIF (ctx.high_risk); + lowest = ctx.first_row; + + /* the limit caps the result set */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (1 != + TALER_EXCHANGEDB_iterate_kyc_accounts (pg, + TALER_EXCHANGE_YNA_ALL, + TALER_EXCHANGE_YNA_ALL, + TALER_EXCHANGE_YNA_ALL, + 0, + 1, + &account_cb, + &ctx)); + FAILIF (lowest != ctx.first_row); + + /* a negative limit walks the list the other way */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (2 != + TALER_EXCHANGEDB_iterate_kyc_accounts (pg, + TALER_EXCHANGE_YNA_ALL, + TALER_EXCHANGE_YNA_ALL, + TALER_EXCHANGE_YNA_ALL, + 1000, + -10, + &account_cb, + &ctx)); + FAILIF (lowest == ctx.first_row); + + /* nothing is under investigation... */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_kyc_accounts (pg, + TALER_EXCHANGE_YNA_YES, + TALER_EXCHANGE_YNA_ALL, + TALER_EXCHANGE_YNA_ALL, + 0, + 10, + &account_cb, + &ctx)); + FAILIF (0 != ctx.total); + /* ...so everything comes back under "not under investigation" */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (2 != + TALER_EXCHANGEDB_iterate_kyc_accounts (pg, + TALER_EXCHANGE_YNA_NO, + TALER_EXCHANGE_YNA_ALL, + TALER_EXCHANGE_YNA_ALL, + 0, + 10, + &account_cb, + &ctx)); + + /* nothing is high risk either */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_kyc_accounts (pg, + TALER_EXCHANGE_YNA_ALL, + TALER_EXCHANGE_YNA_ALL, + TALER_EXCHANGE_YNA_YES, + 0, + 10, + &account_cb, + &ctx)); + FAILIF (0 != ctx.total); + + /* and no account has an AML outcome, which is what "open" means here */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_kyc_accounts (pg, + TALER_EXCHANGE_YNA_ALL, + TALER_EXCHANGE_YNA_NO, + TALER_EXCHANGE_YNA_ALL, + 0, + 10, + &account_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "kyc-targets-empty", + &check_empty }, + { "kyc-targets-lookup", + &check_lookup }, + { "kyc-targets-lock", + &check_lock }, + { "kyc-targets-iterate", + &check_iterate }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-kyc-targets", + "Tests for the exchangedb `kyc_targets' table", + tests); + TDB_account_free (&account); + TDB_account_free (&other); + return ret; +} + + +/* end of test_kyc_targets.c */ diff --git a/src/exchangedb/test_kycauths_in.c b/src/exchangedb/test_kycauths_in.c @@ -0,0 +1,622 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_kycauths_in.c + * @brief tests for the exchangedb functions whose primary table is + * `kycauths_in` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_do_import_credits() (for its KYC-auth half; the + * reserve half is in test_reserves_in.c), + * #TALER_EXCHANGEDB_iterate_kycauth_in_above_serial_id_by_account() and + * #TALER_EXCHANGEDB_iterate_exchange_kycauth_transfers(). + * + * A KYC-auth transfer carries an account public key in its wire subject. + * The import creates the `kyc_targets` and `wire_targets` rows for the + * debited account on first sight and only sets `target_pub` afterwards, so + * the checks import from the same account twice. The primary key is + * (account, wire reference), which is what makes a re-import a no-op. + */ +#include "test_common.h" +#include "exchange-database/begin_shard.h" +#include "exchange-database/do_import_credits.h" +#include "exchange-database/iterate_exchange_kycauth_transfers.h" +#include "exchange-database/iterate_kycauth_in_above_serial_id_by_account.h" + + +/** + * Account the first two transfers come from. + */ +#define PAYTO_A "payto://x-taler-bank/localhost/ka-a?receiver-name=A" + +/** + * Account the third transfer comes from. + */ +#define PAYTO_B "payto://x-taler-bank/localhost/ka-b?receiver-name=B" + + +/** + * Closure for #kycauth_cb(). + */ +struct AuthContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Stop after this many rows; 0 for no limit. + */ + unsigned int stop_after; + + /** + * Account key we are looking for, NULL to match nothing. + */ + const union TALER_AccountPublicKeyP *account_pub; + + /** + * How many times did we see @e account_pub? + */ + unsigned int matched; + + /** + * Credit reported for @e account_pub. + */ + struct TALER_Amount credit; + + /** + * Sender account reported for @e account_pub, owned by this struct. + */ + char *sender; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_kycauth_in_above_serial_id_by_account(). + * + * @param cls a `struct AuthContext *` + * @param rowid row of the transfer + * @param account_pub key the transfer associated with the account + * @param credit amount that was transferred + * @param sender_account_details account the money came from + * @param wire_reference bank's identifier for the transfer + * @param execution_date when the transfer was made + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +kycauth_cb (void *cls, + uint64_t rowid, + const union TALER_AccountPublicKeyP *account_pub, + const struct TALER_Amount *credit, + const struct TALER_FullPayto sender_account_details, + uint64_t wire_reference, + struct GNUNET_TIME_Timestamp execution_date) +{ + struct AuthContext *ctx = cls; + + (void) rowid; + (void) wire_reference; + (void) execution_date; + ctx->total++; + if ( (NULL != ctx->account_pub) && + (0 == GNUNET_memcmp (account_pub, + ctx->account_pub)) ) + { + ctx->matched++; + ctx->credit = *credit; + GNUNET_free (ctx->sender); + ctx->sender = (NULL == sender_account_details.full_payto) + ? NULL + : GNUNET_strdup (sender_account_details.full_payto); + } + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * Closure for #transfer_cb(). + */ +struct TransferContext +{ + /** + * How many transfers did the callback see? + */ + unsigned int total; + + /** + * Row of the first transfer seen. + */ + uint64_t first_row; + + /** + * Sum of the whole-unit parts of the amounts seen. + */ + uint64_t value_sum; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_exchange_kycauth_transfers(). + * + * @param cls a `struct TransferContext *` + * @param row_id row of the transfer + * @param payto_uri account the money came from + * @param execution_time when the transfer was made + * @param amount amount that was transferred + */ +static void +transfer_cb (void *cls, + uint64_t row_id, + const char *payto_uri, + struct GNUNET_TIME_Absolute execution_time, + const struct TALER_Amount *amount) +{ + struct TransferContext *ctx = cls; + + (void) payto_uri; + (void) execution_time; + if (0 == ctx->total++) + ctx->first_row = row_id; + ctx->value_sum += amount->value; +} + + +/** + * Import one KYC authentication transfer, advancing a work shard of its + * own along with it. + * + * @param pg the database context + * @param job_name job whose shard to open and advance + * @param account_name exchange bank account the transfer arrived at + * @param payto account the money came from + * @param seed seed for the account public key + * @param wire_reference bank's identifier for the transfer + * @param amount amount that was transferred, e.g. "10" + * @param[out] account_pub set to the key the transfer carried + * @return transaction status of the import + */ +static enum GNUNET_DB_QueryStatus +import_one (struct TALER_EXCHANGEDB_PostgresContext *pg, + const char *job_name, + const char *account_name, + const char *payto, + uint32_t seed, + uint64_t wire_reference, + const char *amount, + union TALER_AccountPublicKeyP *account_pub) +{ + struct TALER_Amount balance = TDB_amount (amount); + struct TALER_EXCHANGEDB_KycauthInInfo info; + struct TALER_EXCHANGEDB_CreditBatch batch; + uint64_t start; + uint64_t end; + uint64_t progress; + + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_EXCHANGEDB_begin_shard (pg, + job_name, + GNUNET_TIME_UNIT_HOURS, + 1024, + &start, + &end, + &progress)); + TDB_fill (account_pub, + sizeof (*account_pub), + seed); + memset (&info, + 0, + sizeof (info)); + info.account_pub = account_pub; + info.balance = &balance; + info.execution_time = GNUNET_TIME_timestamp_get (); + info.sender_account_details.full_payto = (char *) payto; + info.wire_reference = wire_reference; + memset (&batch, + 0, + sizeof (batch)); + batch.exchange_account_name = account_name; + batch.kycauths = &info; + batch.kycauths_length = 1; + batch.job_name = job_name; + batch.shard_start = start; + batch.shard_end = end; + batch.progress_row = end; + batch.lease = GNUNET_TIME_UNIT_HOURS; + return TALER_EXCHANGEDB_do_import_credits (pg, + &batch, + NULL); +} + + +/** + * Nothing is known while the table is empty. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct AuthContext ctx = { 0 }; + struct TransferContext tctx = { 0 }; + struct TALER_Amount zero = TDB_amount ("0"); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_kycauth_in_above_serial_id_by_account ( + pg, + "exchange-account-1", + 0, + &kycauth_cb, + &ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_exchange_kycauth_transfers (pg, + &zero, + 0, + 10, + NULL, + &transfer_cb, + &tctx)); + FAILIF (0 != ctx.total); + FAILIF (0 != tctx.total); + FAILIF (0 != TDB_count (pg, + "FROM kycauths_in")); + return 0; +} + + +/** + * Importing a transfer registers the account key and creates the account + * rows; importing it again is a no-op. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_import (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + union TALER_AccountPublicKeyP account_pub; + struct AuthContext ctx; + struct TALER_Amount expect = TDB_amount ("10"); + char *hex; + + FAILIF (0 > + import_one (pg, + "ka-job-a", + "exchange-account-1", + PAYTO_A, + 10, + 1, + "10", + &account_pub)); + FAILIF (1 != TDB_count (pg, + "FROM kycauths_in")); + /* the account is now known and carries the key from the wire subject */ + hex = TDB_hex (&account_pub, + sizeof (account_pub)); + FAILIF_C (1 != TDB_count (pg, + "FROM kyc_targets" + " WHERE target_pub=decode('%s','hex')", + hex), + GNUNET_free (hex)); + GNUNET_free (hex); + FAILIF (1 != TDB_count (pg, + "FROM wire_targets")); + + /* the same transfer again: the primary key is (account, wire reference) */ + FAILIF (0 > + import_one (pg, + "ka-job-a", + "exchange-account-1", + PAYTO_A, + 10, + 1, + "10", + &account_pub)); + FAILIF (1 != TDB_count (pg, + "FROM kycauths_in")); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.account_pub = &account_pub; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_kycauth_in_above_serial_id_by_account ( + pg, + "exchange-account-1", + 0, + &kycauth_cb, + &ctx)); + FAILIF_C (1 != ctx.matched, + GNUNET_free (ctx.sender)); + FAILIF_C (0 != TALER_amount_cmp (&ctx.credit, + &expect), + GNUNET_free (ctx.sender)); + FAILIF_C (0 != strcmp (ctx.sender, + PAYTO_A), + GNUNET_free (ctx.sender)); + GNUNET_free (ctx.sender); + return 0; +} + + +/** + * With several transfers on file the by-account iterator filters by the + * exchange bank account and honours an aborting callback. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + union TALER_AccountPublicKeyP a2; + union TALER_AccountPublicKeyP a3; + struct AuthContext ctx; + + /* a second transfer from the same account, replacing the key */ + FAILIF (0 > + import_one (pg, + "ka-job-a", + "exchange-account-1", + PAYTO_A, + 11, + 2, + "2", + &a2)); + /* and one from a different account to a different exchange account */ + FAILIF (0 > + import_one (pg, + "ka-job-b", + "exchange-account-2", + PAYTO_B, + 12, + 3, + "5", + &a3)); + FAILIF (3 != TDB_count (pg, + "FROM kycauths_in")); + /* the second transfer from PAYTO_A took over its target_pub */ + { + char *hex = TDB_hex (&a2, + sizeof (a2)); + + FAILIF_C (1 != TDB_count (pg, + "FROM kyc_targets" + " WHERE target_pub=decode('%s','hex')", + hex), + GNUNET_free (hex)); + GNUNET_free (hex); + } + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.account_pub = &a3; + FAILIF (2 != + TALER_EXCHANGEDB_iterate_kycauth_in_above_serial_id_by_account ( + pg, + "exchange-account-1", + 0, + &kycauth_cb, + &ctx)); + FAILIF_C (0 != ctx.matched, + GNUNET_free (ctx.sender)); + GNUNET_free (ctx.sender); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.account_pub = &a3; + FAILIF (1 != + TALER_EXCHANGEDB_iterate_kycauth_in_above_serial_id_by_account ( + pg, + "exchange-account-2", + 0, + &kycauth_cb, + &ctx)); + FAILIF_C (1 != ctx.matched, + GNUNET_free (ctx.sender)); + FAILIF_C (0 != strcmp (ctx.sender, + PAYTO_B), + GNUNET_free (ctx.sender)); + GNUNET_free (ctx.sender); + + /* an exchange account nobody wired to */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_kycauth_in_above_serial_id_by_account ( + pg, + "exchange-account-nowhere", + 0, + &kycauth_cb, + &ctx)); + FAILIF (0 != ctx.total); + + /* the serial bound is inclusive, so a bound past the end finds nothing */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_kycauth_in_above_serial_id_by_account ( + pg, + "exchange-account-1", + 1000, + &kycauth_cb, + &ctx)); + FAILIF (0 != ctx.total); + + /* a callback that gives up stops the iteration without erroring out */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.stop_after = 1; + FAILIF (2 != + TALER_EXCHANGEDB_iterate_kycauth_in_above_serial_id_by_account ( + pg, + "exchange-account-1", + 0, + &kycauth_cb, + &ctx)); + FAILIF (1 != ctx.total); + GNUNET_free (ctx.sender); + return 0; +} + + +/** + * The AML view of the same rows filters by amount and by account, and can + * walk the table backwards. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_kycauth_transfers (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_NormalizedPayto npayto; + struct TALER_NormalizedPaytoHashP h_payto; + struct TALER_FullPayto payto_a = { + .full_payto = (char *) PAYTO_A + }; + struct TALER_Amount zero = TDB_amount ("0"); + struct TALER_Amount five = TDB_amount ("5"); + struct TALER_Amount huge = TDB_amount ("1000"); + struct TransferContext ctx; + uint64_t lowest; + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (3 != + TALER_EXCHANGEDB_iterate_exchange_kycauth_transfers (pg, + &zero, + 0, + 10, + NULL, + &transfer_cb, + &ctx)); + FAILIF (10 + 2 + 5 != ctx.value_sum); + lowest = ctx.first_row; + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (2 != + TALER_EXCHANGEDB_iterate_exchange_kycauth_transfers (pg, + &five, + 0, + 10, + NULL, + &transfer_cb, + &ctx)); + FAILIF (10 + 5 != ctx.value_sum); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_exchange_kycauth_transfers (pg, + &huge, + 0, + 10, + NULL, + &transfer_cb, + &ctx)); + FAILIF (0 != ctx.total); + + npayto = TALER_payto_normalize (payto_a); + GNUNET_assert (NULL != npayto.normalized_payto); + TALER_normalized_payto_hash (npayto, + &h_payto); + GNUNET_free (npayto.normalized_payto); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (2 != + TALER_EXCHANGEDB_iterate_exchange_kycauth_transfers (pg, + &zero, + 0, + 10, + &h_payto, + &transfer_cb, + &ctx)); + FAILIF (10 + 2 != ctx.value_sum); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (1 != + TALER_EXCHANGEDB_iterate_exchange_kycauth_transfers (pg, + &zero, + 0, + 1, + NULL, + &transfer_cb, + &ctx)); + FAILIF (lowest != ctx.first_row); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (3 != + TALER_EXCHANGEDB_iterate_exchange_kycauth_transfers (pg, + &zero, + 1000, + -10, + NULL, + &transfer_cb, + &ctx)); + FAILIF (lowest == ctx.first_row); + FAILIF (10 + 2 + 5 != ctx.value_sum); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "kycauths-in-empty", + &check_empty }, + { "kycauths-in-import", + &check_import }, + { "kycauths-in-iterate", + &check_iterate }, + { "kycauths-in-transfers", + &check_kycauth_transfers }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-kycauths-in", + "Tests for the exchangedb `kycauths_in' table", + tests); +} + + +/* end of test_kycauths_in.c */ diff --git a/src/exchangedb/test_legitimization_measures.c b/src/exchangedb/test_legitimization_measures.c @@ -0,0 +1,838 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_legitimization_measures.c + * @brief tests for the exchangedb functions whose primary table is + * `legitimization_measures` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_do_trigger_kyc_rule_for_account(), + * #TALER_EXCHANGEDB_insert_active_legitimization_measure(), + * #TALER_EXCHANGEDB_insert_successor_measure(), + * #TALER_EXCHANGEDB_get_pending_legitimization(), + * #TALER_EXCHANGEDB_get_active_legitimization(), + * #TALER_EXCHANGEDB_get_completed_legitimization(), + * #TALER_EXCHANGEDB_get_kyc_status_by_token(), + * #TALER_EXCHANGEDB_get_open_legitimization_measure(), + * #TALER_EXCHANGEDB_get_legitimization_requirement_by_row() and + * #TALER_EXCHANGEDB_iterate_aml_measures(). + * + * A row of `legitimization_measures` is one set of KYC measures the + * exchange put on an account. It is keyed by the account's access token, + * so `kyc_targets` has to exist first -- do_trigger_kyc_rule_for_account() + * creates it when it has to. Only one measure set per account is active + * at a time: triggering a different set finishes the previous one. + */ +#include "test_common.h" +#include "exchange-database/do_trigger_kyc_rule_for_account.h" +#include "exchange-database/get_active_legitimization.h" +#include "exchange-database/get_completed_legitimization.h" +#include "exchange-database/get_kyc_status_by_token.h" +#include "exchange-database/get_legitimization_requirement_by_row.h" +#include "exchange-database/get_open_legitimization_measure.h" +#include "exchange-database/get_pending_legitimization.h" +#include "exchange-database/insert_active_legitimization_measure.h" +#include "exchange-database/insert_legitimization_process.h" +#include "exchange-database/insert_successor_measure.h" +#include "exchange-database/iterate_aml_measures.h" + + +/** + * Account the checks put measures on. + */ +static struct TDB_Account account; + + +/** + * Row of the measure set that check_trigger() created. + */ +static uint64_t measure_row; + + +/** + * Key the account may use to authorise KYC work on itself. + */ +static union TALER_AccountPublicKeyP account_pub; + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Build a measure set naming @a check. + * + * @param check name of the check to demand + * @return the measure set, to be freed with json_decref() + */ +static json_t * +make_measures (const char *check) +{ + json_t *j; + + j = GNUNET_JSON_PACK ( + GNUNET_JSON_pack_array_steal ( + "measures", + json_pack ("[{s:s,s:s,s:s}]", + "check_name", + check, + "prog_name", + "skip", + "context", + "none")), + GNUNET_JSON_pack_bool ("is_and_combinator", + true)); + GNUNET_assert (NULL != j); + return j; +} + + +/** + * Read the access token the database generated for our account. + * + * @param pg the database context + * @param[out] access_token set to the account's access token + */ +static void +get_access_token (struct TALER_EXCHANGEDB_PostgresContext *pg, + struct TALER_AccountAccessTokenP *access_token) +{ + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_auto_from_type (&account.h_normalized), + GNUNET_PQ_query_param_end + }; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_auto_from_type ("access_token", + access_token), + GNUNET_PQ_result_spec_end + }; + + GNUNET_assert (GNUNET_OK == + GNUNET_PQ_prepare_anon (pg->conn, + "SELECT access_token" + " FROM kyc_targets" + " WHERE h_normalized_payto=$1;")); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "", + params, + rs)); +} + + +/** + * Closure for #measure_cb(). + */ +struct MeasureContext +{ + /** + * How many measures did the callback see? + */ + unsigned int total; + + /** + * Row we are looking for, 0 to match nothing. + */ + uint64_t measure_row; + + /** + * How many times did we see it? + */ + unsigned int matched; + + /** + * Was it reported as finished? + */ + bool is_finished; + + /** + * Account it was reported for. + */ + struct TALER_NormalizedPaytoHashP h_payto; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_aml_measures(). + * + * @param cls a `struct MeasureContext *` + * @param h_payto account the measure applies to + * @param start_time when the measure was imposed + * @param jmeasures the measures + * @param is_finished whether the measure was completed + * @param measure_serial_id row of the measure + */ +static void +measure_cb (void *cls, + struct TALER_NormalizedPaytoHashP *h_payto, + struct GNUNET_TIME_Absolute start_time, + const json_t *jmeasures, + bool is_finished, + uint64_t measure_serial_id) +{ + struct MeasureContext *ctx = cls; + + (void) start_time; + (void) jmeasures; + ctx->total++; + if ( (0 != ctx->measure_row) && + (measure_serial_id == ctx->measure_row) ) + { + ctx->matched++; + ctx->is_finished = is_finished; + ctx->h_payto = *h_payto; + } +} + + +/** + * Nothing is known while the table is empty. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_NormalizedPaytoHashP h_payto; + struct TALER_AccountAccessTokenP access_token; + struct GNUNET_TIME_Absolute start_time; + struct MeasureContext ctx = { 0 }; + json_t *jmeasures = NULL; + uint64_t row; + bool is_finished; + bool is_wallet; + + TDB_FILL (h_payto, + 1); + TDB_FILL (access_token, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_pending_legitimization (pg, + 1, + &access_token, + &h_payto, + &jmeasures, + &is_finished, + &is_wallet)); + FAILIF (NULL != jmeasures); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_kyc_status_by_token (pg, + &access_token, + &row, + &jmeasures)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_open_legitimization_measure (pg, + &h_payto, + &row, + &start_time)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_aml_measures (pg, + NULL, + TALER_EXCHANGE_YNA_ALL, + 0, + 10, + &measure_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * Triggering a rule creates the account and the measure set. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_trigger (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_NormalizedPayto npayto; + struct TALER_FullPayto payto; + struct TALER_NormalizedPaytoHashP h_payto; + struct TALER_AccountAccessTokenP access_token; + struct TALER_AccountAccessTokenP got_token; + struct TALER_NormalizedPaytoHashP got_payto; + json_t *jmeasures = make_measures ("verify-id"); + json_t *got = NULL; + uint64_t row = 0; + bool bad_kyc_auth = false; + bool is_finished = true; + bool is_wallet = true; + + payto.full_payto = (char *) + "payto://x-taler-bank/localhost/lm?receiver-name=LM"; + TALER_full_payto_hash (payto, + &account.h_full); + npayto = TALER_payto_normalize (payto); + GNUNET_assert (NULL != npayto.normalized_payto); + TALER_normalized_payto_hash (npayto, + &h_payto); + GNUNET_free (npayto.normalized_payto); + account.payto.full_payto = GNUNET_strdup (payto.full_payto); + account.h_normalized = h_payto; + + TDB_FILL (account_pub, + 42); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_trigger_kyc_rule_for_account ( + pg, + payto, + &h_payto, + &account_pub, + NULL, + jmeasures, + 1, + &row, + &bad_kyc_auth), + json_decref (jmeasures)); + FAILIF_C (0 == row, + json_decref (jmeasures)); + /* The account was not known, so there was no key to match against; the + function reports that even though it just set one. */ + FAILIF_C (! bad_kyc_auth, + json_decref (jmeasures)); + measure_row = row; + FAILIF_C (1 != TDB_count (pg, + "FROM legitimization_measures"), + json_decref (jmeasures)); + /* the account was created along the way */ + FAILIF_C (1 != TDB_count (pg, + "FROM kyc_targets"), + json_decref (jmeasures)); + FAILIF_C (1 != TDB_count (pg, + "FROM wire_targets"), + json_decref (jmeasures)); + + /* the measure set can be read back by its row... */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_pending_legitimization (pg, + row, + &got_token, + &got_payto, + &got, + &is_finished, + &is_wallet), + json_decref (jmeasures)); + FAILIF_C (0 != GNUNET_memcmp (&got_payto, + &h_payto), + json_decref (got); json_decref (jmeasures)); + FAILIF_C (is_finished, + json_decref (got); json_decref (jmeasures)); + FAILIF_C (is_wallet, + json_decref (got); json_decref (jmeasures)); + FAILIF_C (1 != json_equal (got, + jmeasures), + json_decref (got); json_decref (jmeasures)); + json_decref (got); + got = NULL; + + /* ...and by the account's access token */ + get_access_token (pg, + &access_token); + FAILIF_C (0 != GNUNET_memcmp (&got_token, + &access_token), + json_decref (jmeasures)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_kyc_status_by_token (pg, + &access_token, + &row, + &got), + json_decref (jmeasures)); + FAILIF_C (row != measure_row, + json_decref (got); json_decref (jmeasures)); + json_decref (got); + + /* triggering the very same measure set again keeps the one row */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_trigger_kyc_rule_for_account ( + pg, + payto, + &h_payto, + NULL, + NULL, + jmeasures, + 1, + &row, + &bad_kyc_auth), + json_decref (jmeasures)); + FAILIF_C (row != measure_row, + json_decref (jmeasures)); + FAILIF_C (1 != TDB_count (pg, + "FROM legitimization_measures"), + json_decref (jmeasures)); + json_decref (jmeasures); + return 0; +} + + +/** + * A different measure set supersedes the previous one, which is marked + * finished. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_supersede (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + json_t *jmeasures = make_measures ("verify-address"); + uint64_t row = 0; + bool bad_kyc_auth = false; + + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_trigger_kyc_rule_for_account ( + pg, + account.payto, + &account.h_normalized, + NULL, + NULL, + jmeasures, + 2, + &row, + &bad_kyc_auth), + json_decref (jmeasures)); + json_decref (jmeasures); + FAILIF (row == measure_row); + FAILIF (2 != TDB_count (pg, + "FROM legitimization_measures")); + FAILIF (1 != TDB_count (pg, + "FROM legitimization_measures" + " WHERE is_finished")); + measure_row = row; + return 0; +} + + +/** + * The account's rule requirement and its open measure are reported. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_requirement (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_AccountAccessTokenP access_token; + struct TALER_AccountAccessTokenP got_token; + struct GNUNET_TIME_Absolute start_time; + enum GNUNET_GenericReturnValue is_wallet; + json_t *jrules = NULL; + uint64_t rule_gen = 42; + uint64_t row = 0; + bool aml_review = true; + bool kyc_required = false; + + get_access_token (pg, + &access_token); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_legitimization_requirement_by_row ( + pg, + &account.h_normalized, + &account_pub, + &is_wallet, + &got_token, + &rule_gen, + &jrules, + &aml_review, + &kyc_required)); + FAILIF_C (0 != GNUNET_memcmp (&got_token, + &access_token), + json_decref (jrules)); + FAILIF_C (GNUNET_NO != is_wallet, + json_decref (jrules)); + /* no AML decision has been taken for the account */ + FAILIF_C (aml_review, + json_decref (jrules)); + FAILIF_C (0 != rule_gen, + json_decref (jrules)); + /* ...but a measure is pending, so KYC is required */ + FAILIF_C (! kyc_required, + json_decref (jrules)); + json_decref (jrules); + + /* the exchange's own account of why it is holding back */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_open_legitimization_measure ( + pg, + &account.h_normalized, + &row, + &start_time)); + /* the oldest undecided measure is the one from check_trigger() */ + FAILIF (0 == row); + FAILIF (GNUNET_TIME_absolute_is_zero (start_time)); + + /* an account nobody put a measure on has none */ + { + struct TALER_NormalizedPaytoHashP other; + + TDB_FILL (other, + 98); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_open_legitimization_measure (pg, + &other, + &row, + &start_time)); + } + return 0; +} + + +/** + * A legitimization process ties a measure set to the check it is + * satisfying. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_process (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_AccountAccessTokenP access_token; + struct TALER_NormalizedPaytoHashP h_payto; + json_t *jmeasures = NULL; + size_t attributes_len = 42; + void *attributes = NULL; + uint64_t process_row = 0; + uint32_t measure_index = 99; + bool is_finished = true; + bool is_wallet = true; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_legitimization_process ( + pg, + &account.h_normalized, + 0, + measure_row, + "test-provider", + "provider-account-1", + "provider-legi-1", + &process_row)); + FAILIF (0 == process_row); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_active_legitimization (pg, + process_row, + &measure_index, + &jmeasures)); + FAILIF_C (0 != measure_index, + json_decref (jmeasures)); + FAILIF_C (NULL == jmeasures, + json_decref (jmeasures)); + json_decref (jmeasures); + jmeasures = NULL; + + /* a process that does not exist has no active measure */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_active_legitimization (pg, + process_row + 1000, + &measure_index, + &jmeasures)); + + /* the completed view reaches the same measure through the process */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_completed_legitimization (pg, + measure_row, + 0, + &access_token, + &h_payto, + &is_wallet, + &jmeasures, + &is_finished, + &attributes_len, + &attributes)); + FAILIF_C (0 != GNUNET_memcmp (&h_payto, + &account.h_normalized), + json_decref (jmeasures); GNUNET_free (attributes)); + FAILIF_C (is_finished, + json_decref (jmeasures); GNUNET_free (attributes)); + /* no attributes have been collected yet */ + FAILIF_C (NULL != attributes, + json_decref (jmeasures); GNUNET_free (attributes)); + json_decref (jmeasures); + jmeasures = NULL; + + /* a measure index no process is working on is not found */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_completed_legitimization (pg, + measure_row, + 7, + &access_token, + &h_payto, + &is_wallet, + &jmeasures, + &is_finished, + &attributes_len, + &attributes)); + return 0; +} + + +/** + * A measure can also be created directly against an access token. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insert_active (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_AccountAccessTokenP access_token; + json_t *jmeasures = make_measures ("verify-phone"); + uint64_t row = 0; + + get_access_token (pg, + &access_token); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_active_legitimization_measure ( + pg, + &access_token, + jmeasures, + &row), + json_decref (jmeasures)); + json_decref (jmeasures); + FAILIF (0 == row); + FAILIF (3 != TDB_count (pg, + "FROM legitimization_measures")); + /* it becomes the one active measure for the account */ + FAILIF (1 != TDB_count (pg, + "FROM legitimization_measures" + " WHERE NOT is_finished")); + measure_row = row; + + return 0; +} + + +/** + * A successor measure replaces what is active for the account. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_successor (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct GNUNET_TIME_Timestamp last_date; + json_t *jmeasures = make_measures ("verify-income"); + bool unknown_account = true; + + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_successor_measure ( + pg, + &account.h_normalized, + ts (1600000000), + "verify-income", + jmeasures, + &unknown_account, + &last_date), + json_decref (jmeasures)); + FAILIF_C (unknown_account, + json_decref (jmeasures)); + FAILIF_C (4 != TDB_count (pg, + "FROM legitimization_measures"), + json_decref (jmeasures)); + + /* an older decision is ignored */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_successor_measure ( + pg, + &account.h_normalized, + ts (1500000000), + "verify-income", + jmeasures, + &unknown_account, + &last_date), + json_decref (jmeasures)); + FAILIF_C (GNUNET_TIME_timestamp_cmp (last_date, + !=, + ts (1600000000)), + json_decref (jmeasures)); + FAILIF_C (4 != TDB_count (pg, + "FROM legitimization_measures"), + json_decref (jmeasures)); + + /* an account the exchange never saw is reported as unknown */ + { + struct TALER_NormalizedPaytoHashP other; + + TDB_FILL (other, + 95); + unknown_account = false; + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_successor_measure ( + pg, + &other, + ts (1600000000), + "verify-income", + jmeasures, + &unknown_account, + &last_date), + json_decref (jmeasures)); + FAILIF_C (! unknown_account, + json_decref (jmeasures)); + } + json_decref (jmeasures); + return 0; +} + + +/** + * The measure listing reports the account's measures, filtered. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct MeasureContext ctx; + unsigned int all; + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_aml_measures (pg, + &account.h_normalized, + TALER_EXCHANGE_YNA_ALL, + 0, + 100, + &measure_cb, + &ctx)); + all = ctx.total; + FAILIF (0 == all); + + /* the account filter matters */ + { + struct TALER_NormalizedPaytoHashP other; + + TDB_FILL (other, + 94); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_aml_measures (pg, + &other, + TALER_EXCHANGE_YNA_ALL, + 0, + 100, + &measure_cb, + &ctx)); + FAILIF (0 != ctx.total); + } + + /* only one measure is still open */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_aml_measures (pg, + &account.h_normalized, + TALER_EXCHANGE_YNA_YES, + 0, + 100, + &measure_cb, + &ctx)); + FAILIF (1 != ctx.total); + + /* the finished ones make up the rest */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_aml_measures (pg, + &account.h_normalized, + TALER_EXCHANGE_YNA_NO, + 0, + 100, + &measure_cb, + &ctx)); + FAILIF (all - 1 != ctx.total); + + /* the limit caps the result set */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (1 != + TALER_EXCHANGEDB_iterate_aml_measures (pg, + NULL, + TALER_EXCHANGE_YNA_ALL, + 0, + 1, + &measure_cb, + &ctx)); + FAILIF (1 != ctx.total); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "legitimization-measures-empty", + &check_empty }, + { "legitimization-measures-trigger", + &check_trigger }, + { "legitimization-measures-supersede", + &check_supersede }, + { "legitimization-measures-requirement", + &check_requirement }, + { "legitimization-measures-process", + &check_process }, + { "legitimization-measures-insert-active", + &check_insert_active }, + { "legitimization-measures-successor", + &check_successor }, + { "legitimization-measures-iterate", + &check_iterate }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-legitimization-measures", + "Tests for the exchangedb `legitimization_measures' table", + tests); + TDB_account_free (&account); + return ret; +} + + +/* end of test_legitimization_measures.c */ diff --git a/src/exchangedb/test_legitimization_outcomes.c b/src/exchangedb/test_legitimization_outcomes.c @@ -0,0 +1,542 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_legitimization_outcomes.c + * @brief tests for the exchangedb functions whose primary table is + * `legitimization_outcomes` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_get_kyc_rules(), + * #TALER_EXCHANGEDB_get_kyc_rules_with_account(), + * #TALER_EXCHANGEDB_get_rules_by_access_token(), + * #TALER_EXCHANGEDB_insert_sanction_list_hit(), + * #TALER_EXCHANGEDB_do_persist_aml_program_result(), + * #TALER_EXCHANGEDB_current_rule_builder(), + * #TALER_EXCHANGEDB_iterate_legitimization_outcomes_above_serial_id() and, + * as far as a synchronous test can, #TALER_EXCHANGEDB_begin_rule_update() + * and #TALER_EXCHANGEDB_begin_rule_update_cancel(). + * + * A row of `legitimization_outcomes` is one decision about an account: the + * rule set that applies to it from then on. Only one row per account is + * active at a time; writing a new one deactivates the old. + * + * begin_rule_update() answers through the scheduler, so a check that runs + * inside one scheduler task cannot wait for it. What is checked here is + * that it starts and that cancelling it releases everything. + */ +#include "test_common.h" +#include "exchange-database/account_history.h" +#include "exchange-database/begin_rule_update.h" +#include "exchange-database/do_persist_aml_program_result.h" +#include "exchange-database/do_trigger_kyc_rule_for_account.h" +#include "exchange-database/get_kyc_rules.h" +#include "exchange-database/get_rules_by_access_token.h" +#include "exchange-database/insert_sanction_list_hit.h" +#include "exchange-database/iterate_legitimization_outcomes_above_serial_id.h" + + +/** + * Account the checks decide about. + */ +static struct TDB_Account account; + + +/** + * Build a rule set naming @a name as its only rule. + * + * @param name name of the rule + * @return the rule set, to be freed with json_decref() + */ +static json_t * +make_rules (const char *name) +{ + json_t *j; + + j = GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("rule_name", + name), + GNUNET_JSON_pack_array_steal ("rules", + json_array ())); + GNUNET_assert (NULL != j); + return j; +} + + +/** + * Closure for #outcome_cb(). + */ +struct OutcomeContext +{ + /** + * How many outcomes did the callback see? + */ + unsigned int total; + + /** + * Stop after this many rows; 0 for no limit. + */ + unsigned int stop_after; + + /** + * Account we are looking for, NULL to match nothing. + */ + const struct TALER_NormalizedPaytoHashP *h_payto; + + /** + * How many times did we see it? + */ + unsigned int matched; + + /** + * Whether the last matching outcome had an AML officer behind it. + */ + bool has_aml_decision; + + /** + * Whether it had a legitimization process behind it. + */ + bool has_legitimization_process; + + /** + * Whether an earlier outcome had expired by then. + */ + bool has_expired_predecessor; +}; + + +/** + * Callback for + * #TALER_EXCHANGEDB_iterate_legitimization_outcomes_above_serial_id(). + * + * @param cls a `struct OutcomeContext *` + * @param rowid row of the outcome + * @param h_payto account the outcome is about + * @param decision_time when it was decided + * @param expiration_time when it expires + * @param has_aml_decision whether an AML officer signed it + * @param has_legitimization_process whether a KYC process preceded it + * @param has_expired_predecessor whether it succeeded an expired outcome + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +outcome_cb (void *cls, + uint64_t rowid, + const struct TALER_NormalizedPaytoHashP *h_payto, + struct GNUNET_TIME_Timestamp decision_time, + struct GNUNET_TIME_Timestamp expiration_time, + bool has_aml_decision, + bool has_legitimization_process, + bool has_expired_predecessor) +{ + struct OutcomeContext *ctx = cls; + + (void) rowid; + (void) decision_time; + (void) expiration_time; + ctx->total++; + if ( (NULL != ctx->h_payto) && + (0 == GNUNET_memcmp (h_payto, + ctx->h_payto)) ) + { + ctx->matched++; + ctx->has_aml_decision = has_aml_decision; + ctx->has_legitimization_process = has_legitimization_process; + ctx->has_expired_predecessor = has_expired_predecessor; + } + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * Callback for #TALER_EXCHANGEDB_begin_rule_update(), which a + * synchronous check never gets to see. + * + * @param cls closure + * @param rur the rule set that was worked out + */ +static void +rules_cb (void *cls, + struct TALER_EXCHANGEDB_RuleUpdaterResult *rur) +{ + (void) cls; + (void) rur; + GNUNET_break (0); +} + + +/** + * An account nobody decided about has no custom rules. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_NormalizedPaytoHashP h_payto; + struct OutcomeContext ctx = { 0 }; + json_t *jrules = NULL; + uint64_t rowid; + + TDB_account (pg, + 10, + &account); + TDB_FILL (h_payto, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_kyc_rules (pg, + &h_payto, + &jrules)); + FAILIF (NULL != jrules); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_rules_by_access_token (pg, + &h_payto, + &jrules, + &rowid)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_legitimization_outcomes_above_serial_id ( + pg, + 0, + &outcome_cb, + &ctx)); + FAILIF (0 != ctx.total); + /* an account that exists but has no decision has none either */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_rules_by_access_token (pg, + &account.h_normalized, + &jrules, + &rowid)); + return 0; +} + + +/** + * An AML program result is persisted as an outcome, and the rules it set + * become the account's active rules. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_persist (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_KYCLOGIC_AmlProgramResult apr; + enum TALER_EXCHANGEDB_PersistProgramResultStatus pprs; + json_t *new_rules = make_rules ("strict"); + json_t *properties = GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("FILE_NOTE", + "checked by hand")); + json_t *got = NULL; + uint64_t rowid = 0; + + GNUNET_assert (NULL != properties); + memset (&apr, + 0, + sizeof (apr)); + apr.status = TALER_KYCLOGIC_AMLR_SUCCESS; + apr.details.success.account_properties = properties; + apr.details.success.new_rules = new_rules; + apr.details.success.expiration_time + = GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS); + apr.details.success.to_investigate = true; + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_persist_aml_program_result ( + pg, + 0, + &account.h_normalized, + &apr, + &pprs), + json_decref (new_rules); json_decref (properties)); + FAILIF_C (TALER_EXCHANGEDB_PPRS_OK != pprs, + json_decref (new_rules); json_decref (properties)); + FAILIF_C (1 != TDB_count (pg, + "FROM legitimization_outcomes"), + json_decref (new_rules); json_decref (properties)); + + /* the rules are now the account's active rules */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_kyc_rules (pg, + &account.h_normalized, + &got), + json_decref (new_rules); json_decref (properties)); + FAILIF_C (1 != json_equal (got, + new_rules), + json_decref (got); + json_decref (new_rules); json_decref (properties)); + json_decref (got); + got = NULL; + + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_rules_by_access_token ( + pg, + &account.h_normalized, + &got, + &rowid), + json_decref (new_rules); json_decref (properties)); + FAILIF_C (0 == rowid, + json_decref (got); + json_decref (new_rules); json_decref (properties)); + json_decref (got); + json_decref (new_rules); + json_decref (properties); + return 0; +} + + +/** + * The account-aware rule lookup also reports the keys that may act for + * the account. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_rules_with_account (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + union TALER_AccountPublicKeyP account_pub; + struct TALER_ReservePublicKeyP reserve_pub; + json_t *jrules = NULL; + bool no_account_pub = false; + bool no_reserve_pub = false; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_kyc_rules_with_account (pg, + &account.h_normalized, + NULL, + &no_account_pub, + &account_pub, + &no_reserve_pub, + &reserve_pub, + &jrules)); + FAILIF_C (NULL == jrules, + json_decref (jrules)); + json_decref (jrules); + /* nobody ever registered a key for this account */ + FAILIF (! no_account_pub); + FAILIF (! no_reserve_pub); + return 0; +} + + +/** + * A sanction list hit replaces the account's rules and flags it. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_sanction_hit (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + json_t *new_rules = make_rules ("frozen"); + json_t *properties = GNUNET_JSON_PACK ( + GNUNET_JSON_pack_bool ("HIGH_RISK_CUSTOMER", + true)); + json_t *got = NULL; + const char *events[] = { "sanction-hit" }; + + GNUNET_assert (NULL != properties); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_sanction_list_hit ( + pg, + &account.h_normalized, + true, + new_rules, + properties, + 1, + events), + json_decref (new_rules); json_decref (properties)); + /* the previous outcome was deactivated, not deleted */ + FAILIF_C (2 != TDB_count (pg, + "FROM legitimization_outcomes"), + json_decref (new_rules); json_decref (properties)); + FAILIF_C (1 != TDB_count (pg, + "FROM legitimization_outcomes" + " WHERE is_active"), + json_decref (new_rules); json_decref (properties)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_kyc_rules (pg, + &account.h_normalized, + &got), + json_decref (new_rules); json_decref (properties)); + FAILIF_C (1 != json_equal (got, + new_rules), + json_decref (got); + json_decref (new_rules); json_decref (properties)); + json_decref (got); + json_decref (new_rules); + json_decref (properties); + /* the events the hit asked for were recorded */ + FAILIF (1 != TDB_count (pg, + "FROM kyc_events" + " WHERE event_type='sanction-hit'")); + return 0; +} + + +/** + * The rule builder renders the account's active rules as JSON. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_rule_builder (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_AttributeEncryptionKeyP attribute_key; + struct TALER_EXCHANGEDB_HistoryBuilderContext hbc = { + .account = &account.h_normalized, + .pg = pg, + .attribute_key = &attribute_key, + .is_wallet = false + }; + json_t *j; + + TDB_FILL (attribute_key, + 1); + j = TALER_EXCHANGEDB_current_rule_builder (&hbc); + FAILIF (NULL == j); + json_decref (j); + return 0; +} + + +/** + * The outcome listing reports the decisions with what could account for + * them. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct OutcomeContext ctx; + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.h_payto = &account.h_normalized; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_legitimization_outcomes_above_serial_id ( + pg, + 0, + &outcome_cb, + &ctx)); + FAILIF (2 != ctx.total); + FAILIF (2 != ctx.matched); + /* no AML officer signed either decision */ + FAILIF (ctx.has_aml_decision); + /* and there was no KYC process behind them */ + FAILIF (ctx.has_legitimization_process); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_legitimization_outcomes_above_serial_id ( + pg, + 1000, + &outcome_cb, + &ctx)); + FAILIF (0 != ctx.total); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.stop_after = 1; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_legitimization_outcomes_above_serial_id ( + pg, + 0, + &outcome_cb, + &ctx)); + FAILIF (1 != ctx.total); + return 0; +} + + +/** + * Starting a rule update and cancelling it releases everything. + * + * The update answers through the scheduler, so a check that itself runs + * inside a scheduler task cannot wait for the answer; what is checked is + * that the handle is created and that cancelling it is clean. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_rule_update (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_AttributeEncryptionKeyP attribute_key; + struct TALER_EXCHANGEDB_RuleUpdater *ru; + + TDB_FILL (attribute_key, + 1); + ru = TALER_EXCHANGEDB_begin_rule_update (pg, + &attribute_key, + &account.h_normalized, + false, + &rules_cb, + NULL); + FAILIF (NULL == ru); + TALER_EXCHANGEDB_begin_rule_update_cancel (ru); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "legitimization-outcomes-empty", + &check_empty }, + { "legitimization-outcomes-persist", + &check_persist }, + { "legitimization-outcomes-rules-with-account", + &check_rules_with_account }, + { "legitimization-outcomes-sanction-hit", + &check_sanction_hit }, + { "legitimization-outcomes-rule-builder", + &check_rule_builder }, + { "legitimization-outcomes-iterate", + &check_iterate }, + { "legitimization-outcomes-rule-update", + &check_rule_update }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-legitimization-outcomes", + "Tests for the exchangedb `legitimization_outcomes' table", + tests); + TDB_account_free (&account); + return ret; +} + + +/* end of test_legitimization_outcomes.c */ diff --git a/src/exchangedb/test_legitimization_processes.c b/src/exchangedb/test_legitimization_processes.c @@ -0,0 +1,612 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_legitimization_processes.c + * @brief tests for the exchangedb functions whose primary table is + * `legitimization_processes` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_legitimization_process(), + * #TALER_EXCHANGEDB_update_legitimization_process_by_row(), + * #TALER_EXCHANGEDB_insert_kyc_failure(), + * #TALER_EXCHANGEDB_insert_aml_program_failure(), + * #TALER_EXCHANGEDB_get_legitimization_process_by_account(), + * #TALER_EXCHANGEDB_get_pending_legitimization_process(), + * #TALER_EXCHANGEDB_get_kyc_provider_account() and + * #TALER_EXCHANGEDB_iterate_kyc_references(). + * + * A row of `legitimization_processes` is one run of one KYC provider + * against one account, working on one measure of a + * `legitimization_measures` set. The checks therefore create an account + * and a measure set first, and then walk a process from "started" through + * "redirect known" to "finished". + */ +#include "test_common.h" +#include "exchange-database/do_trigger_kyc_rule_for_account.h" +#include "exchange-database/get_kyc_provider_account.h" +#include "exchange-database/get_legitimization_process_by_account.h" +#include "exchange-database/get_pending_legitimization_process.h" +#include "exchange-database/insert_aml_program_failure.h" +#include "exchange-database/insert_kyc_failure.h" +#include "exchange-database/insert_legitimization_process.h" +#include "exchange-database/iterate_kyc_references.h" +#include "exchange-database/update_legitimization_process_by_row.h" + + +/** + * Account the checks run processes against. + */ +static struct TDB_Account account; + + +/** + * Measure set the processes work on. + */ +static uint64_t measure_row; + + +/** + * Row of the process check_start() created. + */ +static uint64_t process_row; + + +/** + * Closure for #reference_cb(). + */ +struct ReferenceContext +{ + /** + * How many references did the callback see? + */ + unsigned int total; + + /** + * Provider we are looking for, NULL to match nothing. + */ + const char *provider; + + /** + * How many times did we see it? + */ + unsigned int matched; + + /** + * Provider account id reported for it, owned by this struct. + */ + char *user_id; + + /** + * Provider legitimization id reported for it, owned by this struct. + */ + char *legi_id; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_kyc_references(). + * + * @param cls a `struct ReferenceContext *` + * @param kyc_provider_name provider that ran the process + * @param provider_user_id account id at the provider + * @param legi_id legitimization id at the provider + */ +static void +reference_cb (void *cls, + const char *kyc_provider_name, + const char *provider_user_id, + const char *legi_id) +{ + struct ReferenceContext *ctx = cls; + + ctx->total++; + if ( (NULL != ctx->provider) && + (0 == strcmp (kyc_provider_name, + ctx->provider)) ) + { + ctx->matched++; + GNUNET_free (ctx->user_id); + GNUNET_free (ctx->legi_id); + ctx->user_id = (NULL == provider_user_id) + ? NULL + : GNUNET_strdup (provider_user_id); + ctx->legi_id = (NULL == legi_id) + ? NULL + : GNUNET_strdup (legi_id); + } +} + + +/** + * Put a measure on our account, creating the account on the way. + * + * @param pg the database context + */ +static void +setup_account (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + json_t *jmeasures; + uint64_t row = 0; + bool bad_kyc_auth; + + if (0 != measure_row) + return; + TDB_account (pg, + 10, + &account); + jmeasures = GNUNET_JSON_PACK ( + GNUNET_JSON_pack_array_steal ( + "measures", + json_pack ("[{s:s,s:s,s:s}]", + "check_name", + "verify-id", + "prog_name", + "skip", + "context", + "none")), + GNUNET_JSON_pack_bool ("is_and_combinator", + true)); + GNUNET_assert (NULL != jmeasures); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_EXCHANGEDB_do_trigger_kyc_rule_for_account ( + pg, + account.payto, + &account.h_normalized, + NULL, + NULL, + jmeasures, + 1, + &row, + &bad_kyc_auth)); + json_decref (jmeasures); + GNUNET_assert (0 != row); + measure_row = row; +} + + +/** + * Nothing is known while the table is empty. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_NormalizedPaytoHashP h_payto; + struct GNUNET_TIME_Absolute expiration; + struct ReferenceContext ctx = { 0 }; + char *provider_account_id = NULL; + char *provider_legitimization_id = NULL; + char *redirect_url = NULL; + uint64_t row; + bool is_wallet; + + setup_account (pg); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_legitimization_process_by_account ( + pg, + "test-provider", + &account.h_normalized, + &row, + &expiration, + &provider_account_id, + &provider_legitimization_id, + &is_wallet)); + FAILIF (NULL != provider_account_id); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_pending_legitimization_process ( + pg, + &account.h_normalized, + "test-provider", + &redirect_url)); + FAILIF (NULL != redirect_url); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_kyc_provider_account (pg, + "test-provider", + "legi-1", + &h_payto, + &is_wallet, + &row)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_kyc_references (pg, + &account.h_normalized, + &reference_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * Starting a process records it and makes it findable by provider and by + * the provider's own identifiers. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_start (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_NormalizedPaytoHashP h_payto; + struct GNUNET_TIME_Absolute expiration; + struct ReferenceContext ctx; + char *provider_account_id = NULL; + char *provider_legitimization_id = NULL; + uint64_t row = 0; + bool is_wallet = true; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_legitimization_process ( + pg, + &account.h_normalized, + 0, + measure_row, + "test-provider", + "provider-account-1", + "legi-1", + &process_row)); + FAILIF (0 == process_row); + FAILIF (1 != TDB_count (pg, + "FROM legitimization_processes")); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_legitimization_process_by_account ( + pg, + "test-provider", + &account.h_normalized, + &row, + &expiration, + &provider_account_id, + &provider_legitimization_id, + &is_wallet)); + FAILIF (row != process_row); + FAILIF_C (0 != strcmp (provider_account_id, + "provider-account-1"), + GNUNET_free (provider_account_id); + GNUNET_free (provider_legitimization_id)); + FAILIF_C (0 != strcmp (provider_legitimization_id, + "legi-1"), + GNUNET_free (provider_account_id); + GNUNET_free (provider_legitimization_id)); + GNUNET_free (provider_account_id); + GNUNET_free (provider_legitimization_id); + FAILIF (is_wallet); + + /* another provider has no process on this account */ + provider_account_id = NULL; + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_legitimization_process_by_account ( + pg, + "other-provider", + &account.h_normalized, + &row, + &expiration, + &provider_account_id, + &provider_legitimization_id, + &is_wallet)); + + /* the provider's own identifier resolves back to the account */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_kyc_provider_account (pg, + "test-provider", + "legi-1", + &h_payto, + &is_wallet, + &row)); + FAILIF (0 != GNUNET_memcmp (&h_payto, + &account.h_normalized)); + FAILIF (row != process_row); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_kyc_provider_account (pg, + "test-provider", + "legi-does-not-exist", + &h_payto, + &is_wallet, + &row)); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.provider = "test-provider"; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_kyc_references (pg, + &account.h_normalized, + &reference_cb, + &ctx)); + FAILIF_C (1 != ctx.matched, + GNUNET_free (ctx.user_id); GNUNET_free (ctx.legi_id)); + FAILIF_C (0 != strcmp (ctx.user_id, + "provider-account-1"), + GNUNET_free (ctx.user_id); GNUNET_free (ctx.legi_id)); + GNUNET_free (ctx.user_id); + GNUNET_free (ctx.legi_id); + return 0; +} + + +/** + * Updating a process records the redirect URL and can mark it finished. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_update (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct GNUNET_TIME_Absolute expiration + = GNUNET_TIME_relative_to_absolute (GNUNET_TIME_UNIT_HOURS); + char *redirect_url = NULL; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_legitimization_process_by_row ( + pg, + process_row, + "test-provider", + &account.h_normalized, + "provider-account-1", + "legi-1", + "https://kyc.example/start", + expiration, + TALER_EC_NONE, + NULL, + false)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_pending_legitimization_process ( + pg, + &account.h_normalized, + "test-provider", + &redirect_url)); + FAILIF (NULL == redirect_url); + FAILIF_C (0 != strcmp (redirect_url, + "https://kyc.example/start"), + GNUNET_free (redirect_url)); + GNUNET_free (redirect_url); + + /* a row that does not exist is not created */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_update_legitimization_process_by_row ( + pg, + process_row + 1000, + "test-provider", + &account.h_normalized, + "provider-account-1", + "legi-1", + "https://kyc.example/start", + expiration, + TALER_EC_NONE, + NULL, + false)); + FAILIF (1 != TDB_count (pg, + "FROM legitimization_processes")); + + /* marking it finished takes it out of the pending view */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_legitimization_process_by_row ( + pg, + process_row, + "test-provider", + &account.h_normalized, + "provider-account-1", + "legi-1", + "https://kyc.example/start", + expiration, + TALER_EC_NONE, + NULL, + true)); + redirect_url = NULL; + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_pending_legitimization_process ( + pg, + &account.h_normalized, + "test-provider", + &redirect_url)); + FAILIF (NULL != redirect_url); + return 0; +} + + +/** + * A failed KYC check marks the process finished with an error. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_kyc_failure (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + uint64_t row = 0; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_legitimization_process ( + pg, + &account.h_normalized, + 1, + measure_row, + "failing-provider", + "provider-account-2", + "legi-2", + &row)); + FAILIF (2 != TDB_count (pg, + "FROM legitimization_processes")); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_kyc_failure ( + pg, + row, + &account.h_normalized, + "failing-provider", + "provider-account-2", + "legi-2", + "the provider said no", + TALER_EC_EXCHANGE_KYC_GENERIC_PROVIDER_UNEXPECTED_REPLY)); + FAILIF (1 != TDB_count (pg, + "FROM legitimization_processes" + " WHERE finished" + " AND error_code=%u", + (unsigned int) + TALER_EC_EXCHANGE_KYC_GENERIC_PROVIDER_UNEXPECTED_REPLY)); + /* a row that does not exist is not created */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_insert_kyc_failure ( + pg, + row + 1000, + &account.h_normalized, + "failing-provider", + "provider-account-2", + "legi-2", + "the provider said no", + TALER_EC_EXCHANGE_KYC_GENERIC_PROVIDER_UNEXPECTED_REPLY)); + FAILIF (2 != TDB_count (pg, + "FROM legitimization_processes")); + return 0; +} + + +/** + * A failed AML program marks its process finished with an error too. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_aml_program_failure (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + uint64_t row = 0; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_legitimization_process ( + pg, + &account.h_normalized, + 2, + measure_row, + "aml-provider", + "provider-account-3", + "legi-3", + &row)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_aml_program_failure ( + pg, + row, + &account.h_normalized, + "the AML program crashed", + TALER_EC_EXCHANGE_KYC_AML_PROGRAM_FAILURE)); + FAILIF (1 != TDB_count (pg, + "FROM legitimization_processes" + " WHERE finished" + " AND error_code=%u", + (unsigned int) + TALER_EC_EXCHANGE_KYC_AML_PROGRAM_FAILURE)); + /* Unlike insert_kyc_failure(), a row that does not exist is *created* + here rather than ignored -- the stored procedure would rather keep a + record of the failed AML program than lose it. */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_aml_program_failure ( + pg, + row + 1000, + &account.h_normalized, + "the AML program crashed", + TALER_EC_EXCHANGE_KYC_AML_PROGRAM_FAILURE)); + FAILIF (4 != TDB_count (pg, + "FROM legitimization_processes")); + return 0; +} + + +/** + * The reference listing reports every provider that worked on the + * account. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_references (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct ReferenceContext ctx; + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.provider = "aml-provider"; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_kyc_references (pg, + &account.h_normalized, + &reference_cb, + &ctx)); + FAILIF_C (1 != ctx.matched, + GNUNET_free (ctx.user_id); GNUNET_free (ctx.legi_id)); + FAILIF_C (4 != ctx.total, + GNUNET_free (ctx.user_id); GNUNET_free (ctx.legi_id)); + GNUNET_free (ctx.user_id); + GNUNET_free (ctx.legi_id); + + /* an account nobody checked has no references */ + { + struct TALER_NormalizedPaytoHashP other; + + TDB_FILL (other, + 98); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_kyc_references (pg, + &other, + &reference_cb, + &ctx)); + FAILIF (0 != ctx.total); + } + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "legitimization-processes-empty", + &check_empty }, + { "legitimization-processes-start", + &check_start }, + { "legitimization-processes-update", + &check_update }, + { "legitimization-processes-kyc-failure", + &check_kyc_failure }, + { "legitimization-processes-aml-program-failure", + &check_aml_program_failure }, + { "legitimization-processes-references", + &check_references }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-legitimization-processes", + "Tests for the exchangedb `legitimization_processes' table", + tests); + TDB_account_free (&account); + return ret; +} + + +/* end of test_legitimization_processes.c */ diff --git a/src/exchangedb/test_misc.c b/src/exchangedb/test_misc.c @@ -0,0 +1,656 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_misc.c + * @brief tests for the exchangedb functions that have no table of their own + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_start(), #TALER_EXCHANGEDB_start_read_only(), + * #TALER_EXCHANGEDB_start_read_committed(), + * #TALER_EXCHANGEDB_start_deferred_wire_out(), #TALER_EXCHANGEDB_commit(), + * #TALER_EXCHANGEDB_rollback(), #TALER_EXCHANGEDB_preflight(), + * #TALER_EXCHANGEDB_compute_shard(), + * #TALER_EXCHANGEDB_calculate_transaction_list_totals(), + * #TALER_EXCHANGEDB_free_coin_transaction_list(), + * #TALER_EXCHANGEDB_free_reserve_history(), + * #TALER_EXCHANGEDB_event_listen(), + * #TALER_EXCHANGEDB_event_listen_cancel(), + * #TALER_EXCHANGEDB_event_notify(), #TALER_EXCHANGEDB_enable_rules(), + * #TALER_EXCHANGEDB_disable_rules(), + * #TALER_EXCHANGEDB_inject_auditor_triggers(), + * #TALER_EXCHANGEDB_drop_tables() and #TALER_EXCHANGEDB_create_tables(). + * + * These are the connection-level functions: transactions, notifications, + * schema management and the two list destructors. The schema checks come + * last on purpose -- they take the tables away and put them back, so + * nothing may run after them. + */ +#include "test_common.h" +#include "taler/taler_dbevents.h" +#include "exchange-database/commit.h" +#include "exchange-database/compute_shard.h" +#include "exchange-database/create_tables.h" +#include "exchange-database/disable_rules.h" +#include "exchange-database/drop_tables.h" +#include "exchange-database/enable_rules.h" +#include "exchange-database/event_listen.h" +#include "exchange-database/event_listen_cancel.h" +#include "exchange-database/event_notify.h" +#include "exchange-database/free_coin_transaction_list.h" +#include "exchange-database/free_reserve_history.h" +#include "exchange-database/get_coin_transactions.h" +#include "exchange-database/get_reserve_history.h" +#include "exchange-database/inject_auditor_triggers.h" +#include "exchange-database/preflight.h" +#include "exchange-database/rollback.h" +#include "exchange-database/start.h" +#include "exchange-database/start_deferred_wire_out.h" +#include "exchange-database/start_read_committed.h" +#include "exchange-database/start_read_only.h" + + +/** + * Account the checks fund their reserves from. + */ +static struct TDB_Account account; + + +/** + * Denomination the checks use. + */ +static struct TDB_Denom denom; + + +/** + * The four ways to start a transaction. + * + * @param pg the database context + * @param name name of the transaction + * @return #GNUNET_OK on success + */ +static enum GNUNET_GenericReturnValue +start_serializable (struct TALER_EXCHANGEDB_PostgresContext *pg, + const char *name) +{ + return TALER_EXCHANGEDB_start (pg, + name); +} + + +/** + * Start a read-only transaction. + * + * @param pg the database context + * @param name name of the transaction + * @return #GNUNET_OK on success + */ +static enum GNUNET_GenericReturnValue +start_read_only (struct TALER_EXCHANGEDB_PostgresContext *pg, + const char *name) +{ + return TALER_EXCHANGEDB_start_read_only (pg, + name); +} + + +/** + * Start a read-committed transaction. + * + * @param pg the database context + * @param name name of the transaction + * @return #GNUNET_OK on success + */ +static enum GNUNET_GenericReturnValue +start_read_committed (struct TALER_EXCHANGEDB_PostgresContext *pg, + const char *name) +{ + return TALER_EXCHANGEDB_start_read_committed (pg, + name); +} + + +/** + * Start a transaction with deferred wire-out constraints. + * + * @param pg the database context + * @param name name of the transaction + * @return #GNUNET_OK on success + */ +static enum GNUNET_GenericReturnValue +start_deferred (struct TALER_EXCHANGEDB_PostgresContext *pg, + const char *name) +{ + (void) name; + return TALER_EXCHANGEDB_start_deferred_wire_out (pg); +} + + +/** + * All four transaction starters behave the same way as far as the + * connection's bookkeeping is concerned. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_transactions (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + static enum GNUNET_GenericReturnValue (*const starters[])( + struct TALER_EXCHANGEDB_PostgresContext *, + const char *) = { + &start_serializable, + &start_read_only, + &start_read_committed, + &start_deferred + }; + + FAILIF (NULL != pg->transaction_name); + for (unsigned int i = 0; i < 4; i++) + { + /* commit ends the transaction ... */ + FAILIF (GNUNET_OK != + starters[i](pg, + "misc-commit")); + FAILIF (NULL == pg->transaction_name); + FAILIF (0 > TALER_EXCHANGEDB_commit (pg)); + FAILIF (NULL != pg->transaction_name); + + /* ... and so does a rollback */ + FAILIF (GNUNET_OK != + starters[i](pg, + "misc-rollback")); + FAILIF (NULL == pg->transaction_name); + TALER_EXCHANGEDB_rollback (pg); + FAILIF (NULL != pg->transaction_name); + } + + /* rolling back when nothing is open is a no-op */ + TALER_EXCHANGEDB_rollback (pg); + FAILIF (NULL != pg->transaction_name); + return 0; +} + + +/** + * A transaction really isolates: what it wrote is gone after a rollback + * and there after a commit. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_isolation (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + FAILIF (GNUNET_OK != + TALER_EXCHANGEDB_start (pg, + "misc-isolation")); + TDB_account (pg, + 10, + &account); + FAILIF (1 != TDB_count (pg, + "FROM wire_targets")); + TALER_EXCHANGEDB_rollback (pg); + FAILIF (0 != TDB_count (pg, + "FROM wire_targets")); + + FAILIF (GNUNET_OK != + TALER_EXCHANGEDB_start (pg, + "misc-isolation")); + TDB_account (pg, + 10, + &account); + FAILIF (0 > TALER_EXCHANGEDB_commit (pg)); + FAILIF (1 != TDB_count (pg, + "FROM wire_targets")); + return 0; +} + + +/** + * Preflight is happy on an idle connection and cleans up after a caller + * that forgot to end its transaction. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_preflight (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + FAILIF (GNUNET_OK != + TALER_EXCHANGEDB_preflight (pg)); + + /* a forgotten transaction is rolled back, and preflight says so */ + FAILIF (GNUNET_OK != + TALER_EXCHANGEDB_start (pg, + "misc-leak")); + FAILIF (GNUNET_NO != + TALER_EXCHANGEDB_preflight (pg)); + FAILIF (NULL != pg->transaction_name); + FAILIF (GNUNET_OK != + TALER_EXCHANGEDB_preflight (pg)); + + /* which is also what start() does for us */ + FAILIF (GNUNET_OK != + TALER_EXCHANGEDB_start (pg, + "misc-leak")); + FAILIF (GNUNET_OK != + TALER_EXCHANGEDB_start (pg, + "misc-after-leak")); + TALER_EXCHANGEDB_rollback (pg); + return 0; +} + + +/** + * The shard of a merchant key is deterministic and stays in range. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_compute_shard (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_MerchantPublicKeyP m1; + struct TALER_MerchantPublicKeyP m2; + uint64_t s1; + uint64_t s2; + + (void) pg; + TDB_FILL (m1, + 1); + TDB_FILL (m2, + 2); + s1 = TALER_EXCHANGEDB_compute_shard (&m1); + s2 = TALER_EXCHANGEDB_compute_shard (&m2); + FAILIF (s1 > INT32_MAX); + FAILIF (s2 > INT32_MAX); + FAILIF (s1 == s2); + /* the same key always maps to the same shard, otherwise the shard + locks of the aggregator would not partition the work */ + FAILIF (s1 != TALER_EXCHANGEDB_compute_shard (&m1)); + FAILIF (s2 != TALER_EXCHANGEDB_compute_shard (&m2)); + return 0; +} + + +/** + * The coin transaction list adds up and can be released. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_coin_list (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_EXCHANGEDB_TransactionList *tl = NULL; + struct TALER_CoinPublicInfo coin; + struct TDB_Deposit dep; + struct TALER_Amount balance; + struct TALER_Amount total; + struct TALER_Amount zero = TDB_amount ("0"); + struct TALER_Amount one = TDB_amount ("1"); + struct TALER_Amount two = TDB_amount ("2"); + struct TALER_DenominationHashP h_denom_pub; + uint64_t etag = 0; + + /* an empty list is worth the offset it started from */ + FAILIF (GNUNET_OK != + TALER_EXCHANGEDB_calculate_transaction_list_totals (NULL, + &one, + &total)); + FAILIF (0 != TALER_amount_cmp (&total, + &one)); + /* and freeing it is a no-op */ + TALER_EXCHANGEDB_free_coin_transaction_list (NULL); + + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + TDB_coin (pg, + &denom, + 20, + &coin, + NULL); + TDB_deposit (pg, + &account, + &coin, + 30, + "1", + "0.1", + GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS), + GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS), + &dep); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_coin_transactions (pg, + true, + &coin.coin_pub, + 0, + 0, + &etag, + &balance, + &h_denom_pub, + &tl), + TDB_coin_free (&coin)); + TDB_coin_free (&coin); + FAILIF (NULL == tl); + FAILIF_C (GNUNET_OK != + TALER_EXCHANGEDB_calculate_transaction_list_totals (tl, + &zero, + &total), + TALER_EXCHANGEDB_free_coin_transaction_list (tl)); + FAILIF_C (0 != TALER_amount_cmp (&total, + &one), + TALER_EXCHANGEDB_free_coin_transaction_list (tl)); + /* the offset is added on top */ + FAILIF_C (GNUNET_OK != + TALER_EXCHANGEDB_calculate_transaction_list_totals (tl, + &one, + &total), + TALER_EXCHANGEDB_free_coin_transaction_list (tl)); + FAILIF_C (0 != TALER_amount_cmp (&total, + &two), + TALER_EXCHANGEDB_free_coin_transaction_list (tl)); + TALER_EXCHANGEDB_free_coin_transaction_list (tl); + return 0; +} + + +/** + * The reserve history can be released. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_reserve_history (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_EXCHANGEDB_ReserveHistory *rh = NULL; + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_Amount balance; + uint64_t etag = 0; + unsigned int cnt = 0; + + /* freeing nothing is a no-op */ + TALER_EXCHANGEDB_free_reserve_history (NULL); + + TDB_reserve_in (pg, + &account, + 11, + "10", + &reserve_pub); + (void) TDB_withdraw (pg, + &denom, + &reserve_pub, + 11, + "5"); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve_history (pg, + &reserve_pub, + 0, + 0, + &etag, + &balance, + &rh)); + FAILIF (NULL == rh); + for (const struct TALER_EXCHANGEDB_ReserveHistory *pos = rh; + NULL != pos; + pos = pos->next) + cnt++; + FAILIF_C (2 != cnt, + TALER_EXCHANGEDB_free_reserve_history (rh)); + TALER_EXCHANGEDB_free_reserve_history (rh); + return 0; +} + + +/** + * State of the event check. + */ +struct EventState +{ + /** + * How often did the callback fire with a payload? + */ + unsigned int fired; + + /** + * Payload of the last notification. + */ + char payload[16]; + + /** + * Number of bytes in @e payload. + */ + size_t payload_len; +}; + + +/** + * Payload of the notification. + */ +#define PAYLOAD "hello" + + +/** + * Called when the notification arrives (or the listener times out). + * + * @param cls a `struct EventState *` + * @param extra the payload, NULL on timeout + * @param extra_size number of bytes in @a extra + */ +static void +event_cb (void *cls, + const void *extra, + size_t extra_size) +{ + struct EventState *st = cls; + + if (NULL == extra) + return; /* timeout event, not what we are after */ + st->fired++; + st->payload_len = GNUNET_MIN (extra_size, + sizeof (st->payload)); + memcpy (st->payload, + extra, + st->payload_len); +} + + +/** + * A notification sent on the connection reaches a listener registered on + * it, and stops reaching it once the listener is cancelled. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_events (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct GNUNET_DB_EventHeaderP es = { + .size = htons (sizeof (es)), + .type = htons (TALER_DBEVENT_EXCHANGE_KYC_COMPLETED) + }; + struct GNUNET_DB_EventHandler *eh; + struct EventState st = { 0 }; + + eh = TALER_EXCHANGEDB_event_listen (pg, + GNUNET_TIME_UNIT_MINUTES, + &es, + &event_cb, + &st); + FAILIF (NULL == eh); + TALER_EXCHANGEDB_event_notify (pg, + &es, + PAYLOAD, + strlen (PAYLOAD)); + /* the scheduler task that would pick this up does not get to run while + the checks are executing, so poll for it ourselves -- which is also + what the exchange does after every notifying transaction */ + GNUNET_PQ_event_do_poll (pg->conn); + FAILIF_C (1 != st.fired, + TALER_EXCHANGEDB_event_listen_cancel (pg, eh)); + FAILIF_C (strlen (PAYLOAD) != st.payload_len, + TALER_EXCHANGEDB_event_listen_cancel (pg, eh)); + FAILIF_C (0 != memcmp (st.payload, + PAYLOAD, + st.payload_len), + TALER_EXCHANGEDB_event_listen_cancel (pg, eh)); + + /* an unrelated event does not wake us */ + { + struct GNUNET_DB_EventHeaderP other = { + .size = htons (sizeof (other)), + .type = htons (TALER_DBEVENT_EXCHANGE_NEW_KYC_ATTRIBUTES) + }; + + TALER_EXCHANGEDB_event_notify (pg, + &other, + PAYLOAD, + strlen (PAYLOAD)); + GNUNET_PQ_event_do_poll (pg->conn); + FAILIF_C (1 != st.fired, + TALER_EXCHANGEDB_event_listen_cancel (pg, eh)); + } + + /* and after cancelling, neither does the one we listened for */ + TALER_EXCHANGEDB_event_listen_cancel (pg, + eh); + TALER_EXCHANGEDB_event_notify (pg, + &es, + PAYLOAD, + strlen (PAYLOAD)); + GNUNET_PQ_event_do_poll (pg->conn); + FAILIF (1 != st.fired); + return 0; +} + + +/** + * Customization rules that were never deployed cannot be dropped. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_rules (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_disable_rules (pg, + "no-such-customization")); + /* and a schema without SQL files on disk cannot be deployed; the + search_path must be back to `exchange' afterwards either way */ + FAILIF (GNUNET_SYSERR == + TALER_EXCHANGEDB_enable_rules (pg, + "no-such-customization")); + /* the search_path is back to `exchange', so an unqualified query + still finds the exchange's own tables */ + FAILIF (1 != TDB_count (pg, + "FROM wire_targets")); + return 0; +} + + +/** + * The auditor triggers can be injected into an existing schema. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_auditor_triggers (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + FAILIF (GNUNET_OK != + TALER_EXCHANGEDB_inject_auditor_triggers (pg)); + return 0; +} + + +/** + * Dropping and re-creating the schema leaves an empty but usable + * database. Nothing may run after this check. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_schema (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + FAILIF (0 == TDB_count (pg, + "FROM wire_targets")); + FAILIF (GNUNET_OK != + TALER_EXCHANGEDB_drop_tables (pg)); + /* dropping twice fails: drop.sql says `DROP SCHEMA exchange CASCADE' + without IF EXISTS, so there is nothing left to drop the second time */ + FAILIF (GNUNET_OK == + TALER_EXCHANGEDB_drop_tables (pg)); + FAILIF (GNUNET_OK != + TALER_EXCHANGEDB_create_tables (pg, + false, + 0)); + FAILIF (0 != TDB_count (pg, + "FROM wire_targets")); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "misc-transactions", + &check_transactions }, + { "misc-isolation", + &check_isolation }, + { "misc-preflight", + &check_preflight }, + { "misc-compute-shard", + &check_compute_shard }, + { "misc-coin-list", + &check_coin_list }, + { "misc-reserve-history", + &check_reserve_history }, + { "misc-events", + &check_events }, + { "misc-rules", + &check_rules }, + { "misc-auditor-triggers", + &check_auditor_triggers }, + { "misc-schema", + &check_schema }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-misc", + "Tests for the exchangedb functions without a table", + tests); + TDB_denom_free (&denom); + TDB_account_free (&account); + return ret; +} + + +/* end of test_misc.c */ diff --git a/src/exchangedb/test_partners.c b/src/exchangedb/test_partners.c @@ -0,0 +1,196 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_partners.c + * @brief tests for the exchangedb functions whose primary table is + * `partners` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_partner(). It is the only exported + * function that writes the table; the rows are read back by the purse + * machinery, which is covered in test_purse_merges.c and + * test_purse_deposits.c. + * + * `partners` has no foreign keys. The insert is ON CONFLICT DO NOTHING, + * so re-inserting the same (key, validity period) is a no-op rather than a + * failure. + */ +#include "test_common.h" +#include "exchange-database/insert_partner.h" + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Insert a partner exchange. + * + * @param pg the database context + * @param seed seed for the partner's key and signature + * @param from start of the validity period + * @param until end of the validity period + * @param url base URL of the partner + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +add_partner (struct TALER_EXCHANGEDB_PostgresContext *pg, + uint32_t seed, + uint64_t from, + uint64_t until, + const char *url) +{ + struct TALER_MasterPublicKeyP master_pub; + struct TALER_MasterSignatureP master_sig; + struct TALER_Amount wad_fee = TDB_amount ("0.05"); + + TDB_FILL (master_pub, + seed); + TDB_FILL (master_sig, + seed); + return TALER_EXCHANGEDB_insert_partner (pg, + &master_pub, + ts (from), + ts (until), + GNUNET_TIME_UNIT_HOURS, + &wad_fee, + url, + &master_sig); +} + + +/** + * Inserting a partner stores exactly the row we asked for. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insert (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_MasterPublicKeyP master_pub; + struct TALER_Amount wad_fee = TDB_amount ("0.05"); + char *hex; + + FAILIF (0 != TDB_count (pg, + "FROM partners")); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_partner (pg, + 10, + 1600000000, + 1700000000, + "https://partner.example/")); + TDB_FILL (master_pub, + 10); + hex = TDB_hex (&master_pub, + sizeof (master_pub)); + FAILIF_C (1 != TDB_count (pg, + "FROM partners" + " WHERE partner_master_pub=decode('%s','hex')" + " AND partner_base_url='https://partner.example/'" + " AND wad_fee=ROW(%llu,%u)::taler_amount" + " AND start_date=%llu" + " AND end_date=%llu", + hex, + (unsigned long long) wad_fee.value, + (unsigned int) wad_fee.fraction, + (unsigned long long) 1600000000000000LLU, + (unsigned long long) 1700000000000000LLU), + GNUNET_free (hex)); + GNUNET_free (hex); + return 0; +} + + +/** + * Re-inserting the identical row is a no-op, while a different validity + * period for the same key is a new row. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_conflict (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + /* identical to what check_insert() stored */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + add_partner (pg, + 10, + 1600000000, + 1700000000, + "https://partner.example/")); + FAILIF (1 != TDB_count (pg, + "FROM partners")); + /* a later period for the same partner is a separate row */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_partner (pg, + 10, + 1700000000, + 1800000000, + "https://partner.example/")); + FAILIF (2 != TDB_count (pg, + "FROM partners")); + /* and so is a different partner */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_partner (pg, + 11, + 1600000000, + 1700000000, + "https://other.example/")); + FAILIF (3 != TDB_count (pg, + "FROM partners")); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "partners-insert", + &check_insert }, + { "partners-conflict", + &check_conflict }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-partners", + "Tests for the exchangedb `partners' table", + tests); +} + + +/* end of test_partners.c */ diff --git a/src/exchangedb/test_prewire.c b/src/exchangedb/test_prewire.c @@ -0,0 +1,323 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_prewire.c + * @brief tests for the exchangedb functions whose primary table is `prewire` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_prewire(), + * #TALER_EXCHANGEDB_iterate_prewires(), + * #TALER_EXCHANGEDB_update_to_prewire_finished() and + * #TALER_EXCHANGEDB_update_to_prewire_failed(). + * + * `prewire` has no foreign keys. The table is a work queue: the iterator + * only reports rows that are neither finished nor failed, which is what the + * checks below are about. + */ +#include "test_common.h" +#include "exchange-database/insert_prewire.h" +#include "exchange-database/iterate_prewires.h" +#include "exchange-database/update_to_prewire_finished.h" +#include "exchange-database/update_to_prewire_failed.h" + + +/** + * Closure for #prewire_cb(). + */ +struct PrewireContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Row of the first entry seen. + */ + uint64_t first_row; + + /** + * Payload of the first entry seen, owned by this struct. + */ + char *first_buf; + + /** + * Number of bytes in @e first_buf. + */ + size_t first_buf_size; + + /** + * Wire method of the first entry seen, owned by this struct. + */ + char *first_method; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_prewires(). + * + * @param cls a `struct PrewireContext *` + * @param rowid row of the entry + * @param wire_method wire method of the entry + * @param buf payload of the entry + * @param buf_size number of bytes in @a buf + */ +static void +prewire_cb (void *cls, + uint64_t rowid, + const char *wire_method, + const char *buf, + size_t buf_size) +{ + struct PrewireContext *ctx = cls; + + if (0 == ctx->total++) + { + ctx->first_row = rowid; + ctx->first_buf = GNUNET_memdup (buf, + buf_size); + ctx->first_buf_size = buf_size; + ctx->first_method = GNUNET_strdup (wire_method); + } +} + + +/** + * Release what #prewire_cb() allocated. + * + * @param[in,out] ctx context to clean up + */ +static void +ctx_free (struct PrewireContext *ctx) +{ + GNUNET_free (ctx->first_buf); + GNUNET_free (ctx->first_method); +} + + +/** + * An empty queue yields nothing, and marking rows that do not exist does + * nothing. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct PrewireContext ctx = { 0 }; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_prewires (pg, + 0, + 10, + &prewire_cb, + &ctx)); + FAILIF (0 != ctx.total); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_update_to_prewire_finished (pg, + 1)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_update_to_prewire_failed (pg, + 1)); + return 0; +} + + +/** + * Inserted rows come back in order, with their payload intact. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insert_and_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + static const char buf1[] = "first-transfer"; + static const char buf2[] = "second-transfer"; + struct PrewireContext ctx; + uint64_t row1; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_prewire (pg, + "x-taler-bank", + buf1, + sizeof (buf1))); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_prewire (pg, + "iban", + buf2, + sizeof (buf2))); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_prewires (pg, + 0, + 10, + &prewire_cb, + &ctx)); + FAILIF_C (2 != ctx.total, + ctx_free (&ctx)); + FAILIF_C (sizeof (buf1) != ctx.first_buf_size, + ctx_free (&ctx)); + FAILIF_C (0 != memcmp (ctx.first_buf, + buf1, + sizeof (buf1)), + ctx_free (&ctx)); + FAILIF_C (0 != strcmp (ctx.first_method, + "x-taler-bank"), + ctx_free (&ctx)); + row1 = ctx.first_row; + ctx_free (&ctx); + + /* the limit is honoured */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_prewires (pg, + 0, + 1, + &prewire_cb, + &ctx)); + FAILIF_C (1 != ctx.total, + ctx_free (&ctx)); + ctx_free (&ctx); + + /* ...and so is the starting row */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_prewires (pg, + row1 + 1, + 10, + &prewire_cb, + &ctx)); + FAILIF_C (1 != ctx.total, + ctx_free (&ctx)); + FAILIF_C (0 != strcmp (ctx.first_method, + "iban"), + ctx_free (&ctx)); + ctx_free (&ctx); + return 0; +} + + +/** + * Finished and failed rows drop out of the queue. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_finished_and_failed (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct PrewireContext ctx; + uint64_t row1; + uint64_t row2; + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_prewires (pg, + 0, + 10, + &prewire_cb, + &ctx)); + FAILIF_C (2 != ctx.total, + ctx_free (&ctx)); + row1 = ctx.first_row; + ctx_free (&ctx); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_to_prewire_finished (pg, + row1)); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_prewires (pg, + 0, + 10, + &prewire_cb, + &ctx)); + FAILIF_C (1 != ctx.total, + ctx_free (&ctx)); + row2 = ctx.first_row; + ctx_free (&ctx); + FAILIF (row2 == row1); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_to_prewire_failed (pg, + row2)); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_prewires (pg, + 0, + 10, + &prewire_cb, + &ctx)); + FAILIF_C (0 != ctx.total, + ctx_free (&ctx)); + ctx_free (&ctx); + + /* both rows are still on file, one finished and one failed */ + FAILIF (2 != TDB_count (pg, + "FROM prewire")); + FAILIF (1 != TDB_count (pg, + "FROM prewire WHERE finished")); + FAILIF (1 != TDB_count (pg, + "FROM prewire WHERE failed")); + /* marking again is still reported as a row touched */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_to_prewire_finished (pg, + row1)); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "prewire-empty", + &check_empty }, + { "prewire-insert-and-iterate", + &check_insert_and_iterate }, + { "prewire-finished-and-failed", + &check_finished_and_failed }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-prewire", + "Tests for the exchangedb `prewire' table", + tests); +} + + +/* end of test_prewire.c */ diff --git a/src/exchangedb/test_profit_drains.c b/src/exchangedb/test_profit_drains.c @@ -0,0 +1,330 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_profit_drains.c + * @brief tests for the exchangedb functions whose primary table is + * `profit_drains` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_profit_drain(), + * #TALER_EXCHANGEDB_get_profit_drain(), + * #TALER_EXCHANGEDB_get_pending_profit_drain() and + * #TALER_EXCHANGEDB_update_to_profit_drain_finished(). + * + * `profit_drains` has no foreign keys. The pending lookup picks the + * oldest not-yet-executed request, so the checks insert two out of order + * and drain them one by one. + */ +#include "test_common.h" +#include "exchange-database/insert_profit_drain.h" +#include "exchange-database/get_profit_drain.h" +#include "exchange-database/get_pending_profit_drain.h" +#include "exchange-database/update_to_profit_drain_finished.h" + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Insert a drain request. + * + * @param pg the database context + * @param seed seed for the wire transfer identifier and signature + * @param when when was the request made + * @param amount amount to drain, e.g. "10" + * @param[out] wtid set to the wire transfer identifier used + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +add_drain (struct TALER_EXCHANGEDB_PostgresContext *pg, + uint32_t seed, + uint64_t when, + const char *amount, + struct TALER_WireTransferIdentifierRawP *wtid) +{ + struct TALER_FullPayto payto = { + .full_payto = (char *) "payto://x-taler-bank/localhost/profits" + }; + struct TALER_Amount a = TDB_amount (amount); + struct TALER_MasterSignatureP master_sig; + + TDB_fill (wtid, + sizeof (*wtid), + seed); + TDB_FILL (master_sig, + seed); + return TALER_EXCHANGEDB_insert_profit_drain (pg, + wtid, + "exchange-account-1", + payto, + ts (when), + &a, + &master_sig); +} + + +/** + * Nothing is pending and nothing is found while the table is empty. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_WireTransferIdentifierRawP wtid; + struct TALER_FullPayto payto_uri; + struct GNUNET_TIME_Timestamp request_timestamp; + struct TALER_Amount amount; + struct TALER_MasterSignatureP master_sig; + char *account_section = NULL; + uint64_t serial; + + TDB_FILL (wtid, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_profit_drain (pg, + &wtid, + &serial, + &account_section, + &payto_uri, + &request_timestamp, + &amount, + &master_sig)); + FAILIF (NULL != account_section); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_pending_profit_drain (pg, + &serial, + &wtid, + &account_section, + &payto_uri, + &request_timestamp, + &amount, + &master_sig)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_update_to_profit_drain_finished (pg, + 1)); + return 0; +} + + +/** + * A drain request is stored, found by its wire transfer identifier and + * reported as pending. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insert_and_lookup (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_WireTransferIdentifierRawP wtid; + struct TALER_WireTransferIdentifierRawP unknown; + struct TALER_WireTransferIdentifierRawP got_wtid; + struct TALER_FullPayto payto_uri = { NULL }; + struct GNUNET_TIME_Timestamp request_timestamp; + struct TALER_Amount amount; + struct TALER_Amount expect = TDB_amount ("10"); + struct TALER_MasterSignatureP master_sig; + struct TALER_MasterSignatureP expect_sig; + char *account_section = NULL; + uint64_t serial = 0; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_drain (pg, + 10, + 1600000000, + "10", + &wtid)); + TDB_FILL (expect_sig, + 10); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_profit_drain (pg, + &wtid, + &serial, + &account_section, + &payto_uri, + &request_timestamp, + &amount, + &master_sig)); + FAILIF (0 == serial); + FAILIF (0 != strcmp (account_section, + "exchange-account-1")); + GNUNET_free (account_section); + FAILIF (0 != strcmp (payto_uri.full_payto, + "payto://x-taler-bank/localhost/profits")); + GNUNET_free (payto_uri.full_payto); + FAILIF (0 != TALER_amount_cmp (&amount, + &expect)); + FAILIF (GNUNET_TIME_timestamp_cmp (request_timestamp, + !=, + ts (1600000000))); + FAILIF (0 != GNUNET_memcmp (&master_sig, + &expect_sig)); + + /* a wire transfer identifier we never used is not found */ + TDB_FILL (unknown, + 99); + account_section = NULL; + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_profit_drain (pg, + &unknown, + &serial, + &account_section, + &payto_uri, + &request_timestamp, + &amount, + &master_sig)); + + account_section = NULL; + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_pending_profit_drain (pg, + &serial, + &got_wtid, + &account_section, + &payto_uri, + &request_timestamp, + &amount, + &master_sig)); + GNUNET_free (account_section); + GNUNET_free (payto_uri.full_payto); + FAILIF (0 != GNUNET_memcmp (&got_wtid, + &wtid)); + return 0; +} + + +/** + * The oldest unexecuted request is the pending one, and marking it + * executed hands the next one over. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_pending_order (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_WireTransferIdentifierRawP older; + struct TALER_WireTransferIdentifierRawP got_wtid; + struct TALER_FullPayto payto_uri = { NULL }; + struct GNUNET_TIME_Timestamp request_timestamp; + struct TALER_Amount amount; + struct TALER_MasterSignatureP master_sig; + char *account_section = NULL; + uint64_t serial = 0; + + /* inserted second, but with an earlier trigger date */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_drain (pg, + 11, + 1500000000, + "3", + &older)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_pending_profit_drain (pg, + &serial, + &got_wtid, + &account_section, + &payto_uri, + &request_timestamp, + &amount, + &master_sig)); + GNUNET_free (account_section); + GNUNET_free (payto_uri.full_payto); + FAILIF (0 != GNUNET_memcmp (&got_wtid, + &older)); + + /* draining it hands over the remaining one */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_to_profit_drain_finished (pg, + serial)); + account_section = NULL; + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_pending_profit_drain (pg, + &serial, + &got_wtid, + &account_section, + &payto_uri, + &request_timestamp, + &amount, + &master_sig)); + GNUNET_free (account_section); + GNUNET_free (payto_uri.full_payto); + FAILIF (0 == GNUNET_memcmp (&got_wtid, + &older)); + + /* and once both are executed, nothing is pending */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_to_profit_drain_finished (pg, + serial)); + account_section = NULL; + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_pending_profit_drain (pg, + &serial, + &got_wtid, + &account_section, + &payto_uri, + &request_timestamp, + &amount, + &master_sig)); + /* the executed rows are still on file */ + FAILIF (2 != TDB_count (pg, + "FROM profit_drains WHERE executed")); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "profit-drains-empty", + &check_empty }, + { "profit-drains-insert-and-lookup", + &check_insert_and_lookup }, + { "profit-drains-pending-order", + &check_pending_order }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-profit-drains", + "Tests for the exchangedb `profit_drains' table", + tests); +} + + +/* end of test_profit_drains.c */ diff --git a/src/exchangedb/test_purse_decision.c b/src/exchangedb/test_purse_decision.c @@ -0,0 +1,441 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_purse_decision.c + * @brief tests for the exchangedb functions whose primary table is + * `purse_decision` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_do_expire_purse(), + * #TALER_EXCHANGEDB_iterate_purse_decisions_above_serial_id() and + * #TALER_EXCHANGEDB_iterate_all_purse_decisions_above_serial_id(). + * + * A purse is decided either because it was merged or because it expired. + * do_expire_purse() handles the second case: it decides the oldest expired + * purse in the window it is given, marks the decision as a refund and + * gives the coins their money back -- which is what the checks look at. + */ +#include "test_common.h" +#include "exchange-database/do_expire_purse.h" +#include "exchange-database/do_purse_deposit.h" +#include "exchange-database/iterate_all_purse_decisions_above_serial_id.h" +#include "exchange-database/iterate_purse_decisions_above_serial_id.h" + + +/** + * Denomination the checks deposit into purses. + */ +static struct TDB_Denom denom; + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Closure for #decision_cb() and #all_decision_cb(). + */ +struct DecisionContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Stop after this many rows; 0 for no limit. + */ + unsigned int stop_after; + + /** + * Purse we are looking for, NULL to match nothing. + */ + const struct TALER_PurseContractPublicKeyP *purse_pub; + + /** + * How many times did we see it? + */ + unsigned int matched; + + /** + * Was it reported as refunded? + */ + bool refunded; + + /** + * Was a reserve reported for it? + */ + bool have_reserve; +}; + + +/** + * Callback for + * #TALER_EXCHANGEDB_iterate_purse_decisions_above_serial_id(). + * + * @param cls a `struct DecisionContext *` + * @param rowid row of the decision + * @param purse_pub the purse + * @param reserve_pub reserve it went to, NULL if refunded + * @param purse_value target value of the purse + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +decision_cb (void *cls, + uint64_t rowid, + const struct TALER_PurseContractPublicKeyP *purse_pub, + const struct TALER_ReservePublicKeyP *reserve_pub, + const struct TALER_Amount *purse_value) +{ + struct DecisionContext *ctx = cls; + + (void) rowid; + (void) purse_value; + ctx->total++; + if ( (NULL != ctx->purse_pub) && + (0 == GNUNET_memcmp (purse_pub, + ctx->purse_pub)) ) + { + ctx->matched++; + ctx->have_reserve = (NULL != reserve_pub); + } + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * Callback for + * #TALER_EXCHANGEDB_iterate_all_purse_decisions_above_serial_id(). + * + * @param cls a `struct DecisionContext *` + * @param rowid row of the decision + * @param purse_pub the purse + * @param refunded whether the decision was to refund + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +all_decision_cb (void *cls, + uint64_t rowid, + const struct TALER_PurseContractPublicKeyP *purse_pub, + bool refunded) +{ + struct DecisionContext *ctx = cls; + + (void) rowid; + ctx->total++; + if ( (NULL != ctx->purse_pub) && + (0 == GNUNET_memcmp (purse_pub, + ctx->purse_pub)) ) + { + ctx->matched++; + ctx->refunded = refunded; + } + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * With no purse to expire, nothing is decided and nothing is reported. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct DecisionContext ctx = { 0 }; + struct GNUNET_TIME_Absolute expiration; + + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_do_expire_purse (pg, + GNUNET_TIME_UNIT_ZERO_ABS, + GNUNET_TIME_absolute_get (), + &expiration)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_purse_decisions_above_serial_id ( + pg, + 0, + false, + &decision_cb, + &ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_all_purse_decisions_above_serial_id ( + pg, + 0, + &all_decision_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * A purse that has not expired yet is left alone. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_not_expired (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Purse purse; + struct GNUNET_TIME_Absolute expiration; + + /* expires in 2023 */ + TDB_purse (pg, + 10, + "5", + ts (1700000000), + &purse); + /* ...but we only look at purses that expired before 2020 */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_do_expire_purse (pg, + GNUNET_TIME_UNIT_ZERO_ABS, + ts (1600000000).abs_time, + &expiration)); + FAILIF (0 != TDB_count (pg, + "FROM purse_decision")); + return 0; +} + + +/** + * Expiring a purse decides it as a refund and gives the coins their + * money back. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_expire (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Purse purse; + struct TALER_CoinPublicInfo coin; + struct GNUNET_TIME_Absolute expiration; + struct DecisionContext ctx; + struct TALER_CoinSpendSignatureP coin_sig; + struct TALER_Amount amount = TDB_amount ("1"); + bool balance_ok; + bool too_late; + bool conflict; + char *hex; + + /* a purse that expired in 2020, with a coin paid into it */ + TDB_purse (pg, + 11, + "5", + ts (1600000000), + &purse); + TDB_coin (pg, + &denom, + 20, + &coin, + NULL); + TDB_FILL (coin_sig, + 20); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_purse_deposit (pg, + &purse.purse_pub, + &coin.coin_pub, + &amount, + &coin_sig, + &amount, + &balance_ok, + &too_late, + &conflict), + TDB_coin_free (&coin)); + FAILIF_C (! balance_ok, + TDB_coin_free (&coin)); + hex = TDB_hex (&coin.coin_pub, + sizeof (coin.coin_pub)); + FAILIF_C (1 != TDB_count (pg, + "FROM known_coins" + " WHERE coin_pub=decode('%s','hex')" + " AND remaining=ROW(4,0)::taler_amount", + hex), + GNUNET_free (hex); TDB_coin_free (&coin)); + + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_expire_purse (pg, + GNUNET_TIME_UNIT_ZERO_ABS, + GNUNET_TIME_absolute_get (), + &expiration), + GNUNET_free (hex); TDB_coin_free (&coin)); + FAILIF_C (expiration.abs_value_us != + ts (1600000000).abs_time.abs_value_us, + GNUNET_free (hex); TDB_coin_free (&coin)); + FAILIF_C (1 != TDB_count (pg, + "FROM purse_decision"), + GNUNET_free (hex); TDB_coin_free (&coin)); + /* the coin got its money back */ + FAILIF_C (1 != TDB_count (pg, + "FROM known_coins" + " WHERE coin_pub=decode('%s','hex')" + " AND remaining=ROW(5,0)::taler_amount", + hex), + GNUNET_free (hex); TDB_coin_free (&coin)); + GNUNET_free (hex); + TDB_coin_free (&coin); + + /* the decision is a refund, so the by-refund iterator sees it... */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.purse_pub = &purse.purse_pub; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_purse_decisions_above_serial_id ( + pg, + 0, + true, + &decision_cb, + &ctx)); + FAILIF (1 != ctx.matched); + /* ...a refunded purse has no reserve to credit */ + FAILIF (ctx.have_reserve); + + /* ...and the non-refunded view does not */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.purse_pub = &purse.purse_pub; + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_purse_decisions_above_serial_id ( + pg, + 0, + false, + &decision_cb, + &ctx)); + FAILIF (0 != ctx.matched); + + /* the all-decisions view reports it with its refund flag */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.purse_pub = &purse.purse_pub; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_all_purse_decisions_above_serial_id ( + pg, + 0, + &all_decision_cb, + &ctx)); + FAILIF (1 != ctx.matched); + FAILIF (! ctx.refunded); + + /* a second sweep finds nothing left to expire */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_do_expire_purse (pg, + GNUNET_TIME_UNIT_ZERO_ABS, + ts (1650000000).abs_time, + &expiration)); + FAILIF (1 != TDB_count (pg, + "FROM purse_decision")); + return 0; +} + + +/** + * The iterators' serial bounds and abort returns behave as documented. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct DecisionContext ctx; + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_all_purse_decisions_above_serial_id ( + pg, + 1000, + &all_decision_cb, + &ctx)); + FAILIF (0 != ctx.total); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.stop_after = 1; + FAILIF (1 != + TALER_EXCHANGEDB_iterate_all_purse_decisions_above_serial_id ( + pg, + 0, + &all_decision_cb, + &ctx)); + FAILIF (1 != ctx.total); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "purse-decision-empty", + &check_empty }, + { "purse-decision-not-expired", + &check_not_expired }, + { "purse-decision-expire", + &check_expire }, + { "purse-decision-iterate", + &check_iterate }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-purse-decision", + "Tests for the exchangedb `purse_decision' table", + tests); + TDB_denom_free (&denom); + return ret; +} + + +/* end of test_purse_decision.c */ diff --git a/src/exchangedb/test_purse_deletion.c b/src/exchangedb/test_purse_deletion.c @@ -0,0 +1,410 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_purse_deletion.c + * @brief tests for the exchangedb functions whose primary table is + * `purse_deletion` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_do_purse_delete() and + * #TALER_EXCHANGEDB_iterate_all_purse_deletions_above_serial_id(). + * + * Deleting a purse is only possible while it is undecided: the two + * negative answers of do_purse_delete() are "no such purse" and "already + * decided", and the checks below produce both. + */ +#include "test_common.h" +#include "exchange-database/do_expire_purse.h" +#include "exchange-database/do_purse_delete.h" +#include "exchange-database/do_purse_deposit.h" +#include "exchange-database/get_purse.h" +#include "exchange-database/iterate_all_purse_deletions_above_serial_id.h" + + +/** + * Denomination the checks deposit into purses. + */ +static struct TDB_Denom denom; + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Closure for #deletion_cb(). + */ +struct DeletionContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Stop after this many rows; 0 for no limit. + */ + unsigned int stop_after; + + /** + * Purse we are looking for, NULL to match nothing. + */ + const struct TALER_PurseContractPublicKeyP *purse_pub; + + /** + * How many times did we see it? + */ + unsigned int matched; + + /** + * Signature reported for it. + */ + struct TALER_PurseContractSignatureP purse_sig; +}; + + +/** + * Callback for + * #TALER_EXCHANGEDB_iterate_all_purse_deletions_above_serial_id(). + * + * @param cls a `struct DeletionContext *` + * @param rowid row of the deletion + * @param purse_pub the purse that was deleted + * @param purse_sig signature affirming the deletion + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +deletion_cb (void *cls, + uint64_t rowid, + const struct TALER_PurseContractPublicKeyP *purse_pub, + const struct TALER_PurseContractSignatureP *purse_sig) +{ + struct DeletionContext *ctx = cls; + + (void) rowid; + ctx->total++; + if ( (NULL != ctx->purse_pub) && + (0 == GNUNET_memcmp (purse_pub, + ctx->purse_pub)) ) + { + ctx->matched++; + ctx->purse_sig = *purse_sig; + } + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * A purse that does not exist cannot be deleted. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_unknown_purse (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_PurseContractPublicKeyP purse_pub; + struct TALER_PurseContractSignatureP purse_sig; + struct DeletionContext ctx = { 0 }; + bool decided = true; + bool found = true; + + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + TDB_FILL (purse_pub, + 1); + TDB_FILL (purse_sig, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_purse_delete (pg, + &purse_pub, + &purse_sig, + &decided, + &found)); + FAILIF (found); + FAILIF (0 != TDB_count (pg, + "FROM purse_deletion")); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_all_purse_deletions_above_serial_id ( + pg, + 0, + &deletion_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * Deleting an undecided purse records the deletion, refunds the coins in + * it and shows up in the purse's status. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_delete (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Purse purse; + struct TALER_CoinPublicInfo coin; + struct TALER_PurseContractSignatureP purse_sig; + struct TALER_CoinSpendSignatureP coin_sig; + struct TALER_Amount amount = TDB_amount ("1"); + struct GNUNET_TIME_Timestamp purse_creation; + struct GNUNET_TIME_Timestamp purse_expiration; + struct GNUNET_TIME_Timestamp merge_timestamp; + struct TALER_Amount target; + struct TALER_Amount deposited; + struct TALER_PrivateContractHashP h_contract_terms; + struct DeletionContext ctx; + bool balance_ok; + bool too_late; + bool conflict; + bool decided = true; + bool found = false; + bool purse_deleted = false; + bool purse_refunded = true; + char *hex; + + TDB_purse (pg, + 10, + "5", + ts (1700000000), + &purse); + TDB_coin (pg, + &denom, + 20, + &coin, + NULL); + TDB_FILL (coin_sig, + 20); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_purse_deposit (pg, + &purse.purse_pub, + &coin.coin_pub, + &amount, + &coin_sig, + &amount, + &balance_ok, + &too_late, + &conflict), + TDB_coin_free (&coin)); + FAILIF_C (! balance_ok, + TDB_coin_free (&coin)); + + TDB_FILL (purse_sig, + 10); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_purse_delete (pg, + &purse.purse_pub, + &purse_sig, + &decided, + &found), + TDB_coin_free (&coin)); + FAILIF_C (! found, + TDB_coin_free (&coin)); + FAILIF_C (decided, + TDB_coin_free (&coin)); + FAILIF_C (1 != TDB_count (pg, + "FROM purse_deletion"), + TDB_coin_free (&coin)); + /* the coin got its money back */ + hex = TDB_hex (&coin.coin_pub, + sizeof (coin.coin_pub)); + FAILIF_C (1 != TDB_count (pg, + "FROM known_coins" + " WHERE coin_pub=decode('%s','hex')" + " AND remaining=ROW(5,0)::taler_amount", + hex), + GNUNET_free (hex); TDB_coin_free (&coin)); + GNUNET_free (hex); + TDB_coin_free (&coin); + + /* the purse reports itself as deleted */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_purse (pg, + &purse.purse_pub, + &purse_creation, + &purse_expiration, + &target, + &deposited, + &h_contract_terms, + &merge_timestamp, + &purse_deleted, + &purse_refunded)); + FAILIF (! purse_deleted); + + /* the deletion is reported with the signature it was made with */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.purse_pub = &purse.purse_pub; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_all_purse_deletions_above_serial_id ( + pg, + 0, + &deletion_cb, + &ctx)); + FAILIF (1 != ctx.matched); + FAILIF (0 != GNUNET_memcmp (&ctx.purse_sig, + &purse_sig)); + + /* deleting it again finds it already decided */ + decided = false; + found = false; + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_purse_delete (pg, + &purse.purse_pub, + &purse_sig, + &decided, + &found)); + FAILIF (! found); + FAILIF (! decided); + FAILIF (1 != TDB_count (pg, + "FROM purse_deletion")); + return 0; +} + + +/** + * A purse that expired first can no longer be deleted. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_already_decided (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Purse purse; + struct TALER_PurseContractSignatureP purse_sig; + struct GNUNET_TIME_Absolute expiration; + bool decided = false; + bool found = false; + + TDB_purse (pg, + 11, + "5", + ts (1600000000), + &purse); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_expire_purse (pg, + GNUNET_TIME_UNIT_ZERO_ABS, + GNUNET_TIME_absolute_get (), + &expiration)); + TDB_FILL (purse_sig, + 11); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_purse_delete (pg, + &purse.purse_pub, + &purse_sig, + &decided, + &found)); + FAILIF (! found); + FAILIF (! decided); + FAILIF (1 != TDB_count (pg, + "FROM purse_deletion")); + return 0; +} + + +/** + * The iterator's serial bound and abort return behave as documented. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct DeletionContext ctx; + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_all_purse_deletions_above_serial_id ( + pg, + 1000, + &deletion_cb, + &ctx)); + FAILIF (0 != ctx.total); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.stop_after = 1; + FAILIF (1 != + TALER_EXCHANGEDB_iterate_all_purse_deletions_above_serial_id ( + pg, + 0, + &deletion_cb, + &ctx)); + FAILIF (1 != ctx.total); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "purse-deletion-unknown-purse", + &check_unknown_purse }, + { "purse-deletion-delete", + &check_delete }, + { "purse-deletion-already-decided", + &check_already_decided }, + { "purse-deletion-iterate", + &check_iterate }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-purse-deletion", + "Tests for the exchangedb `purse_deletion' table", + tests); + TDB_denom_free (&denom); + return ret; +} + + +/* end of test_purse_deletion.c */ diff --git a/src/exchangedb/test_purse_deposits.c b/src/exchangedb/test_purse_deposits.c @@ -0,0 +1,697 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_purse_deposits.c + * @brief tests for the exchangedb functions whose primary table is + * `purse_deposits` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_do_purse_deposit(), + * #TALER_EXCHANGEDB_get_purse_deposit(), + * #TALER_EXCHANGEDB_iterate_purse_deposits_above_serial_id() and + * #TALER_EXCHANGEDB_iterate_purse_deposits_by_purse(). + * + * `purse_deposits` references `purse_requests` and `known_coins`, which + * TDB_purse() and TDB_coin() create. do_purse_deposit() declines in three + * ways: the coin has too little left, the purse has already been decided + * (too late), and the same coin was already paid into the purse with a + * different amount (conflict). + */ +#include "test_common.h" +#include "exchange-database/do_expire_purse.h" +#include "exchange-database/do_purse_deposit.h" +#include "exchange-database/get_purse_deposit.h" +#include "exchange-database/iterate_purse_deposits_above_serial_id.h" +#include "exchange-database/iterate_purse_deposits_by_purse.h" +#include "exchange-database/rollback.h" +#include "exchange-database/start.h" + + +/** + * Denomination the checks deposit. + */ +static struct TDB_Denom denom; + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Closure for #deposit_cb() and #refund_cb(). + */ +struct DepositContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Stop after this many rows; 0 for no limit. + */ + unsigned int stop_after; + + /** + * Coin we are looking for, NULL to match nothing. + */ + const struct TALER_CoinSpendPublicKeyP *coin_pub; + + /** + * How many times did we see it? + */ + unsigned int matched; + + /** + * Amount reported for it. + */ + struct TALER_Amount amount; + + /** + * Was a reserve reported for its purse? + */ + bool have_reserve; +}; + + +/** + * Callback for + * #TALER_EXCHANGEDB_iterate_purse_deposits_above_serial_id(). + * + * @param cls a `struct DepositContext *` + * @param rowid row of the deposit + * @param deposit details of the deposit + * @param reserve_pub reserve the purse was merged into, NULL if none + * @param flags flags of the purse + * @param auditor_balance balance of the purse + * @param purse_total target amount of the purse + * @param denom_pub denomination of the coin + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +deposit_cb (void *cls, + uint64_t rowid, + const struct TALER_EXCHANGEDB_PurseDeposit *deposit, + const struct TALER_ReservePublicKeyP *reserve_pub, + enum TALER_WalletAccountMergeFlags flags, + const struct TALER_Amount *auditor_balance, + const struct TALER_Amount *purse_total, + const struct TALER_DenominationPublicKey *denom_pub) +{ + struct DepositContext *ctx = cls; + + (void) rowid; + (void) flags; + (void) auditor_balance; + (void) purse_total; + (void) denom_pub; + ctx->total++; + if ( (NULL != ctx->coin_pub) && + (0 == GNUNET_memcmp (&deposit->coin_pub, + ctx->coin_pub)) ) + { + ctx->matched++; + ctx->amount = deposit->amount; + ctx->have_reserve = (NULL != reserve_pub); + } + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_purse_deposits_by_purse(). + * + * @param cls a `struct DepositContext *` + * @param rowid row of the deposit + * @param amount_with_fee how much was paid in + * @param coin_pub which coin paid + * @param denom_pub denomination of that coin + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +refund_cb (void *cls, + uint64_t rowid, + const struct TALER_Amount *amount_with_fee, + const struct TALER_CoinSpendPublicKeyP *coin_pub, + const struct TALER_DenominationPublicKey *denom_pub) +{ + struct DepositContext *ctx = cls; + + (void) rowid; + (void) denom_pub; + ctx->total++; + if ( (NULL != ctx->coin_pub) && + (0 == GNUNET_memcmp (coin_pub, + ctx->coin_pub)) ) + { + ctx->matched++; + ctx->amount = *amount_with_fee; + } + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * Outcome of a purse deposit. + */ +struct DepositStatus +{ + /** + * Did the coin have enough left? + */ + bool balance_ok; + + /** + * Was the purse already decided? + */ + bool too_late; + + /** + * Was the coin already paid into this purse differently? + */ + bool conflict; +}; + + +/** + * Pay a coin into a purse. + * + * @param pg the database context + * @param purse purse to credit + * @param coin coin to debit + * @param seed seed for the coin signature + * @param amount how much to take from the coin, e.g. "1" + * @param fee purse deposit fee included in @a amount, e.g. "0" + * @param[out] st set to the outcome + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +run_deposit (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TDB_Purse *purse, + const struct TALER_CoinPublicInfo *coin, + uint32_t seed, + const char *amount, + const char *fee, + struct DepositStatus *st) +{ + struct TALER_CoinSpendSignatureP coin_sig; + struct TALER_Amount a = TDB_amount (amount); + struct TALER_Amount f = TDB_amount (fee); + struct TALER_Amount net; + + TDB_fill (&coin_sig, + sizeof (coin_sig), + seed); + GNUNET_assert (0 <= + TALER_amount_subtract (&net, + &a, + &f)); + memset (st, + 0, + sizeof (*st)); + return TALER_EXCHANGEDB_do_purse_deposit (pg, + &purse->purse_pub, + &coin->coin_pub, + &a, + &coin_sig, + &net, + &st->balance_ok, + &st->too_late, + &st->conflict); +} + + +/** + * Nothing is reported while the table is empty. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_PurseContractPublicKeyP purse_pub; + struct TALER_CoinSpendPublicKeyP coin_pub; + struct TALER_Amount amount; + struct TALER_DenominationHashP h_denom_pub; + struct TALER_AgeCommitmentHashP phac; + struct TALER_CoinSpendSignatureP coin_sig; + struct DepositContext ctx = { 0 }; + char *partner_url = NULL; + bool no_age_commitment; + + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + TDB_FILL (purse_pub, + 1); + TDB_FILL (coin_pub, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_purse_deposit (pg, + &purse_pub, + &coin_pub, + &amount, + &h_denom_pub, + &phac, + &no_age_commitment, + &coin_sig, + &partner_url)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_purse_deposits_above_serial_id ( + pg, + 0, + &deposit_cb, + &ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_purse_deposits_by_purse (pg, + &purse_pub, + &refund_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * Paying a coin into a purse debits the coin and credits the purse. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_deposit (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Purse purse; + struct TALER_CoinPublicInfo coin; + struct DepositStatus st; + struct TALER_Amount amount; + struct TALER_Amount expect = TDB_amount ("1"); + struct TALER_DenominationHashP h_denom_pub; + struct TALER_AgeCommitmentHashP phac; + struct TALER_CoinSpendSignatureP coin_sig; + struct TALER_CoinSpendSignatureP expect_sig; + char *partner_url = NULL; + bool no_age_commitment = false; + + TDB_purse (pg, + 10, + "5", + ts (1700000000), + &purse); + TDB_coin (pg, + &denom, + 20, + &coin, + NULL); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_deposit (pg, + &purse, + &coin, + 20, + "1", + "0", + &st), + TDB_coin_free (&coin)); + FAILIF_C (! st.balance_ok, + TDB_coin_free (&coin)); + FAILIF_C (st.too_late, + TDB_coin_free (&coin)); + FAILIF_C (st.conflict, + TDB_coin_free (&coin)); + FAILIF_C (1 != TDB_count (pg, + "FROM purse_deposits"), + TDB_coin_free (&coin)); + + /* the coin was debited... */ + { + char *hex = TDB_hex (&coin.coin_pub, + sizeof (coin.coin_pub)); + + FAILIF_C (1 != TDB_count (pg, + "FROM known_coins" + " WHERE coin_pub=decode('%s','hex')" + " AND remaining=ROW(4,0)::taler_amount", + hex), + GNUNET_free (hex); TDB_coin_free (&coin)); + GNUNET_free (hex); + } + /* ...and the purse credited */ + { + char *hex = TDB_hex (&purse.purse_pub, + sizeof (purse.purse_pub)); + + FAILIF_C (1 != TDB_count (pg, + "FROM purse_requests" + " WHERE purse_pub=decode('%s','hex')" + " AND balance=ROW(1,0)::taler_amount", + hex), + GNUNET_free (hex); TDB_coin_free (&coin)); + GNUNET_free (hex); + } + + TDB_FILL (expect_sig, + 20); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_purse_deposit (pg, + &purse.purse_pub, + &coin.coin_pub, + &amount, + &h_denom_pub, + &phac, + &no_age_commitment, + &coin_sig, + &partner_url), + TDB_coin_free (&coin)); + FAILIF_C (0 != TALER_amount_cmp (&amount, + &expect), + GNUNET_free (partner_url); TDB_coin_free (&coin)); + FAILIF_C (0 != GNUNET_memcmp (&h_denom_pub, + &denom.h_denom_pub), + GNUNET_free (partner_url); TDB_coin_free (&coin)); + FAILIF_C (0 != GNUNET_memcmp (&coin_sig, + &expect_sig), + GNUNET_free (partner_url); TDB_coin_free (&coin)); + FAILIF_C (! no_age_commitment, + GNUNET_free (partner_url); TDB_coin_free (&coin)); + /* the purse is ours, so there is no partner exchange */ + FAILIF_C (NULL != partner_url, + GNUNET_free (partner_url); TDB_coin_free (&coin)); + + /* re-depositing the same coin with the same amount is idempotent */ + FAILIF_C (0 > + run_deposit (pg, + &purse, + &coin, + 20, + "1", + "0", + &st), + TDB_coin_free (&coin)); + FAILIF_C (1 != TDB_count (pg, + "FROM purse_deposits"), + TDB_coin_free (&coin)); + + /* ...but a second attempt with a different coin signature is a conflict: + the idempotency check compares the signature, not the amount */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_deposit (pg, + &purse, + &coin, + 29, + "1", + "0", + &st), + TDB_coin_free (&coin)); + FAILIF_C (! st.conflict, + TDB_coin_free (&coin)); + FAILIF_C (1 != TDB_count (pg, + "FROM purse_deposits"), + TDB_coin_free (&coin)); + TDB_coin_free (&coin); + return 0; +} + + +/** + * A coin without enough left cannot be paid into a purse. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insufficient_balance (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Purse purse; + struct TALER_CoinPublicInfo coin; + struct DepositStatus st; + + TDB_purse (pg, + 10, + "5", + ts (1700000000), + &purse); + TDB_coin (pg, + &denom, + 21, + &coin, + NULL); + /* The `purse_deposits` row goes in before the coin balance is checked, + so the caller has to roll back -- which is what the /purses/.../deposit + handler does. */ + FAILIF_C (GNUNET_OK != + TALER_EXCHANGEDB_start (pg, + "test-purse-deposit-insufficient"), + TDB_coin_free (&coin)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_deposit (pg, + &purse, + &coin, + 21, + "6", + "0", + &st), + TALER_EXCHANGEDB_rollback (pg); TDB_coin_free (&coin)); + TALER_EXCHANGEDB_rollback (pg); + TDB_coin_free (&coin); + FAILIF (st.balance_ok); + FAILIF (st.conflict); + FAILIF (1 != TDB_count (pg, + "FROM purse_deposits")); + return 0; +} + + +/** + * Once a merged purse has expired and been decided, the deposit that + * would complete it is refused as too late. + * + * Note what this shows about the shape of the function: the "too late" + * answer only comes out of the attempt to record the decision, which is + * only reached for a purse that has been merged *and* is completed by this + * deposit. A deposit into an expired purse that neither completes it nor + * has been merged is accepted -- see EDBT-9 in bugs.txt. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_too_late (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Purse purse; + struct TALER_CoinPublicInfo coin; + struct TALER_ReservePublicKeyP reserve_pub; + struct DepositStatus st; + struct GNUNET_TIME_Absolute expiration; + + /* a purse that expired in 2020, merged into a reserve */ + TDB_purse (pg, + 11, + "5", + ts (1600000000), + &purse); + TDB_reserve (pg, + 11, + "0", + &reserve_pub); + { + char *phex = TDB_hex (&purse.purse_pub, + sizeof (purse.purse_pub)); + char *rhex = TDB_hex (&reserve_pub, + sizeof (reserve_pub)); + + FAILIF_C (GNUNET_OK != + TDB_exec (pg, + "INSERT INTO purse_merges" + " (reserve_pub,purse_pub,merge_sig,merge_timestamp)" + " VALUES (decode('%s','hex'),decode('%s','hex')" + " ,decode(repeat('11',64),'hex'),1000000);", + rhex, + phex), + GNUNET_free (phex); GNUNET_free (rhex)); + GNUNET_free (phex); + GNUNET_free (rhex); + } + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_do_expire_purse (pg, + GNUNET_TIME_UNIT_ZERO_ABS, + GNUNET_TIME_absolute_get (), + &expiration)); + TDB_coin (pg, + &denom, + 22, + &coin, + NULL); + /* Pay in the full target amount: the "too late" answer only comes out + when the purse would be decided by this deposit, which is when the + function tries to insert the `purse_decision` row that already exists. + As with the balance check, the row is written first, so roll back. */ + FAILIF_C (GNUNET_OK != + TALER_EXCHANGEDB_start (pg, + "test-purse-deposit-too-late"), + TDB_coin_free (&coin)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_deposit (pg, + &purse, + &coin, + 22, + "5", + "0", + &st), + TALER_EXCHANGEDB_rollback (pg); TDB_coin_free (&coin)); + TALER_EXCHANGEDB_rollback (pg); + TDB_coin_free (&coin); + FAILIF (! st.too_late); + FAILIF (1 != TDB_count (pg, + "FROM purse_deposits")); + return 0; +} + + +/** + * The iterators see the deposit; the by-purse one only for its own purse. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinSpendPublicKeyP coin_pub; + struct TALER_PurseContractPublicKeyP purse_pub; + struct DepositContext ctx; + struct TALER_Amount expect = TDB_amount ("1"); + + TDB_FILL (coin_pub, + 20); + TDB_FILL (purse_pub, + 10); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.coin_pub = &coin_pub; + FAILIF (1 != + TALER_EXCHANGEDB_iterate_purse_deposits_above_serial_id ( + pg, + 0, + &deposit_cb, + &ctx)); + FAILIF (1 != ctx.matched); + FAILIF (0 != TALER_amount_cmp (&ctx.amount, + &expect)); + /* the purse was never merged, so there is no reserve */ + FAILIF (ctx.have_reserve); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_purse_deposits_above_serial_id ( + pg, + 1000, + &deposit_cb, + &ctx)); + FAILIF (0 != ctx.total); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.coin_pub = &coin_pub; + FAILIF (1 != + TALER_EXCHANGEDB_iterate_purse_deposits_by_purse (pg, + &purse_pub, + &refund_cb, + &ctx)); + FAILIF (1 != ctx.matched); + FAILIF (0 != TALER_amount_cmp (&ctx.amount, + &expect)); + + /* a purse nobody paid into has no coins */ + { + struct TALER_PurseContractPublicKeyP other; + + TDB_FILL (other, + 11); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_purse_deposits_by_purse (pg, + &other, + &refund_cb, + &ctx)); + FAILIF (0 != ctx.total); + } + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "purse-deposits-empty", + &check_empty }, + { "purse-deposits-deposit", + &check_deposit }, + { "purse-deposits-insufficient-balance", + &check_insufficient_balance }, + { "purse-deposits-too-late", + &check_too_late }, + { "purse-deposits-iterate", + &check_iterate }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-purse-deposits", + "Tests for the exchangedb `purse_deposits' table", + tests); + TDB_denom_free (&denom); + return ret; +} + + +/* end of test_purse_deposits.c */ diff --git a/src/exchangedb/test_purse_merges.c b/src/exchangedb/test_purse_merges.c @@ -0,0 +1,759 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_purse_merges.c + * @brief tests for the exchangedb functions whose primary table is + * `purse_merges` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_do_purse_merge(), + * #TALER_EXCHANGEDB_get_purse_merge(), + * #TALER_EXCHANGEDB_iterate_purse_merges_above_serial_id(), + * #TALER_EXCHANGEDB_iterate_wallet_merges(). + * + * A merge is the exchange acting on the merge capability key: it moves a + * paid-up purse into a reserve. do_purse_merge() therefore declines when + * the purse is not paid up and when the named partner exchange is unknown, + * and reports a conflict when the purse was already merged elsewhere. + */ +#include "test_common.h" +#include "exchange-database/do_purse_deposit.h" +#include "exchange-database/do_purse_merge.h" +#include "exchange-database/get_purse_merge.h" +#include "exchange-database/get_reserve.h" +#include "exchange-database/iterate_purse_merges_above_serial_id.h" +#include "exchange-database/iterate_wallet_merges.h" + + +/** + * Denomination the checks pay purses with. + */ +static struct TDB_Denom denom; + + +/** + * Wallet account the reserve of the checks belongs to. + */ +static struct TDB_Account account; + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Closure for #merge_cb(). + */ +struct MergeContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Stop after this many rows; 0 for no limit. + */ + unsigned int stop_after; + + /** + * Purse we are looking for, NULL to match nothing. + */ + const struct TALER_PurseContractPublicKeyP *purse_pub; + + /** + * How many times did we see it? + */ + unsigned int matched; + + /** + * Reserve reported for it. + */ + struct TALER_ReservePublicKeyP reserve_pub; + + /** + * Amount reported for it. + */ + struct TALER_Amount amount; + + /** + * Was a partner exchange reported for it? + */ + bool have_partner; +}; + + +/** + * Callback for + * #TALER_EXCHANGEDB_iterate_purse_merges_above_serial_id(). + * + * @param cls a `struct MergeContext *` + * @param rowid row of the merge + * @param partner_base_url partner exchange, NULL for us + * @param amount target amount of the purse + * @param balance balance of the purse + * @param flags flags of the purse + * @param merge_pub merge capability key + * @param reserve_pub reserve the purse went into + * @param merge_sig signature affirming the merge + * @param purse_pub the purse + * @param merge_timestamp when it was merged + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +merge_cb (void *cls, + uint64_t rowid, + const char *partner_base_url, + const struct TALER_Amount *amount, + const struct TALER_Amount *balance, + enum TALER_WalletAccountMergeFlags flags, + const struct TALER_PurseMergePublicKeyP *merge_pub, + const struct TALER_ReservePublicKeyP *reserve_pub, + const struct TALER_PurseMergeSignatureP *merge_sig, + const struct TALER_PurseContractPublicKeyP *purse_pub, + struct GNUNET_TIME_Timestamp merge_timestamp) +{ + struct MergeContext *ctx = cls; + + (void) rowid; + (void) balance; + (void) flags; + (void) merge_pub; + (void) merge_sig; + (void) merge_timestamp; + ctx->total++; + if ( (NULL != ctx->purse_pub) && + (0 == GNUNET_memcmp (purse_pub, + ctx->purse_pub)) ) + { + ctx->matched++; + ctx->reserve_pub = *reserve_pub; + ctx->amount = *amount; + ctx->have_partner = (NULL != partner_base_url); + } + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * Closure for #transfer_cb() and #amount_cb(). + */ +struct SumContext +{ + /** + * How many entries did the callback see? + */ + unsigned int total; + + /** + * Sum of the whole-unit parts of the amounts seen. + */ + uint64_t value_sum; + +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_wallet_merges(). + * + * @param cls a `struct SumContext *` + * @param row_id row of the merge + * @param payto_uri wallet account involved + * @param execution_time when the merge happened + * @param amount how much was merged + */ +static void +transfer_cb (void *cls, + uint64_t row_id, + const char *payto_uri, + struct GNUNET_TIME_Absolute execution_time, + const struct TALER_Amount *amount) +{ + struct SumContext *ctx = cls; + + (void) row_id; + (void) payto_uri; + (void) execution_time; + ctx->total++; + ctx->value_sum += amount->value; +} + + +/** + * Outcome of a merge. + */ +struct MergeStatus +{ + /** + * Was the named partner exchange unknown? + */ + bool no_partner; + + /** + * Was the purse not paid up? + */ + bool no_balance; + + /** + * Was the purse already merged elsewhere? + */ + bool in_conflict; +}; + + +/** + * Merge a purse into a reserve. + * + * @param pg the database context + * @param purse purse to merge + * @param reserve_pub reserve to credit + * @param h_payto normalized account of the reserve's wallet + * @param seed seed for the merge and reserve signatures + * @param partner_url partner exchange, NULL for us + * @param[out] st set to the outcome + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +run_merge (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TDB_Purse *purse, + const struct TALER_ReservePublicKeyP *reserve_pub, + uint32_t seed, + const char *partner_url, + struct MergeStatus *st) +{ + struct TALER_PurseMergeSignatureP merge_sig; + struct TALER_ReserveSignatureP reserve_sig; + + TDB_fill (&merge_sig, + sizeof (merge_sig), + seed); + TDB_fill (&reserve_sig, + sizeof (reserve_sig), + seed); + memset (st, + 0, + sizeof (*st)); + return TALER_EXCHANGEDB_do_purse_merge (pg, + &purse->purse_pub, + &merge_sig, + ts (1600000000), + &reserve_sig, + partner_url, + reserve_pub, + &st->no_partner, + &st->no_balance, + &st->in_conflict); +} + + +/** + * Pay a purse up to its target amount. + * + * @param pg the database context + * @param purse purse to fill + * @param seed seed for the coin + * @param amount how much to pay in, e.g. "5" + */ +static void +fill_purse (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TDB_Purse *purse, + uint32_t seed, + const char *amount) +{ + struct TALER_CoinPublicInfo coin; + struct TALER_CoinSpendSignatureP coin_sig; + struct TALER_Amount a = TDB_amount (amount); + bool balance_ok; + bool too_late; + bool conflict; + + TDB_coin (pg, + &denom, + seed, + &coin, + NULL); + TDB_fill (&coin_sig, + sizeof (coin_sig), + seed); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_EXCHANGEDB_do_purse_deposit (pg, + &purse->purse_pub, + &coin.coin_pub, + &a, + &coin_sig, + &a, + &balance_ok, + &too_late, + &conflict)); + GNUNET_assert (balance_ok); + TDB_coin_free (&coin); +} + + +/** + * Nothing is reported while the table is empty. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_PurseContractPublicKeyP purse_pub; + struct TALER_PurseMergeSignatureP merge_sig; + struct GNUNET_TIME_Timestamp merge_timestamp; + struct TALER_ReservePublicKeyP reserve_pub; + struct MergeContext ctx = { 0 }; + struct SumContext sctx = { 0 }; + struct TALER_Amount zero = TDB_amount ("0"); + char *partner_url = NULL; + bool refunded; + + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + TDB_account (pg, + 10, + &account); + TDB_FILL (purse_pub, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_purse_merge (pg, + &purse_pub, + &merge_sig, + &merge_timestamp, + &partner_url, + &reserve_pub, + &refunded)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_purse_merges_above_serial_id ( + pg, + 0, + &merge_cb, + &ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_wallet_merges (pg, + &zero, + 0, + 10, + NULL, + &transfer_cb, + &sctx)); + FAILIF (0 != ctx.total); + FAILIF (0 != sctx.total); + return 0; +} + + +/** + * A purse that has not been paid up cannot be merged. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_no_balance (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Purse purse; + struct TALER_ReservePublicKeyP reserve_pub; + struct MergeStatus st; + + TDB_purse (pg, + 10, + "5", + ts (1700000000), + &purse); + TDB_reserve (pg, + 10, + "0", + &reserve_pub); + /* only EUR:1 of the EUR:5 has been paid in */ + fill_purse (pg, + &purse, + 20, + "1"); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_merge (pg, + &purse, + &reserve_pub, + 10, + NULL, + &st)); + FAILIF (! st.no_balance); + FAILIF (st.no_partner); + FAILIF (st.in_conflict); + FAILIF (0 != TDB_count (pg, + "FROM purse_merges")); + return 0; +} + + +/** + * A partner exchange the exchange does not know cannot receive a merge. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_no_partner (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Purse purse; + struct TALER_ReservePublicKeyP reserve_pub; + struct MergeStatus st; + + TDB_purse (pg, + 10, + "5", + ts (1700000000), + &purse); + TDB_fill (&reserve_pub, + sizeof (reserve_pub), + 10); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_merge (pg, + &purse, + &reserve_pub, + 10, + "https://partner.example/", + &st)); + FAILIF (! st.no_partner); + FAILIF (0 != TDB_count (pg, + "FROM purse_merges")); + return 0; +} + + +/** + * A paid-up purse is merged into the reserve, which is credited. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_merge (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Purse purse; + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_ReservePublicKeyP got_reserve; + struct TALER_EXCHANGEDB_Reserve reserve; + struct MergeStatus st; + struct MergeContext ctx; + struct TALER_PurseMergeSignatureP merge_sig; + struct TALER_PurseMergeSignatureP expect_sig; + struct GNUNET_TIME_Timestamp merge_timestamp; + struct TALER_Amount expect_balance = TDB_amount ("5"); + char *partner_url = NULL; + bool refunded = true; + + TDB_purse (pg, + 10, + "5", + ts (1700000000), + &purse); + TDB_fill (&reserve_pub, + sizeof (reserve_pub), + 10); + /* pay in the remaining EUR:4 */ + fill_purse (pg, + &purse, + 21, + "4"); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_merge (pg, + &purse, + &reserve_pub, + 10, + NULL, + &st)); + FAILIF (st.no_balance); + FAILIF (st.no_partner); + FAILIF (st.in_conflict); + FAILIF (1 != TDB_count (pg, + "FROM purse_merges")); + + /* the reserve got the money */ + memset (&reserve, + 0, + sizeof (reserve)); + reserve.pub = reserve_pub; + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve (pg, + &reserve)); + FAILIF (0 != TALER_amount_cmp (&reserve.balance, + &expect_balance)); + + TDB_FILL (expect_sig, + 10); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_purse_merge (pg, + &purse.purse_pub, + &merge_sig, + &merge_timestamp, + &partner_url, + &got_reserve, + &refunded)); + FAILIF (0 != GNUNET_memcmp (&merge_sig, + &expect_sig)); + FAILIF (0 != GNUNET_memcmp (&got_reserve, + &reserve_pub)); + FAILIF (GNUNET_TIME_timestamp_cmp (merge_timestamp, + !=, + ts (1600000000))); + FAILIF (refunded); + /* the purse stayed with us, so there is no partner exchange */ + FAILIF (NULL != partner_url); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.purse_pub = &purse.purse_pub; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_purse_merges_above_serial_id ( + pg, + 0, + &merge_cb, + &ctx)); + FAILIF (1 != ctx.matched); + FAILIF (0 != GNUNET_memcmp (&ctx.reserve_pub, + &reserve_pub)); + FAILIF (0 != TALER_amount_cmp (&ctx.amount, + &expect_balance)); + FAILIF (ctx.have_partner); + + /* merging the same purse into a different reserve is a conflict */ + { + struct TALER_ReservePublicKeyP other; + + TDB_reserve (pg, + 12, + "0", + &other); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_merge (pg, + &purse, + &other, + 12, + NULL, + &st)); + FAILIF (! st.in_conflict); + FAILIF (1 != TDB_count (pg, + "FROM purse_merges")); + } + return 0; +} + + +/** + * The wallet's view of its merges reports the purse once the reserve is + * tied to a wallet account. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_wallet_merges (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_Amount zero = TDB_amount ("0"); + struct TALER_Amount huge = TDB_amount ("1000"); + struct SumContext ctx; + char *rhex; + char *nhex; + + /* Until the reserve is known to belong to a wallet account, the view + has nothing to join against. */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_wallet_merges (pg, + &zero, + 0, + 10, + NULL, + &transfer_cb, + &ctx)); + FAILIF (0 != ctx.total); + + TDB_fill (&reserve_pub, + sizeof (reserve_pub), + 10); + rhex = TDB_hex (&reserve_pub, + sizeof (reserve_pub)); + nhex = TDB_hex (&account.h_normalized, + sizeof (account.h_normalized)); + FAILIF_C (GNUNET_OK != + TDB_exec (pg, + "UPDATE kyc_targets" + " SET target_pub=decode('%s','hex')" + " ,is_wallet=TRUE" + " WHERE h_normalized_payto=decode('%s','hex');", + rhex, + nhex), + GNUNET_free (rhex); GNUNET_free (nhex)); + GNUNET_free (rhex); + GNUNET_free (nhex); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (1 != + TALER_EXCHANGEDB_iterate_wallet_merges (pg, + &zero, + 0, + 10, + NULL, + &transfer_cb, + &ctx)); + FAILIF (5 != ctx.value_sum); + + /* filtering by that very account keeps it */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (1 != + TALER_EXCHANGEDB_iterate_wallet_merges (pg, + &zero, + 0, + 10, + &account.h_normalized, + &transfer_cb, + &ctx)); + FAILIF (1 != ctx.total); + + /* a threshold nobody meets drops it */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_wallet_merges (pg, + &huge, + 0, + 10, + NULL, + &transfer_cb, + &ctx)); + FAILIF (0 != ctx.total); + + /* and the negative limit walks the table the other way */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (1 != + TALER_EXCHANGEDB_iterate_wallet_merges (pg, + &zero, + 1000, + -10, + NULL, + &transfer_cb, + &ctx)); + FAILIF (1 != ctx.total); + return 0; +} + + +/** + * The iterators' bounds and abort return behave as documented. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct MergeContext ctx; + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_purse_merges_above_serial_id ( + pg, + 1000, + &merge_cb, + &ctx)); + FAILIF (0 != ctx.total); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.stop_after = 1; + FAILIF (1 != + TALER_EXCHANGEDB_iterate_purse_merges_above_serial_id ( + pg, + 0, + &merge_cb, + &ctx)); + FAILIF (1 != ctx.total); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "purse-merges-empty", + &check_empty }, + { "purse-merges-no-balance", + &check_no_balance }, + { "purse-merges-no-partner", + &check_no_partner }, + { "purse-merges-merge", + &check_merge }, + { "purse-merges-wallet-merges", + &check_wallet_merges }, + { "purse-merges-iterate", + &check_iterate }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-purse-merges", + "Tests for the exchangedb `purse_merges' table", + tests); + TDB_account_free (&account); + TDB_denom_free (&denom); + return ret; +} + + +/* end of test_purse_merges.c */ diff --git a/src/exchangedb/test_purse_requests.c b/src/exchangedb/test_purse_requests.c @@ -0,0 +1,477 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_purse_requests.c + * @brief tests for the exchangedb functions whose primary table is + * `purse_requests` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_purse_request(), + * #TALER_EXCHANGEDB_get_purse_request(), + * #TALER_EXCHANGEDB_get_purse_by_merge_pub(), + * #TALER_EXCHANGEDB_get_purse() and + * #TALER_EXCHANGEDB_iterate_purse_requests_above_serial_id(). + * + * `purse_requests` has no foreign keys -- a purse is created before + * anything is paid into it. The interesting case of the insert is the + * conflict: re-creating a purse with the same key but different meta data + * is reported through @e in_conflict rather than as an error. + */ +#include "test_common.h" +#include "exchange-database/get_purse.h" +#include "exchange-database/get_purse_by_merge_pub.h" +#include "exchange-database/get_purse_request.h" +#include "exchange-database/insert_purse_request.h" +#include "exchange-database/iterate_purse_requests_above_serial_id.h" + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Closure for #purse_cb(). + */ +struct PurseContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Stop after this many rows; 0 for no limit. + */ + unsigned int stop_after; + + /** + * Purse we are looking for, NULL to match nothing. + */ + const struct TALER_PurseContractPublicKeyP *purse_pub; + + /** + * How many times did we see it? + */ + unsigned int matched; + + /** + * Target amount reported for it. + */ + struct TALER_Amount amount; + + /** + * Age limit reported for it. + */ + uint32_t age_limit; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_purse_requests_above_serial_id(). + * + * @param cls a `struct PurseContext *` + * @param rowid row of the purse + * @param purse_pub the purse + * @param merge_pub its merge capability key + * @param purse_creation when it was created + * @param purse_expiration when it expires + * @param h_contract_terms contract it is for + * @param age_limit age limit on deposits into it + * @param target_amount how much it is meant to hold + * @param purse_sig signature over its meta data + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +purse_cb (void *cls, + uint64_t rowid, + const struct TALER_PurseContractPublicKeyP *purse_pub, + const struct TALER_PurseMergePublicKeyP *merge_pub, + struct GNUNET_TIME_Timestamp purse_creation, + struct GNUNET_TIME_Timestamp purse_expiration, + const struct TALER_PrivateContractHashP *h_contract_terms, + uint32_t age_limit, + const struct TALER_Amount *target_amount, + const struct TALER_PurseContractSignatureP *purse_sig) +{ + struct PurseContext *ctx = cls; + + (void) rowid; + (void) merge_pub; + (void) purse_creation; + (void) purse_expiration; + (void) h_contract_terms; + (void) purse_sig; + ctx->total++; + if ( (NULL != ctx->purse_pub) && + (0 == GNUNET_memcmp (purse_pub, + ctx->purse_pub)) ) + { + ctx->matched++; + ctx->amount = *target_amount; + ctx->age_limit = age_limit; + } + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * Nothing is known while the table is empty. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_PurseContractPublicKeyP purse_pub; + struct TALER_PurseMergePublicKeyP merge_pub; + struct GNUNET_TIME_Timestamp purse_expiration; + struct GNUNET_TIME_Timestamp purse_creation; + struct GNUNET_TIME_Timestamp merge_timestamp; + struct TALER_PrivateContractHashP h_contract_terms; + struct TALER_Amount target_amount; + struct TALER_Amount balance; + struct TALER_PurseContractSignatureP purse_sig; + struct PurseContext ctx = { 0 }; + uint32_t age_limit; + bool purse_deleted; + bool purse_refunded; + + TDB_FILL (purse_pub, + 1); + TDB_FILL (merge_pub, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_purse_request (pg, + &purse_pub, + &merge_pub, + &purse_expiration, + &h_contract_terms, + &age_limit, + &target_amount, + &balance, + &purse_sig)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_purse_by_merge_pub (pg, + &merge_pub, + &purse_pub, + &purse_expiration, + &h_contract_terms, + &age_limit, + &target_amount, + &balance, + &purse_sig)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_purse (pg, + &purse_pub, + &purse_creation, + &purse_expiration, + &target_amount, + &balance, + &h_contract_terms, + &merge_timestamp, + &purse_deleted, + &purse_refunded)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_purse_requests_above_serial_id ( + pg, + 0, + &purse_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * A created purse is found by its own key, by its merge key and by the + * combined status lookup. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_create_and_lookup (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Purse purse; + struct TALER_PurseContractPublicKeyP got_pub; + struct TALER_PurseMergePublicKeyP got_merge; + struct GNUNET_TIME_Timestamp purse_expiration; + struct GNUNET_TIME_Timestamp purse_creation; + struct GNUNET_TIME_Timestamp merge_timestamp; + struct TALER_PrivateContractHashP h_contract_terms; + struct TALER_Amount target_amount; + struct TALER_Amount balance; + struct TALER_Amount zero = TDB_amount ("0"); + struct TALER_PurseContractSignatureP purse_sig; + uint32_t age_limit; + bool purse_deleted = true; + bool purse_refunded = true; + + TDB_purse (pg, + 10, + "5", + ts (1700000000), + &purse); + FAILIF (1 != TDB_count (pg, + "FROM purse_requests")); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_purse_request (pg, + &purse.purse_pub, + &got_merge, + &purse_expiration, + &h_contract_terms, + &age_limit, + &target_amount, + &balance, + &purse_sig)); + FAILIF (0 != GNUNET_memcmp (&got_merge, + &purse.merge_pub)); + FAILIF (0 != GNUNET_memcmp (&h_contract_terms, + &purse.h_contract_terms)); + FAILIF (0 != GNUNET_memcmp (&purse_sig, + &purse.purse_sig)); + FAILIF (GNUNET_TIME_timestamp_cmp (purse_expiration, + !=, + purse.purse_expiration)); + FAILIF (0 != TALER_amount_cmp (&target_amount, + &purse.amount)); + /* nothing has been paid in yet */ + FAILIF (0 != TALER_amount_cmp (&balance, + &zero)); + FAILIF (0 != age_limit); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_purse_by_merge_pub (pg, + &purse.merge_pub, + &got_pub, + &purse_expiration, + &h_contract_terms, + &age_limit, + &target_amount, + &balance, + &purse_sig)); + FAILIF (0 != GNUNET_memcmp (&got_pub, + &purse.purse_pub)); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_purse (pg, + &purse.purse_pub, + &purse_creation, + &purse_expiration, + &target_amount, + &balance, + &h_contract_terms, + &merge_timestamp, + &purse_deleted, + &purse_refunded)); + FAILIF (purse_deleted); + FAILIF (purse_refunded); + FAILIF (0 != TALER_amount_cmp (&balance, + &zero)); + /* the purse has not been merged */ + FAILIF (! GNUNET_TIME_absolute_is_never (merge_timestamp.abs_time)); + + /* a merge key nobody used finds nothing */ + { + struct TALER_PurseMergePublicKeyP other; + + TDB_FILL (other, + 99); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_purse_by_merge_pub (pg, + &other, + &got_pub, + &purse_expiration, + &h_contract_terms, + &age_limit, + &target_amount, + &balance, + &purse_sig)); + } + return 0; +} + + +/** + * Re-creating a purse with the same key is idempotent if the meta data + * matches and a conflict if it does not. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_conflict (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Purse purse; + struct TALER_Amount purse_fee = TDB_amount ("0"); + struct TALER_Amount other_amount = TDB_amount ("9"); + bool in_conflict = false; + + /* TDB_purse() with the same seed replays the identical request */ + TDB_purse (pg, + 10, + "5", + ts (1700000000), + &purse); + FAILIF (1 != TDB_count (pg, + "FROM purse_requests")); + /* An identical replay is reported as "nothing to do". Note that the + function leaves @e in_conflict untouched on that path, so only the + status says so. */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_insert_purse_request ( + pg, + &purse.purse_pub, + &purse.merge_pub, + purse.purse_expiration, + &purse.h_contract_terms, + 0, + TALER_WAMF_MODE_CREATE_FROM_PURSE_QUOTA, + &purse_fee, + &purse.amount, + &purse.purse_sig, + &in_conflict)); + FAILIF (in_conflict); + + /* the same purse key with a different target amount is a conflict */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_purse_request ( + pg, + &purse.purse_pub, + &purse.merge_pub, + purse.purse_expiration, + &purse.h_contract_terms, + 0, + TALER_WAMF_MODE_CREATE_FROM_PURSE_QUOTA, + &purse_fee, + &other_amount, + &purse.purse_sig, + &in_conflict)); + FAILIF (! in_conflict); + FAILIF (1 != TDB_count (pg, + "FROM purse_requests")); + return 0; +} + + +/** + * The iterator walks the table by serial and honours an aborting + * callback. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Purse p2; + struct PurseContext ctx; + + TDB_purse (pg, + 11, + "3", + ts (1700003600), + &p2); + FAILIF (2 != TDB_count (pg, + "FROM purse_requests")); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.purse_pub = &p2.purse_pub; + FAILIF (2 != + TALER_EXCHANGEDB_iterate_purse_requests_above_serial_id ( + pg, + 0, + &purse_cb, + &ctx)); + FAILIF (1 != ctx.matched); + FAILIF (0 != TALER_amount_cmp (&ctx.amount, + &p2.amount)); + FAILIF (0 != ctx.age_limit); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_purse_requests_above_serial_id ( + pg, + 1000, + &purse_cb, + &ctx)); + FAILIF (0 != ctx.total); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.stop_after = 1; + FAILIF (2 != + TALER_EXCHANGEDB_iterate_purse_requests_above_serial_id ( + pg, + 0, + &purse_cb, + &ctx)); + FAILIF (1 != ctx.total); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "purse-requests-empty", + &check_empty }, + { "purse-requests-create-and-lookup", + &check_create_and_lookup }, + { "purse-requests-conflict", + &check_conflict }, + { "purse-requests-iterate", + &check_iterate }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-purse-requests", + "Tests for the exchangedb `purse_requests' table", + tests); +} + + +/* end of test_purse_requests.c */ diff --git a/src/exchangedb/test_recoup.c b/src/exchangedb/test_recoup.c @@ -0,0 +1,531 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_recoup.c + * @brief tests for the exchangedb functions whose primary table is `recoup` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_do_recoup() and + * #TALER_EXCHANGEDB_iterate_recoups_above_serial_id(). + * + * `recoup` references `known_coins` and `withdraw`, so a full chain is + * built for each check: a funded reserve, a withdraw from it and a coin. + * do_recoup() moves the coin's whole remaining balance back to the reserve, + * which is why the checks look at both sides afterwards. + */ +#include "test_common.h" +#include "exchange-database/do_recoup.h" +#include "exchange-database/get_reserve.h" +#include "exchange-database/iterate_recoups_above_serial_id.h" + + +/** + * Account the checks fund their reserves from. + */ +static struct TDB_Account account; + + +/** + * Denomination the checks use. + */ +static struct TDB_Denom denom; + + +/** + * Closure for #recoup_cb(). + */ +struct RecoupContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Stop after this many rows; 0 for no limit. + */ + unsigned int stop_after; + + /** + * Coin we are looking for, NULL to match nothing. + */ + const struct TALER_CoinSpendPublicKeyP *coin_pub; + + /** + * How many times did we see it? + */ + unsigned int matched; + + /** + * Amount reported for it. + */ + struct TALER_Amount amount; + + /** + * Reserve reported for it. + */ + struct TALER_ReservePublicKeyP reserve_pub; + + /** + * Blinding secret reported for it. + */ + union GNUNET_CRYPTO_BlindingSecretP coin_blind; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_recoups_above_serial_id(). + * + * @param cls a `struct RecoupContext *` + * @param rowid row of the recoup + * @param timestamp when the recoup was requested + * @param amount how much went back to the reserve + * @param reserve_pub the reserve that was credited + * @param coin the coin that was recouped + * @param denom_pub denomination of @a coin + * @param coin_sig signature over the request + * @param coin_blind blinding secret of the coin + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +recoup_cb (void *cls, + uint64_t rowid, + struct GNUNET_TIME_Timestamp timestamp, + const struct TALER_Amount *amount, + const struct TALER_ReservePublicKeyP *reserve_pub, + const struct TALER_CoinPublicInfo *coin, + const struct TALER_DenominationPublicKey *denom_pub, + const struct TALER_CoinSpendSignatureP *coin_sig, + const union GNUNET_CRYPTO_BlindingSecretP *coin_blind) +{ + struct RecoupContext *ctx = cls; + + (void) rowid; + (void) timestamp; + (void) denom_pub; + (void) coin_sig; + ctx->total++; + if ( (NULL != ctx->coin_pub) && + (0 == GNUNET_memcmp (&coin->coin_pub, + ctx->coin_pub)) ) + { + ctx->matched++; + ctx->amount = *amount; + ctx->reserve_pub = *reserve_pub; + ctx->coin_blind = *coin_blind; + } + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * Outcome of a recoup request. + */ +struct RecoupStatus +{ + /** + * Was the recoup accepted? + */ + bool recoup_ok; + + /** + * Did something go wrong inside the database? + */ + bool internal_failure; + + /** + * When the recoup happened. + */ + struct GNUNET_TIME_Timestamp recoup_timestamp; +}; + + +/** + * Recoup a coin into a reserve. + * + * @param pg the database context + * @param reserve_pub reserve to credit + * @param withdraw_id withdraw that justifies the recoup + * @param coin_pub coin to recoup + * @param known_coin_id row of @a coin_pub + * @param seed seed for the blinding secret and coin signature + * @param[out] st set to the outcome + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +run_recoup (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TALER_ReservePublicKeyP *reserve_pub, + uint64_t withdraw_id, + const struct TALER_CoinSpendPublicKeyP *coin_pub, + uint64_t known_coin_id, + uint32_t seed, + struct RecoupStatus *st) +{ + union GNUNET_CRYPTO_BlindingSecretP coin_bks; + struct TALER_CoinSpendSignatureP coin_sig; + + TDB_fill (&coin_bks, + sizeof (coin_bks), + seed); + TDB_fill (&coin_sig, + sizeof (coin_sig), + seed); + memset (st, + 0, + sizeof (*st)); + st->recoup_timestamp = GNUNET_TIME_timestamp_get (); + return TALER_EXCHANGEDB_do_recoup (pg, + reserve_pub, + withdraw_id, + &coin_bks, + coin_pub, + known_coin_id, + &coin_sig, + &st->recoup_timestamp, + &st->recoup_ok, + &st->internal_failure); +} + + +/** + * Recouping a coin the exchange does not know is an internal failure. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_unknown_coin (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_CoinSpendPublicKeyP coin_pub; + struct RecoupContext ctx = { 0 }; + struct RecoupStatus st; + uint64_t withdraw_id; + + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + TDB_account (pg, + 10, + &account); + TDB_reserve_in (pg, + &account, + 10, + "10", + &reserve_pub); + withdraw_id = TDB_withdraw (pg, + &denom, + &reserve_pub, + 10, + "5"); + TDB_FILL (coin_pub, + 99); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_recoup (pg, + &reserve_pub, + withdraw_id, + &coin_pub, + 1, + 99, + &st)); + FAILIF (! st.internal_failure); + FAILIF (st.recoup_ok); + FAILIF (0 != TDB_count (pg, + "FROM recoup")); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_recoups_above_serial_id (pg, + 0, + &recoup_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * A coin that is already empty and was never recouped is refused. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty_coin (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_CoinPublicInfo coin; + struct RecoupStatus st; + uint64_t known_coin_id; + uint64_t withdraw_id; + char *hex; + + TDB_reserve_in (pg, + &account, + 11, + "10", + &reserve_pub); + withdraw_id = TDB_withdraw (pg, + &denom, + &reserve_pub, + 11, + "5"); + TDB_coin (pg, + &denom, + 20, + &coin, + &known_coin_id); + hex = TDB_hex (&coin.coin_pub, + sizeof (coin.coin_pub)); + FAILIF_C (GNUNET_OK != + TDB_exec (pg, + "UPDATE known_coins" + " SET remaining=ROW(0,0)::taler_amount" + " WHERE coin_pub=decode('%s','hex');", + hex), + GNUNET_free (hex); TDB_coin_free (&coin)); + GNUNET_free (hex); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_recoup (pg, + &reserve_pub, + withdraw_id, + &coin.coin_pub, + known_coin_id, + 20, + &st), + TDB_coin_free (&coin)); + TDB_coin_free (&coin); + FAILIF (st.internal_failure); + /* nothing to give back, and no earlier recoup to report */ + FAILIF (st.recoup_ok); + FAILIF (0 != TDB_count (pg, + "FROM recoup")); + return 0; +} + + +/** + * A funded coin is drained and the reserve is credited. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_recoup (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_CoinPublicInfo coin; + struct TALER_EXCHANGEDB_Reserve reserve; + struct RecoupStatus st; + struct RecoupContext ctx; + struct TALER_Amount expect_coin = TDB_amount ("5"); + struct TALER_Amount expect_reserve = TDB_amount ("10"); + union GNUNET_CRYPTO_BlindingSecretP expect_bks; + uint64_t known_coin_id; + uint64_t withdraw_id; + char *hex; + + TDB_reserve_in (pg, + &account, + 12, + "10", + &reserve_pub); + withdraw_id = TDB_withdraw (pg, + &denom, + &reserve_pub, + 12, + "5"); + TDB_coin (pg, + &denom, + 21, + &coin, + &known_coin_id); + /* EUR:10 came in and EUR:5 was withdrawn, so EUR:5 is left */ + memset (&reserve, + 0, + sizeof (reserve)); + reserve.pub = reserve_pub; + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve (pg, + &reserve), + TDB_coin_free (&coin)); + + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_recoup (pg, + &reserve_pub, + withdraw_id, + &coin.coin_pub, + known_coin_id, + 21, + &st), + TDB_coin_free (&coin)); + FAILIF_C (st.internal_failure, + TDB_coin_free (&coin)); + FAILIF_C (! st.recoup_ok, + TDB_coin_free (&coin)); + FAILIF_C (1 != TDB_count (pg, + "FROM recoup"), + TDB_coin_free (&coin)); + + /* the coin is empty... */ + hex = TDB_hex (&coin.coin_pub, + sizeof (coin.coin_pub)); + FAILIF_C (1 != TDB_count (pg, + "FROM known_coins" + " WHERE coin_pub=decode('%s','hex')" + " AND remaining=ROW(0,0)::taler_amount", + hex), + GNUNET_free (hex); TDB_coin_free (&coin)); + GNUNET_free (hex); + /* ...and the reserve got the money */ + memset (&reserve, + 0, + sizeof (reserve)); + reserve.pub = reserve_pub; + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve (pg, + &reserve), + TDB_coin_free (&coin)); + FAILIF_C (0 != TALER_amount_cmp (&reserve.balance, + &expect_reserve), + TDB_coin_free (&coin)); + + /* the recoup is reported with everything it was made with */ + TDB_FILL (expect_bks, + 21); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.coin_pub = &coin.coin_pub; + FAILIF_C (0 >= + TALER_EXCHANGEDB_iterate_recoups_above_serial_id (pg, + 0, + &recoup_cb, + &ctx), + TDB_coin_free (&coin)); + FAILIF_C (1 != ctx.matched, + TDB_coin_free (&coin)); + FAILIF_C (0 != TALER_amount_cmp (&ctx.amount, + &expect_coin), + TDB_coin_free (&coin)); + FAILIF_C (0 != GNUNET_memcmp (&ctx.reserve_pub, + &reserve_pub), + TDB_coin_free (&coin)); + FAILIF_C (0 != GNUNET_memcmp (&ctx.coin_blind, + &expect_bks), + TDB_coin_free (&coin)); + + /* recouping the same coin again finds the earlier recoup */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_recoup (pg, + &reserve_pub, + withdraw_id, + &coin.coin_pub, + known_coin_id, + 21, + &st), + TDB_coin_free (&coin)); + FAILIF_C (! st.recoup_ok, + TDB_coin_free (&coin)); + FAILIF_C (st.internal_failure, + TDB_coin_free (&coin)); + FAILIF_C (1 != TDB_count (pg, + "FROM recoup"), + TDB_coin_free (&coin)); + TDB_coin_free (&coin); + return 0; +} + + +/** + * The iterator's serial bound and abort return behave as documented. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct RecoupContext ctx; + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (1 != + TALER_EXCHANGEDB_iterate_recoups_above_serial_id (pg, + 0, + &recoup_cb, + &ctx)); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_recoups_above_serial_id (pg, + 1000, + &recoup_cb, + &ctx)); + FAILIF (0 != ctx.total); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.stop_after = 1; + FAILIF (1 != + TALER_EXCHANGEDB_iterate_recoups_above_serial_id (pg, + 0, + &recoup_cb, + &ctx)); + FAILIF (1 != ctx.total); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "recoup-unknown-coin", + &check_unknown_coin }, + { "recoup-empty-coin", + &check_empty_coin }, + { "recoup-recoup", + &check_recoup }, + { "recoup-iterate", + &check_iterate }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-recoup", + "Tests for the exchangedb `recoup' table", + tests); + TDB_account_free (&account); + TDB_denom_free (&denom); + return ret; +} + + +/* end of test_recoup.c */ diff --git a/src/exchangedb/test_recoup_refresh.c b/src/exchangedb/test_recoup_refresh.c @@ -0,0 +1,527 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_recoup_refresh.c + * @brief tests for the exchangedb functions whose primary table is + * `recoup_refresh` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_do_recoup_refresh() and, as far as it can be, + * #TALER_EXCHANGEDB_iterate_recoup_refreshes_above_serial_id(). + * + * `recoup_refresh` references `known_coins` and `refresh`, so each check + * builds a melt first. do_recoup_refresh() moves the fresh coin's whole + * remaining balance back to the old coin. + * + * The iterator cannot be checked beyond "does not invent an answer": its + * statement joins `refresh_revealed_coins` and `refresh_commitments`, which + * the schema no longer has (EDB-16 in bugs.txt, in-tree FIXME-#9828). + */ +#include "test_common.h" +#include "exchange-database/do_recoup_refresh.h" +#include "exchange-database/do_refresh.h" +#include "exchange-database/iterate_recoup_refreshes_above_serial_id.h" + + +/** + * Account the checks fund their reserves from. + */ +static struct TDB_Account account; + + +/** + * Denomination the checks use. + */ +static struct TDB_Denom denom; + + +/** + * Melt @a coin and return the row of the melt. + * + * @param pg the database context + * @param coin coin to melt + * @param seed seed for the commitment and signatures + * @param amount how much to melt, e.g. "1" + * @return row of the melt in `refresh` + */ +static uint64_t +melt (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TALER_CoinPublicInfo *coin, + uint32_t seed, + const char *amount) +{ + struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS rf; + struct TALER_BlindedDenominationSignature denom_sig; + struct GNUNET_TIME_Timestamp now = GNUNET_TIME_timestamp_get (); + struct TALER_Amount coin_balance; + uint64_t denom_serial = denom.serial; + uint64_t refresh_id; + uint32_t noreveal_index; + bool found; + bool zombie_required = false; + bool nonce_reuse; + bool balance_ok; + + memset (&rf, + 0, + sizeof (rf)); + rf.coin.coin_pub = coin->coin_pub; + rf.coin.denom_pub_hash = coin->denom_pub_hash; + rf.coin.no_age_commitment = coin->no_age_commitment; + TDB_fill (&rf.coin_sig, + sizeof (rf.coin_sig), + seed); + TDB_fill (&rf.rc, + sizeof (rf.rc), + seed); + TDB_fill (&rf.refresh_seed, + sizeof (rf.refresh_seed), + seed); + TDB_fill (&rf.planchets_h, + sizeof (rf.planchets_h), + seed); + TDB_fill (&rf.selected_h, + sizeof (rf.selected_h), + seed + 1); + rf.amount_with_fee = TDB_amount (amount); + rf.num_coins = 1; + rf.denom_serials = &denom_serial; + TDB_blinded_denom_sig (seed, + &denom_sig); + rf.denom_sigs = &denom_sig; + rf.noreveal_index = 0; + rf.is_v27_refresh = true; + rf.no_blinding_seed = true; + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_EXCHANGEDB_do_refresh (pg, + &rf, + &now, + &found, + &noreveal_index, + &zombie_required, + &nonce_reuse, + &balance_ok, + &coin_balance)); + GNUNET_assert (balance_ok); + TALER_blinded_denom_sig_free (&denom_sig); + { + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_auto_from_type (&rf.rc), + GNUNET_PQ_query_param_end + }; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_uint64 ("refresh_id", + &refresh_id), + GNUNET_PQ_result_spec_end + }; + + GNUNET_assert (GNUNET_OK == + GNUNET_PQ_prepare_anon (pg->conn, + "SELECT refresh_id" + " FROM refresh" + " WHERE rc=$1;")); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "", + params, + rs)); + } + return refresh_id; +} + + +/** + * Callback for + * #TALER_EXCHANGEDB_iterate_recoup_refreshes_above_serial_id(). + * + * @param cls a pointer to an `unsigned int` counter + * @param rowid row of the recoup + * @param timestamp when it happened + * @param amount how much went back + * @param old_coin_pub the old coin + * @param old_denom_pub_hash denomination of the old coin + * @param coin the fresh coin + * @param denom_pub denomination of @a coin + * @param coin_sig signature over the request + * @param coin_blind blinding secret of the fresh coin + * @return #GNUNET_OK + */ +static enum GNUNET_GenericReturnValue +recoup_refresh_cb (void *cls, + uint64_t rowid, + struct GNUNET_TIME_Timestamp timestamp, + const struct TALER_Amount *amount, + const struct TALER_CoinSpendPublicKeyP *old_coin_pub, + const struct TALER_DenominationHashP *old_denom_pub_hash, + const struct TALER_CoinPublicInfo *coin, + const struct TALER_DenominationPublicKey *denom_pub, + const struct TALER_CoinSpendSignatureP *coin_sig, + const union GNUNET_CRYPTO_BlindingSecretP *coin_blind) +{ + unsigned int *total = cls; + + (void) rowid; + (void) timestamp; + (void) amount; + (void) old_coin_pub; + (void) old_denom_pub_hash; + (void) coin; + (void) denom_pub; + (void) coin_sig; + (void) coin_blind; + (*total)++; + return GNUNET_OK; +} + + +/** + * Outcome of a recoup-refresh request. + */ +struct RecoupStatus +{ + /** + * Was the recoup accepted? + */ + bool recoup_ok; + + /** + * Did something go wrong inside the database? + */ + bool internal_failure; + + /** + * When the recoup happened. + */ + struct GNUNET_TIME_Timestamp recoup_timestamp; +}; + + +/** + * Recoup a fresh coin back onto the coin it was refreshed from. + * + * @param pg the database context + * @param old_coin_pub coin to credit + * @param refresh_id melt that justifies the recoup + * @param coin_pub fresh coin to drain + * @param known_coin_id row of @a coin_pub + * @param seed seed for the blinding secret and coin signature + * @param[out] st set to the outcome + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +run_recoup (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TALER_CoinSpendPublicKeyP *old_coin_pub, + uint64_t refresh_id, + const struct TALER_CoinSpendPublicKeyP *coin_pub, + uint64_t known_coin_id, + uint32_t seed, + struct RecoupStatus *st) +{ + union GNUNET_CRYPTO_BlindingSecretP coin_bks; + struct TALER_CoinSpendSignatureP coin_sig; + + TDB_fill (&coin_bks, + sizeof (coin_bks), + seed); + TDB_fill (&coin_sig, + sizeof (coin_sig), + seed); + memset (st, + 0, + sizeof (*st)); + st->recoup_timestamp = GNUNET_TIME_timestamp_get (); + return TALER_EXCHANGEDB_do_recoup_refresh (pg, + old_coin_pub, + refresh_id, + &coin_bks, + coin_pub, + known_coin_id, + &coin_sig, + &st->recoup_timestamp, + &st->recoup_ok, + &st->internal_failure); +} + + +/** + * Recouping a coin the exchange does not know is an internal failure. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_unknown_coin (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_CoinPublicInfo old_coin; + struct TALER_CoinSpendPublicKeyP coin_pub; + struct RecoupStatus st; + uint64_t refresh_id; + + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + TDB_account (pg, + 10, + &account); + TDB_reserve_in (pg, + &account, + 10, + "10", + &reserve_pub); + TDB_coin (pg, + &denom, + 20, + &old_coin, + NULL); + refresh_id = melt (pg, + &old_coin, + 1, + "1"); + TDB_FILL (coin_pub, + 99); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_recoup (pg, + &old_coin.coin_pub, + refresh_id, + &coin_pub, + 1, + 99, + &st), + TDB_coin_free (&old_coin)); + TDB_coin_free (&old_coin); + FAILIF (! st.internal_failure); + FAILIF (st.recoup_ok); + FAILIF (0 != TDB_count (pg, + "FROM recoup_refresh")); + return 0; +} + + +/** + * A fresh coin with nothing left on it and no earlier recoup is refused. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty_coin (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo old_coin; + struct TALER_CoinPublicInfo fresh; + struct RecoupStatus st; + uint64_t known_coin_id; + uint64_t refresh_id; + char *hex; + + TDB_coin (pg, + &denom, + 21, + &old_coin, + NULL); + refresh_id = melt (pg, + &old_coin, + 2, + "1"); + TDB_coin (pg, + &denom, + 22, + &fresh, + &known_coin_id); + hex = TDB_hex (&fresh.coin_pub, + sizeof (fresh.coin_pub)); + FAILIF_C (GNUNET_OK != + TDB_exec (pg, + "UPDATE known_coins" + " SET remaining=ROW(0,0)::taler_amount" + " WHERE coin_pub=decode('%s','hex');", + hex), + GNUNET_free (hex); + TDB_coin_free (&fresh); TDB_coin_free (&old_coin)); + GNUNET_free (hex); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_recoup (pg, + &old_coin.coin_pub, + refresh_id, + &fresh.coin_pub, + known_coin_id, + 22, + &st), + TDB_coin_free (&fresh); TDB_coin_free (&old_coin)); + TDB_coin_free (&fresh); + TDB_coin_free (&old_coin); + FAILIF (st.internal_failure); + FAILIF (st.recoup_ok); + FAILIF (0 != TDB_count (pg, + "FROM recoup_refresh")); + return 0; +} + + +/** + * A funded fresh coin is drained back onto the old coin. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_recoup_refresh (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo old_coin; + struct TALER_CoinPublicInfo fresh; + struct RecoupStatus st; + uint64_t known_coin_id; + uint64_t refresh_id; + char *hex; + + TDB_coin (pg, + &denom, + 23, + &old_coin, + NULL); + refresh_id = melt (pg, + &old_coin, + 3, + "1"); + TDB_coin (pg, + &denom, + 24, + &fresh, + &known_coin_id); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_recoup (pg, + &old_coin.coin_pub, + refresh_id, + &fresh.coin_pub, + known_coin_id, + 24, + &st), + TDB_coin_free (&fresh); TDB_coin_free (&old_coin)); + FAILIF_C (st.internal_failure, + TDB_coin_free (&fresh); TDB_coin_free (&old_coin)); + FAILIF_C (! st.recoup_ok, + TDB_coin_free (&fresh); TDB_coin_free (&old_coin)); + FAILIF_C (1 != TDB_count (pg, + "FROM recoup_refresh"), + TDB_coin_free (&fresh); TDB_coin_free (&old_coin)); + + /* the fresh coin is empty */ + hex = TDB_hex (&fresh.coin_pub, + sizeof (fresh.coin_pub)); + FAILIF_C (1 != TDB_count (pg, + "FROM known_coins" + " WHERE coin_pub=decode('%s','hex')" + " AND remaining=ROW(0,0)::taler_amount", + hex), + GNUNET_free (hex); + TDB_coin_free (&fresh); TDB_coin_free (&old_coin)); + GNUNET_free (hex); + /* the old coin got the EUR:5 back on top of the EUR:4 it had left */ + hex = TDB_hex (&old_coin.coin_pub, + sizeof (old_coin.coin_pub)); + FAILIF_C (1 != TDB_count (pg, + "FROM known_coins" + " WHERE coin_pub=decode('%s','hex')" + " AND remaining=ROW(9,0)::taler_amount", + hex), + GNUNET_free (hex); + TDB_coin_free (&fresh); TDB_coin_free (&old_coin)); + GNUNET_free (hex); + + /* recouping the same coin again finds the earlier recoup */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_recoup (pg, + &old_coin.coin_pub, + refresh_id, + &fresh.coin_pub, + known_coin_id, + 24, + &st), + TDB_coin_free (&fresh); TDB_coin_free (&old_coin)); + FAILIF_C (! st.recoup_ok, + TDB_coin_free (&fresh); TDB_coin_free (&old_coin)); + FAILIF_C (1 != TDB_count (pg, + "FROM recoup_refresh"), + TDB_coin_free (&fresh); TDB_coin_free (&old_coin)); + TDB_coin_free (&fresh); + TDB_coin_free (&old_coin); + return 0; +} + + +/** + * The iterator must not report a recoup-refresh that is not there. + * + * This is all it can be checked for today: its statement joins + * `refresh_revealed_coins` and `refresh_commitments`, tables the schema no + * longer has, so it cannot return a row at all (EDB-16). The assertion + * below holds both now and once that is repaired. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + unsigned int total = 0; + + FAILIF (0 < + TALER_EXCHANGEDB_iterate_recoup_refreshes_above_serial_id ( + pg, + 1000, + &recoup_refresh_cb, + &total)); + FAILIF (0 != total); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "recoup-refresh-unknown-coin", + &check_unknown_coin }, + { "recoup-refresh-empty-coin", + &check_empty_coin }, + { "recoup-refresh-recoup", + &check_recoup_refresh }, + { "recoup-refresh-iterate", + &check_iterate }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-recoup-refresh", + "Tests for the exchangedb `recoup_refresh' table", + tests); + TDB_account_free (&account); + TDB_denom_free (&denom); + return ret; +} + + +/* end of test_recoup_refresh.c */ diff --git a/src/exchangedb/test_refresh.c b/src/exchangedb/test_refresh.c @@ -0,0 +1,781 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_refresh.c + * @brief tests for the exchangedb functions whose primary table is + * `refresh` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_do_refresh(), #TALER_EXCHANGEDB_get_refresh(), + * #TALER_EXCHANGEDB_update_to_refresh_revealed(), + * #TALER_EXCHANGEDB_iterate_refreshes_above_serial_id() and, as far as it + * can be, #TALER_EXCHANGEDB_get_old_coin_by_h_blind(). + * + * `refresh` references `known_coins`, which TDB_coin() creates. The + * do_refresh() answers checked here are: unknown coin, insufficient coin + * balance, the zombie requirement, blinding-seed reuse and an idempotent + * replay. + * + * get_old_coin_by_h_blind() cannot be checked beyond "does not invent an + * answer": its statement selects on a `h_blind_evs` column that the current + * `refresh` table does not have, so it cannot succeed at all -- see EDBT-8 + * in bugs.txt. + */ +#include "test_common.h" +#include "exchange-database/do_refresh.h" +#include "exchange-database/get_known_coin.h" +#include "exchange-database/get_old_coin_by_h_blind.h" +#include "exchange-database/get_refresh.h" +#include "exchange-database/iterate_refreshes_above_serial_id.h" +#include "exchange-database/update_to_refresh_revealed.h" + + +/** + * Denomination the checks melt. + */ +static struct TDB_Denom denom; + + +/** + * Fill in a refresh (melt) request for one fresh coin. + * + * @param seed seed for the commitment, signatures and seeds + * @param amount amount to melt, e.g. "1" + * @param coin coin being melted + * @param with_seed true to pass a blinding seed and a CS R value + * @param[out] rf set to the request; release with free_refresh() + */ +static void +make_refresh (uint32_t seed, + const char *amount, + const struct TALER_CoinPublicInfo *coin, + bool with_seed, + struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS *rf) +{ + memset (rf, + 0, + sizeof (*rf)); + rf->coin.coin_pub = coin->coin_pub; + rf->coin.denom_pub_hash = coin->denom_pub_hash; + rf->coin.no_age_commitment = coin->no_age_commitment; + TDB_fill (&rf->coin_sig, + sizeof (rf->coin_sig), + seed); + TDB_fill (&rf->rc, + sizeof (rf->rc), + seed); + TDB_fill (&rf->refresh_seed, + sizeof (rf->refresh_seed), + seed); + TDB_fill (&rf->planchets_h, + sizeof (rf->planchets_h), + seed); + TDB_fill (&rf->selected_h, + sizeof (rf->selected_h), + seed + 1); + rf->amount_with_fee = TDB_amount (amount); + rf->num_coins = 1; + rf->denom_serials = GNUNET_new (uint64_t); + rf->denom_serials[0] = denom.serial; + rf->denom_sigs = GNUNET_new (struct TALER_BlindedDenominationSignature); + TDB_blinded_denom_sig (seed, + &rf->denom_sigs[0]); + rf->noreveal_index = 1; + /* v27 refresh: the client provides no transfer public keys */ + rf->is_v27_refresh = true; + rf->no_blinding_seed = ! with_seed; + if (with_seed) + { + TDB_fill (&rf->blinding_seed, + sizeof (rf->blinding_seed), + seed); + rf->num_cs_r_values = 1; + rf->cs_r_values = GNUNET_new (struct GNUNET_CRYPTO_CSPublicRPairP); + TDB_fill (rf->cs_r_values, + sizeof (*rf->cs_r_values), + seed); + rf->cs_r_choices = 0; + } +} + + +/** + * Release what make_refresh() allocated. + * + * @param[in,out] rf request to clean up + */ +static void +free_refresh (struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS *rf) +{ + for (size_t i = 0; i<rf->num_coins; i++) + TALER_blinded_denom_sig_free (&rf->denom_sigs[i]); + GNUNET_free (rf->denom_sigs); + GNUNET_free (rf->denom_serials); + GNUNET_free (rf->cs_r_values); + GNUNET_free (rf->transfer_pubs); + GNUNET_free (rf->denom_pub_hashes); +} + + +/** + * Outcome flags of a refresh request. + */ +struct RefreshStatus +{ + /** + * Was there already a row for this commitment? + */ + bool found; + + /** + * Did the melt have to be of a zombie coin? + */ + bool zombie_required; + + /** + * Was the blinding seed used before? + */ + bool nonce_reuse; + + /** + * Was the coin balance sufficient? + */ + bool balance_ok; + + /** + * Balance the coin had. + */ + struct TALER_Amount coin_balance; + + /** + * Index the exchange chose not to reveal. + */ + uint32_t noreveal_index; +}; + + +/** + * Perform a refresh request. + * + * @param pg the database context + * @param rf the request + * @param zombie_required whether the coin must be a zombie + * @param[out] st set to the outcome + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +run_refresh (struct TALER_EXCHANGEDB_PostgresContext *pg, + struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS *rf, + bool zombie_required, + struct RefreshStatus *st) +{ + struct GNUNET_TIME_Timestamp now = GNUNET_TIME_timestamp_get (); + + memset (st, + 0, + sizeof (*st)); + st->zombie_required = zombie_required; + return TALER_EXCHANGEDB_do_refresh (pg, + rf, + &now, + &st->found, + &st->noreveal_index, + &st->zombie_required, + &st->nonce_reuse, + &st->balance_ok, + &st->coin_balance); +} + + +/** + * Melting a coin that is not known does nothing. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_unknown_coin (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo coin; + struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS rf; + struct RefreshStatus st; + + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + memset (&coin, + 0, + sizeof (coin)); + TDB_FILL (coin.coin_pub, + 1); + coin.denom_pub_hash = denom.h_denom_pub; + coin.no_age_commitment = true; + make_refresh (1, + "1", + &coin, + false, + &rf); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + run_refresh (pg, + &rf, + false, + &st), + free_refresh (&rf)); + free_refresh (&rf); + FAILIF (0 != TDB_count (pg, + "FROM refresh")); + return 0; +} + + +/** + * Melting more than the coin is worth does nothing. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insufficient_balance (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo coin; + struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS rf; + struct RefreshStatus st; + struct TALER_Amount expect = TDB_amount ("5"); + + TDB_coin (pg, + &denom, + 20, + &coin, + NULL); + make_refresh (2, + "6", + &coin, + false, + &rf); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_refresh (pg, + &rf, + false, + &st), + free_refresh (&rf); TDB_coin_free (&coin)); + free_refresh (&rf); + FAILIF_C (st.balance_ok, + TDB_coin_free (&coin)); + FAILIF_C (0 != TALER_amount_cmp (&st.coin_balance, + &expect), + TDB_coin_free (&coin)); + /* the row was written before the balance was checked, so the caller has + to roll back -- here the check simply notes that it is there */ + FAILIF_C (1 != TDB_count (pg, + "FROM refresh"), + TDB_coin_free (&coin)); + FAILIF_C (GNUNET_OK != + TDB_exec (pg, + "DELETE FROM refresh;"), + TDB_coin_free (&coin)); + TDB_coin_free (&coin); + return 0; +} + + +/** + * A melt within the coin's balance is recorded and debits the coin. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_refresh (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo coin; + struct TALER_CoinPublicInfo got_coin; + struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS rf; + struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS got; + struct RefreshStatus st; + struct TALER_Amount expect_amount = TDB_amount ("1"); + + TDB_coin (pg, + &denom, + 20, + &coin, + NULL); + make_refresh (3, + "1", + &coin, + true, + &rf); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_refresh (pg, + &rf, + false, + &st), + free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (! st.balance_ok, + free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (st.found, + free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (st.nonce_reuse, + free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (1 != TDB_count (pg, + "FROM refresh"), + free_refresh (&rf); TDB_coin_free (&coin)); + + /* the melt is on file with everything it was created with */ + memset (&got, + 0, + sizeof (got)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_refresh (pg, + &rf.rc, + &got), + free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (0 != TALER_amount_cmp (&got.amount_with_fee, + &expect_amount), + free_refresh (&got); free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (0 != GNUNET_memcmp (&got.coin.coin_pub, + &coin.coin_pub), + free_refresh (&got); free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (0 != GNUNET_memcmp (&got.coin_sig, + &rf.coin_sig), + free_refresh (&got); free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (0 != GNUNET_memcmp (&got.refresh_seed, + &rf.refresh_seed), + free_refresh (&got); free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (1 != got.num_coins, + free_refresh (&got); free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (denom.serial != got.denom_serials[0], + free_refresh (&got); free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (rf.noreveal_index != got.noreveal_index, + free_refresh (&got); free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (got.revealed, + free_refresh (&got); free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (got.no_blinding_seed, + free_refresh (&got); free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (! got.is_v27_refresh, + free_refresh (&got); free_refresh (&rf); TDB_coin_free (&coin)); + free_refresh (&got); + + /* the coin was debited */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_known_coin (pg, + &coin.coin_pub, + &got_coin), + free_refresh (&rf); TDB_coin_free (&coin)); + TALER_denom_sig_free (&got_coin.denom_sig); + { + char *hex = TDB_hex (&coin.coin_pub, + sizeof (coin.coin_pub)); + + FAILIF_C (1 != TDB_count (pg, + "FROM known_coins" + " WHERE coin_pub=decode('%s','hex')" + " AND remaining=ROW(4,0)::taler_amount", + hex), + GNUNET_free (hex); + free_refresh (&rf); TDB_coin_free (&coin)); + GNUNET_free (hex); + } + + /* a replay is idempotent and reports the index we chose */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_refresh (pg, + &rf, + false, + &st), + free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (! st.found, + free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (rf.noreveal_index != st.noreveal_index, + free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (1 != TDB_count (pg, + "FROM refresh"), + free_refresh (&rf); TDB_coin_free (&coin)); + free_refresh (&rf); + TDB_coin_free (&coin); + return 0; +} + + +/** + * Reusing a blinding seed is refused. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_nonce_reuse (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo coin; + struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS rf; + struct RefreshStatus st; + + TDB_coin (pg, + &denom, + 20, + &coin, + NULL); + /* a fresh commitment, but the blinding seed of check_refresh() */ + make_refresh (4, + "1", + &coin, + true, + &rf); + TDB_fill (&rf.blinding_seed, + sizeof (rf.blinding_seed), + 3); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_refresh (pg, + &rf, + false, + &st), + free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (! st.nonce_reuse, + free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (st.found, + free_refresh (&rf); TDB_coin_free (&coin)); + free_refresh (&rf); + TDB_coin_free (&coin); + FAILIF (1 != TDB_count (pg, + "FROM refresh")); + return 0; +} + + +/** + * A melt that insists on a zombie coin is refused when the coin never was + * one. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_zombie_required (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo coin; + struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS rf; + struct RefreshStatus st; + + TDB_coin (pg, + &denom, + 21, + &coin, + NULL); + make_refresh (5, + "1", + &coin, + false, + &rf); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_refresh (pg, + &rf, + true, + &st), + free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (! st.zombie_required, + free_refresh (&rf); TDB_coin_free (&coin)); + FAILIF_C (st.balance_ok, + free_refresh (&rf); TDB_coin_free (&coin)); + free_refresh (&rf); + TDB_coin_free (&coin); + /* the row went in before the zombie check, so the caller must roll back */ + FAILIF (2 != TDB_count (pg, + "FROM refresh")); + FAILIF (GNUNET_OK != + TDB_exec (pg, + "DELETE FROM refresh WHERE NOT revealed" + " AND rc <> (SELECT rc FROM refresh" + " ORDER BY refresh_id ASC LIMIT 1);")); + return 0; +} + + +/** + * A melt can be marked as revealed, and marking one that does not exist + * does nothing. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_revealed (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_RefreshCommitmentP rc; + struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS got; + + TDB_FILL (rc, + 99); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_update_to_refresh_revealed (pg, + &rc)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_refresh (pg, + &rc, + &got)); + + TDB_FILL (rc, + 3); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_to_refresh_revealed (pg, + &rc)); + memset (&got, + 0, + sizeof (got)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_refresh (pg, + &rc, + &got)); + FAILIF_C (! got.revealed, + free_refresh (&got)); + free_refresh (&got); + /* marking it again is still reported as a row touched */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_to_refresh_revealed (pg, + &rc)); + return 0; +} + + +/** + * Closure for #refreshes_cb(). + */ +struct RefreshesContext +{ + /** + * How many melts did the callback see? + */ + unsigned int total; + + /** + * Stop after this many melts; 0 for no limit. + */ + unsigned int stop_after; + + /** + * Row of the last melt. + */ + uint64_t rowid; + + /** + * Amount of the last melt. + */ + struct TALER_Amount amount; + + /** + * Number of new denominations of the last melt. + */ + size_t num_nds; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_refreshes_above_serial_id(). + * + * @param cls a `struct RefreshesContext *` + * @param rowid row of the melt + * @param old_denom_pub denomination of the melted coin + * @param coin_pub the melted coin + * @param coin_sig signature authorising the melt + * @param h_age_commitment age commitment of the coin, NULL if none + * @param amount_with_fee how much was melted + * @param num_nds length of @a new_denom_serials + * @param new_denom_serials denominations of the fresh coins + * @param rc commitment of the melt + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +refreshes_cb (void *cls, + uint64_t rowid, + const struct TALER_DenominationPublicKey *old_denom_pub, + const struct TALER_CoinSpendPublicKeyP *coin_pub, + const struct TALER_CoinSpendSignatureP *coin_sig, + const struct TALER_AgeCommitmentHashP *h_age_commitment, + const struct TALER_Amount *amount_with_fee, + size_t num_nds, + uint64_t new_denom_serials[static num_nds], + const struct TALER_RefreshCommitmentP *rc) +{ + struct RefreshesContext *ctx = cls; + + (void) old_denom_pub; + (void) coin_pub; + (void) coin_sig; + (void) h_age_commitment; + (void) new_denom_serials; + (void) rc; + ctx->total++; + ctx->rowid = rowid; + ctx->amount = *amount_with_fee; + ctx->num_nds = num_nds; + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * The auditor's view walks the melts by serial and stops when asked to. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo coin; + struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS rf; + struct RefreshStatus st; + struct RefreshesContext ctx; + uint64_t rows; + + /* one more melt, so that "stop after the first" is distinguishable + from "saw everything" */ + TDB_coin (pg, + &denom, + 42, + &coin, + NULL); + make_refresh (42, + "1", + &coin, + false, + &rf); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_refresh (pg, + &rf, + false, + &st), + free_refresh (&rf); TDB_coin_free (&coin)); + free_refresh (&rf); + TDB_coin_free (&coin); + FAILIF (! st.balance_ok); + rows = TDB_count (pg, + "FROM refresh"); + FAILIF (2 != rows); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_refreshes_above_serial_id ( + pg, + 0, + &refreshes_cb, + &ctx)); + FAILIF (rows != ctx.total); + /* every melt here asked for exactly one fresh coin */ + FAILIF (1 != ctx.num_nds); + + /* everything above the last row is nothing */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_refreshes_above_serial_id ( + pg, + UINT32_MAX, + &refreshes_cb, + &ctx)); + FAILIF (0 != ctx.total); + + /* a callback that says stop is not called again */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.stop_after = 1; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_refreshes_above_serial_id ( + pg, + 0, + &refreshes_cb, + &ctx)); + FAILIF (1 != ctx.total); + return 0; +} + + +/** + * A blinded coin hash that was never seen must not be attributed to any + * old coin. + * + * This is all get_old_coin_by_h_blind() can be checked for today: its + * statement selects on a `h_blind_evs` column that `refresh` does not + * have, so it cannot return a row at all (EDBT-8). The assertion below + * holds both now and once that is repaired. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_old_coin_by_h_blind (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_BlindedCoinHashP h_blind_ev; + struct TALER_CoinSpendPublicKeyP old_coin_pub; + uint64_t rrc_serial; + + TDB_FILL (h_blind_ev, + 123); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_EXCHANGEDB_get_old_coin_by_h_blind (pg, + &h_blind_ev, + &old_coin_pub, + &rrc_serial)); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "refresh-unknown-coin", + &check_unknown_coin }, + { "refresh-insufficient-balance", + &check_insufficient_balance }, + { "refresh-refresh", + &check_refresh }, + { "refresh-nonce-reuse", + &check_nonce_reuse }, + { "refresh-zombie-required", + &check_zombie_required }, + { "refresh-revealed", + &check_revealed }, + { "refresh-iterate", + &check_iterate }, + { "refresh-old-coin-by-h-blind", + &check_old_coin_by_h_blind }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-refresh", + "Tests for the exchangedb `refresh' table", + tests); + TDB_denom_free (&denom); + return ret; +} + + +/* end of test_refresh.c */ diff --git a/src/exchangedb/test_refunds.c b/src/exchangedb/test_refunds.c @@ -0,0 +1,707 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_refunds.c + * @brief tests for the exchangedb functions whose primary table is + * `refunds` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_do_refund(), #TALER_EXCHANGEDB_insert_refund(), + * #TALER_EXCHANGEDB_iterate_refunds_above_serial_id() and + * #TALER_EXCHANGEDB_iterate_refunds_by_coin(). + * + * `refunds` references `coin_deposits`, so every check deposits a coin + * first with TDB_deposit(). do_refund() has three ways of declining: + * no such deposit, the merchant was already paid, and a reused refund + * transaction id. + */ +#include "test_common.h" +#include "exchange-database/do_refund.h" +#include "exchange-database/insert_refund.h" +#include "exchange-database/iterate_refunds_above_serial_id.h" +#include "exchange-database/iterate_refunds_by_coin.h" +#include "exchange-database/rollback.h" +#include "exchange-database/start.h" + + +/** + * Account the merchants of the checks are paid at. + */ +static struct TDB_Account account; + + +/** + * Denomination the checks deposit. + */ +static struct TDB_Denom denom; + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Closure for #refund_cb(). + */ +struct RefundContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Stop after this many rows; 0 for no limit. + */ + unsigned int stop_after; + + /** + * Coin we are looking for, NULL to match nothing. + */ + const struct TALER_CoinSpendPublicKeyP *coin_pub; + + /** + * How many times did we see it? + */ + unsigned int matched; + + /** + * Amount reported for it. + */ + struct TALER_Amount amount; + + /** + * Refund transaction id reported for it. + */ + uint64_t rtransaction_id; + + /** + * Was the deposit fully refunded? + */ + bool full_refund; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_refunds_above_serial_id(). + * + * @param cls a `struct RefundContext *` + * @param rowid row of the refund + * @param denom_pub denomination of the coin + * @param coin_pub the refunded coin + * @param merchant_pub merchant that refunded + * @param merchant_sig signature over the refund + * @param h_contract_terms contract the refund is for + * @param rtransaction_id merchant's refund transaction id + * @param full_refund whether the deposit is now fully refunded + * @param amount_with_fee how much was refunded + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +refund_cb (void *cls, + uint64_t rowid, + const struct TALER_DenominationPublicKey *denom_pub, + const struct TALER_CoinSpendPublicKeyP *coin_pub, + const struct TALER_MerchantPublicKeyP *merchant_pub, + const struct TALER_MerchantSignatureP *merchant_sig, + const struct TALER_PrivateContractHashP *h_contract_terms, + uint64_t rtransaction_id, + bool full_refund, + const struct TALER_Amount *amount_with_fee) +{ + struct RefundContext *ctx = cls; + + (void) rowid; + (void) denom_pub; + (void) merchant_pub; + (void) merchant_sig; + (void) h_contract_terms; + ctx->total++; + if ( (NULL != ctx->coin_pub) && + (0 == GNUNET_memcmp (coin_pub, + ctx->coin_pub)) ) + { + ctx->matched++; + ctx->amount = *amount_with_fee; + ctx->rtransaction_id = rtransaction_id; + ctx->full_refund = full_refund; + } + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * Closure for #coin_refund_cb(). + */ +struct CoinRefundContext +{ + /** + * How many refunds did the callback see? + */ + unsigned int total; + + /** + * Sum of the whole-unit parts of the amounts seen. + */ + uint64_t value_sum; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_refunds_by_coin(). + * + * @param cls a `struct CoinRefundContext *` + * @param amount_with_fee how much was refunded + * @return #GNUNET_OK + */ +static enum GNUNET_GenericReturnValue +coin_refund_cb (void *cls, + const struct TALER_Amount *amount_with_fee) +{ + struct CoinRefundContext *ctx = cls; + + ctx->total++; + ctx->value_sum += amount_with_fee->value; + return GNUNET_OK; +} + + +/** + * Outcome of a refund request. + */ +struct RefundStatus +{ + /** + * Was there no such deposit? + */ + bool not_found; + + /** + * Was the refund accepted? + */ + bool refund_ok; + + /** + * Was the merchant already paid? + */ + bool gone; + + /** + * Was the refund transaction id reused? + */ + bool conflict; +}; + + +/** + * Refund part of a deposit. + * + * @param pg the database context + * @param coin coin to refund + * @param dep deposit to refund + * @param seed seed for the merchant signature + * @param rtransaction_id merchant's refund transaction id + * @param amount how much to refund, e.g. "0.5" + * @param[out] st set to the outcome + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +run_refund (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TALER_CoinPublicInfo *coin, + const struct TDB_Deposit *dep, + uint32_t seed, + uint64_t rtransaction_id, + const char *amount, + struct RefundStatus *st) +{ + struct TALER_EXCHANGEDB_Refund refund; + struct TALER_Amount deposit_fee = TDB_amount ("0.1"); + + memset (&refund, + 0, + sizeof (refund)); + refund.coin = *coin; + refund.details.merchant_pub = dep->merchant_pub; + TDB_fill (&refund.details.merchant_sig, + sizeof (refund.details.merchant_sig), + seed); + refund.details.h_contract_terms = dep->h_contract_terms; + refund.details.rtransaction_id = rtransaction_id; + refund.details.refund_amount = TDB_amount (amount); + refund.details.refund_fee = TDB_amount ("0"); + memset (st, + 0, + sizeof (*st)); + return TALER_EXCHANGEDB_do_refund (pg, + &refund, + &deposit_fee, + 0, + &st->not_found, + &st->refund_ok, + &st->gone, + &st->conflict); +} + + +/** + * Nothing is reported while the table is empty, and a refund of a deposit + * that does not exist is refused. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo coin; + struct TDB_Deposit dep; + struct RefundContext ctx = { 0 }; + struct CoinRefundContext cctx = { 0 }; + struct RefundStatus st; + + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + TDB_account (pg, + 10, + &account); + TDB_coin (pg, + &denom, + 20, + &coin, + NULL); + memset (&dep, + 0, + sizeof (dep)); + TDB_FILL (dep.merchant_pub, + 98); + TDB_FILL (dep.h_contract_terms, + 98); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_refund (pg, + &coin, + &dep, + 98, + 1, + "1", + &st), + TDB_coin_free (&coin)); + TDB_coin_free (&coin); + FAILIF (! st.not_found); + FAILIF (st.refund_ok); + FAILIF (0 != TDB_count (pg, + "FROM refunds")); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_refunds_above_serial_id (pg, + 0, + &refund_cb, + &ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_refunds_by_coin (pg, + &coin.coin_pub, + &dep.merchant_pub, + &dep.h_contract_terms, + &coin_refund_cb, + &cctx)); + FAILIF (0 != ctx.total); + FAILIF (0 != cctx.total); + return 0; +} + + +/** + * A partial refund is recorded and reported. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_refund (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo coin; + struct TDB_Deposit dep; + struct RefundStatus st; + struct RefundContext ctx; + struct CoinRefundContext cctx; + struct TALER_Amount expect = TDB_amount ("0.5"); + + TDB_coin (pg, + &denom, + 20, + &coin, + NULL); + TDB_deposit (pg, + &account, + &coin, + 20, + "1", + "0.1", + ts (1600000000), + ts (1600000000), + &dep); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_refund (pg, + &coin, + &dep, + 20, + 1, + "0.5", + &st), + TDB_coin_free (&coin)); + FAILIF_C (st.not_found, + TDB_coin_free (&coin)); + FAILIF_C (! st.refund_ok, + TDB_coin_free (&coin)); + FAILIF_C (st.gone, + TDB_coin_free (&coin)); + FAILIF_C (st.conflict, + TDB_coin_free (&coin)); + FAILIF_C (1 != TDB_count (pg, + "FROM refunds"), + TDB_coin_free (&coin)); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.coin_pub = &coin.coin_pub; + FAILIF_C (1 != + TALER_EXCHANGEDB_iterate_refunds_above_serial_id (pg, + 0, + &refund_cb, + &ctx), + TDB_coin_free (&coin)); + FAILIF_C (1 != ctx.matched, + TDB_coin_free (&coin)); + FAILIF_C (0 != TALER_amount_cmp (&ctx.amount, + &expect), + TDB_coin_free (&coin)); + FAILIF_C (1 != ctx.rtransaction_id, + TDB_coin_free (&coin)); + /* only half of the deposit was refunded */ + FAILIF_C (ctx.full_refund, + TDB_coin_free (&coin)); + + memset (&cctx, + 0, + sizeof (cctx)); + FAILIF_C (1 != + TALER_EXCHANGEDB_iterate_refunds_by_coin (pg, + &coin.coin_pub, + &dep.merchant_pub, + &dep.h_contract_terms, + &coin_refund_cb, + &cctx), + TDB_coin_free (&coin)); + FAILIF_C (1 != cctx.total, + TDB_coin_free (&coin)); + + /* a contract nobody refunded has no refunds */ + { + struct TALER_PrivateContractHashP other; + + TDB_FILL (other, + 97); + memset (&cctx, + 0, + sizeof (cctx)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_refunds_by_coin (pg, + &coin.coin_pub, + &dep.merchant_pub, + &other, + &coin_refund_cb, + &cctx), + TDB_coin_free (&coin)); + FAILIF_C (0 != cctx.total, + TDB_coin_free (&coin)); + } + + /* reusing the refund transaction id is a conflict */ + FAILIF_C (0 > + run_refund (pg, + &coin, + &dep, + 21, + 1, + "0.25", + &st), + TDB_coin_free (&coin)); + FAILIF_C (! st.conflict, + TDB_coin_free (&coin)); + FAILIF_C (1 != TDB_count (pg, + "FROM refunds"), + TDB_coin_free (&coin)); + + /* a second refund with a fresh id completes the deposit */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_refund (pg, + &coin, + &dep, + 22, + 2, + "0.5", + &st), + TDB_coin_free (&coin)); + FAILIF_C (! st.refund_ok, + TDB_coin_free (&coin)); + memset (&cctx, + 0, + sizeof (cctx)); + FAILIF_C (2 != + TALER_EXCHANGEDB_iterate_refunds_by_coin (pg, + &coin.coin_pub, + &dep.merchant_pub, + &dep.h_contract_terms, + &coin_refund_cb, + &cctx), + TDB_coin_free (&coin)); + FAILIF_C (2 != cctx.total, + TDB_coin_free (&coin)); + TDB_coin_free (&coin); + return 0; +} + + +/** + * Refunding a deposit the exchange has already paid out is refused. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_gone (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo coin; + struct TDB_Deposit dep; + struct RefundStatus st; + + TDB_coin (pg, + &denom, + 23, + &coin, + NULL); + TDB_deposit (pg, + &account, + &coin, + 23, + "1", + "0.1", + ts (1600000000), + ts (1600000000), + &dep); + FAILIF_C (GNUNET_OK != + TDB_exec (pg, + "UPDATE batch_deposits" + " SET done=TRUE" + " WHERE batch_deposit_serial_id=%llu;", + (unsigned long long) dep.serial), + TDB_coin_free (&coin)); + /* The row goes in before the "already wired out" check, so this is how + the /refund handler uses the function: inside a transaction it can + roll back. */ + FAILIF_C (GNUNET_OK != + TALER_EXCHANGEDB_start (pg, + "test-refund-gone"), + TDB_coin_free (&coin)); + FAILIF_C (0 > + run_refund (pg, + &coin, + &dep, + 23, + 1, + "0.5", + &st), + TALER_EXCHANGEDB_rollback (pg); TDB_coin_free (&coin)); + TALER_EXCHANGEDB_rollback (pg); + TDB_coin_free (&coin); + FAILIF (! st.gone); + FAILIF (st.refund_ok); + FAILIF (2 != TDB_count (pg, + "FROM refunds")); + return 0; +} + + +/** + * insert_refund() writes the row without any of the checks do_refund() + * makes, and finds the deposit through (coin, merchant, contract). + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insert_refund (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_CoinPublicInfo coin; + struct TDB_Deposit dep; + struct TALER_EXCHANGEDB_Refund refund; + struct CoinRefundContext cctx; + + TDB_coin (pg, + &denom, + 24, + &coin, + NULL); + TDB_deposit (pg, + &account, + &coin, + 24, + "2", + "0.1", + ts (1600000000), + ts (1600000000), + &dep); + memset (&refund, + 0, + sizeof (refund)); + refund.coin = coin; + refund.details.merchant_pub = dep.merchant_pub; + TDB_FILL (refund.details.merchant_sig, + 24); + refund.details.h_contract_terms = dep.h_contract_terms; + refund.details.rtransaction_id = 7; + refund.details.refund_amount = TDB_amount ("1"); + refund.details.refund_fee = TDB_amount ("0"); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_refund (pg, + &refund), + TDB_coin_free (&coin)); + memset (&cctx, + 0, + sizeof (cctx)); + FAILIF_C (1 != + TALER_EXCHANGEDB_iterate_refunds_by_coin (pg, + &coin.coin_pub, + &dep.merchant_pub, + &dep.h_contract_terms, + &coin_refund_cb, + &cctx), + TDB_coin_free (&coin)); + FAILIF_C (1 != cctx.value_sum, + TDB_coin_free (&coin)); + + /* a contract that has no deposit selects no row to insert against */ + TDB_FILL (refund.details.h_contract_terms, + 96); + refund.details.rtransaction_id = 8; + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_insert_refund (pg, + &refund), + TDB_coin_free (&coin)); + TDB_coin_free (&coin); + FAILIF (3 != TDB_count (pg, + "FROM refunds")); + return 0; +} + + +/** + * The serial iterator's bound and abort return behave as documented. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct RefundContext ctx; + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (3 != + TALER_EXCHANGEDB_iterate_refunds_above_serial_id (pg, + 0, + &refund_cb, + &ctx)); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_refunds_above_serial_id (pg, + 1000, + &refund_cb, + &ctx)); + FAILIF (0 != ctx.total); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.stop_after = 1; + FAILIF (3 != + TALER_EXCHANGEDB_iterate_refunds_above_serial_id (pg, + 0, + &refund_cb, + &ctx)); + FAILIF (1 != ctx.total); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "refunds-empty", + &check_empty }, + { "refunds-refund", + &check_refund }, + { "refunds-gone", + &check_gone }, + { "refunds-insert-refund", + &check_insert_refund }, + { "refunds-iterate", + &check_iterate }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-refunds", + "Tests for the exchangedb `refunds' table", + tests); + TDB_account_free (&account); + TDB_denom_free (&denom); + return ret; +} + + +/* end of test_refunds.c */ diff --git a/src/exchangedb/test_replication.c b/src/exchangedb/test_replication.c @@ -0,0 +1,480 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_replication.c + * @brief tests for the exchangedb replication functions + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_get_serial_by_table(), + * #TALER_EXCHANGEDB_iterate_records_by_table() and + * #TALER_EXCHANGEDB_insert_records_by_table(). + * + * These three have no table of their own: each is one big switch over + * #TALER_EXCHANGEDB_ReplicatedTable, and taler-auditor-sync drives them in + * a loop over every value of that enum. A table that is missing from one + * of the switches, or whose SQL no longer matches the schema, breaks + * replication for that table only -- and nothing else in the test suite + * would notice. So the checks here walk the whole enum, and then do one + * full round trip (read a record out, delete it, put it back) to show that + * the reader and the writer agree. + */ +#include "test_common.h" +#include "exchange-database/get_serial_by_table.h" +#include "exchange-database/insert_records_by_table.h" +#include "exchange-database/iterate_records_by_table.h" + + +/** + * The two accounts we replicate. + */ +static struct TDB_Account account[2]; + + +/** + * Denomination the checks use. + */ +static struct TDB_Denom denom; + + +/** + * Value we gave @e denom. + */ +#define DENOM_VALUE "5" + + +/** + * Closure for #record_cb(). + */ +struct RecordContext +{ + /** + * How many records did the callback see? + */ + unsigned int total; + + /** + * Table we expect the records to be from. + */ + enum TALER_EXCHANGEDB_ReplicatedTable table; + + /** + * Set to true if a record from another table arrived. + */ + bool wrong_table; + + /** + * Serial of the last record. + */ + uint64_t serial; + + /** + * Payto URI we are looking for, NULL to look for none. + */ + const char *payto; + + /** + * Serial the record with @e payto had, 0 if it did not show up. + */ + uint64_t payto_serial; + + /** + * Value of the last denomination record. + */ + struct TALER_Amount coin; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_records_by_table(). + * + * @param cls a `struct RecordContext *` + * @param td the record + * @return #GNUNET_OK to continue iterating + */ +static int +record_cb (void *cls, + const struct TALER_EXCHANGEDB_TableData *td) +{ + struct RecordContext *ctx = cls; + + ctx->total++; + ctx->serial = td->serial; + if (td->table != ctx->table) + ctx->wrong_table = true; + switch (td->table) + { + case TALER_EXCHANGEDB_RT_DENOMINATIONS: + ctx->coin = td->details.denominations.coin; + break; + case TALER_EXCHANGEDB_RT_WIRE_TARGETS: + if ( (NULL != ctx->payto) && + (0 == strcmp (ctx->payto, + td->details.wire_targets.full_payto_uri.full_payto)) ) + ctx->payto_serial = td->serial; + break; + default: + break; + } + return GNUNET_OK; +} + + +/** + * Every replicated table must have a serial statement, and on an empty + * database none of them has a row. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_all_serials (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + for (unsigned int t = 0; + t <= TALER_EXCHANGEDB_RT_KYCAUTHS_IN; + t++) + { + uint64_t serial = UINT64_MAX; + enum GNUNET_DB_QueryStatus qs; + + qs = TALER_EXCHANGEDB_get_serial_by_table ( + pg, + (enum TALER_EXCHANGEDB_ReplicatedTable) t, + &serial); + if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != qs) + fprintf (stderr, + "get_serial_by_table(%u) returned %d\n", + t, + (int) qs); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != qs); + } + return 0; +} + + +/** + * Every replicated table must be readable, and on an empty database none + * of them yields a record. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_all_iterable (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + for (unsigned int t = 0; + t <= TALER_EXCHANGEDB_RT_KYCAUTHS_IN; + t++) + { + struct RecordContext ctx = { + .table = (enum TALER_EXCHANGEDB_ReplicatedTable) t + }; + enum GNUNET_DB_QueryStatus qs; + + qs = TALER_EXCHANGEDB_iterate_records_by_table ( + pg, + (enum TALER_EXCHANGEDB_ReplicatedTable) t, + 0, + &record_cb, + &ctx); + if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != qs) + fprintf (stderr, + "iterate_records_by_table(%u) returned %d\n", + t, + (int) qs); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != qs); + FAILIF (0 != ctx.total); + } + return 0; +} + + +/** + * A table outside the enum is refused by all three functions. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_unknown_table (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + enum TALER_EXCHANGEDB_ReplicatedTable bogus + = (enum TALER_EXCHANGEDB_ReplicatedTable) (TALER_EXCHANGEDB_RT_KYCAUTHS_IN + + 1); + struct TALER_EXCHANGEDB_TableData td = { + .table = bogus + }; + struct RecordContext ctx = { + .table = bogus + }; + uint64_t serial; + + FAILIF (GNUNET_DB_STATUS_HARD_ERROR != + TALER_EXCHANGEDB_get_serial_by_table (pg, + bogus, + &serial)); + FAILIF (GNUNET_DB_STATUS_HARD_ERROR != + TALER_EXCHANGEDB_iterate_records_by_table (pg, + bogus, + 0, + &record_cb, + &ctx)); + FAILIF (0 != ctx.total); + FAILIF (GNUNET_DB_STATUS_HARD_ERROR != + TALER_EXCHANGEDB_insert_records_by_table (pg, + &td)); + return 0; +} + + +/** + * With rows in place, the serial of a table is the row ID of its last + * record. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_serial (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + uint64_t serial = 0; + + for (unsigned int i = 0; i < 2; i++) + TDB_account (pg, + 10 + i, + &account[i]); + TDB_denom (pg, + 10, + DENOM_VALUE, + "0.1", + &denom); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_serial_by_table ( + pg, + TALER_EXCHANGEDB_RT_WIRE_TARGETS, + &serial)); + FAILIF (2 != serial); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_serial_by_table ( + pg, + TALER_EXCHANGEDB_RT_KYC_TARGETS, + &serial)); + FAILIF (2 != serial); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_serial_by_table ( + pg, + TALER_EXCHANGEDB_RT_DENOMINATIONS, + &serial)); + FAILIF (serial != denom.serial); + + /* a table nobody wrote to still has no serial */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_serial_by_table ( + pg, + TALER_EXCHANGEDB_RT_WIRE_OUT, + &serial)); + return 0; +} + + +/** + * Reading records returns them in serial order and skips everything up + * to and including the requested serial. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct RecordContext ctx; + struct TALER_Amount expect = TDB_amount (DENOM_VALUE); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.table = TALER_EXCHANGEDB_RT_DENOMINATIONS; + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_iterate_records_by_table ( + pg, + TALER_EXCHANGEDB_RT_DENOMINATIONS, + 0, + &record_cb, + &ctx)); + FAILIF (1 != ctx.total); + FAILIF (ctx.wrong_table); + FAILIF (ctx.serial != denom.serial); + FAILIF (0 != TALER_amount_cmp (&ctx.coin, + &expect)); + + /* nothing above the last serial */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.table = TALER_EXCHANGEDB_RT_DENOMINATIONS; + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_records_by_table ( + pg, + TALER_EXCHANGEDB_RT_DENOMINATIONS, + denom.serial, + &record_cb, + &ctx)); + FAILIF (0 != ctx.total); + + /* both accounts, and the second one only above the first serial */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.table = TALER_EXCHANGEDB_RT_WIRE_TARGETS; + ctx.payto = account[1].payto.full_payto; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_records_by_table ( + pg, + TALER_EXCHANGEDB_RT_WIRE_TARGETS, + 0, + &record_cb, + &ctx)); + FAILIF (2 != ctx.total); + FAILIF (0 == ctx.payto_serial); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.table = TALER_EXCHANGEDB_RT_WIRE_TARGETS; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_records_by_table ( + pg, + TALER_EXCHANGEDB_RT_WIRE_TARGETS, + 1, + &record_cb, + &ctx)); + FAILIF (1 != ctx.total); + FAILIF (2 != ctx.serial); + return 0; +} + + +/** + * A record that is read out, dropped and written back lands in the same + * row it came from. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_round_trip (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_EXCHANGEDB_TableData td = { + .table = TALER_EXCHANGEDB_RT_WIRE_TARGETS + }; + struct RecordContext ctx; + char *hex; + + /* find the row of the second account */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.table = TALER_EXCHANGEDB_RT_WIRE_TARGETS; + ctx.payto = account[1].payto.full_payto; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_records_by_table ( + pg, + TALER_EXCHANGEDB_RT_WIRE_TARGETS, + 0, + &record_cb, + &ctx)); + FAILIF (0 == ctx.payto_serial); + + /* the account is not referenced by anything, so we can drop it and + replicate it back in */ + hex = TDB_hex (&account[1].h_full, + sizeof (account[1].h_full)); + FAILIF_C (GNUNET_OK != + TDB_exec (pg, + "DELETE FROM wire_targets" + " WHERE wire_target_h_payto=decode('%s','hex');", + hex), + GNUNET_free (hex)); + GNUNET_free (hex); + FAILIF (1 != TDB_count (pg, + "FROM wire_targets")); + + td.serial = ctx.payto_serial; + td.details.wire_targets.full_payto_uri = account[1].payto; + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_records_by_table (pg, + &td)); + FAILIF (2 != TDB_count (pg, + "FROM wire_targets")); + + /* and it is back under its original serial, with its payto intact */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.table = TALER_EXCHANGEDB_RT_WIRE_TARGETS; + ctx.payto = account[1].payto.full_payto; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_records_by_table ( + pg, + TALER_EXCHANGEDB_RT_WIRE_TARGETS, + 0, + &record_cb, + &ctx)); + FAILIF (2 != ctx.total); + FAILIF (ctx.payto_serial != td.serial); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "replication-all-serials", + &check_all_serials }, + { "replication-all-iterable", + &check_all_iterable }, + { "replication-unknown-table", + &check_unknown_table }, + { "replication-serial", + &check_serial }, + { "replication-iterate", + &check_iterate }, + { "replication-round-trip", + &check_round_trip }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-replication", + "Tests for the exchangedb replication functions", + tests); + TDB_denom_free (&denom); + for (unsigned int i = 0; i < 2; i++) + TDB_account_free (&account[i]); + return ret; +} + + +/* end of test_replication.c */ diff --git a/src/exchangedb/test_reserve_history.c b/src/exchangedb/test_reserve_history.c @@ -0,0 +1,388 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_reserve_history.c + * @brief tests for the exchangedb functions whose primary table is + * `reserve_history` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_get_reserve_history(). + * + * `reserve_history` is an index: AFTER INSERT triggers on the tables that + * change a reserve add one row each, naming the table and the row. The + * function walks that index newest-first and looks each entry up in the + * table it names. So the checks build a reserve with several kinds of + * operation on it and read the history back, and they check the ETag + * short-circuit -- which must not leave a transaction open. + */ +#include "test_common.h" +#include "exchange-database/get_reserve_history.h" +#include "exchange-database/insert_close_request.h" +#include "exchange-database/start.h" +#include "exchange-database/rollback.h" + + +/** + * Account the checks use. + */ +static struct TDB_Account account; + + +/** + * Count the entries of @a rh, and how many of them have type @a type. + * + * @param rh history to walk + * @param type operation to count + * @param[out] matched set to the number of entries of that type + * @return total number of entries + */ +static unsigned int +count_history (const struct TALER_EXCHANGEDB_ReserveHistory *rh, + enum TALER_EXCHANGEDB_ReserveOperation type, + unsigned int *matched) +{ + unsigned int total = 0; + + *matched = 0; + for (const struct TALER_EXCHANGEDB_ReserveHistory *p = rh; + NULL != p; + p = p->next) + { + total++; + if (type == p->type) + (*matched)++; + } + return total; +} + + +/** + * An unknown reserve has no history at all. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_unknown_reserve (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_EXCHANGEDB_ReserveHistory *rh = NULL; + struct TALER_Amount balance; + uint64_t etag_out = 42; + + TDB_FILL (reserve_pub, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_reserve_history (pg, + &reserve_pub, + 0, + 0, + &etag_out, + &balance, + &rh)); + FAILIF (NULL != rh); + return 0; +} + + +/** + * A reserve that exists but was never touched has no history either: the + * `reserves` row alone does not create an index entry. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_untouched_reserve (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_EXCHANGEDB_ReserveHistory *rh = NULL; + struct TALER_Amount balance; + uint64_t etag_out = 42; + + TDB_reserve (pg, + 2, + "5", + &reserve_pub); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_reserve_history (pg, + &reserve_pub, + 0, + 0, + &etag_out, + &balance, + &rh)); + FAILIF (NULL != rh); + return 0; +} + + +/** + * A bank transfer into the reserve shows up as one history entry. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_bank_transfer (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_EXCHANGEDB_ReserveHistory *rh = NULL; + struct TALER_Amount balance; + struct TALER_Amount expect = TDB_amount ("10"); + unsigned int matched; + uint64_t etag_out = 0; + + TDB_account (pg, + 10, + &account); + TDB_reserve_in (pg, + &account, + 10, + "10", + &reserve_pub); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve_history (pg, + &reserve_pub, + 0, + 0, + &etag_out, + &balance, + &rh)); + FAILIF_C (NULL == rh, + TALER_EXCHANGEDB_free_reserve_history (rh)); + FAILIF_C (0 == etag_out, + TALER_EXCHANGEDB_free_reserve_history (rh)); + FAILIF_C (0 != TALER_amount_cmp (&balance, + &expect), + TALER_EXCHANGEDB_free_reserve_history (rh)); + FAILIF_C (1 != count_history (rh, + TALER_EXCHANGEDB_RO_BANK_TO_EXCHANGE, + &matched), + TALER_EXCHANGEDB_free_reserve_history (rh)); + FAILIF_C (1 != matched, + TALER_EXCHANGEDB_free_reserve_history (rh)); + FAILIF_C (0 != TALER_amount_cmp (&rh->details.bank->amount, + &expect), + TALER_EXCHANGEDB_free_reserve_history (rh)); + FAILIF_C (0 != strcmp (rh->details.bank->sender_account_details.full_payto, + account.payto.full_payto), + TALER_EXCHANGEDB_free_reserve_history (rh)); + TALER_EXCHANGEDB_free_reserve_history (rh); + return 0; +} + + +/** + * A close request adds a second entry, and the offset filter hides the + * older one. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_two_entries (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_ReserveSignatureP reserve_sig; + struct TALER_EXCHANGEDB_ReserveHistory *rh = NULL; + struct TALER_Amount balance; + struct TALER_Amount close_balance = TDB_amount ("10"); + struct TALER_Amount close_fee = TDB_amount ("0.5"); + unsigned int matched; + uint64_t etag_first; + uint64_t etag_out = 0; + uint64_t first_off; + + TDB_fill (&reserve_pub, + sizeof (reserve_pub), + 10); + TDB_FILL (reserve_sig, + 10); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve_history (pg, + &reserve_pub, + 0, + 0, + &etag_first, + &balance, + &rh)); + first_off = rh->history_offset; + TALER_EXCHANGEDB_free_reserve_history (rh); + rh = NULL; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_close_request (pg, + &reserve_pub, + account.payto, + &reserve_sig, + GNUNET_TIME_timestamp_get (), + &close_balance, + &close_fee)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve_history (pg, + &reserve_pub, + 0, + 0, + &etag_out, + &balance, + &rh)); + FAILIF_C (2 != count_history (rh, + TALER_EXCHANGEDB_RO_CLOSE_REQUEST, + &matched), + TALER_EXCHANGEDB_free_reserve_history (rh)); + FAILIF_C (1 != matched, + TALER_EXCHANGEDB_free_reserve_history (rh)); + /* The query runs newest-first but the rows are walked back to front, so + the list that comes out is oldest-first. */ + FAILIF_C (TALER_EXCHANGEDB_RO_BANK_TO_EXCHANGE != rh->type, + TALER_EXCHANGEDB_free_reserve_history (rh)); + FAILIF_C (TALER_EXCHANGEDB_RO_CLOSE_REQUEST != rh->next->type, + TALER_EXCHANGEDB_free_reserve_history (rh)); + FAILIF_C (rh->history_offset >= rh->next->history_offset, + TALER_EXCHANGEDB_free_reserve_history (rh)); + FAILIF_C (etag_out <= etag_first, + TALER_EXCHANGEDB_free_reserve_history (rh)); + TALER_EXCHANGEDB_free_reserve_history (rh); + rh = NULL; + + /* starting past the bank transfer leaves only the close request */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve_history (pg, + &reserve_pub, + first_off, + 0, + &etag_out, + &balance, + &rh)); + FAILIF_C (1 != count_history (rh, + TALER_EXCHANGEDB_RO_CLOSE_REQUEST, + &matched), + TALER_EXCHANGEDB_free_reserve_history (rh)); + FAILIF_C (1 != matched, + TALER_EXCHANGEDB_free_reserve_history (rh)); + TALER_EXCHANGEDB_free_reserve_history (rh); + rh = NULL; + + /* starting past everything leaves an empty history, but the reserve is + still known and the ETag is still reported */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve_history (pg, + &reserve_pub, + etag_out, + 0, + &etag_out, + &balance, + &rh)); + FAILIF_C (NULL != rh, + TALER_EXCHANGEDB_free_reserve_history (rh)); + return 0; +} + + +/** + * Passing the current ETag short-circuits the lookup -- and must not leave + * the transaction it opened behind. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_etag (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_EXCHANGEDB_ReserveHistory *rh = NULL; + struct TALER_Amount balance; + uint64_t etag_out = 0; + uint64_t etag_again = 0; + + TDB_fill (&reserve_pub, + sizeof (reserve_pub), + 10); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve_history (pg, + &reserve_pub, + 0, + 0, + &etag_out, + &balance, + &rh)); + TALER_EXCHANGEDB_free_reserve_history (rh); + rh = NULL; + + /* the client already has this version */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve_history (pg, + &reserve_pub, + 0, + etag_out, + &etag_again, + &balance, + &rh)); + FAILIF (etag_again != etag_out); + FAILIF (NULL != rh); + + /* The short-circuit above returns early. If it forgets to close the + read-committed transaction it opened, the connection is parked in + "idle in transaction" -- which the next TALER_EXCHANGEDB_start() would + paper over, since its preflight rolls the stale transaction back and + only logs it. So check the bookkeeping preflight itself looks at. */ + FAILIF (NULL != pg->transaction_name); + FAILIF (GNUNET_OK != + TALER_EXCHANGEDB_start (pg, + "test-reserve-history-etag")); + FAILIF (NULL == pg->transaction_name); + TALER_EXCHANGEDB_rollback (pg); + FAILIF (NULL != pg->transaction_name); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "reserve-history-unknown-reserve", + &check_unknown_reserve }, + { "reserve-history-untouched-reserve", + &check_untouched_reserve }, + { "reserve-history-bank-transfer", + &check_bank_transfer }, + { "reserve-history-two-entries", + &check_two_entries }, + { "reserve-history-etag", + &check_etag }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-reserve-history", + "Tests for the exchangedb `reserve_history' table", + tests); + TDB_account_free (&account); + return ret; +} + + +/* end of test_reserve_history.c */ diff --git a/src/exchangedb/test_reserves.c b/src/exchangedb/test_reserves.c @@ -0,0 +1,624 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_reserves.c + * @brief tests for the exchangedb functions whose primary table is + * `reserves` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_get_reserve(), + * #TALER_EXCHANGEDB_update_reserve(), + * #TALER_EXCHANGEDB_get_reserve_balance(), + * #TALER_EXCHANGEDB_get_reserve_close_info(), + * #TALER_EXCHANGEDB_iterate_expired_reserves() and + * #TALER_EXCHANGEDB_gc(). + * + * A reserve may or may not have a `reserves_in` row: one funded by a bank + * transfer has one, one created by a peer-to-peer merge does not. Several + * of these functions LEFT JOIN through it to get the originating account, + * so both shapes are exercised. The garbage collection check runs last, + * as it removes rows the earlier checks created. + */ +#include "test_common.h" +#include "exchange-database/gc.h" +#include "exchange-database/get_reserve.h" +#include "exchange-database/get_reserve_balance.h" +#include "exchange-database/get_reserve_close_info.h" +#include "exchange-database/iterate_expired_reserves.h" +#include "exchange-database/update_reserve.h" + + +/** + * One second in microseconds. Timestamps read back through + * GNUNET_PQ_result_spec_timestamp() have to be whole seconds, so the + * checks never write a raw microsecond value that is not a multiple of + * this. + */ +#define SECOND 1000000LLU + + +/** + * Create a reserve with an explicit expiration and garbage collection + * date, which TDB_reserve() does not offer. + * + * @param pg the database context + * @param seed seed for the reserve's public key + * @param balance balance of the reserve, e.g. "10" + * @param expiration_us expiration date, in microseconds since the epoch + * @param gc_us garbage collection date, in microseconds since the epoch + * @param[out] reserve_pub set to the public key of the reserve + */ +static void +make_reserve (struct TALER_EXCHANGEDB_PostgresContext *pg, + uint32_t seed, + const char *balance, + uint64_t expiration_us, + uint64_t gc_us, + struct TALER_ReservePublicKeyP *reserve_pub) +{ + struct TALER_Amount b = TDB_amount (balance); + char *hex; + + TDB_fill (reserve_pub, + sizeof (*reserve_pub), + seed); + hex = TDB_hex (reserve_pub, + sizeof (*reserve_pub)); + GNUNET_assert (GNUNET_OK == + TDB_exec (pg, + "INSERT INTO reserves" + " (reserve_pub,current_balance" + " ,expiration_date,gc_date)" + " VALUES (decode('%s','hex')" + " ,ROW(%llu,%u)::taler_amount" + " ,%llu,%llu)" + " ON CONFLICT DO NOTHING;", + hex, + (unsigned long long) b.value, + (unsigned int) b.fraction, + (unsigned long long) expiration_us, + (unsigned long long) gc_us)); + GNUNET_free (hex); +} + + +/** + * Closure for #expired_cb(). + */ +struct ExpiredContext +{ + /** + * How many reserves did the callback see? + */ + unsigned int total; + + /** + * Public key of the last reserve seen. + */ + struct TALER_ReservePublicKeyP reserve_pub; + + /** + * Balance of the last reserve seen. + */ + struct TALER_Amount left; + + /** + * Set if the last reserve was reported with an originating account. + */ + bool have_account; + + /** + * Close request row of the last reserve seen. + */ + uint64_t close_request_row; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_expired_reserves(). + * + * @param cls a `struct ExpiredContext *` + * @param reserve_pub the expired reserve + * @param left balance left in the reserve + * @param account_details originating account + * @param expiration_date when the reserve expired + * @param close_request_row row of the close request, 0 if none + * @return #GNUNET_OK + */ +static enum GNUNET_GenericReturnValue +expired_cb (void *cls, + const struct TALER_ReservePublicKeyP *reserve_pub, + const struct TALER_Amount *left, + const struct TALER_FullPayto account_details, + struct GNUNET_TIME_Timestamp expiration_date, + uint64_t close_request_row) +{ + struct ExpiredContext *ctx = cls; + + (void) expiration_date; + ctx->total++; + ctx->reserve_pub = *reserve_pub; + ctx->left = *left; + ctx->have_account = (NULL != account_details.full_payto); + ctx->close_request_row = close_request_row; + return GNUNET_OK; +} + + +/** + * Nothing is known about a reserve that was never created. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_EXCHANGEDB_Reserve reserve; + struct TALER_Amount balance; + struct TALER_FullPayto payto = { NULL }; + struct ExpiredContext ctx = { 0 }; + + memset (&reserve, + 0, + sizeof (reserve)); + TDB_FILL (reserve.pub, + 1); + reserve.balance = TDB_amount ("1"); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_reserve (pg, + &reserve)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_update_reserve (pg, + &reserve)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_reserve_balance (pg, + &reserve.pub, + &balance, + &payto)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_reserve_close_info (pg, + &reserve.pub, + &balance, + &payto)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_expired_reserves (pg, + GNUNET_TIME_timestamp_get ( + ), + &expired_cb, + &ctx)); + FAILIF (0 != ctx.total); + FAILIF (0 != TDB_count (pg, + "FROM reserves")); + return 0; +} + + +/** + * A reserve without a `reserves_in` row is found, with no origin account. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_without_origin (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_EXCHANGEDB_Reserve reserve; + struct TALER_Amount balance; + struct TALER_Amount expect = TDB_amount ("10"); + struct TALER_FullPayto payto = { + .full_payto = (char *) "sentinel" + }; + + memset (&reserve, + 0, + sizeof (reserve)); + TDB_reserve (pg, + 2, + "10", + &reserve.pub); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve (pg, + &reserve)); + FAILIF (0 != TALER_amount_cmp (&reserve.balance, + &expect)); + FAILIF (GNUNET_TIME_absolute_is_zero (reserve.expiry.abs_time)); + FAILIF (GNUNET_TIME_absolute_is_zero (reserve.gc.abs_time)); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve_balance (pg, + &reserve.pub, + &balance, + &payto)); + FAILIF (0 != TALER_amount_cmp (&balance, + &expect)); + FAILIF (NULL != payto.full_payto); + + payto.full_payto = (char *) "sentinel"; + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve_close_info (pg, + &reserve.pub, + &balance, + &payto)); + FAILIF (0 != TALER_amount_cmp (&balance, + &expect)); + FAILIF (NULL != payto.full_payto); + return 0; +} + + +/** + * A reserve funded by a bank transfer reports its origin account. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_with_origin (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Account account; + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_Amount balance; + struct TALER_Amount expect = TDB_amount ("7"); + struct TALER_FullPayto payto = { NULL }; + + TDB_account (pg, + 3, + &account); + TDB_reserve_in (pg, + &account, + 3, + "7", + &reserve_pub); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve_balance (pg, + &reserve_pub, + &balance, + &payto), + TDB_account_free (&account)); + FAILIF_C (0 != TALER_amount_cmp (&balance, + &expect), + TDB_account_free (&account)); + FAILIF_C (NULL == payto.full_payto, + TDB_account_free (&account)); + FAILIF_C (0 != strcmp (payto.full_payto, + account.payto.full_payto), + GNUNET_free (payto.full_payto); TDB_account_free (&account)); + GNUNET_free (payto.full_payto); + + payto.full_payto = NULL; + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve_close_info (pg, + &reserve_pub, + &balance, + &payto), + TDB_account_free (&account)); + FAILIF_C (NULL == payto.full_payto, + TDB_account_free (&account)); + FAILIF_C (0 != strcmp (payto.full_payto, + account.payto.full_payto), + GNUNET_free (payto.full_payto); TDB_account_free (&account)); + GNUNET_free (payto.full_payto); + TDB_account_free (&account); + return 0; +} + + +/** + * Updating a reserve writes back the balance and both dates. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_update (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_EXCHANGEDB_Reserve reserve; + struct TALER_EXCHANGEDB_Reserve got; + struct GNUNET_TIME_Absolute now = GNUNET_TIME_absolute_get (); + + memset (&reserve, + 0, + sizeof (reserve)); + TDB_FILL (reserve.pub, + 2); + reserve.balance = TDB_amount ("3.5"); + reserve.expiry = GNUNET_TIME_absolute_to_timestamp (now); + reserve.gc = GNUNET_TIME_absolute_to_timestamp ( + GNUNET_TIME_absolute_add (now, + GNUNET_TIME_UNIT_HOURS)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_reserve (pg, + &reserve)); + memset (&got, + 0, + sizeof (got)); + got.pub = reserve.pub; + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve (pg, + &got)); + FAILIF (0 != TALER_amount_cmp (&got.balance, + &reserve.balance)); + FAILIF (GNUNET_TIME_timestamp_cmp (got.expiry, + !=, + reserve.expiry)); + FAILIF (GNUNET_TIME_timestamp_cmp (got.gc, + !=, + reserve.gc)); + return 0; +} + + +/** + * Only an expired reserve that still holds money and has an originating + * account is reported for closing. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_expired (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Account account; + struct TALER_ReservePublicKeyP funded; + struct TALER_ReservePublicKeyP drained; + struct ExpiredContext ctx; + struct GNUNET_TIME_Timestamp now = GNUNET_TIME_timestamp_get (); + struct TALER_Amount expect = TDB_amount ("7"); + char *hex; + + /* nothing has expired yet: the reserves so far all expire in 2096 */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_expired_reserves (pg, + now, + &expired_cb, + &ctx)); + + /* expire the funded reserve from check_with_origin() */ + TDB_account (pg, + 3, + &account); + TDB_fill (&funded, + sizeof (funded), + 3); + hex = TDB_hex (&funded, + sizeof (funded)); + FAILIF_C (GNUNET_OK != + TDB_exec (pg, + "UPDATE reserves" + " SET expiration_date=1000000" + " WHERE reserve_pub=decode('%s','hex');", + hex), + GNUNET_free (hex); TDB_account_free (&account)); + GNUNET_free (hex); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_iterate_expired_reserves (pg, + now, + &expired_cb, + &ctx), + TDB_account_free (&account)); + FAILIF_C (1 != ctx.total, + TDB_account_free (&account)); + FAILIF_C (0 != GNUNET_memcmp (&ctx.reserve_pub, + &funded), + TDB_account_free (&account)); + FAILIF_C (0 != TALER_amount_cmp (&ctx.left, + &expect), + TDB_account_free (&account)); + FAILIF_C (! ctx.have_account, + TDB_account_free (&account)); + + /* an expired reserve with a zero balance is not worth closing */ + make_reserve (pg, + 4, + "0", + SECOND, + SECOND, + &drained); + { + char *rhex = TDB_hex (&drained, + sizeof (drained)); + char *ahex = TDB_hex (&account.h_full, + sizeof (account.h_full)); + + FAILIF_C (GNUNET_OK != + TDB_exec (pg, + "INSERT INTO reserves_in" + " (reserve_pub,wire_reference,credit" + " ,wire_source_h_payto" + " ,exchange_account_section,execution_date)" + " VALUES (decode('%s','hex'),44" + " ,ROW(0,0)::taler_amount" + " ,decode('%s','hex'),'exchange-account-1'" + " ,1000000);", + rhex, + ahex), + GNUNET_free (rhex); GNUNET_free (ahex); + TDB_account_free (&account)); + GNUNET_free (rhex); + GNUNET_free (ahex); + } + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_iterate_expired_reserves (pg, + now, + &expired_cb, + &ctx), + TDB_account_free (&account)); + /* the query returns at most one reserve, and it is still the funded one */ + FAILIF_C (1 != ctx.total, + TDB_account_free (&account)); + FAILIF_C (0 != GNUNET_memcmp (&ctx.reserve_pub, + &funded), + TDB_account_free (&account)); + TDB_account_free (&account); + + /* an expired reserve with money but no originating account is skipped: + it was created by a merge, so there is nowhere to wire the money back + to. Drain the funded one first so it cannot mask the result. */ + hex = TDB_hex (&funded, + sizeof (funded)); + FAILIF_C (GNUNET_OK != + TDB_exec (pg, + "UPDATE reserves" + " SET current_balance=ROW(0,0)::taler_amount" + " WHERE reserve_pub=decode('%s','hex');", + hex), + GNUNET_free (hex)); + GNUNET_free (hex); + { + struct TALER_ReservePublicKeyP merged; + + make_reserve (pg, + 5, + "4", + SECOND, + SECOND, + &merged); + } + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_expired_reserves (pg, + now, + &expired_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * Garbage collection removes drained, expired reserves and leaves the + * others alone. Runs last. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_gc (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP old_empty; + struct TALER_ReservePublicKeyP old_funded; + struct TALER_ReservePublicKeyP young_empty; + char *hex; + uint64_t before; + + /* gc_date in the past and no money left: collectable */ + make_reserve (pg, + 10, + "0", + SECOND, + SECOND, + &old_empty); + /* gc_date in the past but still holding money: kept */ + make_reserve (pg, + 11, + "2", + SECOND, + SECOND, + &old_funded); + /* no money left but gc_date far in the future: kept */ + make_reserve (pg, + 12, + "0", + 4000000000000000LLU, + 5000000000000000LLU, + &young_empty); + before = TDB_count (pg, + "FROM reserves"); + FAILIF (GNUNET_OK != + TALER_EXCHANGEDB_gc (pg)); + + hex = TDB_hex (&old_empty, + sizeof (old_empty)); + FAILIF_C (0 != TDB_count (pg, + "FROM reserves" + " WHERE reserve_pub=decode('%s','hex')", + hex), + GNUNET_free (hex)); + GNUNET_free (hex); + hex = TDB_hex (&old_funded, + sizeof (old_funded)); + FAILIF_C (1 != TDB_count (pg, + "FROM reserves" + " WHERE reserve_pub=decode('%s','hex')", + hex), + GNUNET_free (hex)); + GNUNET_free (hex); + hex = TDB_hex (&young_empty, + sizeof (young_empty)); + FAILIF_C (1 != TDB_count (pg, + "FROM reserves" + " WHERE reserve_pub=decode('%s','hex')", + hex), + GNUNET_free (hex)); + GNUNET_free (hex); + FAILIF (before <= TDB_count (pg, + "FROM reserves")); + /* running it again on the same database changes nothing further */ + before = TDB_count (pg, + "FROM reserves"); + FAILIF (GNUNET_OK != + TALER_EXCHANGEDB_gc (pg)); + FAILIF (before != TDB_count (pg, + "FROM reserves")); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "reserves-empty", + &check_empty }, + { "reserves-without-origin", + &check_without_origin }, + { "reserves-with-origin", + &check_with_origin }, + { "reserves-update", + &check_update }, + { "reserves-expired", + &check_expired }, + { "reserves-gc", + &check_gc }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-reserves", + "Tests for the exchangedb `reserves' table", + tests); +} + + +/* end of test_reserves.c */ diff --git a/src/exchangedb/test_reserves_close.c b/src/exchangedb/test_reserves_close.c @@ -0,0 +1,590 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_reserves_close.c + * @brief tests for the exchangedb functions whose primary table is + * `reserves_close` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_reserve_closed(), + * #TALER_EXCHANGEDB_iterate_reserve_closed_above_serial_id() and + * #TALER_EXCHANGEDB_iterate_reserve_close_info(). + * + * The table references `reserves` and `wire_targets`, both of which the + * checks create with TDB_reserve() and TDB_account(). The KYC-facing + * iterator groups by the *normalized* account, so the checks close two + * reserves to the same account and one to another. + */ +#include "test_common.h" +#include "exchange-database/insert_reserve_closed.h" +#include "exchange-database/iterate_reserve_closed_above_serial_id.h" +#include "exchange-database/iterate_reserve_close_info.h" + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Closure for #closed_cb(). + */ +struct ClosedContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Stop after this many rows; 0 for no limit. + */ + unsigned int stop_after; + + /** + * Reserve we are looking for, NULL to match nothing. + */ + const struct TALER_ReservePublicKeyP *reserve_pub; + + /** + * How many times did we see @e reserve_pub? + */ + unsigned int matched; + + /** + * Amount reported for @e reserve_pub. + */ + struct TALER_Amount amount; + + /** + * Closing fee reported for @e reserve_pub. + */ + struct TALER_Amount closing_fee; + + /** + * Wire transfer identifier reported for @e reserve_pub. + */ + struct TALER_WireTransferIdentifierRawP wtid; + + /** + * Close request row reported for @e reserve_pub. + */ + uint64_t close_request_row; + + /** + * Receiver account reported for @e reserve_pub, owned by this struct. + */ + char *receiver; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_reserve_closed_above_serial_id(). + * + * @param cls a `struct ClosedContext *` + * @param rowid row of the close operation + * @param execution_date when the transfer was made + * @param amount_with_fee how much was debited + * @param closing_fee how much was charged for closing + * @param reserve_pub the reserve that was closed + * @param receiver_account where the money went + * @param wtid identifier of the wire transfer + * @param close_request_row close request that triggered this, 0 if none + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +closed_cb (void *cls, + uint64_t rowid, + struct GNUNET_TIME_Timestamp execution_date, + const struct TALER_Amount *amount_with_fee, + const struct TALER_Amount *closing_fee, + const struct TALER_ReservePublicKeyP *reserve_pub, + const struct TALER_FullPayto receiver_account, + const struct TALER_WireTransferIdentifierRawP *wtid, + uint64_t close_request_row) +{ + struct ClosedContext *ctx = cls; + + (void) rowid; + (void) execution_date; + ctx->total++; + if ( (NULL != ctx->reserve_pub) && + (0 == GNUNET_memcmp (reserve_pub, + ctx->reserve_pub)) ) + { + ctx->matched++; + ctx->amount = *amount_with_fee; + ctx->closing_fee = *closing_fee; + ctx->wtid = *wtid; + ctx->close_request_row = close_request_row; + GNUNET_free (ctx->receiver); + ctx->receiver = GNUNET_strdup (receiver_account.full_payto); + } + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * Closure for #amount_cb(). + */ +struct AmountContext +{ + /** + * How many amounts did the callback see? + */ + unsigned int total; + + /** + * Sum of the whole-unit parts of the amounts seen. + */ + uint64_t value_sum; + + /** + * Date of the first amount seen. + */ + struct GNUNET_TIME_Absolute first_date; + + /** + * Return this from the callback. + */ + enum GNUNET_GenericReturnValue ret; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_reserve_close_info(). + * + * @param cls a `struct AmountContext *` + * @param amount the closed amount + * @param date when the reserve was closed + * @return what @e ret of the closure says + */ +static enum GNUNET_GenericReturnValue +amount_cb (void *cls, + const struct TALER_Amount *amount, + struct GNUNET_TIME_Absolute date) +{ + struct AmountContext *ctx = cls; + + if (0 == ctx->total++) + ctx->first_date = date; + ctx->value_sum += amount->value; + return ctx->ret; +} + + +/** + * Close a reserve. + * + * @param pg the database context + * @param reserve_pub reserve to close + * @param account where the money goes + * @param seed seed for the wire transfer identifier + * @param when when the transfer was made, in seconds since the epoch + * @param amount amount debited from the reserve, e.g. "10" + * @param fee closing fee, e.g. "0.5" + * @param close_request_row close request that triggered this, 0 if none + * @param[out] wtid set to the wire transfer identifier used + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +close_reserve (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TALER_ReservePublicKeyP *reserve_pub, + const struct TDB_Account *account, + uint32_t seed, + uint64_t when, + const char *amount, + const char *fee, + uint64_t close_request_row, + struct TALER_WireTransferIdentifierRawP *wtid) +{ + struct TALER_Amount a = TDB_amount (amount); + struct TALER_Amount f = TDB_amount (fee); + + TDB_fill (wtid, + sizeof (*wtid), + seed); + return TALER_EXCHANGEDB_insert_reserve_closed (pg, + reserve_pub, + ts (when), + account->payto, + wtid, + &a, + &f, + close_request_row); +} + + +/** + * Nothing is reported while the table is empty. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct ClosedContext ctx = { 0 }; + struct AmountContext actx = { 0 }; + struct TALER_NormalizedPaytoHashP h_payto; + + TDB_FILL (h_payto, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_reserve_closed_above_serial_id ( + pg, + 0, + &closed_cb, + &ctx)); + actx.ret = GNUNET_OK; + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_reserve_close_info ( + pg, + &h_payto, + GNUNET_TIME_UNIT_ZERO_ABS, + &amount_cb, + &actx)); + FAILIF (0 != ctx.total); + FAILIF (0 != actx.total); + return 0; +} + + +/** + * A closed reserve is reported with everything it was closed with. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insert_and_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Account account; + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_WireTransferIdentifierRawP wtid; + struct TALER_Amount expect = TDB_amount ("10"); + struct TALER_Amount expect_fee = TDB_amount ("0.5"); + struct ClosedContext ctx; + + TDB_account (pg, + 10, + &account); + /* insert_reserve_closed() debits the reserve, so it has to hold exactly + what is being closed */ + TDB_reserve (pg, + 10, + "10", + &reserve_pub); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + close_reserve (pg, + &reserve_pub, + &account, + 10, + 1600000000, + "10", + "0.5", + 0, + &wtid), + TDB_account_free (&account)); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.reserve_pub = &reserve_pub; + FAILIF_C (0 >= + TALER_EXCHANGEDB_iterate_reserve_closed_above_serial_id ( + pg, + 0, + &closed_cb, + &ctx), + TDB_account_free (&account)); + FAILIF_C (1 != ctx.matched, + GNUNET_free (ctx.receiver); TDB_account_free (&account)); + FAILIF_C (0 != TALER_amount_cmp (&ctx.amount, + &expect), + GNUNET_free (ctx.receiver); TDB_account_free (&account)); + FAILIF_C (0 != TALER_amount_cmp (&ctx.closing_fee, + &expect_fee), + GNUNET_free (ctx.receiver); TDB_account_free (&account)); + FAILIF_C (0 != GNUNET_memcmp (&ctx.wtid, + &wtid), + GNUNET_free (ctx.receiver); TDB_account_free (&account)); + FAILIF_C (0 != ctx.close_request_row, + GNUNET_free (ctx.receiver); TDB_account_free (&account)); + FAILIF_C (0 != strcmp (ctx.receiver, + account.payto.full_payto), + GNUNET_free (ctx.receiver); TDB_account_free (&account)); + GNUNET_free (ctx.receiver); + TDB_account_free (&account); + return 0; +} + + +/** + * The serial bound, the abort return of the callback and a second account + * all behave as documented. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate_filters (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Account account; + struct TDB_Account other; + struct TALER_ReservePublicKeyP r2; + struct TALER_ReservePublicKeyP r3; + struct TALER_WireTransferIdentifierRawP wtid; + struct ClosedContext ctx; + + TDB_account (pg, + 10, + &account); + TDB_account (pg, + 11, + &other); + TDB_reserve (pg, + 11, + "4", + &r2); + TDB_reserve (pg, + 12, + "9", + &r3); + /* a second close to the same account, later */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + close_reserve (pg, + &r2, + &account, + 11, + 1600003600, + "4", + "0.5", + 0, + &wtid), + TDB_account_free (&account); TDB_account_free (&other)); + /* and one to a different account */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + close_reserve (pg, + &r3, + &other, + 12, + 1600007200, + "9", + "0.5", + 0, + &wtid), + TDB_account_free (&account); TDB_account_free (&other)); + FAILIF_C (3 != TDB_count (pg, + "FROM reserves_close"), + TDB_account_free (&account); TDB_account_free (&other)); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF_C (3 != + TALER_EXCHANGEDB_iterate_reserve_closed_above_serial_id ( + pg, + 0, + &closed_cb, + &ctx), + TDB_account_free (&account); TDB_account_free (&other)); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_reserve_closed_above_serial_id ( + pg, + 1000, + &closed_cb, + &ctx), + TDB_account_free (&account); TDB_account_free (&other)); + FAILIF_C (0 != ctx.total, + TDB_account_free (&account); TDB_account_free (&other)); + + /* an aborting callback stops the iteration */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.stop_after = 2; + FAILIF_C (3 != + TALER_EXCHANGEDB_iterate_reserve_closed_above_serial_id ( + pg, + 0, + &closed_cb, + &ctx), + TDB_account_free (&account); TDB_account_free (&other)); + FAILIF_C (2 != ctx.total, + TDB_account_free (&account); TDB_account_free (&other)); + TDB_account_free (&account); + TDB_account_free (&other); + return 0; +} + + +/** + * The KYC view sums the closures per account, newest first, and honours + * the time limit and the callback's abort. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_close_info (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Account account; + struct TDB_Account other; + struct AmountContext ctx; + + TDB_account (pg, + 10, + &account); + TDB_account (pg, + 11, + &other); + + /* both closures to the first account */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.ret = GNUNET_OK; + FAILIF_C (2 != + TALER_EXCHANGEDB_iterate_reserve_close_info ( + pg, + &account.h_normalized, + GNUNET_TIME_UNIT_ZERO_ABS, + &amount_cb, + &ctx), + TDB_account_free (&account); TDB_account_free (&other)); + FAILIF_C (10 + 4 != ctx.value_sum, + TDB_account_free (&account); TDB_account_free (&other)); + /* newest first */ + FAILIF_C (ctx.first_date.abs_value_us != + ts (1600003600).abs_time.abs_value_us, + TDB_account_free (&account); TDB_account_free (&other)); + + /* the other account has just the one */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.ret = GNUNET_OK; + FAILIF_C (1 != + TALER_EXCHANGEDB_iterate_reserve_close_info ( + pg, + &other.h_normalized, + GNUNET_TIME_UNIT_ZERO_ABS, + &amount_cb, + &ctx), + TDB_account_free (&account); TDB_account_free (&other)); + FAILIF_C (9 != ctx.value_sum, + TDB_account_free (&account); TDB_account_free (&other)); + + /* the time limit drops the older closure */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.ret = GNUNET_OK; + FAILIF_C (1 != + TALER_EXCHANGEDB_iterate_reserve_close_info ( + pg, + &account.h_normalized, + ts (1600001800).abs_time, + &amount_cb, + &ctx), + TDB_account_free (&account); TDB_account_free (&other)); + FAILIF_C (4 != ctx.value_sum, + TDB_account_free (&account); TDB_account_free (&other)); + + /* a limit past every closure leaves nothing */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.ret = GNUNET_OK; + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_reserve_close_info ( + pg, + &account.h_normalized, + ts (1700000000).abs_time, + &amount_cb, + &ctx), + TDB_account_free (&account); TDB_account_free (&other)); + FAILIF_C (0 != ctx.total, + TDB_account_free (&account); TDB_account_free (&other)); + + /* a callback that aborts with GNUNET_NO is not an error */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.ret = GNUNET_NO; + FAILIF_C (0 > + TALER_EXCHANGEDB_iterate_reserve_close_info ( + pg, + &account.h_normalized, + GNUNET_TIME_UNIT_ZERO_ABS, + &amount_cb, + &ctx), + TDB_account_free (&account); TDB_account_free (&other)); + FAILIF_C (1 != ctx.total, + TDB_account_free (&account); TDB_account_free (&other)); + TDB_account_free (&account); + TDB_account_free (&other); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "reserves-close-empty", + &check_empty }, + { "reserves-close-insert-and-iterate", + &check_insert_and_iterate }, + { "reserves-close-iterate-filters", + &check_iterate_filters }, + { "reserves-close-close-info", + &check_close_info }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-reserves-close", + "Tests for the exchangedb `reserves_close' table", + tests); +} + + +/* end of test_reserves_close.c */ diff --git a/src/exchangedb/test_reserves_in.c b/src/exchangedb/test_reserves_in.c @@ -0,0 +1,695 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_reserves_in.c + * @brief tests for the exchangedb functions whose primary table is + * `reserves_in` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_do_import_credits() (for its reserve half; the + * KYC-auth half is in test_kycauths_in.c), + * #TALER_EXCHANGEDB_get_reserve_origin(), + * #TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id(), + * #TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id_by_account() and + * #TALER_EXCHANGEDB_iterate_exchange_credit_transfers(). + * + * do_import_credits() creates the `wire_targets` and `reserves` rows it + * needs on its own, so the only fixture the checks have to set up is a work + * shard for the batch to advance. + */ +#include "test_common.h" +#include "exchange-database/begin_shard.h" +#include "exchange-database/do_import_credits.h" +#include "exchange-database/get_reserve_origin.h" +#include "exchange-database/iterate_exchange_credit_transfers.h" +#include "exchange-database/iterate_reserves_in_above_serial_id.h" +#include "exchange-database/iterate_reserves_in_above_serial_id_by_account.h" + + +/** + * Account the first two transfers come from. + */ +#define PAYTO_A "payto://x-taler-bank/localhost/ri-a?receiver-name=A" + +/** + * Account the third transfer comes from. + */ +#define PAYTO_B "payto://x-taler-bank/localhost/ri-b?receiver-name=B" + + +/** + * Closure for #reserve_in_cb(). + */ +struct InContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Stop after this many rows; 0 for no limit. + */ + unsigned int stop_after; + + /** + * Reserve we are looking for, NULL to match nothing. + */ + const struct TALER_ReservePublicKeyP *reserve_pub; + + /** + * How many times did we see @e reserve_pub? + */ + unsigned int matched; + + /** + * Credit reported for @e reserve_pub. + */ + struct TALER_Amount credit; + + /** + * Row reported for @e reserve_pub. + */ + uint64_t rowid; + + /** + * Wire reference reported for @e reserve_pub. + */ + uint64_t wire_reference; + + /** + * Sender account reported for @e reserve_pub, owned by this struct. + */ + char *sender; +}; + + +/** + * Callback for the `reserves_in` iterators. + * + * @param cls a `struct InContext *` + * @param rowid row of the transfer + * @param reserve_pub reserve that was credited + * @param credit amount that was credited + * @param sender_account_details account the money came from + * @param wire_reference bank's identifier for the transfer + * @param execution_date when the transfer was made + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +reserve_in_cb (void *cls, + uint64_t rowid, + const struct TALER_ReservePublicKeyP *reserve_pub, + const struct TALER_Amount *credit, + const struct TALER_FullPayto sender_account_details, + uint64_t wire_reference, + struct GNUNET_TIME_Timestamp execution_date) +{ + struct InContext *ctx = cls; + + (void) execution_date; + ctx->total++; + if ( (NULL != ctx->reserve_pub) && + (0 == GNUNET_memcmp (reserve_pub, + ctx->reserve_pub)) ) + { + ctx->matched++; + ctx->credit = *credit; + ctx->rowid = rowid; + ctx->wire_reference = wire_reference; + GNUNET_free (ctx->sender); + ctx->sender = (NULL == sender_account_details.full_payto) + ? NULL + : GNUNET_strdup (sender_account_details.full_payto); + } + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * Closure for #transfer_cb(). + */ +struct TransferContext +{ + /** + * How many transfers did the callback see? + */ + unsigned int total; + + /** + * Row of the first transfer seen. + */ + uint64_t first_row; + + /** + * Sum of the whole-unit parts of the amounts seen. + */ + uint64_t value_sum; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_exchange_credit_transfers(). + * + * @param cls a `struct TransferContext *` + * @param row_id row of the transfer + * @param payto_uri account the money came from + * @param execution_time when the transfer was made + * @param amount amount that was transferred + */ +static void +transfer_cb (void *cls, + uint64_t row_id, + const char *payto_uri, + struct GNUNET_TIME_Absolute execution_time, + const struct TALER_Amount *amount) +{ + struct TransferContext *ctx = cls; + + (void) payto_uri; + (void) execution_time; + if (0 == ctx->total++) + ctx->first_row = row_id; + ctx->value_sum += amount->value; +} + + +/** + * Import one wire transfer into a reserve, advancing a work shard of its + * own along with it. + * + * @param pg the database context + * @param job_name job whose shard to open and advance + * @param account_name exchange bank account the transfer arrived at + * @param payto account the money came from + * @param seed seed for the reserve's public key + * @param amount amount that was transferred, e.g. "10" + * @param[out] reserve_pub set to the credited reserve + * @param[out] status set to the per-transfer status + * @return transaction status of the import + */ +static enum GNUNET_DB_QueryStatus +import_one (struct TALER_EXCHANGEDB_PostgresContext *pg, + const char *job_name, + const char *account_name, + const char *payto, + uint32_t seed, + const char *amount, + struct TALER_ReservePublicKeyP *reserve_pub, + enum GNUNET_DB_QueryStatus *status) +{ + struct TALER_Amount balance = TDB_amount (amount); + struct TALER_EXCHANGEDB_ReserveInInfo info; + struct TALER_EXCHANGEDB_CreditBatch batch; + uint64_t start; + uint64_t end; + uint64_t progress; + + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_EXCHANGEDB_begin_shard (pg, + job_name, + GNUNET_TIME_UNIT_HOURS, + 1024, + &start, + &end, + &progress)); + TDB_fill (reserve_pub, + sizeof (*reserve_pub), + seed); + memset (&info, + 0, + sizeof (info)); + info.reserve_pub = reserve_pub; + info.balance = &balance; + info.execution_time = GNUNET_TIME_timestamp_get (); + info.sender_account_details.full_payto = (char *) payto; + info.wire_reference = start + 1; + memset (&batch, + 0, + sizeof (batch)); + batch.exchange_account_name = account_name; + batch.reserves = &info; + batch.reserves_length = 1; + batch.job_name = job_name; + batch.shard_start = start; + batch.shard_end = end; + batch.progress_row = end; + batch.lease = GNUNET_TIME_UNIT_HOURS; + return TALER_EXCHANGEDB_do_import_credits (pg, + &batch, + status); +} + + +/** + * Nothing is known while the table is empty. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_FullPaytoHashP h_payto; + struct TALER_FullPayto payto = { NULL }; + struct InContext ctx = { 0 }; + struct TransferContext tctx = { 0 }; + struct TALER_Amount zero = TDB_amount ("0"); + + TDB_FILL (reserve_pub, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_reserve_origin (pg, + &reserve_pub, + &h_payto, + &payto)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id (pg, + 0, + &reserve_in_cb, + &ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id_by_account ( + pg, + "exchange-account-1", + 0, + &reserve_in_cb, + &ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_exchange_credit_transfers (pg, + &zero, + 0, + 10, + NULL, + &transfer_cb, + &tctx)); + FAILIF (0 != ctx.total); + FAILIF (0 != tctx.total); + return 0; +} + + +/** + * Importing a transfer creates the reserve, the account and the + * `reserves_in` row, and doing it again is a no-op. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_import (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_FullPaytoHashP h_payto; + struct TALER_FullPayto payto = { NULL }; + struct TALER_FullPayto expect = { + .full_payto = (char *) PAYTO_A + }; + struct TALER_FullPaytoHashP expect_h; + struct TALER_Amount expect_amount = TDB_amount ("10"); + enum GNUNET_DB_QueryStatus status; + + FAILIF (0 > + import_one (pg, + "ri-job-a", + "exchange-account-1", + PAYTO_A, + 10, + "10", + &reserve_pub, + &status)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != status); + FAILIF (1 != TDB_count (pg, + "FROM reserves_in")); + FAILIF (1 != TDB_count (pg, + "FROM reserves")); + + TALER_full_payto_hash (expect, + &expect_h); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve_origin (pg, + &reserve_pub, + &h_payto, + &payto)); + FAILIF (0 != GNUNET_memcmp (&h_payto, + &expect_h)); + FAILIF (0 != strcmp (payto.full_payto, + PAYTO_A)); + GNUNET_free (payto.full_payto); + + /* the same transfer again: the row is already there */ + { + struct TALER_ReservePublicKeyP again; + + FAILIF (0 > + import_one (pg, + "ri-job-a", + "exchange-account-1", + PAYTO_A, + 10, + "10", + &again, + &status)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != status); + FAILIF (1 != TDB_count (pg, + "FROM reserves_in")); + } + + { + struct InContext ctx; + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.reserve_pub = &reserve_pub; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id ( + pg, + 0, + &reserve_in_cb, + &ctx)); + FAILIF_C (1 != ctx.matched, + GNUNET_free (ctx.sender)); + FAILIF_C (0 != TALER_amount_cmp (&ctx.credit, + &expect_amount), + GNUNET_free (ctx.sender)); + FAILIF_C (0 != strcmp (ctx.sender, + PAYTO_A), + GNUNET_free (ctx.sender)); + GNUNET_free (ctx.sender); + } + return 0; +} + + +/** + * With several transfers on file the iterators filter by serial and by + * exchange bank account, and honour an aborting callback. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP r2; + struct TALER_ReservePublicKeyP r3; + struct InContext ctx; + enum GNUNET_DB_QueryStatus status; + + /* a second transfer from the same account to the same exchange account */ + FAILIF (0 > + import_one (pg, + "ri-job-a", + "exchange-account-1", + PAYTO_A, + 11, + "2", + &r2, + &status)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != status); + /* and one from a different account to a different exchange account */ + FAILIF (0 > + import_one (pg, + "ri-job-b", + "exchange-account-2", + PAYTO_B, + 12, + "5", + &r3, + &status)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != status); + FAILIF (3 != TDB_count (pg, + "FROM reserves_in")); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.reserve_pub = &r3; + FAILIF (3 != + TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id (pg, + 0, + &reserve_in_cb, + &ctx)); + FAILIF_C (1 != ctx.matched, + GNUNET_free (ctx.sender)); + FAILIF_C (0 != strcmp (ctx.sender, + PAYTO_B), + GNUNET_free (ctx.sender)); + GNUNET_free (ctx.sender); + + /* by exchange bank account */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (2 != + TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id_by_account ( + pg, + "exchange-account-1", + 0, + &reserve_in_cb, + &ctx)); + GNUNET_free (ctx.sender); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (1 != + TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id_by_account ( + pg, + "exchange-account-2", + 0, + &reserve_in_cb, + &ctx)); + GNUNET_free (ctx.sender); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id_by_account ( + pg, + "exchange-account-does-not-exist", + 0, + &reserve_in_cb, + &ctx)); + FAILIF (0 != ctx.total); + + /* A callback that gives up stops the iteration but is not an error: the + status stays the number of rows the query matched, not the number the + callback was shown. */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.stop_after = 1; + FAILIF (3 != + TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id (pg, + 0, + &reserve_in_cb, + &ctx)); + FAILIF (1 != ctx.total); + GNUNET_free (ctx.sender); + return 0; +} + + +/** + * The serial number bound of the plain iterator excludes earlier rows. + * The bound is inclusive, so passing the row of the second transfer + * leaves the first one out and nothing else. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_serial_bound (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct InContext ctx; + + FAILIF (3 != TDB_count (pg, + "FROM reserves_in")); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (2 != + TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id (pg, + 2, + &reserve_in_cb, + &ctx)); + GNUNET_free (ctx.sender); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id (pg, + 1000, + &reserve_in_cb, + &ctx)); + GNUNET_free (ctx.sender); + return 0; +} + + +/** + * The AML view of the same rows filters by amount and by account, and can + * walk the table backwards. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_credit_transfers (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_NormalizedPayto npayto; + struct TALER_NormalizedPaytoHashP h_payto; + struct TALER_FullPayto payto_a = { + .full_payto = (char *) PAYTO_A + }; + struct TALER_Amount zero = TDB_amount ("0"); + struct TALER_Amount five = TDB_amount ("5"); + struct TALER_Amount huge = TDB_amount ("1000"); + struct TransferContext ctx; + uint64_t lowest; + + /* everything, ascending */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (3 != + TALER_EXCHANGEDB_iterate_exchange_credit_transfers (pg, + &zero, + 0, + 10, + NULL, + &transfer_cb, + &ctx)); + FAILIF (3 != ctx.total); + FAILIF (10 + 2 + 5 != ctx.value_sum); + lowest = ctx.first_row; + + /* the amount threshold drops the EUR:2 transfer */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (2 != + TALER_EXCHANGEDB_iterate_exchange_credit_transfers (pg, + &five, + 0, + 10, + NULL, + &transfer_cb, + &ctx)); + FAILIF (10 + 5 != ctx.value_sum); + + /* a threshold nobody meets */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_exchange_credit_transfers (pg, + &huge, + 0, + 10, + NULL, + &transfer_cb, + &ctx)); + FAILIF (0 != ctx.total); + + /* filtering by account keeps the two transfers from PAYTO_A */ + npayto = TALER_payto_normalize (payto_a); + GNUNET_assert (NULL != npayto.normalized_payto); + TALER_normalized_payto_hash (npayto, + &h_payto); + GNUNET_free (npayto.normalized_payto); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (2 != + TALER_EXCHANGEDB_iterate_exchange_credit_transfers (pg, + &zero, + 0, + 10, + &h_payto, + &transfer_cb, + &ctx)); + FAILIF (10 + 2 != ctx.value_sum); + + /* the limit caps the result set... */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (1 != + TALER_EXCHANGEDB_iterate_exchange_credit_transfers (pg, + &zero, + 0, + 1, + NULL, + &transfer_cb, + &ctx)); + FAILIF (lowest != ctx.first_row); + + /* ...and a negative limit walks the table the other way, starting from + the offset rather than from the beginning */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (3 != + TALER_EXCHANGEDB_iterate_exchange_credit_transfers (pg, + &zero, + 1000, + -10, + NULL, + &transfer_cb, + &ctx)); + FAILIF (lowest == ctx.first_row); + FAILIF (10 + 2 + 5 != ctx.value_sum); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "reserves-in-empty", + &check_empty }, + { "reserves-in-import", + &check_import }, + { "reserves-in-iterate", + &check_iterate }, + { "reserves-in-serial-bound", + &check_serial_bound }, + { "reserves-in-credit-transfers", + &check_credit_transfers }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-reserves-in", + "Tests for the exchangedb `reserves_in' table", + tests); +} + + +/* end of test_reserves_in.c */ diff --git a/src/exchangedb/test_reserves_open_deposits.c b/src/exchangedb/test_reserves_open_deposits.c @@ -0,0 +1,381 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_reserves_open_deposits.c + * @brief tests for the exchangedb functions whose primary table is + * `reserves_open_deposits` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_reserve_open_deposit(), the only + * exported function that touches the table. The rows are read back by the + * coin history, which test_coin_history.c covers. + * + * The table's primary key is (coin_pub, coin_sig), which is what makes a + * replayed request idempotent. On insufficient coin balance the row is + * inserted first and the balance check fails afterwards, so the caller has + * to roll back -- the check below does exactly that, since that is how the + * /reserves/$RP/open handler uses the function. + */ +#include "test_common.h" +#include "exchange-database/commit.h" +#include "exchange-database/get_known_coin.h" +#include "exchange-database/insert_reserve_open_deposit.h" +#include "exchange-database/rollback.h" +#include "exchange-database/start.h" + + +/** + * Read the remaining balance of a coin. + * + * @param pg the database context + * @param coin_pub coin to look up + * @param[out] remaining set to the coin's remaining value + */ +static void +coin_remaining (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TALER_CoinSpendPublicKeyP *coin_pub, + struct TALER_Amount *remaining) +{ + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_auto_from_type (coin_pub), + GNUNET_PQ_query_param_end + }; + struct GNUNET_PQ_ResultSpec rs[] = { + TALER_PQ_result_spec_amount ("remaining", + pg->currency, + remaining), + GNUNET_PQ_result_spec_end + }; + + GNUNET_assert (GNUNET_OK == + GNUNET_PQ_prepare_anon (pg->conn, + "SELECT remaining" + " FROM known_coins" + " WHERE coin_pub=$1;")); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "", + params, + rs)); +} + + +/** + * Spend part of a coin on keeping a reserve open. + * + * @param pg the database context + * @param coin the coin to spend from + * @param known_coin_id row of @a coin + * @param reserve_pub reserve being kept open + * @param seed seed for the coin and reserve signatures + * @param amount how much of the coin to spend, e.g. "1" + * @param[out] insufficient_funds set if the coin does not have that much + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +open_deposit (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TALER_CoinPublicInfo *coin, + uint64_t known_coin_id, + const struct TALER_ReservePublicKeyP *reserve_pub, + uint32_t seed, + const char *amount, + bool *insufficient_funds) +{ + struct TALER_CoinSpendSignatureP coin_sig; + struct TALER_ReserveSignatureP reserve_sig; + struct TALER_Amount a = TDB_amount (amount); + + TDB_fill (&coin_sig, + sizeof (coin_sig), + seed); + TDB_fill (&reserve_sig, + sizeof (reserve_sig), + seed); + return TALER_EXCHANGEDB_insert_reserve_open_deposit (pg, + coin, + &coin_sig, + known_coin_id, + &a, + &reserve_sig, + reserve_pub, + insufficient_funds); +} + + +/** + * Spending part of a coin records the deposit and debits the coin. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_deposit (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Denom denom; + struct TALER_CoinPublicInfo coin; + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_Amount remaining; + struct TALER_Amount expect = TDB_amount ("4"); + uint64_t known_coin_id; + bool insufficient = true; + + FAILIF (0 != TDB_count (pg, + "FROM reserves_open_deposits")); + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + TDB_coin (pg, + &denom, + 10, + &coin, + &known_coin_id); + TDB_reserve (pg, + 10, + "0", + &reserve_pub); + + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + open_deposit (pg, + &coin, + known_coin_id, + &reserve_pub, + 10, + "1", + &insufficient), + TDB_coin_free (&coin); TDB_denom_free (&denom)); + FAILIF_C (insufficient, + TDB_coin_free (&coin); TDB_denom_free (&denom)); + FAILIF_C (1 != TDB_count (pg, + "FROM reserves_open_deposits"), + TDB_coin_free (&coin); TDB_denom_free (&denom)); + coin_remaining (pg, + &coin.coin_pub, + &remaining); + FAILIF_C (0 != TALER_amount_cmp (&remaining, + &expect), + TDB_coin_free (&coin); TDB_denom_free (&denom)); + + /* a replay of the very same request is absorbed and does not debit the + coin a second time */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + open_deposit (pg, + &coin, + known_coin_id, + &reserve_pub, + 10, + "1", + &insufficient), + TDB_coin_free (&coin); TDB_denom_free (&denom)); + FAILIF_C (insufficient, + TDB_coin_free (&coin); TDB_denom_free (&denom)); + FAILIF_C (1 != TDB_count (pg, + "FROM reserves_open_deposits"), + TDB_coin_free (&coin); TDB_denom_free (&denom)); + coin_remaining (pg, + &coin.coin_pub, + &remaining); + FAILIF_C (0 != TALER_amount_cmp (&remaining, + &expect), + TDB_coin_free (&coin); TDB_denom_free (&denom)); + TDB_coin_free (&coin); + TDB_denom_free (&denom); + return 0; +} + + +/** + * A second, larger deposit from the same coin is refused, and the caller + * has to roll back to undo the row the function inserted first. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insufficient_funds (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Denom denom; + struct TALER_CoinPublicInfo coin; + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_Amount remaining; + struct TALER_Amount expect = TDB_amount ("4"); + uint64_t known_coin_id; + bool insufficient = false; + + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + TDB_coin (pg, + &denom, + 10, + &coin, + &known_coin_id); + TDB_reserve (pg, + 10, + "0", + &reserve_pub); + + FAILIF_C (GNUNET_OK != + TALER_EXCHANGEDB_start (pg, + "test-open-deposit-insufficient"), + TDB_coin_free (&coin); TDB_denom_free (&denom)); + /* EUR:4 is left, so EUR:5 cannot be taken */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + open_deposit (pg, + &coin, + known_coin_id, + &reserve_pub, + 11, + "5", + &insufficient), + TALER_EXCHANGEDB_rollback (pg); + TDB_coin_free (&coin); TDB_denom_free (&denom)); + FAILIF_C (! insufficient, + TALER_EXCHANGEDB_rollback (pg); + TDB_coin_free (&coin); TDB_denom_free (&denom)); + TALER_EXCHANGEDB_rollback (pg); + /* the rollback took the row with it */ + FAILIF_C (1 != TDB_count (pg, + "FROM reserves_open_deposits"), + TDB_coin_free (&coin); TDB_denom_free (&denom)); + coin_remaining (pg, + &coin.coin_pub, + &remaining); + FAILIF_C (0 != TALER_amount_cmp (&remaining, + &expect), + TDB_coin_free (&coin); TDB_denom_free (&denom)); + TDB_coin_free (&coin); + TDB_denom_free (&denom); + return 0; +} + + +/** + * Spending exactly what is left works and leaves the coin at zero. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_exact_balance (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TDB_Denom denom; + struct TALER_CoinPublicInfo coin; + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_Amount remaining; + struct TALER_Amount zero = TDB_amount ("0"); + uint64_t known_coin_id; + bool insufficient = true; + + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + TDB_coin (pg, + &denom, + 10, + &coin, + &known_coin_id); + TDB_reserve (pg, + 10, + "0", + &reserve_pub); + FAILIF_C (GNUNET_OK != + TALER_EXCHANGEDB_start (pg, + "test-open-deposit-exact"), + TDB_coin_free (&coin); TDB_denom_free (&denom)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + open_deposit (pg, + &coin, + known_coin_id, + &reserve_pub, + 12, + "4", + &insufficient), + TALER_EXCHANGEDB_rollback (pg); + TDB_coin_free (&coin); TDB_denom_free (&denom)); + FAILIF_C (insufficient, + TALER_EXCHANGEDB_rollback (pg); + TDB_coin_free (&coin); TDB_denom_free (&denom)); + FAILIF_C (0 > + TALER_EXCHANGEDB_commit (pg), + TDB_coin_free (&coin); TDB_denom_free (&denom)); + coin_remaining (pg, + &coin.coin_pub, + &remaining); + FAILIF_C (0 != TALER_amount_cmp (&remaining, + &zero), + TDB_coin_free (&coin); TDB_denom_free (&denom)); + FAILIF_C (2 != TDB_count (pg, + "FROM reserves_open_deposits"), + TDB_coin_free (&coin); TDB_denom_free (&denom)); + /* and now even the smallest further deposit is refused */ + insufficient = false; + FAILIF_C (GNUNET_OK != + TALER_EXCHANGEDB_start (pg, + "test-open-deposit-drained"), + TDB_coin_free (&coin); TDB_denom_free (&denom)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + open_deposit (pg, + &coin, + known_coin_id, + &reserve_pub, + 13, + "0.01", + &insufficient), + TALER_EXCHANGEDB_rollback (pg); + TDB_coin_free (&coin); TDB_denom_free (&denom)); + FAILIF_C (! insufficient, + TALER_EXCHANGEDB_rollback (pg); + TDB_coin_free (&coin); TDB_denom_free (&denom)); + TALER_EXCHANGEDB_rollback (pg); + TDB_coin_free (&coin); + TDB_denom_free (&denom); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "reserves-open-deposits-deposit", + &check_deposit }, + { "reserves-open-deposits-insufficient-funds", + &check_insufficient_funds }, + { "reserves-open-deposits-exact-balance", + &check_exact_balance }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-reserves-open-deposits", + "Tests for the exchangedb `reserves_open_deposits' table", + tests); +} + + +/* end of test_reserves_open_deposits.c */ diff --git a/src/exchangedb/test_reserves_open_requests.c b/src/exchangedb/test_reserves_open_requests.c @@ -0,0 +1,621 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_reserves_open_requests.c + * @brief tests for the exchangedb functions whose primary table is + * `reserves_open_requests` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_do_reserve_open() and + * #TALER_EXCHANGEDB_iterate_reserve_open_above_serial_id(). + * + * The table references `reserves`, which TDB_reserve() creates. The + * interesting cases of do_reserve_open() are the ones where it declines to + * do the work: an unknown reserve, a payment that does not cover the cost, + * a reserve that cannot pay its share, and a request that asks for nothing + * that is not already granted. Only the success path writes the table. + */ +#include "test_common.h" +#include "exchange-database/do_reserve_open.h" +#include "exchange-database/get_reserve.h" +#include "exchange-database/iterate_reserve_open_above_serial_id.h" + + +/** + * A year in microseconds, as exchange_do_reserve_open() counts it. + */ +#define YEAR_US 31536000000000LLU + + +/** + * Closure for #open_cb(). + */ +struct OpenContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Stop after this many rows; 0 for no limit. + */ + unsigned int stop_after; + + /** + * Reserve we are looking for, NULL to match nothing. + */ + const struct TALER_ReservePublicKeyP *reserve_pub; + + /** + * How many times did we see @e reserve_pub? + */ + unsigned int matched; + + /** + * Reserve payment reported for @e reserve_pub. + */ + struct TALER_Amount reserve_payment; + + /** + * Purse limit reported for @e reserve_pub. + */ + uint32_t purse_limit; + + /** + * Signature reported for @e reserve_pub. + */ + struct TALER_ReserveSignatureP reserve_sig; + + /** + * Expiration reported for @e reserve_pub. + */ + struct GNUNET_TIME_Timestamp expiration; + + /** + * Request timestamp reported for @e reserve_pub. + */ + struct GNUNET_TIME_Timestamp request_timestamp; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_reserve_open_above_serial_id(). + * + * @param cls a `struct OpenContext *` + * @param rowid row of the request + * @param reserve_payment how much the reserve itself paid + * @param request_timestamp when the request was made + * @param reserve_expiration until when the reserve is now open + * @param purse_limit how many purses were asked for + * @param reserve_pub the reserve in question + * @param reserve_sig signature affirming the request + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +open_cb (void *cls, + uint64_t rowid, + const struct TALER_Amount *reserve_payment, + struct GNUNET_TIME_Timestamp request_timestamp, + struct GNUNET_TIME_Timestamp reserve_expiration, + uint32_t purse_limit, + const struct TALER_ReservePublicKeyP *reserve_pub, + const struct TALER_ReserveSignatureP *reserve_sig) +{ + struct OpenContext *ctx = cls; + + (void) rowid; + ctx->total++; + if ( (NULL != ctx->reserve_pub) && + (0 == GNUNET_memcmp (reserve_pub, + ctx->reserve_pub)) ) + { + ctx->matched++; + ctx->reserve_payment = *reserve_payment; + ctx->purse_limit = purse_limit; + ctx->reserve_sig = *reserve_sig; + ctx->expiration = reserve_expiration; + ctx->request_timestamp = request_timestamp; + } + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * Ask to keep a reserve open. + * + * @param pg the database context + * @param reserve_pub reserve to open + * @param seed seed for the reserve signature + * @param total_paid total the client is paying, e.g. "1" + * @param reserve_payment share of that taken from the reserve, e.g. "1" + * @param purse_limit how many purses the client wants + * @param desired_expiration until when the client wants the reserve open + * @param now when the client made the request + * @param open_fee annual fee, e.g. "1" + * @param[out] no_funds set if the reserve cannot pay its share + * @param[out] open_cost set to what the operation costs + * @param[out] final_expiration set to the expiration that was granted + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +open_reserve (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TALER_ReservePublicKeyP *reserve_pub, + uint32_t seed, + const char *total_paid, + const char *reserve_payment, + uint32_t purse_limit, + struct GNUNET_TIME_Timestamp desired_expiration, + struct GNUNET_TIME_Timestamp now, + const char *open_fee, + bool *no_funds, + struct TALER_Amount *open_cost, + struct GNUNET_TIME_Timestamp *final_expiration) +{ + struct TALER_ReserveSignatureP reserve_sig; + struct TALER_Amount paid = TDB_amount (total_paid); + struct TALER_Amount payment = TDB_amount (reserve_payment); + struct TALER_Amount fee = TDB_amount (open_fee); + struct TALER_Amount balance; + + TDB_fill (&reserve_sig, + sizeof (reserve_sig), + seed); + return TALER_EXCHANGEDB_do_reserve_open (pg, + reserve_pub, + &paid, + &payment, + purse_limit, + &reserve_sig, + desired_expiration, + now, + &fee, + no_funds, + &balance, + open_cost, + final_expiration); +} + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * A reserve that does not exist cannot be opened, and nothing is on file. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_Amount open_cost; + struct GNUNET_TIME_Timestamp final_expiration; + struct OpenContext ctx = { 0 }; + bool no_funds = false; + + TDB_FILL (reserve_pub, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + open_reserve (pg, + &reserve_pub, + 1, + "1", + "1", + 1, + ts (2000000000), + ts (1600000000), + "1", + &no_funds, + &open_cost, + &final_expiration)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_reserve_open_above_serial_id ( + pg, + 0, + &open_cb, + &ctx)); + FAILIF (0 != ctx.total); + FAILIF (0 != TDB_count (pg, + "FROM reserves_open_requests")); + return 0; +} + + +/** + * Opening a reserve charges it, extends it and records the request. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_open (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_EXCHANGEDB_Reserve reserve; + struct TALER_Amount open_cost; + struct TALER_Amount expect_cost = TDB_amount ("1"); + struct TALER_Amount expect_balance = TDB_amount ("9"); + struct TALER_ReserveSignatureP expect_sig; + struct GNUNET_TIME_Timestamp final_expiration; + struct GNUNET_TIME_Timestamp now = ts (1600000000); + struct OpenContext ctx; + bool no_funds = true; + + TDB_reserve (pg, + 10, + "10", + &reserve_pub); + /* TDB_reserve() gives the reserve an expiration far in the future, which + would leave nothing for this call to do; pull it back to `now'. */ + { + char *hex = TDB_hex (&reserve_pub, + sizeof (reserve_pub)); + + FAILIF_C (GNUNET_OK != + TDB_exec (pg, + "UPDATE reserves" + " SET expiration_date=%llu" + " WHERE reserve_pub=decode('%s','hex');", + (unsigned long long) now.abs_time.abs_value_us, + hex), + GNUNET_free (hex)); + GNUNET_free (hex); + } + + /* one year of extension at an annual fee of EUR:1 */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + open_reserve (pg, + &reserve_pub, + 10, + "1", + "1", + 1, + ts (1600000000 + YEAR_US / 1000000), + now, + "1", + &no_funds, + &open_cost, + &final_expiration)); + FAILIF (no_funds); + FAILIF (0 != TALER_amount_cmp (&open_cost, + &expect_cost)); + FAILIF (final_expiration.abs_time.abs_value_us != + now.abs_time.abs_value_us + YEAR_US); + + /* the reserve paid for it */ + memset (&reserve, + 0, + sizeof (reserve)); + reserve.pub = reserve_pub; + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve (pg, + &reserve)); + FAILIF (0 != TALER_amount_cmp (&reserve.balance, + &expect_balance)); + FAILIF (GNUNET_TIME_timestamp_cmp (reserve.expiry, + !=, + final_expiration)); + + /* ...and the request itself was recorded */ + TDB_FILL (expect_sig, + 10); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.reserve_pub = &reserve_pub; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_reserve_open_above_serial_id ( + pg, + 0, + &open_cb, + &ctx)); + FAILIF (1 != ctx.matched); + FAILIF (0 != GNUNET_memcmp (&ctx.reserve_sig, + &expect_sig)); + FAILIF (0 != TALER_amount_cmp (&ctx.reserve_payment, + &expect_cost)); + FAILIF (1 != ctx.purse_limit); + FAILIF (GNUNET_TIME_timestamp_cmp (ctx.expiration, + !=, + final_expiration)); + FAILIF (GNUNET_TIME_timestamp_cmp (ctx.request_timestamp, + !=, + now)); + + /* a retry of the very same request is absorbed */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + open_reserve (pg, + &reserve_pub, + 10, + "1", + "1", + 1, + ts (1600000000 + YEAR_US / 1000000), + now, + "1", + &no_funds, + &open_cost, + &final_expiration)); + FAILIF (1 != TDB_count (pg, + "FROM reserves_open_requests")); + return 0; +} + + +/** + * A request that asks for nothing beyond what the reserve already has + * costs nothing and is not recorded. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_nothing_to_do (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_Amount open_cost; + struct TALER_Amount zero = TDB_amount ("0"); + struct GNUNET_TIME_Timestamp final_expiration; + bool no_funds = true; + uint64_t before; + + TDB_reserve (pg, + 11, + "10", + &reserve_pub); + before = TDB_count (pg, + "FROM reserves_open_requests"); + /* The reserve already runs to 2096 and no additional purses are asked + for, so there is nothing left to grant. Note that asking for even one + purse would *not* be a no-op: a fresh reserve has purses_allowed=0. */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + open_reserve (pg, + &reserve_pub, + 11, + "1", + "1", + 0, + ts (1600000000), + ts (1600000000), + "1", + &no_funds, + &open_cost, + &final_expiration)); + FAILIF (no_funds); + FAILIF (0 != TALER_amount_cmp (&open_cost, + &zero)); + FAILIF (before != TDB_count (pg, + "FROM reserves_open_requests")); + return 0; +} + + +/** + * Too little paid, or too little in the reserve, both decline the work. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insufficient (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_Amount open_cost; + struct TALER_Amount expect_cost = TDB_amount ("2"); + struct GNUNET_TIME_Timestamp final_expiration; + struct GNUNET_TIME_Timestamp now = ts (1600000000); + bool no_funds = false; + uint64_t before; + + TDB_reserve (pg, + 12, + "1", + &reserve_pub); + { + char *hex = TDB_hex (&reserve_pub, + sizeof (reserve_pub)); + + FAILIF_C (GNUNET_OK != + TDB_exec (pg, + "UPDATE reserves" + " SET expiration_date=%llu" + " WHERE reserve_pub=decode('%s','hex');", + (unsigned long long) now.abs_time.abs_value_us, + hex), + GNUNET_free (hex)); + GNUNET_free (hex); + } + before = TDB_count (pg, + "FROM reserves_open_requests"); + + /* two years at EUR:1 a year, but only EUR:1 offered */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + open_reserve (pg, + &reserve_pub, + 12, + "1", + "1", + 1, + ts (1600000000 + 2 * (YEAR_US / 1000000)), + now, + "1", + &no_funds, + &open_cost, + &final_expiration)); + FAILIF (no_funds); + FAILIF (0 != TALER_amount_cmp (&open_cost, + &expect_cost)); + FAILIF (before != TDB_count (pg, + "FROM reserves_open_requests")); + + /* enough offered, but the reserve holds only EUR:1 of the EUR:2 */ + no_funds = false; + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + open_reserve (pg, + &reserve_pub, + 12, + "2", + "2", + 1, + ts (1600000000 + 2 * (YEAR_US / 1000000)), + now, + "1", + &no_funds, + &open_cost, + &final_expiration)); + FAILIF (! no_funds); + FAILIF (before != TDB_count (pg, + "FROM reserves_open_requests")); + return 0; +} + + +/** + * The iterator's serial bound and its abort return behave as documented. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_Amount open_cost; + struct GNUNET_TIME_Timestamp final_expiration; + struct GNUNET_TIME_Timestamp now = ts (1600000000); + struct OpenContext ctx; + bool no_funds = true; + + /* a second recorded request, from another reserve */ + TDB_reserve (pg, + 13, + "10", + &reserve_pub); + { + char *hex = TDB_hex (&reserve_pub, + sizeof (reserve_pub)); + + FAILIF_C (GNUNET_OK != + TDB_exec (pg, + "UPDATE reserves" + " SET expiration_date=%llu" + " WHERE reserve_pub=decode('%s','hex');", + (unsigned long long) now.abs_time.abs_value_us, + hex), + GNUNET_free (hex)); + GNUNET_free (hex); + } + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + open_reserve (pg, + &reserve_pub, + 13, + "1", + "1", + 1, + ts (1600000000 + YEAR_US / 1000000), + now, + "1", + &no_funds, + &open_cost, + &final_expiration)); + FAILIF (2 != TDB_count (pg, + "FROM reserves_open_requests")); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (2 != + TALER_EXCHANGEDB_iterate_reserve_open_above_serial_id ( + pg, + 0, + &open_cb, + &ctx)); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_reserve_open_above_serial_id ( + pg, + 1000, + &open_cb, + &ctx)); + FAILIF (0 != ctx.total); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.stop_after = 1; + FAILIF (2 != + TALER_EXCHANGEDB_iterate_reserve_open_above_serial_id ( + pg, + 0, + &open_cb, + &ctx)); + FAILIF (1 != ctx.total); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "reserves-open-requests-empty", + &check_empty }, + { "reserves-open-requests-open", + &check_open }, + { "reserves-open-requests-nothing-to-do", + &check_nothing_to_do }, + { "reserves-open-requests-insufficient", + &check_insufficient }, + { "reserves-open-requests-iterate", + &check_iterate }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-reserves-open-requests", + "Tests for the exchangedb `reserves_open_requests' table", + tests); +} + + +/* end of test_reserves_open_requests.c */ diff --git a/src/exchangedb/test_revolving_work_shards.c b/src/exchangedb/test_revolving_work_shards.c @@ -0,0 +1,269 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_revolving_work_shards.c + * @brief tests for the exchangedb functions whose primary table is + * `revolving_work_shards` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_begin_revolving_shard(), + * #TALER_EXCHANGEDB_release_revolving_shard() and, for its + * `revolving_work_shards` half, #TALER_EXCHANGEDB_delete_shard_locks(). + * + * Unlike `work_shards`, this table is finite: once the configured shard + * limit is covered, the same shards are handed out over and over, which is + * what the checks below pin down. No foreign keys, so no fixtures. + */ +#include "test_common.h" +#include "exchange-database/begin_revolving_shard.h" +#include "exchange-database/delete_shard_locks.h" +#include "exchange-database/release_revolving_shard.h" + + +/** + * Releasing a shard that was never claimed does nothing. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_release_revolving_shard (pg, + "rws-nothing", + 0, + 9)); + FAILIF (0 != TDB_count (pg, + "FROM revolving_work_shards")); + return 0; +} + + +/** + * Fresh shards tile the range [0,shard_limit) and are handed out active. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_claim_fresh (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + uint32_t start; + uint32_t end; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_begin_revolving_shard (pg, + "rws-claim", + 10, + 25, + &start, + &end)); + FAILIF (0 != start); + FAILIF (9 != end); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_begin_revolving_shard (pg, + "rws-claim", + 10, + 25, + &start, + &end)); + FAILIF (10 != start); + FAILIF (19 != end); + /* the last shard is clipped to the limit */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_begin_revolving_shard (pg, + "rws-claim", + 10, + 25, + &start, + &end)); + FAILIF (20 != start); + FAILIF (25 != end); + FAILIF (3 != TDB_count (pg, + "FROM revolving_work_shards" + " WHERE job_name='rws-claim'" + " AND active")); + return 0; +} + + +/** + * Once the range is covered, no further shard can be claimed until one is + * released, and then it is that one that comes back. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_exhausted_and_release (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + uint32_t start; + uint32_t end; + + /* everything from check_claim_fresh() is still active */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_begin_revolving_shard (pg, + "rws-claim", + 10, + 25, + &start, + &end)); + + /* releasing a range that was never claimed changes nothing */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_release_revolving_shard (pg, + "rws-claim", + 0, + 8)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_release_revolving_shard (pg, + "rws-claim-elsewhere", + 10, + 19)); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_release_revolving_shard (pg, + "rws-claim", + 10, + 19)); + FAILIF (1 != TDB_count (pg, + "FROM revolving_work_shards" + " WHERE job_name='rws-claim'" + " AND NOT active")); + /* the released shard is the only one available, so it comes back */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_begin_revolving_shard (pg, + "rws-claim", + 10, + 25, + &start, + &end)); + FAILIF (10 != start); + FAILIF (19 != end); + FAILIF (3 != TDB_count (pg, + "FROM revolving_work_shards" + " WHERE job_name='rws-claim'" + " AND active")); + /* releasing twice is idempotent from the caller's point of view */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_release_revolving_shard (pg, + "rws-claim", + 10, + 19)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_release_revolving_shard (pg, + "rws-claim", + 10, + 19)); + return 0; +} + + +/** + * A shard size at or above the limit yields a single shard covering + * everything. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_single_shard (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + uint32_t start; + uint32_t end; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_begin_revolving_shard (pg, + "rws-single", + 1000, + 4, + &start, + &end)); + FAILIF (0 != start); + FAILIF (4 != end); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_begin_revolving_shard (pg, + "rws-single", + 1000, + 4, + &start, + &end)); + return 0; +} + + +/** + * Deleting the shard locks empties the table. Runs last. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_delete_locks (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + uint32_t start; + uint32_t end; + + FAILIF (0 == TDB_count (pg, + "FROM revolving_work_shards")); + FAILIF (GNUNET_OK != + TALER_EXCHANGEDB_delete_shard_locks (pg)); + FAILIF (0 != TDB_count (pg, + "FROM revolving_work_shards")); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_begin_revolving_shard (pg, + "rws-claim", + 10, + 25, + &start, + &end)); + FAILIF (0 != start); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "revolving-work-shards-empty", + &check_empty }, + { "revolving-work-shards-claim-fresh", + &check_claim_fresh }, + { "revolving-work-shards-exhausted-and-release", + &check_exhausted_and_release }, + { "revolving-work-shards-single-shard", + &check_single_shard }, + { "revolving-work-shards-delete-locks", + &check_delete_locks }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-revolving-work-shards", + "Tests for the exchangedb `revolving_work_shards' table", + tests); +} + + +/* end of test_revolving_work_shards.c */ diff --git a/src/exchangedb/test_signkey_revocations.c b/src/exchangedb/test_signkey_revocations.c @@ -0,0 +1,275 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_signkey_revocations.c + * @brief tests for the exchangedb functions whose primary table is + * `signkey_revocations` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_signkey_revocation() and + * #TALER_EXCHANGEDB_get_signkey_revocation(). + * + * `signkey_revocations` references `exchange_sign_keys`, and both functions + * find the row through the signing key rather than through the revocation's + * own primary key -- so an unknown signing key has to come back as "no + * revocation" rather than as an error. The fixture is inserted with + * #TALER_EXCHANGEDB_insert_signkey(), whose own table is covered by + * test_exchange_sign_keys.c. + */ +#include "test_common.h" +#include "exchange-database/insert_signkey.h" +#include "exchange-database/insert_signkey_revocation.h" +#include "exchange-database/get_signkey_revocation.h" +#include "exchange-database/iterate_active_signkeys.h" + + +/** + * Insert a signing key that is valid for another hour. + * + * @param pg the database context + * @param seed seed for the key and its master signature + * @param[out] exchange_pub set to the public key + */ +static void +add_signkey (struct TALER_EXCHANGEDB_PostgresContext *pg, + uint32_t seed, + struct TALER_ExchangePublicKeyP *exchange_pub) +{ + struct GNUNET_TIME_Absolute now = GNUNET_TIME_absolute_get (); + struct TALER_MasterSignatureP master_sig; + struct TALER_EXCHANGEDB_SignkeyMetaData meta; + + TDB_fill (exchange_pub, + sizeof (*exchange_pub), + seed); + TDB_FILL (master_sig, + seed); + meta.start = GNUNET_TIME_absolute_to_timestamp (now); + meta.expire_sign + = GNUNET_TIME_absolute_to_timestamp ( + GNUNET_TIME_absolute_add (now, + GNUNET_TIME_UNIT_HOURS)); + meta.expire_legal + = GNUNET_TIME_absolute_to_timestamp ( + GNUNET_TIME_absolute_add (now, + GNUNET_TIME_relative_multiply ( + GNUNET_TIME_UNIT_HOURS, + 24))); + GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_EXCHANGEDB_insert_signkey (pg, + exchange_pub, + &meta, + &master_sig)); +} + + +/** + * Counts the keys the active-signkey iterator reports. + */ +struct CountContext +{ + /** + * Key we are looking for. + */ + const struct TALER_ExchangePublicKeyP *exchange_pub; + + /** + * How many times did we see @e exchange_pub? + */ + unsigned int matched; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_active_signkeys(). + * + * @param cls a `struct CountContext *` + * @param exchange_pub public key of the exchange + * @param meta meta data of @a exchange_pub + * @param master_sig master signature over @a exchange_pub + */ +static void +count_cb (void *cls, + const struct TALER_ExchangePublicKeyP *exchange_pub, + const struct TALER_EXCHANGEDB_SignkeyMetaData *meta, + const struct TALER_MasterSignatureP *master_sig) +{ + struct CountContext *ctx = cls; + + (void) meta; + (void) master_sig; + if (0 == GNUNET_memcmp (exchange_pub, + ctx->exchange_pub)) + ctx->matched++; +} + + +/** + * A signing key that does not exist has no revocation, and revoking it + * does nothing. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_unknown_signkey (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ExchangePublicKeyP exchange_pub; + struct TALER_MasterSignatureP master_sig; + struct TALER_MasterSignatureP got; + + TDB_FILL (exchange_pub, + 1); + TDB_FILL (master_sig, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_signkey_revocation (pg, + &exchange_pub, + &got)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_insert_signkey_revocation (pg, + &exchange_pub, + &master_sig)); + FAILIF (0 != TDB_count (pg, + "FROM signkey_revocations")); + return 0; +} + + +/** + * A key that exists but was not revoked has no revocation either. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_not_revoked (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ExchangePublicKeyP exchange_pub; + struct TALER_MasterSignatureP got; + + add_signkey (pg, + 2, + &exchange_pub); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_signkey_revocation (pg, + &exchange_pub, + &got)); + return 0; +} + + +/** + * Revoking a key records the signature and takes the key out of the + * active set. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_revoke (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ExchangePublicKeyP exchange_pub; + struct TALER_MasterSignatureP master_sig; + struct TALER_MasterSignatureP got; + struct CountContext ctx; + + add_signkey (pg, + 3, + &exchange_pub); + TDB_FILL (master_sig, + 33); + + /* while unrevoked, the key is active */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.exchange_pub = &exchange_pub; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_active_signkeys (pg, + &count_cb, + &ctx)); + FAILIF (1 != ctx.matched); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_signkey_revocation (pg, + &exchange_pub, + &master_sig)); + memset (&got, + 0, + sizeof (got)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_signkey_revocation (pg, + &exchange_pub, + &got)); + FAILIF (0 != GNUNET_memcmp (&got, + &master_sig)); + + /* ...and now it is gone from the active set */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.exchange_pub = &exchange_pub; + FAILIF (0 > + TALER_EXCHANGEDB_iterate_active_signkeys (pg, + &count_cb, + &ctx)); + FAILIF (0 != ctx.matched); + + /* the untouched key from the previous check is still fine */ + { + struct TALER_ExchangePublicKeyP other; + + TDB_FILL (other, + 2); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_signkey_revocation (pg, + &other, + &got)); + } + FAILIF (1 != TDB_count (pg, + "FROM signkey_revocations")); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "signkey-revocations-unknown-signkey", + &check_unknown_signkey }, + { "signkey-revocations-not-revoked", + &check_not_revoked }, + { "signkey-revocations-revoke", + &check_revoke }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-signkey-revocations", + "Tests for the exchangedb `signkey_revocations' table", + tests); +} + + +/* end of test_signkey_revocations.c */ diff --git a/src/exchangedb/test_table.sh b/src/exchangedb/test_table.sh @@ -0,0 +1,63 @@ +#!/bin/sh +# This file is in the public domain. +# +# Driver for the per-table src/exchangedb/ tests. Takes the test binary to +# run as its first argument, provisions a scratch database of its own for it +# and removes the database again afterwards. No pre-existing database is +# ever touched: the name is derived from the binary and the PID and is +# deliberately *not* taken from the environment. +set -eu + +BINARY="$1" +shift + +# Where the build tree lives; meson passes this, the fallback is for +# running the script by hand from its build directory. +BUILD_ROOT="${TALER_BUILD_ROOT:-$(cd ../../.. && pwd)}" +SQL_DIR="${TALER_SQL_DIR:-${BUILD_ROOT}/src/exchangedb/sql-schema}" + +# Skip (77) rather than fail if there is no usable PostgreSQL around. +command -v createdb > /dev/null 2>&1 || exit 77 +command -v dropdb > /dev/null 2>&1 || exit 77 +psql -l < /dev/null > /dev/null 2>&1 || exit 77 + +# Postgres database names are limited to 63 characters, so use a digest of +# the binary name rather than the name itself. +TAG=$(echo "$BINARY" | cksum | cut -d' ' -f1) +DBNAME="taler_exdb_t${TAG}_$$" +CONF="${BINARY}_$$.conf" + +# The freshly built libraries must outrank any installed copy, as +# LD_LIBRARY_PATH beats the build tree's RUNPATH. +for d in "${BUILD_ROOT}"/src/*/; do + LD_LIBRARY_PATH="${d%/}${LD_LIBRARY_PATH:+:}${LD_LIBRARY_PATH:-}" +done +export LD_LIBRARY_PATH + +cleanup () +{ + dropdb --if-exists "$DBNAME" > /dev/null 2>&1 || true + rm -f "$CONF" +} +trap cleanup EXIT + +cat > "$CONF" <<EOF +[exchange] +CURRENCY = EUR +BASE_URL = http://localhost/ +[exchangedb-postgres] +CONFIG = postgres:///${DBNAME} +SQL_DIR = ${SQL_DIR}/ +[exchangedb] +MAX_AML_PROGRAM_RUNTIME = 1 minute +IDLE_RESERVE_EXPIRATION_TIME = 4 weeks +LEGAL_RESERVE_EXPIRATION_TIME = 7 years +AGGREGATOR_SHIFT = 1s +DEFAULT_PURSE_LIMIT = 1 +EOF + +createdb "$DBNAME" > /dev/null 2>&1 || exit 77 + +RET=0 +"./$BINARY" -c "$CONF" "$@" || RET=$? +exit $RET diff --git a/src/exchangedb/test_wire_accounts.c b/src/exchangedb/test_wire_accounts.c @@ -0,0 +1,406 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_wire_accounts.c + * @brief tests for the exchangedb functions whose primary table is + * `wire_accounts` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_wire(), #TALER_EXCHANGEDB_update_wire(), + * #TALER_EXCHANGEDB_get_wire_timestamp() and + * #TALER_EXCHANGEDB_iterate_wire_accounts(). + * + * `wire_accounts` has no foreign keys -- it is the exchange's own list of + * bank accounts, not the `wire_targets` list of everyone else's. The + * optional columns (conversion URL, gateways, bank label) are exercised + * both set and NULL, since the iterator has to hand both back. + */ +#include "test_common.h" +#include "exchange-database/insert_wire.h" +#include "exchange-database/update_wire.h" +#include "exchange-database/get_wire_timestamp.h" +#include "exchange-database/iterate_wire_accounts.h" + + +/** + * Closure for #account_cb(). + */ +struct AccountContext +{ + /** + * Account we are looking for. + */ + const char *payto_uri; + + /** + * How many accounts did the callback see in total? + */ + unsigned int total; + + /** + * How many times did we see @e payto_uri? + */ + unsigned int matched; + + /** + * Set if the account was reported with data we did not store. + */ + bool mismatch; + + /** + * Set if the account was reported with a conversion URL. + */ + bool have_conversion_url; + + /** + * Set if the account was reported with a bank label. + */ + bool have_bank_label; + + /** + * Priority reported for the account. + */ + int64_t priority; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_wire_accounts(). + * + * @param cls a `struct AccountContext *` + * @param payto_uri the account + * @param conversion_url conversion service, NULL if none + * @param open_banking_gateway open banking gateway, NULL if none + * @param prepared_transfer_url transfer gateway, NULL if none + * @param debit_restrictions debit restrictions on the account + * @param credit_restrictions credit restrictions on the account + * @param master_sig master signature over the account + * @param bank_label label for the UI, NULL if none + * @param priority ordering hint for the UI + */ +static void +account_cb (void *cls, + const struct TALER_FullPayto payto_uri, + const char *conversion_url, + const char *open_banking_gateway, + const char *prepared_transfer_url, + const json_t *debit_restrictions, + const json_t *credit_restrictions, + const struct TALER_MasterSignatureP *master_sig, + const char *bank_label, + int64_t priority) +{ + struct AccountContext *ctx = cls; + + (void) open_banking_gateway; + (void) prepared_transfer_url; + (void) master_sig; + ctx->total++; + if (0 != strcmp (payto_uri.full_payto, + ctx->payto_uri)) + return; + ctx->matched++; + ctx->have_conversion_url = (NULL != conversion_url); + ctx->have_bank_label = (NULL != bank_label); + ctx->priority = priority; + if ( (! json_is_array (debit_restrictions)) || + (! json_is_array (credit_restrictions)) ) + ctx->mismatch = true; +} + + +/** + * Nothing is known about an account that was never inserted, and updating + * it does not create it. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_FullPayto payto = { + .full_payto = (char *) "payto://x-taler-bank/localhost/wa-none" + }; + struct GNUNET_TIME_Timestamp last_date; + struct TALER_MasterSignatureP master_sig; + json_t *empty = json_array (); + + TDB_FILL (master_sig, + 1); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_wire_timestamp (pg, + payto, + &last_date), + json_decref (empty)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_update_wire (pg, + payto, + NULL, + NULL, + NULL, + empty, + empty, + GNUNET_TIME_timestamp_get (), + &master_sig, + NULL, + 0, + true), + json_decref (empty)); + FAILIF_C (0 != TDB_count (pg, + "FROM wire_accounts"), + json_decref (empty)); + json_decref (empty); + return 0; +} + + +/** + * Insert an account with all optional columns set and read it back. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insert_full (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + const char *uri = "payto://x-taler-bank/localhost/wa-full"; + struct TALER_FullPayto payto = { + .full_payto = (char *) uri + }; + struct GNUNET_TIME_Timestamp start = GNUNET_TIME_timestamp_get (); + struct GNUNET_TIME_Timestamp last_date; + struct TALER_MasterSignatureP master_sig; + struct AccountContext ctx; + json_t *restrictions; + + TDB_FILL (master_sig, + 2); + restrictions = json_pack ("[{s:s}]", + "type", + "deny"); + GNUNET_assert (NULL != restrictions); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_wire (pg, + payto, + "https://conversion.example/", + "https://obg.example/", + "https://ptu.example/", + restrictions, + restrictions, + start, + &master_sig, + "My Bank", + 7), + json_decref (restrictions)); + json_decref (restrictions); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_wire_timestamp (pg, + payto, + &last_date)); + FAILIF (GNUNET_TIME_timestamp_cmp (last_date, + !=, + start)); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.payto_uri = uri; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_wire_accounts (pg, + &account_cb, + &ctx)); + FAILIF (1 != ctx.matched); + FAILIF (ctx.mismatch); + FAILIF (! ctx.have_conversion_url); + FAILIF (! ctx.have_bank_label); + FAILIF (7 != ctx.priority); + return 0; +} + + +/** + * Insert an account with every optional column left out; the iterator has + * to hand the NULLs back as NULL. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insert_minimal (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + const char *uri = "payto://x-taler-bank/localhost/wa-min"; + struct TALER_FullPayto payto = { + .full_payto = (char *) uri + }; + struct TALER_MasterSignatureP master_sig; + struct AccountContext ctx; + json_t *empty = json_array (); + + TDB_FILL (master_sig, + 3); + GNUNET_assert (NULL != empty); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_insert_wire (pg, + payto, + NULL, + NULL, + NULL, + empty, + empty, + GNUNET_TIME_timestamp_get (), + &master_sig, + NULL, + -3), + json_decref (empty)); + json_decref (empty); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.payto_uri = uri; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_wire_accounts (pg, + &account_cb, + &ctx)); + FAILIF (1 != ctx.matched); + FAILIF (ctx.mismatch); + FAILIF (ctx.have_conversion_url); + FAILIF (ctx.have_bank_label); + FAILIF (-3 != ctx.priority); + FAILIF (2 != ctx.total); + return 0; +} + + +/** + * Disabling an account takes it out of the iterator but leaves the row. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_disable (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + const char *uri = "payto://x-taler-bank/localhost/wa-min"; + struct TALER_FullPayto payto = { + .full_payto = (char *) uri + }; + struct GNUNET_TIME_Timestamp change = GNUNET_TIME_timestamp_get (); + struct GNUNET_TIME_Timestamp last_date; + struct AccountContext ctx; + + /* a disabled account may be stored without restrictions and signature */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_wire (pg, + payto, + NULL, + NULL, + NULL, + NULL, + NULL, + change, + NULL, + NULL, + 0, + false)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_wire_timestamp (pg, + payto, + &last_date)); + FAILIF (GNUNET_TIME_timestamp_cmp (last_date, + !=, + change)); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.payto_uri = uri; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_wire_accounts (pg, + &account_cb, + &ctx)); + FAILIF (0 != ctx.matched); + FAILIF (1 != ctx.total); + FAILIF (2 != TDB_count (pg, + "FROM wire_accounts")); + + /* re-enabling brings it back, with the new details */ + { + struct TALER_MasterSignatureP master_sig; + json_t *empty = json_array (); + + TDB_FILL (master_sig, + 4); + GNUNET_assert (NULL != empty); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_wire (pg, + payto, + "https://conv2.example/", + NULL, + NULL, + empty, + empty, + change, + &master_sig, + "Relabelled", + 11, + true), + json_decref (empty)); + json_decref (empty); + } + memset (&ctx, + 0, + sizeof (ctx)); + ctx.payto_uri = uri; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_wire_accounts (pg, + &account_cb, + &ctx)); + FAILIF (1 != ctx.matched); + FAILIF (! ctx.have_conversion_url); + FAILIF (! ctx.have_bank_label); + FAILIF (11 != ctx.priority); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "wire-accounts-empty", + &check_empty }, + { "wire-accounts-insert-full", + &check_insert_full }, + { "wire-accounts-insert-minimal", + &check_insert_minimal }, + { "wire-accounts-disable", + &check_disable }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-wire-accounts", + "Tests for the exchangedb `wire_accounts' table", + tests); +} + + +/* end of test_wire_accounts.c */ diff --git a/src/exchangedb/test_wire_fee.c b/src/exchangedb/test_wire_fee.c @@ -0,0 +1,407 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_wire_fee.c + * @brief tests for the exchangedb functions whose primary table is + * `wire_fee` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_wire_fee(), + * #TALER_EXCHANGEDB_get_wire_fee(), + * #TALER_EXCHANGEDB_get_wire_fee_by_time() and + * #TALER_EXCHANGEDB_iterate_wire_fees(). + * + * `wire_fee` has no foreign keys. What the checks pin down is the shape of + * the validity intervals: `get_wire_fee` treats them as [start,end), and + * `get_wire_fee_by_time` returns an invalid amount rather than an error + * when the range it is asked about spans two different fees. + */ +#include "test_common.h" +#include "exchange-database/insert_wire_fee.h" +#include "exchange-database/get_wire_fee.h" +#include "exchange-database/get_wire_fee_by_time.h" +#include "exchange-database/iterate_wire_fees.h" + + +/** + * Wire method the checks use. + */ +#define METHOD "x-taler-bank" + +/** + * Start of the first fee period; a fixed point in time so that the checks + * do not depend on when they run. + */ +#define T0 1600000000 + +/** + * One hour, in seconds. + */ +#define HOUR 3600 + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Closure for #fee_cb(). + */ +struct FeeContext +{ + /** + * How many fee rows did the callback see? + */ + unsigned int total; + + /** + * Sum of the whole-unit parts of the wire fees seen, as a cheap way to + * tell which rows were reported. + */ + uint64_t value_sum; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_wire_fees(). + * + * @param cls a `struct FeeContext *` + * @param fees the fees of this period + * @param start_date start of the period + * @param end_date end of the period + * @param master_sig signature over the period + */ +static void +fee_cb (void *cls, + const struct TALER_WireFeeSet *fees, + struct GNUNET_TIME_Timestamp start_date, + struct GNUNET_TIME_Timestamp end_date, + const struct TALER_MasterSignatureP *master_sig) +{ + struct FeeContext *ctx = cls; + + (void) start_date; + (void) end_date; + (void) master_sig; + ctx->total++; + ctx->value_sum += fees->wire.value; +} + + +/** + * Insert a fee period. + * + * @param pg the database context + * @param from start of the period + * @param until end of the period + * @param wire wire fee, e.g. "1" + * @param closing closing fee, e.g. "2" + * @param seed seed for the master signature + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +add_fee (struct TALER_EXCHANGEDB_PostgresContext *pg, + uint64_t from, + uint64_t until, + const char *wire, + const char *closing, + uint32_t seed) +{ + struct TALER_WireFeeSet fees; + struct TALER_MasterSignatureP master_sig; + + fees.wire = TDB_amount (wire); + fees.closing = TDB_amount (closing); + TDB_FILL (master_sig, + seed); + return TALER_EXCHANGEDB_insert_wire_fee (pg, + METHOD, + ts (from), + ts (until), + &fees, + &master_sig); +} + + +/** + * No fees means no lookups succeed and the iterator sees nothing. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct GNUNET_TIME_Timestamp start_date; + struct GNUNET_TIME_Timestamp end_date; + struct TALER_WireFeeSet fees; + struct TALER_MasterSignatureP master_sig; + struct FeeContext ctx = { 0 }; + uint64_t rowid; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_wire_fee (pg, + METHOD, + ts (T0), + &rowid, + &start_date, + &end_date, + &fees, + &master_sig)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_wire_fee_by_time (pg, + METHOD, + ts (T0), + ts (T0 + HOUR), + &fees)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_wire_fees (pg, + METHOD, + &fee_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * A single fee period is found inside its interval and nowhere else. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_single_period (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct GNUNET_TIME_Timestamp start_date; + struct GNUNET_TIME_Timestamp end_date; + struct TALER_WireFeeSet fees; + struct TALER_Amount expect_wire = TDB_amount ("1"); + struct TALER_Amount expect_closing = TDB_amount ("2"); + struct TALER_MasterSignatureP master_sig; + struct TALER_MasterSignatureP expect_sig; + uint64_t rowid = 0; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_fee (pg, + T0, + T0 + HOUR, + "1", + "2", + 10)); + TDB_FILL (expect_sig, + 10); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_wire_fee (pg, + METHOD, + ts (T0), + &rowid, + &start_date, + &end_date, + &fees, + &master_sig)); + FAILIF (0 == rowid); + FAILIF (GNUNET_TIME_timestamp_cmp (start_date, + !=, + ts (T0))); + FAILIF (GNUNET_TIME_timestamp_cmp (end_date, + !=, + ts (T0 + HOUR))); + FAILIF (0 != TALER_amount_cmp (&fees.wire, + &expect_wire)); + FAILIF (0 != TALER_amount_cmp (&fees.closing, + &expect_closing)); + FAILIF (0 != GNUNET_memcmp (&master_sig, + &expect_sig)); + + /* the interval is half-open: the end is not covered... */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_wire_fee (pg, + METHOD, + ts (T0 + HOUR), + &rowid, + &start_date, + &end_date, + &fees, + &master_sig)); + /* ...and neither is anything before the start */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_wire_fee (pg, + METHOD, + ts (T0 - 1), + &rowid, + &start_date, + &end_date, + &fees, + &master_sig)); + /* another wire method has no fees at all */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_wire_fee (pg, + "iban", + ts (T0), + &rowid, + &start_date, + &end_date, + &fees, + &master_sig)); + return 0; +} + + +/** + * Over a range with one fee, get_wire_fee_by_time() returns it; over a + * range spanning two different fees it returns an invalid amount. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_by_time (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_WireFeeSet fees; + struct TALER_Amount expect_wire = TDB_amount ("1"); + struct FeeContext ctx; + + /* a second period with the *same* fees, and a third with different ones */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_fee (pg, + T0 + HOUR, + T0 + 2 * HOUR, + "1", + "2", + 11)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_fee (pg, + T0 + 2 * HOUR, + T0 + 3 * HOUR, + "5", + "6", + 12)); + + /* one period: the fee itself */ + memset (&fees, + 0, + sizeof (fees)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_wire_fee_by_time (pg, + METHOD, + ts (T0), + ts (T0 + HOUR), + &fees)); + FAILIF (0 != TALER_amount_cmp (&fees.wire, + &expect_wire)); + + /* two periods that agree: still the fee. The status is the number of + rows the range covered, so it is 2 rather than ONE_RESULT. */ + memset (&fees, + 0, + sizeof (fees)); + FAILIF (2 != + TALER_EXCHANGEDB_get_wire_fee_by_time (pg, + METHOD, + ts (T0), + ts (T0 + 2 * HOUR), + &fees)); + FAILIF (0 != TALER_amount_cmp (&fees.wire, + &expect_wire)); + + /* three periods that disagree: success, but an invalid amount */ + memset (&fees, + 0, + sizeof (fees)); + FAILIF (0 > + TALER_EXCHANGEDB_get_wire_fee_by_time (pg, + METHOD, + ts (T0), + ts (T0 + 3 * HOUR), + &fees)); + FAILIF (GNUNET_OK == + TALER_amount_is_valid (&fees.wire)); + FAILIF (GNUNET_OK == + TALER_amount_is_valid (&fees.closing)); + + /* a range outside every period finds nothing */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_wire_fee_by_time (pg, + METHOD, + ts (T0 + 10 * HOUR), + ts (T0 + 11 * HOUR), + &fees)); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_wire_fees (pg, + METHOD, + &fee_cb, + &ctx)); + FAILIF (3 != ctx.total); + FAILIF (1 + 1 + 5 != ctx.value_sum); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_wire_fees (pg, + "iban", + &fee_cb, + &ctx)); + FAILIF (0 != ctx.total); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "wire-fee-empty", + &check_empty }, + { "wire-fee-single-period", + &check_single_period }, + { "wire-fee-by-time", + &check_by_time }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-wire-fee", + "Tests for the exchangedb `wire_fee' table", + tests); +} + + +/* end of test_wire_fee.c */ diff --git a/src/exchangedb/test_wire_out.c b/src/exchangedb/test_wire_out.c @@ -0,0 +1,632 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_wire_out.c + * @brief tests for the exchangedb functions whose primary table is + * `wire_out` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_insert_wire_out(), + * #TALER_EXCHANGEDB_iterate_wire_outs_above_serial_id(), + * #TALER_EXCHANGEDB_iterate_wire_outs_above_serial_id_by_account(), + * #TALER_EXCHANGEDB_iterate_exchange_debit_transfers() and + * #TALER_EXCHANGEDB_iterate_aggregation_amounts_for_kyc_check(). + * + * `wire_out` records the transfers the exchange actually made. It + * references `wire_targets` for the receiver, which TDB_account() creates; + * the exchange's own account is stored as a plain string. + */ +#include "test_common.h" +#include "exchange-database/insert_wire_out.h" +#include "exchange-database/iterate_aggregation_amounts_for_kyc_check.h" +#include "exchange-database/iterate_exchange_debit_transfers.h" +#include "exchange-database/iterate_wire_outs_above_serial_id.h" +#include "exchange-database/iterate_wire_outs_above_serial_id_by_account.h" + + +/** + * Account the checks pay out to. + */ +static struct TDB_Account account; + + +/** + * A second account, to show the filters are per account. + */ +static struct TDB_Account other; + + +/** + * Build a timestamp from a number of seconds since the epoch. + * + * @param secs seconds since the epoch + * @return the timestamp + */ +static struct GNUNET_TIME_Timestamp +ts (uint64_t secs) +{ + struct GNUNET_TIME_Absolute abs = { + .abs_value_us = secs * 1000LLU * 1000LLU + }; + + return GNUNET_TIME_absolute_to_timestamp (abs); +} + + +/** + * Closure for #wire_out_cb(). + */ +struct OutContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Stop after this many rows; 0 for no limit. + */ + unsigned int stop_after; + + /** + * Wire transfer we are looking for, NULL to match nothing. + */ + const struct TALER_WireTransferIdentifierRawP *wtid; + + /** + * How many times did we see it? + */ + unsigned int matched; + + /** + * Amount reported for it. + */ + struct TALER_Amount amount; + + /** + * Receiver account reported for it, owned by this struct. + */ + char *payto; +}; + + +/** + * Callback for the `wire_out` iterators. + * + * @param cls a `struct OutContext *` + * @param rowid row of the transfer + * @param date when it was made + * @param wtid its wire transfer identifier + * @param payto_uri where the money went + * @param amount how much was transferred + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +wire_out_cb (void *cls, + uint64_t rowid, + struct GNUNET_TIME_Timestamp date, + const struct TALER_WireTransferIdentifierRawP *wtid, + const struct TALER_FullPayto payto_uri, + const struct TALER_Amount *amount) +{ + struct OutContext *ctx = cls; + + (void) rowid; + (void) date; + ctx->total++; + if ( (NULL != ctx->wtid) && + (0 == GNUNET_memcmp (wtid, + ctx->wtid)) ) + { + ctx->matched++; + ctx->amount = *amount; + GNUNET_free (ctx->payto); + ctx->payto = GNUNET_strdup (payto_uri.full_payto); + } + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * Closure for #transfer_cb(). + */ +struct TransferContext +{ + /** + * How many transfers did the callback see? + */ + unsigned int total; + + /** + * Row of the first transfer seen. + */ + uint64_t first_row; + + /** + * Sum of the whole-unit parts of the amounts seen. + */ + uint64_t value_sum; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_exchange_debit_transfers(). + * + * @param cls a `struct TransferContext *` + * @param row_id row of the transfer + * @param payto_uri where the money went + * @param execution_time when it was made + * @param amount how much was transferred + */ +static void +transfer_cb (void *cls, + uint64_t row_id, + const char *payto_uri, + struct GNUNET_TIME_Absolute execution_time, + const struct TALER_Amount *amount) +{ + struct TransferContext *ctx = cls; + + (void) payto_uri; + (void) execution_time; + if (0 == ctx->total++) + ctx->first_row = row_id; + ctx->value_sum += amount->value; +} + + +/** + * Closure for #amount_cb(). + */ +struct AmountContext +{ + /** + * How many amounts did the callback see? + */ + unsigned int total; + + /** + * Sum of the whole-unit parts of the amounts seen. + */ + uint64_t value_sum; + + /** + * Return this from the callback. + */ + enum GNUNET_GenericReturnValue ret; +}; + + +/** + * Callback for + * #TALER_EXCHANGEDB_iterate_aggregation_amounts_for_kyc_check(). + * + * @param cls a `struct AmountContext *` + * @param amount the transferred amount + * @param date when it was transferred + * @return what @e ret of the closure says + */ +static enum GNUNET_GenericReturnValue +amount_cb (void *cls, + const struct TALER_Amount *amount, + struct GNUNET_TIME_Absolute date) +{ + struct AmountContext *ctx = cls; + + (void) date; + ctx->total++; + ctx->value_sum += amount->value; + return ctx->ret; +} + + +/** + * Record an executed wire transfer. + * + * @param pg the database context + * @param acc account the money went to + * @param seed seed for the wire transfer identifier + * @param when when the transfer was made, in seconds since the epoch + * @param amount how much was transferred, e.g. "10" + * @param metadata extra wire subject metadata, NULL for none + * @param[out] wtid set to the wire transfer identifier used + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +add_wire_out (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TDB_Account *acc, + uint32_t seed, + uint64_t when, + const char *amount, + const char *metadata, + struct TALER_WireTransferIdentifierRawP *wtid) +{ + struct TALER_FullPayto exchange_payto = { + .full_payto = (char *) "payto://x-taler-bank/localhost/exchange" + }; + struct TALER_Amount a = TDB_amount (amount); + + TDB_fill (wtid, + sizeof (*wtid), + seed); + return TALER_EXCHANGEDB_insert_wire_out (pg, + ts (when), + wtid, + &acc->h_full, + "exchange-account-1", + exchange_payto, + &a, + metadata); +} + + +/** + * Nothing is reported while the table is empty. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct OutContext ctx = { 0 }; + struct TransferContext tctx = { 0 }; + struct AmountContext actx = { 0 }; + struct TALER_Amount zero = TDB_amount ("0"); + + TDB_account (pg, + 10, + &account); + TDB_account (pg, + 11, + &other); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_wire_outs_above_serial_id (pg, + 0, + &wire_out_cb, + &ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_wire_outs_above_serial_id_by_account ( + pg, + "exchange-account-1", + 0, + &wire_out_cb, + &ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_exchange_debit_transfers (pg, + &zero, + 0, + 10, + NULL, + &transfer_cb, + &tctx)); + actx.ret = GNUNET_OK; + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_aggregation_amounts_for_kyc_check ( + pg, + &account.h_normalized, + GNUNET_TIME_UNIT_ZERO_ABS, + &amount_cb, + &actx)); + FAILIF (0 != ctx.total); + FAILIF (0 != tctx.total); + FAILIF (0 != actx.total); + return 0; +} + + +/** + * A recorded transfer comes back with what it was recorded with. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insert (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_WireTransferIdentifierRawP wtid; + struct OutContext ctx; + struct TALER_Amount expect = TDB_amount ("10"); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_wire_out (pg, + &account, + 10, + 1600000000, + "10", + NULL, + &wtid)); + FAILIF (1 != TDB_count (pg, + "FROM wire_out")); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.wtid = &wtid; + FAILIF (0 >= + TALER_EXCHANGEDB_iterate_wire_outs_above_serial_id (pg, + 0, + &wire_out_cb, + &ctx)); + FAILIF_C (1 != ctx.matched, + GNUNET_free (ctx.payto)); + FAILIF_C (0 != TALER_amount_cmp (&ctx.amount, + &expect), + GNUNET_free (ctx.payto)); + FAILIF_C (0 != strcmp (ctx.payto, + account.payto.full_payto), + GNUNET_free (ctx.payto)); + GNUNET_free (ctx.payto); + return 0; +} + + +/** + * The by-account iterator filters on the exchange's own bank account, and + * the serial bound and the abort return behave as documented. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_WireTransferIdentifierRawP w2; + struct TALER_WireTransferIdentifierRawP w3; + struct OutContext ctx; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_wire_out (pg, + &account, + 11, + 1600003600, + "2", + "invoice-42", + &w2)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + add_wire_out (pg, + &other, + 12, + 1600007200, + "5", + NULL, + &w3)); + FAILIF (3 != TDB_count (pg, + "FROM wire_out")); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (3 != + TALER_EXCHANGEDB_iterate_wire_outs_above_serial_id (pg, + 0, + &wire_out_cb, + &ctx)); + GNUNET_free (ctx.payto); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_wire_outs_above_serial_id (pg, + 1000, + &wire_out_cb, + &ctx)); + FAILIF (0 != ctx.total); + + /* everything went out through the same exchange account */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (3 != + TALER_EXCHANGEDB_iterate_wire_outs_above_serial_id_by_account ( + pg, + "exchange-account-1", + 0, + &wire_out_cb, + &ctx)); + GNUNET_free (ctx.payto); + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_wire_outs_above_serial_id_by_account ( + pg, + "exchange-account-2", + 0, + &wire_out_cb, + &ctx)); + FAILIF (0 != ctx.total); + + /* a callback that gives up stops the iteration */ + memset (&ctx, + 0, + sizeof (ctx)); + ctx.stop_after = 1; + FAILIF (3 != + TALER_EXCHANGEDB_iterate_wire_outs_above_serial_id (pg, + 0, + &wire_out_cb, + &ctx)); + FAILIF (1 != ctx.total); + GNUNET_free (ctx.payto); + return 0; +} + + +/** + * The AML views filter by amount and by receiving account. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_aml_views (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_Amount zero = TDB_amount ("0"); + struct TALER_Amount five = TDB_amount ("5"); + struct TransferContext ctx; + struct AmountContext actx; + uint64_t lowest; + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (3 != + TALER_EXCHANGEDB_iterate_exchange_debit_transfers (pg, + &zero, + 0, + 10, + NULL, + &transfer_cb, + &ctx)); + FAILIF (10 + 2 + 5 != ctx.value_sum); + lowest = ctx.first_row; + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (2 != + TALER_EXCHANGEDB_iterate_exchange_debit_transfers (pg, + &five, + 0, + 10, + NULL, + &transfer_cb, + &ctx)); + FAILIF (10 + 5 != ctx.value_sum); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (2 != + TALER_EXCHANGEDB_iterate_exchange_debit_transfers (pg, + &zero, + 0, + 10, + &account. + h_normalized, + &transfer_cb, + &ctx)); + FAILIF (10 + 2 != ctx.value_sum); + + /* the negative limit walks the table backwards */ + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (3 != + TALER_EXCHANGEDB_iterate_exchange_debit_transfers (pg, + &zero, + 1000, + -10, + NULL, + &transfer_cb, + &ctx)); + FAILIF (lowest == ctx.first_row); + + /* the KYC view groups by the credited account */ + memset (&actx, + 0, + sizeof (actx)); + actx.ret = GNUNET_OK; + FAILIF (2 != + TALER_EXCHANGEDB_iterate_aggregation_amounts_for_kyc_check ( + pg, + &account.h_normalized, + GNUNET_TIME_UNIT_ZERO_ABS, + &amount_cb, + &actx)); + FAILIF (10 + 2 != actx.value_sum); + + memset (&actx, + 0, + sizeof (actx)); + actx.ret = GNUNET_OK; + FAILIF (1 != + TALER_EXCHANGEDB_iterate_aggregation_amounts_for_kyc_check ( + pg, + &other.h_normalized, + GNUNET_TIME_UNIT_ZERO_ABS, + &amount_cb, + &actx)); + FAILIF (5 != actx.value_sum); + + /* a limit in the future hides everything */ + memset (&actx, + 0, + sizeof (actx)); + actx.ret = GNUNET_OK; + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_aggregation_amounts_for_kyc_check ( + pg, + &account.h_normalized, + GNUNET_TIME_relative_to_absolute (GNUNET_TIME_UNIT_HOURS), + &amount_cb, + &actx)); + FAILIF (0 != actx.total); + + /* and an aborting callback is not an error */ + memset (&actx, + 0, + sizeof (actx)); + actx.ret = GNUNET_NO; + FAILIF (0 > + TALER_EXCHANGEDB_iterate_aggregation_amounts_for_kyc_check ( + pg, + &account.h_normalized, + GNUNET_TIME_UNIT_ZERO_ABS, + &amount_cb, + &actx)); + FAILIF (1 != actx.total); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "wire-out-empty", + &check_empty }, + { "wire-out-insert", + &check_insert }, + { "wire-out-iterate", + &check_iterate }, + { "wire-out-aml-views", + &check_aml_views }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-wire-out", + "Tests for the exchangedb `wire_out' table", + tests); + TDB_account_free (&account); + TDB_account_free (&other); + return ret; +} + + +/* end of test_wire_out.c */ diff --git a/src/exchangedb/test_withdraw.c b/src/exchangedb/test_withdraw.c @@ -0,0 +1,825 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_withdraw.c + * @brief tests for the exchangedb functions whose primary table is + * `withdraw` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_do_withdraw(), + * #TALER_EXCHANGEDB_get_withdraw(), + * #TALER_EXCHANGEDB_get_reserve_by_h_planchets(), + * #TALER_EXCHANGEDB_iterate_withdrawals_above_serial_id() and + * #TALER_EXCHANGEDB_iterate_withdraw_amounts_for_kyc_check(). + * + * `withdraw` references `reserves` and `denominations`. do_withdraw() has + * four distinct "did not do the work" answers -- unknown reserve, + * insufficient balance, age requirement not met and blinding-seed reuse -- + * plus an idempotent replay, and each of them is checked. + */ +#include "test_common.h" +#include "exchange-database/do_withdraw.h" +#include "exchange-database/get_withdraw.h" +#include "exchange-database/get_reserve.h" +#include "exchange-database/get_reserve_by_h_planchets.h" +#include "exchange-database/iterate_withdrawals_above_serial_id.h" +#include "exchange-database/iterate_withdraw_amounts_for_kyc_check.h" + + +/** + * Account the checks fund their reserves from. + */ +static struct TDB_Account account; + + +/** + * Denomination the checks withdraw. + */ +static struct TDB_Denom denom; + + +/** + * Fill in a withdraw request for one coin of #denom. + * + * @param seed seed for the planchet hash, signature and blinding seed + * @param amount amount to withdraw, e.g. "5" + * @param reserve_pub reserve to withdraw from + * @param with_seed true to pass a blinding seed + * @param[out] wd set to the request; release with free_withdraw() + */ +static void +make_withdraw (uint32_t seed, + const char *amount, + const struct TALER_ReservePublicKeyP *reserve_pub, + bool with_seed, + struct TALER_EXCHANGEDB_Withdraw *wd) +{ + memset (wd, + 0, + sizeof (*wd)); + wd->amount_with_fee = TDB_amount (amount); + wd->age_proof_required = false; + wd->reserve_pub = *reserve_pub; + TDB_fill (&wd->planchets_h, + sizeof (wd->planchets_h), + seed); + TDB_fill (&wd->reserve_sig, + sizeof (wd->reserve_sig), + seed); + wd->num_coins = 1; + wd->denom_serials = GNUNET_new (uint64_t); + wd->denom_serials[0] = denom.serial; + wd->denom_sigs = GNUNET_new (struct TALER_BlindedDenominationSignature); + TDB_blinded_denom_sig (seed, + &wd->denom_sigs[0]); + wd->no_blinding_seed = ! with_seed; + if (with_seed) + TDB_fill (&wd->blinding_seed, + sizeof (wd->blinding_seed), + seed); +} + + +/** + * Release what make_withdraw() allocated. + * + * @param[in,out] wd request to clean up + */ +static void +free_withdraw (struct TALER_EXCHANGEDB_Withdraw *wd) +{ + for (size_t i = 0; i<wd->num_coins; i++) + TALER_blinded_denom_sig_free (&wd->denom_sigs[i]); + GNUNET_free (wd->denom_sigs); + GNUNET_free (wd->denom_serials); +} + + +/** + * Run a withdraw request. + * + * @param pg the database context + * @param wd the request + * @param[out] st set to the outcome flags + * @return transaction status + */ +struct WithdrawStatus +{ + /** + * Was the balance sufficient? + */ + bool balance_ok; + + /** + * Were the age requirements met? + */ + bool age_ok; + + /** + * Was this a replay? + */ + bool idempotent; + + /** + * Was the blinding seed used before? + */ + bool nonce_reuse; + + /** + * Balance the reserve had. + */ + struct TALER_Amount reserve_balance; + + /** + * Maximum age the reserve allows. + */ + uint16_t allowed_maximum_age; + + /** + * Birthday recorded for the reserve. + */ + uint32_t reserve_birthday; + + /** + * Index the exchange chose not to reveal. + */ + uint16_t noreveal_index; +}; + + +/** + * Perform a withdraw request. + * + * @param pg the database context + * @param wd the request + * @param[out] st set to the outcome + * @return transaction status + */ +static enum GNUNET_DB_QueryStatus +run_withdraw (struct TALER_EXCHANGEDB_PostgresContext *pg, + const struct TALER_EXCHANGEDB_Withdraw *wd, + struct WithdrawStatus *st) +{ + struct GNUNET_TIME_Timestamp now = GNUNET_TIME_timestamp_get (); + + memset (st, + 0, + sizeof (*st)); + return TALER_EXCHANGEDB_do_withdraw (pg, + wd, + &now, + &st->balance_ok, + &st->reserve_balance, + &st->age_ok, + &st->allowed_maximum_age, + &st->reserve_birthday, + &st->idempotent, + &st->noreveal_index, + &st->nonce_reuse); +} + + +/** + * Closure for #withdraw_cb(). + */ +struct WithdrawContext +{ + /** + * How many rows did the callback see? + */ + unsigned int total; + + /** + * Stop after this many rows; 0 for no limit. + */ + unsigned int stop_after; + + /** + * Planchet hash we are looking for, NULL to match nothing. + */ + const struct TALER_HashBlindedPlanchetsP *planchets_h; + + /** + * How many times did we see it? + */ + unsigned int matched; + + /** + * Amount reported for it. + */ + struct TALER_Amount amount; + + /** + * Denomination serials reported for it. + */ + uint64_t denom_serial; + + /** + * Number of denominations reported for it. + */ + size_t num_denom_serials; + + /** + * Was a blinding seed reported for it? + */ + bool have_seed; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_withdrawals_above_serial_id(). + * + * @param cls a `struct WithdrawContext *` + * @param rowid row of the withdraw + * @param num_denom_serials number of denominations withdrawn + * @param denom_serials the denominations withdrawn + * @param selected_h hash over the selected planchets + * @param h_planchets hash over all planchets + * @param blinding_seed blinding seed, NULL if none + * @param age_proof_required was an age proof required? + * @param max_age maximum age of the coins + * @param noreveal_index index the exchange did not reveal + * @param reserve_pub reserve that was drained + * @param reserve_sig signature over the request + * @param execution_date when the withdraw happened + * @param amount_with_fee how much was withdrawn + * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop + */ +static enum GNUNET_GenericReturnValue +withdraw_cb (void *cls, + uint64_t rowid, + size_t num_denom_serials, + const uint64_t *denom_serials, + const struct TALER_HashBlindedPlanchetsP *selected_h, + const struct TALER_HashBlindedPlanchetsP *h_planchets, + const struct TALER_BlindingMasterSeedP *blinding_seed, + bool age_proof_required, + uint8_t max_age, + uint8_t noreveal_index, + const struct TALER_ReservePublicKeyP *reserve_pub, + const struct TALER_ReserveSignatureP *reserve_sig, + struct GNUNET_TIME_Timestamp execution_date, + const struct TALER_Amount *amount_with_fee) +{ + struct WithdrawContext *ctx = cls; + + (void) rowid; + (void) selected_h; + (void) age_proof_required; + (void) max_age; + (void) noreveal_index; + (void) reserve_pub; + (void) reserve_sig; + (void) execution_date; + ctx->total++; + if ( (NULL != ctx->planchets_h) && + (0 == GNUNET_memcmp (h_planchets, + ctx->planchets_h)) ) + { + ctx->matched++; + ctx->amount = *amount_with_fee; + ctx->num_denom_serials = num_denom_serials; + if (0 < num_denom_serials) + ctx->denom_serial = denom_serials[0]; + ctx->have_seed = (NULL != blinding_seed); + } + if ( (0 != ctx->stop_after) && + (ctx->total >= ctx->stop_after) ) + return GNUNET_SYSERR; + return GNUNET_OK; +} + + +/** + * Closure for #amount_cb(). + */ +struct AmountContext +{ + /** + * How many amounts did the callback see? + */ + unsigned int total; + + /** + * Sum of the whole-unit parts of the amounts seen. + */ + uint64_t value_sum; + + /** + * Return this from the callback. + */ + enum GNUNET_GenericReturnValue ret; +}; + + +/** + * Callback for #TALER_EXCHANGEDB_iterate_withdraw_amounts_for_kyc_check(). + * + * @param cls a `struct AmountContext *` + * @param amount the withdrawn amount + * @param date when it was withdrawn + * @return what @e ret of the closure says + */ +static enum GNUNET_GenericReturnValue +amount_cb (void *cls, + const struct TALER_Amount *amount, + struct GNUNET_TIME_Absolute date) +{ + struct AmountContext *ctx = cls; + + (void) date; + ctx->total++; + ctx->value_sum += amount->value; + return ctx->ret; +} + + +/** + * Withdrawing from a reserve that does not exist does nothing. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_unknown_reserve (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_EXCHANGEDB_Withdraw wd; + struct WithdrawStatus st; + struct TALER_HashBlindedPlanchetsP h; + uint64_t withdraw_serial_id; + + TDB_denom (pg, + 10, + "5", + "0.1", + &denom); + TDB_FILL (reserve_pub, + 1); + make_withdraw (1, + "5", + &reserve_pub, + false, + &wd); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + run_withdraw (pg, + &wd, + &st), + free_withdraw (&wd)); + free_withdraw (&wd); + FAILIF (0 != TDB_count (pg, + "FROM withdraw")); + TDB_FILL (h, + 1); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_reserve_by_h_planchets (pg, + &h, + &reserve_pub, + &withdraw_serial_id)); + { + struct TALER_EXCHANGEDB_Withdraw got; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_get_withdraw (pg, + &h, + &got)); + } + return 0; +} + + +/** + * A reserve without enough money keeps it. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_insufficient_balance (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_EXCHANGEDB_Withdraw wd; + struct WithdrawStatus st; + struct TALER_Amount expect = TDB_amount ("1"); + + TDB_account (pg, + 10, + &account); + TDB_reserve_in (pg, + &account, + 10, + "1", + &reserve_pub); + make_withdraw (2, + "5", + &reserve_pub, + false, + &wd); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_withdraw (pg, + &wd, + &st), + free_withdraw (&wd)); + free_withdraw (&wd); + FAILIF (st.balance_ok); + FAILIF (! st.age_ok); + FAILIF (st.idempotent); + FAILIF (0 != TALER_amount_cmp (&st.reserve_balance, + &expect)); + FAILIF (0 != TDB_count (pg, + "FROM withdraw")); + return 0; +} + + +/** + * A funded reserve is drained and the withdraw is recorded. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_withdraw (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_EXCHANGEDB_Withdraw wd; + struct TALER_EXCHANGEDB_Withdraw got; + struct TALER_EXCHANGEDB_Reserve reserve; + struct WithdrawStatus st; + struct TALER_Amount expect_balance = TDB_amount ("5"); + struct TALER_Amount expect_left = TDB_amount ("5"); + struct TALER_ReservePublicKeyP got_pub; + uint64_t withdraw_serial_id = 0; + + TDB_reserve_in (pg, + &account, + 11, + "10", + &reserve_pub); + make_withdraw (11, + "5", + &reserve_pub, + true, + &wd); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_withdraw (pg, + &wd, + &st), + free_withdraw (&wd)); + FAILIF_C (! st.balance_ok, + free_withdraw (&wd)); + FAILIF_C (! st.age_ok, + free_withdraw (&wd)); + FAILIF_C (st.idempotent, + free_withdraw (&wd)); + FAILIF_C (st.nonce_reuse, + free_withdraw (&wd)); + FAILIF_C (1 != TDB_count (pg, + "FROM withdraw"), + free_withdraw (&wd)); + + /* the reserve was debited */ + memset (&reserve, + 0, + sizeof (reserve)); + reserve.pub = reserve_pub; + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve (pg, + &reserve), + free_withdraw (&wd)); + FAILIF_C (0 != TALER_amount_cmp (&reserve.balance, + &expect_left), + free_withdraw (&wd)); + + /* the request can be looked up by its planchet hash */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve_by_h_planchets (pg, + &wd.planchets_h, + &got_pub, + &withdraw_serial_id), + free_withdraw (&wd)); + FAILIF_C (0 != GNUNET_memcmp (&got_pub, + &reserve_pub), + free_withdraw (&wd)); + FAILIF_C (0 == withdraw_serial_id, + free_withdraw (&wd)); + + memset (&got, + 0, + sizeof (got)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_withdraw (pg, + &wd.planchets_h, + &got), + free_withdraw (&wd)); + FAILIF_C (0 != TALER_amount_cmp (&got.amount_with_fee, + &expect_balance), + free_withdraw (&got); free_withdraw (&wd)); + FAILIF_C (0 != GNUNET_memcmp (&got.reserve_pub, + &reserve_pub), + free_withdraw (&got); free_withdraw (&wd)); + FAILIF_C (0 != GNUNET_memcmp (&got.reserve_sig, + &wd.reserve_sig), + free_withdraw (&got); free_withdraw (&wd)); + FAILIF_C (1 != got.num_coins, + free_withdraw (&got); free_withdraw (&wd)); + FAILIF_C (denom.serial != got.denom_serials[0], + free_withdraw (&got); free_withdraw (&wd)); + FAILIF_C (got.no_blinding_seed, + free_withdraw (&got); free_withdraw (&wd)); + free_withdraw (&got); + + /* a replay of the same request is idempotent and does not debit again */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_withdraw (pg, + &wd, + &st), + free_withdraw (&wd)); + FAILIF_C (! st.idempotent, + free_withdraw (&wd)); + FAILIF_C (1 != TDB_count (pg, + "FROM withdraw"), + free_withdraw (&wd)); + memset (&reserve, + 0, + sizeof (reserve)); + reserve.pub = reserve_pub; + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve (pg, + &reserve), + free_withdraw (&wd)); + FAILIF_C (0 != TALER_amount_cmp (&reserve.balance, + &expect_left), + free_withdraw (&wd)); + free_withdraw (&wd); + return 0; +} + + +/** + * Reusing a blinding seed for a different withdraw is refused, and the + * reserve is left debited -- the caller has to roll back. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_nonce_reuse (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_EXCHANGEDB_Withdraw wd; + struct WithdrawStatus st; + + TDB_reserve_in (pg, + &account, + 12, + "10", + &reserve_pub); + /* a fresh planchet hash, but the blinding seed of check_withdraw() */ + make_withdraw (13, + "5", + &reserve_pub, + true, + &wd); + TDB_fill (&wd.blinding_seed, + sizeof (wd.blinding_seed), + 11); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_withdraw (pg, + &wd, + &st), + free_withdraw (&wd)); + FAILIF_C (! st.nonce_reuse, + free_withdraw (&wd)); + FAILIF_C (st.idempotent, + free_withdraw (&wd)); + free_withdraw (&wd); + /* No withdraw row was written... */ + FAILIF (1 != TDB_count (pg, + "FROM withdraw")); + /* ...but the reserve was debited before the seed was checked, so the + caller has to roll the transaction back. This is what the check is + really about: the function is not safe to call outside a transaction. */ + { + struct TALER_EXCHANGEDB_Reserve reserve; + struct TALER_Amount debited = TDB_amount ("5"); + + memset (&reserve, + 0, + sizeof (reserve)); + reserve.pub = reserve_pub; + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_get_reserve (pg, + &reserve)); + FAILIF (0 != TALER_amount_cmp (&reserve.balance, + &debited)); + } + return 0; +} + + +/** + * A reserve with a birthday refuses a withdraw that commits to too high + * an age. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_age_restriction (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_ReservePublicKeyP reserve_pub; + struct TALER_EXCHANGEDB_Withdraw wd; + struct WithdrawStatus st; + char *hex; + + TDB_reserve_in (pg, + &account, + 14, + "10", + &reserve_pub); + /* born 20000 days after the epoch, i.e. in 2024 */ + hex = TDB_hex (&reserve_pub, + sizeof (reserve_pub)); + FAILIF_C (GNUNET_OK != + TDB_exec (pg, + "UPDATE reserves" + " SET birthday=20000" + " WHERE reserve_pub=decode('%s','hex');", + hex), + GNUNET_free (hex)); + GNUNET_free (hex); + + make_withdraw (14, + "5", + &reserve_pub, + false, + &wd); + /* no age commitment at all, from a reserve that has a birthday */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_withdraw (pg, + &wd, + &st), + free_withdraw (&wd)); + free_withdraw (&wd); + FAILIF (st.age_ok); + FAILIF (20000 != st.reserve_birthday); + FAILIF (1 != TDB_count (pg, + "FROM withdraw")); + return 0; +} + + +/** + * The iterators see the withdraw, and the KYC view attributes it to the + * account that funded the reserve. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + struct TALER_HashBlindedPlanchetsP h; + struct WithdrawContext ctx; + struct AmountContext actx; + + TDB_FILL (h, + 11); + memset (&ctx, + 0, + sizeof (ctx)); + ctx.planchets_h = &h; + FAILIF (1 != + TALER_EXCHANGEDB_iterate_withdrawals_above_serial_id (pg, + 0, + &withdraw_cb, + &ctx)); + FAILIF (1 != ctx.matched); + FAILIF (1 != ctx.num_denom_serials); + FAILIF (denom.serial != ctx.denom_serial); + FAILIF (! ctx.have_seed); + + memset (&ctx, + 0, + sizeof (ctx)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_withdrawals_above_serial_id (pg, + 1000, + &withdraw_cb, + &ctx)); + FAILIF (0 != ctx.total); + + memset (&ctx, + 0, + sizeof (ctx)); + ctx.stop_after = 1; + FAILIF (1 != + TALER_EXCHANGEDB_iterate_withdrawals_above_serial_id (pg, + 0, + &withdraw_cb, + &ctx)); + FAILIF (1 != ctx.total); + + /* the KYC view groups by the account that funded the reserve */ + memset (&actx, + 0, + sizeof (actx)); + actx.ret = GNUNET_OK; + FAILIF (1 != + TALER_EXCHANGEDB_iterate_withdraw_amounts_for_kyc_check ( + pg, + &account.h_normalized, + GNUNET_TIME_UNIT_ZERO_ABS, + &amount_cb, + &actx)); + FAILIF (5 != actx.value_sum); + + /* an account nobody withdrew against has nothing */ + { + struct TALER_NormalizedPaytoHashP other; + + TDB_FILL (other, + 77); + memset (&actx, + 0, + sizeof (actx)); + actx.ret = GNUNET_OK; + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_withdraw_amounts_for_kyc_check ( + pg, + &other, + GNUNET_TIME_UNIT_ZERO_ABS, + &amount_cb, + &actx)); + FAILIF (0 != actx.total); + } + + /* a time limit in the future hides everything */ + memset (&actx, + 0, + sizeof (actx)); + actx.ret = GNUNET_OK; + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_iterate_withdraw_amounts_for_kyc_check ( + pg, + &account.h_normalized, + GNUNET_TIME_relative_to_absolute (GNUNET_TIME_UNIT_HOURS), + &amount_cb, + &actx)); + FAILIF (0 != actx.total); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "withdraw-unknown-reserve", + &check_unknown_reserve }, + { "withdraw-insufficient-balance", + &check_insufficient_balance }, + { "withdraw-withdraw", + &check_withdraw }, + { "withdraw-nonce-reuse", + &check_nonce_reuse }, + { "withdraw-age-restriction", + &check_age_restriction }, + { "withdraw-iterate", + &check_iterate }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + int ret; + + ret = TDB_main (argc, + argv, + "test-withdraw", + "Tests for the exchangedb `withdraw' table", + tests); + TDB_account_free (&account); + TDB_denom_free (&denom); + return ret; +} + + +/* end of test_withdraw.c */ diff --git a/src/exchangedb/test_work_shards.c b/src/exchangedb/test_work_shards.c @@ -0,0 +1,374 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file exchangedb/test_work_shards.c + * @brief tests for the exchangedb functions whose primary table is + * `work_shards` + * @author Christian Grothoff + * + * Covers #TALER_EXCHANGEDB_begin_shard(), + * #TALER_EXCHANGEDB_update_shard_progress(), + * #TALER_EXCHANGEDB_abort_shard() and, for its `work_shards` half, + * #TALER_EXCHANGEDB_delete_shard_locks(). + * + * `work_shards` has no foreign keys. Every check uses a job name of its + * own so that the shard numbering of one check cannot confuse the next; the + * delete-locks check runs last because it empties the table. + */ +#include "test_common.h" +#include "exchange-database/abort_shard.h" +#include "exchange-database/begin_shard.h" +#include "exchange-database/delete_shard_locks.h" +#include "exchange-database/update_shard_progress.h" + + +/** + * Lease we ask for; long enough that no check can lose its shard while it + * is running. + */ +#define LEASE GNUNET_TIME_UNIT_HOURS + + +/** + * Nothing to abort and no progress to record while the table is empty. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_abort_shard (pg, + "ws-nothing", + 0, + 10)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_update_shard_progress (pg, + "ws-nothing", + 0, + 10, + 5, + LEASE)); + FAILIF (0 != TDB_count (pg, + "FROM work_shards")); + return 0; +} + + +/** + * The first shard of a job starts at 0, and a job whose only shard is + * leased gets the next range rather than the leased one. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_begin (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + uint64_t start; + uint64_t end; + uint64_t progress; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_begin_shard (pg, + "ws-begin", + LEASE, + 100, + &start, + &end, + &progress)); + FAILIF (0 != start); + FAILIF (100 != end); + FAILIF (0 != progress); + + /* the shard we just took is leased, so this must open the next one */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_begin_shard (pg, + "ws-begin", + LEASE, + 100, + &start, + &end, + &progress)); + FAILIF (100 != start); + FAILIF (200 != end); + FAILIF (100 != progress); + FAILIF (2 != TDB_count (pg, + "FROM work_shards" + " WHERE job_name='ws-begin'")); + /* a different job has its own numbering */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_begin_shard (pg, + "ws-begin-other", + LEASE, + 7, + &start, + &end, + &progress)); + FAILIF (0 != start); + FAILIF (7 != end); + return 0; +} + + +/** + * Progress is recorded, never rewound, and completes the shard exactly + * when it reaches the end of the range. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_progress (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + uint64_t start; + uint64_t end; + uint64_t progress; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_begin_shard (pg, + "ws-progress", + LEASE, + 100, + &start, + &end, + &progress)); + FAILIF (0 != start); + + /* partial progress leaves the shard open */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_shard_progress (pg, + "ws-progress", + 0, + 100, + 50, + LEASE)); + FAILIF (1 != TDB_count (pg, + "FROM work_shards" + " WHERE job_name='ws-progress'" + " AND progress_row=50" + " AND NOT completed")); + + /* ...and cannot be pushed back by a straggler */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_shard_progress (pg, + "ws-progress", + 0, + 100, + 30, + LEASE)); + FAILIF (1 != TDB_count (pg, + "FROM work_shards" + " WHERE job_name='ws-progress'" + " AND progress_row=50")); + + /* reaching the end completes it */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_shard_progress (pg, + "ws-progress", + 0, + 100, + 100, + LEASE)); + FAILIF (1 != TDB_count (pg, + "FROM work_shards" + " WHERE job_name='ws-progress'" + " AND completed")); + + /* a shard range that does not exist is not silently created */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_update_shard_progress (pg, + "ws-progress", + 1000, + 1100, + 1050, + LEASE)); + /* neither is one belonging to another job */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_update_shard_progress (pg, + "ws-progress-elsewhere", + 0, + 100, + 100, + LEASE)); + FAILIF (1 != TDB_count (pg, + "FROM work_shards" + " WHERE job_name='ws-progress'")); + return 0; +} + + +/** + * An aborted shard is handed out again, together with the progress the + * previous worker had committed. A completed shard is not. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_abort_and_takeover (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + uint64_t start; + uint64_t end; + uint64_t progress; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_begin_shard (pg, + "ws-abort", + LEASE, + 10, + &start, + &end, + &progress)); + FAILIF (0 != start); + FAILIF (10 != end); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_shard_progress (pg, + "ws-abort", + 0, + 10, + 4, + LEASE)); + + /* aborting a range that does not exist changes nothing */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_abort_shard (pg, + "ws-abort", + 0, + 11)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_EXCHANGEDB_abort_shard (pg, + "ws-abort-elsewhere", + 0, + 10)); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_abort_shard (pg, + "ws-abort", + 0, + 10)); + /* the abandoned shard comes back, resuming where it was left off */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_begin_shard (pg, + "ws-abort", + LEASE, + 10, + &start, + &end, + &progress)); + FAILIF (0 != start); + FAILIF (10 != end); + FAILIF (4 != progress); + FAILIF (1 != TDB_count (pg, + "FROM work_shards" + " WHERE job_name='ws-abort'")); + + /* once completed, aborting it still works but it is never handed out */ + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_update_shard_progress (pg, + "ws-abort", + 0, + 10, + 10, + LEASE)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_abort_shard (pg, + "ws-abort", + 0, + 10)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_begin_shard (pg, + "ws-abort", + LEASE, + 10, + &start, + &end, + &progress)); + FAILIF (10 != start); + FAILIF (20 != end); + return 0; +} + + +/** + * Deleting the shard locks empties the table and restarts the numbering. + * Runs last, as it discards what the other checks built. + * + * @param pg the database context + * @return 0 on success + */ +static int +check_delete_locks (struct TALER_EXCHANGEDB_PostgresContext *pg) +{ + uint64_t start; + uint64_t end; + uint64_t progress; + + FAILIF (0 == TDB_count (pg, + "FROM work_shards")); + FAILIF (GNUNET_OK != + TALER_EXCHANGEDB_delete_shard_locks (pg)); + FAILIF (0 != TDB_count (pg, + "FROM work_shards")); + /* ...and on an already empty table it is a no-op, not an error */ + FAILIF (GNUNET_OK != + TALER_EXCHANGEDB_delete_shard_locks (pg)); + + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_EXCHANGEDB_begin_shard (pg, + "ws-begin", + LEASE, + 100, + &start, + &end, + &progress)); + FAILIF (0 != start); + return 0; +} + + +/** + * The checks to run, in order. + */ +static const struct TDB_Test tests[] = { + { "work-shards-empty", + &check_empty }, + { "work-shards-begin", + &check_begin }, + { "work-shards-progress", + &check_progress }, + { "work-shards-abort-and-takeover", + &check_abort_and_takeover }, + { "work-shards-delete-locks", + &check_delete_locks }, + { NULL, NULL } +}; + + +int +main (int argc, + char *const *argv) +{ + return TDB_main (argc, + argv, + "test-work-shards", + "Tests for the exchangedb `work_shards' table", + tests); +} + + +/* end of test_work_shards.c */