test(backend): replay the mod's gateway calls as a repeatable e2e check
dev-stack.sh boots accounts/configs/gateway on localhost; e2e.sh then replays the exact request sequences the Java client sends (device link, %config save/load, /me) plus the site-side register/login/confirm and asserts every field the mod reads back. 31 checks, all green.
This commit is contained in:
parent
f51d3bf0f8
commit
5dfbf6c1e8
2 changed files with 205 additions and 0 deletions
45
backend/scripts/dev-stack.sh
Executable file
45
backend/scripts/dev-stack.sh
Executable file
|
|
@ -0,0 +1,45 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Boots accounts-service, configs-service and gateway on localhost for manual
|
||||||
|
# poking and scripts/e2e.sh. Restarts them if they are already running.
|
||||||
|
#
|
||||||
|
# Prerequisites (one-time):
|
||||||
|
# cargo build -p accounts-service -p configs-service -p gateway
|
||||||
|
# a Postgres with accounts_db + configs_db, and MinIO with the
|
||||||
|
# lovisual-avatars-test bucket (see backend/PLAN.md, Task 2).
|
||||||
|
# Dev-only secrets below are fine locally; production reads backend/.env.
|
||||||
|
set -euo pipefail
|
||||||
|
B=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
L=${TMPDIR:-/tmp}/lovisual-stack
|
||||||
|
mkdir -p "$L"
|
||||||
|
|
||||||
|
export JWT_SECRET="local-jwt-secret-local-jwt-secret-1234"
|
||||||
|
export INTERNAL_KEY="local-internal-key-local-internal-key-1"
|
||||||
|
export DATABASE_URL="postgres://lovisual:lovisual@localhost:5432/accounts_db"
|
||||||
|
export CONFIGS_DATABASE_URL="postgres://lovisual:lovisual@localhost:5432/configs_db"
|
||||||
|
export ACCOUNTS_GRPC_URL="http://127.0.0.1:50051"
|
||||||
|
export S3_ENDPOINT="http://localhost:9000"
|
||||||
|
export S3_BUCKET="lovisual-avatars-test"
|
||||||
|
export S3_ACCESS_KEY="minioadmin"
|
||||||
|
export S3_SECRET_KEY="minioadmin"
|
||||||
|
export COOKIE_SECURE="false"
|
||||||
|
# browser-facing avatar prefix served through the gateway (mirrors prod /api/media)
|
||||||
|
export AVATAR_PUBLIC_BASE_URL="http://127.0.0.1:8080/media"
|
||||||
|
|
||||||
|
pkill -f 'target/debug/(accounts-service|configs-service|gateway)' 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
PORT=8081 GRPC_PORT=50051 setsid nohup "$B/target/debug/accounts-service" >"$L/accounts.log" 2>&1 &
|
||||||
|
CONFIGS_PORT=8082 setsid nohup "$B/target/debug/configs-service" >"$L/configs.log" 2>&1 &
|
||||||
|
GATEWAY_PORT=8080 ACCOUNTS_HTTP_URL="http://127.0.0.1:8081" \
|
||||||
|
CONFIGS_HTTP_URL="http://127.0.0.1:8082" SITE_ORIGIN="http://localhost:5173" \
|
||||||
|
setsid nohup "$B/target/debug/gateway" >"$L/gateway.log" 2>&1 &
|
||||||
|
|
||||||
|
up() { [ "$(curl -sf "$1" 2>/dev/null || true)" = "ok" ]; }
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
up http://127.0.0.1:8081/health && up http://127.0.0.1:8082/health && up http://127.0.0.1:8080/health && break
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
for u in 8081 8082 8080; do
|
||||||
|
up "http://127.0.0.1:$u/health" || { echo "port $u did not come up"; tail -5 "$L"/*.log; exit 1; }
|
||||||
|
done
|
||||||
|
echo "accounts+configs+gateway up"
|
||||||
160
backend/scripts/e2e.sh
Executable file
160
backend/scripts/e2e.sh
Executable file
|
|
@ -0,0 +1,160 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# E2E contract check: replays, byte for byte, what the mod sends
|
||||||
|
# (dev-link flow, %config save/load, /me) and what the site sends
|
||||||
|
# (register/login/confirm) against a live gateway, and asserts every shape the
|
||||||
|
# Java client reads back (device_code/user_code/expires_in, 202-pending,
|
||||||
|
# lvd_ tokens, share_code round-trip, 404 + error body, avatar ?v= bust).
|
||||||
|
#
|
||||||
|
# Usage: bash backend/scripts/dev-stack.sh && bash backend/scripts/e2e.sh
|
||||||
|
#
|
||||||
|
# Note: gateway rate limits live in memory, so re-running dev-stack.sh resets
|
||||||
|
# them - otherwise the 3/hour register limit will fail the run.
|
||||||
|
set -uo pipefail
|
||||||
|
GW=http://127.0.0.1:8080
|
||||||
|
JAR=$(mktemp)
|
||||||
|
trap 'rm -f "$JAR"' EXIT
|
||||||
|
pass=0; fail=0
|
||||||
|
ck() { # ck "<name>" "<expected>" "<actual>"
|
||||||
|
if [ "$2" = "$3" ]; then pass=$((pass+1)); printf 'ok %s\n' "$1"
|
||||||
|
else fail=$((fail+1)); printf 'FAIL %s\n expected: %s\n actual: %s\n' "$1" "$2" "$3"; fi
|
||||||
|
}
|
||||||
|
jqv() { jq -r "$1 // null"; }
|
||||||
|
|
||||||
|
# --- mod: POST /device/code -------------------------------------------------
|
||||||
|
resp=$(curl -s -w '\n%{http_code}' -X POST "$GW/device/code" \
|
||||||
|
-H 'Content-Type: application/json' -H 'Accept: application/json' -d '{}')
|
||||||
|
code=$(tail -1 <<<"$resp"); body=$(sed '$d' <<<"$resp")
|
||||||
|
ck "device/code status (2xx)" "2xx" "$([[ "$code" =~ ^2..$ ]] && echo 2xx || echo "$code")"
|
||||||
|
DEVICE_CODE=$(jqv .device_code <<<"$body")
|
||||||
|
USER_CODE=$(jqv .user_code <<<"$body")
|
||||||
|
EXPIRES=$(jqv .expires_in <<<"$body")
|
||||||
|
ck "device_code non-empty" "true" "$([ -n "$DEVICE_CODE" ] && [ "$DEVICE_CODE" != null ] && echo true || echo false)"
|
||||||
|
ck "user_code non-empty" "true" "$([ -n "$USER_CODE" ] && [ "$USER_CODE" != null ] && echo true || echo false)"
|
||||||
|
ck "expires_in numeric" "true" "$([[ "$EXPIRES" =~ ^[0-9]+$ ]] && echo true || echo false)"
|
||||||
|
|
||||||
|
# --- mod: first poll must be 202 (ApiResult.isPending) ----------------------
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$GW/device/token" \
|
||||||
|
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||||
|
-d "{\"device_code\":\"$DEVICE_CODE\"}")
|
||||||
|
ck "device/token pending -> 202" "202" "$code"
|
||||||
|
|
||||||
|
# --- site: register + login + confirm the user code -------------------------
|
||||||
|
EMAIL="e2e-$(date +%s%N)@example.com"
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' -c "$JAR" -X POST "$GW/auth/register" \
|
||||||
|
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||||
|
-d "{\"email\":\"$EMAIL\",\"password\":\"correct-horse-battery-staple\",\"nick\":\"E2E\"}")
|
||||||
|
ck "auth/register status" "201" "$code"
|
||||||
|
|
||||||
|
resp=$(curl -s -w '\n%{http_code}' -c "$JAR" -b "$JAR" -X POST "$GW/auth/login" \
|
||||||
|
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||||
|
-d "{\"email\":\"$EMAIL\",\"password\":\"correct-horse-battery-staple\"}")
|
||||||
|
code=$(tail -1 <<<"$resp"); body=$(sed '$d' <<<"$resp")
|
||||||
|
ck "auth/login status" "200" "$code"
|
||||||
|
ACCESS=$(jqv .access_token <<<"$body")
|
||||||
|
ck "login returns access_token" "true" "$([ -n "$ACCESS" ] && [ "$ACCESS" != null ] && echo true || echo false)"
|
||||||
|
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$GW/device/confirm" \
|
||||||
|
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||||
|
-H "Authorization: Bearer $ACCESS" -d "{\"user_code\":\"$USER_CODE\"}")
|
||||||
|
ck "device/confirm status" "200" "$code"
|
||||||
|
|
||||||
|
# --- mod: poll until linked -------------------------------------------------
|
||||||
|
DEVICE_TOKEN=""
|
||||||
|
for _ in 1 2 3 4 5; do
|
||||||
|
resp=$(curl -s -w '\n%{http_code}' -X POST "$GW/device/token" \
|
||||||
|
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||||
|
-d "{\"device_code\":\"$DEVICE_CODE\"}")
|
||||||
|
code=$(tail -1 <<<"$resp"); body=$(sed '$d' <<<"$resp")
|
||||||
|
if [ "$code" = "200" ]; then DEVICE_TOKEN=$(jqv .device_token <<<"$body"); break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
ck "device/token linked status" "200" "$code"
|
||||||
|
ck "device_token has lvd_ prefix" "true" "$([[ "$DEVICE_TOKEN" == lvd_* ]] && echo true || echo false)"
|
||||||
|
ck "device_code is single-use (second collect -> 404)" "404" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{http_code}' -X POST "$GW/device/token" \
|
||||||
|
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||||
|
-d "{\"device_code\":\"$DEVICE_CODE\"}")"
|
||||||
|
|
||||||
|
# --- mod: GET /me -----------------------------------------------------------
|
||||||
|
resp=$(curl -s -w '\n%{http_code}' -H "Authorization: Bearer $DEVICE_TOKEN" -H 'Accept: application/json' "$GW/me")
|
||||||
|
code=$(tail -1 <<<"$resp"); body=$(sed '$d' <<<"$resp")
|
||||||
|
ck "GET /me status" "200" "$code"
|
||||||
|
ck "/me email matches" "$EMAIL" "$(jqv .email <<<"$body")"
|
||||||
|
|
||||||
|
# --- mod: %config save 1 -> PUT /configs/1 --------------------------------
|
||||||
|
DATA='{"modules":{"Reach":true},"theme":{"accent":"#FF5CC8E7"}}'
|
||||||
|
resp=$(curl -s -w '\n%{http_code}' -X PUT "$GW/configs/1" \
|
||||||
|
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||||
|
-H "Authorization: Bearer $DEVICE_TOKEN" \
|
||||||
|
-d "{\"name\":\"e2e slot\",\"data\":$DATA}")
|
||||||
|
code=$(tail -1 <<<"$resp"); body=$(sed '$d' <<<"$resp")
|
||||||
|
ck "PUT /configs/1 status" "200" "$code"
|
||||||
|
SHARE_CODE=$(jqv .share_code <<<"$body")
|
||||||
|
ck "PUT response carries share_code (8 chars, no 0O1IL)" "true" \
|
||||||
|
"$([[ "$SHARE_CODE" =~ ^[2-9A-HJ-NP-Z]{8}$ ]] && echo true || echo false)"
|
||||||
|
ck "PUT echoes the data back" "$DATA" "$(jqv .data <<<"$body" | jq -c .)"
|
||||||
|
|
||||||
|
# --- mod: GET /configs (slot list) ------------------------------------------
|
||||||
|
resp=$(curl -s -w '\n%{http_code}' -H "Authorization: Bearer $DEVICE_TOKEN" -H 'Accept: application/json' "$GW/configs")
|
||||||
|
code=$(tail -1 <<<"$resp"); body=$(sed '$d' <<<"$resp")
|
||||||
|
ck "GET /configs status" "200" "$code"
|
||||||
|
ck "slot 1 present with our name" "e2e slot" "$(jqv '.[] | select(.slot==1) | .name' <<<"$body")"
|
||||||
|
|
||||||
|
# --- mod: %config load <code> -> GET /configs/shared/{code} (no auth) -----
|
||||||
|
resp=$(curl -s -w '\n%{http_code}' -H 'Accept: application/json' "$GW/configs/shared/$SHARE_CODE")
|
||||||
|
code=$(tail -1 <<<"$resp"); body=$(sed '$d' <<<"$resp")
|
||||||
|
ck "GET /configs/shared/{code} status" "200" "$code"
|
||||||
|
ck "shared name round-trips" "e2e slot" "$(jqv .name <<<"$body")"
|
||||||
|
ck "shared data round-trips" "$DATA" "$(jqv .data <<<"$body" | jq -c .)"
|
||||||
|
|
||||||
|
# --- mod: unknown code -> 404 (ApiResult.isNotFound -> friendly message) ----
|
||||||
|
resp=$(curl -s -w '\n%{http_code}' -H 'Accept: application/json' "$GW/configs/shared/QQQQQQQQ")
|
||||||
|
code=$(tail -1 <<<"$resp"); body=$(sed '$d' <<<"$resp")
|
||||||
|
ck "unknown share code -> 404" "404" "$code"
|
||||||
|
ck "404 body carries error field" "true" \
|
||||||
|
"$([ "$(jqv .error <<<"$body")" != "null" ] && echo true || echo false)"
|
||||||
|
|
||||||
|
# --- easter egg: %config load IDDQD -----------------------------------------
|
||||||
|
resp=$(curl -s -w '\n%{http_code}' -H 'Accept: application/json' "$GW/configs/shared/IDDQD")
|
||||||
|
code=$(tail -1 <<<"$resp"); body=$(sed '$d' <<<"$resp")
|
||||||
|
ck "IDDQD -> 200" "200" "$code"
|
||||||
|
ck "IDDQD is god mode (only ChinaHat on)" "true" \
|
||||||
|
"$(jqv '.data.modules.ChinaHat.enabled // .data.ChinaHat.enabled // "missing"' <<<"$body")"
|
||||||
|
|
||||||
|
# --- site: avatar upload + cache-busted serve (through the gateway) --------
|
||||||
|
PNG=$(mktemp --suffix=.png)
|
||||||
|
python3 -c "
|
||||||
|
import struct,zlib,sys
|
||||||
|
w,h=8,8
|
||||||
|
raw=b''.join(b'\x00'+b'\xff\x00\x00\xff'*w for _ in range(h))
|
||||||
|
def chunk(t,d):
|
||||||
|
c=t+d
|
||||||
|
return struct.pack('>I',len(d))+c+struct.pack('>I',zlib.crc32(c)&0xffffffff)
|
||||||
|
open('$PNG','wb').write(b'\x89PNG\r\n\x1a\n'
|
||||||
|
+chunk(b'IHDR',struct.pack('>IIBBBBB',w,h,8,6,0,0,0))
|
||||||
|
+chunk(b'IDAT',zlib.compress(raw))+chunk(b'IEND',b''))
|
||||||
|
"
|
||||||
|
resp=$(curl -s -w '\n%{http_code}' -X POST "$GW/avatars" \
|
||||||
|
-H "Authorization: Bearer $DEVICE_TOKEN" -H 'Accept: application/json' \
|
||||||
|
-F "file=@$PNG;type=image/png")
|
||||||
|
code=$(tail -1 <<<"$resp"); body=$(sed '$d' <<<"$resp")
|
||||||
|
ck "POST /avatars status" "200" "$code"
|
||||||
|
AVATAR_URL=$(jqv .avatar_url <<<"$body")
|
||||||
|
ck "avatar_url points through the gateway" "true" \
|
||||||
|
"$([[ "$AVATAR_URL" == http://127.0.0.1:8080/media/avatars/*.png ]] && echo true || echo false)"
|
||||||
|
ck "avatar served through the gateway" "200" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{http_code}' "$AVATAR_URL")"
|
||||||
|
resp=$(curl -s -H "Authorization: Bearer $DEVICE_TOKEN" -H 'Accept: application/json' "$GW/me")
|
||||||
|
ck "GET /me avatar_url cache-busted with ?v=" "true" \
|
||||||
|
"$(jq -r '.avatar_url // ""' <<<"$resp" | grep -qE '\?v=[0-9]+$' && echo true || echo false)"
|
||||||
|
rm -f "$PNG"
|
||||||
|
|
||||||
|
# --- CORS: the site's origin must be allowed --------------------------------
|
||||||
|
pref=$(curl -s -o /dev/null -w '%{http_code}' -X OPTIONS "$GW/configs" \
|
||||||
|
-H 'Origin: http://localhost:5173' -H 'Access-Control-Request-Method: GET' \
|
||||||
|
-H 'Access-Control-Request-Headers: authorization')
|
||||||
|
ck "CORS preflight for site origin" "200" "$pref"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "passed=$pass failed=$fail"
|
||||||
|
[ "$fail" -eq 0 ]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue