fix: session cookie over plain http, console onboarding gate, webkit qa coverage
- the session cookie was Secure whenever NODE_ENV=production, so a production build on http://localhost got no cookie back in Safari: every page after signup looked signed-out. It is now Secure only when X-Forwarded-Proto says https, or when COOKIE_SECURE=1. - /app and /app/[id] now require an analyst and a Jev key, the same rule /api/analyze enforces with a 412. Closing the guide mid-way used to leave a console that could not run anything. - Playwright runs the production server (pree2e builds first) and gains a webkit project for session survival; chromium-only QA could never see a Secure-cookie rejection. - tests-e2e/session.spec.ts pins both; .env.example gains COOKIE_SECURE and stops shipping a blank APP_SECRET. - e2e: 21 passed (setup, chromium, webkit, mobile). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
5160a00db5
commit
2ebfcfb7a6
+10
-2
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||
|
||||
@@ -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 <div className="shell">
|
||||
<aside className="rail no-print" aria-label="Previous analyses"><Rail userId={u.id} /></aside>
|
||||
<main className="main">{children}</main>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 MiB |
+9
-2
@@ -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();
|
||||
|
||||
+3
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -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" } },
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
@@ -13,7 +13,9 @@ function withDb<T>(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 (?,?,?,?,?,?,?)")
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user