#!/usr/bin/env bash
#
# THE PRODUCTION IMAGE, SERVED — the checks round 805 ran by hand the first
# time that image's file set was ever booted, kept so they run on every CI
# build (job `web-prod-image`, INV-186).
#
#   bin/prod-image-smoke.sh https://localhost:8443
#   bin/prod-image-smoke.sh https://localhost:8444 --restored
#
# `--restored` (round 808) is for a database restored from a backup of one this
# script already ran against: the workspace and its owner exist, so it signs in
# to them rather than creating them — the restore drill's proof that what was
# backed up is what comes back.
#
# Point it at a TLS proxy in front of the production image — two replicas of
# it, as production runs, with the proxy spreading requests between them — on a
# database its migrations have built and its scheduler has ticked on once. It needs curl and
# python3 (for the second step's code, round 891), and it fails on the first
# thing that is wrong, saying what.
#
# What round 805 found, so what this holds:
#   - the container could not start (Caddy's state belonged to root);
#   - behind the proxy every page answered 500 (the scheme, INV-184);
#   - 32 of 81 routes answered 500 anyway (templates/ and assets/ were not in
#     the image);
#   - an oversized upload got PHP's warning printed into a 200 (INV-185) and the
#     edge's 413 never fired;
#   - and (round 806) with two replicas, sign-up, sign-in and the dashboard
#     failed: each container kept its own sessions (INV-187).
#
# It expects one workspace, `smoke-care`, with an @example.org owner, made
# beforehand by bin/prod-image-workspace.php: since round 933 the sign-up page
# is closed in production, and this checks that it stays closed. Run it
# against a database made for it, never one anybody uses.

set -uo pipefail

base=${1:?usage: prod-image-smoke.sh BASE_URL [--restored]}
restored=0
[ "${2:-}" = --restored ] && restored=1
here=$(cd "$(dirname "$0")/.." && pwd)
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
jar=$work/cookies
failures=0
# Sam's second-factor key, kept where the later CI steps (the restore drill,
# the load test) can read it; overridable for a run outside CI.
key_file=${SMOKE_KEY_FILE:-${RUNNER_TEMP:-/tmp}/smoke-care-second-factor.key}

fail() { echo "FAIL  $*"; failures=$((failures + 1)); }
pass() { echo "ok    $*"; }
status() { curl -sk -o "$work/body" -w '%{http_code}' -m 30 "$@"; }
csrf() { grep -oE 'name="_csrf" value="[^"]+"' "$work/body" | head -1 | sed -E 's/.*value="([^"]+)"/\1/'; }

# Every GET route, from the route table itself, with placeholders filled by
# values that match nothing: a bogus token must be a 404, never a 500.
routes() {
    grep -oE "Route::get\('[^']+'\)" "$here/config/common/routes.php" \
        | sed -E "s/Route::get\('([^']+)'\)/\1/" | sort -u \
        | sed -E -e 's/\{token\}/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/g' \
                 -e "s/\{tenant\}/$1/g" \
                 -e 's/\{[a-zA-Z]+Id:\\d\+\}/1/g' \
                 -e 's/\{documentId\}/nodoc/g' \
                 -e 's/\{version:.*$/2026-01.v1/'
}

crawl() {
    local who=$1 workspace=$2 count=0
    shift 2
    while read -r path; do
        count=$((count + 1))
        code=$(status "$@" "$base$path")
        case $code in
            5*|000) fail "$who GET $path answered $code" ;;
        esac
    done < <(routes "$workspace")
    [ "$count" -gt 50 ] || fail "$who crawl found only $count routes"
    pass "$who: $count GET routes, none answered 5xx"
}

expect() {
    local want=$1 path=$2 got
    got=$(status -b "$jar" "$base$path")
    [ "$got" = "$want" ] && pass "GET $path $got" || fail "GET $path answered $got, expected $want"
}

# --- signed out -------------------------------------------------------------
crawl "signed out" no-such-workspace
for path in / /login /signup /privacy /platform/login /my-data /health/scheduler /health/app; do
    expect 200 "$path"
done

# --- a session, end to end, through the proxy -------------------------------
if [ "$restored" -eq 1 ]; then
    status -c "$jar" -b "$jar" "$base/login" >/dev/null
    pass "restored: the workspace and its owner are expected to exist already"
else
    # Round 933 (Q72 (a), Q116 (a)): nobody makes a workspace from the public
    # page during the pilot. It says so and offers no form, and a sign-up
    # posted anyway, with a good token from the same session, is refused.
    status -c "$jar" -b "$jar" "$base/signup" >/dev/null
    grep -q 'NexHire is in a private pilot' "$work/body" && ! grep -q 'name="workspace"' "$work/body" \
        && pass "the sign-up page says the pilot is private and offers no form" \
        || fail "the sign-up page offers a form in production"
    status -c "$jar" -b "$jar" "$base/login" >/dev/null
    token=$(csrf)
    code=$(status -c "$jar" -b "$jar" --data-urlencode "_csrf=$token" \
        --data-urlencode 'organisationName=Never Care Ltd' --data-urlencode 'workspace=never-care' \
        --data-urlencode 'plan=pro_starter' --data-urlencode 'yourName=Nell Never' \
        --data-urlencode 'email=nell@example.org' --data-urlencode 'password=a long enough passphrase 43!' \
        "$base/signup")
    [ "$code" = 403 ] && pass "a posted sign-up is refused" || fail "a posted sign-up answered $code"
