@teleporthq/teleport-plugin-next-data-source 0.44.15 → 0.44.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/__tests__/_helpers/exposure.ts +29 -0
  2. package/__tests__/_helpers/first-page-runtime.ts +12 -2
  3. package/__tests__/blog-post-contents.test.ts +3 -1
  4. package/__tests__/blog-post-page-enrichment.test.ts +2 -1
  5. package/__tests__/blog-post-seo-fields.test.ts +124 -0
  6. package/__tests__/browser-row-policy.test.ts +65 -5
  7. package/__tests__/data-source-cache-wrapping.test.ts +48 -7
  8. package/__tests__/external-read-routes-guard.test.ts +991 -0
  9. package/__tests__/null-filter-where.test.ts +19 -2
  10. package/__tests__/range-control.test.ts +29 -0
  11. package/__tests__/search-relevance.test.ts +42 -6
  12. package/__tests__/table-read-route-protected-tables.test.ts +54 -4
  13. package/dist/cjs/count-fetchers.d.ts.map +1 -1
  14. package/dist/cjs/count-fetchers.js +1 -1
  15. package/dist/cjs/count-fetchers.js.map +1 -1
  16. package/dist/cjs/data-source-fetchers.d.ts +4 -0
  17. package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
  18. package/dist/cjs/data-source-fetchers.js +97 -20
  19. package/dist/cjs/data-source-fetchers.js.map +1 -1
  20. package/dist/cjs/fetchers/postgresql.d.ts +3 -1
  21. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  22. package/dist/cjs/fetchers/postgresql.js +11 -3
  23. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  24. package/dist/cjs/fetchers/raw-query.d.ts +20 -1
  25. package/dist/cjs/fetchers/raw-query.d.ts.map +1 -1
  26. package/dist/cjs/fetchers/raw-query.js +37 -7
  27. package/dist/cjs/fetchers/raw-query.js.map +1 -1
  28. package/dist/cjs/fetchers/redshift.d.ts.map +1 -1
  29. package/dist/cjs/fetchers/redshift.js +2 -1
  30. package/dist/cjs/fetchers/redshift.js.map +1 -1
  31. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  32. package/dist/cjs/fetchers/teleport.js +5 -3
  33. package/dist/cjs/fetchers/teleport.js.map +1 -1
  34. package/dist/cjs/fetchers/utils/external-read-guard.d.ts +53 -0
  35. package/dist/cjs/fetchers/utils/external-read-guard.d.ts.map +1 -0
  36. package/dist/cjs/fetchers/utils/external-read-guard.js +126 -0
  37. package/dist/cjs/fetchers/utils/external-read-guard.js.map +1 -0
  38. package/dist/cjs/fetchers/utils/table-access-guard.d.ts +76 -12
  39. package/dist/cjs/fetchers/utils/table-access-guard.d.ts.map +1 -1
  40. package/dist/cjs/fetchers/utils/table-access-guard.js +123 -15
  41. package/dist/cjs/fetchers/utils/table-access-guard.js.map +1 -1
  42. package/dist/cjs/index.js +1 -1
  43. package/dist/cjs/pagination-controls.d.ts +4 -1
  44. package/dist/cjs/pagination-controls.d.ts.map +1 -1
  45. package/dist/cjs/pagination-controls.js +4 -1
  46. package/dist/cjs/pagination-controls.js.map +1 -1
  47. package/dist/cjs/pagination-plugin.js +1 -0
  48. package/dist/cjs/pagination-plugin.js.map +1 -1
  49. package/dist/cjs/range-control.d.ts +10 -5
  50. package/dist/cjs/range-control.d.ts.map +1 -1
  51. package/dist/cjs/range-control.js +23 -2
  52. package/dist/cjs/range-control.js.map +1 -1
  53. package/dist/cjs/transformations/blog-post.d.ts +5 -0
  54. package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
  55. package/dist/cjs/transformations/blog-post.js +6 -2
  56. package/dist/cjs/transformations/blog-post.js.map +1 -1
  57. package/dist/cjs/transformations/index.d.ts +18 -2
  58. package/dist/cjs/transformations/index.d.ts.map +1 -1
  59. package/dist/cjs/transformations/index.js +26 -1
  60. package/dist/cjs/transformations/index.js.map +1 -1
  61. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  62. package/dist/esm/count-fetchers.d.ts.map +1 -1
  63. package/dist/esm/count-fetchers.js +1 -1
  64. package/dist/esm/count-fetchers.js.map +1 -1
  65. package/dist/esm/data-source-fetchers.d.ts +4 -0
  66. package/dist/esm/data-source-fetchers.d.ts.map +1 -1
  67. package/dist/esm/data-source-fetchers.js +97 -20
  68. package/dist/esm/data-source-fetchers.js.map +1 -1
  69. package/dist/esm/fetchers/postgresql.d.ts +3 -1
  70. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  71. package/dist/esm/fetchers/postgresql.js +12 -4
  72. package/dist/esm/fetchers/postgresql.js.map +1 -1
  73. package/dist/esm/fetchers/raw-query.d.ts +20 -1
  74. package/dist/esm/fetchers/raw-query.d.ts.map +1 -1
  75. package/dist/esm/fetchers/raw-query.js +38 -8
  76. package/dist/esm/fetchers/raw-query.js.map +1 -1
  77. package/dist/esm/fetchers/redshift.d.ts.map +1 -1
  78. package/dist/esm/fetchers/redshift.js +2 -1
  79. package/dist/esm/fetchers/redshift.js.map +1 -1
  80. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  81. package/dist/esm/fetchers/teleport.js +6 -4
  82. package/dist/esm/fetchers/teleport.js.map +1 -1
  83. package/dist/esm/fetchers/utils/external-read-guard.d.ts +53 -0
  84. package/dist/esm/fetchers/utils/external-read-guard.d.ts.map +1 -0
  85. package/dist/esm/fetchers/utils/external-read-guard.js +118 -0
  86. package/dist/esm/fetchers/utils/external-read-guard.js.map +1 -0
  87. package/dist/esm/fetchers/utils/table-access-guard.d.ts +76 -12
  88. package/dist/esm/fetchers/utils/table-access-guard.d.ts.map +1 -1
  89. package/dist/esm/fetchers/utils/table-access-guard.js +119 -15
  90. package/dist/esm/fetchers/utils/table-access-guard.js.map +1 -1
  91. package/dist/esm/index.js +1 -1
  92. package/dist/esm/pagination-controls.d.ts +4 -1
  93. package/dist/esm/pagination-controls.d.ts.map +1 -1
  94. package/dist/esm/pagination-controls.js +4 -1
  95. package/dist/esm/pagination-controls.js.map +1 -1
  96. package/dist/esm/pagination-plugin.js +1 -0
  97. package/dist/esm/pagination-plugin.js.map +1 -1
  98. package/dist/esm/range-control.d.ts +10 -5
  99. package/dist/esm/range-control.d.ts.map +1 -1
  100. package/dist/esm/range-control.js +23 -2
  101. package/dist/esm/range-control.js.map +1 -1
  102. package/dist/esm/transformations/blog-post.d.ts +5 -0
  103. package/dist/esm/transformations/blog-post.d.ts.map +1 -1
  104. package/dist/esm/transformations/blog-post.js +6 -2
  105. package/dist/esm/transformations/blog-post.js.map +1 -1
  106. package/dist/esm/transformations/index.d.ts +18 -2
  107. package/dist/esm/transformations/index.d.ts.map +1 -1
  108. package/dist/esm/transformations/index.js +27 -2
  109. package/dist/esm/transformations/index.js.map +1 -1
  110. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  111. package/package.json +5 -5
  112. package/src/count-fetchers.ts +10 -2
  113. package/src/data-source-fetchers.ts +144 -23
  114. package/src/fetchers/postgresql.ts +38 -9
  115. package/src/fetchers/raw-query.ts +125 -13
  116. package/src/fetchers/redshift.ts +3 -0
  117. package/src/fetchers/teleport.ts +29 -8
  118. package/src/fetchers/utils/external-read-guard.ts +289 -0
  119. package/src/fetchers/utils/table-access-guard.ts +469 -20
  120. package/src/index.ts +1 -1
  121. package/src/pagination-controls.ts +4 -1
  122. package/src/pagination-plugin.ts +1 -0
  123. package/src/range-control.ts +40 -8
  124. package/src/transformations/blog-post.ts +107 -4
  125. package/src/transformations/index.ts +44 -1
@@ -0,0 +1,991 @@
1
+ /* tslint:disable:function-constructor no-any */
2
+ import { UIDLDataSource } from '@teleporthq/teleport-types'
3
+ import {
4
+ generateDataSourceFetcherWithCore,
5
+ generateRawQueryFetcher,
6
+ } from '../src/data-source-fetchers'
7
+ import { publicExposure, publicRead } from './_helpers/exposure'
8
+
9
+ /**
10
+ * Read routes over an external database (A1-03, A1-07, A1-08) and the
11
+ * raw-query state routes (A5-H4) carry the same guard as the TeleportHQ ones:
12
+ * the table's exposure, whole-number row counts (a browser's capped), safe
13
+ * request fields, no credential column, a generic error.
14
+ */
15
+
16
+ const MYSQL: UIDLDataSource = {
17
+ id: 'ds-mysql-1',
18
+ name: 'Shop',
19
+ type: 'mysql',
20
+ config: { host: 'h', port: 3306, user: 'u', password: 'p', database: 'shop' },
21
+ }
22
+
23
+ interface Reply {
24
+ status: number
25
+ body: any
26
+ sqls: string[]
27
+ }
28
+
29
+ interface Call {
30
+ handler?: 'handler' | 'getCount'
31
+ query?: Record<string, string>
32
+ session?: { role?: string; id?: string } | null
33
+ headers?: Record<string, string>
34
+ }
35
+
36
+ const toScript = (esm: string, exportsLine: string): string =>
37
+ esm
38
+ .replace(/^import .*$/gm, '')
39
+ .replace(/^export default async function handler/m, 'async function handler')
40
+ .replace(/^export \{[^}]*\}\s*$/m, '')
41
+ .replace(/^export default \{[^}]*\}\s*$/m, '')
42
+ .replace(/^export default \w+\s*$/m, '')
43
+ .concat('\n' + exportsLine)
44
+
45
+ const quiet = {
46
+ log: (): void => undefined,
47
+ warn: (): void => undefined,
48
+ error: (): void => undefined,
49
+ }
50
+
51
+ const bootMySQLRoute = (
52
+ table: string,
53
+ options: { exposure?: object; rows?: any[]; fail?: string } = {}
54
+ ): ((call?: Call) => Promise<Reply>) => {
55
+ const esm = generateDataSourceFetcherWithCore(
56
+ MYSQL,
57
+ table,
58
+ false,
59
+ (options.exposure || {}) as any
60
+ )
61
+ const code = toScript(esm, 'module.exports = { handler: handler, getCount: getCount }')
62
+ const sqls: string[] = []
63
+ let session: Call['session'] = null
64
+ const mysql = {
65
+ escapeId: (name: string) => '`' + String(name).replace(/`/g, '``') + '`',
66
+ createConnection: async () => ({
67
+ query: async (sql: string) => {
68
+ sqls.push(sql)
69
+ if (options.fail) {
70
+ throw new Error(options.fail)
71
+ }
72
+ if (/COUNT\(\*\)/i.test(sql)) {
73
+ return [[{ count: (options.rows || []).length }]]
74
+ }
75
+ return [(options.rows || []).map((row) => ({ ...row }))]
76
+ },
77
+ execute: async (sql: string): Promise<any[][]> => {
78
+ sqls.push(sql)
79
+ return [[]]
80
+ },
81
+ end: async (): Promise<void> => undefined,
82
+ }),
83
+ }
84
+ const fakeRequire = (name: string): any => {
85
+ if (name === 'next-auth/jwt') {
86
+ return {
87
+ getToken: async () => (session ? { sub: session.id || 'u1', role: session.role } : null),
88
+ }
89
+ }
90
+ if (name === 'crypto') {
91
+ return require('crypto')
92
+ }
93
+ throw new Error('unexpected require: ' + name)
94
+ }
95
+ const moduleObj: { exports: any } = { exports: {} }
96
+ new Function('require', 'module', 'exports', 'process', 'console', 'mysql', code)(
97
+ fakeRequire,
98
+ moduleObj,
99
+ moduleObj.exports,
100
+ { env: { NEXTAUTH_SECRET: 'server-secret' } },
101
+ quiet,
102
+ mysql
103
+ )
104
+ return async (call: Call = {}) => {
105
+ session = call.session || null
106
+ sqls.length = 0
107
+ const reply: Reply = { status: 0, body: null, sqls }
108
+ const res: any = {
109
+ status(statusCode: number) {
110
+ reply.status = statusCode
111
+ return res
112
+ },
113
+ json(payload: any) {
114
+ reply.body = payload
115
+ return res
116
+ },
117
+ setHeader: (): void => undefined,
118
+ }
119
+ // A signed-in local request carries the plain session cookie (the only name
120
+ // read on http; see teleport-shared session-cookie-resolver).
121
+ const headers = {
122
+ ...(session ? { cookie: 'next-auth.session-token=signed' } : {}),
123
+ ...(call.headers || {}),
124
+ }
125
+ await moduleObj.exports[call.handler || 'handler'](
126
+ { method: 'GET', query: call.query || {}, headers },
127
+ res
128
+ )
129
+ return { ...reply, sqls: [...sqls] }
130
+ }
131
+ }
132
+
133
+ describe('external database read routes', () => {
134
+ const ROWS = [{ id: 1, name: 'Ada', password: 'hash', api_token: 'tok', token_count: 3 }]
135
+
136
+ it('refuses a table no page binds — to a visitor and to any session', async () => {
137
+ const route = bootMySQLRoute('customers', { rows: ROWS })
138
+ expect((await route({})).status).toBe(403)
139
+ expect((await route({ session: { role: 'user' } })).status).toBe(403)
140
+ expect((await route({ handler: 'getCount' })).status).toBe(403)
141
+ })
142
+
143
+ it('serves a publicly bound table to the server-side caller and to the visitor', async () => {
144
+ const route = bootMySQLRoute('customers', {
145
+ rows: ROWS,
146
+ exposure: publicExposure(['customers']),
147
+ })
148
+ const reply = await route({})
149
+ expect(reply.status).toBe(200)
150
+ expect(reply.body.data).toEqual([{ id: 1, name: 'Ada', token_count: 3 }])
151
+ })
152
+
153
+ it('a table only signed-in pages bind needs a session one of them admits', async () => {
154
+ const exposure = {
155
+ dataExposure: {
156
+ tables: { '*:customers': publicRead({ public: false, roles: ['staff'] }) },
157
+ dataSources: {},
158
+ },
159
+ }
160
+ const route = bootMySQLRoute('customers', { rows: ROWS, exposure })
161
+ expect((await route({})).status).toBe(401)
162
+ expect((await route({ session: { role: 'user' } })).status).toBe(403)
163
+ expect((await route({ session: { role: 'staff' } })).status).toBe(200)
164
+ })
165
+
166
+ it('never splices a row count it cannot read as a whole number into the SQL', async () => {
167
+ const route = bootMySQLRoute('customers', {
168
+ rows: ROWS,
169
+ exposure: publicExposure(['customers']),
170
+ })
171
+ for (const limit of ['1;--', '1 /*SPLICED-MARKER*/', '1 OR 1=1', '-5']) {
172
+ const reply = await route({ query: { limit } })
173
+ expect(reply.status).toBe(400)
174
+ expect(reply.sqls).toEqual([])
175
+ }
176
+ const offset = await route({ query: { offset: '0; DROP TABLE customers' } })
177
+ expect(offset.status).toBe(400)
178
+ expect(offset.sqls).toEqual([])
179
+ })
180
+
181
+ it('caps a browser read and gives one that names no count the default', async () => {
182
+ const route = bootMySQLRoute('customers', {
183
+ rows: ROWS,
184
+ exposure: publicExposure(['customers']),
185
+ })
186
+ const huge = await route({ query: { limit: '100000000' } })
187
+ expect(huge.sqls[huge.sqls.length - 1]).toMatch(/ LIMIT 500$/)
188
+ const unnamed = await route({})
189
+ expect(unnamed.sqls[unnamed.sqls.length - 1]).toMatch(/ LIMIT 100$/)
190
+ const page = await route({ query: { perPage: '12', page: '3' } })
191
+ expect(page.sqls[page.sqls.length - 1]).toMatch(/ LIMIT 12 OFFSET 24$/)
192
+ })
193
+
194
+ it('refuses a request field that is not a column name', async () => {
195
+ const route = bootMySQLRoute('customers', {
196
+ rows: ROWS,
197
+ exposure: publicExposure(['customers']),
198
+ })
199
+ const sorted = await route({ query: { sortBy: 'name`; DROP TABLE x; --' } })
200
+ expect(sorted.status).toBe(400)
201
+ const filtered = await route({
202
+ query: {
203
+ filters: JSON.stringify([{ source: '(SELECT 1)', operand: '=', destination: 'x' }]),
204
+ },
205
+ })
206
+ expect(filtered.status).toBe(400)
207
+ expect(filtered.sqls).toEqual([])
208
+ })
209
+
210
+ it('keeps the driver error in the server log and answers a generic one', async () => {
211
+ const route = bootMySQLRoute('customers', {
212
+ exposure: publicExposure(['customers']),
213
+ fail: "Unknown column 'secret_col' in table shop.customers",
214
+ })
215
+ const reply = await route({})
216
+ expect(reply.status).toBe(500)
217
+ expect(reply.body.error).toBe('Failed to fetch data')
218
+ expect(typeof reply.body.id).toBe('string')
219
+ expect(JSON.stringify(reply.body)).not.toMatch(/secret_col|shop\.customers/)
220
+ })
221
+ })
222
+
223
+ describe('raw-query state routes (page-state / global-state)', () => {
224
+ interface StateCall {
225
+ query?: Record<string, string>
226
+ session?: { role?: string; id?: string; email?: string } | null
227
+ entitled?: boolean
228
+ /** The app's own in-process call (getStaticProps, a page-load workflow). */
229
+ serverCall?: boolean
230
+ }
231
+
232
+ const bootStateRoute = (
233
+ sql: string,
234
+ paramFields: string[],
235
+ access: Parameters<typeof generateRawQueryFetcher>[3] = {},
236
+ rows: any[] = [{ id: 'b1', customer_email: 'a@b.c', password: 'hash' }]
237
+ ) => {
238
+ const esm = generateRawQueryFetcher({}, sql, paramFields, access)
239
+ const code = esm
240
+ .replace(
241
+ /^import \{ Client, types as pgTypes \} from 'pg'$/m,
242
+ "const { Client, types: pgTypes } = require('pg')"
243
+ )
244
+ .replace(/^export default async function handler/m, 'module.exports = async function handler')
245
+ const queries: Array<{ sql: string; params: unknown[] }> = []
246
+ let session: StateCall['session'] = null
247
+ let entitled = false
248
+ class FakeClient {
249
+ public async connect(): Promise<void> {
250
+ return
251
+ }
252
+ public async end(): Promise<void> {
253
+ return
254
+ }
255
+ public async query(text: string, params: unknown[] = []): Promise<{ rows: any[] }> {
256
+ queries.push({ sql: text, params })
257
+ if (/FROM teleport_subscriptions/.test(text)) {
258
+ return { rows: entitled ? [{ ok: 1 }] : [] }
259
+ }
260
+ return { rows: rows.map((row) => ({ ...row })) }
261
+ }
262
+ }
263
+ const fakeRequire = (name: string): any => {
264
+ if (name === 'pg') {
265
+ return { Client: FakeClient, types: { setTypeParser: (): void => undefined } }
266
+ }
267
+ if (name === 'next-auth/jwt') {
268
+ return { getToken: async () => (session ? { sub: session.id || 'u1', ...session } : null) }
269
+ }
270
+ if (name === 'crypto') {
271
+ return require('crypto')
272
+ }
273
+ throw new Error('unexpected require: ' + name)
274
+ }
275
+ const moduleObj: { exports: any } = { exports: {} }
276
+ new Function('require', 'module', 'exports', 'process', 'console', code)(
277
+ fakeRequire,
278
+ moduleObj,
279
+ moduleObj.exports,
280
+ { env: { NEXTAUTH_SECRET: 'server-secret', TELEPORT_DB_HOST: 'h' } },
281
+ quiet
282
+ )
283
+ return async (call: StateCall = {}) => {
284
+ session = call.session || null
285
+ entitled = call.entitled === true
286
+ queries.length = 0
287
+ const reply: any = { status: 0, body: null }
288
+ const res: any = {
289
+ status(c: number) {
290
+ reply.status = c
291
+ return res
292
+ },
293
+ json(p: any) {
294
+ reply.body = p
295
+ return res
296
+ },
297
+ }
298
+ const headers = { ...(session ? { cookie: 'next-auth.session-token=signed' } : {}) }
299
+ await moduleObj.exports(
300
+ {
301
+ method: 'GET',
302
+ query: call.query || {},
303
+ headers,
304
+ __tqServerCall: call.serverCall === true,
305
+ },
306
+ res
307
+ )
308
+ return { ...reply, queries: [...queries] }
309
+ }
310
+ }
311
+
312
+ // The A5-H4 probe: a booking item's bookings, by the item id in the URL.
313
+ const BOOKINGS_BY_ITEM = 'SELECT * FROM teleport_bookings WHERE item_id::text = $1'
314
+
315
+ it('refuses a customer table to a visitor, whatever entity the query string names', async () => {
316
+ const route = bootStateRoute(BOOKINGS_BY_ITEM, ['currentPageEntityId'])
317
+ const anonymous = await route({ query: { currentPageEntityId: 'item-1' } })
318
+ expect(anonymous.status).toBe(401)
319
+ expect(anonymous.queries).toEqual([])
320
+ const customer = await route({
321
+ query: { currentPageEntityId: 'item-1' },
322
+ session: { role: 'user' },
323
+ })
324
+ expect(customer.status).toBe(403)
325
+ })
326
+
327
+ it('serves the customer table to the admin, without its credential columns', async () => {
328
+ const route = bootStateRoute(BOOKINGS_BY_ITEM, ['currentPageEntityId'], {
329
+ trustedReaderRoles: ['admin'],
330
+ })
331
+ const reply = await route({
332
+ query: { currentPageEntityId: 'item-1' },
333
+ session: { role: 'admin' },
334
+ })
335
+ expect(reply.status).toBe(200)
336
+ expect(reply.body.data).toEqual([{ id: 'b1', customer_email: 'a@b.c' }])
337
+ })
338
+
339
+ it("serves a statement scoped to the session's own user, bound from the session", async () => {
340
+ const route = bootStateRoute(
341
+ 'SELECT * FROM teleport_favourites WHERE user_id = $1',
342
+ ['currentUserId'],
343
+ {}
344
+ )
345
+ const reply = await route({
346
+ query: { currentUserId: 'victim' },
347
+ session: { id: 'me', role: 'user' },
348
+ })
349
+ expect(reply.status).toBe(200)
350
+ expect(reply.queries[0].params).toEqual(['me'])
351
+ const anonymous = await route({ query: { currentUserId: 'victim' } })
352
+ expect(anonymous.status).toBe(200)
353
+ expect(anonymous.queries).toEqual([])
354
+ })
355
+
356
+ it("runs behind the page's own protection", async () => {
357
+ const route = bootStateRoute(
358
+ 'SELECT id, title FROM team_notes WHERE team_id = $1',
359
+ ['currentPageEntityId'],
360
+ {
361
+ pageAccess: { requiresAuth: true, allowedRoles: ['staff'] },
362
+ }
363
+ )
364
+ expect((await route({ query: { currentPageEntityId: 't1' } })).status).toBe(401)
365
+ expect(
366
+ (await route({ query: { currentPageEntityId: 't1' }, session: { role: 'user' } })).status
367
+ ).toBe(403)
368
+ expect(
369
+ (await route({ query: { currentPageEntityId: 't1' }, session: { role: 'staff' } })).status
370
+ ).toBe(200)
371
+ })
372
+
373
+ it("a subscriber-only page's states need the entitlement too", async () => {
374
+ const route = bootStateRoute(
375
+ 'SELECT id, title FROM premium_lessons WHERE course_id = $1',
376
+ ['currentPageEntityId'],
377
+ {
378
+ pageAccess: {
379
+ requiresAuth: true,
380
+ allowedRoles: [],
381
+ requiresSubscription: true,
382
+ subscriptionProductIds: ['plan-pro'],
383
+ },
384
+ }
385
+ )
386
+ expect((await route({ query: { currentPageEntityId: 'c1' } })).status).toBe(401)
387
+ const without = await route({ query: { currentPageEntityId: 'c1' }, session: { id: 'u1' } })
388
+ expect(without.status).toBe(403)
389
+ expect(without.queries[0].params[2]).toEqual(['plan-pro'])
390
+ const entitled = await route({
391
+ query: { currentPageEntityId: 'c1' },
392
+ session: { id: 'u1' },
393
+ entitled: true,
394
+ })
395
+ expect(entitled.status).toBe(200)
396
+ })
397
+
398
+ it('A3: refuses a statement naming the retired reset-token table, to the app itself too', async () => {
399
+ const route = bootStateRoute(
400
+ 'SELECT email FROM PUBLIC."password_reset_tokens" WHERE token = $1',
401
+ ['currentPageEntityId']
402
+ )
403
+ for (const call of [
404
+ { query: { currentPageEntityId: 't' }, serverCall: true },
405
+ { query: { currentPageEntityId: 't' }, session: { role: 'admin' } },
406
+ ]) {
407
+ const answer = await route(call)
408
+ expect(answer.status).toBe(403)
409
+ expect(answer.queries).toEqual([])
410
+ }
411
+ // The app's own call reads any other statement as before.
412
+ const other = bootStateRoute('SELECT id FROM team_notes WHERE team_id = $1', [
413
+ 'currentPageEntityId',
414
+ ])
415
+ expect((await other({ query: { currentPageEntityId: 't' }, serverCall: true })).status).toBe(
416
+ 200
417
+ )
418
+ })
419
+
420
+ it('binds a route param as one short string, never a list', async () => {
421
+ const route = bootStateRoute('SELECT id FROM team_notes WHERE team_id = $1', [
422
+ 'currentPageEntityId',
423
+ ])
424
+ const listed = await route({ query: { currentPageEntityId: ['a', 'b'] as any } })
425
+ expect(listed.queries[0].params).toEqual(['a'])
426
+ const long = await route({ query: { currentPageEntityId: 'x'.repeat(1000) } })
427
+ expect(long.queries).toEqual([])
428
+ })
429
+ })
430
+
431
+ describe('no read waits on the database for long (A5-L5)', () => {
432
+ it('bounds every query of the Postgres-family read routes on the client, and every external read', () => {
433
+ const teleport: UIDLDataSource = { id: 'ds-t', name: 'db', type: 'teleport', config: {} }
434
+ const postgres: UIDLDataSource = {
435
+ id: 'ds-p',
436
+ name: 'db',
437
+ type: 'postgresql',
438
+ config: { host: 'h', port: 5432, user: 'u', password: 'p', database: 'd' },
439
+ }
440
+ expect(generateDataSourceFetcherWithCore(teleport, 'posts', false, {})).toContain(
441
+ 'query_timeout: 10000'
442
+ )
443
+ expect(generateDataSourceFetcherWithCore(postgres, 'posts', false, {})).toContain(
444
+ 'query_timeout: 10000'
445
+ )
446
+ expect(generateRawQueryFetcher({}, 'SELECT 1', [])).toContain('var QUERY_TIMEOUT_MS = 10000')
447
+ expect(generateDataSourceFetcherWithCore(MYSQL, 'customers', false, {})).toContain(
448
+ 'var __TQ_DRIVER_TIMEOUT_MS = 10000'
449
+ )
450
+ })
451
+ })
452
+
453
+ describe('fix round 1: every field a request names is a known column (C1, I2)', () => {
454
+ const SCHEMA = {
455
+ selectedTables: {
456
+ customers: {
457
+ columns: [{ name: 'id' }, { name: 'name' }, { name: 'email' }, { name: 'password' }],
458
+ },
459
+ },
460
+ }
461
+ const PUBLIC = publicExposure(['customers'])
462
+ const toModule = (esm: string): string =>
463
+ esm
464
+ .replace(/^import .*$/gm, '')
465
+ .replace(/^export \{[^}]*\}\s*$/m, '')
466
+ .replace(/^export default \{[^}]*\}\s*$/m, '')
467
+ .concat('\nmodule.exports = { handler: handler }')
468
+ const call = async (m: any, query: any) => {
469
+ const out: any = {}
470
+ const res: any = {
471
+ status(c: number) {
472
+ out.status = c
473
+ return res
474
+ },
475
+ json(p: any) {
476
+ out.body = p
477
+ return res
478
+ },
479
+ setHeader: (): any => res,
480
+ }
481
+ await m.exports.handler({ method: 'GET', headers: {}, query }, res)
482
+ return out
483
+ }
484
+ const fakeRequire = (name: string): any => (name === 'crypto' ? require('crypto') : {})
485
+
486
+ const bootMariaDb = () => {
487
+ const MARIA: UIDLDataSource = {
488
+ id: 'ds-maria',
489
+ name: 'Shop',
490
+ type: 'mariadb',
491
+ config: { host: 'h', port: 3306, user: 'u', password: 'p', database: 'shop', ...SCHEMA },
492
+ }
493
+ const sqls: string[] = []
494
+ const mariadb = {
495
+ createConnection: async (): Promise<any> => ({
496
+ query: async (sql: string): Promise<any[]> => {
497
+ sqls.push(sql)
498
+ return []
499
+ },
500
+ end: async (): Promise<void> => undefined,
501
+ }),
502
+ }
503
+ const m: any = { exports: {} }
504
+ new Function(
505
+ 'require',
506
+ 'module',
507
+ 'exports',
508
+ 'process',
509
+ 'console',
510
+ 'mariadb',
511
+ toModule(generateDataSourceFetcherWithCore(MARIA, 'customers', false, PUBLIC))
512
+ )(fakeRequire, m, m.exports, { env: { NEXTAUTH_SECRET: 'server-secret' } }, quiet, mariadb)
513
+ return { m, sqls }
514
+ }
515
+
516
+ it('refuses a repeated sortBy and a sort that is not one field with asc|desc (MariaDB)', async () => {
517
+ const { m, sqls } = bootMariaDb()
518
+ for (const query of [
519
+ { sortBy: ['name', 'name` , (SELECT SLEEP(5)) -- '] },
520
+ { sortBy: 'name` , (SELECT SLEEP(5)) -- ' },
521
+ { sortBy: 'name', sortOrder: 'desc; DROP TABLE x' },
522
+ { sorts: JSON.stringify([{ field: 'name' }, { field: 'email' }]) },
523
+ ]) {
524
+ expect((await call(m, query)).status).toBe(400)
525
+ }
526
+ expect(sqls).toEqual([])
527
+ expect((await call(m, { sortBy: 'name', sortOrder: 'desc' })).status).toBe(200)
528
+ })
529
+
530
+ it('refuses a filter OBJECT whose key is not a known column (MariaDB)', async () => {
531
+ const { m, sqls } = bootMariaDb()
532
+ const out = await call(m, { filters: JSON.stringify({ 'id` = 1 OR SLEEP(5) OR `id': 'x' }) })
533
+ expect(out.status).toBe(400)
534
+ expect((await call(m, { filters: JSON.stringify({ nickname: 'x' }) })).status).toBe(400)
535
+ expect(sqls).toEqual([])
536
+ expect((await call(m, { filters: JSON.stringify({ name: 'Ada' }) })).status).toBe(200)
537
+ })
538
+
539
+ it('refuses a browser filter, sort or search on a credential column (I2)', async () => {
540
+ const { m, sqls } = bootMariaDb()
541
+ for (const query of [
542
+ { filters: JSON.stringify([{ source: 'password', operand: '>', destination: '$2b$10$a' }]) },
543
+ { sortBy: 'password' },
544
+ { query: 'abc', queryColumns: JSON.stringify(['name', 'password']) },
545
+ ]) {
546
+ expect((await call(m, query)).status).toBe(400)
547
+ }
548
+ expect(sqls).toEqual([])
549
+ })
550
+
551
+ it('refuses a MongoDB operator or a dotted path a request names as a field', async () => {
552
+ const MONGO: UIDLDataSource = {
553
+ id: 'ds-mongo',
554
+ name: 'M',
555
+ type: 'mongodb',
556
+ config: { connectionString: 'mongodb://h/db', database: 'db', ...SCHEMA },
557
+ }
558
+ const finds: any[] = []
559
+ class MongoClient {
560
+ public async connect(): Promise<void> {
561
+ return
562
+ }
563
+ public async close(): Promise<void> {
564
+ return
565
+ }
566
+ public db() {
567
+ return {
568
+ collection: () => ({
569
+ find: (filter: any) => {
570
+ finds.push(filter)
571
+ const cursor: any = {
572
+ sort: () => cursor,
573
+ skip: () => cursor,
574
+ limit: () => cursor,
575
+ project: () => cursor,
576
+ toArray: async (): Promise<any[]> => [],
577
+ }
578
+ return cursor
579
+ },
580
+ countDocuments: async () => 0,
581
+ }),
582
+ }
583
+ }
584
+ }
585
+ const m: any = { exports: {} }
586
+ new Function(
587
+ 'require',
588
+ 'module',
589
+ 'exports',
590
+ 'process',
591
+ 'console',
592
+ 'MongoClient',
593
+ 'ObjectId',
594
+ toModule(generateDataSourceFetcherWithCore(MONGO, 'customers', false, PUBLIC))
595
+ )(
596
+ fakeRequire,
597
+ m,
598
+ m.exports,
599
+ { env: { NEXTAUTH_SECRET: 'server-secret' } },
600
+ quiet,
601
+ MongoClient,
602
+ class {}
603
+ )
604
+ for (const filters of [
605
+ { $where: 'sleep(5000) || true' },
606
+ { 'name.first': 'x' },
607
+ { nickname: 'x' },
608
+ ]) {
609
+ expect((await call(m, { filters: JSON.stringify(filters) })).status).toBe(400)
610
+ }
611
+ expect(finds).toEqual([])
612
+ })
613
+
614
+ it('a public table never answers a filter or a search on its credential column (TeleportHQ route)', async () => {
615
+ const TQ: UIDLDataSource = {
616
+ id: 'ds-tq',
617
+ name: 'TQ',
618
+ type: 'teleport',
619
+ config: {
620
+ selectedTables: {
621
+ members: { columns: [{ name: 'id' }, { name: 'name' }, { name: 'password' }] },
622
+ },
623
+ },
624
+ }
625
+ const sqls: string[] = []
626
+ class Client {
627
+ public async connect(): Promise<void> {
628
+ return
629
+ }
630
+ public async end(): Promise<void> {
631
+ return
632
+ }
633
+ public async query(sql: string) {
634
+ sqls.push(sql)
635
+ if (/information_schema/i.test(sql)) {
636
+ return { rows: [{ column_name: 'name' }, { column_name: 'password' }] }
637
+ }
638
+ return { rows: [{ id: 1, name: 'Ada', password: '$2b$10$abc' }] }
639
+ }
640
+ }
641
+ const m: any = { exports: {} }
642
+ new Function(
643
+ 'require',
644
+ 'module',
645
+ 'exports',
646
+ 'process',
647
+ 'console',
648
+ 'Client',
649
+ 'pgTypes',
650
+ toModule(generateDataSourceFetcherWithCore(TQ, 'members', false, publicExposure(['members'])))
651
+ )(
652
+ fakeRequire,
653
+ m,
654
+ m.exports,
655
+ { env: { NEXTAUTH_SECRET: 'server-secret', TELEPORT_DB_HOST: 'h' } },
656
+ quiet,
657
+ Client,
658
+ null
659
+ )
660
+ const filtered = await call(m, {
661
+ filters: JSON.stringify([{ source: 'password', operand: '>', destination: '$2b$10$a' }]),
662
+ })
663
+ expect(filtered.status).toBe(400)
664
+ sqls.length = 0
665
+ const searched = await call(m, { query: '$2b$10$ab' })
666
+ expect(searched.status).toBe(200)
667
+ const read = sqls.filter((sql) => /FROM "?members"?/i.test(sql)).pop() as string
668
+ expect(read).toContain('lower("name"::text)')
669
+ expect(read).not.toContain('password')
670
+ expect(searched.body.data[0]).not.toHaveProperty('password')
671
+ })
672
+ })
673
+
674
+ describe('fix round 3: projections, searches, document values and request bodies (N2, N3, N7, N8)', () => {
675
+ const SCHEMA = {
676
+ selectedTables: {
677
+ customers: {
678
+ columns: [
679
+ { name: 'id', type: 'integer' },
680
+ { name: 'name', type: 'text' },
681
+ { name: 'email', type: 'varchar(255)' },
682
+ { name: 'password', type: 'text' },
683
+ ],
684
+ },
685
+ },
686
+ }
687
+ const PUBLIC = publicExposure(['customers'])
688
+ const toModule = (esm: string): string =>
689
+ esm
690
+ .replace(/^import .*$/gm, '')
691
+ .replace(/^export \{[^}]*\}\s*$/m, '')
692
+ .replace(/^export default \{[^}]*\}\s*$/m, '')
693
+ .concat('\nmodule.exports = { handler: handler }')
694
+ const call = async (m: any, query: any, extra: { method?: string; body?: any } = {}) => {
695
+ const out: any = {}
696
+ const res: any = {
697
+ status(c: number) {
698
+ out.status = c
699
+ return res
700
+ },
701
+ json(p: any) {
702
+ out.body = p
703
+ return res
704
+ },
705
+ setHeader: (): any => res,
706
+ }
707
+ await m.exports.handler(
708
+ { method: extra.method || 'GET', headers: {}, query, body: extra.body },
709
+ res
710
+ )
711
+ return out
712
+ }
713
+ const fakeRequire = (name: string): any => (name === 'crypto' ? require('crypto') : {})
714
+ const ENV = { env: { NEXTAUTH_SECRET: 'server-secret' } }
715
+
716
+ const bootSupabase = () => {
717
+ const SUPA: UIDLDataSource = {
718
+ id: 'ds-supa',
719
+ name: 'S',
720
+ type: 'supabase',
721
+ config: { supabaseUrl: 'https://x.supabase.co', serviceRoleKey: 'k', ...SCHEMA },
722
+ }
723
+ const projections: string[] = []
724
+ const ors: string[] = []
725
+ const builder = (): any => {
726
+ const b: any = {
727
+ select(projection: string) {
728
+ projections.push(projection)
729
+ return b
730
+ },
731
+ or(expression: string) {
732
+ ors.push(expression)
733
+ return b
734
+ },
735
+ eq: () => b,
736
+ neq: () => b,
737
+ order: () => b,
738
+ limit: () => b,
739
+ range: () => b,
740
+ in: () => b,
741
+ single: () => b,
742
+ then(resolve: any) {
743
+ resolve({ data: [{ id: 1, name: 'Ada', email: 'a@x', password: '$2b$h' }], error: null })
744
+ },
745
+ }
746
+ return b
747
+ }
748
+ const createClient = () => ({ from: () => builder() })
749
+ const m: any = { exports: {} }
750
+ new Function(
751
+ 'require',
752
+ 'module',
753
+ 'exports',
754
+ 'process',
755
+ 'console',
756
+ 'createClient',
757
+ toModule(generateDataSourceFetcherWithCore(SUPA, 'customers', false, PUBLIC))
758
+ )(fakeRequire, m, m.exports, ENV, quiet, createClient)
759
+ return { m, projections, ors }
760
+ }
761
+
762
+ it('Supabase: a projection is the bare star or plain known columns — no alias, no embedded table (N2)', async () => {
763
+ const { m, projections } = bootSupabase()
764
+ for (const select of [
765
+ 'alias:password',
766
+ 'name:password',
767
+ '*,users(email,password)',
768
+ 'users!inner(email)',
769
+ 'name::text',
770
+ 'nickname',
771
+ 'password',
772
+ ]) {
773
+ expect({ select, status: (await call(m, { select })).status }).toEqual({
774
+ select,
775
+ status: 400,
776
+ })
777
+ }
778
+ expect(projections).toEqual([])
779
+ const plain = await call(m, { select: 'id, name' })
780
+ expect(plain.status).toBe(200)
781
+ expect(projections).toContain('id, name')
782
+ expect(plain.body.data[0]).not.toHaveProperty('password')
783
+ })
784
+
785
+ it("Supabase: a search naming no columns runs over the schema's text columns, never a credential (N3)", async () => {
786
+ const { m, ors } = bootSupabase()
787
+ const out = await call(m, { query: 'abc' })
788
+ expect(out.status).toBe(200)
789
+ const expression = ors.pop() as string
790
+ expect(expression).toMatch(/\bname\.ilike\./)
791
+ expect(expression).toMatch(/\bemail\.ilike\./)
792
+ expect(expression).not.toMatch(/password|\bid\.ilike/)
793
+ })
794
+
795
+ it('MariaDB: a search naming no columns runs over the schema, never a credential (N3)', async () => {
796
+ const MARIA: UIDLDataSource = {
797
+ id: 'ds-maria',
798
+ name: 'Shop',
799
+ type: 'mariadb',
800
+ config: { host: 'h', port: 3306, user: 'u', password: 'p', database: 'shop', ...SCHEMA },
801
+ }
802
+ const sqls: string[] = []
803
+ const mariadb = {
804
+ createConnection: async (): Promise<any> => ({
805
+ query: async (sql: string): Promise<any[]> => {
806
+ sqls.push(sql)
807
+ if (/information_schema/i.test(sql)) {
808
+ return ['id', 'name', 'email', 'password'].map((name) => ({ COLUMN_NAME: name }))
809
+ }
810
+ return []
811
+ },
812
+ end: async (): Promise<void> => undefined,
813
+ }),
814
+ }
815
+ const m: any = { exports: {} }
816
+ new Function(
817
+ 'require',
818
+ 'module',
819
+ 'exports',
820
+ 'process',
821
+ 'console',
822
+ 'mariadb',
823
+ toModule(generateDataSourceFetcherWithCore(MARIA, 'customers', false, PUBLIC))
824
+ )(fakeRequire, m, m.exports, ENV, quiet, mariadb)
825
+ expect((await call(m, { query: '$2b$10$' })).status).toBe(200)
826
+ const search = sqls.filter((sql) => /LIKE/i.test(sql)).pop() as string
827
+ expect(search).toContain('`name`')
828
+ expect(search).not.toContain('password')
829
+ expect(sqls.some((sql) => /information_schema/i.test(sql))).toBe(false)
830
+ })
831
+
832
+ const bootMongo = () => {
833
+ const MONGO: UIDLDataSource = {
834
+ id: 'ds-mongo',
835
+ name: 'M',
836
+ type: 'mongodb',
837
+ config: { connectionString: 'mongodb://h/db', database: 'db', ...SCHEMA },
838
+ }
839
+ const finds: any[] = []
840
+ class MongoClient {
841
+ public async connect(): Promise<void> {
842
+ return
843
+ }
844
+ public async close(): Promise<void> {
845
+ return
846
+ }
847
+ public db() {
848
+ return {
849
+ collection: () => ({
850
+ find: (filter: any) => {
851
+ finds.push(filter)
852
+ const cursor: any = {
853
+ sort: () => cursor,
854
+ skip: () => cursor,
855
+ limit: () => cursor,
856
+ project: () => cursor,
857
+ toArray: async (): Promise<any[]> => [],
858
+ }
859
+ return cursor
860
+ },
861
+ countDocuments: async () => 0,
862
+ findOne: async (): Promise<any> => ({ _id: 'x', name: 'Ada', password: '$2b$h' }),
863
+ }),
864
+ }
865
+ }
866
+ }
867
+ const m: any = { exports: {} }
868
+ new Function(
869
+ 'require',
870
+ 'module',
871
+ 'exports',
872
+ 'process',
873
+ 'console',
874
+ 'MongoClient',
875
+ 'ObjectId',
876
+ toModule(generateDataSourceFetcherWithCore(MONGO, 'customers', false, PUBLIC))
877
+ )(fakeRequire, m, m.exports, ENV, quiet, MongoClient, class {})
878
+ return { m, finds }
879
+ }
880
+
881
+ it('MongoDB: a search is matched as text over non-credential fields (N3)', async () => {
882
+ const { m, finds } = bootMongo()
883
+ expect((await call(m, { query: '^\\$2b.*(a+)+$' })).status).toBe(200)
884
+ const filter = finds.pop()
885
+ const fields = filter.$or.map((condition: any) => Object.keys(condition)[0])
886
+ expect(fields).toEqual(expect.arrayContaining(['name', 'email']))
887
+ expect(fields).not.toContain('password')
888
+ const pattern = filter.$or[0][fields[0]].$regex
889
+ expect(pattern).toBe('\\^\\\\\\$2b\\.\\*\\(a\\+\\)\\+\\$')
890
+ expect(new RegExp(pattern).test('x^\\$2b.*(a+)+$y')).toBe(true)
891
+ })
892
+
893
+ it('MongoDB: a filter value is a plain value or a list of them, never an operator object (N8)', async () => {
894
+ const { m, finds } = bootMongo()
895
+ for (const filters of [
896
+ [{ source: 'name', destination: { $ne: null as string | null } }],
897
+ [{ source: 'name', destination: [{ $gt: '' }] }],
898
+ { name: { $regex: '^A' } },
899
+ [
900
+ {
901
+ type: 'group',
902
+ operator: 'or',
903
+ children: [{ source: 'email', destination: { $exists: true } }],
904
+ },
905
+ ],
906
+ ]) {
907
+ expect((await call(m, { filters: JSON.stringify(filters) })).status).toBe(400)
908
+ }
909
+ expect(finds).toEqual([])
910
+ expect(
911
+ (await call(m, { filters: JSON.stringify([{ source: 'name', destination: ['Ada', 'Bo'] }]) }))
912
+ .status
913
+ ).toBe(200)
914
+ expect((await call(m, { filters: JSON.stringify({ name: 'Ada' }) })).status).toBe(200)
915
+ })
916
+
917
+ it('CSV without a schema: a search naming no columns reads the bound fields only, else it is refused (N3)', async () => {
918
+ const CSV: UIDLDataSource = {
919
+ id: 'ds-csv',
920
+ name: 'C',
921
+ type: 'csv-file',
922
+ config: {
923
+ parsedData: [
924
+ { name: 'Ada', password: 'hash-ada' },
925
+ { name: 'Bo', password: 'hash-bo' },
926
+ ],
927
+ },
928
+ }
929
+ const boot = (fields?: string[]) => {
930
+ const exposure = publicExposure([], ['ds-csv'])
931
+ if (fields) {
932
+ exposure.dataExposure.dataSources['ds-csv'] = publicRead({ fields })
933
+ }
934
+ const m: any = { exports: {} }
935
+ new Function(
936
+ 'require',
937
+ 'module',
938
+ 'exports',
939
+ 'process',
940
+ 'console',
941
+ toModule(generateDataSourceFetcherWithCore(CSV, '', false, exposure))
942
+ )(fakeRequire, m, m.exports, ENV, quiet)
943
+ return m
944
+ }
945
+ const bound = await call(boot(['name']), { query: 'hash' })
946
+ expect(bound.status).toBe(200)
947
+ expect(bound.body.data).toEqual([])
948
+ expect((await call(boot(['name']), { query: 'ada' })).body.data).toHaveLength(1)
949
+ expect((await call(boot(), { query: 'hash' })).status).toBe(400)
950
+ })
951
+
952
+ it("REST configured for POST: a browser's body never goes upstream, the configured one does (N7)", async () => {
953
+ const REST: UIDLDataSource = {
954
+ id: 'ds-rest',
955
+ name: 'R',
956
+ type: 'rest-api',
957
+ config: {
958
+ url: 'https://api.example.test/graphql',
959
+ method: 'POST',
960
+ bodyType: 'json',
961
+ // The editor stores the configured body as its text.
962
+ body: JSON.stringify({ query: '{ products { id name } }' }),
963
+ authorization: { type: 'bearer', credentials: { token: 'SERVER-TOKEN' } },
964
+ },
965
+ }
966
+ const sent: any[] = []
967
+ const fetch = async (url: string, options: any) => {
968
+ sent.push({ url, options })
969
+ return { ok: true, status: 200, json: async (): Promise<any[]> => [] }
970
+ }
971
+ const m: any = { exports: {} }
972
+ new Function(
973
+ 'require',
974
+ 'module',
975
+ 'exports',
976
+ 'process',
977
+ 'console',
978
+ 'fetch',
979
+ toModule(generateDataSourceFetcherWithCore(REST, '', false, publicExposure([], ['ds-rest'])))
980
+ )(fakeRequire, m, m.exports, ENV, quiet, fetch)
981
+ const out = await call(
982
+ m,
983
+ {},
984
+ { method: 'POST', body: { query: '{ users { email password } }' } }
985
+ )
986
+ expect(out.status).toBe(200)
987
+ expect(sent).toHaveLength(1)
988
+ expect(String(sent[0].options.body)).toContain('products')
989
+ expect(String(sent[0].options.body)).not.toContain('users')
990
+ })
991
+ })