@teleporthq/teleport-plugin-next-data-source 0.44.15 → 0.45.0
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.
- package/__tests__/_helpers/exposure.ts +29 -0
- package/__tests__/_helpers/first-page-runtime.ts +12 -2
- package/__tests__/blog-post-contents.test.ts +3 -1
- package/__tests__/blog-post-page-enrichment.test.ts +2 -1
- package/__tests__/blog-post-seo-fields.test.ts +124 -0
- package/__tests__/browser-row-policy.test.ts +65 -5
- package/__tests__/data-source-cache-wrapping.test.ts +48 -7
- package/__tests__/external-read-routes-guard.test.ts +991 -0
- package/__tests__/null-filter-where.test.ts +19 -2
- package/__tests__/range-control.test.ts +29 -0
- package/__tests__/search-relevance.test.ts +42 -6
- package/__tests__/table-read-route-protected-tables.test.ts +54 -4
- package/dist/cjs/count-fetchers.d.ts.map +1 -1
- package/dist/cjs/count-fetchers.js +1 -1
- package/dist/cjs/count-fetchers.js.map +1 -1
- package/dist/cjs/data-source-fetchers.d.ts +4 -0
- package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
- package/dist/cjs/data-source-fetchers.js +97 -20
- package/dist/cjs/data-source-fetchers.js.map +1 -1
- package/dist/cjs/fetchers/postgresql.d.ts +3 -1
- package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
- package/dist/cjs/fetchers/postgresql.js +11 -3
- package/dist/cjs/fetchers/postgresql.js.map +1 -1
- package/dist/cjs/fetchers/raw-query.d.ts +20 -1
- package/dist/cjs/fetchers/raw-query.d.ts.map +1 -1
- package/dist/cjs/fetchers/raw-query.js +37 -7
- package/dist/cjs/fetchers/raw-query.js.map +1 -1
- package/dist/cjs/fetchers/redshift.d.ts.map +1 -1
- package/dist/cjs/fetchers/redshift.js +2 -1
- package/dist/cjs/fetchers/redshift.js.map +1 -1
- package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
- package/dist/cjs/fetchers/teleport.js +5 -3
- package/dist/cjs/fetchers/teleport.js.map +1 -1
- package/dist/cjs/fetchers/utils/external-read-guard.d.ts +53 -0
- package/dist/cjs/fetchers/utils/external-read-guard.d.ts.map +1 -0
- package/dist/cjs/fetchers/utils/external-read-guard.js +126 -0
- package/dist/cjs/fetchers/utils/external-read-guard.js.map +1 -0
- package/dist/cjs/fetchers/utils/table-access-guard.d.ts +76 -12
- package/dist/cjs/fetchers/utils/table-access-guard.d.ts.map +1 -1
- package/dist/cjs/fetchers/utils/table-access-guard.js +123 -15
- package/dist/cjs/fetchers/utils/table-access-guard.js.map +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/pagination-controls.d.ts +4 -1
- package/dist/cjs/pagination-controls.d.ts.map +1 -1
- package/dist/cjs/pagination-controls.js +4 -1
- package/dist/cjs/pagination-controls.js.map +1 -1
- package/dist/cjs/pagination-plugin.js +1 -0
- package/dist/cjs/pagination-plugin.js.map +1 -1
- package/dist/cjs/range-control.d.ts +10 -5
- package/dist/cjs/range-control.d.ts.map +1 -1
- package/dist/cjs/range-control.js +23 -2
- package/dist/cjs/range-control.js.map +1 -1
- package/dist/cjs/transformations/blog-post.d.ts +5 -0
- package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
- package/dist/cjs/transformations/blog-post.js +6 -2
- package/dist/cjs/transformations/blog-post.js.map +1 -1
- package/dist/cjs/transformations/index.d.ts +18 -2
- package/dist/cjs/transformations/index.d.ts.map +1 -1
- package/dist/cjs/transformations/index.js +26 -1
- package/dist/cjs/transformations/index.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/count-fetchers.d.ts.map +1 -1
- package/dist/esm/count-fetchers.js +1 -1
- package/dist/esm/count-fetchers.js.map +1 -1
- package/dist/esm/data-source-fetchers.d.ts +4 -0
- package/dist/esm/data-source-fetchers.d.ts.map +1 -1
- package/dist/esm/data-source-fetchers.js +97 -20
- package/dist/esm/data-source-fetchers.js.map +1 -1
- package/dist/esm/fetchers/postgresql.d.ts +3 -1
- package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
- package/dist/esm/fetchers/postgresql.js +12 -4
- package/dist/esm/fetchers/postgresql.js.map +1 -1
- package/dist/esm/fetchers/raw-query.d.ts +20 -1
- package/dist/esm/fetchers/raw-query.d.ts.map +1 -1
- package/dist/esm/fetchers/raw-query.js +38 -8
- package/dist/esm/fetchers/raw-query.js.map +1 -1
- package/dist/esm/fetchers/redshift.d.ts.map +1 -1
- package/dist/esm/fetchers/redshift.js +2 -1
- package/dist/esm/fetchers/redshift.js.map +1 -1
- package/dist/esm/fetchers/teleport.d.ts.map +1 -1
- package/dist/esm/fetchers/teleport.js +6 -4
- package/dist/esm/fetchers/teleport.js.map +1 -1
- package/dist/esm/fetchers/utils/external-read-guard.d.ts +53 -0
- package/dist/esm/fetchers/utils/external-read-guard.d.ts.map +1 -0
- package/dist/esm/fetchers/utils/external-read-guard.js +118 -0
- package/dist/esm/fetchers/utils/external-read-guard.js.map +1 -0
- package/dist/esm/fetchers/utils/table-access-guard.d.ts +76 -12
- package/dist/esm/fetchers/utils/table-access-guard.d.ts.map +1 -1
- package/dist/esm/fetchers/utils/table-access-guard.js +119 -15
- package/dist/esm/fetchers/utils/table-access-guard.js.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/pagination-controls.d.ts +4 -1
- package/dist/esm/pagination-controls.d.ts.map +1 -1
- package/dist/esm/pagination-controls.js +4 -1
- package/dist/esm/pagination-controls.js.map +1 -1
- package/dist/esm/pagination-plugin.js +1 -0
- package/dist/esm/pagination-plugin.js.map +1 -1
- package/dist/esm/range-control.d.ts +10 -5
- package/dist/esm/range-control.d.ts.map +1 -1
- package/dist/esm/range-control.js +23 -2
- package/dist/esm/range-control.js.map +1 -1
- package/dist/esm/transformations/blog-post.d.ts +5 -0
- package/dist/esm/transformations/blog-post.d.ts.map +1 -1
- package/dist/esm/transformations/blog-post.js +6 -2
- package/dist/esm/transformations/blog-post.js.map +1 -1
- package/dist/esm/transformations/index.d.ts +18 -2
- package/dist/esm/transformations/index.d.ts.map +1 -1
- package/dist/esm/transformations/index.js +27 -2
- package/dist/esm/transformations/index.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/count-fetchers.ts +10 -2
- package/src/data-source-fetchers.ts +144 -23
- package/src/fetchers/postgresql.ts +38 -9
- package/src/fetchers/raw-query.ts +125 -13
- package/src/fetchers/redshift.ts +3 -0
- package/src/fetchers/teleport.ts +29 -8
- package/src/fetchers/utils/external-read-guard.ts +289 -0
- package/src/fetchers/utils/table-access-guard.ts +469 -20
- package/src/index.ts +1 -1
- package/src/pagination-controls.ts +4 -1
- package/src/pagination-plugin.ts +1 -0
- package/src/range-control.ts +40 -8
- package/src/transformations/blog-post.ts +107 -4
- 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
|
+
})
|