@teleporthq/teleport-plugin-next-data-source 0.43.67 → 0.43.68

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 (202) hide show
  1. package/__tests__/array-overlap-sql.test.ts +53 -0
  2. package/__tests__/blog-post-page-enrichment.test.ts +214 -0
  3. package/__tests__/browser-row-policy.test.ts +503 -0
  4. package/__tests__/data-source-cache-wrapping.test.ts +18 -2
  5. package/__tests__/ecommerce-product-options.test.ts +206 -0
  6. package/__tests__/ecommerce-product-pages.test.ts +82 -0
  7. package/__tests__/ecommerce-product-pre-options.golden.json +505 -0
  8. package/__tests__/ecommerce-product-storefront-contract.test.ts +293 -0
  9. package/__tests__/ecommerce-product-subscriptions.test.ts +121 -0
  10. package/__tests__/fetchers.test.ts +2 -2
  11. package/__tests__/items-path.test.ts +150 -0
  12. package/__tests__/product-options-storefront.fixture.json +2510 -0
  13. package/__tests__/product-page-url-fixtures.json +25 -0
  14. package/__tests__/product-price-sort.ts +4 -2
  15. package/__tests__/raw-rows-mode.test.ts +111 -0
  16. package/__tests__/rest-api-response-cache.test.ts +434 -0
  17. package/__tests__/table-read-route-protected-tables.test.ts +493 -0
  18. package/dist/cjs/array-overlap-sql.d.ts +21 -0
  19. package/dist/cjs/array-overlap-sql.d.ts.map +1 -0
  20. package/dist/cjs/array-overlap-sql.js +25 -0
  21. package/dist/cjs/array-overlap-sql.js.map +1 -0
  22. package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
  23. package/dist/cjs/data-source-fetchers.js +10 -4
  24. package/dist/cjs/data-source-fetchers.js.map +1 -1
  25. package/dist/cjs/fetchers/javascript.d.ts.map +1 -1
  26. package/dist/cjs/fetchers/javascript.js +1 -1
  27. package/dist/cjs/fetchers/javascript.js.map +1 -1
  28. package/dist/cjs/fetchers/postgresql.d.ts +1 -1
  29. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  30. package/dist/cjs/fetchers/postgresql.js +8 -4
  31. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  32. package/dist/cjs/fetchers/raw-query.d.ts +8 -1
  33. package/dist/cjs/fetchers/raw-query.d.ts.map +1 -1
  34. package/dist/cjs/fetchers/raw-query.js +42 -4
  35. package/dist/cjs/fetchers/raw-query.js.map +1 -1
  36. package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
  37. package/dist/cjs/fetchers/rest-api.js +7 -2
  38. package/dist/cjs/fetchers/rest-api.js.map +1 -1
  39. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  40. package/dist/cjs/fetchers/teleport.js +8 -5
  41. package/dist/cjs/fetchers/teleport.js.map +1 -1
  42. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts +43 -0
  43. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts.map +1 -0
  44. package/dist/cjs/fetchers/utils/browser-row-policy.js +66 -0
  45. package/dist/cjs/fetchers/utils/browser-row-policy.js.map +1 -0
  46. package/dist/cjs/fetchers/utils/request-sql-guards.d.ts +8 -0
  47. package/dist/cjs/fetchers/utils/request-sql-guards.d.ts.map +1 -0
  48. package/dist/cjs/fetchers/utils/request-sql-guards.js +12 -0
  49. package/dist/cjs/fetchers/utils/request-sql-guards.js.map +1 -0
  50. package/dist/cjs/fetchers/utils/rest-api-response-cache.d.ts +40 -0
  51. package/dist/cjs/fetchers/utils/rest-api-response-cache.d.ts.map +1 -0
  52. package/dist/cjs/fetchers/utils/rest-api-response-cache.js +64 -0
  53. package/dist/cjs/fetchers/utils/rest-api-response-cache.js.map +1 -0
  54. package/dist/cjs/fetchers/utils/table-access-guard.d.ts +23 -0
  55. package/dist/cjs/fetchers/utils/table-access-guard.d.ts.map +1 -0
  56. package/dist/cjs/fetchers/utils/table-access-guard.js +34 -0
  57. package/dist/cjs/fetchers/utils/table-access-guard.js.map +1 -0
  58. package/dist/cjs/items-path.d.ts +10 -0
  59. package/dist/cjs/items-path.d.ts.map +1 -0
  60. package/dist/cjs/items-path.js +86 -0
  61. package/dist/cjs/items-path.js.map +1 -0
  62. package/dist/cjs/localized-columns.d.ts.map +1 -1
  63. package/dist/cjs/localized-columns.js +13 -0
  64. package/dist/cjs/localized-columns.js.map +1 -1
  65. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  66. package/dist/cjs/pagination-plugin.js +17 -5
  67. package/dist/cjs/pagination-plugin.js.map +1 -1
  68. package/dist/cjs/transformations/blog-adjacent-posts.d.ts +16 -0
  69. package/dist/cjs/transformations/blog-adjacent-posts.d.ts.map +1 -0
  70. package/dist/cjs/transformations/blog-adjacent-posts.js +25 -0
  71. package/dist/cjs/transformations/blog-adjacent-posts.js.map +1 -0
  72. package/dist/cjs/transformations/blog-comments.d.ts +30 -0
  73. package/dist/cjs/transformations/blog-comments.d.ts.map +1 -0
  74. package/dist/cjs/transformations/blog-comments.js +34 -0
  75. package/dist/cjs/transformations/blog-comments.js.map +1 -0
  76. package/dist/cjs/transformations/blog-post.d.ts +2 -0
  77. package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
  78. package/dist/cjs/transformations/blog-post.js +4 -1
  79. package/dist/cjs/transformations/blog-post.js.map +1 -1
  80. package/dist/cjs/transformations/ecommerce-product.d.ts +9 -1
  81. package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
  82. package/dist/cjs/transformations/ecommerce-product.js +28 -1
  83. package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
  84. package/dist/cjs/transformations/heading-anchors.d.ts +25 -0
  85. package/dist/cjs/transformations/heading-anchors.d.ts.map +1 -0
  86. package/dist/cjs/transformations/heading-anchors.js +29 -0
  87. package/dist/cjs/transformations/heading-anchors.js.map +1 -0
  88. package/dist/cjs/transformations/index.d.ts +47 -4
  89. package/dist/cjs/transformations/index.d.ts.map +1 -1
  90. package/dist/cjs/transformations/index.js +56 -18
  91. package/dist/cjs/transformations/index.js.map +1 -1
  92. package/dist/cjs/transformations/shared-utils.d.ts.map +1 -1
  93. package/dist/cjs/transformations/shared-utils.js +1 -1
  94. package/dist/cjs/transformations/shared-utils.js.map +1 -1
  95. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  96. package/dist/cjs/utils.d.ts.map +1 -1
  97. package/dist/cjs/utils.js +1 -1
  98. package/dist/cjs/utils.js.map +1 -1
  99. package/dist/esm/array-overlap-sql.d.ts +21 -0
  100. package/dist/esm/array-overlap-sql.d.ts.map +1 -0
  101. package/dist/esm/array-overlap-sql.js +21 -0
  102. package/dist/esm/array-overlap-sql.js.map +1 -0
  103. package/dist/esm/data-source-fetchers.d.ts.map +1 -1
  104. package/dist/esm/data-source-fetchers.js +11 -5
  105. package/dist/esm/data-source-fetchers.js.map +1 -1
  106. package/dist/esm/fetchers/javascript.d.ts.map +1 -1
  107. package/dist/esm/fetchers/javascript.js +1 -1
  108. package/dist/esm/fetchers/javascript.js.map +1 -1
  109. package/dist/esm/fetchers/postgresql.d.ts +1 -1
  110. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  111. package/dist/esm/fetchers/postgresql.js +8 -4
  112. package/dist/esm/fetchers/postgresql.js.map +1 -1
  113. package/dist/esm/fetchers/raw-query.d.ts +8 -1
  114. package/dist/esm/fetchers/raw-query.d.ts.map +1 -1
  115. package/dist/esm/fetchers/raw-query.js +42 -4
  116. package/dist/esm/fetchers/raw-query.js.map +1 -1
  117. package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
  118. package/dist/esm/fetchers/rest-api.js +7 -2
  119. package/dist/esm/fetchers/rest-api.js.map +1 -1
  120. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  121. package/dist/esm/fetchers/teleport.js +8 -5
  122. package/dist/esm/fetchers/teleport.js.map +1 -1
  123. package/dist/esm/fetchers/utils/browser-row-policy.d.ts +43 -0
  124. package/dist/esm/fetchers/utils/browser-row-policy.d.ts.map +1 -0
  125. package/dist/esm/fetchers/utils/browser-row-policy.js +60 -0
  126. package/dist/esm/fetchers/utils/browser-row-policy.js.map +1 -0
  127. package/dist/esm/fetchers/utils/request-sql-guards.d.ts +8 -0
  128. package/dist/esm/fetchers/utils/request-sql-guards.d.ts.map +1 -0
  129. package/dist/esm/fetchers/utils/request-sql-guards.js +8 -0
  130. package/dist/esm/fetchers/utils/request-sql-guards.js.map +1 -0
  131. package/dist/esm/fetchers/utils/rest-api-response-cache.d.ts +40 -0
  132. package/dist/esm/fetchers/utils/rest-api-response-cache.d.ts.map +1 -0
  133. package/dist/esm/fetchers/utils/rest-api-response-cache.js +59 -0
  134. package/dist/esm/fetchers/utils/rest-api-response-cache.js.map +1 -0
  135. package/dist/esm/fetchers/utils/table-access-guard.d.ts +23 -0
  136. package/dist/esm/fetchers/utils/table-access-guard.d.ts.map +1 -0
  137. package/dist/esm/fetchers/utils/table-access-guard.js +29 -0
  138. package/dist/esm/fetchers/utils/table-access-guard.js.map +1 -0
  139. package/dist/esm/items-path.d.ts +10 -0
  140. package/dist/esm/items-path.d.ts.map +1 -0
  141. package/dist/esm/items-path.js +58 -0
  142. package/dist/esm/items-path.js.map +1 -0
  143. package/dist/esm/localized-columns.d.ts.map +1 -1
  144. package/dist/esm/localized-columns.js +13 -0
  145. package/dist/esm/localized-columns.js.map +1 -1
  146. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  147. package/dist/esm/pagination-plugin.js +17 -5
  148. package/dist/esm/pagination-plugin.js.map +1 -1
  149. package/dist/esm/transformations/blog-adjacent-posts.d.ts +16 -0
  150. package/dist/esm/transformations/blog-adjacent-posts.d.ts.map +1 -0
  151. package/dist/esm/transformations/blog-adjacent-posts.js +21 -0
  152. package/dist/esm/transformations/blog-adjacent-posts.js.map +1 -0
  153. package/dist/esm/transformations/blog-comments.d.ts +30 -0
  154. package/dist/esm/transformations/blog-comments.d.ts.map +1 -0
  155. package/dist/esm/transformations/blog-comments.js +30 -0
  156. package/dist/esm/transformations/blog-comments.js.map +1 -0
  157. package/dist/esm/transformations/blog-post.d.ts +2 -0
  158. package/dist/esm/transformations/blog-post.d.ts.map +1 -1
  159. package/dist/esm/transformations/blog-post.js +4 -1
  160. package/dist/esm/transformations/blog-post.js.map +1 -1
  161. package/dist/esm/transformations/ecommerce-product.d.ts +9 -1
  162. package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
  163. package/dist/esm/transformations/ecommerce-product.js +29 -2
  164. package/dist/esm/transformations/ecommerce-product.js.map +1 -1
  165. package/dist/esm/transformations/heading-anchors.d.ts +25 -0
  166. package/dist/esm/transformations/heading-anchors.d.ts.map +1 -0
  167. package/dist/esm/transformations/heading-anchors.js +25 -0
  168. package/dist/esm/transformations/heading-anchors.js.map +1 -0
  169. package/dist/esm/transformations/index.d.ts +47 -4
  170. package/dist/esm/transformations/index.d.ts.map +1 -1
  171. package/dist/esm/transformations/index.js +56 -18
  172. package/dist/esm/transformations/index.js.map +1 -1
  173. package/dist/esm/transformations/shared-utils.d.ts.map +1 -1
  174. package/dist/esm/transformations/shared-utils.js +1 -1
  175. package/dist/esm/transformations/shared-utils.js.map +1 -1
  176. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  177. package/dist/esm/utils.d.ts.map +1 -1
  178. package/dist/esm/utils.js +1 -1
  179. package/dist/esm/utils.js.map +1 -1
  180. package/package.json +5 -5
  181. package/src/array-overlap-sql.ts +41 -0
  182. package/src/data-source-fetchers.ts +41 -5
  183. package/src/fetchers/javascript.ts +9 -1
  184. package/src/fetchers/postgresql.ts +37 -10
  185. package/src/fetchers/raw-query.ts +60 -5
  186. package/src/fetchers/rest-api.ts +27 -5
  187. package/src/fetchers/teleport.ts +37 -13
  188. package/src/fetchers/utils/browser-row-policy.ts +212 -0
  189. package/src/fetchers/utils/request-sql-guards.ts +25 -0
  190. package/src/fetchers/utils/rest-api-response-cache.ts +188 -0
  191. package/src/fetchers/utils/table-access-guard.ts +39 -0
  192. package/src/items-path.ts +74 -0
  193. package/src/localized-columns.ts +13 -0
  194. package/src/pagination-plugin.ts +29 -7
  195. package/src/transformations/blog-adjacent-posts.ts +74 -0
  196. package/src/transformations/blog-comments.ts +132 -0
  197. package/src/transformations/blog-post.ts +38 -5
  198. package/src/transformations/ecommerce-product.ts +568 -5
  199. package/src/transformations/heading-anchors.ts +85 -0
  200. package/src/transformations/index.ts +165 -22
  201. package/src/transformations/shared-utils.ts +6 -0
  202. package/src/utils.ts +26 -4
