test_merchant_depositcheck_confirmation.sh (11354B)
1 #!/usr/bin/env bash 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 6 # it under the terms of the GNU General Public License as 7 # published by the Free Software Foundation; either version 3, or 8 # (at your option) any later version. 9 # 10 # TALER is distributed in the hope that it will be useful, but 11 # WITHOUT ANY WARRANTY; without even the implied warranty of 12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 # GNU General Public License for more details. 14 # 15 # You should have received a copy of the GNU General Public 16 # License along with TALER; see the file COPYING. If not, see 17 # <http://www.gnu.org/licenses/> 18 # 19 20 21 22 23 # Testcase for the depositcheck/reconciliation confirmation race: 24 # taler-merchant-depositcheck creates merchant_expected_transfers with 25 # expected_credit_amount = NULL because /deposits only knows the coin's 26 # contribution. taler-merchant-reconciliation later fills in the total 27 # amount via /transfers/$WTID. The confirmation linking must also happen 28 # on that UPDATE, not only on INSERT. 29 30 # FIXME: we need to make this tests simpler 31 # 32 # goto REPRODUCE THE RACE 33 # I was unable to test by endpoint because of src/backend/taler-merchant-httpd_get-private-transfers.c:84 is hardcoded with true 34 35 set -eu 36 37 . setup.sh 38 39 # Exit, with status code "skip" (no 'real' failure) 40 function exit_skip() { 41 echo "SKIPPING: $1" 42 exit 77 43 } 44 45 # Use fakebank (instant) for this test. 46 USE_FAKEBANK=1 47 ACCOUNT="exchange-account-2" 48 WIRE_METHOD="x-taler-bank" 49 BANK_FLAGS="-f -d $WIRE_METHOD -u $ACCOUNT" 50 BANK_URL="http://localhost:8082/" 51 52 53 echo -n "Testing for taler-harness" 54 taler-harness --help >/dev/null </dev/null || exit_skip " MISSING" 55 echo " FOUND" 56 57 # Launch exchange, merchant and bank. 58 setup -c "test_template.conf" \ 59 -em \ 60 -r "merchant-exchange-default" \ 61 $BANK_FLAGS 62 LAST_RESPONSE=$(mktemp -p "${TMPDIR:-/tmp}" test_response.conf-XXXXXX) 63 CONF="test_template.conf.edited" 64 WALLET_DB=$(mktemp -p "${TMPDIR:-/tmp}" test_wallet.json-XXXXXX) 65 EXCHANGE_URL="http://localhost:8081/" 66 67 FACADE_URL="http://localhost:8082/accounts/gnunet/taler-revenue/" 68 FACADE_USERNAME="gnunet" 69 FACADE_PASSWORD="password" 70 71 # Extract merchant DB connection for direct DB assertions. 72 echo -n "Checking for psql ..." 73 psql --version >/dev/null 2>&1 || exit_skip " no psql" 74 echo " OK" 75 76 DB_URI=$(sed -n '/^\[merchantdb-postgres\]/,/^\[/p' "$CONF" \ 77 | awk -F= '/^CONFIG[[:space:]]*=/ { gsub(/^[[:space:]]+|[[:space:]]+$/,"",$2); print $2; exit }') 78 if [ -z "$DB_URI" ] 79 then 80 exit_fail "Could not extract merchant DB URI from $CONF" 81 fi 82 83 # Helper to run a query against a merchant instance schema. 84 function merchant_psql() { 85 psql -At -q -d "$DB_URI" -v ON_ERROR_STOP=1 \ 86 -c "SET search_path TO ${MERCHANT_SCHEMA}; $1" 87 } 88 89 echo -n "First prepare wallet with coins..." 90 rm -f "${WALLET_DB}" 91 taler-wallet-cli \ 92 --no-throttle \ 93 --wallet-db="$WALLET_DB" \ 94 api \ 95 --expect-success 'withdrawTestBalance' \ 96 "$(jq -n ' 97 { 98 amount: "TESTKUDOS:99", 99 corebankApiBaseUrl: $BANK_URL, 100 exchangeBaseUrl: $EXCHANGE_URL 101 }' \ 102 --arg BANK_URL "${BANK_URL}" \ 103 --arg EXCHANGE_URL "$EXCHANGE_URL" 104 )" 2>wallet-withdraw-1.err >wallet-withdraw-1.out 105 echo -n "." 106 taler-exchange-wirewatch \ 107 -L "INFO" \ 108 -c "$CONF" \ 109 -t \ 110 &> taler-exchange-wirewatch.out 111 echo -n "." 112 timeout 60 taler-wallet-cli \ 113 --wallet-db="$WALLET_DB" \ 114 run-until-done \ 115 2>wallet-withdraw-finish-1.err \ 116 >wallet-withdraw-finish-1.out 117 echo " OK" 118 119 # 120 # CREATE INSTANCE FOR TESTING 121 # 122 123 echo -n "Configuring merchant admin instance ..." 124 GNUNET_PAYTO="payto://x-taler-bank/localhost/gnunet?receiver-name=gnunet" 125 STATUS=$(curl -H "Content-Type: application/json" -X POST \ 126 http://localhost:9966/management/instances \ 127 -d '{"auth":{"method":"token","password":"super_secret"},"id":"admin","name":"default","user_type":"business","address":{},"jurisdiction":{},"use_stefan":true,"default_wire_transfer_delay":{"d_us" : 50000000},"default_pay_delay":{"d_us": 60000000}}' \ 128 -w "%{http_code}" -s -o /dev/null) 129 130 if [ "$STATUS" != "204" ] 131 then 132 exit_fail "Expected 204 no content. Got: $STATUS" 133 fi 134 ADMIN_TOKEN=$(merchant_login admin super_secret) 135 echo "OK" 136 137 # Resolve the per-instance schema name for direct DB inspection. 138 MERCHANT_SERIAL=$(psql -At -q -d "$DB_URI" -v ON_ERROR_STOP=1 \ 139 -c "SELECT merchant_serial FROM merchant.merchant_instances WHERE merchant_id='admin';") 140 MERCHANT_SCHEMA="merchant_instance_${MERCHANT_SERIAL}" 141 142 echo -n "Configuring bank account..." 143 STATUS=$(curl -H "Content-Type: application/json" -X POST \ 144 -H "Authorization: Bearer $ADMIN_TOKEN" \ 145 http://localhost:9966/private/accounts \ 146 -d '{"payto_uri":"'"$GNUNET_PAYTO"'","credit_facade_url":"'"${FACADE_URL}"'","credit_facade_credentials":{"type":"basic","username":"'"$FACADE_USERNAME"'","password":"'"$FACADE_PASSWORD"'"}}' \ 147 -w "%{http_code}" -s -o /dev/null) 148 149 if [ "$STATUS" != "200" ] 150 then 151 exit_fail "Expected 200 OK. Got: $STATUS" 152 fi 153 echo "OK" 154 155 # CREATE ORDER AND SELL IT 156 echo -n "Creating order to be paid..." 157 STATUS=$(curl 'http://localhost:9966/private/orders' \ 158 -H "Authorization: Bearer $ADMIN_TOKEN" \ 159 -d '{"refund_delay":{"d_us":0},"order":{"amount":"TESTKUDOS:1","summary":"payme"}}' \ 160 -w "%{http_code}" \ 161 -s \ 162 -o "$LAST_RESPONSE") 163 164 if [ "$STATUS" != "200" ] 165 then 166 exit_fail "Expected 200 OK. Got: $STATUS " "$(cat "$LAST_RESPONSE")" 167 fi 168 169 ORDER_ID=$(jq -e -r .order_id < "$LAST_RESPONSE") 170 STATUS=$(curl "http://localhost:9966/private/orders/${ORDER_ID}" \ 171 -H "Authorization: Bearer $ADMIN_TOKEN" \ 172 -w "%{http_code}" \ 173 -s \ 174 -o "$LAST_RESPONSE") 175 176 if [ "$STATUS" != "200" ] 177 then 178 exit_fail "Expected 200 ok. Got: $STATUS" "$(cat "$LAST_RESPONSE")" 179 fi 180 PAY_URL=$(jq -e -r .taler_pay_uri < "$LAST_RESPONSE") 181 echo OK 182 183 NOW=$(date +%s) 184 echo -n "Pay order ..." 185 taler-wallet-cli \ 186 --no-throttle \ 187 --wallet-db="$WALLET_DB" \ 188 handle-uri "${PAY_URL}" \ 189 -y 2> wallet-pay1.err > wallet-pay1.log 190 NOW2=$(date +%s) 191 echo "OK. Took $(( NOW2 - NOW))s." 192 193 STATUS=$(curl "http://localhost:9966/private/orders/${ORDER_ID}" \ 194 -H "Authorization: Bearer $ADMIN_TOKEN" \ 195 -w "%{http_code}" \ 196 -s \ 197 -o "$LAST_RESPONSE") 198 199 if [ "$STATUS" != "200" ] 200 then 201 exit_fail "Expected 200 Ok. Got: $STATUS" "$(cat "$LAST_RESPONSE")" 202 fi 203 204 ORDER_STATUS=$(jq -r .order_status < "$LAST_RESPONSE") 205 if [ "$ORDER_STATUS" != "paid" ] 206 then 207 exit_fail "Expected order status 'paid'. Got: $ORDER_STATUS" "$(cat "$LAST_RESPONSE")" 208 fi 209 210 # 211 # WIRE TRANSFER TO MERCHANT 212 # 213 214 WIRE_DEADLINE=$(jq -r .contract_terms.wire_transfer_deadline.t_s < "$LAST_RESPONSE") 215 NOW=$(date +%s) 216 217 TO_SLEEP="$(( 3600 + WIRE_DEADLINE - NOW ))" 218 echo -n "Perform wire transfers (with ${TO_SLEEP}s timeshift) ..." 219 taler-exchange-aggregator \ 220 -y \ 221 -c "$CONF" \ 222 -T "${TO_SLEEP}000000" \ 223 -t \ 224 -L INFO &> aggregator.log 225 taler-exchange-transfer \ 226 -c "$CONF" \ 227 -t \ 228 -L INFO &> transfer.log 229 echo " DONE" 230 231 # 232 # REPRODUCE THE RACE: import the bank transfer first, then run depositcheck, 233 # then reconciliation. With the bug, the transfer stays unconfirmed because 234 # the expected_credit_amount only becomes known on UPDATE. 235 # 236 237 echo -n "Running taler-merchant-wirewatch to import transfer ..." 238 taler-merchant-wirewatch \ 239 -c "$CONF" \ 240 -t \ 241 -L INFO &> merchant-wirewatch.log 242 echo " OK" 243 244 # DB sanity check: we have the bank-side row but no expected transfer yet. 245 echo -n "Checking DB state after wirewatch ..." 246 DB_STATE=$(merchant_psql "SELECT (SELECT COUNT(*) FROM merchant_transfers), (SELECT COUNT(*) FROM merchant_expected_transfers);") 247 IFS='|' read -r TRANSFER_COUNT EXPECTED_COUNT <<< "$DB_STATE" 248 if [ "$TRANSFER_COUNT" != "1" ] 249 then 250 exit_fail "Expected 1 merchant_transfers row, got $TRANSFER_COUNT" 251 fi 252 if [ "$EXPECTED_COUNT" != "0" ] 253 then 254 exit_fail "Expected 0 merchant_expected_transfers rows before depositcheck, got $EXPECTED_COUNT" 255 fi 256 echo " OK" 257 258 echo -n "Running taler-merchant-depositcheck to create expected transfer ..." 259 taler-merchant-depositcheck \ 260 -c "$CONF" \ 261 -e "http://localhost:8081/" \ 262 -T "${TO_SLEEP}000000" \ 263 -t \ 264 -L INFO &> merchant-depositcheck.log 265 echo " OK" 266 267 # DB sanity check: depositcheck created an unconfirmed expected transfer 268 # without knowing the final credit amount. 269 echo -n "Checking DB state after depositcheck ..." 270 DB_STATE=$(merchant_psql "SELECT expected_credit_amount IS NULL, confirmed, retry_needed FROM merchant_expected_transfers;") 271 IFS='|' read -r AMOUNT_NULL CONFIRMED RETRY <<< "$DB_STATE" 272 if [ "$AMOUNT_NULL" != "t" ] 273 then 274 exit_fail "Expected expected_credit_amount to be NULL after depositcheck" 275 fi 276 if [ "$CONFIRMED" != "f" ] 277 then 278 exit_fail "Expected confirmed=false after depositcheck, got $CONFIRMED" 279 fi 280 if [ "$RETRY" != "t" ] 281 then 282 exit_fail "Expected retry_needed=true after depositcheck, got $RETRY" 283 fi 284 echo " OK" 285 286 echo -n "Running taler-merchant-reconciliation to finalize transfer amount ..." 287 taler-merchant-reconciliation \ 288 -c "$CONF" \ 289 -T "${TO_SLEEP}000000" \ 290 -t \ 291 -L INFO &> merchant-reconciliation.log 292 echo " OK" 293 294 # Primary regression check: reconciliation must have linked the expected 295 # transfer to the imported bank transfer now that the amount is known. 296 echo -n "Checking DB state after reconciliation ..." 297 DB_STATE=$(merchant_psql "SELECT expected_credit_amount IS NULL, confirmed FROM merchant_expected_transfers;") 298 IFS='|' read -r AMOUNT_NULL CONFIRMED <<< "$DB_STATE" 299 if [ "$AMOUNT_NULL" != "f" ] 300 then 301 exit_fail "Expected expected_credit_amount to be set after reconciliation" 302 fi 303 if [ "$CONFIRMED" != "t" ] 304 then 305 exit_fail "Expected confirmed=true after reconciliation, got $CONFIRMED (UPDATE trigger likely missing)" 306 fi 307 echo " OK" 308 309 echo -n "Checking incoming transfer is confirmed via API ..." 310 STATUS=$(curl 'http://localhost:9966/private/incoming' \ 311 -H "Authorization: Bearer $ADMIN_TOKEN" \ 312 -w "%{http_code}" \ 313 -s \ 314 -o "$LAST_RESPONSE") 315 if [ "$STATUS" != "200" ] 316 then 317 exit_fail "Expected response 200 Ok. Got: $STATUS" "$(jq . < "$LAST_RESPONSE")" 318 fi 319 320 INCOMING_LIST_SIZE=$(jq -r '.incoming | length' < "$LAST_RESPONSE") 321 if [ "$INCOMING_LIST_SIZE" != "1" ] 322 then 323 exit_fail "Expected one incoming transfer. Got: $INCOMING_LIST_SIZE" "$(jq . < "$LAST_RESPONSE")" 324 fi 325 326 CONFIRMED=$(jq -r '.incoming[0].confirmed' < "$LAST_RESPONSE") 327 if [ "$CONFIRMED" != "true" ] 328 then 329 jq . < "$LAST_RESPONSE" 330 exit_fail "Expected incoming[0].confirmed 'true'. Got: $CONFIRMED" 331 fi 332 echo " OK" 333 334 # Also verify the order is wired. 335 echo -n "Checking order status ..." 336 STATUS=$(curl "http://localhost:9966/private/orders/${ORDER_ID}?transfer=YES" \ 337 -H "Authorization: Bearer $ADMIN_TOKEN" \ 338 -w "%{http_code}" \ 339 -s \ 340 -o "$LAST_RESPONSE") 341 342 if [ "$STATUS" != "200" ] 343 then 344 jq . < "$LAST_RESPONSE" 345 exit_fail "Expected 200 ok. got: $STATUS" "$(cat "$LAST_RESPONSE")" 346 fi 347 348 WAS_WIRED=$(jq -r .wired < "$LAST_RESPONSE") 349 if [ "$WAS_WIRED" != "true" ] 350 then 351 jq . < "$LAST_RESPONSE" 352 exit_fail "Got .wired 'false', expected 'true'" 353 fi 354 echo " OK" 355 356 echo "TEST PASSED" 357 358 exit 0