#!/usr/bin/env bash
# This file is in the public domain.
#
# Regression test for finding 11: '/solve' must not declare the user
# permanently out of options while a PIN retransmission is still available.
#
# A '/challenge' retransmission resets auth_attempts_left to 3, so a
# validation with pin_transmissions_left > 0 can still be completed even
# when both the address budget and the current PIN's guess budget are
# spent.  Answering such a request with "users exhausted all possibilities
# of passing the check" is wrong and the SPA presents it as terminal.
#
# The test drives a validation to address_attempts_left = 0,
# auth_attempts_left = 0, pin_transmissions_left > 0, checks that the
# terminal answer is *not* given, and then completes the validation via a
# retransmission to prove that the user really was not out of options.
#
# Reaching that state exploits that the two budgets are independent: the
# three address attempts of a validation each come with three PIN
# transmissions, so spending every address attempt and every guess on the
# PINs they transmitted still leaves transmissions unspent.
#
# Also a regression test for finding 12: '/solve' must not answer every
# failure with 403 #TALER_EC_CHALLENGER_INVALID_PIN ("the PIN code
# provided is incorrect").  Running out of guesses is reported as 429
# #TALER_EC_CHALLENGER_NO_PIN_ATTEMPTS_LEFT, submitting a PIN before any
# challenge was transmitted is reported as 409
# #TALER_EC_CHALLENGER_NO_CHALLENGE_TRANSMITTED, and 'exhausted' is set on
# the response that consumes the last attempt rather than on the one after
# it.

set -eu

# Exit, with status code "skip" (no 'real' failure)
function exit_skip() {
    echo " SKIP: $1"
    exit 77
}

# Exit, with error message (hard failure)
function exit_fail() {
    echo " FAIL: $@"
    exit 1
}

# Cleanup to run whenever we exit
function cleanup()
{
    for n in $(jobs -p)
    do
        kill $n 2> /dev/null || true
    done
    rm -f "$LAST_RESPONSE" "$FILENAME"
    wait
}

LAST_RESPONSE=$(mktemp responseXXXXXX.log)
FILENAME="test-challenger-exhaustion.txt"

# Install cleanup handler (except for kill -9)
trap cleanup EXIT

export PATH="$PATH:."

echo -n "Testing for jq"
jq -h > /dev/null || exit_skip "jq required"
echo " FOUND"
echo -n "Testing for curl"
curl -h > /dev/null || exit_skip "curl required"
echo " FOUND"
echo -n "Testing for wget"
wget -h > /dev/null || exit_skip "wget required"
echo " FOUND"
echo -n "Testing for challenger-httpd ..."
challenger-httpd -h > /dev/null || exit_skip "challenger-httpd required"
echo " FOUND"

CONF="test-challenger-exhaustion.conf"
BURL="http://localhost:9967"
REDIRECT_URI="http://client.example.com/"
CLIENT_SECRET="secret-token:secret"
CLIENT_ID=1

echo -n "Initialize challenger database ..."
challenger-dbinit -r -c "${CONF}" &> dbinit.log
echo " OK"

echo -n "Add challenger client ..."
challenger-admin -c "${CONF}" -a "${CLIENT_SECRET}" "${REDIRECT_URI}" &> admin.log
echo " OK"

echo -n "Start challenger-httpd ..."
challenger-httpd -L INFO -c "${CONF}" &> httpd.log &

# Wait for challenger to be available
for n in $(seq 1 50)
do
    echo -n "."
    sleep 0.2
    OK=0
    wget --tries=1 --timeout=1 "${BURL}/config" -o /dev/null -O /dev/null >/dev/null || continue
    OK=1
    break
done
if [ 1 != $OK ]
then
    exit_skip "Failed to launch challenger service"
fi
echo " OK"

