Line data Source code
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 6 : ts (uint64_t secs)
60 : {
61 6 : struct GNUNET_TIME_Absolute abs = {
62 6 : .abs_value_us = secs * 1000LLU * 1000LLU
63 : };
64 :
65 6 : 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 5 : 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 5 : 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 5 : ctx->total++;
146 5 : if ( (NULL != ctx->coin_pub) &&
147 1 : (0 == GNUNET_memcmp (coin_pub,
148 : ctx->coin_pub)) )
149 : {
150 1 : ctx->matched++;
151 1 : ctx->amount = *amount_with_fee;
152 1 : ctx->rtransaction_id = rtransaction_id;
153 1 : ctx->full_refund = full_refund;
154 : }
155 5 : if ( (0 != ctx->stop_after) &&
156 1 : (ctx->total >= ctx->stop_after) )
157 1 : return GNUNET_SYSERR;
158 4 : 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 4 : coin_refund_cb (void *cls,
188 : const struct TALER_Amount *amount_with_fee)
189 : {
190 4 : struct CoinRefundContext *ctx = cls;
191 :
192 4 : ctx->total++;
193 4 : ctx->value_sum += amount_with_fee->value;
194 4 : 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 5 : 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 5 : struct TALER_Amount deposit_fee = TDB_amount ("0.1");
248 :
249 5 : memset (&refund,
250 : 0,
251 : sizeof (refund));
252 5 : refund.coin = *coin;
253 5 : refund.details.merchant_pub = dep->merchant_pub;
254 5 : TDB_fill (&refund.details.merchant_sig,
255 : sizeof (refund.details.merchant_sig),
256 : seed);
257 5 : refund.details.h_contract_terms = dep->h_contract_terms;
258 5 : refund.details.rtransaction_id = rtransaction_id;
259 5 : refund.details.refund_amount = TDB_amount (amount);
260 5 : refund.details.refund_fee = TDB_amount ("0");
261 5 : memset (st,
262 : 0,
263 : sizeof (*st));
264 5 : 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 1 : check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
284 : {
285 : struct TALER_CoinPublicInfo coin;
286 : struct TDB_Deposit dep;
287 1 : struct RefundContext ctx = { 0 };
288 1 : struct CoinRefundContext cctx = { 0 };
289 : struct RefundStatus st;
290 :
291 1 : TDB_denom (pg,
292 : 10,
293 : "5",
294 : "0.1",
295 : &denom);
296 1 : TDB_account (pg,
297 : 10,
298 : &account);
299 1 : TDB_coin (pg,
300 : &denom,
301 : 20,
302 : &coin,
303 : NULL);
304 1 : memset (&dep,
305 : 0,
306 : sizeof (dep));
307 1 : TDB_FILL (dep.merchant_pub,
308 : 98);
309 1 : TDB_FILL (dep.h_contract_terms,
310 : 98);
311 1 : 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 1 : TDB_coin_free (&coin);
321 1 : FAILIF (! st.not_found);
322 1 : FAILIF (st.refund_ok);
323 1 : FAILIF (0 != TDB_count (pg,
324 : "FROM refunds"));
325 :
326 1 : FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
327 : TALER_EXCHANGEDB_iterate_refunds_above_serial_id (pg,
328 : 0,
329 : &refund_cb,
330 : &ctx));
331 1 : 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 1 : FAILIF (0 != ctx.total);
339 1 : FAILIF (0 != cctx.total);
340 1 : 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 1 : 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 1 : struct TALER_Amount expect = TDB_amount ("0.5");
359 :
360 1 : TDB_coin (pg,
361 : &denom,
362 : 20,
363 : &coin,
364 : NULL);
365 1 : TDB_deposit (pg,
366 : &account,
367 : &coin,
368 : 20,
369 : "1",
370 : "0.1",
371 : ts (1600000000),
372 : ts (1600000000),
373 : &dep);
374 1 : 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 1 : FAILIF_C (st.not_found,
384 : TDB_coin_free (&coin));
385 1 : FAILIF_C (! st.refund_ok,
386 : TDB_coin_free (&coin));
387 1 : FAILIF_C (st.gone,
388 : TDB_coin_free (&coin));
389 1 : FAILIF_C (st.conflict,
390 : TDB_coin_free (&coin));
391 1 : FAILIF_C (1 != TDB_count (pg,
392 : "FROM refunds"),
393 : TDB_coin_free (&coin));
394 :
395 1 : memset (&ctx,
396 : 0,
397 : sizeof (ctx));
398 1 : ctx.coin_pub = &coin.coin_pub;
399 1 : 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 1 : FAILIF_C (1 != ctx.matched,
406 : TDB_coin_free (&coin));
407 1 : FAILIF_C (0 != TALER_amount_cmp (&ctx.amount,
408 : &expect),
409 : TDB_coin_free (&coin));
410 1 : FAILIF_C (1 != ctx.rtransaction_id,
411 : TDB_coin_free (&coin));
412 : /* only half of the deposit was refunded */
413 1 : FAILIF_C (ctx.full_refund,
414 : TDB_coin_free (&coin));
415 :
416 1 : memset (&cctx,
417 : 0,
418 : sizeof (cctx));
419 1 : 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 1 : 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 1 : TDB_FILL (other,
435 : 97);
436 1 : memset (&cctx,
437 : 0,
438 : sizeof (cctx));
439 1 : 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 1 : FAILIF_C (0 != cctx.total,
448 : TDB_coin_free (&coin));
449 : }
450 :
451 : /* reusing the refund transaction id is a conflict */
452 1 : 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 1 : FAILIF_C (! st.conflict,
462 : TDB_coin_free (&coin));
463 1 : 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 1 : 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 1 : FAILIF_C (! st.refund_ok,
478 : TDB_coin_free (&coin));
479 1 : memset (&cctx,
480 : 0,
481 : sizeof (cctx));
482 1 : 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 1 : FAILIF_C (2 != cctx.total,
491 : TDB_coin_free (&coin));
492 1 : TDB_coin_free (&coin);
493 1 : 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 1 : check_gone (struct TALER_EXCHANGEDB_PostgresContext *pg)
505 : {
506 : struct TALER_CoinPublicInfo coin;
507 : struct TDB_Deposit dep;
508 : struct RefundStatus st;
509 :
510 1 : TDB_coin (pg,
511 : &denom,
512 : 23,
513 : &coin,
514 : NULL);
515 1 : TDB_deposit (pg,
516 : &account,
517 : &coin,
518 : 23,
519 : "1",
520 : "0.1",
521 : ts (1600000000),
522 : ts (1600000000),
523 : &dep);
524 1 : 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 1 : FAILIF_C (GNUNET_OK !=
535 : TALER_EXCHANGEDB_start (pg,
536 : "test-refund-gone"),
537 : TDB_coin_free (&coin));
538 1 : 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 1 : TALER_EXCHANGEDB_rollback (pg);
548 1 : TDB_coin_free (&coin);
549 1 : FAILIF (! st.gone);
550 1 : FAILIF (st.refund_ok);
551 1 : FAILIF (2 != TDB_count (pg,
552 : "FROM refunds"));
553 1 : 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 1 : 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 1 : TDB_coin (pg,
573 : &denom,
574 : 24,
575 : &coin,
576 : NULL);
577 1 : TDB_deposit (pg,
578 : &account,
579 : &coin,
580 : 24,
581 : "2",
582 : "0.1",
583 : ts (1600000000),
584 : ts (1600000000),
585 : &dep);
586 1 : memset (&refund,
587 : 0,
588 : sizeof (refund));
589 1 : refund.coin = coin;
590 1 : refund.details.merchant_pub = dep.merchant_pub;
591 1 : TDB_FILL (refund.details.merchant_sig,
592 : 24);
593 1 : refund.details.h_contract_terms = dep.h_contract_terms;
594 1 : refund.details.rtransaction_id = 7;
595 1 : refund.details.refund_amount = TDB_amount ("1");
596 1 : refund.details.refund_fee = TDB_amount ("0");
597 1 : FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
598 : TALER_EXCHANGEDB_insert_refund (pg,
599 : &refund),
600 : TDB_coin_free (&coin));
601 1 : memset (&cctx,
602 : 0,
603 : sizeof (cctx));
604 1 : 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 1 : 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 1 : TDB_FILL (refund.details.h_contract_terms,
617 : 96);
618 1 : refund.details.rtransaction_id = 8;
619 1 : FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
620 : TALER_EXCHANGEDB_insert_refund (pg,
621 : &refund),
622 : TDB_coin_free (&coin));
623 1 : TDB_coin_free (&coin);
624 1 : FAILIF (3 != TDB_count (pg,
625 : "FROM refunds"));
626 1 : 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 1 : check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
638 : {
639 : struct RefundContext ctx;
640 :
641 1 : memset (&ctx,
642 : 0,
643 : sizeof (ctx));
644 1 : FAILIF (3 !=
645 : TALER_EXCHANGEDB_iterate_refunds_above_serial_id (pg,
646 : 0,
647 : &refund_cb,
648 : &ctx));
649 1 : memset (&ctx,
650 : 0,
651 : sizeof (ctx));
652 1 : FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
653 : TALER_EXCHANGEDB_iterate_refunds_above_serial_id (pg,
654 : 1000,
655 : &refund_cb,
656 : &ctx));
657 1 : FAILIF (0 != ctx.total);
658 1 : memset (&ctx,
659 : 0,
660 : sizeof (ctx));
661 1 : ctx.stop_after = 1;
662 1 : FAILIF (3 !=
663 : TALER_EXCHANGEDB_iterate_refunds_above_serial_id (pg,
664 : 0,
665 : &refund_cb,
666 : &ctx));
667 1 : FAILIF (1 != ctx.total);
668 1 : 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 1 : main (int argc,
692 : char *const *argv)
693 : {
694 : int ret;
695 :
696 1 : ret = TDB_main (argc,
697 : argv,
698 : "test-refunds",
699 : "Tests for the exchangedb `refunds' table",
700 : tests);
701 1 : TDB_account_free (&account);
702 1 : TDB_denom_free (&denom);
703 1 : return ret;
704 : }
705 :
706 :
707 : /* end of test_refunds.c */
|