🌳
pt0/deployF/testsF/authCoreTestsAI.mts
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',
26type TestCtx = {
27 testEmail?: string
28 gqUserTok?: string
29 browserTestEmail?: string
30 browser?: Browser
31 browserContext?: BrowserContext
32 page?: Page
33 hasCookie?: boolean
34 gameRoom?: string
35 fakeRoom?: string
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 } }`
50 : null
51 const reqEmailLoginGql = reqEmailLoginMutationName
52 ? `mutation($email: String!, $returnpath: String) { ${reqEmailLoginMutationName}(email: $email, returnpath: $returnpath) }`
53 : null
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) {
60 tests.push({
61 name: 'reqEmailLoginWorks',
62 fn: async () => {
63 const testEmail = genTestEmail('reqlogin')
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)}`}
68 }
69 })
70 }
72 // OTP tests
73 tests.push({
74 name: 'badOtpRejected',
75 fn: async () => {
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)}`}
79 }
80 })
82 tests.push({
83 name: 'validOtpReturnsToken',
84 fn: async () => {
85 ctx.testEmail = genTestEmail('authtest')
86 const validOtp = genEmailOtp({email: ctx.testEmail})
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)}`}
91 }
92 })
94 // User from token tests (only if query name provided)
95 if (userFromTokGql) {
96 tests.push({
97 name: 'tokenResolvesToUser',
98 fn: async () => {
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)}`}
104 }
105 })
107 tests.push({
108 name: 'badTokenReturnsNull',
109 fn: async () => {
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)}`}
113 }
114 })
115 }
117 // Browser tests (playwright)
118 if (!skipBrowserTests) {
119 tests.push({
120 name: 'emailLoginLinkSetsCookie',
121 fn: async () => {
122 ctx.browserTestEmail = genTestEmail('uitest')
123 const emailToken = genEmailOtp({email: ctx.browserTestEmail})
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'})
132 let cookie
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)
137 }
138 ctx.hasCookie = !!cookie?.value
139 ctx.page = page
140 return {passed: ctx.hasCookie, msg: ctx.hasCookie ? userTokenKey : 'cookie not set after 10s'}
141 }
142 })
144 tests.push({
145 name: 'emailLoginLinkShowsLoggedIn',
146 fn: async () => {
147 if (!ctx.hasCookie) {
148 await ctx.browser?.close()
149 return {passed: false, msg: 'skipped - no cookie'}
150 }
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))
159 } else {
160 hasLoggedInUi = !!(await ctx.page?.locator(selector).first().isVisible().catch(() => false))
161 }
162 }
163 await ctx.browser?.close()
164 return {passed: hasLoggedInUi, msg: hasLoggedInUi ? '' : `selector "${loggedInSelector}" not found after 15s`}
165 }
166 })
167 }
169 return tests
170 }
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 = {}) => {
183 const {
184 beforeCreateRoom,
185 roomQueryName = 'gqGame',
186 roomActionName = 'gameAction',
187 createRoomName = 'gmCreateGameRoom',
188 } = cfg
190 return (ctx: TestCtx, baseUrl: string): Test[] => {
191 const tests: Test[] = []
193 tests.push({
194 name: 'createGameRoom',
195 fn: async () => {
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)}`}
203 }
204 })
206 tests.push({
207 name: 'createGameRoomIdempotent',
208 fn: async () => {
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}`}
214 }
215 })
217 tests.push({
218 name: 'createGameRoomRequiresAuth',
219 fn: async () => {
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)}`}
223 }
224 })
226 tests.push({
227 name: 'nonexistentRoomRejected',
228 fn: async () => {
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)}`}
233 }
234 })
236 tests.push({
237 name: 'nonexistentRoomActionRejected',
238 fn: async () => {
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)}`}
242 }
243 })
245 tests.push({
246 name: 'resecureRoomChangesLabel',
247 fn: async () => {
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)}`}
255 }
256 })
258 tests.push({
259 name: 'resecureRoomRequiresHost',
260 fn: async () => {
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)}`}
266 }
267 })
269 tests.push({
270 name: 'resecureRoomNonHostRejected',
271 fn: async () => {
272 if (!ctx.gameRoom) return {passed: false, msg: 'skipped - no room'}
273 // Create a different authenticated user and try to resecure
274 const otherEmail = genTestEmail('other')
275 const otherOtp = genEmailOtp({email: otherEmail})
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)}`}
283 }
284 })
286 return tests
287 }