# Start a validation and get it into the 'address submitted' state.
function new_validation() {
    STATUS=$(curl "${BURL}/setup/${CLIENT_ID}" \
        -H "Authorization: Bearer ${CLIENT_SECRET}" \
        -d '' \
        -w "%{http_code}" -s -o $LAST_RESPONSE)
    if [ "$STATUS" != "200" ]
    then
        exit_fail "/setup: expected 200 OK. Got: $STATUS" $(cat $LAST_RESPONSE)
    fi
    NONCE=$(jq -r .nonce < "$LAST_RESPONSE")
    STATUS=$(curl "${BURL}/authorize/${NONCE}" \
        -G \
        -H "Accept: application/json" \
        --data-urlencode "response_type=code" \
        --data-urlencode "client_id=${CLIENT_ID}" \
        --data-urlencode "redirect_uri=${REDIRECT_URI}" \
        -w "%{http_code}" -s -o $LAST_RESPONSE)
    if [ "$STATUS" != "200" ]
    then
        exit_fail "/authorize: expected 200 OK. Got: $STATUS" $(cat $LAST_RESPONSE)
    fi
}

# submit_address $1=address-suffix.  Leaves the freshly transmitted PIN in
# $PIN, whether a PIN was transmitted at all in $TRANSMITTED and the time
# from which the next transmission is allowed in $RETRANSMISSION_TIME.
function submit_address() {
    rm -f "${FILENAME}"
    STATUS=$(curl "${BURL}/challenge/${NONCE}" \
        -X POST \
        -H "Accept: application/json" \
        --data-urlencode "filename=${FILENAME}" \
        --data-urlencode "note=$1" \
        -w "%{http_code}" -s -o $LAST_RESPONSE)
    if [ "$STATUS" != "200" ]
    then
        exit_fail "/challenge: expected 200 OK. Got: $STATUS" $(cat $LAST_RESPONSE)
    fi
    TRANSMITTED=$(jq -r .transmitted < "$LAST_RESPONSE")
    RETRANSMISSION_TIME=$(jq -r .retransmission_time.t_s < "$LAST_RESPONSE")
    # Every submission here transmits: changing the address refills the
    # transmission budget and clears the cooldown, and the retransmission
    # at the end waits the cooldown out.  Still, only pick up a PIN that
    # was really sent, so that expect_transmitted() below reports the
    # mismatch rather than awk reporting a missing file.
    if [ "$TRANSMITTED" = "true" ]
    then
        PIN=$(awk '{print $5}' < "${FILENAME}")
    fi
}

# expect_transmitted $1=true|false $2=what was expected to (not) transmit
function expect_transmitted() {
    if [ "$TRANSMITTED" != "$1" ]
    then
        exit_fail "/challenge: expected transmitted=$1 for $2. Got: $(cat $LAST_RESPONSE)"
    fi
}

# Sleep until the cooldown of the last transmission has passed, so that the
# next /challenge is guaranteed to (re)transmit.
function await_retransmission() {
    local delta
    delta=$(( RETRANSMISSION_TIME - $(date +%s) + 1 ))
    if [ "${delta}" -gt 0 ]
    then
        sleep "${delta}"
    fi
}

# solve $1=pin; leaves the status in $STATUS
function solve() {
    STATUS=$(curl "${BURL}/solve/${NONCE}" \
        -X POST \
        -H "Accept: application/json" \
        --data-urlencode "pin=$1" \
        -w "%{http_code}" -s -o $LAST_RESPONSE)
}

# Burn all three guesses for the currently transmitted PIN.
function burn_guesses() {
    for i in 1 2 3
    do
        solve "$(( (10#${PIN} + i) % 100000000 ))"
        if [ "$STATUS" = "200" ] || [ "$STATUS" = "302" ]
        then
            exit_fail "/solve accepted a wrong PIN!"
        fi
        # A wrong PIN really is a wrong PIN: 403 + CHALLENGER_INVALID_PIN.
        if [ "$STATUS" != "403" ]
        then
            exit_fail "/solve: expected 403 for a wrong PIN. Got: $STATUS" $(cat $LAST_RESPONSE)
        fi
        CODE=$(jq -r .code < "$LAST_RESPONSE")
        if [ "$CODE" != "9758" ]
        then
            exit_fail "/solve: expected code 9758 (INVALID_PIN) for a wrong PIN. Got: $CODE"
        fi
        EXHAUSTED=$(jq -r .exhausted < "$LAST_RESPONSE")
        LEFT=$(jq -r .auth_attempts_left < "$LAST_RESPONSE")
        if [ "$i" = "3" ]
        then
            # this very response consumed the last attempt
            if [ "$LEFT" != "0" ] || [ "$EXHAUSTED" != "true" ]
            then
                exit_fail "/solve: expected auth_attempts_left=0 and exhausted=true on the response consuming the last attempt. Got: $(cat $LAST_RESPONSE)"
            fi
        else
            if [ "$EXHAUSTED" != "false" ]
            then
                exit_fail "/solve: unexpected exhausted=true with ${LEFT} attempts left"
            fi
        fi
    done
}

echo -n "PIN submitted before any challenge was transmitted ..."
new_validation
solve "12345678"
if [ "$(jq -r .no_challenge < "$LAST_RESPONSE")" != "true" ]
then
    exit_fail "/solve: expected no_challenge=true. Got: $(cat $LAST_RESPONSE)"
fi
CODE=$(jq -r .code < "$LAST_RESPONSE")
if [ "$CODE" = "9758" ]
then
    exit_fail "/solve claims 'the PIN code provided is incorrect' although no PIN was ever sent"
fi
if [ "$STATUS" != "409" ] || [ "$CODE" != "9761" ]
then
    exit_fail "/solve: expected 409 with code 9761 (NO_CHALLENGE_TRANSMITTED). Got: $STATUS / $CODE" $(cat $LAST_RESPONSE)
fi
echo " OK"

echo -n "Driving validation to 'no addresses, no guesses, PIN left' ..."
new_validation
# Each of the three address submissions spends one of the three address
# attempts and, because an address change refills the transmission budget
# and clears the cooldown, transmits a PIN for the new address -- which
# sets auth_attempts_left to 3, and burn_guesses spends those three
# guesses again.  After the third round the validation has no addresses
# and no guesses left, but PIN transmissions to spare.
for a in one two three
do
    submit_address "${a}"
    expect_transmitted "true" "the submission of address '${a}'"
    burn_guesses
done
# Fail loudly rather than testing something weaker should the budget
# semantics change again.
ADDRESSES_LEFT=$(jq -r .addresses_left < "$LAST_RESPONSE")
TRANSMISSIONS_LEFT=$(jq -r .pin_transmissions_left < "$LAST_RESPONSE")
ATTEMPTS_LEFT=$(jq -r .auth_attempts_left < "$LAST_RESPONSE")
if [ "$ADDRESSES_LEFT" != "0" ] || \
   [ "$ATTEMPTS_LEFT" != "0" ] || \
   [ "$TRANSMISSIONS_LEFT" -lt 1 ]
then
    exit_fail "expected addresses_left=0, auth_attempts_left=0 and pin_transmissions_left>0. Got: $(cat $LAST_RESPONSE)"
fi
echo " OK (addresses_left=${ADDRESSES_LEFT}, auth_attempts_left=${ATTEMPTS_LEFT}, pin_transmissions_left=${TRANSMISSIONS_LEFT})"

echo -n "Wrong PIN must not be answered as terminal exhaustion ..."
solve "$(( (10#${PIN} + 1) % 100000000 ))"
if grep -q "exhausted all possibilities" "$LAST_RESPONSE"
then
    exit_fail "/solve claims the user is out of options while a PIN retransmission is still available: $(cat $LAST_RESPONSE)"
fi
echo " OK"

