feat(server): mint API + Postgres schema + manual adapter (PR 1)
Source-agnostic license issuance service. FastAPI app fronts a Postgres `licenses` table; the only currently-wired source is `manual` (operator mints via /internal/mint). Gumroad webhook adapter lands in PR 2. Key design points: - Signing reuses src/license/crypto.py via a COPY into the image (single source of truth — blobs minted server-side verify against the same embedded pubkey on the buyer's machine). - Source adapter Protocol (app/adapters/base.py) is the seam for Gumroad / Lemon Squeezy / Stripe in later PRs; Mint API speaks only SaleEvent / RefundEvent. - (source, source_order_id) UNIQUE composite gives idempotent webhook retries without double-mint. - JSONB type uses with_variant(JSON, 'sqlite') so the same models drive both Postgres prod and SQLite tests (no testcontainers dep). - Bearer-token auth on /internal/*; the IP-loopback guard was removed after the docker bridge made it fight legitimate prod traffic (nginx defense + Bearer remain). - Secrets resolved via *_FILE env vars pointing at /run/secrets/<name>, so passwords never appear in `docker inspect`. 21 unit tests (SQLite in-memory, StaticPool) plus a real-Postgres docker-compose smoke test in server/scripts/smoke.sh that builds the image, runs the alembic migration, mints a license, verifies the signature against the host dev pubkey, and checks the DB row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
84
server/scripts/smoke.sh
Executable file
84
server/scripts/smoke.sh
Executable file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# End-to-end smoke test for the license server.
|
||||
#
|
||||
# Builds the API image, brings up Postgres + API, runs the Alembic
|
||||
# migration, mints a license through /internal/mint, verifies the
|
||||
# resulting blob's Ed25519 signature against the dev pubkey, and
|
||||
# confirms the row landed in the DB. Tears everything down at exit.
|
||||
#
|
||||
# Run from the server/ directory: ./scripts/smoke.sh
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
PROJECT=dt-license-smoke
|
||||
COMPOSE=(docker compose -p "$PROJECT" -f compose.test.yml)
|
||||
|
||||
cleanup() {
|
||||
echo "--- Tearing down ---"
|
||||
"${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "--- Building image ---"
|
||||
"${COMPOSE[@]}" build
|
||||
|
||||
echo "--- Starting stack ---"
|
||||
"${COMPOSE[@]}" up -d
|
||||
|
||||
echo "--- Waiting for API health (max 60s) ---"
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf http://127.0.0.1:18090/health 2>/dev/null | grep -q '"status":"ok"'; then
|
||||
echo "API up after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "--- Running migrations ---"
|
||||
"${COMPOSE[@]}" exec -T api alembic upgrade head
|
||||
|
||||
echo "--- Re-checking health post-migration ---"
|
||||
curl -sf http://127.0.0.1:18090/health | tee /dev/stderr | grep -q '"db":"ok"'
|
||||
|
||||
echo "--- POST /internal/mint ---"
|
||||
RESP=$(curl -s -w "\nHTTP=%{http_code}" -X POST http://127.0.0.1:18090/internal/mint \
|
||||
-H "Authorization: Bearer test-admin-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Smoke Test","email":"smoke@example.com","tier":"core","source":"manual"}')
|
||||
echo "$RESP"
|
||||
HTTP_CODE=$(echo "$RESP" | tail -n1 | sed 's/HTTP=//')
|
||||
RESP=$(echo "$RESP" | sed '$d')
|
||||
if [ "$HTTP_CODE" != "201" ]; then
|
||||
echo "MINT FAILED (HTTP $HTTP_CODE)"
|
||||
"${COMPOSE[@]}" logs --tail 50 api
|
||||
exit 1
|
||||
fi
|
||||
echo "$RESP" | python3 -m json.tool | head -8
|
||||
|
||||
BLOB=$(echo "$RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin)["blob"])')
|
||||
|
||||
echo "--- Verifying blob signature against host dev pubkey ---"
|
||||
python3 - <<EOF
|
||||
import sys
|
||||
sys.path.insert(0, "..")
|
||||
from src.license.crypto import decode_blob, verify
|
||||
payload = decode_blob("$BLOB")
|
||||
sig = payload.pop("signature")
|
||||
assert verify(payload, sig), "signature must verify"
|
||||
assert payload["name"] == "Smoke Test"
|
||||
assert payload["email"] == "smoke@example.com"
|
||||
assert payload["tier"] == "core"
|
||||
print("OK: signature verifies, payload matches")
|
||||
EOF
|
||||
|
||||
echo "--- Verifying DB row ---"
|
||||
"${COMPOSE[@]}" exec -T postgres \
|
||||
psql -U dt_test -d dt_test -t -c \
|
||||
"SELECT license_key, email, tier, source FROM licenses;" \
|
||||
| grep -q smoke@example.com
|
||||
|
||||
echo
|
||||
echo "===================================="
|
||||
echo " SMOKE TEST PASSED"
|
||||
echo "===================================="
|
||||
Reference in New Issue
Block a user