Files
clean-architecture-backend-…/infra/notification/smoke/notification-smoke.sh
T

110 lines
5.9 KiB
Bash
Executable File

#!/bin/sh
# Notification lane client. One script, three phases, because the handoff lane needs the accept and
# the verify to be the same client talking about the same request id.
#
# ingest accept a request while the platform is INGEST_ONLY, and record its id
# serving accept and expect delivery in the same run
# handoff-verify re-check a request accepted in an earlier phase, after a SERVING restart
#
# The evidence a handoff needs is that the id from phase one is delivered exactly once after the
# restart, on the route frozen at accept — not that some message arrived.
#
# Three things this client does that it did not have to before NTF-INT-008, and each is a fact about
# the platform rather than about the test:
#
# 1. it authenticates. Submission and template publication are ordinary non-public paths, so they
# sit behind the same JWT the rest of the API does. The token is obtained by client credentials
# against the same issuer URL the application validates against — a token from a different URL
# is not the same token.
# 2. it publishes a template first. A submission pins a template id and version and the platform
# refuses one it cannot resolve, so "send a notification" is two calls, not one.
# 3. it addresses a recipient by value. The platform stores contact points encrypted and references
# them by id; the accept endpoint registers or reuses one, so the address never reaches a plan.
set -eu
STATE_FILE="/opt/notification-smoke-state/request-id"
SECRET_FILE="/run/secrets/keycloak-graphql-smoke-client-secret"
BASE_PATH="${APP_BASE_PATH:-/api}"
TEMPLATE_ID="smoke"
RECIPIENT="smoke@example.test"
fail() { echo "notification-smoke: $1" >&2; exit 1; }
token() {
[ -r "${SECRET_FILE}" ] || fail "the client secret was not mounted"
RESPONSE="$(curl -sf -X POST "${KEYCLOAK_ISSUER}/protocol/openid-connect/token" \
-d grant_type=client_credentials \
-d "client_id=${KEYCLOAK_CLIENT_ID}" \
--data-urlencode "client_secret=$(cat "${SECRET_FILE}")")" \
|| fail "client-credentials token request failed"
echo "${RESPONSE}" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p'
}
case "${NOTIFICATION_SMOKE_PHASE}" in
ingest|serving)
ACCESS_TOKEN="$(token)"
[ -n "${ACCESS_TOKEN}" ] || fail "the token response carried no access_token"
# 1. the template. Republishing the same version is the same immutable content, so a lane that
# reruns against a surviving volume is not a different lane; a 409 here means the platform
# holds a version with this id and different content, which is a real failure.
PUBLISH_STATUS="$(curl -s -o /tmp/publish.json -w '%{http_code}' \
-X POST "${APP_BASE_URL}${BASE_PATH}/notification-templates" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H 'Content-Type: application/json' \
-d "{\"templateId\":\"${TEMPLATE_ID}\",\"version\":1,\"channel\":\"EMAIL\",\"locale\":\"en\",
\"slots\":{\"SUBJECT\":\"lane smoke\",\"TEXT_BODY\":\"lane smoke body\"}}")"
case "${PUBLISH_STATUS}" in
201|409) : ;;
401|403) fail "template publication was refused (${PUBLISH_STATUS}); the token lacks notification-template:publish" ;;
*) fail "template publication answered ${PUBLISH_STATUS}: $(cat /tmp/publish.json)" ;;
esac
# 2. the submission.
ACCEPT_STATUS="$(curl -s -o /tmp/accept.json -w '%{http_code}' \
-X POST "${APP_BASE_URL}${BASE_PATH}/notifications" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H 'Content-Type: application/json' \
-d "{\"recipientRef\":\"lane-smoke-recipient\",\"channel\":\"EMAIL\",
\"address\":\"${RECIPIENT}\",\"template\":\"${TEMPLATE_ID}\",\"templateVersion\":1,
\"locale\":\"en\",\"variables\":{},\"category\":\"transactional\"}")"
[ "${ACCEPT_STATUS}" = "202" ] \
|| fail "accept answered ${ACCEPT_STATUS}: $(cat /tmp/accept.json)"
REQUEST_ID="$(sed -n 's/.*"requestId":"\([^"]*\)".*/\1/p' /tmp/accept.json)"
[ -n "${REQUEST_ID}" ] || fail "the accept response carried no requestId"
# Not an `&&` chain: a failed mkdir in one is exempt from `set -e`, so the id went unrecorded
# and the ingest phase still reported success — leaving the handoff phase to fail later about a
# state file "the phases did not share", which describes the symptom and not the cause.
mkdir -p "$(dirname "${STATE_FILE}")" || fail "the state directory is not writable"
echo "${REQUEST_ID}" > "${STATE_FILE}" || fail "the request id could not be recorded"
echo "notification-smoke: accepted ${REQUEST_ID} in ${NOTIFICATION_SMOKE_PHASE}"
;;
handoff-verify)
[ -r "${STATE_FILE}" ] || fail "no request id from the ingest phase; the phases did not share state"
REQUEST_ID="$(cat "${STATE_FILE}")"
;;
*)
fail "unknown phase ${NOTIFICATION_SMOKE_PHASE}"
;;
esac
if [ "${NOTIFICATION_SMOKE_PHASE}" = "ingest" ]; then
# INGEST_ONLY accepts durably and sends nothing. A message here means a worker ran that should not
# have.
COUNT="$(curl -sf "${MAILPIT_BASE_URL}/api/v1/messages?limit=200" 2>/dev/null \
| grep -o '"ID"' | wc -l | tr -d ' ')" || COUNT=0
[ "${COUNT}" = "0" ] || fail "INGEST_ONLY delivered ${COUNT} message(s); no worker should have run"
echo "notification-smoke: ingest stored the request and sent nothing"
exit 0
fi
# serving and handoff-verify: exactly one delivery, and still exactly one after another poll window.
sleep 10
first="$(curl -sf "${MAILPIT_BASE_URL}/api/v1/search?query=smoke%40example.test" | grep -o '"ID"' | wc -l | tr -d ' ')"
[ "${first}" = "1" ] || fail "expected exactly one delivery, saw ${first}"
sleep 15
second="$(curl -sf "${MAILPIT_BASE_URL}/api/v1/search?query=smoke%40example.test" | grep -o '"ID"' | wc -l | tr -d ' ')"
[ "${second}" = "1" ] || fail "a second dispatch window produced ${second} deliveries; at-most-once is broken"
echo "notification-smoke: ${REQUEST_ID} delivered exactly once and stayed that way"