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 */