🌳
pt0/gamesapp/testsF/commonHardeningTestsAI.mts
5type TestFn = () => Promise<{passed: boolean, msg: string}>
6type TestAdder = (name: string, fn: TestFn) => void
8const gameActionMutation = `mutation($gameRoom: String!, $actionJSON: JSON!) { gameAction(gameRoom: $gameRoom, actionJSON: $actionJSON) }`
10// Add common hardening tests to a test collector
11export const addCommonHardeningTests = (test: TestAdder, baseUrl: string, opts?: {hasQuota?: boolean}) => {
12 test('queryDepthLimitEnforced', async () => {
13 const deepQuery = `query { __schema { types { fields { type { ofType { ofType { ofType { name } } } } } } } }`
14 const resp = await gqlFetch({baseUrl, query: deepQuery})
15 const hasDepthError = hasGqlErrMatch(resp, 'depth', 'QUERY_TOO_DEEP')
16 const hasIntrospectionDisabled = hasGqlErrMatch(resp, 'introspection') || (resp as any)?.errors?.some((e: any) => e.message === 'Internal Server Error')
17 const passed = hasDepthError || hasIntrospectionDisabled
18 return {
19 passed,
20 msg: passed ? '' : `expected depth/introspection error, got: ${JSON.stringify((resp as any)?.errors || 'no error')}`,
21 }
22 })
24 test('normalDepthQueryAllowed', async () => {
25 const resp = await gqlFetch({baseUrl, query: `query { gqGame(gameRoom: "test") { id gqGameState } }`})
26 const hasDepthError = hasGqlErrMatch(resp, 'depth', 'QUERY_TOO_DEEP')
27 return {passed: !hasDepthError, msg: hasDepthError ? `unexpected depth error: ${JSON.stringify((resp as any)?.errors)}` : ''}
28 })
30 test('batchedRequestsRejected', async () => {
31 const resp = await fetch(`${baseUrl}/api/graphql`, {
32 method: 'POST', headers: {'Content-Type': 'application/json', ...getTestExceptionHeaders()},
33 body: JSON.stringify([{query: '{ gqGame(gameRoom: "a") { id } }'}, {query: '{ gqGame(gameRoom: "b") { id } }'}]),
34 })
35 const data = await resp.json()
36 const isBatchError = hasGqlErrMatch(data, 'batch') || resp.status >= 400
37 const isNotArrayResponse = !Array.isArray(data)
38 return {
39 passed: isBatchError || isNotArrayResponse,
40 msg: (isBatchError || isNotArrayResponse) ? '' : `unexpected: got array of ${(data as any[])?.length} responses`,
41 }
42 })
44 test('oversizedPlayerAliasRejected', async () => {
45 const oversizedAlias = genTestStr(inputLimits.playerAliasMaxChars + 10)
46 const resp = await gqlFetch({baseUrl, query: gameActionMutation, variables: {
47 gameRoom: 'test-hardening', actionJSON: {playerAction: 'setPlayerAlias', playerAlias: oversizedAlias},
48 }})
49 const passed = hasGqlErrMatch(resp, 'name too long', 'too long')
50 return {passed, msg: passed ? `${oversizedAlias.length} chars` : `expected 'too long' error, got: ${JSON.stringify((resp as any)?.errors?.[0]?.message || resp)}`}
51 })
53 test('oversizedDescriptionRejected', async () => {
54 const oversizedDesc = genTestStr(inputLimits.descriptionTextMaxChars + 50)
55 const resp = await gqlFetch({baseUrl, query: gameActionMutation, variables: {
56 gameRoom: 'test-hardening', actionJSON: {playerAction: 'submitDescription', descriptionText: oversizedDesc},
57 }})
58 const passed = hasGqlErrMatch(resp, 'description too long', 'too long')
59 return {passed, msg: passed ? `${oversizedDesc.length} chars` : `expected 'too long' error, got: ${JSON.stringify((resp as any)?.errors?.[0]?.message || resp)}`}
60 })
62 test('oversizedPayloadRejected', async () => {
63 const largePayload = genTestStr(inputLimits.actionJsonMaxBytes + 1000)
64 const resp = await gqlFetch({baseUrl, query: gameActionMutation, variables: {
65 gameRoom: 'test-hardening', actionJSON: {playerAction: 'test', largeData: largePayload},
66 }})
67 const passed = hasGqlErrMatch(resp, 'payload too large', 'too large')
68 return {passed, msg: passed ? `~${Math.round(largePayload.length / 1024)}KB` : `expected 'too large' error, got: ${JSON.stringify((resp as any)?.errors?.[0]?.message || resp)}`}
69 })
71 test('normalSizedInputsAllowed', async () => {
72 const normalAlias = genTestStr(inputLimits.playerAliasMaxChars - 5)
73 const resp = await gqlFetch({baseUrl, query: gameActionMutation, variables: {
74 gameRoom: 'test-hardening', actionJSON: {playerAction: 'setPlayerAlias', playerAlias: normalAlias},
75 }})
76 const hasSizeError = hasGqlErrMatch(resp, 'too long', 'too large')
77 return {passed: !hasSizeError, msg: hasSizeError ? `unexpected size error: ${JSON.stringify((resp as any)?.errors)}` : ''}
78 })
80 if (!opts?.hasQuota) return
81 test('gqlQuotaLimitEnforced [~2s]', async () => {
82 const testIpHeader = getTestIpHeaderName()
83 if (!testIpHeader) return {passed: false, msg: 'appSecret not available, cannot test quota'}
84 const randomOctet = () => Math.floor(Math.random() * 255)
85 const fakeIp = `10.88.${randomOctet()}.${randomOctet()}`
86 const query = `query { gqDeployInfo }`
87 const send = () => gqlFetch({baseUrl, query, extraHeaders: {[testIpHeader]: fakeIp}})
88 for (let i = 0; i < 30; i++) await send()
89 const resp = await send()
90 const passed = hasGqlErrMatch(resp, 'rate limited', 'RATE_LIMITED')
91 return {passed, msg: passed ? '31st request rate limited' : `expected RATE_LIMITED after 30 req/min, got: ${JSON.stringify((resp as any)?.errors?.[0]?.message || 'no error')}`}
92 })
95// Export gameActionMutation for app-specific tests that need it
96export { gameActionMutation, inputLimits }