fi

# Round 1032 (ASVS 5.0 V3.3.1, V3.3.3): the session cookie is `__Host-`
# prefixed, which a browser keeps only when it is Secure, for `/` and with no
# Domain, so no other host under the same name can set or read it.
grep -E '__Host-nexhire' "$jar" >/dev/null || fail "no __Host-nexhire session cookie was set"
cookie=$(curl -sk -D - -o /dev/null "$base/login" | tr -d '\r' | grep -i '^set-cookie: __Host-nexhire=')
case $cookie in
    *Secure*HttpOnly*|*HttpOnly*Secure*) pass "the session cookie is Secure and HttpOnly" ;;
    *) fail "session cookie flags: $cookie" ;;
esac
grep -qiE '; *path=/(;|$)' <<<"$cookie" && ! grep -qi '; *domain=' <<<"$cookie" \
    && pass "the session cookie is for / on this host alone (no Domain)" \
    || fail "the session cookie is not host-only: $cookie"

# Round 1024 (ASVS 5.0 V3.4): what the web server serves by itself carries the
# headers every page PHP answers carries, and a page PHP answered keeps its own
# policy. Without the `?` in docker/Caddyfile both policies are sent (measured),
# and a browser obeys both, so the page's scripts would be blocked.
headers() { curl -sk -D - -o /dev/null -m 30 "$@" | tr -d '\r'; }
for path in /favicon.svg /robots.txt; do
    got=$(headers "$base$path")
    missing=
    for want in 'Strict-Transport-Security: max-age=15768000' 'X-Content-Type-Options: nosniff' \
        "Content-Security-Policy: default-src 'none'" 'Referrer-Policy: strict-origin-when-cross-origin'; do
        grep -qiF "$want" <<<"$got" || missing="$missing [$want]"
    done
    [ -z "$missing" ] && pass "GET $path, served by the web server itself, carries the security headers" \
        || fail "GET $path lacks$missing"
done
policies=$(headers "$base/login" | grep -i '^content-security-policy:')
[ "$(grep -c . <<<"$policies")" = 1 ] && grep -q "'nonce-" <<<"$policies" \
    && pass "a page PHP answers keeps its own policy, and only one" \
    || fail "the sign-in page's policy was replaced or doubled: $policies"

# Round 891 (ADR 0032 (4)): Sam is an Owner, so the password alone signs
# nobody in. The first sign-in sets a second factor up and keeps its key in
# $key_file; the restore drill and the load test sign in with it afterwards.
"$here/bin/prod-image-sign-in.sh" "$base" smoke-care sam@example.org 'a long enough passphrase 42!' "$key_file" "$jar" \
    | grep -q '^signed in$' && pass "signed in, second step included" || fail "sign-in did not get past the second step"
code=$(status -b "$jar" "$base/dashboard")
[ "$code" = 200 ] && grep -q '<title>Dashboard' "$work/body" && pass "the dashboard, signed in" || fail "dashboard answered $code"

crawl "signed in" smoke-care -b "$jar"

# --- a body over the limits -------------------------------------------------
# Over the edge's cap: refused on its declared length with the static page,
# before PHP. Between post_max_size and the cap: the application's own page.
# Either way a 413 with words in it, and never a word from PHP itself.
head -c 21000000 /dev/zero > "$work/21mb"
head -c 18000000 /dev/zero > "$work/18mb"
apply=$base/careers/smoke-care/jobs/1/apply
# Byte for byte, because the application's own page has the same title: a
# match on words would pass with the edge refusal gone and PHP doing the work
# the edge exists to spare it (round 805's control d, which survived exactly
# that way until this compared bytes).
code=$(status -X POST --data-binary "@$work/21mb" "$apply")
[ "$code" = 413 ] && cmp -s "$work/body" "$here/public/413.html" && pass "21 MB refused at the edge with its page" \
    || fail "21 MB answered $code, and not with the edge's page: $(head -c 200 "$work/body")"
code=$(status -X POST --data-binary "@$work/18mb" "$apply")
[ "$code" = 413 ] && grep -q '<title>That file is too big' "$work/body" && pass "18 MB refused by the application with its page" \
    || fail "18 MB answered $code: $(head -c 200 "$work/body")"
grep -qi 'warning' "$work/body" && fail "PHP wrote into the response: $(grep -i -m1 warning "$work/body")"

echo
if [ "$failures" -gt 0 ]; then
    echo "$failures check(s) failed"
    exit 1
fi
echo "every check passed"
