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 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},
)
+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()