merchant

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

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