diff --git a/src/playwright.ts b/src/playwright.ts index be6fde6..2ea436c 100644 --- a/src/playwright.ts +++ b/src/playwright.ts @@ -14,6 +14,19 @@ interface CreateUserOptions { pluginData?: Record } +type OAuthProvider = 'google' | 'github' | 'apple' | 'microsoft' | 'facebook' | 'twitter' | 'discord' | 'gitlab' + +interface CreateOAuthUserOptions { + /** OAuth provider to simulate (e.g. 'google', 'github') */ + provider: OAuthProvider + email?: string + name?: string + /** Provider account ID. Auto-generated if omitted. */ + providerAccountId?: string + /** Plugin-specific options, keyed by plugin ID */ + pluginData?: Record +} + interface TestUser { id: string email: string @@ -23,6 +36,10 @@ interface TestUser { plugins: Record } +interface TestOAuthUser extends TestUser { + account: { provider: OAuthProvider, providerAccountId: string } +} + interface TestAuth { /** * Create a test user and set session cookies on the current browser context. @@ -33,6 +50,16 @@ interface TestAuth { */ createUser: (options?: CreateUserOptions) => Promise + /** + * Create a test OAuth user (Google, GitHub, etc) without going through the + * real provider's auth flow. Uses internalAdapter.createOAuthUser, which + * exercises the same database hooks as a real OAuth signup — including + * databaseHooks.user.create.after AND databaseHooks.account.create.after + * with the correct providerId. Use for testing OAuth-specific behavior in + * your app's auth hooks. + */ + createOAuthUser: (options: CreateOAuthUserOptions) => Promise + /** * Delete a test user by email. Called automatically in teardown * for all users created during the test. @@ -44,7 +71,7 @@ interface TestAuthFixtures { auth: TestAuth } -export type { CreateUserOptions, TestAuth, TestAuthFixtures, TestUser } +export type { CreateOAuthUserOptions, CreateUserOptions, OAuthProvider, TestAuth, TestAuthFixtures, TestOAuthUser, TestUser } /** * Create Playwright fixtures configured for your Better Auth app. @@ -87,10 +114,33 @@ export function createTestFixtures(config: { throw new Error('baseURL must be configured in Playwright') } - const origin = baseURL.replace(/\/+$/, '') + const verifiedBaseURL = baseURL + const origin = verifiedBaseURL.replace(/\/+$/, '') const created: string[] = [] const context = page.context() + // Apply Set-Cookie headers from a fetch response onto the browser context + async function applyCookiesFromResponse(res: Response): Promise { + const domain = new URL(verifiedBaseURL).hostname + const setCookieHeaders = res.headers.getSetCookie() + const cookies = setCookieHeaders.map((header) => { + const [nameValue, ...attrs] = header.split(';') + const [name, ...valueParts] = nameValue!.split('=') + const value = valueParts.join('=') + return { + name: name!.trim(), + value, + domain, + path: '/', + httpOnly: attrs.some(a => a.trim().toLowerCase() === 'httponly'), + secure: attrs.some(a => a.trim().toLowerCase() === 'secure'), + } + }).filter(c => c.name && c.value) + if (cookies.length > 0) { + await context.addCookies(cookies) + } + } + const auth: TestAuth = { async createUser(options = {}) { const email @@ -124,33 +174,59 @@ export function createTestFixtures(config: { plugins: Record } created.push(email) + await applyCookiesFromResponse(res) - // Extract Set-Cookie headers from the response and set them on the browser context - const domain = new URL(baseURL).hostname - const setCookieHeaders = res.headers.getSetCookie() - const cookies = setCookieHeaders.map((header) => { - const [nameValue, ...attrs] = header.split(';') - const [name, ...valueParts] = nameValue!.split('=') - const value = valueParts.join('=') - return { - name: name!.trim(), - value, - domain, - path: '/', - // Preserve httpOnly/secure from original cookie attributes - httpOnly: attrs.some(a => a.trim().toLowerCase() === 'httponly'), - secure: attrs.some(a => a.trim().toLowerCase() === 'secure'), - } - }).filter(c => c.name && c.value) - if (cookies.length > 0) { - await context.addCookies(cookies) + return { + id: data.user.id, + email: data.user.email, + name: data.user.name, + session: data.session, + plugins: data.plugins, + } + }, + + async createOAuthUser(options) { + const email + = options.email + ?? `test-oauth-${crypto.randomUUID().slice(0, 8)}@test.local` + + const res = await fetch(`${origin}${basePath}/test-data/oauth-user`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Test-Secret': config.secret, + }, + body: JSON.stringify({ + email, + name: options.name, + provider: options.provider, + providerAccountId: options.providerAccountId, + pluginData: options.pluginData, + }), + }) + + if (!res.ok) { + const error = await res.text() + throw new Error( + `better-auth-playwright: createOAuthUser failed (${res.status}): ${error}`, + ) } + const data = (await res.json()) as { + user: { id: string, email: string, name: string } + session: { id: string, token: string } + account: { provider: OAuthProvider, providerAccountId: string } + plugins: Record + } + created.push(email) + await applyCookiesFromResponse(res) + return { id: data.user.id, email: data.user.email, name: data.user.name, session: data.session, + account: data.account, plugins: data.plugins, } }, diff --git a/src/server.ts b/src/server.ts index d2806ec..46dcbf7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -145,6 +145,107 @@ export function testPlugin(options: TestPluginOptions = {}): BetterAuthPlugin { }, ), + createTestOAuthUser: createAuthEndpoint( + '/test-data/oauth-user', + { + method: 'POST', + body: z.object({ + email: z.string().email(), + name: z.string().optional(), + provider: z.enum(['google', 'github', 'apple', 'microsoft', 'facebook', 'twitter', 'discord', 'gitlab']), + providerAccountId: z.string().optional(), + pluginData: z.record(z.string(), z.any()).optional(), + }), + metadata: { isAction: false }, + }, + async (ctx) => { + if (!secret) + return ctx.json(null, { status: 404 }) + const headerSecret = ctx.headers?.get('x-test-secret') + if (headerSecret !== secret) { + return ctx.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const adapter = ctx.context.internalAdapter + const email = ctx.body.email + const name = ctx.body.name ?? email.split('@')[0] + const provider = ctx.body.provider + const providerAccountId = ctx.body.providerAccountId ?? `test-${provider}-${Date.now()}` + + // 1. Create user + OAuth account in one transaction via the same + // code path real OAuth signups use. Fires both + // databaseHooks.user.create.after AND + // databaseHooks.account.create.after with the correct providerId. + const { user } = await adapter.createOAuthUser( + { email, name, emailVerified: true }, + { providerId: provider, accountId: providerAccountId }, + ) + + // 2. Create session directly (bypasses auth flow) + const session = await adapter.createSession(user.id) + + // 3. Run test data plugins sequentially in registration order + const pluginResults: Record = {} + for (const plugin of testPlugins) { + const pluginOpts = ctx.body.pluginData?.[plugin.id] ?? {} + if (!ctx.request) { + return ctx.json( + { error: 'Internal error: request object missing from context' }, + { status: 500 }, + ) + } + const pluginCtx: CreateUserContext = { + authContext: ctx.context, + user, + session, + request: ctx.request, + } + try { + pluginResults[plugin.id] = await plugin.onCreateUser( + pluginCtx, + pluginOpts, + ) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + let rollbackNote = '' + try { + await adapter.deleteUser(user.id) + } + catch { + rollbackNote = ' (warning: user rollback also failed — orphan record may exist)' + } + return ctx.json( + { error: `Plugin "${plugin.id}" failed: ${message}${rollbackNote}` }, + { status: 500 }, + ) + } + } + + // 4. Re-fetch session after plugins (plugins may have updated it) + const finalSession = await adapter.findSession(session.token) + if (!finalSession) { + return ctx.json( + { error: 'Session lookup failed after plugin execution' }, + { status: 500 }, + ) + } + + // 5. Set signed session cookie + await setSessionCookie(ctx, { + session: finalSession.session, + user, + }) + + return ctx.json({ + user: { id: user.id, email: user.email, name: user.name }, + session: { id: session.id, token: session.token }, + account: { provider, providerAccountId }, + plugins: pluginResults, + }) + }, + ), + deleteTestUser: createAuthEndpoint( '/test-data/delete-user', {