diff --git a/backend/app.py b/backend/app.py index 1d18327..3ae2532 100644 --- a/backend/app.py +++ b/backend/app.py @@ -191,6 +191,7 @@ from .woo import ( WooConnectionError, WooCredentialsMissing, WooError, + WooForbiddenError, ) @@ -218,6 +219,8 @@ async def test_woo() -> dict[str, Any]: result = taxonomy_mod.sync(client, get_connection()) except WooAuthError as e: return {"ok": False, "detail": f"auth rejected by store: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None} + except WooForbiddenError as e: + return {"ok": False, "detail": f"request blocked by store (403): {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None} except WooConnectionError as e: return {"ok": False, "detail": f"store unreachable: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None} except WooError as e: diff --git a/backend/tests/test_woo.py b/backend/tests/test_woo.py index 038c2a6..ac6f432 100644 --- a/backend/tests/test_woo.py +++ b/backend/tests/test_woo.py @@ -14,6 +14,7 @@ from backend.woo import ( WooClient, WooConnectionError, WooCredentialsMissing, + WooForbiddenError, WooServerError, ) from backend import taxonomy as taxonomy_mod @@ -138,3 +139,25 @@ def test_anchor_stable_across_identical_syncs(db_dir): c2, _ = _client([cats, tags, attrs]) r2 = taxonomy_mod.sync(c2, db_mod.get_connection()) assert r1["taxonomy_anchor"] == r2["taxonomy_anchor"] + + +def test_403_raises_forbidden_error(db_dir): + def fake_request(url, method, data, headers): + raise WooForbiddenError("request forbidden (403)") + + c = WooClient("admin", "secret", "https://store.example.com", request=fake_request) + with pytest.raises(WooForbiddenError): + c.get_categories() + + +def test_requests_include_user_agent(db_dir): + seen_headers = {} + + def fake_request(url, method, data, headers): + seen_headers.update(headers) + return 200, {"X-WP-TotalPages": "1"}, b"[]" + + c = WooClient("admin", "secret", "https://store.example.com", request=fake_request) + c.get_categories() + assert "User-Agent" in seen_headers + assert "SlabUploader" in seen_headers["User-Agent"] diff --git a/backend/woo.py b/backend/woo.py index 184f505..d1eb557 100644 --- a/backend/woo.py +++ b/backend/woo.py @@ -13,6 +13,10 @@ from typing import Any, Callable from .crypto import DecryptError, decrypt +# WordPress security plugins (Wordfence, WAF, CDN) commonly block the default +# Python-urllib User-Agent with 403. Send a descriptive one. +_USER_AGENT = "SlabUploader/1.0 (+https://www.whidbeywoodstore.com)" + class WooError(Exception): """Base for all Woo client failures.""" @@ -26,6 +30,10 @@ class WooAuthError(WooError): """The store rejected the credentials (401).""" +class WooForbiddenError(WooError): + """The store blocked the request (403) — usually a security plugin or WAF.""" + + class WooNotFoundError(WooError): pass @@ -54,6 +62,8 @@ def _default_request(url: str, method: str, data: bytes | None, headers: dict[st msg = parsed.get("message") if isinstance(parsed, dict) else None if e.code == 401: raise WooAuthError(msg or "authentication rejected (401)") from e + if e.code == 403: + raise WooForbiddenError(msg or "request forbidden (403)") from e if e.code == 404: raise WooNotFoundError(msg or "not found (404)") from e raise WooServerError(f"server error {e.code}: {msg}") from e @@ -90,7 +100,11 @@ class WooClient: url = f"{self.base_url}/wp-json/wc/v3{path}" return self._request( url, "GET", None, - {"Authorization": f"Basic {self._auth}", "Accept": "application/json"}, + { + "Authorization": f"Basic {self._auth}", + "Accept": "application/json", + "User-Agent": _USER_AGENT, + }, ) def _fetch_all(self, path: str) -> list[dict[str, Any]]: @@ -132,6 +146,7 @@ class WooClient: { "Authorization": f"Basic {self._auth}", "Content-Type": f"multipart/form-data; boundary={boundary}", + "User-Agent": _USER_AGENT, }, ) if code not in (200, 201):