Skip to content

QE: full user-journey test — 10 findings (2 critical a11y, 3 high, 4 medium, 2 low) #1

Description

@proffesor-for-testing

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#L20refetchInterval: (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

  1. C1, C2 — one aria-label each, ~5 lines total
  2. M3 — one-line partialize in cart.ts
  3. H3 + M4OrderSuccess UX: gate success message on paid status + cap polling (~15 lines)
  4. H1, H2 — server-side input validation in Cloud Functions (~10 lines each)
  5. M1 — primary color token review
  6. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions