// Integration coverage for the better-auth + Neon stack: unlike the rest of // the suite these tests mock nothing — they drive the real /api/auth handlers, // which write real rows through the real pg pool. Requires DATABASE_URL (or // the Neon integration's mogplex_DATABASE_URL) to point at a database with the // neon/migrations schema applied; the file self-skips when neither is // resolvable so DB-less environments still run the rest of the suite. import crypto from "node:crypto"; import { expect, test } from "@playwright/test"; import { Pool } from "pg"; import { resolveNeonDatabaseUrl } from "./helpers/neon"; import { seedPasswordUser } from "./helpers/password-user"; const databaseUrl = resolveNeonDatabaseUrl(); const uniqueSuffix = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`; const email = `e2e-${uniqueSuffix}@e2e.mogplex.test`; const password = "e2e-Password-1234"; test.describe("better-auth against real Neon", () => { test.skip( !databaseUrl, "DATABASE_URL / mogplex_DATABASE_URL not resolvable — skipping real-database auth flow" ); let pool: Pool; test.beforeAll(() => { pool = new Pool({ connectionString: databaseUrl, max: 1 }); }); test.afterAll(async () => { // session/account rows cascade from the user delete; the profile row // provisioned by the user-create hook is removed explicitly first. await pool.query( `delete from profiles where auth_user_id in (select id from "user" where email = $1)`, [email] ); await pool.query('delete from "user" where email = $1', [email]); await pool.end(); }); test("email signup is blocked while existing password users can sign in through CLI OAuth", async ({ page, request, }) => { const signUp = await request.post("/api/auth/sign-up/email", { data: { name: "E2E Neon", email, password }, }); expect(signUp.status(), await signUp.text()).toBe(400); expect((await signUp.json()).code).toBe("EMAIL_PASSWORD_SIGN_UP_DISABLED"); const created = await pool.query( 'select id, "emailVerified" from "user" where email = $1', [email] ); expect(created.rowCount).toBe(0); const userId = await seedPasswordUser(pool, { email, password, verified: false, }); // requireEmailVerification gates sign-in until the address is verified. const unverifiedSignIn = await request.post("/api/auth/sign-in/email", { data: { email, password }, }); expect(unverifiedSignIn.status()).toBe(403); // Verify out-of-band (the emailed link is the production path) and the // same credentials produce a session. await pool.query( 'update "user" set "emailVerified" = true where email = $1', [email] ); const wrongPassword = await request.post("/api/auth/sign-in/email", { data: { email, password: "definitely-not-it-1234" }, }); expect(wrongPassword.status()).toBe(401); // Exercise the CLI OAuth round trip as `mogplex login` drives it: // authorize (no session) → /login carrying the authorize query → sign-in // → top-level navigation back through authorize → 302 to the loopback // redirect with an authorization code → PKCE token exchange. // // CI's database may not have pending migrations applied (they run on // deploy), so self-provision the CLI's public OAuth client with the // same idempotent upsert as neon/migrations/20260806100000. await pool.query(` insert into "oauthApplication" ( "id", "name", "clientId", "clientSecret", "redirectUrls", "type", "disabled", "createdAt", "updatedAt" ) values ( gen_random_uuid(), 'Mogplex CLI', 'mogplex-cli', '', 'http://127.0.0.1:24816/auth/callback,http://127.0.0.1:24818/auth/callback,http://localhost:24816/auth/callback,http://localhost:24818/auth/callback', 'public', false, now(), now() ) on conflict ("clientId") do update set "redirectUrls" = excluded."redirectUrls", "type" = excluded."type", "disabled" = excluded."disabled", "updatedAt" = now() `); const verifier = crypto.randomBytes(64).toString("base64url"); const challenge = crypto .createHash("sha256") .update(verifier) .digest("base64url"); const state = crypto.randomBytes(32).toString("base64url"); const redirectUri = "http://127.0.0.1:24816/auth/callback"; // Stand in for the CLI's loopback server: capture the code-bearing // navigation the browser makes after the authorize redirect. const { createServer } = await import("node:http"); let captureCallback!: (url: string) => void; const captured = new Promise((resolve) => { captureCallback = resolve; }); const loopback = createServer((req, res) => { res.writeHead(200, { "Content-Type": "text/plain" }); res.end("ok"); if (req.url?.startsWith("/auth/callback")) captureCallback(req.url); }); await new Promise((resolve) => loopback.listen(24816, "127.0.0.1", resolve) ); try { const authorizeQuery = new URLSearchParams({ response_type: "code", client_id: "mogplex-cli", redirect_uri: redirectUri, scope: "openid profile email offline_access read write", state, code_challenge: challenge, code_challenge_method: "S256", }); await page.goto(`/api/auth/mcp/authorize?${authorizeQuery.toString()}`); const loginUrl = new URL(page.url()); expect(loginUrl.pathname).toBe("/login"); expect(loginUrl.searchParams.get("client_id")).toBe("mogplex-cli"); await page.getByTestId("signin-email").fill(email); await page.getByTestId("signin-password").fill(password); await page.getByTestId("signin-submit").click(); await expect( page.getByRole("heading", { name: "Connect Mogplex CLI?" }) ).toBeVisible(); await expect( page.getByText("Stay connected when you are away") ).toBeVisible(); await page .getByRole("button", { name: "Allow access", exact: true }) .click(); const callbackUrl = new URL(await captured, redirectUri); expect(callbackUrl.searchParams.get("state")).toBe(state); const code = callbackUrl.searchParams.get("code"); expect(code).toBeTruthy(); const sessionRows = await pool.query( 'select id from "session" where "userId" = $1', [userId] ); expect(sessionRows.rowCount).toBeGreaterThan(0); // Public-client token exchange: PKCE verifier, no client secret. const tokenRes = await request.post("/api/auth/mcp/token", { form: { grant_type: "authorization_code", client_id: "mogplex-cli", code: code!, redirect_uri: redirectUri, code_verifier: verifier, }, }); const tokenBody = (await tokenRes.json()) as { access_token?: string; refresh_token?: string; id_token?: string; }; expect(tokenRes.status(), JSON.stringify(tokenBody)).toBe(200); expect(tokenBody.access_token).toBeTruthy(); expect(tokenBody.refresh_token).toBeTruthy(); expect(tokenBody.id_token).toBeTruthy(); // The rotating refresh grant works without a client secret too. const refreshRes = await request.post("/api/auth/mcp/token", { form: { grant_type: "refresh_token", client_id: "mogplex-cli", refresh_token: tokenBody.refresh_token!, }, }); const refreshBody = (await refreshRes.json()) as { access_token?: string; }; expect(refreshRes.status(), JSON.stringify(refreshBody)).toBe(200); expect(refreshBody.access_token).toBeTruthy(); expect(refreshBody.access_token).not.toBe(tokenBody.access_token); const cliHeaders = { Authorization: `Bearer ${refreshBody.access_token}`, }; const settingsRes = await request.get("/api/settings", { headers: cliHeaders, maxRedirects: 0, }); expect(settingsRes.status(), await settingsRes.text()).toBe(200); // Exercise the real destination query against the deployed schema; // mocked settings dialogs cannot detect nonexistent flow columns. const targetsRes = await page.request.get("/api/settings/model-targets", { maxRedirects: 0, }); expect(targetsRes.status(), await targetsRes.text()).toBe(200); const targets = await targetsRes.json(); expect(targets.automations).toEqual([]); expect( targets.surfaces.map((surface: { id: string }) => surface.id) ).toEqual(["chat", "slack", "cli", "control", "agents"]); const modelsRes = await request.get("/api/models?format=cli", { headers: cliHeaders, maxRedirects: 0, }); expect(modelsRes.status(), await modelsRes.text()).toBe(200); expect(await modelsRes.json()).toEqual(expect.any(Array)); const mcpServersRes = await request.get("/api/mcp-servers", { headers: cliHeaders, maxRedirects: 0, }); expect(mcpServersRes.status(), await mcpServersRes.text()).toBe(200); const inferenceRes = await request.post( "/api/cli/inference/chat/completions", { data: { messages: [] }, headers: cliHeaders, maxRedirects: 0, } ); expect(inferenceRes.status(), await inferenceRes.text()).toBe(400); expect(await inferenceRes.json()).toEqual({ error: { message: "messages is required" }, }); // The cliTokenTtl hook clamps CLI refresh tokens to 30 days even // though the provider-wide setting is effectively-never (MCP clients). const ttlRows = await pool.query( `select "expiresAt" from "oauthRefreshToken" where "clientId" = 'mogplex-cli' and "userId" = $1 and revoked is null`, [userId] ); expect(ttlRows.rowCount).toBeGreaterThan(0); const maxTtlMs = 31 * 24 * 60 * 60 * 1000; for (const row of ttlRows.rows) { const expiresAt = new Date(row.expiresAt).getTime(); expect(expiresAt).toBeLessThan(Date.now() + maxTtlMs); expect(expiresAt).toBeGreaterThan(Date.now()); } } finally { loopback.close(); loopback.closeAllConnections(); } // Retired PAT handoff: old CLI builds hitting /cli-auth get an upgrade // prompt, not a consent screen (and no login redirect loop). const browserRequest = page.context().request; const cliAuth = await browserRequest.get("/cli-auth", { maxRedirects: 0, }); expect(cliAuth.status()).toBe(200); expect(await cliAuth.text()).toContain("Update Mogplex to sign in"); // Sign-out invalidates the session server-side, not just the cookie. The // Origin header satisfies better-auth's CSRF check for cookie-backed // POSTs (browsers send it automatically; the API context does not). const signOut = await browserRequest.post("/api/auth/sign-out", { data: {}, headers: { origin: new URL(signUp.url()).origin }, }); expect(signOut.status()).toBe(200); const afterSignOut = await browserRequest.get("/api/auth/get-session"); const afterBody = (await afterSignOut.json().catch(() => null)) as { user?: { email?: string }; } | null; expect(afterBody?.user?.email ?? null).toBeNull(); // /cli-auth is public now (upgrade notice for pre-OAuth CLI builds) — // logged-out visitors see it directly instead of a login bounce. const loggedOutCliAuth = await browserRequest.get("/cli-auth", { maxRedirects: 0, }); expect(loggedOutCliAuth.status()).toBe(200); expect(await loggedOutCliAuth.text()).toContain( "Update Mogplex to sign in" ); }); });