Why curl wasn't enough (hydration + missing auth token), the bug catalogue (hydration crash, PWA middleware, consent principal bug, and the systemic 'frontend never sends the access token' issue), how to run the stack locally on the Mac, and the concrete Playwright real-token e2e suite to implement.
12 KiB
CannaManage — Local Docker Testing + Playwright E2E (with real tokens)
From: Homelab Lumen (Fedora workstation, Roo Code) To: Work Lumen (Mac, Kiro IDE) Date: 2026-06-13 Topic: How to test CannaManage with the local Docker stack on the Mac, why curl was not enough, what we found, and what extra Playwright tests need to be implemented — including how to wire real auth tokens.
TL;DR
We deployed CannaManage to the TrueNAS Docker stack and chased a string of "it loads but nothing works" bugs. The decisive lesson:
curlvalidates the server. Only a real browser validates the app. Many bugs only appear during React hydration or because the browser never sends the auth token that a hand-craftedcurldoes. We need automated Playwright E2E tests that drive a real browser through a real login and assert on real authenticated API calls.
This doc tells you how to run the stack locally on the Mac, what we found, and the concrete Playwright suite to build.
1. Why local Docker testing on the Mac matters
- Parity with prod. The TrueNAS deploy runs the exact
docker-compose.yml+ frontend/backend images. Running the same compose locally on the Mac reproduces prod-only bugs (SSG/hydration, service worker, the Next.js rewrite proxy, NextAuth cookies) thatnpm run devhides. - The proxy + auth chain only exists in the built app.
next devand unit tests bypass the/api/backend/* → BACKEND_URL/api/v1/*rewrite and the NextAuth session cookie flow. Bugs in that chain (we hit several) are invisible until you run the real container. - curl lies by omission. curl talks straight to the backend and you attach the
Authorizationheader by hand. The browser does NOT — see §3. So curl can return 200 on an endpoint that is 100% broken in the actual app.
How to run it locally on the Mac
# from the repo root
cd cannamanage
# Build + start the full stack (db + backend + frontend)
docker compose up -d --build
# Watch health
docker compose ps
docker compose logs -f backend # wait for "Started CannaManageApplication"
# App: http://localhost:3000 (frontend)
# API: http://localhost:8080 (backend) ← NOTE: on the Mac 8080 is free; see warning below
# DB : localhost:5432
# Seed the dev admin + data (after backend has run Flyway):
docker exec -i cannamanage-db psql -U cannamanage -d cannamanage < scripts/seed/init.sql
# Login creds (dev seed): admin@test.de / test123
⚠️ Port warning (bit us hard on TrueNAS): on the homelab server, host port 8080 was already taken by SearXNG, so the backend was remapped to host 8081 (internal stays 8080, so
BACKEND_URL=http://backend:8080is unchanged). On the Mac, 8080 is usually free — but always confirm which port the backend is actually published on before you curl it. I wasted a chunk of this session curling:8080and getting SearXNG's 404 page back, which looked like "all routes 404." Checkdocker compose ps/docker compose port backend 8080.
2. The tooling we added (so you can do the same)
On the Fedora workstation (no Node was installed!) we set up a Playwright browser probe:
- Installed Node 22 + npm, then
@playwright/mcp+ Chromium (npx playwright install chromium). - Wrote a standalone probe script:
cannamanage/scripts/debug/dashboard-probe.mjsIt logs in through the real/loginform and captures console errors, pageerrors, failed requests, and 4xx/5xx responses + a screenshot. This is what exposed every client-side bug that curl could not see.
On the Mac you already have Node, so just:
cd cannamanage/scripts/debug
npm install playwright
npx playwright install chromium
BASE_URL=http://localhost:3000 LOGIN_EMAIL=admin@test.de LOGIN_PASSWORD=test123 node dashboard-probe.mjs
There is also a Playwright MCP server now (server name playwright) wired into Roo's
mcp_settings.json — gives browser_navigate, browser_snapshot, browser_console_messages,
browser_network_requests, browser_take_screenshot as tools. Worth adding to Kiro too.
3. What we found (the bug catalogue)
A. App-wide hydration crash — the "Oops! Something went wrong" (FIXED, commit 4be9c4c)
- Root
app/layout.tsxrendered global client components (PwaInstallPrompt→useTranslations,Toaster,Sonner) as siblings of{children}, but only each route-group layout wrapped its own children inNextIntlClientProvider. Those globals had no intl context → next-intl threw on every page including/login→ React error boundary "Oops". - curl saw HTTP 200 for
/loginthe whole time — because the crash is in client hydration. - Fix: root layout is now
asyncand wraps the body inNextIntlClientProviderviagetMessages().
B. PWA assets caught by auth middleware (FIXED, commit 4be9c4c)
middleware.tsmatcher didn't exclude/manifest.jsonor/sw.js→ both 307'd to/login(HTML). Browser parsed HTML as JSON (manifest.json:1 Syntax error) and a stale/HTML service worker kept serving old bundles ("website hasn't changed after redeploy"). Bumpedsw.jsCACHE_NAMEv1→v2 to purge.
C. Consent / DSGVO 500 "User not found" (FIXED, commit 52251cf)
JwtAuthFiltersets the SpringAuthenticationprincipal to the userId (UUID) — the JWT subject is the userId, not the email. ButConsentController.resolveUserId()andDsgvoController.resolveUserId()didString email = auth.getName(); findByEmailAndTenantId(email, …). Searching the email column for a UUID never matched → "User not found" → consent banner unusable (check 404, Accept POST 500, button "didn't react"). Fixed by parsingauth.getName()as the userId.
D. ⭐ THE BIG ONE — frontend never sends the access token (NOT yet fixed — your call)
This is the systemic bug and the reason curl/browser disagree:
src/lib/api-client.tsaccepts an optionaltokenparam but nothing ever passes it. No service insrc/services/*reads the NextAuth session or attachesAuthorization: Bearer ….- So every authenticated call from the browser reaches the backend with NO token. Login works
only because
/api/v1/auth/*is in the backend'sshouldNotFilterallowlist. - That's why
/consent/check,/dashboard/stats,/distributions/recent,/consentetc. all fail in the browser but succeed when I curl them with a hand-added Bearer token. - The NextAuth JWT callback already stashes
token.accessToken(seesrc/lib/auth.ts), but thesessioncallback does not expose it to the client, andapi-clientdoesn't inject it.
Two viable fixes (decide architecture):
- Server-side proxy injection (recommended, most secure): replace the static
next.config.mjsrewrite with a Route Handler atapp/api/backend/[...path]/route.tsthat reads the session viaauth()and forwardsAuthorization: Bearer <token.accessToken>toBACKEND_URL. Token never touches client JS. Single choke point. - Client-side token attach: expose
accessTokenin thesessioncallback, then have each service (or a wrappedapiClient) callgetSession()and passtoken. Simpler but leaks the access token into the browser and touches every service.
E. Dashboard API contract mismatches (follow-up, non-fatal)
- Frontend calls
/dashboard/stats+/distributions/recent; backend has no such routes (/clubs/me/statsexists;/distributions/recenthas no/recentmapping → 500). Dashboard falls back to mock data so it renders. Either add the endpoints or fix the service paths + theClubStatsDTO shape (frontendtotalStockGrams/distributionsToday/monthlyQuotaUsagePercent≠ backendtotalDistributionsThisMonth/totalGramsDistributedThisMonth/activeBatches).
4. What we need in automated Playwright E2E tests
Goal: a CI-runnable suite that drives a real browser against the real Docker stack and
fails on exactly the classes of bugs above. The existing e2e/mock-backend.mjs mocks auth for
visual tours — that is NOT enough; it can't catch the token gap (D) or the principal bug (C)
because there's no real backend/JWT. We need a real-stack E2E profile.
4.1 Test harness
- Add
@playwright/testwith aplaywright.config.ts:webServer/ global-setup that runsdocker compose up -d --buildand waits forhttp://localhost:3000+ backend health, then seedsinit.sql.baseURL: http://localhost:3000.- Capture
trace,screenshot, and console + network on failure.
4.2 Real-token auth fixture (the key piece you asked about)
We must obtain and use real JWTs, two complementary ways:
(a) UI login fixture — a Playwright storageState created once by logging in through the
real form, so the NextAuth session cookie is reused across tests:
// global-setup.ts
const page = await browser.newPage()
await page.goto('/login')
await page.fill('#email', 'admin@test.de')
await page.fill('#password', 'test123')
await page.click('button[type=submit]')
await page.waitForURL('**/dashboard')
await page.context().storageState({ path: 'e2e/.auth/admin.json' })
Then tests use test.use({ storageState: 'e2e/.auth/admin.json' }).
(b) API token helper — for direct backend assertions and for seeding state fast, fetch a real JWT from the backend and reuse it:
export async function getApiToken(request, email='admin@test.de', password='test123') {
const res = await request.post(`${BACKEND_URL}/api/v1/auth/login`, { data: { email, password } })
return (await res.json()).accessToken // flat LoginResponse: {accessToken,refreshToken,expiresIn,role}
}
// use it: request.get('/api/v1/consent/check', { headers: { Authorization: `Bearer ${token}` } })
Store these in a tokens fixture so specs can hit the backend directly and the proxied
/api/backend/* path. Comparing the two is exactly how we'd have caught bug D automatically.
4.3 Specs to write (each asserts NO console/page errors + the right network result)
- Hydration smoke (catches A): for every top route (
/login,/dashboard,/members,/stock,/distributions,/reports,/portal-login,/pricing,/impressum) — navigate, assertpage.on('pageerror')count == 0 and no[console.error]. - PWA assets (catches B):
GET /manifest.json→ 200application/json;GET /sw.js→ 200application/javascript; assert NOT redirected to/login. - Authenticated proxy carries the token (catches D — the big one): after UI login, intercept
the request to
/api/backend/consent/checkand assert it (i) has anAuthorizationheader by the time it hits the backend and (ii) returns 200, not 401/500. This is the test that would have caught the whole token gap. - Consent lifecycle (catches C): fresh user → banner visible → click Accept → banner
disappears →
GET /api/backend/consent/checkreturnshasDataProcessingConsent: true→ reload, banner stays gone. - Dashboard data (catches E): assert
/dashboard/stats+/distributions/recentreturn 200 (once endpoints are aligned) and the dashboard shows real numbers, not mock fallback. Until fixed, mark astest.fixmereferencing the contract mismatch. - Contract guard: a spec that logs in via API helper and hits each documented backend route with the real token, asserting 2xx — a cheap regression net for principal/route bugs.
4.4 CI wiring
- Run on the Docker stack in CI (compose service),
playwright test, upload trace + screenshots as artifacts. Gate merges on the hydration smoke + token + consent specs.
5. Status / handover
- Commits on
main:4be9c4c(hydration + PWA),26a77b5(docs),52251cf(consent/dsgvo principal). - Backend + frontend rebuilt and redeployed on TrueNAS; consent verified at API level (200/200/200).
- OPEN — needs your decision + implementation: bug D (token never attached). Until that's fixed, the browser consent/stats calls still fail even though the backend is correct. Pick proxy injection vs client attach (I recommend the server-side proxy Route Handler).
- Full per-bug detail is in
cannamanage/docs/ROO-HANDOVER.mdand BigMind chunks 54/55/56 + facts 206/207/208.
— Homelab Lumen