1// Generic email OTP auth tests - parameterized for different apps 2// Apps can import and use these with their own GraphQL query names 4import { chromium, type Browser, type BrowserContext, type Page } from 'playwright' 10export type AuthTestConfig = { 11 // GraphQL query names (vary by app schema) 12 otpQueryName?: string // default: gqUserTokFromEmailOtp 13 userFromTokQueryName?: string // default: gqUserFromToken (set null to skip those tests) 14 reqEmailLoginMutationName?: string // e.g. gmReqEmailLogin, gmReqGamesEmailLogin (set null to skip) 15 // Browser test config 16 loggedInSelector?: string // CSS selector or text to find when logged in (default: text="Logout") 17 skipBrowserTests?: boolean // skip playwright browser tests 20const defaultConfig: AuthTestConfig = { 21 otpQueryName: 'gqUserTokFromEmailOtp', 22 userFromTokQueryName: 'gqUserFromToken', 23 loggedInSelector: 'text=Logout', 29 browserTestEmail?: string 31 browserContext?: BrowserContext 38type TestResult = { passed: boolean; msg: string } 39type TestFn = () => Promise<TestResult> 40type Test = { name: string; fn: TestFn } 42// Returns array of generic auth tests that can be combined with app-specific tests 43export const mkCoreAuthTests = (cfg: AuthTestConfig = {}): ((ctx: TestCtx, baseUrl: string) => Test[]) => { 44 const config = {...defaultConfig, ...cfg} 45 const {otpQueryName, userFromTokQueryName, reqEmailLoginMutationName, loggedInSelector, skipBrowserTests} = config 47 const otpGql = `query($email: String!, $email_token: String!) { ${otpQueryName}(email: $email, email_token: $email_token) }` 48 const userFromTokGql = userFromTokQueryName 49 ? `query($gqUserTok: String!) { ${userFromTokQueryName}(gqUserTok: $gqUserTok) { id email } }` 51 const reqEmailLoginGql = reqEmailLoginMutationName 52 ? `mutation($email: String!, $returnpath: String) { ${reqEmailLoginMutationName}(email: $email, returnpath: $returnpath) }` 55 return (ctx: TestCtx, baseUrl: string): Test[] => { 56 const tests: Test[] = [] 58 // Request email login test (tests the email sending path - catches missing fromName etc) 59 if (reqEmailLoginGql) { 61 name: 'reqEmailLoginWorks', 64 const resp = await gqlFetch({baseUrl, query: reqEmailLoginGql, variables: {email: testEmail, returnpath: '/'}}) 65 // Mutation returns null on success, errors array on failure 66 const ok = !resp?.errors?.length 67 return {passed: ok, msg: ok ? '' : `error: ${JSON.stringify(resp?.errors)}`} 74 name: 'badOtpRejected', 76 const resp = await gqlFetch({baseUrl, query: otpGql, variables: {email: 'test@example.com', email_token: '000000000'}}) 77 const hasErr = resp?.errors?.length > 0 78 return {passed: hasErr, msg: hasErr ? '' : `unexpected success: ${JSON.stringify(resp?.data)}`} 83 name: 'validOtpReturnsToken', 87 const resp = await gqlFetch({baseUrl, query: otpGql, variables: {email: ctx.testEmail, email_token: validOtp}}) 88 ctx.gqUserTok = resp?.data?.[otpQueryName!] 89 const ok = !!ctx.gqUserTok && !resp?.errors?.length 90 return {passed: ok, msg: ok ? '' : `unexpected: ${JSON.stringify(resp?.errors || resp?.data)}`} 94 // User from token tests (only if query name provided) 97 name: 'tokenResolvesToUser', 99 if (!ctx.gqUserTok) return {passed: false, msg: 'skipped - no token from previous step'} 100 const resp = await gqlFetch({baseUrl, query: userFromTokGql, variables: {gqUserTok: ctx.gqUserTok}}) 101 const user = resp?.data?.[userFromTokQueryName!] 102 const ok = user?.email === ctx.testEmail 103 return {passed: ok, msg: ok ? user.email : `unexpected: ${JSON.stringify(resp?.errors || user)}`} 108 name: 'badTokenReturnsNull', 110 const resp = await gqlFetch({baseUrl, query: userFromTokGql, variables: {gqUserTok: 'nonexistent_token'}}) 111 const ok = resp?.data?.[userFromTokQueryName!] === null 112 return {passed: ok, msg: ok ? '' : `unexpected: ${JSON.stringify(resp)}`} 117 // Browser tests (playwright) 118 if (!skipBrowserTests) { 120 name: 'emailLoginLinkSetsCookie', 124 const loginUrl = `${baseUrl}/?email_token=${emailToken}&email=${encodeURIComponent(ctx.browserTestEmail)}` 127 ctx.browser = await chromium.launch({headless: true}) 128 ctx.browserContext = await ctx.browser.newContext() 129 const page = await ctx.browserContext.newPage() 130 await page.goto(loginUrl, {waitUntil: 'load'}) 133 for (let i = 0; i < 40 && !cookie; i++) { 134 await page.waitForTimeout(250) 135 const cookies = await ctx.browserContext.cookies() 136 cookie = cookies.find(c => c.name === userTokenKey) 138 ctx.hasCookie = !!cookie?.value 140 return {passed: ctx.hasCookie, msg: ctx.hasCookie ? userTokenKey : 'cookie not set after 10s'} 145 name: 'emailLoginLinkShowsLoggedIn', 147 if (!ctx.hasCookie) { 148 await ctx.browser?.close() 149 return {passed: false, msg: 'skipped - no cookie'} 151 let hasLoggedInUi = false 152 for (let i = 0; i < 30 && !hasLoggedInUi; i++) { 153 await ctx.page?.waitForTimeout(500) 154 // Support both text= selectors and CSS selectors 155 const selector = loggedInSelector! 156 if (selector.startsWith('text=')) { 157 const text = selector.slice(5) 158 hasLoggedInUi = !!(await ctx.page?.getByText(text, {exact: true}).first().isVisible().catch(() => false)) 160 hasLoggedInUi = !!(await ctx.page?.locator(selector).first().isVisible().catch(() => false)) 163 await ctx.browser?.close() 164 return {passed: hasLoggedInUi, msg: hasLoggedInUi ? '' : `selector "${loggedInSelector}" not found after 15s`} 173// Game room tests config 174export type GameRoomTestConfig = { 175 beforeCreateRoom?: (ctx: TestCtx, baseUrl: string) => Promise<void> // e.g. call faucet 176 roomQueryName?: string // default: gqGame 177 roomActionName?: string // default: gameAction 178 createRoomName?: string // default: gmCreateGameRoom 181// Generic game room tests (for apps with gmCreateGameRoom, gqGame, gameAction) 182export const mkGameRoomTests = (cfg: GameRoomTestConfig = {}) => { 185 roomQueryName = 'gqGame', 186 roomActionName = 'gameAction', 187 createRoomName = 'gmCreateGameRoom', 190 return (ctx: TestCtx, baseUrl: string): Test[] => { 191 const tests: Test[] = [] 194 name: 'createGameRoom', 196 if (!ctx.gqUserTok) return {passed: false, msg: 'skipped - no auth token'} 197 if (beforeCreateRoom) await beforeCreateRoom(ctx, baseUrl) 198 const resp = await gqlFetch({baseUrl, gqUserTok: ctx.gqUserTok, query: `mutation { ${createRoomName} }`}) 199 ctx.gameRoom = resp?.data?.[createRoomName] 200 const words = ctx.gameRoom?.split('-') || [] 201 const ok = words.length === 4 && words.every((w: string) => w.length >= 3) 202 return {passed: ok, msg: ok ? ctx.gameRoom! : `unexpected: ${JSON.stringify(resp?.errors || ctx.gameRoom)}`} 207 name: 'createGameRoomIdempotent', 209 if (!ctx.gqUserTok) return {passed: false, msg: 'skipped - no auth token'} 210 const resp = await gqlFetch({baseUrl, gqUserTok: ctx.gqUserTok, query: `mutation { ${createRoomName} }`}) 211 const gameRoom2 = resp?.data?.[createRoomName] 212 const ok = gameRoom2 === ctx.gameRoom 213 return {passed: ok, msg: ok ? gameRoom2 : `different: ${ctx.gameRoom} vs ${gameRoom2}`} 218 name: 'createGameRoomRequiresAuth', 220 const resp = await gqlFetch({baseUrl, query: `mutation { ${createRoomName} }`}) 221 const ok = !!resp?.errors?.length && !resp?.data?.[createRoomName] 222 return {passed: ok, msg: ok ? '' : `unexpected: ${JSON.stringify(resp)}`} 227 name: 'nonexistentRoomRejected', 229 ctx.fakeRoom = `fake-room-${Date.now()}` 230 const resp = await gqlFetch({baseUrl, query: `query { ${roomQueryName}(gameRoom: "${ctx.fakeRoom}") { gqGameState } }`}) 231 const ok = resp?.errors?.[0]?.message === 'room not found' 232 return {passed: ok, msg: ok ? '' : `unexpected: ${JSON.stringify(resp?.errors || resp?.data)}`} 237 name: 'nonexistentRoomActionRejected', 239 const resp = await gqlFetch({baseUrl, query: `mutation { ${roomActionName}(gameRoom: "${ctx.fakeRoom}", actionJSON: {playerAction: "setPlayerAlias", playerAlias: "test"}) }`}) 240 const ok = resp?.errors?.[0]?.message === 'room not found' 241 return {passed: ok, msg: ok ? '' : `unexpected: ${JSON.stringify(resp?.errors || resp?.data)}`} 246 name: 'resecureRoomChangesLabel', 248 if (!ctx.gqUserTok || !ctx.gameRoom) return {passed: false, msg: 'skipped - no auth or room'} 249 const oldRoom = ctx.gameRoom 250 const resp = await gqlFetch({baseUrl, gqUserTok: ctx.gqUserTok, query: `mutation { resecureRoom(gameRoom: "${oldRoom}") }`}) 251 const newRoom = resp?.data?.resecureRoom 252 const ok = !!newRoom && newRoom !== oldRoom && newRoom.split('-').length === 4 253 ctx.gameRoom = newRoom // update for subsequent tests 254 return {passed: ok, msg: ok ? `${oldRoom} -> ${newRoom!}` : `unexpected: ${JSON.stringify(resp?.errors || resp?.data)}`} 259 name: 'resecureRoomRequiresHost', 261 if (!ctx.gameRoom) return {passed: false, msg: 'skipped - no room'} 262 // Try to resecure without auth (should fail) 263 const resp = await gqlFetch({baseUrl, query: `mutation { resecureRoom(gameRoom: "${ctx.gameRoom}") }`}) 264 const ok = !!resp?.errors?.length && !resp?.data?.resecureRoom 265 return {passed: ok, msg: ok ? '' : `unexpected: ${JSON.stringify(resp)}`} 270 name: 'resecureRoomNonHostRejected', 272 if (!ctx.gameRoom) return {passed: false, msg: 'skipped - no room'} 273 // Create a different authenticated user and try to resecure 276 const otpGql = `query($email: String!, $email_token: String!) { gqUserTokFromEmailOtp(email: $email, email_token: $email_token) }` 277 const otpResp = await gqlFetch({baseUrl, query: otpGql, variables: {email: otherEmail, email_token: otherOtp}}) 278 const otherTok = otpResp?.data?.gqUserTokFromEmailOtp 279 if (!otherTok) return {passed: false, msg: 'failed to auth other user'} 280 const resp = await gqlFetch({baseUrl, gqUserTok: otherTok, query: `mutation { resecureRoom(gameRoom: "${ctx.gameRoom}") }`}) 281 const ok = !!resp?.errors?.length && !resp?.data?.resecureRoom 282 return {passed: ok, msg: ok ? '' : `unexpected: ${JSON.stringify(resp)}`}