#!/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"