tests/e2e/better-auth-neon.spec.ts
Rama main · réplica de lectura del repositorio original.
// 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<string>((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<void>((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"
);
});
});