diff --git a/.env.example b/.env.example index 9347d32..1d87d37 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,10 @@ -APP_SECRET= -DATA_DIR=/data +# At least 16 characters. Encrypts the model keys you paste in /settings (AES-256-GCM). +# Changing it makes already-stored keys unreadable — you will have to paste them again. +APP_SECRET=replace-with-32-random-characters + +# Where qai.db lives. Default ./data; the Docker image sets /data. +DATA_DIR=./data + +# Only for a direct https deploy with no proxy setting X-Forwarded-Proto. +# Leave unset on plain http: Safari will not store a Secure cookie there. +# COOKIE_SECURE=1 diff --git a/.qa-data/qai.db b/.qa-data/qai.db new file mode 100644 index 0000000..4a3492c Binary files /dev/null and b/.qa-data/qai.db differ diff --git a/.qa-data/qai.db-shm b/.qa-data/qai.db-shm new file mode 100644 index 0000000..7d69b2e Binary files /dev/null and b/.qa-data/qai.db-shm differ diff --git a/.qa-data/qai.db-wal b/.qa-data/qai.db-wal new file mode 100644 index 0000000..5f905cb Binary files /dev/null and b/.qa-data/qai.db-wal differ diff --git a/README.md b/README.md index f70568b..495e7d5 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ bash scripts/smoke.sh # healthz, login page, signed-out redirect, analy | --- | --- | | `APP_SECRET` | ≥16 chars. Encrypts API keys at rest (AES-256-GCM). Changing it makes stored keys unreadable. | | `DATA_DIR` | where `qai.db` (SQLite, `node:sqlite`) lives. Default `./data`. Docker uses `/data`. | +| `COOKIE_SECURE` | set to `1` only when the app is served over https without a proxy that sets `X-Forwarded-Proto`. The session cookie is `Secure` when that header says `https`; forcing it on plain http makes Safari refuse to store it. | Model keys are **not** config. Each user types them in the app (first-run guide or `/settings`), so a second user brings their own endpoint and their own spend. Blank key field = keep the stored key. @@ -56,12 +57,17 @@ brings their own endpoint and their own spend. Blank key field = keep the stored ```bash npm test # node --test on tests/*.test.ts, no test framework installed npm run build # production build + type check -npm run e2e # Playwright: signup, settings, real analyst + real Jev run, copy, print, history, 4 widths +npm run e2e # builds first, then Playwright against the production server ``` The unit suites run offline: the analyst and Jev are exercised through injected `fetch`, so no key and no network. -`npm run e2e` is the opposite — it drives the real app against the real models, and reads `JEV_API_KEY` from -`.env.local`. Expect it to be slow; that is the models, not the harness. +`npm run e2e` is the opposite — `pree2e` builds, then Playwright drives `npm run start` (the production server, +not `next dev`: cookie flags and hydration differ, and that difference once hid a sign-out bug) against the real +models. It reads `JEV_API_KEY` from `.env.local`. Expect it to be slow; that is the models, not the harness. + +Projects: `chromium` (everything except the session spec, reusing one signed-in session), `webkit` (Safari — +session survival only, because it is the one engine that rejects a `Secure` cookie over `http://localhost`), +and `mobile` (Pixel 7, layout only, so no model run is paid for twice). `tests/digest.test.ts` skips unless a genuine plan exists at `fixtures/real.sqlplan`. Drop one there from SSMS and the skip becomes the real check on ShowPlan attribute spellings. diff --git a/app/app/layout.tsx b/app/app/layout.tsx index 9ca239c..359a1d9 100644 --- a/app/app/layout.tsx +++ b/app/app/layout.tsx @@ -1,8 +1,14 @@ +import { redirect } from "next/navigation"; import { requireUser } from "@/lib/auth"; +import { settingsView } from "@/lib/settings"; import { Rail } from "@/components/rail"; export default async function Console({ children }: { children: React.ReactNode }) { const u = await requireUser(); + const v = settingsView(u.id); + // Same requirement /api/analyze enforces (it answers 412). Without this, a user who closes the guide + // mid-way lands on a console that cannot run anything, and only finds out when Analyse turns red. + if (!v.has_analyst || !v.has_jev) redirect("/setup?step=2"); return
{children}
diff --git a/e0c47bcd-00af-49ae-aac4-c32a92af6f01_upscayl_4x_upscayl-standard-4x.png b/e0c47bcd-00af-49ae-aac4-c32a92af6f01_upscayl_4x_upscayl-standard-4x.png new file mode 100644 index 0000000..5593a34 Binary files /dev/null and b/e0c47bcd-00af-49ae-aac4-c32a92af6f01_upscayl_4x_upscayl-standard-4x.png differ diff --git a/lib/auth.ts b/lib/auth.ts index 10ffae8..4bbad87 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -33,6 +33,7 @@ export function signUpNewUser(email: string, password: string): { ok: true; id: // next/headers is imported lazily: `node --test` loads this module to test the crypto above, and the // request-scoped binding must not be touched at import time. const jar = async () => (await import("next/headers")).cookies(); +const fwdProto = async () => ((await (await import("next/headers")).headers())).get("x-forwarded-proto") ?? ""; export function changeEmail(userId: string, email: string): { ok: true } | { ok: false; error: string } { const e = email.trim().toLowerCase(); @@ -47,9 +48,15 @@ export function changeEmail(userId: string, email: string): { ok: true } | { ok: export async function startSession(userId: string) { const token = randomBytes(32).toString("hex"); putSession(hashToken(token), userId, Date.now() + SESSION_DAYS * 864e5); + const secure = process.env.COOKIE_SECURE === "1" || (await fwdProto()).includes("https"); (await jar()).set(SESSION_COOKIE, token, { - httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", - path: "/", maxAge: SESSION_DAYS * 86400 }); + httpOnly: true, sameSite: "lax", secure, + path: "/", maxAge: SESSION_DAYS * 86400, + // NODE_ENV is the wrong signal here. A production build served over plain http://localhost would set + // Secure, Safari then refuses to store the cookie at all, and every page after signup looks signed-out. + // (Chromium accepts Secure on localhost, which is why a chromium-only suite could never see it.) + // Only a TLS proxy reports https; COOKIE_SECURE=1 forces it on for a direct https deploy. + }); } export async function endSession() { const store = await jar(); diff --git a/package.json b/package.json index 07b3a6d..a1e8c63 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "start": "next start", "lint": "eslint", "test": "node --no-warnings --test tests/*.test.ts", - "e2e": "playwright test" + "e2e": "playwright test", + "pree2e": "npm run build" }, "dependencies": { "@openuidev/lang-core": "^0.3.0", @@ -29,4 +30,4 @@ "eslint-config-next": "16.3.5", "typescript": "^5" } -} +} \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts index 52bfc3c..0917fee 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -18,11 +18,19 @@ export default defineConfig({ use: { baseURL: "http://localhost:3000", trace: "retain-on-failure" }, projects: [ { name: "setup", testMatch: /auth\.setup\.ts/ }, - { name: "chromium", use: { ...devices["Desktop Chrome"] }, dependencies: ["setup"] }, + // The expensive specs are chromium-only (each real run costs ~30s of model time) and reuse the + // session the setup project wrote. + { name: "chromium", use: { ...devices["Desktop Chrome"] }, dependencies: ["setup"], + testIgnore: /session\.spec\.ts/ }, + // Safari/WebKit only checks session survival: it is the one engine that refuses a Secure cookie on + // http://localhost, which is exactly how the wizard "signed itself out" in a production build. + { name: "webkit", use: { ...devices["Desktop Safari"] }, testMatch: /session\.spec\.ts/ }, // A real model run costs ~40s; the mobile project only needs the layout checks, not a second full run. { name: "mobile", use: { ...devices["Pixel 7"] }, dependencies: ["setup"], testMatch: /responsive\.spec\.ts/ }, ], - webServer: { command: "npm run dev", url: "http://localhost:3000/api/healthz", reuseExistingServer: true, + webServer: { // Run the production server: the cookie flags and hydration behaviour differ from dev, and that +// difference hid a sign-out bug from a dev-only suite. `npm run pree2e` builds before this boots. +command: "npm run start", url: "http://localhost:3000/api/healthz", reuseExistingServer: false, timeout: 120_000, env: { DATA_DIR: ".playwright-data", APP_SECRET: process.env.APP_SECRET ?? "playwright-secret-value" } }, }); diff --git a/probe-tmp.mjs b/probe-tmp.mjs new file mode 100644 index 0000000..0fd8405 --- /dev/null +++ b/probe-tmp.mjs @@ -0,0 +1,30 @@ +import { chromium } from "@playwright/test"; + +const BASE = "http://127.0.0.1:3100"; +const b = await chromium.launch(); +const ctx = await b.newContext({ baseURL: BASE }); +const p = await ctx.newPage(); +const where = () => new URL(p.url()).pathname + new URL(p.url()).search; +const h1 = async () => (await p.locator("h1").first().innerText().catch(() => "?")); + +const email = `qa${Date.now()}@test.dev`; +await p.goto("/signup"); +await p.fill("#email", email); +await p.fill("#password", "0123456789ab"); +await p.getByRole("button", { name: "Create account" }).click(); +await p.waitForURL(/setup|app|login/, { timeout: 20_000 }); +console.log("1 signup done :", where(), "| h1:", await h1()); + +await p.getByRole("link", { name: "Next" }).click(); +await p.waitForURL(/step=2/, { timeout: 20_000 }); +console.log("2 clicked Next :", where(), "| h1:", await h1()); + +await p.getByRole("link", { name: "Next" }).click(); +await p.waitForTimeout(2500); +console.log("3 Next again :", where(), "| h1:", await h1(), "| alert:", await p.getByRole("alert").count()); + +// Is the session still alive after that? +await p.goto("/app"); +await p.waitForTimeout(2000); +console.log("4 open /app :", where(), "| h1:", await h1()); +await b.close(); diff --git a/tests-e2e/edge.spec.ts b/tests-e2e/edge.spec.ts index 39946ea..b79f463 100644 --- a/tests-e2e/edge.spec.ts +++ b/tests-e2e/edge.spec.ts @@ -13,7 +13,9 @@ function withDb(fn: (d: DatabaseSync) => T): T { test("a row left running renders what exists instead of redirecting forever", async ({ page }) => { const id = withDb((d) => { - const userId = d.prepare("SELECT id FROM users ORDER BY rowid DESC LIMIT 1").get() as { id: string }; + // The setup project's account, not "the newest user": the webkit specs sign up their own accounts and + // would otherwise put this row under an account the stored session cannot open (404, not the note). + const userId = d.prepare("SELECT id FROM users WHERE email LIKE 'qa+%@qai.test' ORDER BY rowid DESC LIMIT 1").get() as { id: string }; const aid = randomUUID(); // Backdated so it never becomes the rail's first row: history.spec.ts depends on that row being a real run. d.prepare("INSERT INTO analyses (id, user_id, title, xml, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?)") diff --git a/tests-e2e/session.spec.ts b/tests-e2e/session.spec.ts new file mode 100644 index 0000000..ee611b5 --- /dev/null +++ b/tests-e2e/session.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from "@playwright/test"; + +// WebKit-only on purpose. Chromium accepts a `Secure` cookie on http://localhost; Safari does not, and it +// silently drops it, so a production build on plain http looked like "the wizard signed me out". +// This file must stay self-contained: it signs up its own account and never uses .auth/user.json. + +test("signup, wizard Next links, and /app stay logged in over plain http", async ({ page }) => { + const email = `safari${Date.now()}@qai.test`; + await page.goto("/signup"); + await page.fill("#email", email); + await page.fill("#password", "0123456789ab"); + await page.getByRole("button", { name: "Create account" }).click(); + + await expect(page).toHaveURL(/\/setup/, { timeout: 20_000 }); + await expect(page.getByText("Step 1 of 3")).toBeVisible(); + + await page.getByRole("link", { name: "Next" }).click(); + await expect(page).toHaveURL(/step=2/); + await expect(page.getByText("Say which models to ask.")).toBeVisible(); + + await page.getByRole("link", { name: "Next" }).click(); + await expect(page).toHaveURL(/step=3/); + + // Session proof: this user has no keys yet, so the console gate sends them to the guide. + // Signed-in lands on /setup?step=2; a lost session would land on /login. + await page.goto("/app"); + await expect(page).toHaveURL(/\/setup\?step=2/); +}); + +test("a reload deep in the app keeps the session", async ({ page }) => { + const email = `reload${Date.now()}@qai.test`; + await page.goto("/signup"); + await page.fill("#email", email); + await page.fill("#password", "0123456789ab"); + await page.getByRole("button", { name: "Create account" }).click(); + // Wait for the signup POST to finish: the cookie is not in effect until its response lands, and an + // immediate goto races it (which reads as "the session did not survive a reload"). + await expect(page).toHaveURL(/\/setup/, { timeout: 20_000 }); + await page.goto("/settings"); + await page.reload(); + await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible(); + // The form is still here after the reload: this is the account's own settings page, not the login form. + await expect(page.getByRole("button", { name: "Save" })).toBeVisible(); +});