test_merchant_fountains.sh (34770B)
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 # Exercises the token fountain endpoints (DD 98): private CRUD, 21 # GET /fountain/info, withdrawal validation and replay after family deletion. 22 # Unblinding and spending withdrawn tokens are exercised by the C harness. 23 24 # Cleanup to run whenever we exit 25 function my_cleanup() 26 { 27 for n in $(jobs -p) 28 do 29 kill "$n" 2> /dev/null || true 30 done 31 wait 32 if [ -n "${LAST_RESPONSE+x}" ] 33 then 34 rm -f "${LAST_RESPONSE}" 35 fi 36 } 37 38 . setup.sh 39 40 setup -c test_template.conf -m 41 CONF="test_template.conf.edited" 42 LAST_RESPONSE=$(mktemp -p "${TMPDIR:-/tmp}" test_response.conf-XXXXXX) 43 MERCHANT_URL="http://localhost:9966" 44 45 echo -n "Configuring 'admin' instance ..." >&2 46 47 STATUS=$(curl -H "Content-Type: application/json" -X POST \ 48 "$MERCHANT_URL/management/instances" \ 49 -d '{"auth":{"method":"token","password":"new_pw"},"id":"admin","name":"default","user_type":"business","address":{},"jurisdiction":{},"use_stefan":true,"default_wire_transfer_delay":{"d_us" : 3600000000},"default_pay_delay":{"d_us": 3600000000}}' \ 50 -w "%{http_code}" \ 51 -s \ 52 -o /dev/null) 53 54 if [ "$STATUS" != "204" ] 55 then 56 exit_fail "Expected 204, instance created. got: $STATUS" >&2 57 fi 58 59 BASIC_AUTH=$(echo -n admin:new_pw | base64) 60 61 STATUS=$(curl -H "Content-Type: application/json" -X POST \ 62 -H "Authorization: Basic $BASIC_AUTH" \ 63 "$MERCHANT_URL/private/token" \ 64 -d '{"scope":"spa"}' \ 65 -w "%{http_code}" -s -o "$LAST_RESPONSE") 66 67 if [ "$STATUS" != "200" ] 68 then 69 exit_fail "Expected 200 OK. Got: $STATUS" 70 fi 71 72 BEARER_TOKEN=$(jq -e -r .access_token < "$LAST_RESPONSE") 73 74 echo " OK" >&2 75 76 # 77 # CREATE TOKEN FAMILIES FOR THE FOUNTAIN GRANTS 78 # 79 echo -n "Creating discount token family..." >&2 80 VALID_AFTER="{\"t_s\": $(date +%s)}" # now 81 VALID_BEFORE="{\"t_s\": $(date +%s -d "+300 days")}" # 300 days from now 82 DURATION="{\"d_us\": $(expr 3 \* 60 \* 1000000)}" # 3 minutes 83 GRANULARITY="{\"d_us\": $(expr 60 \* 1000000)}" # 1 minute 84 STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies" \ 85 -X POST \ 86 -H "Authorization: Bearer $BEARER_TOKEN" \ 87 -d "{\"kind\": \"discount\", \"slug\":\"test-discount\", \"name\": \"Test discount\", \"description\": \"Less money\", \"valid_after\": $VALID_AFTER, \"valid_before\": $VALID_BEFORE, \"duration\": $DURATION, \"validity_granularity\": $GRANULARITY}" \ 88 -w "%{http_code}" \ 89 -s \ 90 -o "$LAST_RESPONSE") 91 if [ "$STATUS" != "204" ] 92 then 93 cat "$LAST_RESPONSE" >&2 94 exit_fail "Expected '204' response. Got instead $STATUS" 95 fi 96 echo "Ok" >&2 97 98 echo -n "Creating subscription token family..." >&2 99 STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies" \ 100 -X POST \ 101 -H "Authorization: Bearer $BEARER_TOKEN" \ 102 -d "{\"kind\": \"subscription\", \"slug\":\"test-subscription\", \"name\": \"Test subscription\", \"description\": \"Monthly pass\", \"valid_after\": $VALID_AFTER, \"valid_before\": $VALID_BEFORE, \"duration\": $DURATION, \"validity_granularity\": $GRANULARITY}" \ 103 -w "%{http_code}" \ 104 -s \ 105 -o "$LAST_RESPONSE") 106 if [ "$STATUS" != "204" ] 107 then 108 cat "$LAST_RESPONSE" >&2 109 exit_fail "Expected '204' response. Got instead $STATUS" 110 fi 111 echo "Ok" >&2 112 113 # 114 # CREATE A FOUNTAIN 115 # 116 echo -n "Creating fountain..." >&2 117 POLL_FREQ="{\"d_us\": 3600000000}" # 1 hour 118 STATUS=$(curl "$MERCHANT_URL/private/fountains" \ 119 -X POST \ 120 -H "Authorization: Bearer $BEARER_TOKEN" \ 121 -d "{\"description\": \"campaign-reference-42\", \"poll_freq\": $POLL_FREQ, \"grants\": []}" \ 122 -w "%{http_code}" \ 123 -s \ 124 -o "$LAST_RESPONSE") 125 if [ "$STATUS" != "200" ] 126 then 127 cat "$LAST_RESPONSE" >&2 128 exit_fail "Expected '200 OK' response. Got instead $STATUS" 129 fi 130 FOUNTAIN_ID=$(jq -e -r .fountain_id < "$LAST_RESPONSE") 131 FOUNTAIN_SECRET=$(jq -e -r .fountain_secret < "$LAST_RESPONSE") 132 echo "Ok (id: $FOUNTAIN_ID)" >&2 133 134 echo -n "Empty fountain remains accessible while grants are added and cleared..." >&2 135 FOUNTAIN_GRANTS='[{"token_family_slug":"test-discount","tokens_per_period_limit":5,"tokens_per_period_stash":2,"key_window_size":2},{"token_family_slug":"test-subscription","tokens_per_period_limit":3,"tokens_per_period_stash":1,"key_window_size":1}]' 136 STATUS=$(curl "$MERCHANT_URL/fountain/info" \ 137 -H "Authorization: Bearer $FOUNTAIN_SECRET" \ 138 -w "%{http_code}" -s -o "$LAST_RESPONSE") 139 if [ "$STATUS" != "200" ] || ! jq -e '.grants == []' "$LAST_RESPONSE" > /dev/null 140 then 141 exit_fail "Expected accessible empty fountain after creation" 142 fi 143 for EXPECTED_GRANTS in '[]' "$FOUNTAIN_GRANTS" '[]' "$FOUNTAIN_GRANTS" 144 do 145 STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \ 146 -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" \ 147 -d "{\"grants\": $EXPECTED_GRANTS}" \ 148 -w "%{http_code}" -s -o "$LAST_RESPONSE") 149 if [ "$STATUS" != "204" ] 150 then 151 exit_fail "Expected 204 updating fountain grants, got $STATUS" 152 fi 153 STATUS=$(curl "$MERCHANT_URL/fountain/info" \ 154 -H "Authorization: Bearer $FOUNTAIN_SECRET" \ 155 -w "%{http_code}" -s -o "$LAST_RESPONSE") 156 if [ "$STATUS" != "200" ] || ! jq -e --argjson expected "$EXPECTED_GRANTS" \ 157 '(.grants | map(.token_family_slug) | sort) == ($expected | map(.token_family_slug) | sort)' \ 158 "$LAST_RESPONSE" > /dev/null 159 then 160 exit_fail "Fountain info did not reflect the updated grants" 161 fi 162 done 163 echo "Ok" >&2 164 165 echo -n "Patching poll frequency and preserving it when omitted..." >&2 166 for PATCH_BODY in '{"poll_freq":{"d_us":60000000}}' '{"description":"campaign-reference-42"}' '{}' 167 do 168 STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \ 169 -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" \ 170 -d "$PATCH_BODY" -w "%{http_code}" -s -o "$LAST_RESPONSE") 171 if [ "$STATUS" != "204" ] 172 then 173 exit_fail "Expected 204 for fountain PATCH, got $STATUS" 174 fi 175 STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \ 176 -H "Authorization: Bearer $BEARER_TOKEN" \ 177 -w "%{http_code}" -s -o "$LAST_RESPONSE") 178 if [ "$STATUS" != "200" ] || ! jq -e '.poll_freq.d_us == 60000000' "$LAST_RESPONSE" > /dev/null 179 then 180 exit_fail "PATCH did not set or preserve poll_freq" 181 fi 182 done 183 # Restore the original polling frequency for the remaining CRUD checks. 184 STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \ 185 -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" \ 186 -d "{\"poll_freq\": $POLL_FREQ}" -w "%{http_code}" -s -o "$LAST_RESPONSE") 187 if [ "$STATUS" != "204" ] 188 then 189 exit_fail "Expected 204 restoring poll_freq, got $STATUS" 190 fi 191 echo "Ok" >&2 192 193 echo -n "Creating fountain with unknown token family fails..." >&2 194 STATUS=$(curl "$MERCHANT_URL/private/fountains" \ 195 -X POST \ 196 -H "Authorization: Bearer $BEARER_TOKEN" \ 197 -d "{\"description\": \"broken\", \"poll_freq\": $POLL_FREQ, \"grants\": [{\"token_family_slug\": \"no-such-family\", \"tokens_per_period_limit\": 5, \"tokens_per_period_stash\": 2, \"key_window_size\": 2}]}" \ 198 -w "%{http_code}" \ 199 -s \ 200 -o "$LAST_RESPONSE") 201 if [ "$STATUS" != "404" ] 202 then 203 cat "$LAST_RESPONSE" >&2 204 exit_fail "Expected '404' response. Got instead $STATUS" 205 fi 206 echo "Ok" >&2 207 208 echo -n "Creating fountain with stash above limit fails..." >&2 209 STATUS=$(curl "$MERCHANT_URL/private/fountains" \ 210 -X POST \ 211 -H "Authorization: Bearer $BEARER_TOKEN" \ 212 -d "{\"description\": \"broken\", \"poll_freq\": $POLL_FREQ, \"grants\": [{\"token_family_slug\": \"test-discount\", \"tokens_per_period_limit\": 2, \"tokens_per_period_stash\": 5, \"key_window_size\": 2}]}" \ 213 -w "%{http_code}" \ 214 -s \ 215 -o "$LAST_RESPONSE") 216 if [ "$STATUS" != "400" ] 217 then 218 cat "$LAST_RESPONSE" >&2 219 exit_fail "Expected '400' response. Got instead $STATUS" 220 fi 221 echo "Ok" >&2 222 223 # 224 # LIST AND INSPECT 225 # 226 echo -n "Listing fountains..." >&2 227 STATUS=$(curl "$MERCHANT_URL/private/fountains" \ 228 -H "Authorization: Bearer $BEARER_TOKEN" \ 229 -w "%{http_code}" \ 230 -s \ 231 -o "$LAST_RESPONSE") 232 if [ "$STATUS" != "200" ] 233 then 234 cat "$LAST_RESPONSE" >&2 235 exit_fail "Expected '200 OK' response. Got instead $STATUS" 236 fi 237 COUNT=$(jq -e '.fountains | length' < "$LAST_RESPONSE") 238 if [ "$COUNT" != "1" ] 239 then 240 exit_fail "Expected 1 fountain in list, got $COUNT" 241 fi 242 echo "Ok" >&2 243 244 echo -n "Inspecting fountain..." >&2 245 STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \ 246 -H "Authorization: Bearer $BEARER_TOKEN" \ 247 -w "%{http_code}" \ 248 -s \ 249 -o "$LAST_RESPONSE") 250 if [ "$STATUS" != "200" ] 251 then 252 cat "$LAST_RESPONSE" >&2 253 exit_fail "Expected '200 OK' response. Got instead $STATUS" 254 fi 255 DESC=$(jq -e -r .description < "$LAST_RESPONSE") 256 if [ "$DESC" != "campaign-reference-42" ] 257 then 258 exit_fail "Expected description 'campaign-reference-42', got $DESC" 259 fi 260 GRANTS=$(jq -e '.grants | length' < "$LAST_RESPONSE") 261 if [ "$GRANTS" != "2" ] 262 then 263 exit_fail "Expected 2 grants, got $GRANTS" 264 fi 265 if jq -e '.fountain_secret' < "$LAST_RESPONSE" > /dev/null 2>&1 266 then 267 exit_fail "Fountain secret must never be returned by the private API" 268 fi 269 echo "Ok" >&2 270 271 echo -n "Inspecting unknown fountain fails..." >&2 272 STATUS=$(curl "$MERCHANT_URL/private/fountains/does-not-exist" \ 273 -H "Authorization: Bearer $BEARER_TOKEN" \ 274 -w "%{http_code}" \ 275 -s \ 276 -o "$LAST_RESPONSE") 277 if [ "$STATUS" != "404" ] 278 then 279 cat "$LAST_RESPONSE" >&2 280 exit_fail "Expected '404' response. Got instead $STATUS" 281 fi 282 echo "Ok" >&2 283 284 # 285 # WALLET INFO ENDPOINT 286 # 287 echo -n "Fetching fountain info with bearer secret..." >&2 288 STATUS=$(curl "$MERCHANT_URL/fountain/info" \ 289 -H "Authorization: Bearer $FOUNTAIN_SECRET" \ 290 -w "%{http_code}" \ 291 -s \ 292 -o "$LAST_RESPONSE") 293 if [ "$STATUS" != "200" ] 294 then 295 cat "$LAST_RESPONSE" >&2 296 exit_fail "Expected '200 OK' response. Got instead $STATUS" 297 fi 298 GRANTS=$(jq -e '.grants | length' < "$LAST_RESPONSE") 299 if [ "$GRANTS" != "2" ] 300 then 301 exit_fail "Expected 2 grants in info, got $GRANTS" 302 fi 303 # At least one issue key must be offered, and at most one per slot of 304 # the grant's key window (slots whose validity period is covered by an 305 # already-listed key share that key, so fewer is legitimate). 306 DISCOUNT_KEYS=$(jq -e '.grants[] | select(.token_family_slug == "test-discount") | .token_family.keys | length' < "$LAST_RESPONSE") 307 if [ "$DISCOUNT_KEYS" -lt 1 ] || [ "$DISCOUNT_KEYS" -gt 3 ] 308 then 309 cat "$LAST_RESPONSE" >&2 310 exit_fail "Expected 1..3 issue keys for test-discount, got $DISCOUNT_KEYS" 311 fi 312 KEYS=$(jq -e '.grants[] | select(.token_family_slug == "test-subscription") | .token_family.keys | length' < "$LAST_RESPONSE") 313 if [ "$KEYS" -lt 1 ] || [ "$KEYS" -gt 2 ] 314 then 315 cat "$LAST_RESPONSE" >&2 316 exit_fail "Expected 1..2 issue keys for test-subscription, got $KEYS" 317 fi 318 # Issue keys must be distinct. 319 UNIQUE_KEYS=$(jq -e '[.grants[] | select(.token_family_slug == "test-discount") | .token_family.keys[]] | unique | length' < "$LAST_RESPONSE") 320 if [ "$UNIQUE_KEYS" != "$DISCOUNT_KEYS" ] 321 then 322 cat "$LAST_RESPONSE" >&2 323 exit_fail "Duplicate issue keys advertised: $DISCOUNT_KEYS listed, $UNIQUE_KEYS distinct" 324 fi 325 echo "Ok" >&2 326 327 echo -n "Fetching fountain info again is idempotent..." >&2 328 STATUS=$(curl "$MERCHANT_URL/fountain/info" \ 329 -H "Authorization: Bearer $FOUNTAIN_SECRET" \ 330 -w "%{http_code}" \ 331 -s \ 332 -o "$LAST_RESPONSE") 333 if [ "$STATUS" != "200" ] 334 then 335 cat "$LAST_RESPONSE" >&2 336 exit_fail "Expected '200 OK' response. Got instead $STATUS" 337 fi 338 # Re-polling must be idempotent: no additional keys may be minted. 339 KEYS=$(jq -e '.grants[] | select(.token_family_slug == "test-discount") | .token_family.keys | length' < "$LAST_RESPONSE") 340 if [ "$KEYS" != "$DISCOUNT_KEYS" ] 341 then 342 cat "$LAST_RESPONSE" >&2 343 exit_fail "Re-poll changed issue key count: was $DISCOUNT_KEYS, now $KEYS" 344 fi 345 echo "Ok" >&2 346 347 echo -n "Fetching fountain info with bad secret fails..." >&2 348 STATUS=$(curl "$MERCHANT_URL/fountain/info" \ 349 -H "Authorization: Bearer 0000000000000000000000000000000000000000000000000000" \ 350 -w "%{http_code}" \ 351 -s \ 352 -o "$LAST_RESPONSE") 353 if [ "$STATUS" != "401" ] 354 then 355 cat "$LAST_RESPONSE" >&2 356 exit_fail "Expected '401' response. Got instead $STATUS" 357 fi 358 echo "Ok" >&2 359 360 echo -n "Fetching fountain info without secret fails..." >&2 361 STATUS=$(curl "$MERCHANT_URL/fountain/info" \ 362 -w "%{http_code}" \ 363 -s \ 364 -o "$LAST_RESPONSE") 365 if [ "$STATUS" != "401" ] 366 then 367 cat "$LAST_RESPONSE" >&2 368 exit_fail "Expected '401' response. Got instead $STATUS" 369 fi 370 echo "Ok" >&2 371 372 # 373 # WITHDRAW VALIDATION ERRORS 374 # 375 echo -n "Withdrawing with bad secret fails..." >&2 376 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \ 377 -X POST \ 378 -d "{\"fountain_secret\": \"0000000000000000000000000000000000000000000000000000\", \"grants\": []}" \ 379 -w "%{http_code}" \ 380 -s \ 381 -o "$LAST_RESPONSE") 382 if [ "$STATUS" != "401" ] 383 then 384 cat "$LAST_RESPONSE" >&2 385 exit_fail "Expected '401' response. Got instead $STATUS" 386 fi 387 echo "Ok" >&2 388 389 echo -n "Withdrawing for ungranted family fails..." >&2 390 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \ 391 -X POST \ 392 -d "{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": [{\"token_family_slug\": \"no-such-family\", \"envelopes\": []}]}" \ 393 -w "%{http_code}" \ 394 -s \ 395 -o "$LAST_RESPONSE") 396 if [ "$STATUS" != "409" ] 397 then 398 cat "$LAST_RESPONSE" >&2 399 exit_fail "Expected '409' response. Got instead $STATUS" 400 fi 401 echo "Ok" >&2 402 403 echo -n "Withdrawing outside the key window fails..." >&2 404 FUTURE="{\"t_s\": $(date +%s -d "+200 days")}" 405 # A real envelope is required: an empty "envelopes" array is rejected as a 406 # malformed request before the key window is ever resolved. 407 ENVELOPE="{\"cipher\": \"RSA\", \"rsa_blinded_planchet\": \"04\"}" 408 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \ 409 -X POST \ 410 -d "{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": [{\"token_family_slug\": \"test-discount\", \"valid_at\": $FUTURE, \"envelopes\": [$ENVELOPE]}]}" \ 411 -w "%{http_code}" \ 412 -s \ 413 -o "$LAST_RESPONSE") 414 # Check the error code as well: an unknown grant also answers 409, and would 415 # otherwise hide a request that never reached the key window check. 416 if [ "$STATUS" != "409" ] || ! jq -e '.code == 2951' "$LAST_RESPONSE" > /dev/null 417 then 418 cat "$LAST_RESPONSE" >&2 419 exit_fail "Expected '409' TOKEN_KEY_SLOT_OUTSIDE_WINDOW. Got instead $STATUS" 420 fi 421 echo "Ok" >&2 422 423 echo -n "Withdrawing without envelopes fails..." >&2 424 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \ 425 -X POST \ 426 -d "{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": [{\"token_family_slug\": \"test-discount\", \"envelopes\": []}]}" \ 427 -w "%{http_code}" \ 428 -s \ 429 -o "$LAST_RESPONSE") 430 if [ "$STATUS" != "400" ] 431 then 432 cat "$LAST_RESPONSE" >&2 433 exit_fail "Expected '400' response. Got instead $STATUS" 434 fi 435 echo "Ok" >&2 436 437 # 438 # PATCH 439 # 440 echo -n "Wrong envelope cipher is a bad request and does not consume quota..." >&2 441 STATUS=$(curl "$MERCHANT_URL/private/fountains" \ 442 -X POST -H "Authorization: Bearer $BEARER_TOKEN" \ 443 -d '{"description":"cipher-validation","poll_freq":{"d_us":60000000},"grants":[{"token_family_slug":"test-discount","tokens_per_period_limit":1,"tokens_per_period_stash":1,"key_window_size":0}]}' \ 444 -w "%{http_code}" -s -o "$LAST_RESPONSE") 445 if [ "$STATUS" != "200" ] 446 then 447 exit_fail "Expected 200 creating cipher-validation fountain, got $STATUS" 448 fi 449 CIPHER_FOUNTAIN_ID=$(jq -er .fountain_id "$LAST_RESPONSE") 450 CIPHER_FOUNTAIN_SECRET=$(jq -er .fountain_secret "$LAST_RESPONSE") 451 # The family uses RSA. These zero-valued CS fields are structurally valid, 452 # so rejection must come from matching the envelope against the issue key. 453 CS_ZERO=0000000000000000000000000000000000000000000000000000 454 CS_ENVELOPE=$(jq -nc --arg z "$CS_ZERO" \ 455 '{cipher:"CS",cs_nonce:$z,cs_blinded_c0:$z,cs_blinded_c1:$z}') 456 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST \ 457 -d "{\"fountain_secret\":\"$CIPHER_FOUNTAIN_SECRET\",\"grants\":[{\"token_family_slug\":\"test-discount\",\"envelopes\":[$CS_ENVELOPE]}]}" \ 458 -w "%{http_code}" -s -o "$LAST_RESPONSE") 459 if [ "$STATUS" != "400" ] || ! jq -e '.code == 26' "$LAST_RESPONSE" > /dev/null 460 then 461 cat "$LAST_RESPONSE" >&2 462 exit_fail "Expected 400 GENERIC_PARAMETER_MALFORMED for wrong cipher, got $STATUS" 463 fi 464 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST \ 465 -d "{\"fountain_secret\":\"$CIPHER_FOUNTAIN_SECRET\",\"grants\":[{\"token_family_slug\":\"test-discount\",\"envelopes\":[$ENVELOPE]}]}" \ 466 -w "%{http_code}" -s -o "$LAST_RESPONSE") 467 if [ "$STATUS" != "200" ] 468 then 469 cat "$LAST_RESPONSE" >&2 470 exit_fail "Wrong-cipher request consumed quota: valid withdrawal returned $STATUS" 471 fi 472 STATUS=$(curl "$MERCHANT_URL/private/fountains/$CIPHER_FOUNTAIN_ID" \ 473 -X DELETE -H "Authorization: Bearer $BEARER_TOKEN" \ 474 -w "%{http_code}" -s -o "$LAST_RESPONSE") 475 if [ "$STATUS" != "204" ] 476 then 477 exit_fail "Expected 204 deleting cipher-validation fountain, got $STATUS" 478 fi 479 echo "Ok" >&2 480 481 echo -n "Fountain quotas are restricted to nonnegative INT8 values..." >&2 482 # Keep the large integer literals as text: passing them through jq arithmetic 483 # can round INT64_MAX and accidentally test a different input. 484 for QUOTAS in \ 485 '"tokens_per_period_limit":0,"tokens_per_period_stash":0' \ 486 '"tokens_per_period_limit":9223372036854775807,"tokens_per_period_stash":9223372036854775807' 487 do 488 QUOTA_GRANT="{\"token_family_slug\":\"test-discount\",$QUOTAS,\"key_window_size\":0}" 489 STATUS=$(curl "$MERCHANT_URL/private/fountains" \ 490 -X POST -H "Authorization: Bearer $BEARER_TOKEN" \ 491 -d "{\"description\":\"quota-bounds\",\"poll_freq\":{\"d_us\":60000000},\"grants\":[$QUOTA_GRANT]}" \ 492 -w "%{http_code}" -s -o "$LAST_RESPONSE") 493 if [ "$STATUS" != "200" ] 494 then 495 cat "$LAST_RESPONSE" >&2 496 exit_fail "Expected 200 for valid quota boundary, got $STATUS" 497 fi 498 QUOTA_ID=$(jq -er .fountain_id "$LAST_RESPONSE") 499 STATUS=$(curl "$MERCHANT_URL/private/fountains/$QUOTA_ID" \ 500 -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" \ 501 -d "{\"grants\":[$QUOTA_GRANT]}" \ 502 -w "%{http_code}" -s -o "$LAST_RESPONSE") 503 if [ "$STATUS" != "204" ] 504 then 505 exit_fail "Expected 204 patching valid quota boundary, got $STATUS" 506 fi 507 for BAD_QUOTAS in \ 508 '"tokens_per_period_limit":-1,"tokens_per_period_stash":0' \ 509 '"tokens_per_period_limit":0,"tokens_per_period_stash":-1' \ 510 '"tokens_per_period_limit":-1,"tokens_per_period_stash":-1' \ 511 '"tokens_per_period_limit":-9223372036854775808,"tokens_per_period_stash":0' \ 512 '"tokens_per_period_limit":9223372036854775808,"tokens_per_period_stash":0' 513 do 514 BAD_GRANT="{\"token_family_slug\":\"test-discount\",$BAD_QUOTAS,\"key_window_size\":0}" 515 for METHOD in POST PATCH 516 do 517 QUOTA_URL="$MERCHANT_URL/private/fountains" 518 if [ "$METHOD" = PATCH ] 519 then 520 QUOTA_URL="$QUOTA_URL/$QUOTA_ID" 521 fi 522 STATUS=$(curl "$QUOTA_URL" -X "$METHOD" \ 523 -H "Authorization: Bearer $BEARER_TOKEN" \ 524 -d "{\"description\":\"quota-bounds\",\"poll_freq\":{\"d_us\":60000000},\"grants\":[$BAD_GRANT]}" \ 525 -w "%{http_code}" -s -o "$LAST_RESPONSE") 526 if [ "$STATUS" != "400" ] 527 then 528 cat "$LAST_RESPONSE" >&2 529 exit_fail "Expected 400 for $METHOD with $BAD_QUOTAS, got $STATUS" 530 fi 531 done 532 done 533 STATUS=$(curl "$MERCHANT_URL/private/fountains/$QUOTA_ID" \ 534 -X DELETE -H "Authorization: Bearer $BEARER_TOKEN" \ 535 -w "%{http_code}" -s -o "$LAST_RESPONSE") 536 if [ "$STATUS" != "204" ] 537 then 538 exit_fail "Expected 204 deleting quota test fountain, got $STATUS" 539 fi 540 done 541 echo "Ok" >&2 542 543 echo -n "Offset families advertise covering keys and allow withdrawal..." >&2 544 OFFSET_NOW=$(date +%s) 545 OFFSET_START=$((OFFSET_NOW - 172800)) 546 OFFSET_END=$((OFFSET_NOW + 172800)) 547 STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies" \ 548 -X POST -H "Authorization: Bearer $BEARER_TOKEN" \ 549 -d "{\"slug\":\"test-offset\",\"name\":\"Offset family\",\"description\":\"Offset coverage\",\"kind\":\"discount\",\"valid_after\":{\"t_s\":$OFFSET_START},\"valid_before\":{\"t_s\":$OFFSET_END},\"duration\":{\"d_us\":7140000000},\"validity_granularity\":{\"d_us\":3600000000},\"start_offset\":{\"d_us\":3540000000}}" \ 550 -w "%{http_code}" -s -o "$LAST_RESPONSE") 551 if [ "$STATUS" != "204" ] 552 then 553 cat "$LAST_RESPONSE" >&2 554 exit_fail "Expected 204 creating offset family, got $STATUS" 555 fi 556 STATUS=$(curl "$MERCHANT_URL/private/fountains" \ 557 -X POST -H "Authorization: Bearer $BEARER_TOKEN" \ 558 -d '{"description":"offset-coverage","poll_freq":{"d_us":60000000},"grants":[{"token_family_slug":"test-offset","tokens_per_period_limit":3,"tokens_per_period_stash":1,"key_window_size":2}]}' \ 559 -w "%{http_code}" -s -o "$LAST_RESPONSE") 560 if [ "$STATUS" != "200" ] 561 then 562 exit_fail "Expected 200 creating offset fountain, got $STATUS" 563 fi 564 OFFSET_SECRET=$(jq -er .fountain_secret "$LAST_RESPONSE") 565 OFFSET_ID=$(jq -er .fountain_id "$LAST_RESPONSE") 566 STATUS=$(curl "$MERCHANT_URL/fountain/info" \ 567 -H "Authorization: Bearer $OFFSET_SECRET" \ 568 -w "%{http_code}" -s -o "$LAST_RESPONSE") 569 if [ "$STATUS" != "200" ] || ! jq -e --argjson now "$OFFSET_NOW" \ 570 '.grants[0].token_family.keys as $keys | 571 ($keys | length) == 3 and 572 $keys[0].signature_validity_start.t_s <= $now and 573 $keys[0].signature_validity_end.t_s >= $now and 574 all(range(1;3); . as $i | 575 $keys[$i].signature_validity_start.t_s <= $keys[$i-1].signature_validity_end.t_s + 1 and 576 $keys[$i].signature_validity_end.t_s > $keys[$i-1].signature_validity_end.t_s)' \ 577 "$LAST_RESPONSE" > /dev/null 578 then 579 cat "$LAST_RESPONSE" >&2 580 exit_fail "Offset family did not advertise a covering key window" 581 fi 582 OFFSET_SLOTS=$(jq -r '.grants[0].token_family.keys[].signature_validity_start.t_s' "$LAST_RESPONSE") 583 for SLOT in $OFFSET_SLOTS 584 do 585 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST \ 586 -d "{\"fountain_secret\":\"$OFFSET_SECRET\",\"grants\":[{\"token_family_slug\":\"test-offset\",\"valid_at\":{\"t_s\":$SLOT},\"envelopes\":[$ENVELOPE]}]}" \ 587 -w "%{http_code}" -s -o "$LAST_RESPONSE") 588 if [ "$STATUS" != "200" ] 589 then 590 cat "$LAST_RESPONSE" >&2 591 exit_fail "Expected 200 withdrawing advertised offset key, got $STATUS" 592 fi 593 done 594 STATUS=$(curl "$MERCHANT_URL/private/fountains/$OFFSET_ID" \ 595 -X DELETE -H "Authorization: Bearer $BEARER_TOKEN" \ 596 -w "%{http_code}" -s -o "$LAST_RESPONSE") 597 if [ "$STATUS" != "204" ] 598 then 599 exit_fail "Expected 204 deleting offset fountain, got $STATUS" 600 fi 601 echo "Ok" >&2 602 603 echo -n "Future keys are bounded by durations from now, not the family start..." >&2 604 # One-hour duration and one-minute granularity distinguish which interval 605 # bounds prefetching. Start halfway through the first future duration. 606 FIRST_START=$(date +%s -d "+30 minutes") 607 FAMILY_REQUEST=$(jq -nc --argjson start "$FIRST_START" \ 608 '{slug:"test-future",name:"Future promotion",description:"Starts soon",kind:"discount",valid_after:{t_s:$start},duration:{d_us:3600000000},validity_granularity:{d_us:60000000}}') 609 STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies" \ 610 -X POST -H "Authorization: Bearer $BEARER_TOKEN" -d "$FAMILY_REQUEST" \ 611 -w "%{http_code}" -s -o "$LAST_RESPONSE") 612 if [ "$STATUS" != "204" ] 613 then 614 exit_fail "Expected 204 creating future token family, got $STATUS" 615 fi 616 STATUS=$(curl "$MERCHANT_URL/private/fountains" \ 617 -X POST -H "Authorization: Bearer $BEARER_TOKEN" \ 618 -d '{"description":"future-start","poll_freq":{"d_us":60000000},"grants":[{"token_family_slug":"test-future","tokens_per_period_limit":3,"tokens_per_period_stash":1,"key_window_size":0}]}' \ 619 -w "%{http_code}" -s -o "$LAST_RESPONSE") 620 if [ "$STATUS" != "200" ] 621 then 622 exit_fail "Expected 200 creating future fountain, got $STATUS" 623 fi 624 FUTURE_SECRET=$(jq -er .fountain_secret "$LAST_RESPONSE") 625 FUTURE_ID=$(jq -er .fountain_id "$LAST_RESPONSE") 626 STATUS=$(curl "$MERCHANT_URL/fountain/info" \ 627 -H "Authorization: Bearer $FUTURE_SECRET" \ 628 -w "%{http_code}" -s -o "$LAST_RESPONSE") 629 if [ "$STATUS" != "200" ] || ! jq -e '.grants[0].token_family.keys == []' "$LAST_RESPONSE" > /dev/null 630 then 631 exit_fail "Zero-size window advertised a future key" 632 fi 633 for VALID_AT in null "$(date +%s)" "$FIRST_START" 634 do 635 WITHDRAW_REQUEST=$(jq -nc --arg secret "$FUTURE_SECRET" --argjson at "$VALID_AT" \ 636 --argjson envelope "$ENVELOPE" \ 637 '{fountain_secret:$secret,grants:[({token_family_slug:"test-future",envelopes:[$envelope]} + (if $at == null then {} else {valid_at:{t_s:$at}} end))]}') 638 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST -d "$WITHDRAW_REQUEST" \ 639 -w "%{http_code}" -s -o "$LAST_RESPONSE") 640 if [ "$STATUS" != "409" ] || ! jq -e '.code == 2951' "$LAST_RESPONSE" > /dev/null 641 then 642 cat "$LAST_RESPONSE" >&2 643 exit_fail "Expected 409 for a future family with window 0, got $STATUS" 644 fi 645 done 646 for WINDOW in 1 2 647 do 648 PATCH_REQUEST=$(jq -nc --argjson window "$WINDOW" \ 649 '{grants:[{token_family_slug:"test-future",tokens_per_period_limit:3,tokens_per_period_stash:1,key_window_size:$window}]}') 650 STATUS=$(curl "$MERCHANT_URL/private/fountains/$FUTURE_ID" \ 651 -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" -d "$PATCH_REQUEST" \ 652 -w "%{http_code}" -s -o "$LAST_RESPONSE") 653 if [ "$STATUS" != "204" ] 654 then 655 exit_fail "Expected 204 changing future window, got $STATUS" 656 fi 657 STATUS=$(curl "$MERCHANT_URL/fountain/info" \ 658 -H "Authorization: Bearer $FUTURE_SECRET" \ 659 -w "%{http_code}" -s -o "$LAST_RESPONSE") 660 HORIZON=$(($(date +%s) + WINDOW * 3600)) 661 if [ "$STATUS" != "200" ] || ! jq -e --argjson count "$WINDOW" --argjson horizon "$HORIZON" \ 662 '(.grants[0].token_family.keys | length) == $count and 663 all(.grants[0].token_family.keys[]; .signature_validity_start.t_s <= $horizon and .signature_validity_end.t_s <= ($horizon + 3600))' \ 664 "$LAST_RESPONSE" > /dev/null 665 then 666 cat "$LAST_RESPONSE" >&2 667 exit_fail "Future window did not respect its duration-based horizon" 668 fi 669 SLOT_START=$(jq -er '.grants[0].token_family.keys[-1].signature_validity_start.t_s' "$LAST_RESPONSE") 670 WITHDRAW_REQUEST=$(jq -nc --arg secret "$FUTURE_SECRET" --argjson at "$SLOT_START" \ 671 --argjson envelope "$ENVELOPE" \ 672 '{fountain_secret:$secret,grants:[{token_family_slug:"test-future",valid_at:{t_s:$at},envelopes:[$envelope]}]}') 673 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST -d "$WITHDRAW_REQUEST" \ 674 -w "%{http_code}" -s -o "$LAST_RESPONSE") 675 if [ "$STATUS" != "200" ] 676 then 677 exit_fail "Expected 200 for advertised future key, got $STATUS" 678 fi 679 # The next key would start outside the horizon, even though this 680 # family began later than now. Neither info nor withdraw may offer it. 681 WITHDRAW_REQUEST=$(jq --argjson at "$((SLOT_START + 3601))" \ 682 '.grants[0].valid_at = {t_s:$at}' <<< "$WITHDRAW_REQUEST") 683 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST -d "$WITHDRAW_REQUEST" \ 684 -w "%{http_code}" -s -o "$LAST_RESPONSE") 685 if [ "$STATUS" != "409" ] || ! jq -e '.code == 2951' "$LAST_RESPONSE" > /dev/null 686 then 687 exit_fail "Expected 409 beyond the duration-based horizon, got $STATUS" 688 fi 689 done 690 # Even with a nonzero window, a family starting four durations ahead 691 # must not shift the horizon to its own first key. 692 FAR_START=$(date +%s -d "+4 hours") 693 FAMILY_REQUEST=$(jq --argjson start "$FAR_START" \ 694 '.slug = "test-far-future" | .valid_after = {t_s:$start}' <<< "$FAMILY_REQUEST") 695 STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies" \ 696 -X POST -H "Authorization: Bearer $BEARER_TOKEN" -d "$FAMILY_REQUEST" \ 697 -w "%{http_code}" -s -o "$LAST_RESPONSE") 698 if [ "$STATUS" != "204" ] 699 then 700 exit_fail "Expected 204 creating far-future family, got $STATUS" 701 fi 702 PATCH_REQUEST=$(jq '.grants[0].token_family_slug = "test-far-future"' <<< "$PATCH_REQUEST") 703 STATUS=$(curl "$MERCHANT_URL/private/fountains/$FUTURE_ID" \ 704 -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" -d "$PATCH_REQUEST" \ 705 -w "%{http_code}" -s -o "$LAST_RESPONSE") 706 if [ "$STATUS" != "204" ] 707 then 708 exit_fail "Expected 204 changing granted family, got $STATUS" 709 fi 710 STATUS=$(curl "$MERCHANT_URL/fountain/info" \ 711 -H "Authorization: Bearer $FUTURE_SECRET" \ 712 -w "%{http_code}" -s -o "$LAST_RESPONSE") 713 if [ "$STATUS" != "200" ] || ! jq -e '.grants[0].token_family.keys == []' "$LAST_RESPONSE" > /dev/null 714 then 715 exit_fail "Far-future family shifted the advertised horizon" 716 fi 717 WITHDRAW_REQUEST=$(jq --argjson at "$FAR_START" \ 718 '.grants[0].token_family_slug = "test-far-future" | .grants[0].valid_at = {t_s:$at}' <<< "$WITHDRAW_REQUEST") 719 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST -d "$WITHDRAW_REQUEST" \ 720 -w "%{http_code}" -s -o "$LAST_RESPONSE") 721 if [ "$STATUS" != "409" ] || ! jq -e '.code == 2951' "$LAST_RESPONSE" > /dev/null 722 then 723 exit_fail "Expected 409 for far-future family, got $STATUS" 724 fi 725 echo "Ok" >&2 726 727 echo -n "Saving a two-family withdrawal for replay..." >&2 728 REPLAY_REQUEST="{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": [{\"token_family_slug\": \"test-discount\", \"envelopes\": [$ENVELOPE]}, {\"token_family_slug\": \"test-subscription\", \"envelopes\": [$ENVELOPE]}]}" 729 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \ 730 -X POST -d "$REPLAY_REQUEST" \ 731 -w "%{http_code}" -s -o "$LAST_RESPONSE") 732 if [ "$STATUS" != "200" ] || ! jq -e '.grants | length == 2' "$LAST_RESPONSE" > /dev/null 733 then 734 cat "$LAST_RESPONSE" >&2 735 exit_fail "Expected a successful two-family withdrawal, got $STATUS" 736 fi 737 REPLAY_RESPONSE=$(jq -cS . "$LAST_RESPONSE") 738 echo "Ok" >&2 739 740 echo -n "Patching fountain (drop subscription grant)..." >&2 741 STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \ 742 -X PATCH \ 743 -H "Authorization: Bearer $BEARER_TOKEN" \ 744 -d "{\"description\": \"campaign-reference-43\", \"grants\": [{\"token_family_slug\": \"test-discount\", \"tokens_per_period_limit\": 7, \"tokens_per_period_stash\": 3, \"key_window_size\": 2}]}" \ 745 -w "%{http_code}" \ 746 -s \ 747 -o "$LAST_RESPONSE") 748 if [ "$STATUS" != "204" ] 749 then 750 cat "$LAST_RESPONSE" >&2 751 exit_fail "Expected '204' response. Got instead $STATUS" 752 fi 753 STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \ 754 -H "Authorization: Bearer $BEARER_TOKEN" \ 755 -w "%{http_code}" \ 756 -s \ 757 -o "$LAST_RESPONSE") 758 if [ "$STATUS" != "200" ] 759 then 760 cat "$LAST_RESPONSE" >&2 761 exit_fail "Expected '200 OK' response. Got instead $STATUS" 762 fi 763 DESC=$(jq -e -r .description < "$LAST_RESPONSE") 764 if [ "$DESC" != "campaign-reference-43" ] 765 then 766 exit_fail "Expected patched description, got $DESC" 767 fi 768 GRANTS=$(jq -e '.grants | length' < "$LAST_RESPONSE") 769 if [ "$GRANTS" != "1" ] 770 then 771 exit_fail "Expected 1 grant after patch, got $GRANTS" 772 fi 773 echo "Ok" >&2 774 775 echo -n "Withdrawing for dropped grant fails..." >&2 776 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \ 777 -X POST \ 778 -d "{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": [{\"token_family_slug\": \"test-subscription\", \"envelopes\": []}]}" \ 779 -w "%{http_code}" \ 780 -s \ 781 -o "$LAST_RESPONSE") 782 if [ "$STATUS" != "409" ] 783 then 784 cat "$LAST_RESPONSE" >&2 785 exit_fail "Expected '409' response. Got instead $STATUS" 786 fi 787 echo "Ok" >&2 788 789 echo -n "Patching unknown fountain fails..." >&2 790 STATUS=$(curl "$MERCHANT_URL/private/fountains/does-not-exist" \ 791 -X PATCH \ 792 -H "Authorization: Bearer $BEARER_TOKEN" \ 793 -d "{\"description\": \"nope\"}" \ 794 -w "%{http_code}" \ 795 -s \ 796 -o "$LAST_RESPONSE") 797 if [ "$STATUS" != "404" ] 798 then 799 cat "$LAST_RESPONSE" >&2 800 exit_fail "Expected '404' response. Got instead $STATUS" 801 fi 802 echo "Ok" >&2 803 804 # 805 # DELETE 806 # 807 echo -n "Replaying the complete response after token family deletion..." >&2 808 STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies/test-subscription" \ 809 -X DELETE -H "Authorization: Bearer $BEARER_TOKEN" \ 810 -w "%{http_code}" -s -o "$LAST_RESPONSE") 811 if [ "$STATUS" != "204" ] 812 then 813 cat "$LAST_RESPONSE" >&2 814 exit_fail "Expected token family deletion to return 204, got $STATUS" 815 fi 816 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \ 817 -X POST -d "$REPLAY_REQUEST" \ 818 -w "%{http_code}" -s -o "$LAST_RESPONSE") 819 if [ "$STATUS" != "200" ] || [ "$(jq -cS . "$LAST_RESPONSE")" != "$REPLAY_RESPONSE" ] 820 then 821 cat "$LAST_RESPONSE" >&2 822 exit_fail "Family deletion changed the original withdrawal response ($STATUS)" 823 fi 824 echo "Ok" >&2 825 826 echo -n "Deleting fountain..." >&2 827 STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \ 828 -X DELETE \ 829 -H "Authorization: Bearer $BEARER_TOKEN" \ 830 -w "%{http_code}" \ 831 -s \ 832 -o "$LAST_RESPONSE") 833 if [ "$STATUS" != "204" ] 834 then 835 cat "$LAST_RESPONSE" >&2 836 exit_fail "Expected '204' response. Got instead $STATUS" 837 fi 838 echo "Ok" >&2 839 840 echo -n "Fountain info after deletion fails..." >&2 841 STATUS=$(curl "$MERCHANT_URL/fountain/info" \ 842 -H "Authorization: Bearer $FOUNTAIN_SECRET" \ 843 -w "%{http_code}" \ 844 -s \ 845 -o "$LAST_RESPONSE") 846 if [ "$STATUS" != "401" ] 847 then 848 cat "$LAST_RESPONSE" >&2 849 exit_fail "Expected '401' response. Got instead $STATUS" 850 fi 851 echo "Ok" >&2 852 853 echo -n "Withdrawal after deletion fails..." >&2 854 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \ 855 -X POST \ 856 -d "{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": []}" \ 857 -w "%{http_code}" -s -o "$LAST_RESPONSE") 858 if [ "$STATUS" != "401" ] 859 then 860 exit_fail "Expected 401 withdrawing from deleted fountain, got $STATUS" 861 fi 862 echo "Ok" >&2 863 864 echo -n "Deleting unknown fountain fails..." >&2 865 STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \ 866 -X DELETE \ 867 -H "Authorization: Bearer $BEARER_TOKEN" \ 868 -w "%{http_code}" \ 869 -s \ 870 -o "$LAST_RESPONSE") 871 if [ "$STATUS" != "404" ] 872 then 873 cat "$LAST_RESPONSE" >&2 874 exit_fail "Expected '404' response. Got instead $STATUS" 875 fi 876 echo "Ok" >&2 877 878 echo "Test PASSED" >&2 879 exit 0