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_reserves.c
18 : * @brief tests for the exchangedb functions whose primary table is
19 : * `reserves`
20 : * @author Christian Grothoff
21 : *
22 : * Covers #TALER_EXCHANGEDB_get_reserve(),
23 : * #TALER_EXCHANGEDB_update_reserve(),
24 : * #TALER_EXCHANGEDB_get_reserve_balance(),
25 : * #TALER_EXCHANGEDB_get_reserve_close_info(),
26 : * #TALER_EXCHANGEDB_iterate_expired_reserves() and
27 : * #TALER_EXCHANGEDB_gc().
28 : *
29 : * A reserve may or may not have a `reserves_in` row: one funded by a bank
30 : * transfer has one, one created by a peer-to-peer merge does not. Several
31 : * of these functions LEFT JOIN through it to get the originating account,
32 : * so both shapes are exercised. The garbage collection check runs last,
33 : * as it removes rows the earlier checks created.
34 : */
35 : #include "test_common.h"
36 : #include "exchange-database/gc.h"
37 : #include "exchange-database/get_reserve.h"
38 : #include "exchange-database/get_reserve_balance.h"
39 : #include "exchange-database/get_reserve_close_info.h"
40 : #include "exchange-database/iterate_expired_reserves.h"
41 : #include "exchange-database/update_reserve.h"
42 :
43 :
44 : /**
45 : * One second in microseconds. Timestamps read back through
46 : * GNUNET_PQ_result_spec_timestamp() have to be whole seconds, so the
47 : * checks never write a raw microsecond value that is not a multiple of
48 : * this.
49 : */
50 : #define SECOND 1000000LLU
51 :
52 :
53 : /**
54 : * Create a reserve with an explicit expiration and garbage collection
55 : * date, which TDB_reserve() does not offer.
56 : *
57 : * @param pg the database context
58 : * @param seed seed for the reserve's public key
59 : * @param balance balance of the reserve, e.g. "10"
60 : * @param expiration_us expiration date, in microseconds since the epoch
61 : * @param gc_us garbage collection date, in microseconds since the epoch
62 : * @param[out] reserve_pub set to the public key of the reserve
63 : */
64 : static void
65 5 : make_reserve (struct TALER_EXCHANGEDB_PostgresContext *pg,
66 : uint32_t seed,
67 : const char *balance,
68 : uint64_t expiration_us,
69 : uint64_t gc_us,
70 : struct TALER_ReservePublicKeyP *reserve_pub)
71 : {
72 5 : struct TALER_Amount b = TDB_amount (balance);
73 : char *hex;
74 :
75 5 : TDB_fill (reserve_pub,
76 : sizeof (*reserve_pub),
77 : seed);
78 5 : hex = TDB_hex (reserve_pub,
79 : sizeof (*reserve_pub));
80 5 : GNUNET_assert (GNUNET_OK ==
81 : TDB_exec (pg,
82 : "INSERT INTO reserves"
83 : " (reserve_pub,current_balance"
84 : " ,expiration_date,gc_date)"
85 : " VALUES (decode('%s','hex')"
86 : " ,ROW(%llu,%u)::taler_amount"
87 : " ,%llu,%llu)"
88 : " ON CONFLICT DO NOTHING;",
89 : hex,
90 : (unsigned long long) b.value,
91 : (unsigned int) b.fraction,
92 : (unsigned long long) expiration_us,
93 : (unsigned long long) gc_us));
94 5 : GNUNET_free (hex);
95 5 : }
96 :
97 :
98 : /**
99 : * Closure for #expired_cb().
100 : */
101 : struct ExpiredContext
102 : {
103 : /**
104 : * How many reserves did the callback see?
105 : */
106 : unsigned int total;
107 :
108 : /**
109 : * Public key of the last reserve seen.
110 : */
111 : struct TALER_ReservePublicKeyP reserve_pub;
112 :
113 : /**
114 : * Balance of the last reserve seen.
115 : */
116 : struct TALER_Amount left;
117 :
118 : /**
119 : * Set if the last reserve was reported with an originating account.
120 : */
121 : bool have_account;
122 :
123 : /**
124 : * Close request row of the last reserve seen.
125 : */
126 : uint64_t close_request_row;
127 : };
128 :
129 :
130 : /**
131 : * Callback for #TALER_EXCHANGEDB_iterate_expired_reserves().
132 : *
133 : * @param cls a `struct ExpiredContext *`
134 : * @param reserve_pub the expired reserve
135 : * @param left balance left in the reserve
136 : * @param account_details originating account
137 : * @param expiration_date when the reserve expired
138 : * @param close_request_row row of the close request, 0 if none
139 : * @return #GNUNET_OK
140 : */
141 : static enum GNUNET_GenericReturnValue
142 2 : expired_cb (void *cls,
143 : const struct TALER_ReservePublicKeyP *reserve_pub,
144 : const struct TALER_Amount *left,
145 : const struct TALER_FullPayto account_details,
146 : struct GNUNET_TIME_Timestamp expiration_date,
147 : uint64_t close_request_row)
148 : {
149 2 : struct ExpiredContext *ctx = cls;
150 :
151 : (void) expiration_date;
152 2 : ctx->total++;
153 2 : ctx->reserve_pub = *reserve_pub;
154 2 : ctx->left = *left;
155 2 : ctx->have_account = (NULL != account_details.full_payto);
156 2 : ctx->close_request_row = close_request_row;
157 2 : return GNUNET_OK;
158 : }
159 :
160 :
161 : /**
162 : * Nothing is known about a reserve that was never created.
163 : *
164 : * @param pg the database context
165 : * @return 0 on success
166 : */
167 : static int
168 1 : check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
169 : {
170 : struct TALER_EXCHANGEDB_Reserve reserve;
171 : struct TALER_Amount balance;
172 1 : struct TALER_FullPayto payto = { NULL };
173 1 : struct ExpiredContext ctx = { 0 };
174 :
175 1 : memset (&reserve,
176 : 0,
177 : sizeof (reserve));
178 1 : TDB_FILL (reserve.pub,
179 : 1);
180 1 : reserve.balance = TDB_amount ("1");
181 1 : FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
182 : TALER_EXCHANGEDB_get_reserve (pg,
183 : &reserve));
184 1 : FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
185 : TALER_EXCHANGEDB_update_reserve (pg,
186 : &reserve));
187 1 : FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
188 : TALER_EXCHANGEDB_get_reserve_balance (pg,
189 : &reserve.pub,
190 : &balance,
191 : &payto));
192 1 : FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
193 : TALER_EXCHANGEDB_get_reserve_close_info (pg,
194 : &reserve.pub,
195 : &balance,
196 : &payto));
197 1 : FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
198 : TALER_EXCHANGEDB_iterate_expired_reserves (pg,
199 : GNUNET_TIME_timestamp_get (
200 : ),
201 : &expired_cb,
202 : &ctx));
203 1 : FAILIF (0 != ctx.total);
204 1 : FAILIF (0 != TDB_count (pg,
205 : "FROM reserves"));
206 1 : return 0;
207 : }
208 :
209 :
210 : /**
211 : * A reserve without a `reserves_in` row is found, with no origin account.
212 : *
213 : * @param pg the database context
214 : * @return 0 on success
215 : */
216 : static int
217 1 : check_without_origin (struct TALER_EXCHANGEDB_PostgresContext *pg)
218 : {
219 : struct TALER_EXCHANGEDB_Reserve reserve;
220 : struct TALER_Amount balance;
221 1 : struct TALER_Amount expect = TDB_amount ("10");
222 1 : struct TALER_FullPayto payto = {
223 : .full_payto = (char *) "sentinel"
224 : };
225 :
226 1 : memset (&reserve,
227 : 0,
228 : sizeof (reserve));
229 1 : TDB_reserve (pg,
230 : 2,
231 : "10",
232 : &reserve.pub);
233 1 : FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
234 : TALER_EXCHANGEDB_get_reserve (pg,
235 : &reserve));
236 1 : FAILIF (0 != TALER_amount_cmp (&reserve.balance,
237 : &expect));
238 1 : FAILIF (GNUNET_TIME_absolute_is_zero (reserve.expiry.abs_time));
239 1 : FAILIF (GNUNET_TIME_absolute_is_zero (reserve.gc.abs_time));
240 :
241 1 : FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
242 : TALER_EXCHANGEDB_get_reserve_balance (pg,
243 : &reserve.pub,
244 : &balance,
245 : &payto));
246 1 : FAILIF (0 != TALER_amount_cmp (&balance,
247 : &expect));
248 1 : FAILIF (NULL != payto.full_payto);
249 :
250 1 : payto.full_payto = (char *) "sentinel";
251 1 : FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
252 : TALER_EXCHANGEDB_get_reserve_close_info (pg,
253 : &reserve.pub,
254 : &balance,
255 : &payto));
256 1 : FAILIF (0 != TALER_amount_cmp (&balance,
257 : &expect));
258 1 : FAILIF (NULL != payto.full_payto);
259 1 : return 0;
260 : }
261 :
262 :
263 : /**
264 : * A reserve funded by a bank transfer reports its origin account.
265 : *
266 : * @param pg the database context
267 : * @return 0 on success
268 : */
269 : static int
270 1 : check_with_origin (struct TALER_EXCHANGEDB_PostgresContext *pg)
271 : {
272 : struct TDB_Account account;
273 : struct TALER_ReservePublicKeyP reserve_pub;
274 : struct TALER_Amount balance;
275 1 : struct TALER_Amount expect = TDB_amount ("7");
276 1 : struct TALER_FullPayto payto = { NULL };
277 :
278 1 : TDB_account (pg,
279 : 3,
280 : &account);
281 1 : TDB_reserve_in (pg,
282 : &account,
283 : 3,
284 : "7",
285 : &reserve_pub);
286 1 : FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
287 : TALER_EXCHANGEDB_get_reserve_balance (pg,
288 : &reserve_pub,
289 : &balance,
290 : &payto),
291 : TDB_account_free (&account));
292 1 : FAILIF_C (0 != TALER_amount_cmp (&balance,
293 : &expect),
294 : TDB_account_free (&account));
295 1 : FAILIF_C (NULL == payto.full_payto,
296 : TDB_account_free (&account));
297 1 : FAILIF_C (0 != strcmp (payto.full_payto,
298 : account.payto.full_payto),
299 : GNUNET_free (payto.full_payto); TDB_account_free (&account));
300 1 : GNUNET_free (payto.full_payto);
301 :
302 1 : payto.full_payto = NULL;
303 1 : FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
304 : TALER_EXCHANGEDB_get_reserve_close_info (pg,
305 : &reserve_pub,
306 : &balance,
307 : &payto),
308 : TDB_account_free (&account));
309 1 : FAILIF_C (NULL == payto.full_payto,
310 : TDB_account_free (&account));
311 1 : FAILIF_C (0 != strcmp (payto.full_payto,
312 : account.payto.full_payto),
313 : GNUNET_free (payto.full_payto); TDB_account_free (&account));
314 1 : GNUNET_free (payto.full_payto);
315 1 : TDB_account_free (&account);
316 1 : return 0;
317 : }
318 :
319 :
320 : /**
321 : * Updating a reserve writes back the balance and both dates.
322 : *
323 : * @param pg the database context
324 : * @return 0 on success
325 : */
326 : static int
327 1 : check_update (struct TALER_EXCHANGEDB_PostgresContext *pg)
328 : {
329 : struct TALER_EXCHANGEDB_Reserve reserve;
330 : struct TALER_EXCHANGEDB_Reserve got;
331 1 : struct GNUNET_TIME_Absolute now = GNUNET_TIME_absolute_get ();
332 :
333 1 : memset (&reserve,
334 : 0,
335 : sizeof (reserve));
336 1 : TDB_FILL (reserve.pub,
337 : 2);
338 1 : reserve.balance = TDB_amount ("3.5");
339 1 : reserve.expiry = GNUNET_TIME_absolute_to_timestamp (now);
340 1 : reserve.gc = GNUNET_TIME_absolute_to_timestamp (
341 : GNUNET_TIME_absolute_add (now,
342 : GNUNET_TIME_UNIT_HOURS));
343 1 : FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
344 : TALER_EXCHANGEDB_update_reserve (pg,
345 : &reserve));
346 1 : memset (&got,
347 : 0,
348 : sizeof (got));
349 1 : got.pub = reserve.pub;
350 1 : FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
351 : TALER_EXCHANGEDB_get_reserve (pg,
352 : &got));
353 1 : FAILIF (0 != TALER_amount_cmp (&got.balance,
354 : &reserve.balance));
355 1 : FAILIF (GNUNET_TIME_timestamp_cmp (got.expiry,
356 : !=,
357 : reserve.expiry));
358 1 : FAILIF (GNUNET_TIME_timestamp_cmp (got.gc,
359 : !=,
360 : reserve.gc));
361 1 : return 0;
362 : }
363 :
364 :
365 : /**
366 : * Only an expired reserve that still holds money and has an originating
367 : * account is reported for closing.
368 : *
369 : * @param pg the database context
370 : * @return 0 on success
371 : */
372 : static int
373 1 : check_expired (struct TALER_EXCHANGEDB_PostgresContext *pg)
374 : {
375 : struct TDB_Account account;
376 : struct TALER_ReservePublicKeyP funded;
377 : struct TALER_ReservePublicKeyP drained;
378 : struct ExpiredContext ctx;
379 1 : struct GNUNET_TIME_Timestamp now = GNUNET_TIME_timestamp_get ();
380 1 : struct TALER_Amount expect = TDB_amount ("7");
381 : char *hex;
382 :
383 : /* nothing has expired yet: the reserves so far all expire in 2096 */
384 1 : memset (&ctx,
385 : 0,
386 : sizeof (ctx));
387 1 : FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
388 : TALER_EXCHANGEDB_iterate_expired_reserves (pg,
389 : now,
390 : &expired_cb,
391 : &ctx));
392 :
393 : /* expire the funded reserve from check_with_origin() */
394 1 : TDB_account (pg,
395 : 3,
396 : &account);
397 1 : TDB_fill (&funded,
398 : sizeof (funded),
399 : 3);
400 1 : hex = TDB_hex (&funded,
401 : sizeof (funded));
402 1 : FAILIF_C (GNUNET_OK !=
403 : TDB_exec (pg,
404 : "UPDATE reserves"
405 : " SET expiration_date=1000000"
406 : " WHERE reserve_pub=decode('%s','hex');",
407 : hex),
408 : GNUNET_free (hex); TDB_account_free (&account));
409 1 : GNUNET_free (hex);
410 1 : memset (&ctx,
411 : 0,
412 : sizeof (ctx));
413 1 : FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
414 : TALER_EXCHANGEDB_iterate_expired_reserves (pg,
415 : now,
416 : &expired_cb,
417 : &ctx),
418 : TDB_account_free (&account));
419 1 : FAILIF_C (1 != ctx.total,
420 : TDB_account_free (&account));
421 1 : FAILIF_C (0 != GNUNET_memcmp (&ctx.reserve_pub,
422 : &funded),
423 : TDB_account_free (&account));
424 1 : FAILIF_C (0 != TALER_amount_cmp (&ctx.left,
425 : &expect),
426 : TDB_account_free (&account));
427 1 : FAILIF_C (! ctx.have_account,
428 : TDB_account_free (&account));
429 :
430 : /* an expired reserve with a zero balance is not worth closing */
431 1 : make_reserve (pg,
432 : 4,
433 : "0",
434 : SECOND,
435 : SECOND,
436 : &drained);
437 : {
438 1 : char *rhex = TDB_hex (&drained,
439 : sizeof (drained));
440 1 : char *ahex = TDB_hex (&account.h_full,
441 : sizeof (account.h_full));
442 :
443 1 : FAILIF_C (GNUNET_OK !=
444 : TDB_exec (pg,
445 : "INSERT INTO reserves_in"
446 : " (reserve_pub,wire_reference,credit"
447 : " ,wire_source_h_payto"
448 : " ,exchange_account_section,execution_date)"
449 : " VALUES (decode('%s','hex'),44"
450 : " ,ROW(0,0)::taler_amount"
451 : " ,decode('%s','hex'),'exchange-account-1'"
452 : " ,1000000);",
453 : rhex,
454 : ahex),
455 : GNUNET_free (rhex); GNUNET_free (ahex);
456 : TDB_account_free (&account));
457 1 : GNUNET_free (rhex);
458 1 : GNUNET_free (ahex);
459 : }
460 1 : memset (&ctx,
461 : 0,
462 : sizeof (ctx));
463 1 : FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
464 : TALER_EXCHANGEDB_iterate_expired_reserves (pg,
465 : now,
466 : &expired_cb,
467 : &ctx),
468 : TDB_account_free (&account));
469 : /* the query returns at most one reserve, and it is still the funded one */
470 1 : FAILIF_C (1 != ctx.total,
471 : TDB_account_free (&account));
472 1 : FAILIF_C (0 != GNUNET_memcmp (&ctx.reserve_pub,
473 : &funded),
474 : TDB_account_free (&account));
475 1 : TDB_account_free (&account);
476 :
477 : /* an expired reserve with money but no originating account is skipped:
478 : it was created by a merge, so there is nowhere to wire the money back
479 : to. Drain the funded one first so it cannot mask the result. */
480 1 : hex = TDB_hex (&funded,
481 : sizeof (funded));
482 1 : FAILIF_C (GNUNET_OK !=
483 : TDB_exec (pg,
484 : "UPDATE reserves"
485 : " SET current_balance=ROW(0,0)::taler_amount"
486 : " WHERE reserve_pub=decode('%s','hex');",
487 : hex),
488 : GNUNET_free (hex));
489 1 : GNUNET_free (hex);
490 : {
491 : struct TALER_ReservePublicKeyP merged;
492 :
493 1 : make_reserve (pg,
494 : 5,
495 : "4",
496 : SECOND,
497 : SECOND,
498 : &merged);
499 : }
500 1 : memset (&ctx,
501 : 0,
502 : sizeof (ctx));
503 1 : FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
504 : TALER_EXCHANGEDB_iterate_expired_reserves (pg,
505 : now,
506 : &expired_cb,
507 : &ctx));
508 1 : FAILIF (0 != ctx.total);
509 1 : return 0;
510 : }
511 :
512 :
513 : /**
514 : * Garbage collection removes drained, expired reserves and leaves the
515 : * others alone. Runs last.
516 : *
517 : * @param pg the database context
518 : * @return 0 on success
519 : */
520 : static int
521 1 : check_gc (struct TALER_EXCHANGEDB_PostgresContext *pg)
522 : {
523 : struct TALER_ReservePublicKeyP old_empty;
524 : struct TALER_ReservePublicKeyP old_funded;
525 : struct TALER_ReservePublicKeyP young_empty;
526 : char *hex;
527 : uint64_t before;
528 :
529 : /* gc_date in the past and no money left: collectable */
530 1 : make_reserve (pg,
531 : 10,
532 : "0",
533 : SECOND,
534 : SECOND,
535 : &old_empty);
536 : /* gc_date in the past but still holding money: kept */
537 1 : make_reserve (pg,
538 : 11,
539 : "2",
540 : SECOND,
541 : SECOND,
542 : &old_funded);
543 : /* no money left but gc_date far in the future: kept */
544 1 : make_reserve (pg,
545 : 12,
546 : "0",
547 : 4000000000000000LLU,
548 : 5000000000000000LLU,
549 : &young_empty);
550 1 : before = TDB_count (pg,
551 : "FROM reserves");
552 1 : FAILIF (GNUNET_OK !=
553 : TALER_EXCHANGEDB_gc (pg));
554 :
555 1 : hex = TDB_hex (&old_empty,
556 : sizeof (old_empty));
557 1 : FAILIF_C (0 != TDB_count (pg,
558 : "FROM reserves"
559 : " WHERE reserve_pub=decode('%s','hex')",
560 : hex),
561 : GNUNET_free (hex));
562 1 : GNUNET_free (hex);
563 1 : hex = TDB_hex (&old_funded,
564 : sizeof (old_funded));
565 1 : FAILIF_C (1 != TDB_count (pg,
566 : "FROM reserves"
567 : " WHERE reserve_pub=decode('%s','hex')",
568 : hex),
569 : GNUNET_free (hex));
570 1 : GNUNET_free (hex);
571 1 : hex = TDB_hex (&young_empty,
572 : sizeof (young_empty));
573 1 : FAILIF_C (1 != TDB_count (pg,
574 : "FROM reserves"
575 : " WHERE reserve_pub=decode('%s','hex')",
576 : hex),
577 : GNUNET_free (hex));
578 1 : GNUNET_free (hex);
579 1 : FAILIF (before <= TDB_count (pg,
580 : "FROM reserves"));
581 : /* running it again on the same database changes nothing further */
582 1 : before = TDB_count (pg,
583 : "FROM reserves");
584 1 : FAILIF (GNUNET_OK !=
585 : TALER_EXCHANGEDB_gc (pg));
586 1 : FAILIF (before != TDB_count (pg,
587 : "FROM reserves"));
588 1 : return 0;
589 : }
590 :
591 :
592 : /**
593 : * The checks to run, in order.
594 : */
595 : static const struct TDB_Test tests[] = {
596 : { "reserves-empty",
597 : &check_empty },
598 : { "reserves-without-origin",
599 : &check_without_origin },
600 : { "reserves-with-origin",
601 : &check_with_origin },
602 : { "reserves-update",
603 : &check_update },
604 : { "reserves-expired",
605 : &check_expired },
606 : { "reserves-gc",
607 : &check_gc },
608 : { NULL, NULL }
609 : };
610 :
611 :
612 : int
613 1 : main (int argc,
614 : char *const *argv)
615 : {
616 1 : return TDB_main (argc,
617 : argv,
618 : "test-reserves",
619 : "Tests for the exchangedb `reserves' table",
620 : tests);
621 : }
622 :
623 :
624 : /* end of test_reserves.c */
|