proto(woo): blocking setup gate; require X-Woo-* on Woo HTTP routes

Full-screen WooSetupGate on App mount when this browser lacks device WP
user + app password. Save + Test Woo dismisses only after a successful
test. Publish/test-woo auth failures reopen the gate.

Server: client_for_request requires X-Woo-* on publish, taxonomy sync,
and test-woo (401 woo_device_creds_required); health probe keeps
Settings. Optional SLAB_WOO_ALLOW_SETTINGS_FALLBACK for legacy tests.

See PROTOTYPE.md.
This commit is contained in:
grokbot_ty
2026-09-26 19:20:16 -07:00
parent 2f6335ec26
commit 2f9b8ebd02
16 changed files with 600 additions and 58 deletions
+14 -3
View File
@@ -211,6 +211,7 @@ from .woo import (
WooAuthError,
WooConnectionError,
WooCredentialsMissing,
WooDeviceCredsRequired,
WooError,
WooForbiddenError,
client_for_request,
@@ -225,11 +226,16 @@ def _woo_credentials() -> tuple[str, str | None, str | None]:
@app.post("/api/v1/settings/test-woo")
async def test_woo(request: Request) -> dict[str, Any]:
"""Probe Woo + sync taxonomy. Optional X-Woo-Username / X-Woo-App-Password
override shop-default Settings for this request only (never persisted).
"""Probe Woo + sync taxonomy. Requires X-Woo-Username / X-Woo-App-Password
(device creds); never reads shop-default Settings for this route.
"""
try:
client = client_for_request(get_connection(), request.headers)
except WooDeviceCredsRequired as e:
return JSONResponse(
status_code=401,
content=_error("woo_device_creds_required", str(e)),
)
except WooCredentialsMissing as e:
return {
"ok": False,
@@ -262,9 +268,14 @@ async def get_taxonomy() -> dict[str, Any]:
@app.post("/api/v1/admin/taxonomy/sync")
async def post_taxonomy_sync(request: Request) -> dict[str, Any]:
"""Refresh taxonomy cache. Honors X-Woo-* override headers for this request."""
"""Refresh taxonomy cache. Requires X-Woo-* device headers (no Settings fallback)."""
try:
client = client_for_request(get_connection(), request.headers)
except WooDeviceCredsRequired as e:
return JSONResponse(
status_code=401,
content=_error("woo_device_creds_required", str(e)),
)
except WooCredentialsMissing as e:
return JSONResponse(status_code=502, content=_error("woo_unavailable", str(e)))
try:
+16 -1
View File
@@ -21,6 +21,7 @@ from .woo import (
WooClient,
WooConnectionError,
WooCredentialsMissing,
WooDeviceCredsRequired,
WooError,
WooForbiddenError,
client_for_request,
@@ -124,6 +125,12 @@ def _attr_id_from_synced(attrs: dict, slug: str, name: str) -> int | None:
def _classify(e: Exception) -> tuple[str, str, bool]:
"""Return (code, plain message, recoverable). PUBLISH-36/37/38."""
if isinstance(e, WooDeviceCredsRequired):
return (
"woo_device_creds_required",
"Enter this device's WordPress user and application password.",
False,
)
if isinstance(e, WooCredentialsMissing):
return "woo_unconfigured", "Woo is not configured. Set the store credentials in Settings.", False
if isinstance(e, WooAuthError):
@@ -152,9 +159,17 @@ async def publish(slab_id: str, request: Request) -> dict:
if not ready:
return JSONResponse(status_code=422, content=_error("unpublishable", "Slab is not ready", {"missing": missing}))
# 2. SKU lookup (PUBLISH-15) — stop if it exists in the store.
# Per-request Woo creds: X-Woo-* headers → Settings fallback (never persisted).
# Per-request Woo creds: require X-Woo-* device headers (no Settings fallback).
try:
client = client_for_request(conn, request.headers)
except WooDeviceCredsRequired as e:
return JSONResponse(
status_code=401,
content=_error(
"woo_device_creds_required",
"Enter this device's WordPress user and application password.",
),
)
except WooCredentialsMissing as e:
return JSONResponse(status_code=422, content=_error("woo_unconfigured", str(e)))
try:
+1
View File
@@ -21,6 +21,7 @@ def env(monkeypatch):
os.environ["SLAB_IMAGES_DIR"] = tmp
os.environ["WOO_BASE_URL"] = "https://store.example.com"
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
os.environ["SLAB_WOO_ALLOW_SETTINGS_FALLBACK"] = "1" # legacy tests; prefer X-Woo-* headers
db_mod._reset_for_tests()
from backend.crypto import encrypt
@@ -59,6 +59,7 @@ def env(monkeypatch):
os.environ["SLAB_IMAGES_DIR"] = tmp
os.environ["WOO_BASE_URL"] = "https://store.example.com"
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
os.environ["SLAB_WOO_ALLOW_SETTINGS_FALLBACK"] = "1" # legacy tests; prefer X-Woo-* headers
db_mod._reset_for_tests()
from backend.crypto import encrypt
+76 -11
View File
@@ -1,4 +1,4 @@
"""Prototype: per-request X-Woo-* headers build distinct WooClients without clobbering Settings."""
"""Prototype: X-Woo-* headers required on HTTP Woo routes; never clobber Settings."""
import base64
import os
@@ -12,6 +12,7 @@ import backend.db as db_mod
from backend.crypto import encrypt, decrypt
from backend.woo import (
WooClient,
WooDeviceCredsRequired,
client_for_request,
request_woo_override,
settings_woo_creds,
@@ -27,6 +28,7 @@ def db_dir():
os.environ["SLAB_IMAGES_DIR"] = tmp
os.environ["WOO_BASE_URL"] = "https://store.example.com"
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
os.environ.pop("SLAB_WOO_ALLOW_SETTINGS_FALLBACK", None)
db_mod._reset_for_tests()
conn = db_mod.init_db()
conn.execute(
@@ -59,33 +61,38 @@ def test_override_headers_produce_distinct_auth_without_clobbering_settings(db_d
client_a = client_for_request(conn, h_a)
client_b = client_for_request(conn, h_b)
client_fallback = client_for_request(conn, Headers({}))
assert _auth_pair(client_a) == ("alice", "alice-app-pw")
assert _auth_pair(client_b) == ("bob", "bob-app-pw")
assert _auth_pair(client_fallback) == ("shop-default", "shop-secret")
assert client_a._auth != client_b._auth
after = settings_woo_creds(conn)
assert after == before
assert decrypt(after[2]) == "shop-secret"
# Explicit: Settings row values unchanged
row_u = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()[0]
row_p = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()[0]
assert row_u == "shop-default"
assert decrypt(row_p) == "shop-secret"
def test_partial_headers_fall_back_to_settings(db_dir):
def test_missing_headers_raise_device_creds_required(db_dir):
conn = db_dir
# Username only — incomplete override
with pytest.raises(WooDeviceCredsRequired):
client_for_request(conn, Headers({}))
with pytest.raises(WooDeviceCredsRequired):
client_for_request(conn, Headers({HEADER_WOO_USERNAME: "alice"}))
assert request_woo_override(Headers({HEADER_WOO_USERNAME: "alice"})) is None
client = client_for_request(conn, Headers({HEADER_WOO_USERNAME: "alice"}))
def test_env_escape_hatch_allows_settings_fallback(db_dir, monkeypatch):
conn = db_dir
monkeypatch.setenv("SLAB_WOO_ALLOW_SETTINGS_FALLBACK", "1")
client = client_for_request(conn, Headers({}))
assert _auth_pair(client) == ("shop-default", "shop-secret")
def test_test_woo_endpoint_uses_request_override(db_dir, monkeypatch):
"""API: POST /settings/test-woo with X-Woo-* builds client for that user."""
def test_test_woo_endpoint_requires_headers(db_dir, monkeypatch):
"""API: POST /settings/test-woo with X-Woo-* builds client; missing → 401."""
from fastapi.testclient import TestClient
from backend import app as app_mod
@@ -108,6 +115,64 @@ def test_test_woo_endpoint_uses_request_override(db_dir, monkeypatch):
r3 = tc.post("/api/v1/settings/test-woo")
assert r1.status_code == 200 and r1.json()["ok"] is True
assert r2.status_code == 200 and r2.json()["ok"] is True
assert r3.status_code == 200 and r3.json()["ok"] is True
assert seen == ["alice", "bob", "shop-default"]
assert r3.status_code == 401
assert r3.json()["error"]["code"] == "woo_device_creds_required"
assert seen == ["alice", "bob"]
assert settings_woo_creds(db_dir)[1] == "shop-default"
def test_taxonomy_sync_requires_headers(db_dir, monkeypatch):
from fastapi.testclient import TestClient
from backend import app as app_mod
monkeypatch.setattr(
app_mod.taxonomy_mod,
"sync",
lambda client, conn: {
"taxonomy_anchor": "x",
"taxonomy_last_sync_at": "2026-01-01T00:00:00",
},
)
tc = TestClient(app_mod.app)
missing = tc.post("/api/v1/admin/taxonomy/sync")
assert missing.status_code == 401
assert missing.json()["error"]["code"] == "woo_device_creds_required"
ok = tc.post(
"/api/v1/admin/taxonomy/sync",
headers={HEADER_WOO_USERNAME: "alice", HEADER_WOO_APP_PASSWORD: "pw"},
)
assert ok.status_code == 200 and ok.json()["ok"] is True
def test_publish_requires_headers(db_dir):
"""POST publish without X-Woo-* → 401 woo_device_creds_required."""
from fastapi.testclient import TestClient
from backend import app as app_mod
import json
conn = db_dir
conn.execute(
"INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Walnut','walnut',NULL,100)"
)
conn.execute(
"INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge','le',NULL,101)"
)
conn.execute(
"INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Edge Type','edge-type',200)"
)
conn.execute(
"INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,8,'Flat','flat',300)"
)
conn.execute(
"""INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,
species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,thickness_term_id,
moisture_term_id,fig_tag_ids,feat_tag_ids,price,price_source,title,short_description,description,
content_source,status,server_rev,client_rev,created_at,updated_at) VALUES
('s1','SLAB-01',114,1.75,30,18.4,18,24.3,24.3,1,'[2]',1,'[1]',1,1,1,'[]','[]',184,
'recommendation','T','S','D','manual','ready',1,1,'t','t')"""
)
conn.commit()
tc = TestClient(app_mod.app)
r = tc.post("/api/v1/slabs/s1/publish")
assert r.status_code == 401, r.text
assert r.json()["error"]["code"] == "woo_device_creds_required"
+1
View File
@@ -36,6 +36,7 @@ def env(monkeypatch):
os.environ["SLAB_IMAGES_DIR"] = tmp
os.environ["WOO_BASE_URL"] = "https://store.example.com"
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
os.environ["SLAB_WOO_ALLOW_SETTINGS_FALLBACK"] = "1" # legacy tests; prefer X-Woo-* headers
db_mod._reset_for_tests()
from backend.crypto import encrypt
+38 -12
View File
@@ -1,12 +1,11 @@
"""WooCommerce REST v3 client (stdlib urllib). Basic-auth, HTTPS-only, paginated.
Default creds come from Settings (wp_username plain, app_password AES-GCM).
Missing creds raise WooCredentialsMissing (fail-closed), distinct from
WooAuthError (401 from the store).
Shop-default creds live in Settings (wp_username plain, app_password AES-GCM) and
are used by the health probe only. HTTP Woo routes (publish / taxonomy sync /
test-woo) require X-Woo-Username + X-Woo-App-Password on the request — no
Settings fallback. Missing headers raise WooDeviceCredsRequired. See PROTOTYPE.md.
Prototype per-user override: callers may pass X-Woo-Username + X-Woo-App-Password
request headers into client_for_request(); those build a per-request WooClient
and are never written into Settings. See PROTOTYPE.md.
Optional escape hatch for legacy tests: SLAB_WOO_ALLOW_SETTINGS_FALLBACK=1.
"""
import base64
@@ -30,6 +29,10 @@ class WooCredentialsMissing(WooError):
"""Woo base URL or credentials are not configured (fail-closed)."""
class WooDeviceCredsRequired(WooCredentialsMissing):
"""X-Woo-Username + X-Woo-App-Password required; Settings fallback disabled."""
class WooAuthError(WooError):
"""The store rejected the credentials (401)."""
@@ -188,9 +191,9 @@ class WooClient:
# Woo-touching API calls via:
# X-Woo-Username
# X-Woo-App-Password
# Server uses those for THIS request's WooClient only; never writes them into
# the Settings row. If either header is missing/blank, fall back to the
# shop-default singleton (woo_wp_username + encrypted woo_app_password).
# HTTP Woo routes REQUIRE both headers (no Settings fallback). Health probe
# still uses shop-default Settings via WooClient.from_settings / settings_woo_creds.
# Never write override headers into the Settings row.
HEADER_WOO_USERNAME = "X-Woo-Username"
HEADER_WOO_APP_PASSWORD = "X-Woo-App-Password"
@@ -218,10 +221,29 @@ def request_woo_override(headers) -> tuple[str, str] | None:
return None
def client_for_request(conn, headers=None, request: Callable[..., tuple[int, dict, bytes]] | None = None) -> WooClient:
"""Build a WooClient for one HTTP request: override headers → Settings fallback.
def _allow_settings_fallback() -> bool:
"""Legacy-test escape hatch. Production HTTP Woo routes leave this unset."""
import os
Never mutates Settings. Inference stays on shared Settings (caller responsibility).
return os.environ.get("SLAB_WOO_ALLOW_SETTINGS_FALLBACK", "").strip().lower() in (
"1",
"true",
"yes",
)
def client_for_request(
conn,
headers=None,
request: Callable[..., tuple[int, dict, bytes]] | None = None,
*,
require_override: bool = True,
) -> WooClient:
"""Build a WooClient for one HTTP Woo request.
Requires X-Woo-* headers by default (no Settings fallback). Set
require_override=False or SLAB_WOO_ALLOW_SETTINGS_FALLBACK=1 only for
health-adjacent / legacy-test paths. Never mutates Settings.
"""
base, settings_user, settings_enc = settings_woo_creds(conn)
override = request_woo_override(headers)
@@ -230,4 +252,8 @@ def client_for_request(conn, headers=None, request: Callable[..., tuple[int, dic
if request is None:
request = _default_request
return WooClient(user, password, base, request)
if require_override and not _allow_settings_fallback():
raise WooDeviceCredsRequired(
"X-Woo-Username and X-Woo-App-Password are required on this device"
)
return WooClient.from_settings(base, settings_user, settings_enc, request=request)