"""Settings API tests (SETTINGS-5,75,85,90, QUALITY-125, DATA-MODEL ยง6).""" import base64 import os import shutil 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") @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_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_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_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).""" 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(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(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(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(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