@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.
- package/__tests__/array-overlap-sql.test.ts +53 -0
- package/__tests__/blog-post-page-enrichment.test.ts +214 -0
- package/__tests__/browser-row-policy.test.ts +503 -0
- package/__tests__/data-source-cache-wrapping.test.ts +18 -2
- package/__tests__/ecommerce-product-options.test.ts +206 -0
- package/__tests__/ecommerce-product-pages.test.ts +82 -0
- package/__tests__/ecommerce-product-pre-options.golden.json +505 -0
- package/__tests__/ecommerce-product-storefront-contract.test.ts +293 -0
- package/__tests__/ecommerce-product-subscriptions.test.ts +121 -0
- package/__tests__/fetchers.test.ts +2 -2
- package/__tests__/items-path.test.ts +150 -0
- package/__tests__/product-options-storefront.fixture.json +2510 -0
- package/__tests__/product-page-url-fixtures.json +25 -0
- package/__tests__/product-price-sort.ts +4 -2
- package/__tests__/raw-rows-mode.test.ts +111 -0
- package/__tests__/rest-api-response-cache.test.ts +434 -0
- package/__tests__/table-read-route-protected-tables.test.ts +493 -0
- package/dist/cjs/array-overlap-sql.d.ts +21 -0
- package/dist/cjs/array-overlap-sql.d.ts.map +1 -0
- package/dist/cjs/array-overlap-sql.js +25 -0
- package/dist/cjs/array-overlap-sql.js.map +1 -0
- package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
- package/dist/cjs/data-source-fetchers.js +10 -4
- package/dist/cjs/data-source-fetchers.js.map +1 -1
- package/dist/cjs/fetchers/javascript.d.ts.map +1 -1
- package/dist/cjs/fetchers/javascript.js +1 -1
- package/dist/cjs/fetchers/javascript.js.map +1 -1
- package/dist/cjs/fetchers/postgresql.d.ts +1 -1
- package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
- package/dist/cjs/fetchers/postgresql.js +8 -4
- package/dist/cjs/fetchers/postgresql.js.map +1 -1
- package/dist/cjs/fetchers/raw-query.d.ts +8 -1
- package/dist/cjs/fetchers/raw-query.d.ts.map +1 -1
- package/dist/cjs/fetchers/raw-query.js +42 -4
- package/dist/cjs/fetchers/raw-query.js.map +1 -1
- package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
- package/dist/cjs/fetchers/rest-api.js +7 -2
- package/dist/cjs/fetchers/rest-api.js.map +1 -1
- package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
- package/dist/cjs/fetchers/teleport.js +8 -5
- package/dist/cjs/fetchers/teleport.js.map +1 -1
- package/dist/cjs/fetchers/utils/browser-row-policy.d.ts +43 -0
- package/dist/cjs/fetchers/utils/browser-row-policy.d.ts.map +1 -0
- package/dist/cjs/fetchers/utils/browser-row-policy.js +66 -0
- package/dist/cjs/fetchers/utils/browser-row-policy.js.map +1 -0
- package/dist/cjs/fetchers/utils/request-sql-guards.d.ts +8 -0
- package/dist/cjs/fetchers/utils/request-sql-guards.d.ts.map +1 -0
- package/dist/cjs/fetchers/utils/request-sql-guards.js +12 -0
- package/dist/cjs/fetchers/utils/request-sql-guards.js.map +1 -0
- package/dist/cjs/fetchers/utils/rest-api-response-cache.d.ts +40 -0
- package/dist/cjs/fetchers/utils/rest-api-response-cache.d.ts.map +1 -0
- package/dist/cjs/fetchers/utils/rest-api-response-cache.js +64 -0
- package/dist/cjs/fetchers/utils/rest-api-response-cache.js.map +1 -0
- package/dist/cjs/fetchers/utils/table-access-guard.d.ts +23 -0
- package/dist/cjs/fetchers/utils/table-access-guard.d.ts.map +1 -0
- package/dist/cjs/fetchers/utils/table-access-guard.js +34 -0
- package/dist/cjs/fetchers/utils/table-access-guard.js.map +1 -0
- package/dist/cjs/items-path.d.ts +10 -0
- package/dist/cjs/items-path.d.ts.map +1 -0
- package/dist/cjs/items-path.js +86 -0
- package/dist/cjs/items-path.js.map +1 -0
- package/dist/cjs/localized-columns.d.ts.map +1 -1
- package/dist/cjs/localized-columns.js +13 -0
- package/dist/cjs/localized-columns.js.map +1 -1
- package/dist/cjs/pagination-plugin.d.ts.map +1 -1
- package/dist/cjs/pagination-plugin.js +17 -5
- package/dist/cjs/pagination-plugin.js.map +1 -1
- package/dist/cjs/transformations/blog-adjacent-posts.d.ts +16 -0
- package/dist/cjs/transformations/blog-adjacent-posts.d.ts.map +1 -0
- package/dist/cjs/transformations/blog-adjacent-posts.js +25 -0
- package/dist/cjs/transformations/blog-adjacent-posts.js.map +1 -0
- package/dist/cjs/transformations/blog-comments.d.ts +30 -0
- package/dist/cjs/transformations/blog-comments.d.ts.map +1 -0
- package/dist/cjs/transformations/blog-comments.js +34 -0
- package/dist/cjs/transformations/blog-comments.js.map +1 -0
- package/dist/cjs/transformations/blog-post.d.ts +2 -0
- package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
- package/dist/cjs/transformations/blog-post.js +4 -1
- package/dist/cjs/transformations/blog-post.js.map +1 -1
- package/dist/cjs/transformations/ecommerce-product.d.ts +9 -1
- package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
- package/dist/cjs/transformations/ecommerce-product.js +28 -1
- package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
- package/dist/cjs/transformations/heading-anchors.d.ts +25 -0
- package/dist/cjs/transformations/heading-anchors.d.ts.map +1 -0
- package/dist/cjs/transformations/heading-anchors.js +29 -0
- package/dist/cjs/transformations/heading-anchors.js.map +1 -0
- package/dist/cjs/transformations/index.d.ts +47 -4
- package/dist/cjs/transformations/index.d.ts.map +1 -1
- package/dist/cjs/transformations/index.js +56 -18
- package/dist/cjs/transformations/index.js.map +1 -1
- package/dist/cjs/transformations/shared-utils.d.ts.map +1 -1
- package/dist/cjs/transformations/shared-utils.js +1 -1
- package/dist/cjs/transformations/shared-utils.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/utils.d.ts.map +1 -1
- package/dist/cjs/utils.js +1 -1
- package/dist/cjs/utils.js.map +1 -1
- package/dist/esm/array-overlap-sql.d.ts +21 -0
- package/dist/esm/array-overlap-sql.d.ts.map +1 -0
- package/dist/esm/array-overlap-sql.js +21 -0
- package/dist/esm/array-overlap-sql.js.map +1 -0
- package/dist/esm/data-source-fetchers.d.ts.map +1 -1
- package/dist/esm/data-source-fetchers.js +11 -5
- package/dist/esm/data-source-fetchers.js.map +1 -1
- package/dist/esm/fetchers/javascript.d.ts.map +1 -1
- package/dist/esm/fetchers/javascript.js +1 -1
- package/dist/esm/fetchers/javascript.js.map +1 -1
- package/dist/esm/fetchers/postgresql.d.ts +1 -1
- package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
- package/dist/esm/fetchers/postgresql.js +8 -4
- package/dist/esm/fetchers/postgresql.js.map +1 -1
- package/dist/esm/fetchers/raw-query.d.ts +8 -1
- package/dist/esm/fetchers/raw-query.d.ts.map +1 -1
- package/dist/esm/fetchers/raw-query.js +42 -4
- package/dist/esm/fetchers/raw-query.js.map +1 -1
- package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
- package/dist/esm/fetchers/rest-api.js +7 -2
- package/dist/esm/fetchers/rest-api.js.map +1 -1
- package/dist/esm/fetchers/teleport.d.ts.map +1 -1
- package/dist/esm/fetchers/teleport.js +8 -5
- package/dist/esm/fetchers/teleport.js.map +1 -1
- package/dist/esm/fetchers/utils/browser-row-policy.d.ts +43 -0
- package/dist/esm/fetchers/utils/browser-row-policy.d.ts.map +1 -0
- package/dist/esm/fetchers/utils/browser-row-policy.js +60 -0
- package/dist/esm/fetchers/utils/browser-row-policy.js.map +1 -0
- package/dist/esm/fetchers/utils/request-sql-guards.d.ts +8 -0
- package/dist/esm/fetchers/utils/request-sql-guards.d.ts.map +1 -0
- package/dist/esm/fetchers/utils/request-sql-guards.js +8 -0
- package/dist/esm/fetchers/utils/request-sql-guards.js.map +1 -0
- package/dist/esm/fetchers/utils/rest-api-response-cache.d.ts +40 -0
- package/dist/esm/fetchers/utils/rest-api-response-cache.d.ts.map +1 -0
- package/dist/esm/fetchers/utils/rest-api-response-cache.js +59 -0
- package/dist/esm/fetchers/utils/rest-api-response-cache.js.map +1 -0
- package/dist/esm/fetchers/utils/table-access-guard.d.ts +23 -0
- package/dist/esm/fetchers/utils/table-access-guard.d.ts.map +1 -0
- package/dist/esm/fetchers/utils/table-access-guard.js +29 -0
- package/dist/esm/fetchers/utils/table-access-guard.js.map +1 -0
- package/dist/esm/items-path.d.ts +10 -0
- package/dist/esm/items-path.d.ts.map +1 -0
- package/dist/esm/items-path.js +58 -0
- package/dist/esm/items-path.js.map +1 -0
- package/dist/esm/localized-columns.d.ts.map +1 -1
- package/dist/esm/localized-columns.js +13 -0
- package/dist/esm/localized-columns.js.map +1 -1
- package/dist/esm/pagination-plugin.d.ts.map +1 -1
- package/dist/esm/pagination-plugin.js +17 -5
- package/dist/esm/pagination-plugin.js.map +1 -1
- package/dist/esm/transformations/blog-adjacent-posts.d.ts +16 -0
- package/dist/esm/transformations/blog-adjacent-posts.d.ts.map +1 -0
- package/dist/esm/transformations/blog-adjacent-posts.js +21 -0
- package/dist/esm/transformations/blog-adjacent-posts.js.map +1 -0
- package/dist/esm/transformations/blog-comments.d.ts +30 -0
- package/dist/esm/transformations/blog-comments.d.ts.map +1 -0
- package/dist/esm/transformations/blog-comments.js +30 -0
- package/dist/esm/transformations/blog-comments.js.map +1 -0
- package/dist/esm/transformations/blog-post.d.ts +2 -0
- package/dist/esm/transformations/blog-post.d.ts.map +1 -1
- package/dist/esm/transformations/blog-post.js +4 -1
- package/dist/esm/transformations/blog-post.js.map +1 -1
- package/dist/esm/transformations/ecommerce-product.d.ts +9 -1
- package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
- package/dist/esm/transformations/ecommerce-product.js +29 -2
- package/dist/esm/transformations/ecommerce-product.js.map +1 -1
- package/dist/esm/transformations/heading-anchors.d.ts +25 -0
- package/dist/esm/transformations/heading-anchors.d.ts.map +1 -0
- package/dist/esm/transformations/heading-anchors.js +25 -0
- package/dist/esm/transformations/heading-anchors.js.map +1 -0
- package/dist/esm/transformations/index.d.ts +47 -4
- package/dist/esm/transformations/index.d.ts.map +1 -1
- package/dist/esm/transformations/index.js +56 -18
- package/dist/esm/transformations/index.js.map +1 -1
- package/dist/esm/transformations/shared-utils.d.ts.map +1 -1
- package/dist/esm/transformations/shared-utils.js +1 -1
- package/dist/esm/transformations/shared-utils.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/utils.d.ts.map +1 -1
- package/dist/esm/utils.js +1 -1
- package/dist/esm/utils.js.map +1 -1
- package/package.json +5 -5
- package/src/array-overlap-sql.ts +41 -0
- package/src/data-source-fetchers.ts +41 -5
- package/src/fetchers/javascript.ts +9 -1
- package/src/fetchers/postgresql.ts +37 -10
- package/src/fetchers/raw-query.ts +60 -5
- package/src/fetchers/rest-api.ts +27 -5
- package/src/fetchers/teleport.ts +37 -13
- package/src/fetchers/utils/browser-row-policy.ts +212 -0
- package/src/fetchers/utils/request-sql-guards.ts +25 -0
- package/src/fetchers/utils/rest-api-response-cache.ts +188 -0
- package/src/fetchers/utils/table-access-guard.ts +39 -0
- package/src/items-path.ts +74 -0
- package/src/localized-columns.ts +13 -0
- package/src/pagination-plugin.ts +29 -7
- package/src/transformations/blog-adjacent-posts.ts +74 -0
- package/src/transformations/blog-comments.ts +132 -0
- package/src/transformations/blog-post.ts +38 -5
- package/src/transformations/ecommerce-product.ts +568 -5
- package/src/transformations/heading-anchors.ts +85 -0
- package/src/transformations/index.ts +165 -22
- package/src/transformations/shared-utils.ts +6 -0
- 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
|
-
|
|
45
|
-
|
|
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 —
|