Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 97 additions & 21 deletions src/playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ interface CreateUserOptions {
pluginData?: Record<string, unknown>
}

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<string, unknown>
}

interface TestUser {
id: string
email: string
Expand All @@ -23,6 +36,10 @@ interface TestUser {
plugins: Record<string, unknown>
}

interface TestOAuthUser extends TestUser {
account: { provider: OAuthProvider, providerAccountId: string }
}

interface TestAuth {
/**
* Create a test user and set session cookies on the current browser context.
Expand All @@ -33,6 +50,16 @@ interface TestAuth {
*/
createUser: (options?: CreateUserOptions) => Promise<TestUser>

/**
* 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<TestOAuthUser>

/**
* Delete a test user by email. Called automatically in teardown
* for all users created during the test.
Expand All @@ -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.
Expand Down Expand Up @@ -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<void> {
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
Expand Down Expand Up @@ -124,33 +174,59 @@ export function createTestFixtures(config: {
plugins: Record<string, unknown>
}
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<string, unknown>
}
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,
}
},
Expand Down
101 changes: 101 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}
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',
{
Expand Down
Loading