echo -n "Exhaustion must be reported like /challenge reports it ..."
CODE=$(jq -r .code < "$LAST_RESPONSE")
if [ "$STATUS" != "429" ] || [ "$CODE" != "9766" ]
then
    exit_fail "/solve: expected 429 with code 9766 (NO_PIN_ATTEMPTS_LEFT) once the guesses are spent. Got: $STATUS / $CODE" $(cat $LAST_RESPONSE)
fi
if [ "$(jq -r .exhausted < "$LAST_RESPONSE")" != "true" ]
then
    exit_fail "/solve: expected exhausted=true. Got: $(cat $LAST_RESPONSE)"
fi
echo " OK"

# Prove the user really was not out of options: retransmit and solve.
echo -n "Retransmitting the PIN ..."
await_retransmission
submit_address "three"
expect_transmitted "true" "the retransmission"
echo " OK"

echo -n "Solving with the retransmitted PIN ${PIN} ..."
solve "${PIN}"
if [ "$STATUS" != "200" ]
then
    exit_fail "/solve: expected 200 OK after retransmission. Got: $STATUS" $(cat $LAST_RESPONSE)
fi
TYPE=$(jq -r .type < "$LAST_RESPONSE")
if [ "$TYPE" != "completed" ]
then
    exit_fail "/solve: expected 'completed'. Got: $TYPE" $(cat $LAST_RESPONSE)
fi
echo " OK"

# Now the other end of the scale: a validation with nothing left at all.
echo -n "Driving a validation to terminal exhaustion ..."
new_validation
for a in one two three
do
    submit_address "${a}"
    expect_transmitted "true" "the submission of address '${a}'"
    burn_guesses
done
# Spend what remains of the PIN transmission budget.  burn_guesses() cannot
# be reused for the last round: once addresses, transmissions and guesses
# are all spent, the response that consumes the final guess is already the
# terminal one, so it is a 429 rather than the 403 burn_guesses() expects.
while true
do
    TRANSMISSIONS_LEFT=$(jq -r .pin_transmissions_left < "$LAST_RESPONSE")
    if [ "${TRANSMISSIONS_LEFT}" -le 0 ]
    then
        break
    fi
    await_retransmission
    submit_address "three"
    expect_transmitted "true" "a retransmission while spending the budget"
    for i in 1 2 3
    do
        solve "$(( (10#${PIN} + i) % 100000000 ))"
    done
done
echo " OK"

# Since v8 this uses the same InvalidPinResponse shape as every other
# /solve failure, so that a client only ever parses one body.
echo -n "Terminal exhaustion uses the InvalidPinResponse shape ..."
solve "11111111"
CODE=$(jq -r .code < "$LAST_RESPONSE")
if [ "$STATUS" != "429" ] || [ "$CODE" != "9757" ]
then
    exit_fail "/solve: expected 429 with code 9757 (TOO_MANY_ATTEMPTS) once nothing is left. Got: $STATUS / $CODE" $(cat $LAST_RESPONSE)
fi
if [ "$(jq -r .type < "$LAST_RESPONSE")" != "pending" ]
then
    exit_fail "/solve: terminal 429 must use the InvalidPinResponse shape. Got: $(cat $LAST_RESPONSE)"
fi
for field in addresses_left pin_transmissions_left auth_attempts_left
do
    if [ "$(jq -r ".${field}" < "$LAST_RESPONSE")" != "0" ]
    then
        exit_fail "/solve: expected ${field}=0 on the terminal 429. Got: $(cat $LAST_RESPONSE)"
    fi
done
if [ "$(jq -r .exhausted < "$LAST_RESPONSE")" != "true" ] || \
   [ "$(jq -r .no_challenge < "$LAST_RESPONSE")" != "false" ]
then
    exit_fail "/solve: expected exhausted=true and no_challenge=false. Got: $(cat $LAST_RESPONSE)"
fi
echo " OK"

exit 0
