Fix 403 on test-woo: add User-Agent header, classify 403 as WooForbiddenError

WordPress security plugins (Wordfence, WAF) block the default
Python-urllib User-Agent with 403. Send a descriptive User-Agent on
all Woo requests. Also add WooForbiddenError so 403 is classified
distinctly from 401 (auth) per SETTINGS-80, and surface a clear
message in test-woo.
This commit is contained in:
2026-09-20 07:59:00 -07:00
parent d1e862770d
commit b99cbdf194
3 changed files with 42 additions and 1 deletions
+3
View File
@@ -191,6 +191,7 @@ from .woo import (
WooConnectionError, WooConnectionError,
WooCredentialsMissing, WooCredentialsMissing,
WooError, WooError,
WooForbiddenError,
) )
@@ -218,6 +219,8 @@ async def test_woo() -> dict[str, Any]:
result = taxonomy_mod.sync(client, get_connection()) result = taxonomy_mod.sync(client, get_connection())
except WooAuthError as e: except WooAuthError as e:
return {"ok": False, "detail": f"auth rejected by store: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None} 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: except WooConnectionError as e:
return {"ok": False, "detail": f"store unreachable: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None} return {"ok": False, "detail": f"store unreachable: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
except WooError as e: except WooError as e:
+23
View File
@@ -14,6 +14,7 @@ from backend.woo import (
WooClient, WooClient,
WooConnectionError, WooConnectionError,
WooCredentialsMissing, WooCredentialsMissing,
WooForbiddenError,
WooServerError, WooServerError,
) )
from backend import taxonomy as taxonomy_mod 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]) c2, _ = _client([cats, tags, attrs])
r2 = taxonomy_mod.sync(c2, db_mod.get_connection()) r2 = taxonomy_mod.sync(c2, db_mod.get_connection())
assert r1["taxonomy_anchor"] == r2["taxonomy_anchor"] 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"]
+16 -1
View File
@@ -13,6 +13,10 @@ from typing import Any, Callable
from .crypto import DecryptError, decrypt 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): class WooError(Exception):
"""Base for all Woo client failures.""" """Base for all Woo client failures."""
@@ -26,6 +30,10 @@ class WooAuthError(WooError):
"""The store rejected the credentials (401).""" """The store rejected the credentials (401)."""
class WooForbiddenError(WooError):
"""The store blocked the request (403) — usually a security plugin or WAF."""
class WooNotFoundError(WooError): class WooNotFoundError(WooError):
pass 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 msg = parsed.get("message") if isinstance(parsed, dict) else None
if e.code == 401: if e.code == 401:
raise WooAuthError(msg or "authentication rejected (401)") from e 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: if e.code == 404:
raise WooNotFoundError(msg or "not found (404)") from e raise WooNotFoundError(msg or "not found (404)") from e
raise WooServerError(f"server error {e.code}: {msg}") 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}" url = f"{self.base_url}/wp-json/wc/v3{path}"
return self._request( return self._request(
url, "GET", None, 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]]: def _fetch_all(self, path: str) -> list[dict[str, Any]]:
@@ -132,6 +146,7 @@ class WooClient:
{ {
"Authorization": f"Basic {self._auth}", "Authorization": f"Basic {self._auth}",
"Content-Type": f"multipart/form-data; boundary={boundary}", "Content-Type": f"multipart/form-data; boundary={boundary}",
"User-Agent": _USER_AGENT,
}, },
) )
if code not in (200, 201): if code not in (200, 201):