Skip to content

Commit df56db7

Browse files
authored
feat: add createOAuthUser test endpoint for Oauth signup E2E (#4)
* feat: add createOAuthUser test endpoint for Oauth signup E2E * fix: add explicit return type on applyCookiesFromResponse to satisfy lint
1 parent 99d85aa commit df56db7

2 files changed

Lines changed: 198 additions & 21 deletions

File tree

src/playwright.ts

Lines changed: 97 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,19 @@ interface CreateUserOptions {
1414
pluginData?: Record<string, unknown>
1515
}
1616

17+
type OAuthProvider = 'google' | 'github' | 'apple' | 'microsoft' | 'facebook' | 'twitter' | 'discord' | 'gitlab'
18+
19+
interface CreateOAuthUserOptions {
20+
/** OAuth provider to simulate (e.g. 'google', 'github') */
21+
provider: OAuthProvider
22+
email?: string
23+
name?: string
24+
/** Provider account ID. Auto-generated if omitted. */
25+
providerAccountId?: string
26+
/** Plugin-specific options, keyed by plugin ID */
27+
pluginData?: Record<string, unknown>
28+
}
29+
1730
interface TestUser {
1831
id: string
1932
email: string
@@ -23,6 +36,10 @@ interface TestUser {
2336
plugins: Record<string, unknown>
2437
}
2538

39+
interface TestOAuthUser extends TestUser {
40+
account: { provider: OAuthProvider, providerAccountId: string }
41+
}
42+
2643
interface TestAuth {
2744
/**
2845
* Create a test user and set session cookies on the current browser context.
@@ -33,6 +50,16 @@ interface TestAuth {
3350
*/
3451
createUser: (options?: CreateUserOptions) => Promise<TestUser>
3552

53+
/**
54+
* Create a test OAuth user (Google, GitHub, etc) without going through the
55+
* real provider's auth flow. Uses internalAdapter.createOAuthUser, which
56+
* exercises the same database hooks as a real OAuth signup — including
57+
* databaseHooks.user.create.after AND databaseHooks.account.create.after
58+
* with the correct providerId. Use for testing OAuth-specific behavior in
59+
* your app's auth hooks.
60+
*/
61+
createOAuthUser: (options: CreateOAuthUserOptions) => Promise<TestOAuthUser>
62+
3663
/**
3764
* Delete a test user by email. Called automatically in teardown
3865
* for all users created during the test.
@@ -44,7 +71,7 @@ interface TestAuthFixtures {
4471
auth: TestAuth
4572
}
4673

47-
export type { CreateUserOptions, TestAuth, TestAuthFixtures, TestUser }
74+
export type { CreateOAuthUserOptions, CreateUserOptions, OAuthProvider, TestAuth, TestAuthFixtures, TestOAuthUser, TestUser }
4875

4976
/**
5077
* Create Playwright fixtures configured for your Better Auth app.
@@ -87,10 +114,33 @@ export function createTestFixtures(config: {
87114
throw new Error('baseURL must be configured in Playwright')
88115
}
89116

90-
const origin = baseURL.replace(/\/+$/, '')
117+
const verifiedBaseURL = baseURL
118+
const origin = verifiedBaseURL.replace(/\/+$/, '')
91119
const created: string[] = []
92120
const context = page.context()
93121

122+
// Apply Set-Cookie headers from a fetch response onto the browser context
123+
async function applyCookiesFromResponse(res: Response): Promise<void> {
124+
const domain = new URL(verifiedBaseURL).hostname
125+
const setCookieHeaders = res.headers.getSetCookie()
126+
const cookies = setCookieHeaders.map((header) => {
127+
const [nameValue, ...attrs] = header.split(';')
128+
const [name, ...valueParts] = nameValue!.split('=')
129+
const value = valueParts.join('=')
130+
return {
131+
name: name!.trim(),
132+
value,
133+
domain,
134+
path: '/',
135+
httpOnly: attrs.some(a => a.trim().toLowerCase() === 'httponly'),
136+
secure: attrs.some(a => a.trim().toLowerCase() === 'secure'),
137+
}
138+
}).filter(c => c.name && c.value)
139+
if (cookies.length > 0) {
140+
await context.addCookies(cookies)
141+
}
142+
}
143+
94144
const auth: TestAuth = {
95145
async createUser(options = {}) {
96146
const email
@@ -124,33 +174,59 @@ export function createTestFixtures(config: {
124174
plugins: Record<string, unknown>
125175
}
126176
created.push(email)
177+
await applyCookiesFromResponse(res)
127178

128-
// Extract Set-Cookie headers from the response and set them on the browser context
129-
const domain = new URL(baseURL).hostname
130-
const setCookieHeaders = res.headers.getSetCookie()
131-
const cookies = setCookieHeaders.map((header) => {
132-
const [nameValue, ...attrs] = header.split(';')
133-
const [name, ...valueParts] = nameValue!.split('=')
134-
const value = valueParts.join('=')
135-
return {
136-
name: name!.trim(),
137-
value,
138-
domain,
139-
path: '/',
140-
// Preserve httpOnly/secure from original cookie attributes
141-
httpOnly: attrs.some(a => a.trim().toLowerCase() === 'httponly'),
142-
secure: attrs.some(a => a.trim().toLowerCase() === 'secure'),
143-
}
144-
}).filter(c => c.name && c.value)
145-
if (cookies.length > 0) {
146-
await context.addCookies(cookies)
179+
return {
180+
id: data.user.id,
181+
email: data.user.email,
182+
name: data.user.name,
183+
session: data.session,
184+
plugins: data.plugins,
185+
}
186+
},
187+
188+
async createOAuthUser(options) {
189+
const email
190+
= options.email
191+
?? `test-oauth-${crypto.randomUUID().slice(0, 8)}@test.local`
192+
193+
const res = await fetch(`${origin}${basePath}/test-data/oauth-user`, {
194+
method: 'POST',
195+
headers: {
196+
'Content-Type': 'application/json',
197+
'X-Test-Secret': config.secret,
198+
},
199+
body: JSON.stringify({
200+
email,
201+
name: options.name,
202+
provider: options.provider,
203+
providerAccountId: options.providerAccountId,
204+
pluginData: options.pluginData,
205+
}),
206+
})
207+
208+
if (!res.ok) {
209+
const error = await res.text()
210+
throw new Error(
211+
`better-auth-playwright: createOAuthUser failed (${res.status}): ${error}`,
212+
)
147213
}
148214

215+
const data = (await res.json()) as {
216+
user: { id: string, email: string, name: string }
217+
session: { id: string, token: string }
218+
account: { provider: OAuthProvider, providerAccountId: string }
219+
plugins: Record<string, unknown>
220+
}
221+
created.push(email)
222+
await applyCookiesFromResponse(res)
223+
149224
return {
150225
id: data.user.id,
151226
email: data.user.email,
152227
name: data.user.name,
153228
session: data.session,
229+
account: data.account,
154230
plugins: data.plugins,
155231
}
156232
},

src/server.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,107 @@ export function testPlugin(options: TestPluginOptions = {}): BetterAuthPlugin {
145145
},
146146
),
147147

148+
createTestOAuthUser: createAuthEndpoint(
149+
'/test-data/oauth-user',
150+
{
151+
method: 'POST',
152+
body: z.object({
153+
email: z.string().email(),
154+
name: z.string().optional(),
155+
provider: z.enum(['google', 'github', 'apple', 'microsoft', 'facebook', 'twitter', 'discord', 'gitlab']),
156+
providerAccountId: z.string().optional(),
157+
pluginData: z.record(z.string(), z.any()).optional(),
158+
}),
159+
metadata: { isAction: false },
160+
},
161+
async (ctx) => {
162+
if (!secret)
163+
return ctx.json(null, { status: 404 })
164+
const headerSecret = ctx.headers?.get('x-test-secret')
165+
if (headerSecret !== secret) {
166+
return ctx.json({ error: 'Unauthorized' }, { status: 401 })
167+
}
168+
169+
const adapter = ctx.context.internalAdapter
170+
const email = ctx.body.email
171+
const name = ctx.body.name ?? email.split('@')[0]
172+
const provider = ctx.body.provider
173+
const providerAccountId = ctx.body.providerAccountId ?? `test-${provider}-${Date.now()}`
174+
175+
// 1. Create user + OAuth account in one transaction via the same
176+
// code path real OAuth signups use. Fires both
177+
// databaseHooks.user.create.after AND
178+
// databaseHooks.account.create.after with the correct providerId.
179+
const { user } = await adapter.createOAuthUser(
180+
{ email, name, emailVerified: true },
181+
{ providerId: provider, accountId: providerAccountId },
182+
)
183+
184+
// 2. Create session directly (bypasses auth flow)
185+
const session = await adapter.createSession(user.id)
186+
187+
// 3. Run test data plugins sequentially in registration order
188+
const pluginResults: Record<string, unknown> = {}
189+
for (const plugin of testPlugins) {
190+
const pluginOpts = ctx.body.pluginData?.[plugin.id] ?? {}
191+
if (!ctx.request) {
192+
return ctx.json(
193+
{ error: 'Internal error: request object missing from context' },
194+
{ status: 500 },
195+
)
196+
}
197+
const pluginCtx: CreateUserContext = {
198+
authContext: ctx.context,
199+
user,
200+
session,
201+
request: ctx.request,
202+
}
203+
try {
204+
pluginResults[plugin.id] = await plugin.onCreateUser(
205+
pluginCtx,
206+
pluginOpts,
207+
)
208+
}
209+
catch (err) {
210+
const message = err instanceof Error ? err.message : String(err)
211+
let rollbackNote = ''
212+
try {
213+
await adapter.deleteUser(user.id)
214+
}
215+
catch {
216+
rollbackNote = ' (warning: user rollback also failed — orphan record may exist)'
217+
}
218+
return ctx.json(
219+
{ error: `Plugin "${plugin.id}" failed: ${message}${rollbackNote}` },
220+
{ status: 500 },
221+
)
222+
}
223+
}
224+
225+
// 4. Re-fetch session after plugins (plugins may have updated it)
226+
const finalSession = await adapter.findSession(session.token)
227+
if (!finalSession) {
228+
return ctx.json(
229+
{ error: 'Session lookup failed after plugin execution' },
230+
{ status: 500 },
231+
)
232+
}
233+
234+
// 5. Set signed session cookie
235+
await setSessionCookie(ctx, {
236+
session: finalSession.session,
237+
user,
238+
})
239+
240+
return ctx.json({
241+
user: { id: user.id, email: user.email, name: user.name },
242+
session: { id: session.id, token: session.token },
243+
account: { provider, providerAccountId },
244+
plugins: pluginResults,
245+
})
246+
},
247+
),
248+
148249
deleteTestUser: createAuthEndpoint(
149250
'/test-data/delete-user',
150251
{

0 commit comments

Comments
 (0)