merchant

Merchant backend to process payments, run by merchants
Log | Files | Refs | Submodules | README | LICENSE

test_merchant_fountains.sh (37743B)


      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 # Exercise the default page, hard cap, both directions and invalid cursors.
    224 echo -n "Checking bounded fountain pagination..." >&2
    225 PAGINATION_IDS=()
    226 for ((i=0; i<104; i++))
    227 do
    228     STATUS=$(curl "$MERCHANT_URL/private/fountains" -X POST \
    229         -H "Authorization: Bearer $BEARER_TOKEN" \
    230         -d "{\"description\":\"pagination-$i\",\"poll_freq\":$POLL_FREQ,\"grants\":[]}" \
    231         -w "%{http_code}" -s -o "$LAST_RESPONSE")
    232     if [ "$STATUS" != "200" ]; then
    233         exit_fail "Failed to create pagination fixture ($STATUS)"
    234     fi
    235     PAGINATION_IDS+=("$(jq -er .fountain_id "$LAST_RESPONSE")")
    236 done
    237 for QUERY in '' '?limit=1000' '?limit=-9223372036854775808' '?limit=0'
    238 do
    239     STATUS=$(curl "$MERCHANT_URL/private/fountains$QUERY" \
    240         -H "Authorization: Bearer $BEARER_TOKEN" \
    241         -w "%{http_code}" -s -o "$LAST_RESPONSE")
    242     EXPECTED=100
    243     [ -z "$QUERY" ] && EXPECTED=20
    244     [ "$QUERY" = '?limit=0' ] && EXPECTED=0
    245     if [ "$STATUS" != "200" ] || ! jq -e --argjson n "$EXPECTED" \
    246         '.fountains | length == $n' "$LAST_RESPONSE" > /dev/null; then
    247         exit_fail "Unexpected page size for $QUERY ($STATUS)"
    248     fi
    249 done
    250 for LIMIT in 17 -17
    251 do
    252     OFFSET=0
    253     [ "$LIMIT" -lt 0 ] && OFFSET=9223372036854775807
    254     SEEN='[]'
    255     while true
    256     do
    257         STATUS=$(curl "$MERCHANT_URL/private/fountains?limit=$LIMIT&offset=$OFFSET" \
    258             -H "Authorization: Bearer $BEARER_TOKEN" \
    259             -w "%{http_code}" -s -o "$LAST_RESPONSE")
    260         if [ "$STATUS" != "200" ]; then
    261             exit_fail "Pagination failed ($STATUS)"
    262         fi
    263         PAGE=$(jq -c '[.fountains[].fountain_serial]' "$LAST_RESPONSE")
    264         [ "$PAGE" = '[]' ] && break
    265         if ! jq -ne --argjson p "$PAGE" --argjson o "$OFFSET" --argjson l "$LIMIT" \
    266             'if $l > 0 then ($p == ($p | sort)) and all($p[]; . > $o)
    267              else ($p == ($p | sort | reverse)) and all($p[]; . < $o) end' > /dev/null; then
    268             exit_fail "Fountain page is unordered or includes its cursor"
    269         fi
    270         SEEN=$(jq -nc --argjson a "$SEEN" --argjson b "$PAGE" '$a + $b')
    271         OFFSET=$(jq -r '.[-1]' <<< "$PAGE")
    272     done
    273     if ! jq -ne --argjson a "$SEEN" \
    274         '($a | length) == 105 and ($a | unique | length) == 105' > /dev/null; then
    275         exit_fail "Pagination skipped or repeated fountains"
    276     fi
    277 done
    278 for QUERY in '?limit=invalid' '?offset=invalid' '?offset=-1' '?offset=9223372036854775808'
    279 do
    280     STATUS=$(curl "$MERCHANT_URL/private/fountains$QUERY" \
    281         -H "Authorization: Bearer $BEARER_TOKEN" \
    282         -w "%{http_code}" -s -o "$LAST_RESPONSE")
    283     [ "$STATUS" = "400" ] || exit_fail "Expected 400 for $QUERY, got $STATUS"
    284 done
    285 for ID in "${PAGINATION_IDS[@]}"
    286 do
    287     STATUS=$(curl "$MERCHANT_URL/private/fountains/$ID" -X DELETE \
    288         -H "Authorization: Bearer $BEARER_TOKEN" \
    289         -w "%{http_code}" -s -o "$LAST_RESPONSE")
    290     [ "$STATUS" = "204" ] || exit_fail "Failed to remove pagination fixture"
    291 done
    292 echo "Ok" >&2
    293 
    294 #
    295 # LIST AND INSPECT
    296 #
    297 echo -n "Listing fountains..." >&2
    298 STATUS=$(curl "$MERCHANT_URL/private/fountains" \
    299     -H "Authorization: Bearer $BEARER_TOKEN" \
    300     -w "%{http_code}" \
    301     -s \
    302     -o "$LAST_RESPONSE")
    303 if [ "$STATUS" != "200" ]
    304 then
    305     cat "$LAST_RESPONSE" >&2
    306     exit_fail "Expected '200 OK' response. Got instead $STATUS"
    307 fi
    308 COUNT=$(jq -e '.fountains | length' < "$LAST_RESPONSE")
    309 if [ "$COUNT" != "1" ]
    310 then
    311     exit_fail "Expected 1 fountain in list, got $COUNT"
    312 fi
    313 echo "Ok" >&2
    314 
    315 echo -n "Inspecting fountain..." >&2
    316 STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \
    317     -H "Authorization: Bearer $BEARER_TOKEN" \
    318     -w "%{http_code}" \
    319     -s \
    320     -o "$LAST_RESPONSE")
    321 if [ "$STATUS" != "200" ]
    322 then
    323     cat "$LAST_RESPONSE" >&2
    324     exit_fail "Expected '200 OK' response. Got instead $STATUS"
    325 fi
    326 DESC=$(jq -e -r .description < "$LAST_RESPONSE")
    327 if [ "$DESC" != "campaign-reference-42" ]
    328 then
    329     exit_fail "Expected description 'campaign-reference-42', got $DESC"
    330 fi
    331 GRANTS=$(jq -e '.grants | length' < "$LAST_RESPONSE")
    332 if [ "$GRANTS" != "2" ]
    333 then
    334     exit_fail "Expected 2 grants, got $GRANTS"
    335 fi
    336 if jq -e '.fountain_secret' < "$LAST_RESPONSE" > /dev/null 2>&1
    337 then
    338     exit_fail "Fountain secret must never be returned by the private API"
    339 fi
    340 echo "Ok" >&2
    341 
    342 echo -n "Inspecting unknown fountain fails..." >&2
    343 STATUS=$(curl "$MERCHANT_URL/private/fountains/does-not-exist" \
    344     -H "Authorization: Bearer $BEARER_TOKEN" \
    345     -w "%{http_code}" \
    346     -s \
    347     -o "$LAST_RESPONSE")
    348 if [ "$STATUS" != "404" ]
    349 then
    350     cat "$LAST_RESPONSE" >&2
    351     exit_fail "Expected '404' response. Got instead $STATUS"
    352 fi
    353 echo "Ok" >&2
    354 
    355 #
    356 # WALLET INFO ENDPOINT
    357 #
    358 echo -n "Fetching fountain info with bearer secret..." >&2
    359 STATUS=$(curl "$MERCHANT_URL/fountain/info" \
    360     -H "Authorization: Bearer $FOUNTAIN_SECRET" \
    361     -w "%{http_code}" \
    362     -s \
    363     -o "$LAST_RESPONSE")
    364 if [ "$STATUS" != "200" ]
    365 then
    366     cat "$LAST_RESPONSE" >&2
    367     exit_fail "Expected '200 OK' response. Got instead $STATUS"
    368 fi
    369 GRANTS=$(jq -e '.grants | length' < "$LAST_RESPONSE")
    370 if [ "$GRANTS" != "2" ]
    371 then
    372     exit_fail "Expected 2 grants in info, got $GRANTS"
    373 fi
    374 # At least one issue key must be offered, and at most one per slot of
    375 # the grant's key window (slots whose validity period is covered by an
    376 # already-listed key share that key, so fewer is legitimate).
    377 DISCOUNT_KEYS=$(jq -e '.grants[] | select(.token_family_slug == "test-discount") | .token_family.keys | length' < "$LAST_RESPONSE")
    378 if [ "$DISCOUNT_KEYS" -lt 1 ] || [ "$DISCOUNT_KEYS" -gt 3 ]
    379 then
    380     cat "$LAST_RESPONSE" >&2
    381     exit_fail "Expected 1..3 issue keys for test-discount, got $DISCOUNT_KEYS"
    382 fi
    383 KEYS=$(jq -e '.grants[] | select(.token_family_slug == "test-subscription") | .token_family.keys | length' < "$LAST_RESPONSE")
    384 if [ "$KEYS" -lt 1 ] || [ "$KEYS" -gt 2 ]
    385 then
    386     cat "$LAST_RESPONSE" >&2
    387     exit_fail "Expected 1..2 issue keys for test-subscription, got $KEYS"
    388 fi
    389 # Issue keys must be distinct.
    390 UNIQUE_KEYS=$(jq -e '[.grants[] | select(.token_family_slug == "test-discount") | .token_family.keys[]] | unique | length' < "$LAST_RESPONSE")
    391 if [ "$UNIQUE_KEYS" != "$DISCOUNT_KEYS" ]
    392 then
    393     cat "$LAST_RESPONSE" >&2
    394     exit_fail "Duplicate issue keys advertised: $DISCOUNT_KEYS listed, $UNIQUE_KEYS distinct"
    395 fi
    396 echo "Ok" >&2
    397 
    398 echo -n "Fetching fountain info again is idempotent..." >&2
    399 STATUS=$(curl "$MERCHANT_URL/fountain/info" \
    400     -H "Authorization: Bearer $FOUNTAIN_SECRET" \
    401     -w "%{http_code}" \
    402     -s \
    403     -o "$LAST_RESPONSE")
    404 if [ "$STATUS" != "200" ]
    405 then
    406     cat "$LAST_RESPONSE" >&2
    407     exit_fail "Expected '200 OK' response. Got instead $STATUS"
    408 fi
    409 # Re-polling must be idempotent: no additional keys may be minted.
    410 KEYS=$(jq -e '.grants[] | select(.token_family_slug == "test-discount") | .token_family.keys | length' < "$LAST_RESPONSE")
    411 if [ "$KEYS" != "$DISCOUNT_KEYS" ]
    412 then
    413     cat "$LAST_RESPONSE" >&2
    414     exit_fail "Re-poll changed issue key count: was $DISCOUNT_KEYS, now $KEYS"
    415 fi
    416 echo "Ok" >&2
    417 
    418 echo -n "Fetching fountain info with bad secret fails..." >&2
    419 STATUS=$(curl "$MERCHANT_URL/fountain/info" \
    420     -H "Authorization: Bearer 0000000000000000000000000000000000000000000000000000" \
    421     -w "%{http_code}" \
    422     -s \
    423     -o "$LAST_RESPONSE")
    424 if [ "$STATUS" != "401" ]
    425 then
    426     cat "$LAST_RESPONSE" >&2
    427     exit_fail "Expected '401' response. Got instead $STATUS"
    428 fi
    429 echo "Ok" >&2
    430 
    431 echo -n "Fetching fountain info without secret fails..." >&2
    432 STATUS=$(curl "$MERCHANT_URL/fountain/info" \
    433     -w "%{http_code}" \
    434     -s \
    435     -o "$LAST_RESPONSE")
    436 if [ "$STATUS" != "401" ]
    437 then
    438     cat "$LAST_RESPONSE" >&2
    439     exit_fail "Expected '401' response. Got instead $STATUS"
    440 fi
    441 echo "Ok" >&2
    442 
    443 #
    444 # WITHDRAW VALIDATION ERRORS
    445 #
    446 echo -n "Withdrawing with bad secret fails..." >&2
    447 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \
    448     -X POST \
    449     -d "{\"fountain_secret\": \"0000000000000000000000000000000000000000000000000000\", \"grants\": []}" \
    450     -w "%{http_code}" \
    451     -s \
    452     -o "$LAST_RESPONSE")
    453 if [ "$STATUS" != "401" ]
    454 then
    455     cat "$LAST_RESPONSE" >&2
    456     exit_fail "Expected '401' response. Got instead $STATUS"
    457 fi
    458 echo "Ok" >&2
    459 
    460 echo -n "Withdrawing for ungranted family fails..." >&2
    461 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \
    462     -X POST \
    463     -d "{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": [{\"token_family_slug\": \"no-such-family\", \"envelopes\": []}]}" \
    464     -w "%{http_code}" \
    465     -s \
    466     -o "$LAST_RESPONSE")
    467 if [ "$STATUS" != "409" ]
    468 then
    469     cat "$LAST_RESPONSE" >&2
    470     exit_fail "Expected '409' response. Got instead $STATUS"
    471 fi
    472 echo "Ok" >&2
    473 
    474 echo -n "Withdrawing outside the key window fails..." >&2
    475 FUTURE="{\"t_s\": $(date +%s -d "+200 days")}"
    476 # A real envelope is required: an empty "envelopes" array is rejected as a
    477 # malformed request before the key window is ever resolved.
    478 ENVELOPE="{\"cipher\": \"RSA\", \"rsa_blinded_planchet\": \"04\"}"
    479 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \
    480     -X POST \
    481     -d "{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": [{\"token_family_slug\": \"test-discount\", \"valid_at\": $FUTURE, \"envelopes\": [$ENVELOPE]}]}" \
    482     -w "%{http_code}" \
    483     -s \
    484     -o "$LAST_RESPONSE")
    485 # Check the error code as well: an unknown grant also answers 409, and would
    486 # otherwise hide a request that never reached the key window check.
    487 if [ "$STATUS" != "409" ] || ! jq -e '.code == 2951' "$LAST_RESPONSE" > /dev/null
    488 then
    489     cat "$LAST_RESPONSE" >&2
    490     exit_fail "Expected '409' TOKEN_KEY_SLOT_OUTSIDE_WINDOW. Got instead $STATUS"
    491 fi
    492 echo "Ok" >&2
    493 
    494 echo -n "Withdrawing without envelopes fails..." >&2
    495 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \
    496     -X POST \
    497     -d "{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": [{\"token_family_slug\": \"test-discount\", \"envelopes\": []}]}" \
    498     -w "%{http_code}" \
    499     -s \
    500     -o "$LAST_RESPONSE")
    501 if [ "$STATUS" != "400" ]
    502 then
    503     cat "$LAST_RESPONSE" >&2
    504     exit_fail "Expected '400' response. Got instead $STATUS"
    505 fi
    506 echo "Ok" >&2
    507 
    508 #
    509 # PATCH
    510 #
    511 echo -n "Wrong envelope cipher is a bad request and does not consume quota..." >&2
    512 STATUS=$(curl "$MERCHANT_URL/private/fountains" \
    513     -X POST -H "Authorization: Bearer $BEARER_TOKEN" \
    514     -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}]}' \
    515     -w "%{http_code}" -s -o "$LAST_RESPONSE")
    516 if [ "$STATUS" != "200" ]
    517 then
    518     exit_fail "Expected 200 creating cipher-validation fountain, got $STATUS"
    519 fi
    520 CIPHER_FOUNTAIN_ID=$(jq -er .fountain_id "$LAST_RESPONSE")
    521 CIPHER_FOUNTAIN_SECRET=$(jq -er .fountain_secret "$LAST_RESPONSE")
    522 # The family uses RSA. These zero-valued CS fields are structurally valid,
    523 # so rejection must come from matching the envelope against the issue key.
    524 CS_ZERO=0000000000000000000000000000000000000000000000000000
    525 CS_ENVELOPE=$(jq -nc --arg z "$CS_ZERO" \
    526     '{cipher:"CS",cs_nonce:$z,cs_blinded_c0:$z,cs_blinded_c1:$z}')
    527 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST \
    528     -d "{\"fountain_secret\":\"$CIPHER_FOUNTAIN_SECRET\",\"grants\":[{\"token_family_slug\":\"test-discount\",\"envelopes\":[$CS_ENVELOPE]}]}" \
    529     -w "%{http_code}" -s -o "$LAST_RESPONSE")
    530 if [ "$STATUS" != "400" ] || ! jq -e '.code == 26' "$LAST_RESPONSE" > /dev/null
    531 then
    532     cat "$LAST_RESPONSE" >&2
    533     exit_fail "Expected 400 GENERIC_PARAMETER_MALFORMED for wrong cipher, got $STATUS"
    534 fi
    535 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST \
    536     -d "{\"fountain_secret\":\"$CIPHER_FOUNTAIN_SECRET\",\"grants\":[{\"token_family_slug\":\"test-discount\",\"envelopes\":[$ENVELOPE]}]}" \
    537     -w "%{http_code}" -s -o "$LAST_RESPONSE")
    538 if [ "$STATUS" != "200" ]
    539 then
    540     cat "$LAST_RESPONSE" >&2
    541     exit_fail "Wrong-cipher request consumed quota: valid withdrawal returned $STATUS"
    542 fi
    543 STATUS=$(curl "$MERCHANT_URL/private/fountains/$CIPHER_FOUNTAIN_ID" \
    544     -X DELETE -H "Authorization: Bearer $BEARER_TOKEN" \
    545     -w "%{http_code}" -s -o "$LAST_RESPONSE")
    546 if [ "$STATUS" != "204" ]
    547 then
    548     exit_fail "Expected 204 deleting cipher-validation fountain, got $STATUS"
    549 fi
    550 echo "Ok" >&2
    551 
    552 echo -n "Fountain quotas are restricted to nonnegative INT8 values..." >&2
    553 # Keep the large integer literals as text: passing them through jq arithmetic
    554 # can round INT64_MAX and accidentally test a different input.
    555 for QUOTAS in \
    556     '"tokens_per_period_limit":0,"tokens_per_period_stash":0' \
    557     '"tokens_per_period_limit":9223372036854775807,"tokens_per_period_stash":9223372036854775807'
    558 do
    559     QUOTA_GRANT="{\"token_family_slug\":\"test-discount\",$QUOTAS,\"key_window_size\":0}"
    560     STATUS=$(curl "$MERCHANT_URL/private/fountains" \
    561         -X POST -H "Authorization: Bearer $BEARER_TOKEN" \
    562         -d "{\"description\":\"quota-bounds\",\"poll_freq\":{\"d_us\":60000000},\"grants\":[$QUOTA_GRANT]}" \
    563         -w "%{http_code}" -s -o "$LAST_RESPONSE")
    564     if [ "$STATUS" != "200" ]
    565     then
    566         cat "$LAST_RESPONSE" >&2
    567         exit_fail "Expected 200 for valid quota boundary, got $STATUS"
    568     fi
    569     QUOTA_ID=$(jq -er .fountain_id "$LAST_RESPONSE")
    570     STATUS=$(curl "$MERCHANT_URL/private/fountains/$QUOTA_ID" \
    571         -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" \
    572         -d "{\"grants\":[$QUOTA_GRANT]}" \
    573         -w "%{http_code}" -s -o "$LAST_RESPONSE")
    574     if [ "$STATUS" != "204" ]
    575     then
    576         exit_fail "Expected 204 patching valid quota boundary, got $STATUS"
    577     fi
    578     for BAD_QUOTAS in \
    579         '"tokens_per_period_limit":-1,"tokens_per_period_stash":0' \
    580         '"tokens_per_period_limit":0,"tokens_per_period_stash":-1' \
    581         '"tokens_per_period_limit":-1,"tokens_per_period_stash":-1' \
    582         '"tokens_per_period_limit":-9223372036854775808,"tokens_per_period_stash":0' \
    583         '"tokens_per_period_limit":9223372036854775808,"tokens_per_period_stash":0'
    584     do
    585         BAD_GRANT="{\"token_family_slug\":\"test-discount\",$BAD_QUOTAS,\"key_window_size\":0}"
    586         for METHOD in POST PATCH
    587         do
    588             QUOTA_URL="$MERCHANT_URL/private/fountains"
    589             if [ "$METHOD" = PATCH ]
    590             then
    591                 QUOTA_URL="$QUOTA_URL/$QUOTA_ID"
    592             fi
    593             STATUS=$(curl "$QUOTA_URL" -X "$METHOD" \
    594                 -H "Authorization: Bearer $BEARER_TOKEN" \
    595                 -d "{\"description\":\"quota-bounds\",\"poll_freq\":{\"d_us\":60000000},\"grants\":[$BAD_GRANT]}" \
    596                 -w "%{http_code}" -s -o "$LAST_RESPONSE")
    597             if [ "$STATUS" != "400" ]
    598             then
    599                 cat "$LAST_RESPONSE" >&2
    600                 exit_fail "Expected 400 for $METHOD with $BAD_QUOTAS, got $STATUS"
    601             fi
    602         done
    603     done
    604     STATUS=$(curl "$MERCHANT_URL/private/fountains/$QUOTA_ID" \
    605         -X DELETE -H "Authorization: Bearer $BEARER_TOKEN" \
    606         -w "%{http_code}" -s -o "$LAST_RESPONSE")
    607     if [ "$STATUS" != "204" ]
    608     then
    609         exit_fail "Expected 204 deleting quota test fountain, got $STATUS"
    610     fi
    611 done
    612 echo "Ok" >&2
    613 
    614 echo -n "Offset families advertise covering keys and allow withdrawal..." >&2
    615 OFFSET_NOW=$(date +%s)
    616 OFFSET_START=$((OFFSET_NOW - 172800))
    617 OFFSET_END=$((OFFSET_NOW + 172800))
    618 STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies" \
    619     -X POST -H "Authorization: Bearer $BEARER_TOKEN" \
    620     -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}}" \
    621     -w "%{http_code}" -s -o "$LAST_RESPONSE")
    622 if [ "$STATUS" != "204" ]
    623 then
    624     cat "$LAST_RESPONSE" >&2
    625     exit_fail "Expected 204 creating offset family, got $STATUS"
    626 fi
    627 STATUS=$(curl "$MERCHANT_URL/private/fountains" \
    628     -X POST -H "Authorization: Bearer $BEARER_TOKEN" \
    629     -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}]}' \
    630     -w "%{http_code}" -s -o "$LAST_RESPONSE")
    631 if [ "$STATUS" != "200" ]
    632 then
    633     exit_fail "Expected 200 creating offset fountain, got $STATUS"
    634 fi
    635 OFFSET_SECRET=$(jq -er .fountain_secret "$LAST_RESPONSE")
    636 OFFSET_ID=$(jq -er .fountain_id "$LAST_RESPONSE")
    637 STATUS=$(curl "$MERCHANT_URL/fountain/info" \
    638     -H "Authorization: Bearer $OFFSET_SECRET" \
    639     -w "%{http_code}" -s -o "$LAST_RESPONSE")
    640 if [ "$STATUS" != "200" ] || ! jq -e --argjson now "$OFFSET_NOW" \
    641     '.grants[0].token_family.keys as $keys |
    642      ($keys | length) == 3 and
    643      $keys[0].signature_validity_start.t_s <= $now and
    644      $keys[0].signature_validity_end.t_s >= $now and
    645      all(range(1;3); . as $i |
    646          $keys[$i].signature_validity_start.t_s <= $keys[$i-1].signature_validity_end.t_s + 1 and
    647          $keys[$i].signature_validity_end.t_s > $keys[$i-1].signature_validity_end.t_s)' \
    648     "$LAST_RESPONSE" > /dev/null
    649 then
    650     cat "$LAST_RESPONSE" >&2
    651     exit_fail "Offset family did not advertise a covering key window"
    652 fi
    653 OFFSET_SLOTS=$(jq -r '.grants[0].token_family.keys[].signature_validity_start.t_s' "$LAST_RESPONSE")
    654 for SLOT in $OFFSET_SLOTS
    655 do
    656     STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST \
    657         -d "{\"fountain_secret\":\"$OFFSET_SECRET\",\"grants\":[{\"token_family_slug\":\"test-offset\",\"valid_at\":{\"t_s\":$SLOT},\"envelopes\":[$ENVELOPE]}]}" \
    658         -w "%{http_code}" -s -o "$LAST_RESPONSE")
    659     if [ "$STATUS" != "200" ]
    660     then
    661         cat "$LAST_RESPONSE" >&2
    662         exit_fail "Expected 200 withdrawing advertised offset key, got $STATUS"
    663     fi
    664 done
    665 STATUS=$(curl "$MERCHANT_URL/private/fountains/$OFFSET_ID" \
    666     -X DELETE -H "Authorization: Bearer $BEARER_TOKEN" \
    667     -w "%{http_code}" -s -o "$LAST_RESPONSE")
    668 if [ "$STATUS" != "204" ]
    669 then
    670     exit_fail "Expected 204 deleting offset fountain, got $STATUS"
    671 fi
    672 echo "Ok" >&2
    673 
    674 echo -n "Future keys are bounded by durations from now, not the family start..." >&2
    675 # One-hour duration and one-minute granularity distinguish which interval
    676 # bounds prefetching. Start halfway through the first future duration.
    677 FIRST_START=$(date +%s -d "+30 minutes")
    678 FAMILY_REQUEST=$(jq -nc --argjson start "$FIRST_START" \
    679     '{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}}')
    680 STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies" \
    681     -X POST -H "Authorization: Bearer $BEARER_TOKEN" -d "$FAMILY_REQUEST" \
    682     -w "%{http_code}" -s -o "$LAST_RESPONSE")
    683 if [ "$STATUS" != "204" ]
    684 then
    685     exit_fail "Expected 204 creating future token family, got $STATUS"
    686 fi
    687 STATUS=$(curl "$MERCHANT_URL/private/fountains" \
    688     -X POST -H "Authorization: Bearer $BEARER_TOKEN" \
    689     -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}]}' \
    690     -w "%{http_code}" -s -o "$LAST_RESPONSE")
    691 if [ "$STATUS" != "200" ]
    692 then
    693     exit_fail "Expected 200 creating future fountain, got $STATUS"
    694 fi
    695 FUTURE_SECRET=$(jq -er .fountain_secret "$LAST_RESPONSE")
    696 FUTURE_ID=$(jq -er .fountain_id "$LAST_RESPONSE")
    697 STATUS=$(curl "$MERCHANT_URL/fountain/info" \
    698     -H "Authorization: Bearer $FUTURE_SECRET" \
    699     -w "%{http_code}" -s -o "$LAST_RESPONSE")
    700 if [ "$STATUS" != "200" ] || ! jq -e '.grants[0].token_family.keys == []' "$LAST_RESPONSE" > /dev/null
    701 then
    702     exit_fail "Zero-size window advertised a future key"
    703 fi
    704 for VALID_AT in null "$(date +%s)" "$FIRST_START"
    705 do
    706     WITHDRAW_REQUEST=$(jq -nc --arg secret "$FUTURE_SECRET" --argjson at "$VALID_AT" \
    707         --argjson envelope "$ENVELOPE" \
    708         '{fountain_secret:$secret,grants:[({token_family_slug:"test-future",envelopes:[$envelope]} + (if $at == null then {} else {valid_at:{t_s:$at}} end))]}')
    709     STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST -d "$WITHDRAW_REQUEST" \
    710         -w "%{http_code}" -s -o "$LAST_RESPONSE")
    711     if [ "$STATUS" != "409" ] || ! jq -e '.code == 2951' "$LAST_RESPONSE" > /dev/null
    712     then
    713         cat "$LAST_RESPONSE" >&2
    714         exit_fail "Expected 409 for a future family with window 0, got $STATUS"
    715     fi
    716 done
    717 for WINDOW in 1 2
    718 do
    719     PATCH_REQUEST=$(jq -nc --argjson window "$WINDOW" \
    720         '{grants:[{token_family_slug:"test-future",tokens_per_period_limit:3,tokens_per_period_stash:1,key_window_size:$window}]}')
    721     STATUS=$(curl "$MERCHANT_URL/private/fountains/$FUTURE_ID" \
    722         -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" -d "$PATCH_REQUEST" \
    723         -w "%{http_code}" -s -o "$LAST_RESPONSE")
    724     if [ "$STATUS" != "204" ]
    725     then
    726         exit_fail "Expected 204 changing future window, got $STATUS"
    727     fi
    728     STATUS=$(curl "$MERCHANT_URL/fountain/info" \
    729         -H "Authorization: Bearer $FUTURE_SECRET" \
    730         -w "%{http_code}" -s -o "$LAST_RESPONSE")
    731     HORIZON=$(($(date +%s) + WINDOW * 3600))
    732     if [ "$STATUS" != "200" ] || ! jq -e --argjson count "$WINDOW" --argjson horizon "$HORIZON" \
    733         '(.grants[0].token_family.keys | length) == $count and
    734          all(.grants[0].token_family.keys[]; .signature_validity_start.t_s <= $horizon and .signature_validity_end.t_s <= ($horizon + 3600))' \
    735         "$LAST_RESPONSE" > /dev/null
    736     then
    737         cat "$LAST_RESPONSE" >&2
    738         exit_fail "Future window did not respect its duration-based horizon"
    739     fi
    740     SLOT_START=$(jq -er '.grants[0].token_family.keys[-1].signature_validity_start.t_s' "$LAST_RESPONSE")
    741     WITHDRAW_REQUEST=$(jq -nc --arg secret "$FUTURE_SECRET" --argjson at "$SLOT_START" \
    742         --argjson envelope "$ENVELOPE" \
    743         '{fountain_secret:$secret,grants:[{token_family_slug:"test-future",valid_at:{t_s:$at},envelopes:[$envelope]}]}')
    744     STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST -d "$WITHDRAW_REQUEST" \
    745         -w "%{http_code}" -s -o "$LAST_RESPONSE")
    746     if [ "$STATUS" != "200" ]
    747     then
    748         exit_fail "Expected 200 for advertised future key, got $STATUS"
    749     fi
    750     # The next key would start outside the horizon, even though this
    751     # family began later than now. Neither info nor withdraw may offer it.
    752     WITHDRAW_REQUEST=$(jq --argjson at "$((SLOT_START + 3601))" \
    753         '.grants[0].valid_at = {t_s:$at}' <<< "$WITHDRAW_REQUEST")
    754     STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST -d "$WITHDRAW_REQUEST" \
    755         -w "%{http_code}" -s -o "$LAST_RESPONSE")
    756     if [ "$STATUS" != "409" ] || ! jq -e '.code == 2951' "$LAST_RESPONSE" > /dev/null
    757     then
    758         exit_fail "Expected 409 beyond the duration-based horizon, got $STATUS"
    759     fi
    760 done
    761 # Even with a nonzero window, a family starting four durations ahead
    762 # must not shift the horizon to its own first key.
    763 FAR_START=$(date +%s -d "+4 hours")
    764 FAMILY_REQUEST=$(jq --argjson start "$FAR_START" \
    765     '.slug = "test-far-future" | .valid_after = {t_s:$start}' <<< "$FAMILY_REQUEST")
    766 STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies" \
    767     -X POST -H "Authorization: Bearer $BEARER_TOKEN" -d "$FAMILY_REQUEST" \
    768     -w "%{http_code}" -s -o "$LAST_RESPONSE")
    769 if [ "$STATUS" != "204" ]
    770 then
    771     exit_fail "Expected 204 creating far-future family, got $STATUS"
    772 fi
    773 PATCH_REQUEST=$(jq '.grants[0].token_family_slug = "test-far-future"' <<< "$PATCH_REQUEST")
    774 STATUS=$(curl "$MERCHANT_URL/private/fountains/$FUTURE_ID" \
    775     -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" -d "$PATCH_REQUEST" \
    776     -w "%{http_code}" -s -o "$LAST_RESPONSE")
    777 if [ "$STATUS" != "204" ]
    778 then
    779     exit_fail "Expected 204 changing granted family, got $STATUS"
    780 fi
    781 STATUS=$(curl "$MERCHANT_URL/fountain/info" \
    782     -H "Authorization: Bearer $FUTURE_SECRET" \
    783     -w "%{http_code}" -s -o "$LAST_RESPONSE")
    784 if [ "$STATUS" != "200" ] || ! jq -e '.grants[0].token_family.keys == []' "$LAST_RESPONSE" > /dev/null
    785 then
    786     exit_fail "Far-future family shifted the advertised horizon"
    787 fi
    788 WITHDRAW_REQUEST=$(jq --argjson at "$FAR_START" \
    789     '.grants[0].token_family_slug = "test-far-future" | .grants[0].valid_at = {t_s:$at}' <<< "$WITHDRAW_REQUEST")
    790 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST -d "$WITHDRAW_REQUEST" \
    791     -w "%{http_code}" -s -o "$LAST_RESPONSE")
    792 if [ "$STATUS" != "409" ] || ! jq -e '.code == 2951' "$LAST_RESPONSE" > /dev/null
    793 then
    794     exit_fail "Expected 409 for far-future family, got $STATUS"
    795 fi
    796 echo "Ok" >&2
    797 
    798 echo -n "Saving a two-family withdrawal for replay..." >&2
    799 REPLAY_REQUEST="{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": [{\"token_family_slug\": \"test-discount\", \"envelopes\": [$ENVELOPE]}, {\"token_family_slug\": \"test-subscription\", \"envelopes\": [$ENVELOPE]}]}"
    800 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \
    801     -X POST -d "$REPLAY_REQUEST" \
    802     -w "%{http_code}" -s -o "$LAST_RESPONSE")
    803 if [ "$STATUS" != "200" ] || ! jq -e '.grants | length == 2' "$LAST_RESPONSE" > /dev/null
    804 then
    805     cat "$LAST_RESPONSE" >&2
    806     exit_fail "Expected a successful two-family withdrawal, got $STATUS"
    807 fi
    808 REPLAY_RESPONSE=$(jq -cS . "$LAST_RESPONSE")
    809 echo "Ok" >&2
    810 
    811 echo -n "Patching fountain (drop subscription grant)..." >&2
    812 STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \
    813     -X PATCH \
    814     -H "Authorization: Bearer $BEARER_TOKEN" \
    815     -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}]}" \
    816     -w "%{http_code}" \
    817     -s \
    818     -o "$LAST_RESPONSE")
    819 if [ "$STATUS" != "204" ]
    820 then
    821     cat "$LAST_RESPONSE" >&2
    822     exit_fail "Expected '204' response. Got instead $STATUS"
    823 fi
    824 STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \
    825     -H "Authorization: Bearer $BEARER_TOKEN" \
    826     -w "%{http_code}" \
    827     -s \
    828     -o "$LAST_RESPONSE")
    829 if [ "$STATUS" != "200" ]
    830 then
    831     cat "$LAST_RESPONSE" >&2
    832     exit_fail "Expected '200 OK' response. Got instead $STATUS"
    833 fi
    834 DESC=$(jq -e -r .description < "$LAST_RESPONSE")
    835 if [ "$DESC" != "campaign-reference-43" ]
    836 then
    837     exit_fail "Expected patched description, got $DESC"
    838 fi
    839 GRANTS=$(jq -e '.grants | length' < "$LAST_RESPONSE")
    840 if [ "$GRANTS" != "1" ]
    841 then
    842     exit_fail "Expected 1 grant after patch, got $GRANTS"
    843 fi
    844 echo "Ok" >&2
    845 
    846 echo -n "Withdrawing for dropped grant fails..." >&2
    847 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \
    848     -X POST \
    849     -d "{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": [{\"token_family_slug\": \"test-subscription\", \"envelopes\": []}]}" \
    850     -w "%{http_code}" \
    851     -s \
    852     -o "$LAST_RESPONSE")
    853 if [ "$STATUS" != "409" ]
    854 then
    855     cat "$LAST_RESPONSE" >&2
    856     exit_fail "Expected '409' response. Got instead $STATUS"
    857 fi
    858 echo "Ok" >&2
    859 
    860 echo -n "Patching unknown fountain fails..." >&2
    861 STATUS=$(curl "$MERCHANT_URL/private/fountains/does-not-exist" \
    862     -X PATCH \
    863     -H "Authorization: Bearer $BEARER_TOKEN" \
    864     -d "{\"description\": \"nope\"}" \
    865     -w "%{http_code}" \
    866     -s \
    867     -o "$LAST_RESPONSE")
    868 if [ "$STATUS" != "404" ]
    869 then
    870     cat "$LAST_RESPONSE" >&2
    871     exit_fail "Expected '404' response. Got instead $STATUS"
    872 fi
    873 echo "Ok" >&2
    874 
    875 #
    876 # DELETE
    877 #
    878 echo -n "Replaying the complete response after token family deletion..." >&2
    879 STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies/test-subscription" \
    880     -X DELETE -H "Authorization: Bearer $BEARER_TOKEN" \
    881     -w "%{http_code}" -s -o "$LAST_RESPONSE")
    882 if [ "$STATUS" != "204" ]
    883 then
    884     cat "$LAST_RESPONSE" >&2
    885     exit_fail "Expected token family deletion to return 204, got $STATUS"
    886 fi
    887 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \
    888     -X POST -d "$REPLAY_REQUEST" \
    889     -w "%{http_code}" -s -o "$LAST_RESPONSE")
    890 if [ "$STATUS" != "200" ] || [ "$(jq -cS . "$LAST_RESPONSE")" != "$REPLAY_RESPONSE" ]
    891 then
    892     cat "$LAST_RESPONSE" >&2
    893     exit_fail "Family deletion changed the original withdrawal response ($STATUS)"
    894 fi
    895 echo "Ok" >&2
    896 
    897 echo -n "Deleting fountain..." >&2
    898 STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \
    899     -X DELETE \
    900     -H "Authorization: Bearer $BEARER_TOKEN" \
    901     -w "%{http_code}" \
    902     -s \
    903     -o "$LAST_RESPONSE")
    904 if [ "$STATUS" != "204" ]
    905 then
    906     cat "$LAST_RESPONSE" >&2
    907     exit_fail "Expected '204' response. Got instead $STATUS"
    908 fi
    909 echo "Ok" >&2
    910 
    911 echo -n "Fountain info after deletion fails..." >&2
    912 STATUS=$(curl "$MERCHANT_URL/fountain/info" \
    913     -H "Authorization: Bearer $FOUNTAIN_SECRET" \
    914     -w "%{http_code}" \
    915     -s \
    916     -o "$LAST_RESPONSE")
    917 if [ "$STATUS" != "401" ]
    918 then
    919     cat "$LAST_RESPONSE" >&2
    920     exit_fail "Expected '401' response. Got instead $STATUS"
    921 fi
    922 echo "Ok" >&2
    923 
    924 echo -n "Withdrawal after deletion fails..." >&2
    925 STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \
    926     -X POST \
    927     -d "{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": []}" \
    928     -w "%{http_code}" -s -o "$LAST_RESPONSE")
    929 if [ "$STATUS" != "401" ]
    930 then
    931     exit_fail "Expected 401 withdrawing from deleted fountain, got $STATUS"
    932 fi
    933 echo "Ok" >&2
    934 
    935 echo -n "Deleting unknown fountain fails..." >&2
    936 STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \
    937     -X DELETE \
    938     -H "Authorization: Bearer $BEARER_TOKEN" \
    939     -w "%{http_code}" \
    940     -s \
    941     -o "$LAST_RESPONSE")
    942 if [ "$STATUS" != "404" ]
    943 then
    944     cat "$LAST_RESPONSE" >&2
    945     exit_fail "Expected '404' response. Got instead $STATUS"
    946 fi
    947 echo "Ok" >&2
    948 
    949 echo "Test PASSED" >&2
    950 exit 0