exchange

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

test_refunds.c (20342B)


      1 /*
      2   This file is part of TALER
      3   Copyright (C) 2026 Taler Systems SA
      4 
      5   TALER is free software; you can redistribute it and/or modify it under the
      6   terms of the GNU General Public License as published by the Free Software
      7   Foundation; either version 3, or (at your option) any later version.
      8 
      9   TALER is distributed in the hope that it will be useful, but WITHOUT ANY
     10   WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11   A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
     12 
     13   You should have received a copy of the GNU General Public License along with
     14   TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15 */
     16 /**
     17  * @file exchangedb/test_refunds.c
     18  * @brief tests for the exchangedb functions whose primary table is
     19  *        `refunds`
     20  * @author Christian Grothoff
     21  *
     22  * Covers #TALER_EXCHANGEDB_do_refund(), #TALER_EXCHANGEDB_insert_refund(),
     23  * #TALER_EXCHANGEDB_iterate_refunds_above_serial_id() and
     24  * #TALER_EXCHANGEDB_iterate_refunds_by_coin().
     25  *
     26  * `refunds` references `coin_deposits`, so every check deposits a coin
     27  * first with TDB_deposit().  do_refund() has three ways of declining:
     28  * no such deposit, the merchant was already paid, and a reused refund
     29  * transaction id.
     30  */
     31 #include "test_common.h"
     32 #include "exchange-database/do_refund.h"
     33 #include "exchange-database/insert_refund.h"
     34 #include "exchange-database/iterate_refunds_above_serial_id.h"
     35 #include "exchange-database/iterate_refunds_by_coin.h"
     36 #include "exchange-database/rollback.h"
     37 #include "exchange-database/start.h"
     38 
     39 
     40 /**
     41  * Account the merchants of the checks are paid at.
     42  */
     43 static struct TDB_Account account;
     44 
     45 
     46 /**
     47  * Denomination the checks deposit.
     48  */
     49 static struct TDB_Denom denom;
     50 
     51 
     52 /**
     53  * Build a timestamp from a number of seconds since the epoch.
     54  *
     55  * @param secs seconds since the epoch
     56  * @return the timestamp
     57  */
     58 static struct GNUNET_TIME_Timestamp
     59 ts (uint64_t secs)
     60 {
     61   struct GNUNET_TIME_Absolute abs = {
     62     .abs_value_us = secs * 1000LLU * 1000LLU
     63   };
     64 
     65   return GNUNET_TIME_absolute_to_timestamp (abs);
     66 }
     67 
     68 
     69 /**
     70  * Closure for #refund_cb().
     71  */
     72 struct RefundContext
     73 {
     74   /**
     75    * How many rows did the callback see?
     76    */
     77   unsigned int total;
     78 
     79   /**
     80    * Stop after this many rows; 0 for no limit.
     81    */
     82   unsigned int stop_after;
     83 
     84   /**
     85    * Coin we are looking for, NULL to match nothing.
     86    */
     87   const struct TALER_CoinSpendPublicKeyP *coin_pub;
     88 
     89   /**
     90    * How many times did we see it?
     91    */
     92   unsigned int matched;
     93 
     94   /**
     95    * Amount reported for it.
     96    */
     97   struct TALER_Amount amount;
     98 
     99   /**
    100    * Refund transaction id reported for it.
    101    */
    102   uint64_t rtransaction_id;
    103 
    104   /**
    105    * Was the deposit fully refunded?
    106    */
    107   bool full_refund;
    108 };
    109 
    110 
    111 /**
    112  * Callback for #TALER_EXCHANGEDB_iterate_refunds_above_serial_id().
    113  *
    114  * @param cls a `struct RefundContext *`
    115  * @param rowid row of the refund
    116  * @param denom_pub denomination of the coin
    117  * @param coin_pub the refunded coin
    118  * @param merchant_pub merchant that refunded
    119  * @param merchant_sig signature over the refund
    120  * @param h_contract_terms contract the refund is for
    121  * @param rtransaction_id merchant's refund transaction id
    122  * @param full_refund whether the deposit is now fully refunded
    123  * @param amount_with_fee how much was refunded
    124  * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
    125  */
    126 static enum GNUNET_GenericReturnValue
    127 refund_cb (void *cls,
    128            uint64_t rowid,
    129            const struct TALER_DenominationPublicKey *denom_pub,
    130            const struct TALER_CoinSpendPublicKeyP *coin_pub,
    131            const struct TALER_MerchantPublicKeyP *merchant_pub,
    132            const struct TALER_MerchantSignatureP *merchant_sig,
    133            const struct TALER_PrivateContractHashP *h_contract_terms,
    134            uint64_t rtransaction_id,
    135            bool full_refund,
    136            const struct TALER_Amount *amount_with_fee)
    137 {
    138   struct RefundContext *ctx = cls;
    139 
    140   (void) rowid;
    141   (void) denom_pub;
    142   (void) merchant_pub;
    143   (void) merchant_sig;
    144   (void) h_contract_terms;
    145   ctx->total++;
    146   if ( (NULL != ctx->coin_pub) &&
    147        (0 == GNUNET_memcmp (coin_pub,
    148                             ctx->coin_pub)) )
    149   {
    150     ctx->matched++;
    151     ctx->amount = *amount_with_fee;
    152     ctx->rtransaction_id = rtransaction_id;
    153     ctx->full_refund = full_refund;
    154   }
    155   if ( (0 != ctx->stop_after) &&
    156        (ctx->total >= ctx->stop_after) )
    157     return GNUNET_SYSERR;
    158   return GNUNET_OK;
    159 }
    160 
    161 
    162 /**
    163  * Closure for #coin_refund_cb().
    164  */
    165 struct CoinRefundContext
    166 {
    167   /**
    168    * How many refunds did the callback see?
    169    */
    170   unsigned int total;
    171 
    172   /**
    173    * Sum of the whole-unit parts of the amounts seen.
    174    */
    175   uint64_t value_sum;
    176 };
    177 
    178 
    179 /**
    180  * Callback for #TALER_EXCHANGEDB_iterate_refunds_by_coin().
    181  *
    182  * @param cls a `struct CoinRefundContext *`
    183  * @param amount_with_fee how much was refunded
    184  * @return #GNUNET_OK
    185  */
    186 static enum GNUNET_GenericReturnValue
    187 coin_refund_cb (void *cls,
    188                 const struct TALER_Amount *amount_with_fee)
    189 {
    190   struct CoinRefundContext *ctx = cls;
    191 
    192   ctx->total++;
    193   ctx->value_sum += amount_with_fee->value;
    194   return GNUNET_OK;
    195 }
    196 
    197 
    198 /**
    199  * Outcome of a refund request.
    200  */
    201 struct RefundStatus
    202 {
    203   /**
    204    * Was there no such deposit?
    205    */
    206   bool not_found;
    207 
    208   /**
    209    * Was the refund accepted?
    210    */
    211   bool refund_ok;
    212 
    213   /**
    214    * Was the merchant already paid?
    215    */
    216   bool gone;
    217 
    218   /**
    219    * Was the refund transaction id reused?
    220    */
    221   bool conflict;
    222 };
    223 
    224 
    225 /**
    226  * Refund part of a deposit.
    227  *
    228  * @param pg the database context
    229  * @param coin coin to refund
    230  * @param dep deposit to refund
    231  * @param seed seed for the merchant signature
    232  * @param rtransaction_id merchant's refund transaction id
    233  * @param amount how much to refund, e.g. "0.5"
    234  * @param[out] st set to the outcome
    235  * @return transaction status
    236  */
    237 static enum GNUNET_DB_QueryStatus
    238 run_refund (struct TALER_EXCHANGEDB_PostgresContext *pg,
    239             const struct TALER_CoinPublicInfo *coin,
    240             const struct TDB_Deposit *dep,
    241             uint32_t seed,
    242             uint64_t rtransaction_id,
    243             const char *amount,
    244             struct RefundStatus *st)
    245 {
    246   struct TALER_EXCHANGEDB_Refund refund;
    247   struct TALER_Amount deposit_fee = TDB_amount ("0.1");
    248 
    249   memset (&refund,
    250           0,
    251           sizeof (refund));
    252   refund.coin = *coin;
    253   refund.details.merchant_pub = dep->merchant_pub;
    254   TDB_fill (&refund.details.merchant_sig,
    255             sizeof (refund.details.merchant_sig),
    256             seed);
    257   refund.details.h_contract_terms = dep->h_contract_terms;
    258   refund.details.rtransaction_id = rtransaction_id;
    259   refund.details.refund_amount = TDB_amount (amount);
    260   refund.details.refund_fee = TDB_amount ("0");
    261   memset (st,
    262           0,
    263           sizeof (*st));
    264   return TALER_EXCHANGEDB_do_refund (pg,
    265                                      &refund,
    266                                      &deposit_fee,
    267                                      0,
    268                                      &st->not_found,
    269                                      &st->refund_ok,
    270                                      &st->gone,
    271                                      &st->conflict);
    272 }
    273 
    274 
    275 /**
    276  * Nothing is reported while the table is empty, and a refund of a deposit
    277  * that does not exist is refused.
    278  *
    279  * @param pg the database context
    280  * @return 0 on success
    281  */
    282 static int
    283 check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
    284 {
    285   struct TALER_CoinPublicInfo coin;
    286   struct TDB_Deposit dep;
    287   struct RefundContext ctx = { 0 };
    288   struct CoinRefundContext cctx = { 0 };
    289   struct RefundStatus st;
    290 
    291   TDB_denom (pg,
    292              10,
    293              "5",
    294              "0.1",
    295              &denom);
    296   TDB_account (pg,
    297                10,
    298                &account);
    299   TDB_coin (pg,
    300             &denom,
    301             20,
    302             &coin,
    303             NULL);
    304   memset (&dep,
    305           0,
    306           sizeof (dep));
    307   TDB_FILL (dep.merchant_pub,
    308             98);
    309   TDB_FILL (dep.h_contract_terms,
    310             98);
    311   FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
    312             run_refund (pg,
    313                         &coin,
    314                         &dep,
    315                         98,
    316                         1,
    317                         "1",
    318                         &st),
    319             TDB_coin_free (&coin));
    320   TDB_coin_free (&coin);
    321   FAILIF (! st.not_found);
    322   FAILIF (st.refund_ok);
    323   FAILIF (0 != TDB_count (pg,
    324                           "FROM refunds"));
    325 
    326   FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
    327           TALER_EXCHANGEDB_iterate_refunds_above_serial_id (pg,
    328                                                             0,
    329                                                             &refund_cb,
    330                                                             &ctx));
    331   FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
    332           TALER_EXCHANGEDB_iterate_refunds_by_coin (pg,
    333                                                     &coin.coin_pub,
    334                                                     &dep.merchant_pub,
    335                                                     &dep.h_contract_terms,
    336                                                     &coin_refund_cb,
    337                                                     &cctx));
    338   FAILIF (0 != ctx.total);
    339   FAILIF (0 != cctx.total);
    340   return 0;
    341 }
    342 
    343 
    344 /**
    345  * A partial refund is recorded and reported.
    346  *
    347  * @param pg the database context
    348  * @return 0 on success
    349  */
    350 static int
    351 check_refund (struct TALER_EXCHANGEDB_PostgresContext *pg)
    352 {
    353   struct TALER_CoinPublicInfo coin;
    354   struct TDB_Deposit dep;
    355   struct RefundStatus st;
    356   struct RefundContext ctx;
    357   struct CoinRefundContext cctx;
    358   struct TALER_Amount expect = TDB_amount ("0.5");
    359 
    360   TDB_coin (pg,
    361             &denom,
    362             20,
    363             &coin,
    364             NULL);
    365   TDB_deposit (pg,
    366                &account,
    367                &coin,
    368                20,
    369                "1",
    370                "0.1",
    371                ts (1600000000),
    372                ts (1600000000),
    373                &dep);
    374   FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
    375             run_refund (pg,
    376                         &coin,
    377                         &dep,
    378                         20,
    379                         1,
    380                         "0.5",
    381                         &st),
    382             TDB_coin_free (&coin));
    383   FAILIF_C (st.not_found,
    384             TDB_coin_free (&coin));
    385   FAILIF_C (! st.refund_ok,
    386             TDB_coin_free (&coin));
    387   FAILIF_C (st.gone,
    388             TDB_coin_free (&coin));
    389   FAILIF_C (st.conflict,
    390             TDB_coin_free (&coin));
    391   FAILIF_C (1 != TDB_count (pg,
    392                             "FROM refunds"),
    393             TDB_coin_free (&coin));
    394 
    395   memset (&ctx,
    396           0,
    397           sizeof (ctx));
    398   ctx.coin_pub = &coin.coin_pub;
    399   FAILIF_C (1 !=
    400             TALER_EXCHANGEDB_iterate_refunds_above_serial_id (pg,
    401                                                               0,
    402                                                               &refund_cb,
    403                                                               &ctx),
    404             TDB_coin_free (&coin));
    405   FAILIF_C (1 != ctx.matched,
    406             TDB_coin_free (&coin));
    407   FAILIF_C (0 != TALER_amount_cmp (&ctx.amount,
    408                                    &expect),
    409             TDB_coin_free (&coin));
    410   FAILIF_C (1 != ctx.rtransaction_id,
    411             TDB_coin_free (&coin));
    412   /* only half of the deposit was refunded */
    413   FAILIF_C (ctx.full_refund,
    414             TDB_coin_free (&coin));
    415 
    416   memset (&cctx,
    417           0,
    418           sizeof (cctx));
    419   FAILIF_C (1 !=
    420             TALER_EXCHANGEDB_iterate_refunds_by_coin (pg,
    421                                                       &coin.coin_pub,
    422                                                       &dep.merchant_pub,
    423                                                       &dep.h_contract_terms,
    424                                                       &coin_refund_cb,
    425                                                       &cctx),
    426             TDB_coin_free (&coin));
    427   FAILIF_C (1 != cctx.total,
    428             TDB_coin_free (&coin));
    429 
    430   /* a contract nobody refunded has no refunds */
    431   {
    432     struct TALER_PrivateContractHashP other;
    433 
    434     TDB_FILL (other,
    435               97);
    436     memset (&cctx,
    437             0,
    438             sizeof (cctx));
    439     FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
    440               TALER_EXCHANGEDB_iterate_refunds_by_coin (pg,
    441                                                         &coin.coin_pub,
    442                                                         &dep.merchant_pub,
    443                                                         &other,
    444                                                         &coin_refund_cb,
    445                                                         &cctx),
    446               TDB_coin_free (&coin));
    447     FAILIF_C (0 != cctx.total,
    448               TDB_coin_free (&coin));
    449   }
    450 
    451   /* reusing the refund transaction id is a conflict */
    452   FAILIF_C (0 >
    453             run_refund (pg,
    454                         &coin,
    455                         &dep,
    456                         21,
    457                         1,
    458                         "0.25",
    459                         &st),
    460             TDB_coin_free (&coin));
    461   FAILIF_C (! st.conflict,
    462             TDB_coin_free (&coin));
    463   FAILIF_C (1 != TDB_count (pg,
    464                             "FROM refunds"),
    465             TDB_coin_free (&coin));
    466 
    467   /* a second refund with a fresh id completes the deposit */
    468   FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
    469             run_refund (pg,
    470                         &coin,
    471                         &dep,
    472                         22,
    473                         2,
    474                         "0.5",
    475                         &st),
    476             TDB_coin_free (&coin));
    477   FAILIF_C (! st.refund_ok,
    478             TDB_coin_free (&coin));
    479   memset (&cctx,
    480           0,
    481           sizeof (cctx));
    482   FAILIF_C (2 !=
    483             TALER_EXCHANGEDB_iterate_refunds_by_coin (pg,
    484                                                       &coin.coin_pub,
    485                                                       &dep.merchant_pub,
    486                                                       &dep.h_contract_terms,
    487                                                       &coin_refund_cb,
    488                                                       &cctx),
    489             TDB_coin_free (&coin));
    490   FAILIF_C (2 != cctx.total,
    491             TDB_coin_free (&coin));
    492   TDB_coin_free (&coin);
    493   return 0;
    494 }
    495 
    496 
    497 /**
    498  * Refunding a deposit the exchange has already paid out is refused.
    499  *
    500  * @param pg the database context
    501  * @return 0 on success
    502  */
    503 static int
    504 check_gone (struct TALER_EXCHANGEDB_PostgresContext *pg)
    505 {
    506   struct TALER_CoinPublicInfo coin;
    507   struct TDB_Deposit dep;
    508   struct RefundStatus st;
    509 
    510   TDB_coin (pg,
    511             &denom,
    512             23,
    513             &coin,
    514             NULL);
    515   TDB_deposit (pg,
    516                &account,
    517                &coin,
    518                23,
    519                "1",
    520                "0.1",
    521                ts (1600000000),
    522                ts (1600000000),
    523                &dep);
    524   FAILIF_C (GNUNET_OK !=
    525             TDB_exec (pg,
    526                       "UPDATE batch_deposits"
    527                       " SET done=TRUE"
    528                       " WHERE batch_deposit_serial_id=%llu;",
    529                       (unsigned long long) dep.serial),
    530             TDB_coin_free (&coin));
    531   /* The row goes in before the "already wired out" check, so this is how
    532      the /refund handler uses the function: inside a transaction it can
    533      roll back. */
    534   FAILIF_C (GNUNET_OK !=
    535             TALER_EXCHANGEDB_start (pg,
    536                                     "test-refund-gone"),
    537             TDB_coin_free (&coin));
    538   FAILIF_C (0 >
    539             run_refund (pg,
    540                         &coin,
    541                         &dep,
    542                         23,
    543                         1,
    544                         "0.5",
    545                         &st),
    546             TALER_EXCHANGEDB_rollback (pg); TDB_coin_free (&coin));
    547   TALER_EXCHANGEDB_rollback (pg);
    548   TDB_coin_free (&coin);
    549   FAILIF (! st.gone);
    550   FAILIF (st.refund_ok);
    551   FAILIF (2 != TDB_count (pg,
    552                           "FROM refunds"));
    553   return 0;
    554 }
    555 
    556 
    557 /**
    558  * insert_refund() writes the row without any of the checks do_refund()
    559  * makes, and finds the deposit through (coin, merchant, contract).
    560  *
    561  * @param pg the database context
    562  * @return 0 on success
    563  */
    564 static int
    565 check_insert_refund (struct TALER_EXCHANGEDB_PostgresContext *pg)
    566 {
    567   struct TALER_CoinPublicInfo coin;
    568   struct TDB_Deposit dep;
    569   struct TALER_EXCHANGEDB_Refund refund;
    570   struct CoinRefundContext cctx;
    571 
    572   TDB_coin (pg,
    573             &denom,
    574             24,
    575             &coin,
    576             NULL);
    577   TDB_deposit (pg,
    578                &account,
    579                &coin,
    580                24,
    581                "2",
    582                "0.1",
    583                ts (1600000000),
    584                ts (1600000000),
    585                &dep);
    586   memset (&refund,
    587           0,
    588           sizeof (refund));
    589   refund.coin = coin;
    590   refund.details.merchant_pub = dep.merchant_pub;
    591   TDB_FILL (refund.details.merchant_sig,
    592             24);
    593   refund.details.h_contract_terms = dep.h_contract_terms;
    594   refund.details.rtransaction_id = 7;
    595   refund.details.refund_amount = TDB_amount ("1");
    596   refund.details.refund_fee = TDB_amount ("0");
    597   FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
    598             TALER_EXCHANGEDB_insert_refund (pg,
    599                                             &refund),
    600             TDB_coin_free (&coin));
    601   memset (&cctx,
    602           0,
    603           sizeof (cctx));
    604   FAILIF_C (1 !=
    605             TALER_EXCHANGEDB_iterate_refunds_by_coin (pg,
    606                                                       &coin.coin_pub,
    607                                                       &dep.merchant_pub,
    608                                                       &dep.h_contract_terms,
    609                                                       &coin_refund_cb,
    610                                                       &cctx),
    611             TDB_coin_free (&coin));
    612   FAILIF_C (1 != cctx.value_sum,
    613             TDB_coin_free (&coin));
    614 
    615   /* a contract that has no deposit selects no row to insert against */
    616   TDB_FILL (refund.details.h_contract_terms,
    617             96);
    618   refund.details.rtransaction_id = 8;
    619   FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
    620             TALER_EXCHANGEDB_insert_refund (pg,
    621                                             &refund),
    622             TDB_coin_free (&coin));
    623   TDB_coin_free (&coin);
    624   FAILIF (3 != TDB_count (pg,
    625                           "FROM refunds"));
    626   return 0;
    627 }
    628 
    629 
    630 /**
    631  * The serial iterator's bound and abort return behave as documented.
    632  *
    633  * @param pg the database context
    634  * @return 0 on success
    635  */
    636 static int
    637 check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
    638 {
    639   struct RefundContext ctx;
    640 
    641   memset (&ctx,
    642           0,
    643           sizeof (ctx));
    644   FAILIF (3 !=
    645           TALER_EXCHANGEDB_iterate_refunds_above_serial_id (pg,
    646                                                             0,
    647                                                             &refund_cb,
    648                                                             &ctx));
    649   memset (&ctx,
    650           0,
    651           sizeof (ctx));
    652   FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
    653           TALER_EXCHANGEDB_iterate_refunds_above_serial_id (pg,
    654                                                             1000,
    655                                                             &refund_cb,
    656                                                             &ctx));
    657   FAILIF (0 != ctx.total);
    658   memset (&ctx,
    659           0,
    660           sizeof (ctx));
    661   ctx.stop_after = 1;
    662   FAILIF (3 !=
    663           TALER_EXCHANGEDB_iterate_refunds_above_serial_id (pg,
    664                                                             0,
    665                                                             &refund_cb,
    666                                                             &ctx));
    667   FAILIF (1 != ctx.total);
    668   return 0;
    669 }
    670 
    671 
    672 /**
    673  * The checks to run, in order.
    674  */
    675 static const struct TDB_Test tests[] = {
    676   { "refunds-empty",
    677     &check_empty },
    678   { "refunds-refund",
    679     &check_refund },
    680   { "refunds-gone",
    681     &check_gone },
    682   { "refunds-insert-refund",
    683     &check_insert_refund },
    684   { "refunds-iterate",
    685     &check_iterate },
    686   { NULL, NULL }
    687 };
    688 
    689 
    690 int
    691 main (int argc,
    692       char *const *argv)
    693 {
    694   int ret;
    695 
    696   ret = TDB_main (argc,
    697                   argv,
    698                   "test-refunds",
    699                   "Tests for the exchangedb `refunds' table",
    700                   tests);
    701   TDB_account_free (&account);
    702   TDB_denom_free (&denom);
    703   return ret;
    704 }
    705 
    706 
    707 /* end of test_refunds.c */