feat(#30): Gate B [10/10]: Deploy compose + WOO_BASE_URL (OPS-1, GC-5)

- deploy/.env.example: placeholders only (no real URLs, proper comments)
- frontend/Dockerfile: Node 20 stub server for compose build
- deploy/tests/test-compose-health.sh: OPS-1 compose health smoke test
- deploy/tests/test-woo-base-url.sh: GC-4/5/FUND-25 validation suite
- backend/app/api/settings.py: PUT rejects woo_base_url (422), consumer keys (422),
  text() wrapping for SQLAlchemy 2.x, key primary key fix
- backend/tests/contract/test_woo_base_url.py: 8 contract tests for GC-4/GC-5

Tests: 39/39 backend tests pass (8 new, 31 existing green)
Deploy: 5/5 shell tests pass (Docker unavailable for compose-up path)
This commit is contained in:
grokbot_ty
2026-09-07 08:18:25 +00:00
committed by grokbot_ty
parent 5019b10584
commit 68d236961a
6 changed files with 448 additions and 14 deletions
+16 -6
View File
@@ -14,6 +14,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import text
from app.config import Settings from app.config import Settings
from app.db.engine import get_session from app.db.engine import get_session
@@ -26,11 +27,15 @@ router = APIRouter()
class SettingsUpdate(BaseModel): class SettingsUpdate(BaseModel):
"""Partial settings update payload. """Partial settings update payload.
Woo consumer keys are rejected. Woo consumer keys and woo_base_url are rejected (woo_base_url is compose-only).
Consumer keys are included as fields so they can be explicitly rejected.
""" """
woo_base_url: str | None = None
woo_wp_username: str | None = None woo_wp_username: str | None = None
woo_app_password: str | None = None woo_app_password: str | None = None
woo_consumer_key: str | None = None
woo_consumer_secret: str | None = None
inference_base_url: str | None = None inference_base_url: str | None = None
inference_api_key: str | None = None inference_api_key: str | None = None
inference_model: str | None = None inference_model: str | None = None
@@ -95,7 +100,7 @@ async def _build_settings_view(app_settings: Settings = Depends(_get_app_setting
key_bytes = get_aes_key(app_settings.aes_key) key_bytes = get_aes_key(app_settings.aes_key)
session = await get_session() session = await get_session()
rows = await session.execute("SELECT key, value_enc FROM settings") rows = await session.execute(text("SELECT key, value_enc FROM settings"))
all_rows = rows.all() all_rows = rows.all()
await session.close() await session.close()
@@ -174,13 +179,18 @@ async def update_settings(
Rejects consumer key fields. Returns 422 for invalid woo_create_status. Rejects consumer key fields. Returns 422 for invalid woo_create_status.
""" """
# Reject consumer keys # Reject consumer keys and woo_base_url (compose-only, read-only in UI)
payload = update.model_dump(exclude_unset=True) payload = update.model_dump(exclude_unset=True)
if "woo_consumer_key" in payload or "woo_consumer_secret" in payload: if "woo_consumer_key" in payload or "woo_consumer_secret" in payload:
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail="WooCommerce consumer keys are not supported. Use WordPress application passwords.", detail="WooCommerce consumer keys are not supported. Use WordPress application passwords.",
) )
if "woo_base_url" in payload:
raise HTTPException(
status_code=422,
detail="woo_base_url is set via compose environment, not editable via settings.",
)
# Validate woo_create_status # Validate woo_create_status
if update.woo_create_status is not None and update.woo_create_status not in ( if update.woo_create_status is not None and update.woo_create_status not in (
@@ -203,17 +213,17 @@ async def update_settings(
ct = encrypt_setting(key_bytes, str(value)) ct = encrypt_setting(key_bytes, str(value))
setting = await session.execute( setting = await session.execute(
"SELECT id FROM settings WHERE key = :key", {"key": field} text("SELECT key FROM settings WHERE key = :key"), {"key": field}
) )
existing = setting.fetchone() existing = setting.fetchone()
if existing: if existing:
await session.execute( await session.execute(
"UPDATE settings SET value_enc = :val, updated_at = :now WHERE key = :key", text("UPDATE settings SET value_enc = :val, updated_at = :now WHERE key = :key"),
{"val": ct, "now": now, "key": field}, {"val": ct, "now": now, "key": field},
) )
else: else:
await session.execute( await session.execute(
"INSERT INTO settings (key, value_enc, updated_at) VALUES (:key, :val, :now)", text("INSERT INTO settings (key, value_enc, updated_at) VALUES (:key, :val, :now)"),
{"key": field, "val": ct, "now": now}, {"key": field, "val": ct, "now": now},
) )
+140
View File
@@ -0,0 +1,140 @@
"""GC-4/GC-5: WOO_BASE_URL contract tests.
GC-4: WOO_BASE_URL is compose-only HTTPS; Settings GET returns it read-only;
PUT rejects write attempts with 422.
GC-5: FastAPI refuses to start when WOO_BASE_URL is missing or http://.
FUND-8: App factory creates fresh instance per test.
FUND-9: TestClient context manager manages lifespan.
FUND-10: dependency_overrides cleared after every test.
"""
import pytest
from fastapi.testclient import TestClient
from app.config import Settings
from app.main import create_app
class TestWooBaseUrlValidation:
"""GC-5: FastAPI refuses invalid WOO_BASE_URL at startup."""
def test_refuses_http_url(self) -> None:
"""GC-5: Settings.validate() raises ValueError for http:// URL."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="http://insecure.example.com",
)
with pytest.raises(ValueError, match="(?i)https"):
settings.validate()
def test_refuses_missing_url(self) -> None:
"""GC-5: Settings.validate() raises ValueError when woo_base_url is None."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
)
with pytest.raises(ValueError):
settings.validate()
def test_accepts_https_url(self) -> None:
"""GC-5: Settings.validate() accepts HTTPS URL."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://secure.example.com",
)
settings.validate() # Should not raise
class TestWooBaseUrlReadOnly:
"""GC-4: Settings endpoints handle woo_base_url correctly."""
def test_get_settings_returns_woo_base_url(self) -> None:
"""GC-4: GET /api/v1/settings returns woo_base_url from compose env."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://store.example.com",
)
app = create_app(settings)
with TestClient(app) as client:
response = client.get("/api/v1/settings")
assert response.status_code == 200
data = response.json()
assert data["woo_base_url"] == "https://store.example.com"
app.dependency_overrides.clear()
def test_put_settings_rejects_woo_base_url(self) -> None:
"""GC-4: PUT /api/v1/settings rejects woo_base_url field with 422."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://store.example.com",
)
app = create_app(settings)
with TestClient(app) as client:
response = client.put(
"/api/v1/settings",
json={"woo_base_url": "https://hacked.example.com"},
)
assert response.status_code == 422
data = response.json()
assert "detail" in data
app.dependency_overrides.clear()
def test_put_settings_allows_other_fields(self) -> None:
"""GC-4: PUT /api/v1/settings still accepts other settings fields."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://store.example.com",
)
app = create_app(settings)
with TestClient(app) as client:
response = client.put(
"/api/v1/settings",
json={"woo_create_status": "publish"},
)
assert response.status_code == 200
data = response.json()
assert data["woo_create_status"] == "publish"
app.dependency_overrides.clear()
def test_get_settings_never_returns_app_password(self) -> None:
"""GC-4: GET /api/v1/settings never returns woo_app_password."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://store.example.com",
)
app = create_app(settings)
with TestClient(app) as client:
response = client.get("/api/v1/settings")
assert response.status_code == 200
data = response.json()
assert "woo_app_password" not in data
app.dependency_overrides.clear()
def test_put_settings_rejects_consumer_keys(self) -> None:
"""GC-4: PUT /api/v1/settings rejects consumer key fields with 422."""
settings = Settings(
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
woo_base_url="https://store.example.com",
)
app = create_app(settings)
with TestClient(app) as client:
response = client.put(
"/api/v1/settings",
json={
"woo_consumer_key": "ck_test",
"woo_consumer_secret": "cs_test",
},
)
assert response.status_code == 422
app.dependency_overrides.clear()
+13 -8
View File
@@ -1,11 +1,16 @@
# Placeholders only. Copy to deploy/.env (chmod 600). Never commit real values. # SlabUploader deploy environment — placeholders only.
# Generate SLAB_AES_KEY: openssl rand -base64 32 # Copy to deploy/.env and fill real values. Do not commit real values.
# chmod 600 deploy/.env
SLAB_AES_KEY= # AES-GCM master key: 32 bytes base64. Generated via: openssl rand -base64 32
APP_HOSTNAME=slab.example.local SLAB_AES_KEY=CHANGE_ME_32_BYTE_BASE64_KEY
# WooCommerce store URL (HTTPS only). Deploy-time SoT, not editable in app.
WOO_BASE_URL=https://your-store.example.com
# Hostname for Caddy TLS (LAN/Tailscale). Used in Caddyfile.fragment.
APP_HOSTNAME=slab.local
# Bind ports (loopback only — Caddy is the TLS edge).
FRONTEND_PORT=18080 FRONTEND_PORT=18080
FASTAPI_PORT=18081 FASTAPI_PORT=18081
# Plan of record: Woo store URL. HTTPS only. Not editable in Settings.
# Server must reject http:// at startup.
WOO_BASE_URL=https://www.whidbeywoodstore.com
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# OPS-1: Compose health smoke test.
# Usage: run from repo root: bash deploy/tests/test-compose-health.sh
# Expected: compose up, wait for services, curl health endpoint, assert 200 + ok.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
COMPOSE_FILE="$REPO_ROOT/deploy/docker-compose.yml"
ENV_FILE="$REPO_ROOT/deploy/.env"
APP_HOSTNAME="${APP_HOSTNAME:-}"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
pass() { echo -e "${GREEN}PASS${NC} $*"; }
fail() { echo -e "${RED}FAIL${NC} $*"; exit 1; }
warn() { echo -e "${YELLOW}WARN${NC} $*"; }
# ── Prerequisites ──────────────────────────────────────────────────────────────
if ! command -v docker &>/dev/null; then
warn "docker not found — skipping compose up, continuing with validation-only tests"
SKIP_COMPOSE=1
else
SKIP_COMPOSE=0
fi
if ! command -v curl &>/dev/null; then
fail "curl is required for health check"
fi
# ── Compose up ─────────────────────────────────────────────────────────────────
if [ "$SKIP_COMPOSE" -eq 0 ]; then
pass "Docker available, proceeding with compose up"
if [ ! -f "$ENV_FILE" ]; then
fail ".env file not found at $ENV_FILE. Copy .env.example and fill values."
fi
pass "Starting compose stack..."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build 2>&1 || fail "docker compose up failed"
# Wait for services to be healthy
pass "Waiting for services to be ready (up to 120s)..."
MAX_WAIT=120
WAITED=0
while [ $WAITED -lt $MAX_WAIT ]; do
STATUS=$(docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" ps --format json 2>/dev/null | grep -c '"status":"running"' || true)
if [ "$STATUS" -eq 2 ]; then
pass "Both frontend and fastapi running"
break
fi
sleep 5
WAITED=$((WAITED + 5))
done
if [ "$STATUS" -ne 2 ]; then
warn "Services did not reach running state within ${MAX_WAIT}s — continuing with direct health check"
fi
fi
# ── Health check ───────────────────────────────────────────────────────────────
HEALTH_URL="https://${APP_HOSTNAME}/api/health"
if [ -z "$APP_HOSTNAME" ]; then
HEALTH_URL="http://127.0.0.1:18081/api/health"
warn "APP_HOSTNAME not set — using direct fastapi port for health check"
fi
pass "Checking health endpoint: $HEALTH_URL"
HTTP_CODE=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 15 "$HEALTH_URL" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
BODY=$(curl -sk --max-time 15 "$HEALTH_URL" 2>/dev/null)
STATUS=$(echo "$BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null || echo "")
if [ "$STATUS" = "ok" ]; then
pass "OPS-1: Health check passed (HTTP 200, status=ok)"
else
fail "OPS-1: Health check returned unexpected status: $STATUS"
fi
else
fail "OPS-1: Health check failed with HTTP $HTTP_CODE"
fi
echo ""
echo "=== Compose health test complete ==="
exit 0
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env bash
# GC-4/5, FUND-25: WOO_BASE_URL validation tests.
# Tests WOO_BASE_URL is compose-only HTTPS, startup refusal of http://,
# and settings read-only behavior.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
BACKEND_DIR="$REPO_ROOT/backend"
APP_HOSTNAME="${APP_HOSTNAME:-slab.local}"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
pass() { echo -e "${GREEN}PASS${NC} $*"; }
fail() { echo -e "${RED}FAIL${NC} $*"; exit 1; }
warn() { echo -e "${YELLOW}WARN${NC} $*"; }
TOTAL=0
PASSED=0
FAILED=0
check() {
local label="$1"
shift
TOTAL=$((TOTAL + 1))
if "$@"; then
PASSED=$((PASSED + 1))
pass "$label"
else
FAILED=$((FAILED + 1))
fail "$label"
fi
}
# ── GC-5: FastAPI refuses http:// WOO_BASE_URL ────────────────────────────────
# Test via Python: import and validate Settings
test_gc5_http_rejected() {
cd "$BACKEND_DIR"
python3 -c "
from app.config import Settings
import sys
try:
s = Settings(aes_key='dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk', woo_base_url='http://test.example.com')
s.validate()
sys.exit(1) # Should have raised ValueError
except ValueError as e:
if 'https' in str(e).lower():
sys.exit(0)
sys.exit(1)
"
}
# ── GC-5: FastAPI refuses missing WOO_BASE_URL ────────────────────────────────
test_gc5_missing_rejected() {
cd "$BACKEND_DIR"
python3 -c "
from app.config import Settings
import sys
try:
s = Settings(aes_key='dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk')
s.validate()
sys.exit(1)
except ValueError as e:
if 'required' in str(e).lower() or 'missing' in str(e).lower():
sys.exit(0)
sys.exit(1)
"
}
# ── GC-5: HTTPS WOO_BASE_URL is accepted ──────────────────────────────────────
test_gc5_https_accepted() {
cd "$BACKEND_DIR"
python3 -c "
from app.config import Settings
s = Settings(aes_key='dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk', woo_base_url='https://test.example.com')
s.validate()
assert s.woo_base_url == 'https://test.example.com'
print('OK')
"
}
# ── GC-4: Settings GET returns woo_base_url ───────────────────────────────────
# Test via FastAPI TestClient (requires compose or local server).
# If no server is running, we test via Python directly.
test_gc4_settings_get_returns_url() {
cd "$BACKEND_DIR"
python3 -c "
from app.config import Settings
from app.main import create_app
from fastapi.testclient import TestClient
settings = Settings(
aes_key='dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk',
woo_base_url='https://test.example.com',
)
app = create_app(settings)
with TestClient(app) as client:
r = client.get('/api/v1/settings')
assert r.status_code == 200, f'Expected 200, got {r.status_code}'
data = r.json()
assert 'woo_base_url' in data, 'Missing woo_base_url in settings response'
assert data['woo_base_url'] == 'https://test.example.com', f'Unexpected woo_base_url: {data[\"woo_base_url\"]}'
app.dependency_overrides.clear()
print('OK')
"
}
# ── GC-4: Settings PUT rejects woo_base_url ───────────────────────────────────
test_gc4_put_rejects_url() {
cd "$BACKEND_DIR"
python3 -c "
from app.config import Settings
from app.main import create_app
from fastapi.testclient import TestClient
settings = Settings(
aes_key='dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk',
woo_base_url='https://test.example.com',
)
app = create_app(settings)
with TestClient(app) as client:
r = client.put('/api/v1/settings', json={'woo_base_url': 'https://hacked.example.com'})
assert r.status_code == 422, f'Expected 422, got {r.status_code}'
body = r.json()
assert 'detail' in body or 'errors' in body, 'Missing error detail'
app.dependency_overrides.clear()
print('OK')
"
}
# ── FUND-25: Port collision detection (compose-only check) ────────────────────
# Check that FRONTEND_PORT and FASTAPI_PORT defaults don't collide with known services.
test_fund25_port_free() {
FRONTEND_PORT="${FRONTEND_PORT:-18080}"
FASTAPI_PORT="${FASTAPI_PORT:-18081}"
if [ "$SKIP_COMPOSE" -eq 0 ]; then
# On a real host, check if ports are free
for PORT in "$FRONTEND_PORT" "$FASTAPI_PORT"; do
if ss -tlnp 2>/dev/null | grep -q ":$PORT "; then
echo "Port $PORT is in use"
return 1
fi
done
fi
return 0
}
# ── Run tests ──────────────────────────────────────────────────────────────────
SKIP_COMPOSE=0
if ! command -v docker &>/dev/null; then
SKIP_COMPOSE=1
warn "Docker not available — skipping compose-dependent tests"
fi
echo ""
echo "=== WOO_BASE_URL validation tests ==="
echo ""
check "GC-5: HTTP WOO_BASE_URL rejected" test_gc5_http_rejected
check "GC-5: Missing WOO_BASE_URL rejected" test_gc5_missing_rejected
check "GC-5: HTTPS WOO_BASE_URL accepted" test_gc5_https_accepted
check "GC-4: Settings GET returns woo_base_url" test_gc4_settings_get_returns_url
check "GC-4: Settings PUT rejects woo_base_url" test_gc4_put_rejects_url
if [ "$SKIP_COMPOSE" -eq 0 ]; then
check "FUND-25: Port collision check" test_fund25_port_free
else
pass "FUND-25: Port collision check (skipped — no Docker)"
fi
echo ""
echo "=== Results: $PASSED passed, $FAILED failed, $TOTAL total ==="
if [ "$FAILED" -gt 0 ]; then
exit 1
fi
exit 0
+17
View File
@@ -0,0 +1,17 @@
FROM node:20-slim
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci --ignore-scripts
COPY frontend/ .
# Build the SvelteKit app (stub for Gate B — will be updated when SvelteKit scaffolds)
# For now, copy a stub HTML page. Vite build runs in production mode later.
RUN mkdir -p build && \
echo '<!DOCTYPE html><html><head><meta charset="utf-8"><title>SlabUploader</title></head><body><h1>SlabUploader — coming soon</h1><p>SvelteKit app stub. Update this Dockerfile when the SvelteKit app is scaffolded.</p></body></html>' > build/index.html
EXPOSE 80
CMD ["node", "-e", "const h=require('http'),fs=require('fs'),url=require('url');h.createServer((req,res)=>{const f='build/index.html';res.writeHead(200,{'Content-Type':'text/html'}).end(fs.readFileSync(f))}).listen(80);console.log('frontend on :80')"]