Files
SlabUploader/backend/tests/test_settings_api.py
Ty b3380f3848 feat(settings): S1 AES-GCM crypto + Settings API/UI (masked write-only secrets)
- backend/crypto.py: AES-GCM (cryptography.AESGCM); key=base64(SLAB_AES_KEY)
  must be exactly 32 bytes; value_enc=base64(nonce(12)||ct); DecryptError on tamper
- backend/settings.py: get_settings (secrets->{configured:bool}, woo_base_url env
  read-only, flags default false), update_settings (rejects woo_base_url SETTINGS-85,
  encrypts secrets, partial plain writes)
- backend/app.py: wire GET/PUT /api/v1/settings
- frontend/src/create/settings/Settings.svelte: masked form + partial PUT
- tests: crypto roundtrip/tamper, masked GET, encrypted PUT, reject woo_base_url,
  flags default, decrypt-fail->configured:false (TDD)

Verified live: secrets round-trip configured:true with valid key; woo_base_url write
rejected 400; no secret leak in GET. Gates: check_requirements + check_coverage exit 0;
pytest 13 passed. Deploy+build on ubudual5060 (never Windows).
2026-09-19 15:48:42 -07:00

141 lines
6.1 KiB
Python

"""TDD red tests for S1 Settings API (SETTINGS-5,75,85,90, QUALITY-125, DATA-MODEL §6)."""
import base64
import json
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
import backend.db as db_mod
def _valid_key_b64() -> str:
raw = b"0123456789abcdef0123456789abcdef"
return base64.b64encode(raw).decode()
def _setup_env(monkeypatch, tmp):
db_path = os.path.join(tmp, "test.db")
img_dir = os.path.join(tmp, "images")
os.mkdir(img_dir)
monkeypatch.setenv("SLAB_DB_PATH", db_path)
monkeypatch.setenv("SLAB_IMAGES_DIR", img_dir)
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()
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_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_flags_default_false_when_absent(monkeypatch):
"""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()
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()