diff --git a/.env.dev.example b/.env.dev.example index d26b9f14..2c876a01 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -15,9 +15,15 @@ DEV_MOCK_JMAP=true # Point the app at its own mock endpoint. -# IMPORTANT: This must match the origin the app runs on (default: port 3000). -# Using a different port (e.g. 3001) will cause CORS errors. -JMAP_SERVER_URL=/api/dev-jmap +# IMPORTANT: must be an ABSOLUTE URL matching the origin the app runs on +# (default: port 3000) - NOT a relative path. A relative path here makes +# /api/auth/stalwart-context 400 on every request (resolveTrustedJmapUrl +# rejects it), which silently breaks the real server-side session-cookie +# flow that S/MIME enrollment, offline sync, and the AI server/retrieval +# routes all depend on. The client-side mock fetch works either way, which +# is why this is easy to miss - it only bites features needing a real +# server-side session identity. +JMAP_SERVER_URL=http://localhost:3000/api/dev-jmap # ============================================================================= # App diff --git a/docs/TEST-BASIS-2026-08-06.md b/docs/TEST-BASIS-2026-08-06.md new file mode 100644 index 00000000..04b3bf77 --- /dev/null +++ b/docs/TEST-BASIS-2026-08-06.md @@ -0,0 +1,232 @@ +# Test basis — 2026-08-06 morning + +Covers everything shipped overnight: AI Assistant (web), S/MIME (web + mobile), +and the 2-theme rebrand. For each area: how to get into a testable state, +concrete steps with expected results, and — importantly — what's still a +known gap so nothing here gets mistaken for more finished than it is. + +All web steps assume `vncmail-plus` running locally via the built-in mock +JMAP server (fastest path, no real Stalwart needed): + +```bash +cp .env.dev.example .env.local +# then set, in .env.local: +# AI_SERVER_BASE_URL=http://127.0.0.1:11434 (real Ollama on this Mac) +# SMIME_CA_DEV_LOCAL=true +npm run dev +``` + +Open `http://localhost:3000` and **log in via the "Anmelden" (dev-mode +login) button, not "Demo starten"**. Demo mode is a pure client-side +in-memory session with no server-side cookie — AI's `server` class, S/MIME +enrollment, and offline routes all need the real cookie the login button +sets, and will look broken (401s) under Demo mode for reasons that have +nothing to do with the features themselves. + +--- + +## 1. AI Assistant (web) + +Settings → AI Assistant. + +### 1.1 Local (Ollama on this Mac) + +1. Select provider **Local**. Model list should populate from the running + `ollama serve` (confirm with `ollama list` in a terminal first). +2. Ask a question in "Try it" with no special mail content, e.g. "reply with + exactly the words LOCAL AI WORKS". +3. **Expect:** exact echo back, no seat/entitlement banner (local is free, + unmetered, never reaches this app's own backend). + +### 1.2 Server (this app's backend → your Ollama) + +1. Select provider **Server**. Model list comes from `/api/ai/server/models` + (same models as Local, proxied — confirms the same-origin proxy path + works, not just direct-to-loopback). +2. Ask a first question. **Expect:** a small "seat assigned" notice on the + *first* successful call for this user, none on subsequent calls. +3. Check `data/admin-state/ai-metering.jsonl` on disk — a new line should + appear with real `promptTokens`/`completionTokens`/`latencyMs` from the + actual Ollama call, not placeholders. +4. To test the entitlement *ceiling*: `curl -X PUT localhost:3000/api/admin/ai/entitlement -H 'Content-Type: application/json' -d '{"seatsTotal":0}'` + (needs an admin session cookie), then retry a Server-class question as a + *different* username. **Expect:** HTTP 402, "no licensed seats configured". + Set `seatsTotal` back up afterwards. + +### 1.3 Public (BYOK) + +1. Add a profile: name, model, base URL, API key (any real provider you + have a key for, or a fake key just to test the UI — the request itself + will fail at the provider, not in this app). +2. Add a *second* profile with a different name/model. +3. In "Try it", use the **"Answer with"** selector to choose between the two + profiles for the same question. **Expect:** each answers using its own + configured model, no cross-talk. +4. Remove a profile from the list. **Expect:** it disappears immediately, + and its key is gone from `localStorage` (`DevTools → Application → + Local Storage`, key prefix `vncmail.ai.key.`). + +### 1.4 Retrieval (grounded answers over your own mail) + +The demo/mock inbox includes a "Villa sul Lago" booking confirmation email. + +1. On **Server** or **Local**, ask: *"When is check-in for the Villa sul + Lago booking?"* +2. **Expect:** a specific date/time, with a cited source referencing the + actual email (not a generic non-answer). This exercises the full + pipeline: real JMAP `Email/query`+`Email/get`, real Ollama embeddings + (`nomic-embed-text`), cosine similarity, RRF fusion with the local + text-match leg. + +### 1.5 Known gaps — don't be surprised by these + +- **No admin UI yet for any of the above.** Seat totals, model allow-lists, + per-class on/off, usage — all real on the backend (§1.2, §1.3) but only + reachable via `curl`/direct API calls today. A full spec + visual mockup + for the admin console exists at + [`docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md`](ADMIN-AI-POLICY-CONSOLE-SPEC.md) + — presented for approval, **intentionally not built** pending sign-off. +- **BYOK provider allow-listing does not exist at all yet** — a user can + point a Public profile at literally any URL. Not a regression, just never + built; see the spec's §6.1 for the proposed (client-side-only) approach. +- Tier (base/standard/pro) is stored but doesn't change behavior yet — only + `seatsTotal` gates anything. + +--- + +## 2. S/MIME — web + +### 2.1 What to test today + +With `SMIME_CA_DEV_LOCAL=true` set, the CA-issuance backend is real and +independently verified (4 passing tests: CSR signature verification, full +chain verification via `leaf.verify(caCert)`, correct SAN/rfc822Name +addresses, `emailProtection` EKU present — see +`lib/smime-ca/__tests__/local-dev-provider.test.ts`). + +There is **no user-facing "Enroll" button wired up yet** — that was a +deliberate scope decision made overnight (see the commit message on +`c2c07293`), not an oversight: this plugin has known, still-open +security-audit findings, and generating brand-new CSR/key-import code +against it at 1am risked introducing a new one rather than closing an old +one. So today, test the backend directly: + +```bash +# Generate a throwaway CSR (any tool - openssl shown here): +openssl req -new -newkey rsa:2048 -nodes -keyout /tmp/test.key \ + -out /tmp/test.csr -subj "/CN=Test User" + +# Call the enrollment route directly (needs an authenticated session cookie +# from the "Anmelden" login above - copy it from DevTools → Application → +# Cookies, or use a logged-in curl session): +curl -X POST localhost:3000/api/smime/enroll \ + -H 'Content-Type: application/json' \ + --cookie "" \ + -d "{\"csrPem\": $(python3 -c 'import json,sys; print(json.dumps(open("/tmp/test.csr").read()))'), \"addresses\": [\"you@example.com\"]}" +``` + +**Expect:** a JSON response with `certificatePem`, `chainPem`, `serialNumber`, +`notAfter`. Verify it's a real cert: `openssl x509 -in <(echo "$certificatePem") -noout -text` +should show issuer `VNCmail+ LOCAL DEV S/MIME CA`, your `you@example.com` in +Subject Alternative Name, and Extended Key Usage including `E-mail +Protection`. + +### 2.2 Known gap — the important one + +**Nobody can actually enroll from the UI today.** The plugin's Settings +section still has no "Get a certificate" button, no client-side CSR +generation, and no wiring to import an issued cert into its existing +encrypted-at-rest key storage (`vnc/plugins/smime/src/pkcs12.js`'s +AES-GCM+PBKDF2 convention). The plugin itself is confirmed *mounted* +(privileged iframe, same-origin fetch works without a bridge) with 0 certs +imported — that's expected, not a bug. This is real, well-scoped follow-up +work, not blocked on anything — just deliberately not rushed overnight. + +--- + +## 3. S/MIME — mobile (vncmail-native) + +Unlike web, mobile got the **full stack**, merged to `main` this session +(`7b89839`, combining the S/MIME work with the AI/offline-sync work that +landed on `main` in parallel): real CMS sign/verify/encrypt/decrypt on +`node-forge` primitives (wire-compatible with the audited webmail plugin, +verified byte-for-byte against OpenSSL), PKCS#12 import, keys wrapped +AES-256-GCM/PBKDF2 in `expo-secure-store` (Keychain/Keystore — never +AsyncStorage in the clear). + +### 3.1 Test steps + +1. Build and run on a simulator/device (`npm run ios` / `npm run android` + from `vncmail-native`). +2. Settings → S/MIME → **Import certificate**. Use a real `.p12` file (an + OpenSSL-generated one works fine for testing) and its passphrase. +3. **Expect:** cert imported, persists across an app restart, a wrong + passphrase is rejected, the right one unlocks it. +4. Compose a new email to yourself → enable **Sign** (and/or **Encrypt**) → + send. +5. Open the received message. **Expect:** a signature banner showing + verified/signed-by, and if encrypted, the body decrypts and displays + normally. +6. Try receiving a deliberately malformed/unauthenticated-cipher message + (or just trust the 259-line hardening test suite — + `src/lib/__tests__/smime-hardening.test.ts` — covers this without + needing to hand-craft one). **Expect:** unauthenticated content renders + as inert plaintext, never live HTML. + +### 3.2 Known-good, already verified + +479+ unit tests (834 after the merge with the AI/sync work) pass, including +a full round-trip test. `typecheck` is clean. Also verified live on an +Android emulator during that session: real OpenSSL-3-generated `.p12` +imported through the actual device file picker. + +### 3.3 Known gap + +No cross-checking was done *this session* between the mobile +implementation and the CA now issuing certs on web (§2) — they were built +independently and haven't been tested importing a *web-CA-issued* +certificate into the mobile app. Worth doing once web's Enroll UI (§2.2) +exists, not before. + +--- + +## 4. Theme + +Settings → Themes. Exactly 2 built-in themes now: **SRC** (default, Swiss +red `#D52B1E`) and **VNClagoon** (navy/cyan). The other 6 generic built-ins +(Nord, Catppuccin, Solarized, Roundcube Elastic, Aurora Glass, plus the old +default) are hidden via admin theme policy, not deleted — an admin could +re-enable them, a normal user can't see them. + +### 4.1 Test steps + +1. Confirm SRC is active on first load (no theme ever selected before). +2. Switch to VNClagoon. **Expect:** a "Theme activated" toast, and the + chrome genuinely re-skins (navy/cyan replaces red throughout — sidebar, + buttons, unread markers, not just an accent color here and there). +3. Switch back to SRC. **Expect:** same, in reverse. +4. Search the UI (empty-state illustrations, manifest/PWA name, login page, + locale strings in a couple of languages) for the string "Bulwark" — all + 24 locale files were rebranded to "VNCmail+" this session, plus + `app/manifest.ts`, demo fixtures, and the inbox empty-state logo, which + now resolves through the same theme-logo system every other themed + surface uses instead of a hardcoded path. + +### 4.2 Known gap + +Not exhaustively re-checked for every locale/every screen — the 24-file +find-and-replace was mechanical (script-driven) and spot-checked, not +manually walked screen-by-screen in each language. If a stray "Bulwark" +turns up somewhere, it's most likely a screen that wasn't in scope of the +locale-string sweep (e.g. a hardcoded string in a component rather than a +translation key) — same category as the logo fix above, worth a quick grep +(`grep -ril bulwark`) if one surfaces. + +--- + +## 5. Offline / mail-index + +Already QA'd and fixed this session (not re-listed as a to-do): a leaked +SQLite handle on `PRAGMA` failure, and contacts/files that weren't being +removed from the local index on delete. Both fixed and covered by tests — +no separate manual test needed tomorrow unless something else turns up.