@@ -0,0 +1,503 @@
1
+ /* tslint:disable:function-constructor max-classes-per-file */
2
+ import { generateDataSourceFetcherWithCore } from '../src/data-source-fetchers'
3
+ import { generateRawQueryFetcher } from '../src/fetchers/raw-query'
4
+
5
+ /**
6
+ * The per-table read routes served whole tables to anyone: every draft,
7
+ * scheduled and archived blog post with its author's email address, every draft
8
+ * product, and the AI assistant's conversations. A visitor is now served the
9
+ * published rows without a hidden column, the store (its server code, its
10
+ * administrators) everything — and the verdicts below come from the executed
11
+ * route, not from its source text.
12
+ */
13
+
14
+ const SECRET = 'server-secret'
15
+ const ADMIN_ROLES = ['admin']
16
+
17
+ interface FakeSession {
18
+ role?: string
19
+ sub?: string
20
+ id?: string
21
+ }
22
+
23
+ interface RouteResult {
24
+ status: number
25
+ body: any
26
+ queries: Array<{ sql: string; params: unknown[] }>
27
+ }
28
+
29
+ interface BootOptions {
30
+ type?: 'teleport' | 'postgresql'
31
+ appSecret?: string | null
32
+ rows?: any[]
33
+ columns?: string[]
34
+ cache?: { scope: string; ttlSeconds: number; sMaxAge?: number }
35
+ failWith?: Error
36
+ }
37
+
38
+ interface CacheCall {
39
+ scope: string
40
+ sMaxAge: number
41
+ }
42
+
43
+ function bootTableRoute(tableName: string, options: BootOptions = {}) {
44
+ const appSecret = options.appSecret === undefined ? SECRET : options.appSecret
45
+ const type = options.type || 'teleport'
46
+ const dataSource = {
47
+ id: 'ds-1',
48
+ name: 'db',
49
+ type,
50
+ config:
51
+ type === 'teleport'
52
+ ? {
53
+ host: 'teleporthq.secrets.TELEPORT_DB_HOST',
54
+ port: 5432,
55
+ user: 'teleporthq.secrets.TELEPORT_DB_USER',
56
+ password: 'teleporthq.secrets.TELEPORT_DB_PASSWORD',
57
+ database: 'teleporthq.secrets.TELEPORT_DB_NAME',
58
+ selectedTables: { [tableName]: { columns: [{ name: 'id', type: 'uuid' }] } },
59
+ }
60
+ : { host: 'h', port: 5432, user: 'u', password: 'p', database: 'd' },
61
+ }
62
+ const esm = generateDataSourceFetcherWithCore(
63
+ dataSource as never,
64
+ tableName,
65
+ false,
66
+ { trustedReaderRoles: ADMIN_ROLES },
67
+ options.cache
68
+ )
69
+ const code = esm
70
+ .replace("import { Client } from 'pg'", "const { Client } = require('pg')")
71
+ .replace(
72
+ "import { tqWithCache } from '../tq-cache/server'",
73
+ "const { tqWithCache } = require('tq-cache')"
74
+ )
75
+ .replace(/^export default async function handler/m, 'async function handler')
76
+ .replace(/^export \{[^}]*\}\s*$/m, '')
77
+ .replace(/^export default \{[^}]*\}\s*$/m, '')
78
+ .concat(
79
+ options.cache
80
+ ? '\nmodule.exports = { handler: cachedHandler, getCount: cachedGetCount, fetchData: fetchData, fetchCount: fetchCount }'
81
+ : '\nmodule.exports = { handler: handler, getCount: getCount, fetchData: fetchData, fetchCount: fetchCount }'
82
+ )
83
+ expect(code).not.toMatch(/^export /m)
84
+ expect(code).not.toMatch(/^import /m)
85
+
86
+ const queries: Array<{ sql: string; params: unknown[] }> = []
87
+ const cacheCalls: CacheCall[] = []
88
+ let session: FakeSession | null = null
89
+ class FakeClient {
90
+ public async connect(): Promise<void> {
91
+ return
92
+ }
93
+ public async end(): Promise<void> {
94
+ return
95
+ }
96
+ public async query(sql: string, params: unknown[] = []): Promise<any> {
97
+ queries.push({ sql, params })
98
+ if (options.failWith) {
99
+ throw options.failWith
100
+ }
101
+ if (/information_schema/i.test(sql)) {
102
+ const columnRows = (options.columns || []).map((name) => ({ column_name: name }))
103
+ return { rows: columnRows, rowCount: columnRows.length }
104
+ }
105
+ if (/^SELECT COUNT/i.test(sql)) {
106
+ return { rows: [{ count: String((options.rows || []).length) }], rowCount: 1 }
107
+ }
108
+ if (new RegExp(`FROM "?${tableName}"?`).test(sql)) {
109
+ const rows = options.rows || []
110
+ return { rows, rowCount: rows.length }
111
+ }
112
+ return { rows: [], rowCount: 0 }
113
+ }
114
+ }
115
+ // Records the scope and CDN window each cached view is wrapped with, and
116
+ // serves every request straight through — the split is what is under test.
117
+ const tqWithCache = (fn: any, opts: any) => async (req: any, res: any) => {
118
+ cacheCalls.push({ scope: opts.scope, sMaxAge: opts.sMaxAge })
119
+ return fn(req, res)
120
+ }
121
+ const fakeRequire = (name: string): any => {
122
+ if (name === 'pg') {
123
+ return { Client: FakeClient }
124
+ }
125
+ if (name === 'tq-cache') {
126
+ return { tqWithCache }
127
+ }
128
+ if (name === 'next-auth/jwt') {
129
+ return {
130
+ getToken: async () => (session ? { sub: session.sub || 'u1', role: session.role } : null),
131
+ }
132
+ }
133
+ throw new Error('unexpected require: ' + name)
134
+ }
135
+ const moduleObj: { exports: any } = { exports: {} }
136
+ new Function('require', 'module', 'exports', 'process', 'console', code)(
137
+ fakeRequire,
138
+ moduleObj,
139
+ moduleObj.exports,
140
+ {
141
+ env: {
142
+ ...(appSecret ? { NEXTAUTH_SECRET: appSecret } : {}),
143
+ TELEPORT_DB_HOST: 'h',
144
+ TELEPORT_DB_USER: 'u',
145
+ TELEPORT_DB_PASSWORD: 'p',
146
+ TELEPORT_DB_NAME: 'd',
147
+ },
148
+ },
149
+ { error: () => undefined, warn: () => undefined, log: () => undefined }
150
+ )
151
+
152
+ const run = async (params: {
153
+ handler?: 'handler' | 'getCount'
154
+ query?: Record<string, string>
155
+ headers?: Record<string, string>
156
+ session?: FakeSession | null
157
+ }): Promise<RouteResult> => {
158
+ queries.length = 0
159
+ session = params.session || null
160
+ let status = 0
161
+ let responseBody: any = null
162
+ const res = {
163
+ status(statusCode: number) {
164
+ status = statusCode
165
+ return this
166
+ },
167
+ json(payload: any) {
168
+ responseBody = payload
169
+ return this
170
+ },
171
+ setHeader() {
172
+ return this
173
+ },
174
+ }
175
+ const req = {
176
+ method: 'GET',
177
+ query: params.query || {},
178
+ headers: {
179
+ cookie: params.session ? 'next-auth.session-token=signed' : '',
180
+ ...(params.headers || {}),
181
+ },
182
+ }
183
+ await moduleObj.exports[params.handler || 'handler'](req, res)
184
+ return { status, body: responseBody, queries: queries.slice() }
185
+ }
186
+
187
+ const serverFetch = async (): Promise<unknown> => {
188
+ queries.length = 0
189
+ session = null
190
+ return moduleObj.exports.fetchData({})
191
+ }
192
+
193
+ return { run, serverFetch, queries, cacheCalls }
194
+ }
195
+
196
+ const internal = { 'x-internal-data-secret': SECRET }
197
+
198
+ const mainQuery = (result: RouteResult): string =>
199
+ (result.queries.find((q) => /^\s*SELECT \* FROM/i.test(q.sql)) || { sql: '' }).sql
200
+
201
+ const POSTS = [
202
+ {
203
+ id: 'p1',
204
+ title: 'Hello',
205
+ slug: 'hello',
206
+ status: 'published',
207
+ author_name: 'Ana',
208
+ author_email: 'ana@example.com',
209
+ },
210
+ ]
211
+
212
+ describe('blog posts: a visitor reads the published posts, never an author email', () => {
213
+ const posts = bootTableRoute('teleport_blog_posts', {
214
+ rows: POSTS,
215
+ columns: ['id', 'title', 'author_email'],
216
+ })
217
+
218
+ it('narrows a visitor to the published posts and drops the email from every row', async () => {
219
+ const guest = await posts.run({})
220
+ expect(guest.status).toBe(200)
221
+ expect(mainQuery(guest)).toContain("(status = 'published')")
222
+ expect(JSON.stringify(guest.body.data)).not.toContain('ana@example.com')
223
+ expect(guest.body.data[0].author_email).toBeUndefined()
224
+ expect(guest.body.data[0].authorEmail).toBeUndefined()
225
+ expect(guest.body.data[0].title).toBe('Hello')
226
+
227
+ // A signed-in shopper is still a visitor.
228
+ const shopper = await posts.run({ session: { role: 'user' } })
229
+ expect(mainQuery(shopper)).toContain("(status = 'published')")
230
+ expect(JSON.stringify(shopper.body.data)).not.toContain('ana@example.com')
231
+ })
232
+
233
+ it('serves the store everything: the admin, a server segment and getStaticProps', async () => {
234
+ const admin = await posts.run({ session: { role: 'admin' } })
235
+ expect(mainQuery(admin)).not.toContain("status = 'published'")
236
+ expect(JSON.stringify(admin.body.data)).toContain('ana@example.com')
237
+
238
+ const server = await posts.run({ headers: internal })
239
+ expect(mainQuery(server)).not.toContain("status = 'published'")
240
+
241
+ // (The transform's own related/adjacent-post lookups ask for published
242
+ // posts themselves; the read of the table is what must stay whole.)
243
+ await posts.serverFetch()
244
+ const serverRead = posts.queries.find((q) => /^\s*SELECT \* FROM/i.test(q.sql))
245
+ expect(serverRead && serverRead.sql).not.toContain("(status = 'published')")
246
+ })
247
+
248
+ it('narrows the count the same way', async () => {
249
+ const guest = await posts.run({ handler: 'getCount' })
250
+ expect(guest.status).toBe(200)
251
+ expect(guest.queries.some((q) => /COUNT\(\*\).*\(status = 'published'\)/.test(q.sql))).toBe(
252
+ true
253
+ )
254
+ })
255
+
256
+ it('refuses a visitor who filters or sorts by the hidden column, before the database is touched', async () => {
257
+ const filters = JSON.stringify([
258
+ { source: 'author_email', operand: '=', destination: 'ana@example.com' },
259
+ ])
260
+ for (const query of [
261
+ { filters },
262
+ { sorts: JSON.stringify([{ field: 'author_email', order: 'asc' }]) },
263
+ { sortBy: 'author_email' },
264
+ { query: 'ana', queryColumns: JSON.stringify(['author_email']) },
265
+ ]) {
266
+ const refused = await posts.run({ query })
267
+ expect(refused.status).toBe(403)
268
+ expect(refused.queries).toEqual([])
269
+ const count = await posts.run({ handler: 'getCount', query })
270
+ expect(count.status).toBe(403)
271
+ }
272
+ // The admin may.
273
+ expect((await posts.run({ query: { filters }, session: { role: 'admin' } })).status).toBe(200)
274
+ })
275
+
276
+ it('searches a visitor only through the columns it is served', async () => {
277
+ const guest = await posts.run({ query: { query: 'ana' } })
278
+ expect(guest.status).toBe(200)
279
+ const sql = mainQuery(guest)
280
+ expect(sql).toContain('"title"::text ILIKE')
281
+ expect(sql).not.toContain('"author_email"')
282
+
283
+ const admin = await posts.run({ query: { query: 'ana' }, session: { role: 'admin' } })
284
+ expect(mainQuery(admin)).toContain('"author_email"::text ILIKE')
285
+ })
286
+
287
+ it('applies to the generic Postgres route too', async () => {
288
+ const pgPosts = bootTableRoute('teleport_blog_posts', { type: 'postgresql', rows: POSTS })
289
+ const guest = await pgPosts.run({})
290
+ expect(guest.status).toBe(200)
291
+ expect(mainQuery(guest)).toContain("(status = 'published')")
292
+ expect(JSON.stringify(guest.body.data)).not.toContain('ana@example.com')
293
+ const admin = await pgPosts.run({ session: { role: 'admin' } })
294
+ expect(JSON.stringify(admin.body.data)).toContain('ana@example.com')
295
+ })
296
+ })
297
+
298
+ describe('products: a visitor reads the active products', () => {
299
+ const products = bootTableRoute('teleport_products', {
300
+ rows: [{ id: 'x', name: 'Mug', slug: 'mug', status: 'active', price: 1 }],
301
+ })
302
+
303
+ it('narrows a visitor, not the store', async () => {
304
+ expect(mainQuery(await products.run({}))).toContain("(LOWER(TRIM(status)) = 'active')")
305
+ expect(mainQuery(await products.run({ session: { role: 'admin' } }))).not.toContain(
306
+ "= 'active'"
307
+ )
308
+ expect(mainQuery(await products.run({ headers: internal }))).not.toContain("= 'active'")
309
+ })
310
+ })
311
+
312
+ describe('the AI assistant tables are read by the store only', () => {
313
+ it('refuses a guest and a shopper, serves the admin and the server', async () => {
314
+ const messages = bootTableRoute('teleport_ai_chat_messages')
315
+ const guest = await messages.run({})
316
+ expect(guest.status).toBe(401)
317
+ expect(guest.queries).toEqual([])
318
+ const shopper = await messages.run({ handler: 'getCount', session: { role: 'user' } })
319
+ expect(shopper.status).toBe(403)
320
+ expect(shopper.queries).toEqual([])
321
+ expect((await messages.run({ session: { role: 'admin' } })).status).toBe(200)
322
+ expect((await messages.run({ headers: internal })).status).toBe(200)
323
+ })
324
+
325
+ it('refuses a raw statement over them from another route, even without an app secret', async () => {
326
+ const open = bootTableRoute('team_members', { appSecret: null })
327
+ const guest = await open.run({
328
+ query: { rawQuery: 'SELECT message FROM teleport_ai_chat_messages' },
329
+ })
330
+ expect(guest.status).toBe(401)
331
+ expect(guest.queries).toEqual([])
332
+ })
333
+ })
334
+
335
+ describe('database errors stay in the server log', () => {
336
+ it('answers a failed read with a generic message', async () => {
337
+ const failing = bootTableRoute('team_members', {
338
+ failWith: Object.assign(new Error('relation "secret_table" does not exist'), {
339
+ code: '42P01',
340
+ }),
341
+ })
342
+ const list = await failing.run({})
343
+ expect(list.status).toBe(500)
344
+ expect(JSON.stringify(list.body)).not.toContain('secret_table')
345
+ const count = await failing.run({ handler: 'getCount' })
346
+ expect(count.status).toBe(500)
347
+ expect(JSON.stringify(count.body)).not.toContain('secret_table')
348
+ })
349
+ })
350
+
351
+ describe('the response cache never hands one view to the other', () => {
352
+ const CACHE = { scope: 'ds-1:teleport_blog_posts', ttlSeconds: 60, sMaxAge: 30 }
353
+
354
+ it('keeps the store and a visitor in separate cache scopes, and the store out of the CDN', async () => {
355
+ const posts = bootTableRoute('teleport_blog_posts', { rows: POSTS, cache: CACHE })
356
+
357
+ posts.cacheCalls.length = 0
358
+ await posts.run({})
359
+ expect(posts.cacheCalls).toEqual([{ scope: 'ds-1:teleport_blog_posts', sMaxAge: 30 }])
360
+
361
+ posts.cacheCalls.length = 0
362
+ await posts.serverFetch()
363
+ expect(posts.cacheCalls).toEqual([{ scope: 'ds-1:teleport_blog_posts:trusted', sMaxAge: 0 }])
364
+
365
+ posts.cacheCalls.length = 0
366
+ await posts.run({ session: { role: 'admin' } })
367
+ expect(posts.cacheCalls).toEqual([{ scope: 'ds-1:teleport_blog_posts:trusted', sMaxAge: 0 }])
368
+
369
+ posts.cacheCalls.length = 0
370
+ await posts.run({ handler: 'getCount' })
371
+ expect(posts.cacheCalls).toEqual([{ scope: 'ds-1:teleport_blog_posts:count', sMaxAge: 30 }])
372
+ })
373
+
374
+ it('lets every caller share one view of a table that answers everyone alike', async () => {
375
+ const members = bootTableRoute('team_members', {
376
+ cache: { scope: 'ds-1:team_members', ttlSeconds: 60 },
377
+ })
378
+ members.cacheCalls.length = 0
379
+ await members.serverFetch()
380
+ await members.run({})
381
+ expect(members.cacheCalls.map((call) => call.scope)).toEqual([
382
+ 'ds-1:team_members',
383
+ 'ds-1:team_members',
384
+ ])
385
+ })
386
+ })
387
+
388
+ describe('raw-query state routes bind {{Current User.*}} to the session', () => {
389
+ const boot = (paramFields: string[]) => {
390
+ const esm = generateRawQueryFetcher(
391
+ {},
392
+ 'SELECT f.entity_id FROM teleport_favourites f WHERE f.user_id::text = $1',
393
+ paramFields
394
+ )
395
+ const code = esm
396
+ .replace("import { Client } from 'pg'", "const { Client } = require('pg')")
397
+ .replace(/^export default async function handler/m, 'async function handler')
398
+ .concat('\nmodule.exports = { handler: handler }')
399
+ const queries: Array<{ sql: string; params: unknown[] }> = []
400
+ let session: FakeSession | null = null
401
+ let fail: Error | null = null
402
+ class FakeClient {
403
+ public async connect(): Promise<void> {
404
+ return
405
+ }
406
+ public async end(): Promise<void> {
407
+ return
408
+ }
409
+ public async query(sql: string, params: unknown[]): Promise<any> {
410
+ queries.push({ sql, params })
411
+ if (fail) {
412
+ throw fail
413
+ }
414
+ return { rows: [{ entity_id: 'p1' }] }
415
+ }
416
+ }
417
+ const fakeRequire = (name: string): any => {
418
+ if (name === 'pg') {
419
+ return { Client: FakeClient }
420
+ }
421
+ if (name === 'next-auth/jwt') {
422
+ return { getToken: async () => session }
423
+ }
424
+ throw new Error('unexpected require: ' + name)
425
+ }
426
+ const moduleObj: { exports: any } = { exports: {} }
427
+ new Function('require', 'module', 'exports', 'process', 'console', code)(
428
+ fakeRequire,
429
+ moduleObj,
430
+ moduleObj.exports,
431
+ { env: { NEXTAUTH_SECRET: SECRET, TELEPORT_DB_CONNECTION_STRING: 'postgresql://x' } },
432
+ { error: () => undefined, warn: () => undefined, log: () => undefined }
433
+ )
434
+ return async (params: {
435
+ query?: Record<string, string>
436
+ session?: FakeSession | null
437
+ failWith?: Error
438
+ }) => {
439
+ queries.length = 0
440
+ session = params.session || null
441
+ fail = params.failWith || null
442
+ let status = 0
443
+ let body: any = null
444
+ const res = {
445
+ status(statusCode: number) {
446
+ status = statusCode
447
+ return this
448
+ },
449
+ json(payload: any) {
450
+ body = payload
451
+ return this
452
+ },
453
+ }
454
+ await moduleObj.exports.handler(
455
+ {
456
+ method: 'GET',
457
+ query: params.query || {},
458
+ headers: { cookie: params.session ? 'next-auth.session-token=signed' : '' },
459
+ },
460
+ res
461
+ )
462
+ return { status, body, queries: queries.slice() }
463
+ }
464
+ }
465
+
466
+ it("reads the signed-in user's id from the session, never from the query string", async () => {
467
+ const route = boot(['currentUserId'])
468
+ const result = await route({
469
+ query: { currentUserId: 'victim-id' },
470
+ session: { sub: 'me-id', id: 'me-id' },
471
+ })
472
+ expect(result.status).toBe(200)
473
+ expect(result.queries).toHaveLength(1)
474
+ expect(result.queries[0].params).toEqual(['me-id'])
475
+ })
476
+
477
+ it('answers no rows, without a query, when there is no session', async () => {
478
+ const route = boot(['currentUserId'])
479
+ const result = await route({ query: { currentUserId: 'victim-id' } })
480
+ expect(result.status).toBe(200)
481
+ expect(result.body.data).toEqual([])
482
+ expect(result.queries).toEqual([])
483
+ })
484
+
485
+ it("keeps the details page's route param a request value", async () => {
486
+ const route = boot(['currentUserId', 'currentPageEntityId'])
487
+ const result = await route({
488
+ query: { currentUserId: 'victim-id', currentPageEntityId: 'post-7' },
489
+ session: { sub: 'me-id' },
490
+ })
491
+ expect(result.queries[0].params).toEqual(['me-id', 'post-7'])
492
+ })
493
+
494
+ it('keeps the database error out of the response', async () => {
495
+ const route = boot(['currentUserId'])
496
+ const result = await route({
497
+ session: { sub: 'me-id' },
498
+ failWith: new Error('column "secret_col" does not exist'),
499
+ })
500
+ expect(result.status).toBe(500)
501
+ expect(JSON.stringify(result.body)).not.toContain('secret_col')
502
+ })
503
+ })
@@ -41,13 +41,29 @@ describe('generateDataSourceFetcherWithCore — server cache wrapping', () => {
41
41
  )
42
42
 
43
43
  expect(code).toContain("import { tqWithCache } from '../tq-cache/server'")
44
- expect(code).toContain('const cachedHandler = tqWithCache(handler, __tqDataCache)')
45
- expect(code).toContain('const cachedGetCount = tqWithCache(getCount, __tqCountCache)')
44
+ // A Postgres-family route answers the store and a visitor differently (see
45
+ // browser-row-policy.ts), so its cache is split by view.
46
+ expect(code).toContain('const cachedHandler = __tqCacheByView(handler, __tqDataCache)')
47
+ expect(code).toContain('const cachedGetCount = __tqCacheByView(getCount, __tqCountCache)')
46
48
  expect(code).toContain(
47
49
  'export { fetchData, fetchCount, cachedHandler as handler, cachedGetCount as getCount }'
48
50
  )
49
51
  })
50
52
 
53
+ it('wraps a route without the row policy in one shared cache, as before', () => {
54
+ const mysql: UIDLDataSource = {
55
+ id: 'ds_2',
56
+ name: 'Shop',
57
+ type: 'mysql',
58
+ config: { host: 'h', port: 3306, user: 'u', password: 'p', database: 'd' },
59
+ }
60
+ const code = generateDataSourceFetcherWithCore(mysql, 'products', false, {}, CACHE)
61
+
62
+ expect(code).toContain('const cachedHandler = tqWithCache(handler, __tqDataCache)')
63
+ expect(code).toContain('const cachedGetCount = tqWithCache(getCount, __tqCountCache)')
64
+ expect(code).not.toContain('__tqCacheByView')
65
+ })
66
+
51
67
  /**
52
68
  * `getStaticProps` reaches the database through `fetchData`, not through the
53
69
  * API route, so pointing it at the raw handler would leave the prefetch —