#!/usr/bin/env python3 """Is the Micron asset picker read-only, and does it degrade to manual entry? — D11. Cody Schaefer's `origin/Micron-Assets` branch, merged Aug 20 2026 and adapted to the R2 creator (decisions-2026-08-20.md). The properties this pins: * **Read-only, structurally.** assets_db.py holds one SELECT and nothing else; /api/assets has no writing verb. Picking an asset can never change Micron. * **Unconfigured is a first-class state.** No MICRON_DB_URL -> configured:false, the picker says so, and manual entry carries the package. The suite must run without Micron existing at all — every other probe implicitly relies on that. * **Broken is not a leak.** A configured-but-unusable URL 503s with a message that never echoes the connection string (whose parse errors can quote password fragments). * **The client honours the catalog.** Search ranks exact matches first, a picked row is locked to the DB's own casing and badged, imports canonicalise casing / fall back to manual / skip duplicates, and the import summary goes through the T7.9 dialog kit, not a native alert(). Boots its own throwaway SQLite + uvicorn + headless browser; run it alone, not back to back with other probes. Exit 0 all passed, 1 a failure, 2 could not run. """ import io import json import os import re import sys import tempfile import time sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import cdp # noqa: E402 from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402 from sections_check import set_sop # noqa: E402 from stepper_check import dismiss_dialogs # noqa: E402 from qa_gate_check import api # noqa: E402 ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) HTML = os.path.join(ROOT, "html") SERVER = os.path.join(ROOT, "server") def ascii_(v, n=240): return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n] def wait_creator(page, tries=40): for _ in range(tries): if page.eval("!!window.wpCreatorReady"): return True time.sleep(0.3) return False def strip_py(src): src = re.sub(r'""".*?"""', "", src, flags=re.S) return "\n".join(re.sub(r"#.*$", "", ln) for ln in src.split("\n")) def main(): exe = cdp.find_browser() if not exe: print("no headless-capable browser found; set WP_BROWSER.") return 2 # ── 1. read-only, structurally ───────────────────────────────────────────── print("\n1. read-only, structurally") src = strip_py(io.open(os.path.join(SERVER, "assets_db.py"), encoding="utf-8").read()) verbs = re.findall(r"\b(INSERT|UPDATE|DELETE|MERGE|EXEC|TRUNCATE|DROP|ALTER)\b", src, re.I) chk("assets_db.py contains no writing SQL verb", not verbs, verbs) chk("...and exactly one SELECT (the whole schema contract)", len(re.findall(r"\bSELECT\b", src, re.I)) == 1) app_src = io.open(os.path.join(SERVER, "app.py"), encoding="utf-8").read() chk("/api/assets is a GET and only a GET", len(re.findall(r'@app\.get\("/api/assets"\)', app_src)) == 1 and not re.findall(r'@app\.(post|put|patch|delete)\("/api/assets', app_src)) outside = [f for f in ("models.py", "auth.py", "notify.py") if "MICRON_DB_URL" in io.open(os.path.join(SERVER, f), encoding="utf-8").read()] chk("the connection string is env-only plumbing, not model or auth state", not outside, outside) tmpdir = tempfile.mkdtemp(prefix="wpsuite-assets-") db_path = os.path.join(tmpdir, "check.db") server = None browser = None try: tok = seed(db_path) set_sop(db_path, {}) port = cdp.free_port() base = "http://127.0.0.1:%d" % port server = start_server(port, db_path) # ── 2. the API's unconfigured state ──────────────────────────────────── print("\n2. unconfigured is a first-class state") st, _ = api(base, "/api/assets", "not-a-session") chk("anonymous gets 401, same as every other /api/ path", st == 401, st) st, body = api(base, "/api/assets", tok["root"]) chk("signed in, no MICRON_DB_URL: 200 with configured:false", st == 200 and body and body.get("configured") is False and body.get("assets") == [], ascii_(body)) chk("...and the detail tells the user what to do instead", "manual" in (body.get("detail") or "").lower(), ascii_(body)) # ── 3. the picker, catalog absent ────────────────────────────────────── print("\n3. the picker degrades to manual entry") browser = cdp.Browser(exe) page = browser.page() page.clear_cookies() page.set_cookie("wp_session", tok["root"]) page.viewport(1440, 900) page.goto(base + "/wp-creation-index.html?project=projA") dismiss_dialogs(page) chk("the creator boots", wait_creator(page)) time.sleep(1.2) chk("the search box is disabled and says the catalog is not configured", page.eval("(() => { const b=document.getElementById('asset-search');" " return b.disabled && /not configured/i.test(b.placeholder); })()")) chk("the source note announces it (role=status, non-empty)", page.eval("(() => { const n=document.getElementById('asset-source-note');" " return n.getAttribute('role')==='status' && n.textContent.length>0; })()")) chk("no assets yet: the empty state renders instead of a blank table", page.eval("/No assets yet/.test(document.getElementById('asset-body').textContent)")) page.eval("addManualAsset()") chk("+ Add asset adds an editable manual row", page.eval("pkgAssets.length") == 1 and page.eval("pkgAssets[0].source") == "manual" and page.eval("!!document.querySelector('#asset-body input')")) page.eval("document.querySelector('#asset-body input').value='HAND-01';" "document.querySelector('#asset-body input')" ".dispatchEvent(new Event('input',{bubbles:true}))") chk("...and typing lands in the model", page.eval("pkgAssets[0].tag") == "HAND-01") # ── 4. the client honours the catalog (injected; no SQL Server here) ── print("\n4. search, pick, import — against an injected catalog") page.eval("pkgAssets=[]; buildAssets();" "assetCatalog=['AHU-2P-014','AHU-2P-015','PUMP-01','XPUMP-PUMP-011','CT-100'];" "assetCatalogIndex=new Map(assetCatalog.map(t=>[t.toLowerCase(),t]));" "assetCatalogState='ready';" "(() => { const b=document.getElementById('asset-search');" " b.disabled=false; b.placeholder='Search asset IDs'; })()") page.eval("runAssetSearch('pump-01')") chk("an exact match outranks a longer contains-match", page.eval("JSON.stringify(assetResults)") == '["PUMP-01","XPUMP-PUMP-011"]', ascii_(page.eval("JSON.stringify(assetResults)"))) chk("results render as real