From e36979617b938f04b4f684965be9f9d75cda95e9 Mon Sep 17 00:00:00 2001 From: Ty Date: Sun, 20 Sep 2026 21:15:29 -0700 Subject: [PATCH] fix(m3): uuid secure-context fallback + middle-tier SKU business rules - frontend: uuid() helper (crypto.randomUUID -> getRandomValues) for plain-HTTP LAN UAT - slabs.py: empty SKU at create -> NULL (no-SKU-yet is transient, filled in Review) - review.py: ready-gate enforces REVIEW-45 (SKU 3-24 alnum+hyphen) in the middle tier - DB CHECK stays as backstop only (referential/structural), per layering principle --- backend/review.py | 12 +++++ backend/slabs.py | 8 +++- backend/tests/test_slabs_api.py | 44 +++++++++++++++++++ frontend/src/App.svelte | 6 +-- frontend/src/browser.ts | 22 ++++++++++ frontend/src/create/photos/PhotoAttach.svelte | 3 +- 6 files changed, 90 insertions(+), 5 deletions(-) diff --git a/backend/review.py b/backend/review.py index 2b8c8b3..02da487 100644 --- a/backend/review.py +++ b/backend/review.py @@ -114,8 +114,20 @@ def missing_required(slab: dict) -> list[str]: return [f for f in _REQUIRED if slab.get(f) in (None, "", [])] +# REVIEW-45: SKU is 3 to 24 chars, alphanumeric and hyphen only. The middle tier +# (not the DB CHECK) is the business-logic arbiter here; the DB constraint is a backstop. +_SKU_RE = re.compile(r"^[A-Za-z0-9-]{3,24}$") + + +def sku_valid(sku: Any) -> bool: + return isinstance(sku, str) and bool(_SKU_RE.match(sku)) + + def compute_ready(slab: dict) -> tuple[bool, list[str]]: missing = missing_required(slab) + # A present-but-malformed SKU is not ready (business rule, enforced in the middle tier). + if "sku" not in missing and not sku_valid(slab.get("sku")): + missing.append("sku") return (len(missing) == 0), missing diff --git a/backend/slabs.py b/backend/slabs.py index e3ca79e..4669a64 100644 --- a/backend/slabs.py +++ b/backend/slabs.py @@ -110,8 +110,14 @@ async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]: missing = [f for f in REQUIRED_CREATE_FIELDS if f not in payload] if missing: return _err("validation", "missing fields", {"fields": missing}, status=400) + # Business rule (middle tier, not the DB): an empty/blank SKU at create means + # "not set yet" (the owner fills it in Review). Store NULL — the DB CHECK only + # governs present SKUs, and NULL is the honest "absent" representation. + sku = payload["sku"] + if isinstance(sku, str) and sku.strip() == "": + payload = {**payload, "sku": None} conn = get_connection() - if _sku_taken(conn, payload["sku"]): + if payload["sku"] is not None and _sku_taken(conn, payload["sku"]): return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409) now = _now_iso() data = _encode(payload) diff --git a/backend/tests/test_slabs_api.py b/backend/tests/test_slabs_api.py index b22fb14..1f5670e 100644 --- a/backend/tests/test_slabs_api.py +++ b/backend/tests/test_slabs_api.py @@ -180,6 +180,50 @@ def test_soft_delete(client, db): assert resp_get.status_code == 404 +def test_create_empty_sku_stored_as_null(client, db): + # Business rule (middle tier): empty SKU at create = "not set yet" -> stored as NULL, + # not rejected by the DB CHECK. This is the UAT "Continue to Review" path. + slab_id = str(uuid.uuid4()) + payload = {"id": slab_id, "sku": "", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None} + resp = client.post("/api/v1/slabs", json=payload) + assert resp.status_code == 201 + assert resp.json()["sku"] is None + # A second empty-SKU slab is allowed (NULLs are not unique-conflicts). + resp2 = client.post("/api/v1/slabs", json={**payload, "id": str(uuid.uuid4())}) + assert resp2.status_code == 201 + + +def test_create_null_sku_allowed(client, db): + slab_id = str(uuid.uuid4()) + payload = {"id": slab_id, "sku": None, "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None} + resp = client.post("/api/v1/slabs", json=payload) + assert resp.status_code == 201 + assert resp.json()["sku"] is None + + +def test_ready_gate_rejects_malformed_sku(client, db): + # REVIEW-45 enforced in the middle tier: a present-but-out-of-range/char-set SKU is not ready. + from backend.review import compute_ready, sku_valid + base = {"length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual"} + # valid + assert sku_valid("AB-1") is True + ready, missing = compute_ready({**base, "sku": "AB-1"}) + assert ready is True and "sku" not in missing + # too short + assert sku_valid("AB") is False + ready, missing = compute_ready({**base, "sku": "AB"}) + assert ready is False and "sku" in missing + # bad char + ready, missing = compute_ready({**base, "sku": "AB!"}) + assert ready is False and "sku" in missing + # too long + ready, missing = compute_ready({**base, "sku": "A" * 25}) + assert ready is False and "sku" in missing + # missing (None) + ready, missing = compute_ready({**base, "sku": None}) + assert ready is False and "sku" in missing + + def test_delete_published_conflict(client, db): slab_id = str(uuid.uuid4()) payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 4288d20..63cd5d6 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,6 +1,6 @@