From d83c9d5c21025933d37cbf0a32d69bb8f036977d Mon Sep 17 00:00:00 2001 From: Ty Balascio Date: Sat, 19 Sep 2026 16:40:37 -0700 Subject: [PATCH] feat(woo): S4 Woo client + taxonomy sync + test-woo + admin taxonomy - backend/woo.py: stdlib urllib Woo v3 client. Basic-auth, HTTPS-only, X-WP-TotalPages pagination, typed errors (CredentialsMissing/Auth/NotFound/Server/Connection), injectable transport for tests, WP core media upload. - backend/taxonomy.py: sync pulls categories/tags/attributes/terms into cache in one commit (rollback keeps prior cache on failure); fig-*/feat-* tag scoping; stable taxonomy_anchor hash; get_cached grouped admin view. - backend/app.py: POST /settings/test-woo (fail-closed, resync on success), GET /admin/taxonomy, POST /admin/taxonomy/sync (502 on Woo failure). - frontend: Settings Test Woo button + result display. - tests: 8 woo/taxonomy tests (auth header, non-https refusal, pagination, missing creds, 401, scoped sync, failure-keeps-cache, anchor stability). Verified: pytest 31 passed; check_requirements + check_coverage exit 0; npm build ok; live QA test-woo fail-closed + sync 502. --- backend/app.py | 74 ++++++++++ backend/taxonomy.py | 99 +++++++++++++ backend/tests/test_woo.py | 140 +++++++++++++++++++ backend/woo.py | 139 ++++++++++++++++++ frontend/src/create/settings/Settings.svelte | 18 +++ 5 files changed, 470 insertions(+) create mode 100644 backend/taxonomy.py create mode 100644 backend/tests/test_woo.py create mode 100644 backend/woo.py diff --git a/backend/app.py b/backend/app.py index 743b9a3..e9978ed 100644 --- a/backend/app.py +++ b/backend/app.py @@ -149,6 +149,10 @@ async def _unwrap_error(request: Request, exc: HTTPException): return JSONResponse(status_code=exc.status_code, content={"error": {"code": "validation" if exc.status_code == 400 else "internal", "message": str(exc.detail), "detail": None}}) +def _error(code: str, message: str, detail: Any = None) -> dict: + return {"error": {"code": code, "message": message, "detail": detail}} + + @app.get("/api/health") async def health() -> dict[str, Any]: """Health per APP-30/45: non-200 when db or image volume not writable (probe file).""" @@ -180,6 +184,76 @@ async def put_settings(payload: dict[str, Any]) -> dict[str, Any]: return settings_mod.update_settings(payload) +from . import taxonomy as taxonomy_mod +from .woo import ( + WooClient, + WooAuthError, + WooConnectionError, + WooCredentialsMissing, + WooError, +) + + +def _woo_credentials() -> tuple[str, str | None, str | None]: + conn = get_connection() + base = os.environ.get("WOO_BASE_URL", "") + user_row = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone() + pass_row = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone() + return base, (user_row[0] if user_row else None), (pass_row[0] if pass_row else None) + + +@app.post("/api/v1/settings/test-woo") +async def test_woo() -> dict[str, Any]: + base, username, enc_password = _woo_credentials() + try: + client = WooClient.from_settings(base, username, enc_password) + except WooCredentialsMissing as e: + return { + "ok": False, + "detail": str(e), + "taxonomy_anchor": None, + "taxonomy_last_sync_at": None, + } + try: + 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 WooConnectionError as e: + return {"ok": False, "detail": f"store unreachable: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None} + except WooError as e: + return {"ok": False, "detail": str(e), "taxonomy_anchor": None, "taxonomy_last_sync_at": None} + return { + "ok": True, + "detail": "taxonomy synced", + "taxonomy_anchor": result["taxonomy_anchor"], + "taxonomy_last_sync_at": result["taxonomy_last_sync_at"], + } + + +@app.get("/api/v1/admin/taxonomy") +async def get_taxonomy() -> dict[str, Any]: + return taxonomy_mod.get_cached(get_connection()) + + +@app.post("/api/v1/admin/taxonomy/sync") +async def post_taxonomy_sync() -> dict[str, Any]: + base, username, enc_password = _woo_credentials() + try: + client = WooClient.from_settings(base, username, enc_password) + except WooCredentialsMissing as e: + return JSONResponse(status_code=502, content=_error("woo_unavailable", str(e))) + try: + result = taxonomy_mod.sync(client, get_connection()) + except WooError as e: + return JSONResponse(status_code=502, content=_error("woo_unavailable", str(e))) + return { + "ok": True, + "detail": "taxonomy synced", + "taxonomy_anchor": result["taxonomy_anchor"], + "taxonomy_last_sync_at": result["taxonomy_last_sync_at"], + } + + # expose for tests from .slabs import router as slabs_router diff --git a/backend/taxonomy.py b/backend/taxonomy.py new file mode 100644 index 0000000..a79b9a1 --- /dev/null +++ b/backend/taxonomy.py @@ -0,0 +1,99 @@ +"""Taxonomy sync: pull Woo categories/tags/attributes/terms into the local cache. + +Rebuild happens in one transaction; on any failure the prior cache is kept. +Only fig-* / feat-* tag families are retained (METADATA-15). taxonomy_anchor is a +stable hash of the stored subset, recorded only when it changes. +""" + +import hashlib +import json +from datetime import datetime, timezone + +from .woo import WooClient + + +def _now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat() + + +def _scoped_tags(tags: list[dict]) -> list[dict]: + return [t for t in tags if t.get("name", "").startswith(("fig-", "feat-"))] + + +def _subset_hash(categories: list[dict], attributes: list[dict], + terms_by_attr: dict, scoped_tags: list[dict]) -> str: + subset = { + "categories": sorted(categories, key=lambda c: c["id"]), + "attributes": sorted(attributes, key=lambda a: a["id"]), + "terms": {str(k): sorted(v, key=lambda t: t["id"]) for k, v in terms_by_attr.items()}, + "tags": sorted(scoped_tags, key=lambda t: t["id"]), + } + return hashlib.sha256(json.dumps(subset, sort_keys=True).encode("utf-8")).hexdigest() + + +def sync(client: WooClient, conn) -> dict: + """Rebuild the taxonomy cache from the store. Raises WooError on failure (caller + maps to 502; prior cache is retained because we only commit on success).""" + categories = client.get_categories() + tags = client.get_tags() + attributes = client.get_attributes() + terms_by_attr = {a["id"]: client.get_attribute_terms(a["id"]) for a in attributes} + scoped_tags = _scoped_tags(tags) + + conn.execute("DELETE FROM woo_attribute_terms") + conn.execute("DELETE FROM woo_attributes") + conn.execute("DELETE FROM woo_categories") + conn.execute("DELETE FROM woo_tags") + conn.executemany( + "INSERT INTO woo_categories(id, name, slug, parent_id, woo_id) VALUES (?,?,?,?,?)", + [(c["id"], c["name"], c["slug"], c.get("parent_id"), c.get("woo_id")) for c in categories], + ) + conn.executemany( + "INSERT INTO woo_attributes(id, name, slug, woo_id) VALUES (?,?,?,?)", + [(a["id"], a["name"], a["slug"], a.get("woo_id")) for a in attributes], + ) + for attr_id, terms in terms_by_attr.items(): + conn.executemany( + "INSERT INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) VALUES (?,?,?,?,?)", + [(t["id"], attr_id, t["name"], t["slug"], t.get("woo_id")) for t in terms], + ) + conn.executemany( + "INSERT INTO woo_tags(id, name, slug, woo_id) VALUES (?,?,?,?)", + [(t["id"], t["name"], t["slug"], t.get("woo_id")) for t in scoped_tags], + ) + + anchor = _subset_hash(categories, attributes, terms_by_attr, scoped_tags) + now = _now_iso() + conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('taxonomy_last_sync_at', ?, NULL)", (now,)) + conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('taxonomy_anchor', ?, NULL)", (anchor,)) + conn.commit() + return {"taxonomy_anchor": anchor, "taxonomy_last_sync_at": now} + + +def get_cached(conn) -> dict: + """Return the cached taxonomy grouped for the admin view.""" + categories = [dict(r) for r in conn.execute("SELECT * FROM woo_categories ORDER BY name").fetchall()] + attributes = [dict(r) for r in conn.execute("SELECT * FROM woo_attributes ORDER BY name").fetchall()] + terms = [dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms ORDER BY name").fetchall()] + tags = [dict(r) for r in conn.execute("SELECT * FROM woo_tags ORDER BY name").fetchall()] + anchor_row = conn.execute("SELECT value FROM settings WHERE key='taxonomy_anchor'").fetchone() + sync_row = conn.execute("SELECT value FROM settings WHERE key='taxonomy_last_sync_at'").fetchone() + terms_by_attr: dict[int, list] = {a["id"]: [] for a in attributes} + for t in terms: + terms_by_attr.setdefault(t["attribute_id"], []).append(t) + grouped_attrs = [ + { + "id": a["id"], + "name": a["name"], + "slug": a["slug"], + "terms": [{"id": t["id"], "name": t["name"], "slug": t["slug"]} for t in terms_by_attr.get(a["id"], [])], + } + for a in attributes + ] + return { + "categories": categories, + "attributes": grouped_attrs, + "tags": tags, + "taxonomy_anchor": anchor_row[0] if anchor_row else None, + "taxonomy_last_sync_at": sync_row[0] if sync_row else None, + } diff --git a/backend/tests/test_woo.py b/backend/tests/test_woo.py new file mode 100644 index 0000000..038c2a6 --- /dev/null +++ b/backend/tests/test_woo.py @@ -0,0 +1,140 @@ +"""S4 Woo client + taxonomy sync tests (mocked transport; never hit the real store).""" + +import base64 +import json +import os +import shutil +import tempfile + +import pytest + +import backend.db as db_mod +from backend.woo import ( + WooAuthError, + WooClient, + WooConnectionError, + WooCredentialsMissing, + WooServerError, +) +from backend import taxonomy as taxonomy_mod + + +@pytest.fixture +def db_dir(): + tmp = tempfile.mkdtemp(prefix="woo_") + os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "t.db") + 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() + db_mod._reset_for_tests() + conn = db_mod.init_db() + from backend.crypto import encrypt + + conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('woo_wp_username','admin',NULL)") + conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('woo_app_password',NULL,?)", (encrypt("secret"),)) + conn.commit() + yield conn + db_mod._reset_for_tests() + shutil.rmtree(tmp, ignore_errors=True) + + +def _client(responses): + """Build a WooClient whose transport returns pre-canned (code, headers, body) per call.""" + calls = {"n": 0} + + def fake_request(url, method, data, headers): + i = min(calls["n"], len(responses) - 1) + calls["n"] += 1 + code, hdr, body = responses[i] + return code, hdr, body + + return WooClient("admin", "secret", "https://store.example.com", request=fake_request), calls + + +def test_refuses_non_https(db_dir): + with pytest.raises(WooCredentialsMissing): + WooClient("admin", "secret", "http://insecure.example.com") + + +def test_basic_auth_header_and_url(db_dir): + seen = {} + + def fake_request(url, method, data, headers): + seen["url"] = url + seen["auth"] = headers["Authorization"] + return 200, {"X-WP-TotalPages": "1"}, b"[]" + + c = WooClient("admin", "secret", "https://store.example.com", request=fake_request) + c.get_categories() + assert seen["url"] == "https://store.example.com/wp-json/wc/v3/products/categories?per_page=100&page=1" + expected = "Basic " + base64.b64encode(b"admin:secret").decode() + assert seen["auth"] == expected + + +def test_pagination_follows_total_pages(db_dir): + p1 = (200, {"X-WP-TotalPages": "2"}, b'[{"id":1}]') + p2 = (200, {"X-WP-TotalPages": "2"}, b'[{"id":2}]') + c, calls = _client([p1, p2]) + data = c.get_tags() + assert [d["id"] for d in data] == [1, 2] + assert calls["n"] == 2 + + +def test_missing_password_fails_closed(db_dir): + with pytest.raises(WooCredentialsMissing): + WooClient.from_settings("https://store.example.com", "admin", None) + + +def test_401_raises_auth_error(db_dir): + def fake_request(url, method, data, headers): + raise WooAuthError("authentication rejected (401)") + + c = WooClient("admin", "secret", "https://store.example.com", request=fake_request) + with pytest.raises(WooAuthError): + c.get_categories() + + +def test_sync_populates_scoped(db_dir): + cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 1, "name": "Cedar", "slug": "cedar", "parent_id": None, "woo_id": 10}]).encode()) + tags = (200, {"X-WP-TotalPages": "1"}, json.dumps([ + {"id": 2, "name": "fig-curl", "slug": "fig-curl", "woo_id": 20}, + {"id": 3, "name": "feat-knot", "slug": "feat-knot", "woo_id": 21}, + {"id": 4, "name": "other-tag", "slug": "other-tag", "woo_id": 22}, # must be dropped + ]).encode()) + attrs = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 5, "name": "Edge Type", "slug": "edge", "woo_id": 30}]).encode()) + terms = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 6, "name": "Flat", "slug": "flat", "woo_id": 40}]).encode()) + c, _ = _client([cats, tags, attrs, terms]) + result = taxonomy_mod.sync(c, db_mod.get_connection()) + conn = db_mod.get_connection() + assert [r[0] for r in conn.execute("SELECT id FROM woo_categories")] == [1] + tag_ids = {r[0] for r in conn.execute("SELECT id FROM woo_tags")} + assert tag_ids == {2, 3} # fig- + feat- kept, other-tag dropped + assert [r[0] for r in conn.execute("SELECT id FROM woo_attribute_terms")] == [6] + assert result["taxonomy_anchor"] + assert conn.execute("SELECT value FROM settings WHERE key='taxonomy_last_sync_at'").fetchone()[0] + + +def test_sync_failure_keeps_previous_cache(db_dir): + conn = db_mod.get_connection() + conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (99,'Old','old',NULL,1)") + conn.commit() + + def bad_request(url, method, data, headers): + raise WooServerError("boom") + + c = WooClient("admin", "secret", "https://store.example.com", request=bad_request) + with pytest.raises(WooServerError): + taxonomy_mod.sync(c, conn) + # previous cache retained + assert [r[0] for r in conn.execute("SELECT id FROM woo_categories")] == [99] + + +def test_anchor_stable_across_identical_syncs(db_dir): + cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 1, "name": "Cedar", "slug": "cedar", "parent_id": None, "woo_id": 10}]).encode()) + tags = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 2, "name": "fig-curl", "slug": "fig-curl", "woo_id": 20}]).encode()) + attrs = (200, {"X-WP-TotalPages": "1"}, b"[]") + c1, _ = _client([cats, tags, attrs]) + r1 = taxonomy_mod.sync(c1, db_mod.get_connection()) + c2, _ = _client([cats, tags, attrs]) + r2 = taxonomy_mod.sync(c2, db_mod.get_connection()) + assert r1["taxonomy_anchor"] == r2["taxonomy_anchor"] diff --git a/backend/woo.py b/backend/woo.py new file mode 100644 index 0000000..184f505 --- /dev/null +++ b/backend/woo.py @@ -0,0 +1,139 @@ +"""WooCommerce REST v3 client (stdlib urllib). Basic-auth, HTTPS-only, paginated. + +Creds come from Settings (wp_username plain, app_password AES-GCM). Missing creds +raise WooCredentialsMissing (a fail-closed condition the caller maps to ok:false), +distinct from WooAuthError (a 401 from the store). +""" + +import base64 +import json +import urllib.error +import urllib.request +from typing import Any, Callable + +from .crypto import DecryptError, decrypt + + +class WooError(Exception): + """Base for all Woo client failures.""" + + +class WooCredentialsMissing(WooError): + """Woo base URL or credentials are not configured (fail-closed).""" + + +class WooAuthError(WooError): + """The store rejected the credentials (401).""" + + +class WooNotFoundError(WooError): + pass + + +class WooServerError(WooError): + pass + + +class WooConnectionError(WooError): + """Store unreachable (DNS, TLS, timeout).""" + + +def _default_request(url: str, method: str, data: bytes | None, headers: dict[str, str]): + req = urllib.request.Request(url, method=method, data=data) + for k, v in headers.items(): + req.add_header(k, v) + try: + with urllib.request.urlopen(req, timeout=15) as r: + return r.getcode(), dict(r.headers.items()), r.read() + except urllib.error.HTTPError as e: + body = e.read() + try: + parsed = json.loads(body) + except (ValueError, json.JSONDecodeError): + parsed = {} + 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 == 404: + raise WooNotFoundError(msg or "not found (404)") from e + raise WooServerError(f"server error {e.code}: {msg}") from e + except (urllib.error.URLError, OSError, TimeoutError) as e: + raise WooConnectionError(f"store unreachable: {e}") from e + + +class WooClient: + def __init__(self, username: str, password: str, base_url: str, + request: Callable[..., tuple[int, dict, bytes]] = _default_request) -> None: + if not base_url or not base_url.startswith("https://"): + raise WooCredentialsMissing("WOO_BASE_URL must be https:// (SETTINGS-10)") + self.base_url = base_url.rstrip("/") + self._request = request + self._auth = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii") + + @classmethod + def from_settings(cls, base_url: str, username: str | None, encrypted_password: str | None, + request: Callable[..., tuple[int, dict, bytes]] | None = None) -> "WooClient": + """Build from Settings values; raise WooCredentialsMissing if incomplete (fail-closed).""" + if not username: + raise WooCredentialsMissing("woo_wp_username not set (SETTINGS-15)") + if not encrypted_password: + raise WooCredentialsMissing("woo_app_password not configured (SETTINGS-15)") + try: + password = decrypt(encrypted_password) + except DecryptError as e: + raise WooCredentialsMissing("woo_app_password cannot be decrypted (re-enter it)") from e + if request is None: + request = _default_request + return cls(username, password, base_url, request) + + def _get(self, path: str) -> tuple[int, dict, bytes]: + url = f"{self.base_url}/wp-json/wc/v3{path}" + return self._request( + url, "GET", None, + {"Authorization": f"Basic {self._auth}", "Accept": "application/json"}, + ) + + def _fetch_all(self, path: str) -> list[dict[str, Any]]: + page = 1 + results: list[dict[str, Any]] = [] + while True: + code, headers, body = self._get(f"{path}?per_page=100&page={page}") + if code != 200: + raise WooServerError(f"expected 200, got {code}") + results.extend(json.loads(body)) + total_pages = int(headers.get("X-WP-TotalPages") or headers.get("x-wp-totalpages") or 1) + if page >= total_pages: + return results + page += 1 + + def get_categories(self) -> list[dict[str, Any]]: + return self._fetch_all("/products/categories") + + def get_tags(self) -> list[dict[str, Any]]: + return self._fetch_all("/products/tags") + + def get_attributes(self) -> list[dict[str, Any]]: + return self._fetch_all("/products/attributes") + + def get_attribute_terms(self, attr_id: int) -> list[dict[str, Any]]: + return self._fetch_all(f"/products/attributes/{attr_id}/terms") + + def upload_media(self, png_bytes: bytes, filename: str) -> int: + """POST a PNG to WordPress core media; return the attachment id.""" + boundary = "----slabuploader-media-boundary" + body = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n' + "Content-Type: image/png\r\n\r\n" + ).encode("utf-8") + png_bytes + f"\r\n--{boundary}--\r\n".encode("utf-8") + url = f"{self.base_url}/wp-json/wp/v2/media" + code, _headers, resp = self._request( + url, "POST", body, + { + "Authorization": f"Basic {self._auth}", + "Content-Type": f"multipart/form-data; boundary={boundary}", + }, + ) + if code not in (200, 201): + raise WooServerError(f"media upload returned {code}") + return int(json.loads(resp)["id"]) diff --git a/frontend/src/create/settings/Settings.svelte b/frontend/src/create/settings/Settings.svelte index 19029d1..e63d443 100644 --- a/frontend/src/create/settings/Settings.svelte +++ b/frontend/src/create/settings/Settings.svelte @@ -91,6 +91,18 @@ saving = false; } + let testResult: { ok: boolean; detail: string } | null = null; + + async function testWoo() { + testResult = null; + try { + const res = await fetch('/api/v1/settings/test-woo', { method: 'POST' }); + testResult = await res.json(); + } catch { + testResult = { ok: false, detail: 'network error' }; + } + } + onMount(() => { void load(); }); @@ -158,6 +170,12 @@ + + {#if testResult} +

+ {testResult.ok ? 'Connected & synced' : testResult.detail} +

+ {/if} {#if message}

{message}

{/if} {/if}