diff --git a/backend/scripts/dev-stack.sh b/backend/scripts/dev-stack.sh new file mode 100755 index 0000000..9979ec9 --- /dev/null +++ b/backend/scripts/dev-stack.sh @@ -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" diff --git a/backend/scripts/e2e.sh b/backend/scripts/e2e.sh new file mode 100755 index 0000000..081268e --- /dev/null +++ b/backend/scripts/e2e.sh @@ -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 "" "" "" + 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 -> 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 ]