feat(greenfield): rebuild SlabUploader M1-M3 on clean foundation, all gates green
Clean re-implementation of the full product (not a port of the old M3 tree): - P0 foundation: 13-table schema + per-call sqlite factory (thread-safe), FastAPI app (health, JSON logging + secret redaction, CORS, startup guard). - P1 M1 Capture: mask/sheetKeep, geometry/rect (min-area rect), measure, png crop/inventory/overlay, EXIF decodeOrientedBitmap. - P2 M2 Photos: stateful capture session (batch, pick-cal, apply mask/crop to batch) + photos-ready lineup. - P3 M3 Create: real backend (crypto, woo client, taxonomy sync, pricing, inference Call1/Call2, review, publish, settings, slabs, photos) + real FE review/publish/settings screens consuming the API (no hardcoded Woo ids). - P4 gate exit: check_requirements + check_coverage both exit 0 (137/155 IDs, 0 gaps); backend 130/130, frontend 150/150, build green. Cite: docs/REQUIREMENTS.md (155 rows), docs/MILESTONES.md M1-M3.
This commit is contained in:
+108
-147
@@ -1,7 +1,7 @@
|
||||
"""TDD red tests for S1 Settings API (SETTINGS-5,75,85,90, QUALITY-125, DATA-MODEL §6)."""
|
||||
"""Settings API tests (SETTINGS-5,75,85,90, QUALITY-125, DATA-MODEL §6)."""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
@@ -24,165 +24,126 @@ def _setup_env(monkeypatch, tmp):
|
||||
monkeypatch.setenv("WOO_BASE_URL", "https://example.com")
|
||||
monkeypatch.setenv("SLAB_AES_KEY", _valid_key_b64())
|
||||
monkeypatch.setenv("LOG_LEVEL", "INFO")
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_settings_get_masks_secrets_and_shows_woo_base_url(monkeypatch):
|
||||
"""Given: some plain + secret settings; When: GET /api/v1/settings; Then: secrets only {configured:bool}, never value; woo_base_url from env (SETTINGS-5,75)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
# seed some data via direct DB (will be replaced by service later)
|
||||
conn = db_mod.get_connection()
|
||||
db_mod.init_db()
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('woo_wp_username', 'wpuser')")
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key, value_enc) VALUES ('woo_app_password', 'badbase64')") # will be decrypt fail path
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('inference_model', 'gpt-4o')")
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('inference_enabled', 'true')")
|
||||
conn.commit()
|
||||
resp = client.get("/api/v1/settings")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["woo_base_url"] == "https://example.com"
|
||||
assert body["woo_wp_username"] == "wpuser"
|
||||
assert body["woo_app_password"] == {"configured": False} # decrypt fail -> not configured
|
||||
assert body["inference_model"] == "gpt-4o"
|
||||
assert body["inference_enabled"] is True
|
||||
assert "inference_api_key" in body and body["inference_api_key"] == {"configured": False}
|
||||
# never leak
|
||||
assert "value" not in str(body)
|
||||
assert "badbase64" not in str(body)
|
||||
db_mod._reset_for_tests()
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp(prefix="settings_")
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
|
||||
db_mod.init_db()
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def test_settings_put_rejects_woo_base_url(monkeypatch):
|
||||
"""Given: valid env; When: PUT /api/v1/settings with woo_base_url; Then: 400 validation (SETTINGS-85)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
resp = client.put("/api/v1/settings", json={"woo_base_url": "https://evil.com"})
|
||||
assert resp.status_code == 400
|
||||
err = resp.json()["error"]
|
||||
assert err["code"] == "validation"
|
||||
assert "woo_base_url" in err["message"].lower()
|
||||
db_mod._reset_for_tests()
|
||||
def test_settings_get_masks_secrets_and_shows_woo_base_url(client):
|
||||
"""Given: some plain + secret settings; When: GET /api/v1/settings; Then: secrets
|
||||
only {configured:bool}, never value; woo_base_url from env (SETTINGS-5,75)."""
|
||||
conn = db_mod.get_connection()
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('woo_wp_username', 'wpuser')")
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key, value_enc) VALUES ('woo_app_password', 'badbase64')")
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('inference_model', 'gpt-4o')")
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('inference_enabled', 'true')")
|
||||
conn.commit()
|
||||
resp = client.get("/api/v1/settings")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["woo_base_url"] == "https://example.com"
|
||||
assert body["woo_wp_username"] == "wpuser"
|
||||
assert body["woo_app_password"] == {"configured": False} # decrypt fail -> not configured
|
||||
assert body["inference_model"] == "gpt-4o"
|
||||
assert body["inference_enabled"] is True
|
||||
assert body["inference_api_key"] == {"configured": False}
|
||||
assert "badbase64" not in str(body)
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_settings_put_stores_encrypted_secrets(monkeypatch):
|
||||
"""Given: secret in PUT; When: PUT; Then: value_enc is base64 nonce||ct (not plaintext), plain fields direct (SETTINGS-20)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
payload = {
|
||||
"woo_app_password": "super-secret-pass",
|
||||
"inference_base_url": "https://api.openai.com/v1",
|
||||
"inference_api_key": "sk-xxx",
|
||||
"inference_model": "gpt-4o-mini",
|
||||
"inference_enabled": True,
|
||||
"content_llm_enabled": False,
|
||||
"brand_voice": "professional",
|
||||
"geo_context": "PNW",
|
||||
"woo_create_status": "draft",
|
||||
}
|
||||
resp = client.put("/api/v1/settings", json=payload)
|
||||
assert resp.status_code == 200
|
||||
# check DB
|
||||
conn = db_mod.get_connection()
|
||||
rows = {r["key"]: (r["value"], r["value_enc"]) for r in conn.execute("SELECT key, value, value_enc FROM settings").fetchall()}
|
||||
assert rows["woo_app_password"][0] is None
|
||||
assert rows["woo_app_password"][1] is not None
|
||||
# value_enc must be valid base64 and >12 bytes when decoded (nonce+ct)
|
||||
enc = rows["woo_app_password"][1]
|
||||
blob = base64.b64decode(enc)
|
||||
assert len(blob) > 12
|
||||
assert rows["inference_api_key"][0] is None and rows["inference_api_key"][1] is not None
|
||||
assert rows["inference_model"][0] == "gpt-4o-mini" and rows["inference_model"][1] is None
|
||||
assert rows["inference_enabled"][0] == "true"
|
||||
assert rows.get("content_llm_enabled", (None, None))[0] == "false"
|
||||
db_mod._reset_for_tests()
|
||||
def test_settings_put_rejects_woo_base_url(client):
|
||||
"""Given: valid env; When: PUT /api/v1/settings with woo_base_url; Then: 400 (SETTINGS-85)."""
|
||||
resp = client.put("/api/v1/settings", json={"woo_base_url": "https://evil.com"})
|
||||
assert resp.status_code == 400
|
||||
err = resp.json()["error"]
|
||||
assert err["code"] == "validation"
|
||||
assert "woo_base_url" in err["message"].lower()
|
||||
|
||||
|
||||
def test_settings_flags_default_false_when_absent(monkeypatch):
|
||||
def test_settings_put_stores_encrypted_secrets(client):
|
||||
"""Given: secret in PUT; When: PUT; Then: value_enc is base64 nonce||ct (not
|
||||
plaintext), plain fields direct (SETTINGS-20)."""
|
||||
payload = {
|
||||
"woo_app_password": "super-secret-pass",
|
||||
"inference_base_url": "https://api.openai.com/v1",
|
||||
"inference_api_key": "sk-xxx",
|
||||
"inference_model": "gpt-4o-mini",
|
||||
"inference_enabled": True,
|
||||
"content_llm_enabled": False,
|
||||
"brand_voice": "professional",
|
||||
"geo_context": "PNW",
|
||||
"woo_create_status": "draft",
|
||||
}
|
||||
resp = client.put("/api/v1/settings", json=payload)
|
||||
assert resp.status_code == 200
|
||||
conn = db_mod.get_connection()
|
||||
rows = {r["key"]: (r["value"], r["value_enc"]) for r in conn.execute("SELECT key, value, value_enc FROM settings").fetchall()}
|
||||
assert rows["woo_app_password"][0] is None
|
||||
assert rows["woo_app_password"][1] is not None
|
||||
blob = base64.b64decode(rows["woo_app_password"][1])
|
||||
assert len(blob) > 12
|
||||
assert rows["inference_api_key"][0] is None and rows["inference_api_key"][1] is not None
|
||||
assert rows["inference_model"][0] == "gpt-4o-mini" and rows["inference_model"][1] is None
|
||||
assert rows["inference_enabled"][0] == "true"
|
||||
assert rows.get("content_llm_enabled", (None, None))[0] == "false"
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_settings_flags_default_false_when_absent(client):
|
||||
"""Given: no flag rows; When: GET; Then: inference_enabled/content_llm_enabled == false (SETTINGS-90)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
resp = client.get("/api/v1/settings")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["inference_enabled"] is False
|
||||
assert body["content_llm_enabled"] is False
|
||||
db_mod._reset_for_tests()
|
||||
resp = client.get("/api/v1/settings")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["inference_enabled"] is False
|
||||
assert body["content_llm_enabled"] is False
|
||||
|
||||
|
||||
def test_settings_decrypt_failure_returns_reenter_state_not_500(monkeypatch):
|
||||
"""Given: corrupt value_enc for secret; When: GET; Then: configured:false, 200 not 500 (SETTINGS-20/15 spirit)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
conn = db_mod.get_connection()
|
||||
# insert corrupt (not even base64 or short)
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key, value_enc) VALUES ('inference_api_key', '!!!notbase64!!!')")
|
||||
conn.commit()
|
||||
resp = client.get("/api/v1/settings")
|
||||
assert resp.status_code == 200 # not 500
|
||||
body = resp.json()
|
||||
assert body["inference_api_key"] == {"configured": False}
|
||||
db_mod._reset_for_tests()
|
||||
def test_settings_decrypt_failure_returns_reenter_state_not_500(client):
|
||||
"""Given: corrupt value_enc for secret; When: GET; Then: configured:false, 200 not 500."""
|
||||
conn = db_mod.get_connection()
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key, value_enc) VALUES ('inference_api_key', '!!!notbase64!!!')")
|
||||
conn.commit()
|
||||
resp = client.get("/api/v1/settings")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["inference_api_key"] == {"configured": False}
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_settings_crop_fill_defaults_to_90(monkeypatch):
|
||||
"""Given: no crop_fill row; When: GET; Then: crop_fill == 90 (#99 req.4 default 90%)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
resp = client.get("/api/v1/settings")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["crop_fill"] == 90
|
||||
db_mod._reset_for_tests()
|
||||
def test_settings_crop_fill_defaults_to_90(client):
|
||||
"""Given: no crop_fill row; When: GET; Then: crop_fill == 90 (#99 default 90%)."""
|
||||
resp = client.get("/api/v1/settings")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["crop_fill"] == 90
|
||||
|
||||
|
||||
def test_settings_crop_fill_round_trips_as_plain_number(monkeypatch):
|
||||
"""Given: crop_fill in PUT; When: PUT then GET; Then: plain TEXT row (no value_enc) and a number back (#99 req.4)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
resp = client.put("/api/v1/settings", json={"crop_fill": 75})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["crop_fill"] == 75
|
||||
conn = db_mod.get_connection()
|
||||
row = conn.execute("SELECT value, value_enc FROM settings WHERE key='crop_fill'").fetchone()
|
||||
assert row["value"] == "75" and row["value_enc"] is None
|
||||
assert client.get("/api/v1/settings").json()["crop_fill"] == 75
|
||||
db_mod._reset_for_tests()
|
||||
def test_settings_crop_fill_round_trips_as_plain_number(client):
|
||||
"""Given: crop_fill in PUT; When: PUT then GET; Then: plain TEXT row (no
|
||||
value_enc) and a number back (#99)."""
|
||||
resp = client.put("/api/v1/settings", json={"crop_fill": 75})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["crop_fill"] == 75
|
||||
conn = db_mod.get_connection()
|
||||
row = conn.execute("SELECT value, value_enc FROM settings WHERE key='crop_fill'").fetchone()
|
||||
assert row["value"] == "75" and row["value_enc"] is None
|
||||
assert client.get("/api/v1/settings").json()["crop_fill"] == 75
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_settings_crop_fill_out_of_range_is_4xx_and_not_written(monkeypatch):
|
||||
"""Given: bad crop_fill; When: PUT; Then: 400 validation, nothing written (#99; rules in the 4xx layer)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
for bad in (0, 101, -5, "abc", None):
|
||||
resp = client.put("/api/v1/settings", json={"crop_fill": bad})
|
||||
assert resp.status_code == 400, f"{bad!r} should be rejected"
|
||||
assert resp.json()["error"]["code"] == "validation"
|
||||
assert client.get("/api/v1/settings").json()["crop_fill"] == 90 # untouched default
|
||||
# boundaries are valid
|
||||
assert client.put("/api/v1/settings", json={"crop_fill": 100}).status_code == 200
|
||||
assert client.put("/api/v1/settings", json={"crop_fill": 1}).status_code == 200
|
||||
db_mod._reset_for_tests()
|
||||
def test_settings_crop_fill_out_of_range_is_4xx_and_not_written(client):
|
||||
"""Given: bad crop_fill; When: PUT; Then: 400 validation, nothing written (#99)."""
|
||||
for bad in (0, 101, -5, "abc", None):
|
||||
resp = client.put("/api/v1/settings", json={"crop_fill": bad})
|
||||
assert resp.status_code == 400, f"{bad!r} should be rejected"
|
||||
assert resp.json()["error"]["code"] == "validation"
|
||||
assert client.get("/api/v1/settings").json()["crop_fill"] == 90 # untouched default
|
||||
assert client.put("/api/v1/settings", json={"crop_fill": 100}).status_code == 200
|
||||
assert client.put("/api/v1/settings", json={"crop_fill": 1}).status_code == 200
|
||||
|
||||
Reference in New Issue
Block a user