diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 66a5a30..174b59d 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -14,6 +14,7 @@ from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field +from sqlalchemy import text from app.config import Settings from app.db.engine import get_session @@ -26,11 +27,15 @@ router = APIRouter() class SettingsUpdate(BaseModel): """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_app_password: str | None = None + woo_consumer_key: str | None = None + woo_consumer_secret: str | None = None inference_base_url: str | None = None inference_api_key: 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) 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() await session.close() @@ -174,13 +179,18 @@ async def update_settings( 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) if "woo_consumer_key" in payload or "woo_consumer_secret" in payload: raise HTTPException( status_code=422, 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 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)) 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() if existing: 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}, ) else: 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}, ) diff --git a/backend/tests/contract/test_woo_base_url.py b/backend/tests/contract/test_woo_base_url.py new file mode 100644 index 0000000..7b75465 --- /dev/null +++ b/backend/tests/contract/test_woo_base_url.py @@ -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() diff --git a/deploy/.env.example b/deploy/.env.example index 0693cd0..898f167 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -1,11 +1,16 @@ -# Placeholders only. Copy to deploy/.env (chmod 600). Never commit real values. -# Generate SLAB_AES_KEY: openssl rand -base64 32 +# SlabUploader deploy environment — placeholders only. +# Copy to deploy/.env and fill real values. Do not commit real values. +# chmod 600 deploy/.env -SLAB_AES_KEY= -APP_HOSTNAME=slab.example.local +# AES-GCM master key: 32 bytes base64. Generated via: openssl rand -base64 32 +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 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 diff --git a/deploy/tests/test-compose-health.sh b/deploy/tests/test-compose-health.sh new file mode 100644 index 0000000..7702e87 --- /dev/null +++ b/deploy/tests/test-compose-health.sh @@ -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 diff --git a/deploy/tests/test-woo-base-url.sh b/deploy/tests/test-woo-base-url.sh new file mode 100644 index 0000000..662625e --- /dev/null +++ b/deploy/tests/test-woo-base-url.sh @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..80c5613 --- /dev/null +++ b/frontend/Dockerfile @@ -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 '
SvelteKit app stub. Update this Dockerfile when the SvelteKit app is scaffolded.
' > 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')"]