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:
@@ -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},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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')"]
|
||||||
Reference in New Issue
Block a user