Summary
Full user-journey QE pass against store.agentics.org combining API contract tests, UI E2E (Playwright), and accessibility audits (axe-core, WCAG 2.1 AA). The happy path works end-to-end — 20 products served, validation surfaces correct field errors, live Stripe Checkout session creation succeeds (cs_test_… URL returned 200), CORS is properly locked to store.agentics.org, mobile (375px) has 0 horizontal overflow.
This issue catalogs 10 findings discovered during testing, risk-ranked, with file/line references and suggested fixes.
📄 Full report: qe/REPORT.md
📁 All artifacts: qe/
Test scope:
- API: 19 cases across 5 Cloud Function endpoints (
storeListProducts, storeGetProduct, storeShippingRates, storeCreateCheckoutSession, storeGetOrderBySession, storeOrderLookup)
- UI: 17 journey steps from Home → Catalog → Product → Cart → Checkout → Stripe (intercepted) → OrderSuccess → Order Lookup → 404
- Responsive: Desktop 1280, Tablet 768, Mobile 375
- A11y: axe-core injected on 7 routes
🔴 Critical
C1 — Cart quantity input has no accessible label (WCAG A — label)
File: src/pages/Cart.tsx#L36-L43
Evidence: axe label violation on /cart. <input type="number"> has no aria-label and no associated <label htmlFor>.
Impact: Screen readers announce the field as "spin button" with no indication of what it controls.
Fix:
<input
type="number"
min={1}
aria-label={`Quantity for ${item.productName}`}
value={item.quantity}
onChange={(e) => setQuantity(item.variantId, Math.max(0, parseInt(e.target.value || "0", 10)))}
className="..."
/>
C2 — Variant <Select> triggers have no accessible name (WCAG A — button-name)
File: src/pages/ProductDetail.tsx#L140-L160
Evidence: axe button-name violation on /product/.... The Radix <SelectTrigger> renders as a <button>. The visible <label> above it is a bare <label> element with no htmlFor/id association.
Impact: Screen readers announce the Size and Color dropdown triggers with no name.
Fix: Add aria-label="Size" / aria-label="Color" to the <SelectTrigger> (simplest), or wire <label htmlFor="size-select"> to <SelectTrigger id="size-select">.
🟠 High
H1 — storeCreateCheckoutSession returns 500 (not 400) for out-of-bounds quantities
Endpoint: POST /storeCreateCheckoutSession
Evidence:
| Input |
Expected |
Actual |
quantity: -1 |
400 with validation msg |
500 {"error":"checkout failed"} |
quantity: 99999 |
400 or 200-with-cap |
500 {"error":"checkout failed"} |
Impact: Client UI prevents these via Math.max(0, …) and filter, but a direct/buggy client sees 500s. Generic error surfaces nothing actionable. |
|
|
Fix: Validate quantity is an integer in 1..N (e.g. 100) in storeCreateCheckoutSession and return 400 {"error":"quantity must be 1..N"}. |
|
|
H2 — storeGetProduct returns 500 for malformed id values
Endpoint: GET /storeGetProduct?id=<script>alert(1)</script> (URL-encoded)
Evidence:
| Input |
Expected |
Actual |
id=DOES_NOT_EXIST |
200 {"product":null} |
200 {"product":null} ✓ |
id=<script>alert(1)</script> |
400 / 404 |
500 {"error":"failed"} |
| Impact: Not XSS (response is JSON, not HTML), but differential error mapping leaks signal and fills error logs. |
|
|
Fix: Validate id against /^[A-Za-z0-9]+$/ (Firestore shape) before querying; return 400 on malformed. |
|
|
H3 — OrderSuccess shows "Thanks for your order!" even with bogus/missing session_id
File: src/pages/OrderSuccess.tsx#L30-L34, L69-L78
Evidence:
/order/success?session_id=cs_test_bogus → ✓ "Thanks for your order!" + "Your payment is being processed. This may take a moment." (polls every 3s forever)
/order/success (no param) → ✓ "Thanks for your order!" + "Missing session reference"
See screenshots: 14-order-success-bogus.png, 15-order-success-no-sid.png
Impact: A user whose payment failed and was redirected to success-with-no-order is told the order succeeded. A shareable /order/success URL falsely confirms an order.
Fix: Gate the success header on order && order.paymentStatus === "paid". Show "Looking up order…" / "Couldn't find this order — try Order Lookup" otherwise. See M4 for polling cap.
🟡 Medium
M1 — Color-contrast violations on every route (WCAG AA — color-contrast)
Where: axe flagged 33 nodes total across 7 routes — primary brand orange (#E25C3D) used as button background with white text fails 4.5:1; text-primary links also fail in some contexts. Raw data in qe/a11y-results.json.
Impact: Low-vision users may struggle to read CTAs. Compliance posture.
Fix: Darken the primary token in tailwind.config.ts or strengthen the foreground variant. Verify with a contrast checker.
M2 — Heading-order skips on most pages (best-practice — heading-order)
Where: Catalog, Cart, Checkout, OrderLookup, ProductDetail, 404 — pages jump from h1 directly to h3/h4. The SheetTitle in CartDrawer renders an h2 adjacent to a page h1.
Impact: Screen-reader users lose document structure when navigating by heading.
Fix: Use next consecutive level. For drawer titles, axe sometimes false-positives modal-scoped headings; verify per-route after other fixes.
M3 — Cart drawer isOpen state is persisted to localStorage
File: src/lib/cart.ts#L54
Evidence (reproduced in browser): After clicking "Add to cart", localStorage.agentics-store-cart contains {"state":{ "items":[...], "isOpen":true }}. Reloading the page re-opens the drawer overlay. Dismissal via Esc / click-outside / X works correctly — this is just an unintended re-open on revisit. See 06-after-reload-drawer-state.png.
Impact: Minor UX nit — drawer auto-opens on tab return.
Fix: One line:
persist(
(set, get) => ({ ... }),
{ name: "agentics-store-cart", version: 1, partialize: (s) => ({ items: s.items }) }
)
M4 — OrderSuccess polls every 3s forever when no order is ever found
File: src/pages/OrderSuccess.tsx#L20 — refetchInterval: (q) => (q.state.data?.printfulId ? false : 3000)
Impact: A tab left open on /order/success?session_id=<bad> keeps pinging storeGetOrderBySession every 3s indefinitely. Battery/network waste; multiplied across abandoned tabs it's a self-DoS pattern.
Fix: Cap with attempt counter (e.g. stop after 10 polls / 30s) and surface a "Couldn't confirm this order" CTA pointing to /order.
🟢 Low
L1 — storeOrderLookup silently returns {order: null} for any input
Endpoint: POST /storeOrderLookup
Evidence: {"email":"not-an-email","orderId":"x"} and {"email":"x@y.com","orderId":"1 OR 1=1"} both return 200 {"order":null}.
Note: Likely intentional anti-enumeration design — refusing to leak whether an email is known is fine. Flagging only for consistency with other endpoints that validate format.
Fix (optional): Return a generic 400 if email does not match a basic regex, for faster client-side feedback without leaking presence.
L2 — storeGetOrderBySession returns misleading error for nonexistent sessions
Endpoint: GET /storeGetOrderBySession?sessionId=bogus
Evidence: HTTP 400 {"error":"sessionId required"} — the param IS present, just invalid.
Fix: Message should be "order not found for sessionId" (with appropriate status code).
What works well ✅
- 20 products serve from Printful, 110 variants, all with thumbnails & images
- Live Stripe Checkout session creation returns valid
cs_test_… URL with full cart payload
- CORS preflight properly returns
access-control-allow-origin: https://store.agentics.org only
- Blank checkout form surfaces all 6 expected field-level Zod errors
- Server validates missing fields, bad rate ID, empty cart with clean 400s and specific error messages
- SEO complete: title, meta description, og:image, canonical, JSON-LD organization + product + breadcrumb data
- Responsive: 0px horizontal overflow on mobile (375px) and tablet (768px) on home/catalog/checkout
<html lang="en"> set; viewport meta present
- All 20 catalog images have alt text
- Negative/zero qty in cart correctly removes the item (intended via
Math.max(0, …) + filter)
- Cart drawer Esc / click-outside / X dismissal all work correctly
Suggested fix order
- C1, C2 — one
aria-label each, ~5 lines total
- M3 — one-line
partialize in cart.ts
- H3 + M4 —
OrderSuccess UX: gate success message on paid status + cap polling (~15 lines)
- H1, H2 — server-side input validation in Cloud Functions (~10 lines each)
- M1 — primary color token review
- L1, L2 — error-message wording polish
Repro artifacts (committed to branch qe-reports)
To reproduce:
git checkout qe-reports
npm install && npm run dev # in one terminal
# In another:
mkdir -p /tmp/qe-run && cd /tmp/qe-run
npm init -y && npm install playwright@1.60.0 axe-core@4.10.0
npx playwright install chromium --with-deps
cp <repo>/qe/journey.mjs <repo>/qe/a11y.mjs .
node journey.mjs
node a11y.mjs
Summary
Full user-journey QE pass against
store.agentics.orgcombining API contract tests, UI E2E (Playwright), and accessibility audits (axe-core, WCAG 2.1 AA). The happy path works end-to-end — 20 products served, validation surfaces correct field errors, live Stripe Checkout session creation succeeds (cs_test_…URL returned 200), CORS is properly locked tostore.agentics.org, mobile (375px) has 0 horizontal overflow.This issue catalogs 10 findings discovered during testing, risk-ranked, with file/line references and suggested fixes.
📄 Full report:
qe/REPORT.md📁 All artifacts:
qe/Test scope:
storeListProducts,storeGetProduct,storeShippingRates,storeCreateCheckoutSession,storeGetOrderBySession,storeOrderLookup)🔴 Critical
C1 — Cart quantity input has no accessible label (WCAG A —
label)File:
src/pages/Cart.tsx#L36-L43Evidence: axe
labelviolation on/cart.<input type="number">has noaria-labeland no associated<label htmlFor>.Impact: Screen readers announce the field as "spin button" with no indication of what it controls.
Fix:
C2 — Variant
<Select>triggers have no accessible name (WCAG A —button-name)File:
src/pages/ProductDetail.tsx#L140-L160Evidence: axe
button-nameviolation on/product/.... The Radix<SelectTrigger>renders as a<button>. The visible<label>above it is a bare<label>element with nohtmlFor/idassociation.Impact: Screen readers announce the Size and Color dropdown triggers with no name.
Fix: Add
aria-label="Size"/aria-label="Color"to the<SelectTrigger>(simplest), or wire<label htmlFor="size-select">to<SelectTrigger id="size-select">.🟠 High
H1 —
storeCreateCheckoutSessionreturns 500 (not 400) for out-of-bounds quantitiesEndpoint:
POST /storeCreateCheckoutSessionEvidence:
quantity: -1{"error":"checkout failed"}quantity: 99999{"error":"checkout failed"}Math.max(0, …)and filter, but a direct/buggy client sees 500s. Generic error surfaces nothing actionable.quantityis an integer in1..N(e.g. 100) instoreCreateCheckoutSessionand return400 {"error":"quantity must be 1..N"}.H2 —
storeGetProductreturns 500 for malformedidvaluesEndpoint:
GET /storeGetProduct?id=<script>alert(1)</script>(URL-encoded)Evidence:
id=DOES_NOT_EXIST{"product":null}{"product":null}✓id=<script>alert(1)</script>{"error":"failed"}idagainst/^[A-Za-z0-9]+$/(Firestore shape) before querying; return 400 on malformed.H3 —
OrderSuccessshows "Thanks for your order!" even with bogus/missingsession_idFile:
src/pages/OrderSuccess.tsx#L30-L34,L69-L78Evidence:
/order/success?session_id=cs_test_bogus→ ✓ "Thanks for your order!" + "Your payment is being processed. This may take a moment." (polls every 3s forever)/order/success(no param) → ✓ "Thanks for your order!" + "Missing session reference"See screenshots:
14-order-success-bogus.png,15-order-success-no-sid.pngImpact: A user whose payment failed and was redirected to success-with-no-order is told the order succeeded. A shareable
/order/successURL falsely confirms an order.Fix: Gate the success header on
order && order.paymentStatus === "paid". Show "Looking up order…" / "Couldn't find this order — try Order Lookup" otherwise. See M4 for polling cap.🟡 Medium
M1 — Color-contrast violations on every route (WCAG AA —
color-contrast)Where: axe flagged 33 nodes total across 7 routes — primary brand orange (
#E25C3D) used as button background with white text fails 4.5:1;text-primarylinks also fail in some contexts. Raw data inqe/a11y-results.json.Impact: Low-vision users may struggle to read CTAs. Compliance posture.
Fix: Darken the primary token in
tailwind.config.tsor strengthen the foreground variant. Verify with a contrast checker.M2 — Heading-order skips on most pages (best-practice —
heading-order)Where: Catalog, Cart, Checkout, OrderLookup, ProductDetail, 404 — pages jump from
h1directly toh3/h4. TheSheetTitleinCartDrawerrenders anh2adjacent to a pageh1.Impact: Screen-reader users lose document structure when navigating by heading.
Fix: Use next consecutive level. For drawer titles, axe sometimes false-positives modal-scoped headings; verify per-route after other fixes.
M3 — Cart drawer
isOpenstate is persisted to localStorageFile:
src/lib/cart.ts#L54Evidence (reproduced in browser): After clicking "Add to cart",
localStorage.agentics-store-cartcontains{"state":{ "items":[...], "isOpen":true }}. Reloading the page re-opens the drawer overlay. Dismissal via Esc / click-outside / X works correctly — this is just an unintended re-open on revisit. See06-after-reload-drawer-state.png.Impact: Minor UX nit — drawer auto-opens on tab return.
Fix: One line:
M4 —
OrderSuccesspolls every 3s forever when no order is ever foundFile:
src/pages/OrderSuccess.tsx#L20—refetchInterval: (q) => (q.state.data?.printfulId ? false : 3000)Impact: A tab left open on
/order/success?session_id=<bad>keeps pingingstoreGetOrderBySessionevery 3s indefinitely. Battery/network waste; multiplied across abandoned tabs it's a self-DoS pattern.Fix: Cap with attempt counter (e.g. stop after 10 polls / 30s) and surface a "Couldn't confirm this order" CTA pointing to
/order.🟢 Low
L1 —
storeOrderLookupsilently returns{order: null}for any inputEndpoint:
POST /storeOrderLookupEvidence:
{"email":"not-an-email","orderId":"x"}and{"email":"x@y.com","orderId":"1 OR 1=1"}both return200 {"order":null}.Note: Likely intentional anti-enumeration design — refusing to leak whether an email is known is fine. Flagging only for consistency with other endpoints that validate format.
Fix (optional): Return a generic
400ifemaildoes not match a basic regex, for faster client-side feedback without leaking presence.L2 —
storeGetOrderBySessionreturns misleading error for nonexistent sessionsEndpoint:
GET /storeGetOrderBySession?sessionId=bogusEvidence: HTTP
400 {"error":"sessionId required"}— the param IS present, just invalid.Fix: Message should be
"order not found for sessionId"(with appropriate status code).What works well ✅
cs_test_…URL with full cart payloadaccess-control-allow-origin: https://store.agentics.orgonly<html lang="en">set; viewport meta presentMath.max(0, …)+ filter)Suggested fix order
aria-labeleach, ~5 lines totalpartializeincart.tsOrderSuccessUX: gate success message on paid status + cap polling (~15 lines)Repro artifacts (committed to branch
qe-reports)qe/REPORT.md— full prose reportqe/findings.json— Playwright output: console errors, network log, all Cloud Function calls observedqe/a11y-results.json— axe-core raw violations per routeqe/screenshots/— 25 screenshots across 21 journey stepsqe/journey.mjs,qe/a11y.mjs— runnable test scriptsTo reproduce: