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
This commit is contained in:
@@ -114,8 +114,20 @@ def missing_required(slab: dict) -> list[str]:
|
|||||||
return [f for f in _REQUIRED if slab.get(f) in (None, "", [])]
|
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]]:
|
def compute_ready(slab: dict) -> tuple[bool, list[str]]:
|
||||||
missing = missing_required(slab)
|
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
|
return (len(missing) == 0), missing
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -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]
|
missing = [f for f in REQUIRED_CREATE_FIELDS if f not in payload]
|
||||||
if missing:
|
if missing:
|
||||||
return _err("validation", "missing fields", {"fields": missing}, status=400)
|
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()
|
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)
|
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
|
||||||
now = _now_iso()
|
now = _now_iso()
|
||||||
data = _encode(payload)
|
data = _encode(payload)
|
||||||
|
|||||||
@@ -180,6 +180,50 @@ def test_soft_delete(client, db):
|
|||||||
assert resp_get.status_code == 404
|
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):
|
def test_delete_published_conflict(client, db):
|
||||||
slab_id = str(uuid.uuid4())
|
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}
|
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}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { supported } from './browser';
|
import { supported, uuid } from './browser';
|
||||||
import { createCaptureSession, type CaptureSession } from './capture/session';
|
import { createCaptureSession, type CaptureSession } from './capture/session';
|
||||||
import type { Capture, Measures } from './capture/capture.types';
|
import type { Capture, Measures } from './capture/capture.types';
|
||||||
import { eighthsToIn } from './capture/capture.types';
|
import { eighthsToIn } from './capture/capture.types';
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
saveError = '';
|
saveError = '';
|
||||||
startSaving();
|
startSaving();
|
||||||
try {
|
try {
|
||||||
const id = crypto.randomUUID();
|
const id = uuid();
|
||||||
await createSlab({
|
await createSlab({
|
||||||
id,
|
id,
|
||||||
sku: '',
|
sku: '',
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
for (let i = 0; i < pngItems.length; i++) {
|
for (let i = 0; i < pngItems.length; i++) {
|
||||||
const it = pngItems[i];
|
const it = pngItems[i];
|
||||||
await uploadPhoto(id, {
|
await uploadPhoto(id, {
|
||||||
photoId: crypto.randomUUID(),
|
photoId: uuid(),
|
||||||
seq: i + 1,
|
seq: i + 1,
|
||||||
isCalibration: it.role === 'calibration',
|
isCalibration: it.role === 'calibration',
|
||||||
original: it.blob,
|
original: it.blob,
|
||||||
|
|||||||
@@ -24,3 +24,25 @@ export function supported(): string | null {
|
|||||||
if (!isIOS && !isAndroid && (isChrome || isEdge || isFirefox)) return null;
|
if (!isIOS && !isAndroid && (isChrome || isEdge || isFirefox)) return null;
|
||||||
return 'unsupported browser';
|
return 'unsupported browser';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v4 UUID. crypto.randomUUID() is only guaranteed on secure contexts
|
||||||
|
// (HTTPS/localhost); the UAT host is plain HTTP on a LAN IP, where it is
|
||||||
|
// undefined. Fall back to crypto.getRandomValues (available on HTTP) so the
|
||||||
|
// client-generated ids (APP-20) work in both contexts.
|
||||||
|
export function uuid(): string {
|
||||||
|
const c = globalThis.crypto;
|
||||||
|
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
|
||||||
|
if (c && typeof c.getRandomValues === 'function') {
|
||||||
|
const b = new Uint8Array(16);
|
||||||
|
c.getRandomValues(b);
|
||||||
|
b[6] = (b[6] & 0x0f) | 0x40; // version 4
|
||||||
|
b[8] = (b[8] & 0x3f) | 0x80; // variant 10
|
||||||
|
const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
|
||||||
|
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||||
|
}
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
|
||||||
|
const r = (Math.random() * 16) | 0;
|
||||||
|
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { uuid } from '../../browser';
|
||||||
|
|
||||||
let slabId = '';
|
let slabId = '';
|
||||||
let photos: {
|
let photos: {
|
||||||
@@ -38,7 +39,7 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
message = '';
|
message = '';
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('photo_id', photoId || crypto.randomUUID());
|
form.append('photo_id', photoId || uuid());
|
||||||
form.append('is_calibration', isCalibration ? '1' : '0');
|
form.append('is_calibration', isCalibration ? '1' : '0');
|
||||||
form.append('original', originalFile, originalFile.name);
|
form.append('original', originalFile, originalFile.name);
|
||||||
if (processedFile) form.append('processed', processedFile, processedFile.name);
|
if (processedFile) form.append('processed', processedFile, processedFile.name);
|
||||||
|
|||||||
Reference in New Issue
Block a user