@teleporthq/teleport-plugin-next-workflows 0.43.32 → 0.43.34
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__/client-config-redaction.test.ts +192 -0
- package/__tests__/data-api-boolean-checkbox-coercion.test.ts +143 -0
- package/__tests__/data-api-safe-query.test.ts +3 -1
- package/__tests__/data-update-item-runtime.test.ts +116 -0
- package/__tests__/raw-query-param-binding.test.ts +216 -0
- package/__tests__/runtime-template-token-resolution.test.ts +38 -0
- package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
- package/dist/cjs/data-api-route-generator.js +92 -6
- package/dist/cjs/data-api-route-generator.js.map +1 -1
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +31 -0
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/nodes/data/data-delete-item.d.ts.map +1 -1
- package/dist/cjs/nodes/data/data-delete-item.js +15 -4
- package/dist/cjs/nodes/data/data-delete-item.js.map +1 -1
- package/dist/cjs/nodes/data/data-raw-query.d.ts.map +1 -1
- package/dist/cjs/nodes/data/data-raw-query.js +7 -1
- package/dist/cjs/nodes/data/data-raw-query.js.map +1 -1
- package/dist/cjs/nodes/data/data-select.d.ts.map +1 -1
- package/dist/cjs/nodes/data/data-select.js +51 -3
- package/dist/cjs/nodes/data/data-select.js.map +1 -1
- package/dist/cjs/nodes/data/data-update-item.d.ts.map +1 -1
- package/dist/cjs/nodes/data/data-update-item.js +58 -5
- package/dist/cjs/nodes/data/data-update-item.js.map +1 -1
- package/dist/cjs/nodes/general/general-extract-form-data.d.ts.map +1 -1
- package/dist/cjs/nodes/general/general-extract-form-data.js +37 -1
- package/dist/cjs/nodes/general/general-extract-form-data.js.map +1 -1
- package/dist/cjs/segment-splitter.d.ts +26 -2
- package/dist/cjs/segment-splitter.d.ts.map +1 -1
- package/dist/cjs/segment-splitter.js +45 -1
- package/dist/cjs/segment-splitter.js.map +1 -1
- package/dist/cjs/sql-query-validator.d.ts +40 -0
- package/dist/cjs/sql-query-validator.d.ts.map +1 -1
- package/dist/cjs/sql-query-validator.js +49 -1
- package/dist/cjs/sql-query-validator.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-component-plugin.js +22 -8
- package/dist/cjs/workflow-component-plugin.js.map +1 -1
- package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-project-plugin.js +8 -2
- package/dist/cjs/workflow-project-plugin.js.map +1 -1
- package/dist/esm/data-api-route-generator.d.ts.map +1 -1
- package/dist/esm/data-api-route-generator.js +92 -6
- package/dist/esm/data-api-route-generator.js.map +1 -1
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +31 -0
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/nodes/data/data-delete-item.d.ts.map +1 -1
- package/dist/esm/nodes/data/data-delete-item.js +15 -4
- package/dist/esm/nodes/data/data-delete-item.js.map +1 -1
- package/dist/esm/nodes/data/data-raw-query.d.ts.map +1 -1
- package/dist/esm/nodes/data/data-raw-query.js +7 -1
- package/dist/esm/nodes/data/data-raw-query.js.map +1 -1
- package/dist/esm/nodes/data/data-select.d.ts.map +1 -1
- package/dist/esm/nodes/data/data-select.js +51 -3
- package/dist/esm/nodes/data/data-select.js.map +1 -1
- package/dist/esm/nodes/data/data-update-item.d.ts.map +1 -1
- package/dist/esm/nodes/data/data-update-item.js +58 -5
- package/dist/esm/nodes/data/data-update-item.js.map +1 -1
- package/dist/esm/nodes/general/general-extract-form-data.d.ts.map +1 -1
- package/dist/esm/nodes/general/general-extract-form-data.js +37 -1
- package/dist/esm/nodes/general/general-extract-form-data.js.map +1 -1
- package/dist/esm/segment-splitter.d.ts +26 -2
- package/dist/esm/segment-splitter.d.ts.map +1 -1
- package/dist/esm/segment-splitter.js +42 -0
- package/dist/esm/segment-splitter.js.map +1 -1
- package/dist/esm/sql-query-validator.d.ts +40 -0
- package/dist/esm/sql-query-validator.d.ts.map +1 -1
- package/dist/esm/sql-query-validator.js +47 -0
- package/dist/esm/sql-query-validator.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-component-plugin.js +23 -9
- package/dist/esm/workflow-component-plugin.js.map +1 -1
- package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-project-plugin.js +9 -3
- package/dist/esm/workflow-project-plugin.js.map +1 -1
- package/package.json +4 -4
- package/src/data-api-route-generator.ts +92 -6
- package/src/executor-generator.ts +31 -0
- package/src/nodes/data/data-delete-item.ts +15 -4
- package/src/nodes/data/data-raw-query.ts +7 -1
- package/src/nodes/data/data-select.ts +48 -3
- package/src/nodes/data/data-update-item.ts +62 -5
- package/src/nodes/general/general-extract-form-data.ts +37 -1
- package/src/segment-splitter.ts +49 -0
- package/src/sql-query-validator.ts +64 -0
- package/src/workflow-component-plugin.ts +23 -3
- package/src/workflow-project-plugin.ts +18 -3
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// SECURITY regression guard: a generated app must never ship a server node's
|
|
2
|
+
// raw SQL (or any other server-only config) inside the CLIENT page bundle.
|
|
3
|
+
//
|
|
4
|
+
// Data nodes (data-select / data-raw-query / data-count / data-create-item /
|
|
5
|
+
// …) and AI nodes run in their own server API route. Before this guard the
|
|
6
|
+
// workflow component plugin serialized the FULL workflow — every segment's
|
|
7
|
+
// nodes AND the top-level node list — with each node's `config` verbatim into
|
|
8
|
+
// the module-level `__wfConfig_*` constant that ships to the browser, leaking
|
|
9
|
+
// the SQL text (confirmed live: a published page chunk contained
|
|
10
|
+
// `SELECT * FROM vehicles WHERE ... license_plate ILIKE ...`).
|
|
11
|
+
//
|
|
12
|
+
// The plugin now redacts every server node's config down to a client-safe
|
|
13
|
+
// whitelist (only the AI-streaming flag). This test proves the raw SQL is
|
|
14
|
+
// ABSENT from the emitted client module while the server API route still
|
|
15
|
+
// carries it, and that the metadata the client executor DOES need
|
|
16
|
+
// (streaming flag + id/type/stepNumber/edges for branch-skip bookkeeping) and
|
|
17
|
+
// client node config are preserved.
|
|
18
|
+
|
|
19
|
+
import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'
|
|
20
|
+
import { splitIntoSegments } from '../src/segment-splitter'
|
|
21
|
+
import {
|
|
22
|
+
generateServerSegmentAPIRoute,
|
|
23
|
+
generateStreamingServerSegmentAPIRoute,
|
|
24
|
+
hasStreamingAINode,
|
|
25
|
+
} from '../src/api-route-generator'
|
|
26
|
+
|
|
27
|
+
const RAW_SQL = "SELECT * FROM vehicles WHERE ($1 = '' OR license_plate ILIKE '%' || $1 || '%')"
|
|
28
|
+
const DATA_SOURCE_ID = 'vehicles-datasource-id'
|
|
29
|
+
const AI_PROMPT = 'Summarize this SENSITIVE_SERVER_PROMPT'
|
|
30
|
+
const AI_MODEL = 'gpt-4o-secret-model'
|
|
31
|
+
const CLIENT_MARKER = 'CLIENT_VISIBLE_VALUE'
|
|
32
|
+
|
|
33
|
+
const TRIGGER_ID = 'trigger-1'
|
|
34
|
+
const DATA_ID = 'data-1'
|
|
35
|
+
const AI_ID = 'ai-1'
|
|
36
|
+
const CLIENT_ID = 'client-1'
|
|
37
|
+
|
|
38
|
+
const buildWorkflow = (): any => ({
|
|
39
|
+
id: 'wf-1',
|
|
40
|
+
name: 'Vehicles Page Load',
|
|
41
|
+
trigger: {
|
|
42
|
+
type: 'event-page-loaded',
|
|
43
|
+
nodeId: TRIGGER_ID,
|
|
44
|
+
scope: 'page',
|
|
45
|
+
config: { pageId: 'page-1' },
|
|
46
|
+
},
|
|
47
|
+
nodes: [
|
|
48
|
+
{
|
|
49
|
+
id: DATA_ID,
|
|
50
|
+
type: 'data-raw-query',
|
|
51
|
+
config: {
|
|
52
|
+
dataSourceId: DATA_SOURCE_ID,
|
|
53
|
+
query: RAW_SQL,
|
|
54
|
+
params: ['secret-plate'],
|
|
55
|
+
},
|
|
56
|
+
executionEnv: 'server',
|
|
57
|
+
stepNumber: 1,
|
|
58
|
+
label: 'Fetch vehicles',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: AI_ID,
|
|
62
|
+
type: 'ai-custom-prompt',
|
|
63
|
+
config: {
|
|
64
|
+
prompt: AI_PROMPT,
|
|
65
|
+
model: AI_MODEL,
|
|
66
|
+
streaming: true,
|
|
67
|
+
},
|
|
68
|
+
executionEnv: 'server',
|
|
69
|
+
stepNumber: 2,
|
|
70
|
+
label: 'Summarize',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: CLIENT_ID,
|
|
74
|
+
type: 'state-update-local-state',
|
|
75
|
+
config: { property: 'summary', value: CLIENT_MARKER },
|
|
76
|
+
executionEnv: 'client',
|
|
77
|
+
stepNumber: 3,
|
|
78
|
+
label: 'Write summary',
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
edges: [
|
|
82
|
+
{ id: 'e1', source: TRIGGER_ID, target: DATA_ID },
|
|
83
|
+
{ id: 'e2', source: DATA_ID, target: AI_ID },
|
|
84
|
+
{ id: 'e3', source: AI_ID, target: CLIENT_ID },
|
|
85
|
+
],
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const buildStructure = (): any => ({
|
|
89
|
+
uidl: {
|
|
90
|
+
name: 'Page',
|
|
91
|
+
outputOptions: { pageId: 'page-1', fileName: 'page-1' },
|
|
92
|
+
node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
|
|
93
|
+
stateDefinitions: {
|
|
94
|
+
summary: { type: 'string', defaultValue: '' },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
chunks: [
|
|
98
|
+
{
|
|
99
|
+
type: 'chunk-type-ast',
|
|
100
|
+
name: 'jsx-component',
|
|
101
|
+
content: {
|
|
102
|
+
type: 'VariableDeclaration',
|
|
103
|
+
declarations: [
|
|
104
|
+
{
|
|
105
|
+
type: 'VariableDeclarator',
|
|
106
|
+
init: {
|
|
107
|
+
type: 'ArrowFunctionExpression',
|
|
108
|
+
body: {
|
|
109
|
+
type: 'BlockStatement',
|
|
110
|
+
body: [{ type: 'ReturnStatement', argument: null }],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
options: {
|
|
119
|
+
workflows: { workflows: { 'wf-1': buildWorkflow() }, customNodes: {} },
|
|
120
|
+
},
|
|
121
|
+
dependencies: {},
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const getModuleCode = async (): Promise<string> => {
|
|
125
|
+
const plugin = createNextWorkflowPlugin({ isPage: true })
|
|
126
|
+
const structure = buildStructure()
|
|
127
|
+
await plugin(structure as any)
|
|
128
|
+
const moduleChunk = (structure.chunks as any[]).find((c: any) => c.name === 'workflow-module')
|
|
129
|
+
if (!moduleChunk) {
|
|
130
|
+
throw new Error('workflow-module chunk not emitted by plugin')
|
|
131
|
+
}
|
|
132
|
+
return String(moduleChunk.content)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe('client bundle redacts server node config', () => {
|
|
136
|
+
let moduleCode: string
|
|
137
|
+
|
|
138
|
+
beforeAll(async () => {
|
|
139
|
+
moduleCode = await getModuleCode()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('does NOT leak the raw SQL query into the client module', () => {
|
|
143
|
+
expect(moduleCode).not.toContain(RAW_SQL)
|
|
144
|
+
expect(moduleCode).not.toContain('license_plate')
|
|
145
|
+
expect(moduleCode).not.toContain('SELECT * FROM vehicles')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('drops every server-only data field from the client copy', () => {
|
|
149
|
+
// Field names + values that only the server API route may see.
|
|
150
|
+
expect(moduleCode).not.toContain(DATA_SOURCE_ID)
|
|
151
|
+
expect(moduleCode).not.toContain('dataSourceId')
|
|
152
|
+
expect(moduleCode).not.toContain('secret-plate')
|
|
153
|
+
// AI server node's prompt/model are server-only too.
|
|
154
|
+
expect(moduleCode).not.toContain(AI_PROMPT)
|
|
155
|
+
expect(moduleCode).not.toContain(AI_MODEL)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('preserves the AI streaming flag so streaming detection still works', () => {
|
|
159
|
+
// findStreamingAINodes reads config.streaming off the top-level node list.
|
|
160
|
+
expect(moduleCode).toContain('"streaming":true')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('preserves server node identity/edges for branch-skip bookkeeping', () => {
|
|
164
|
+
// The client executor needs { id, type, stepNumber } + edges of server
|
|
165
|
+
// nodes to skip non-taken branches and sort segment results.
|
|
166
|
+
expect(moduleCode).toContain('"data-raw-query"')
|
|
167
|
+
expect(moduleCode).toContain('"ai-custom-prompt"')
|
|
168
|
+
expect(moduleCode).toContain(DATA_ID)
|
|
169
|
+
expect(moduleCode).toContain(AI_ID)
|
|
170
|
+
expect(moduleCode).toContain('"stepNumber"')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('keeps client node config intact (only server nodes are redacted)', () => {
|
|
174
|
+
expect(moduleCode).toContain(CLIENT_MARKER)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe('server API route still carries the SQL', () => {
|
|
179
|
+
it('embeds the raw SQL + data source in the generated server route', () => {
|
|
180
|
+
const segments = splitIntoSegments(buildWorkflow())
|
|
181
|
+
const serverSegment = segments.find((s) => s.env === 'server')
|
|
182
|
+
expect(serverSegment).toBeDefined()
|
|
183
|
+
|
|
184
|
+
const route = hasStreamingAINode(serverSegment!)
|
|
185
|
+
? generateStreamingServerSegmentAPIRoute(serverSegment!, 'Vehicles Page Load')
|
|
186
|
+
: generateServerSegmentAPIRoute(serverSegment!, 'Vehicles Page Load')
|
|
187
|
+
|
|
188
|
+
// The server copy is legitimate and must remain.
|
|
189
|
+
expect(route).toContain(RAW_SQL)
|
|
190
|
+
expect(route).toContain(DATA_SOURCE_ID)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { generateDataAPIRoute } from '../src/data-api-route-generator'
|
|
2
|
+
|
|
3
|
+
// Regression guard for "Edit Press Item" data loss: an unchecked HTML
|
|
4
|
+
// checkbox is omitted from FormData entirely, so `general-extract-form-data`
|
|
5
|
+
// resolves it to '' / undefined — never `false`. Before this fix,
|
|
6
|
+
// `coerceValueForPgColumn` had no `boolean` branch, so that '' fell through
|
|
7
|
+
// to the generic empty-string-to-null guard and was written as SQL NULL: a
|
|
8
|
+
// silent "not featured" -> NULL corruption when the column is nullable, and a
|
|
9
|
+
// hard 23502 not-null-violation (surfaced to the user as "update failed")
|
|
10
|
+
// when the boolean column is NOT NULL.
|
|
11
|
+
describe('data-api route generator: coerceValueForPgColumn boolean column handling', () => {
|
|
12
|
+
const code = generateDataAPIRoute()
|
|
13
|
+
|
|
14
|
+
it('emits an explicit boolean branch ahead of the ARRAY / generic handling', () => {
|
|
15
|
+
expect(code).toContain('function coerceValueForPgColumn')
|
|
16
|
+
expect(code).toMatch(/if \(dt === 'boolean'\)/)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('is invoked from both handleCreate and handleUpdate', () => {
|
|
20
|
+
const handleCreateIdx = code.indexOf('async function handleCreate')
|
|
21
|
+
const handleUpdateIdx = code.indexOf('async function handleUpdate')
|
|
22
|
+
const handleDeleteIdx = code.indexOf('async function handleDelete')
|
|
23
|
+
expect(handleCreateIdx).toBeGreaterThan(0)
|
|
24
|
+
expect(handleUpdateIdx).toBeGreaterThan(handleCreateIdx)
|
|
25
|
+
expect(handleDeleteIdx).toBeGreaterThan(handleUpdateIdx)
|
|
26
|
+
|
|
27
|
+
const handleCreateBody = code.slice(handleCreateIdx, handleUpdateIdx)
|
|
28
|
+
const handleUpdateBody = code.slice(handleUpdateIdx, handleDeleteIdx)
|
|
29
|
+
expect(handleCreateBody).toContain('coerceValueForPgColumn(col, val, colTypes)')
|
|
30
|
+
expect(handleUpdateBody).toContain('coerceValueForPgColumn(col, val, colTypes)')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Behaviour tests: spin up the emitted helper in a fresh JS sandbox and
|
|
35
|
+
// exercise every boolean coercion path, mirroring the pattern established by
|
|
36
|
+
// data-api-uuid-coercion.test.ts (the generated file is one big template
|
|
37
|
+
// literal, so the helper is extracted by balanced-brace matching rather than
|
|
38
|
+
// imported directly).
|
|
39
|
+
describe('data-api route generator: coerceValueForPgColumn runtime behaviour', () => {
|
|
40
|
+
const code = generateDataAPIRoute()
|
|
41
|
+
|
|
42
|
+
const extractFunctionSource = (haystack: string, funcDecl: string): string => {
|
|
43
|
+
const startIdx = haystack.indexOf(funcDecl)
|
|
44
|
+
if (startIdx === -1) {
|
|
45
|
+
throw new Error('Helper not found in generated code: ' + funcDecl)
|
|
46
|
+
}
|
|
47
|
+
let depth = 0
|
|
48
|
+
let i = haystack.indexOf('{', startIdx)
|
|
49
|
+
if (i === -1) {
|
|
50
|
+
throw new Error('No opening brace after ' + funcDecl)
|
|
51
|
+
}
|
|
52
|
+
for (; i < haystack.length; i++) {
|
|
53
|
+
const ch = haystack.charAt(i)
|
|
54
|
+
if (ch === '{') {
|
|
55
|
+
depth++
|
|
56
|
+
} else if (ch === '}') {
|
|
57
|
+
depth--
|
|
58
|
+
if (depth === 0) {
|
|
59
|
+
return haystack.slice(startIdx, i + 1)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
throw new Error('Unbalanced braces for ' + funcDecl)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const varDeclSource = (haystack: string, varDecl: string): string => {
|
|
67
|
+
const startIdx = haystack.indexOf(varDecl)
|
|
68
|
+
if (startIdx === -1) {
|
|
69
|
+
throw new Error('Var declaration not found in generated code: ' + varDecl)
|
|
70
|
+
}
|
|
71
|
+
const endIdx = haystack.indexOf(';', startIdx)
|
|
72
|
+
return haystack.slice(startIdx, endIdx + 1)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const booleanTrueStringsSource = varDeclSource(code, 'var BOOLEAN_TRUE_STRINGS')
|
|
76
|
+
const splitCommaSource = extractFunctionSource(code, 'function splitCommaOrPgArrayString')
|
|
77
|
+
const helperSource = extractFunctionSource(code, 'function coerceValueForPgColumn')
|
|
78
|
+
|
|
79
|
+
const factory = new Function(
|
|
80
|
+
booleanTrueStringsSource +
|
|
81
|
+
'\n' +
|
|
82
|
+
splitCommaSource +
|
|
83
|
+
'\n' +
|
|
84
|
+
helperSource +
|
|
85
|
+
'\nreturn coerceValueForPgColumn;'
|
|
86
|
+
)
|
|
87
|
+
const coerceValueForPgColumn = factory() as (
|
|
88
|
+
col: string,
|
|
89
|
+
val: unknown,
|
|
90
|
+
colTypes: Record<string, string>
|
|
91
|
+
) => unknown
|
|
92
|
+
|
|
93
|
+
const boolCol = 'is_featured'
|
|
94
|
+
const colTypes: Record<string, string> = {
|
|
95
|
+
is_featured: 'boolean',
|
|
96
|
+
tags: 'ARRAY',
|
|
97
|
+
name: 'text',
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
it('coerces an unchecked-checkbox empty string to false (not NULL)', () => {
|
|
101
|
+
expect(coerceValueForPgColumn(boolCol, '', colTypes)).toBe(false)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('coerces undefined (field never present in the submitted body) to false', () => {
|
|
105
|
+
expect(coerceValueForPgColumn(boolCol, undefined, colTypes)).toBe(false)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('coerces null to false', () => {
|
|
109
|
+
expect(coerceValueForPgColumn(boolCol, null, colTypes)).toBe(false)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('coerces checkbox truthy strings ("on", "true", "1") to true', () => {
|
|
113
|
+
expect(coerceValueForPgColumn(boolCol, 'on', colTypes)).toBe(true)
|
|
114
|
+
expect(coerceValueForPgColumn(boolCol, 'true', colTypes)).toBe(true)
|
|
115
|
+
expect(coerceValueForPgColumn(boolCol, '1', colTypes)).toBe(true)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('is case-insensitive and trims whitespace on truthy strings', () => {
|
|
119
|
+
expect(coerceValueForPgColumn(boolCol, 'On', colTypes)).toBe(true)
|
|
120
|
+
expect(coerceValueForPgColumn(boolCol, ' TRUE ', colTypes)).toBe(true)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('coerces falsy strings ("off", "false", "0") to false', () => {
|
|
124
|
+
expect(coerceValueForPgColumn(boolCol, 'off', colTypes)).toBe(false)
|
|
125
|
+
expect(coerceValueForPgColumn(boolCol, 'false', colTypes)).toBe(false)
|
|
126
|
+
expect(coerceValueForPgColumn(boolCol, '0', colTypes)).toBe(false)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('passes real booleans through unchanged', () => {
|
|
130
|
+
expect(coerceValueForPgColumn(boolCol, true, colTypes)).toBe(true)
|
|
131
|
+
expect(coerceValueForPgColumn(boolCol, false, colTypes)).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('treats any non-zero number as true and zero as false', () => {
|
|
135
|
+
expect(coerceValueForPgColumn(boolCol, 1, colTypes)).toBe(true)
|
|
136
|
+
expect(coerceValueForPgColumn(boolCol, 0, colTypes)).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('does not affect non-boolean columns (ARRAY / text untouched)', () => {
|
|
140
|
+
expect(coerceValueForPgColumn('tags', 'a,b,c', colTypes)).toEqual(['a', 'b', 'c'])
|
|
141
|
+
expect(coerceValueForPgColumn('name', '', colTypes)).toBe('')
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -75,7 +75,9 @@ describe('data-api safeQuery wrapper — defends against Postgres type-coercion
|
|
|
75
75
|
})
|
|
76
76
|
|
|
77
77
|
it('handleRawQuery routes through safeQuery so workflow-level raw SELECTs also degrade gracefully', () => {
|
|
78
|
-
|
|
78
|
+
// Now binds the resolved `params` array (positional $N binds) rather than a
|
|
79
|
+
// hardcoded empty array — see raw-query-param-binding.test.ts.
|
|
80
|
+
expect(code).toMatch(/safeQuery\(client,\s*query,\s*params,\s*'raw-query'\)/)
|
|
79
81
|
})
|
|
80
82
|
|
|
81
83
|
it('handleCreate is NOT wrapped (an INSERT failing on a bad UUID is a real error, not "no match")', () => {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { loadHandler, HandlerFn } from './_helpers/load-handler'
|
|
2
|
+
|
|
3
|
+
// Regression guards for the data-update-item runtime output contract (run
|
|
4
|
+
// d9a24741):
|
|
5
|
+
// - the gate `if (updateNode.affected > 0)` read `undefined` because the
|
|
6
|
+
// handler only returned `updatedCount` → every successful edit fell to its
|
|
7
|
+
// "record may have been deleted" branch. The handler now aliases `affected`.
|
|
8
|
+
// - an un-prefilled form field is OMITTED by general-extract-form-data, so its
|
|
9
|
+
// columnMapping value resolves to `undefined`; sending it wrote NULL into a
|
|
10
|
+
// NOT-NULL column (`guests.full_name`). The handler now omits undefined
|
|
11
|
+
// columnMappings so the column keeps its stored value.
|
|
12
|
+
|
|
13
|
+
type FetchCall = { url: string; body: Record<string, unknown> }
|
|
14
|
+
|
|
15
|
+
function installFetch(response: Record<string, unknown>, ok = true): FetchCall[] {
|
|
16
|
+
const calls: FetchCall[] = []
|
|
17
|
+
;(globalThis as any).fetch = async (url: string, opts: { body: string }) => {
|
|
18
|
+
calls.push({ url, body: JSON.parse(opts.body) })
|
|
19
|
+
return { ok, json: async () => response }
|
|
20
|
+
}
|
|
21
|
+
return calls
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function uninstallFetch(): void {
|
|
25
|
+
delete (globalThis as any).fetch
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('data-update-item runtime output + null-safety', () => {
|
|
29
|
+
let handler: HandlerFn
|
|
30
|
+
|
|
31
|
+
beforeAll(() => {
|
|
32
|
+
handler = loadHandler('data-update-item')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
uninstallFetch()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('aliases `affected` to `updatedCount` on a successful update', async () => {
|
|
40
|
+
installFetch({ id: 'r1', updatedCount: 1, item: { id: 'r1' } })
|
|
41
|
+
const result = (await handler(
|
|
42
|
+
{
|
|
43
|
+
tableName: 'events',
|
|
44
|
+
filters: [{ field: 'id', operator: '=', value: 'e1' }],
|
|
45
|
+
columnMappings: [{ column: 'title', value: 'New title' }],
|
|
46
|
+
},
|
|
47
|
+
{}
|
|
48
|
+
)) as Record<string, unknown>
|
|
49
|
+
expect(result.updatedCount).toBe(1)
|
|
50
|
+
expect(result.affected).toBe(1)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('reports affected:0 on the no-op (unresolved route-param) degrade', async () => {
|
|
54
|
+
const result = (await handler(
|
|
55
|
+
{
|
|
56
|
+
tableName: 'events',
|
|
57
|
+
filters: [{ field: 'id', operator: '=', value: '__TQ_UNRESOLVED_ROUTE_PARAM__' }],
|
|
58
|
+
columnMappings: [{ column: 'title', value: 'x' }],
|
|
59
|
+
},
|
|
60
|
+
{}
|
|
61
|
+
)) as Record<string, unknown>
|
|
62
|
+
expect(result.updatedCount).toBe(0)
|
|
63
|
+
expect(result.affected).toBe(0)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('omits an undefined-resolving columnMapping so it never writes NULL', async () => {
|
|
67
|
+
const calls = installFetch({ id: 'r1', updatedCount: 1 })
|
|
68
|
+
await handler(
|
|
69
|
+
{
|
|
70
|
+
tableName: 'guests',
|
|
71
|
+
filters: [{ field: 'id', operator: '=', value: 'g1' }],
|
|
72
|
+
columnMappings: [
|
|
73
|
+
{ column: 'full_name', value: undefined },
|
|
74
|
+
{ column: 'phone', value: '555-1234' },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
{}
|
|
78
|
+
)
|
|
79
|
+
expect(calls).toHaveLength(1)
|
|
80
|
+
const sentMappings = calls[0].body.columnMappings as Array<{ column: string }>
|
|
81
|
+
expect(sentMappings.map((m) => m.column)).toEqual(['phone'])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('never sends an empty UPDATE when every columnMapping is undefined', async () => {
|
|
85
|
+
const calls = installFetch({ id: 'r1', updatedCount: 1 })
|
|
86
|
+
const result = (await handler(
|
|
87
|
+
{
|
|
88
|
+
tableName: 'guests',
|
|
89
|
+
filters: [{ field: 'id', operator: '=', value: 'g1' }],
|
|
90
|
+
columnMappings: [
|
|
91
|
+
{ column: 'full_name', value: undefined },
|
|
92
|
+
{ column: 'phone', value: undefined },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
{}
|
|
96
|
+
)) as Record<string, unknown>
|
|
97
|
+
expect(calls).toHaveLength(0)
|
|
98
|
+
expect(result.updatedCount).toBe(0)
|
|
99
|
+
expect(result.affected).toBe(0)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('preserves an explicit null columnMapping (intentional clear ≠ undefined)', async () => {
|
|
103
|
+
const calls = installFetch({ id: 'r1', updatedCount: 1 })
|
|
104
|
+
await handler(
|
|
105
|
+
{
|
|
106
|
+
tableName: 'guests',
|
|
107
|
+
filters: [{ field: 'id', operator: '=', value: 'g1' }],
|
|
108
|
+
columnMappings: [{ column: 'notes', value: null }],
|
|
109
|
+
},
|
|
110
|
+
{}
|
|
111
|
+
)
|
|
112
|
+
expect(calls).toHaveLength(1)
|
|
113
|
+
const sentMappings = calls[0].body.columnMappings as Array<{ column: string; value: unknown }>
|
|
114
|
+
expect(sentMappings).toEqual([{ column: 'notes', value: null }])
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { generateDataAPIRoute } from '../src/data-api-route-generator'
|
|
2
|
+
import { generateSharedRuntimeUtilsCode } from '../src'
|
|
3
|
+
import { analyzeRawQueryParameterization } from '../src/sql-query-validator'
|
|
4
|
+
import { loadHandler } from './_helpers/load-handler'
|
|
5
|
+
|
|
6
|
+
// SQL-INJECTION SECURITY regression guard.
|
|
7
|
+
//
|
|
8
|
+
// The generation-time net rewrites every {{state.X}}/{{Current User.id}}/…
|
|
9
|
+
// VALUE interpolation in a raw-SQL node into a positional $N placeholder and
|
|
10
|
+
// moves the {{…}} token into a sibling `params` (data-raw-query) /
|
|
11
|
+
// `rawQueryUserPartParams` (data-select) array. This suite proves the PUBLISHED
|
|
12
|
+
// app honours that contract end-to-end:
|
|
13
|
+
// 1. the executor node handlers FORWARD the resolved params array to the API,
|
|
14
|
+
// 2. the values are BOUND ($N) at runtime — never interpolated into SQL text,
|
|
15
|
+
// 3. the validator treats the parameterized form as SAFE and still flags a
|
|
16
|
+
// legacy {{…}}-in-SQL query.
|
|
17
|
+
|
|
18
|
+
type SharedUtils = {
|
|
19
|
+
resolveConfig: (cfg: unknown, ctx: Record<string, unknown>) => any
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function loadSharedRuntime(): SharedUtils {
|
|
23
|
+
const src = generateSharedRuntimeUtilsCode()
|
|
24
|
+
const wrapper: { exports: Record<string, unknown> } = { exports: {} }
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
26
|
+
new Function('module', 'exports', src)(wrapper, wrapper.exports)
|
|
27
|
+
return wrapper.exports as unknown as SharedUtils
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function captureRawQueryFetchBody(config: unknown): Promise<any> {
|
|
31
|
+
const fetchMock = jest.fn(async () => ({
|
|
32
|
+
ok: true,
|
|
33
|
+
json: async () => ({ rows: [] }),
|
|
34
|
+
}))
|
|
35
|
+
const originalFetch = (globalThis as any).fetch
|
|
36
|
+
;(globalThis as any).fetch = fetchMock
|
|
37
|
+
try {
|
|
38
|
+
const handler = loadHandler('data-raw-query')
|
|
39
|
+
await handler(config, {})
|
|
40
|
+
return JSON.parse((fetchMock.mock.calls[0] as any[])[1].body)
|
|
41
|
+
} finally {
|
|
42
|
+
;(globalThis as any).fetch = originalFetch
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function captureSelectFetchBody(config: unknown): Promise<any> {
|
|
47
|
+
const fetchMock = jest.fn(async () => ({
|
|
48
|
+
ok: true,
|
|
49
|
+
json: async () => ({ rows: [], count: 0 }),
|
|
50
|
+
}))
|
|
51
|
+
const originalFetch = (globalThis as any).fetch
|
|
52
|
+
;(globalThis as any).fetch = fetchMock
|
|
53
|
+
try {
|
|
54
|
+
const handler = loadHandler('data-select')
|
|
55
|
+
await handler(config, {})
|
|
56
|
+
return JSON.parse((fetchMock.mock.calls[0] as any[])[1].body)
|
|
57
|
+
} finally {
|
|
58
|
+
;(globalThis as any).fetch = originalFetch
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('raw-query param binding — executor node handlers forward the bound params', () => {
|
|
63
|
+
it('data-raw-query POSTs { query, params } with $N SQL and the values in params', async () => {
|
|
64
|
+
const body = await captureRawQueryFetchBody({
|
|
65
|
+
dataSourceId: 'ds1',
|
|
66
|
+
query: 'SELECT * FROM products WHERE name ILIKE $1',
|
|
67
|
+
params: ['oak desk'],
|
|
68
|
+
})
|
|
69
|
+
expect(body.query).toBe('SELECT * FROM products WHERE name ILIKE $1')
|
|
70
|
+
expect(body.params).toEqual(['oak desk'])
|
|
71
|
+
// The value must NOT appear anywhere in the SQL text.
|
|
72
|
+
expect(body.query).not.toContain('oak desk')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('resolves the {{state.X}} token INTO the params array (not the SQL) — injection is neutralised', async () => {
|
|
76
|
+
const utils = loadSharedRuntime()
|
|
77
|
+
// Attacker-controlled search term that would break out of a string literal
|
|
78
|
+
// if it were interpolated into the SQL text.
|
|
79
|
+
const injection = "x'; DROP TABLE products; --"
|
|
80
|
+
const resolved = utils.resolveConfig(
|
|
81
|
+
{
|
|
82
|
+
dataSourceId: 'ds1',
|
|
83
|
+
query: 'SELECT * FROM products WHERE name ILIKE $1',
|
|
84
|
+
params: ['{{state.searchQuery}}'],
|
|
85
|
+
},
|
|
86
|
+
{ __stateValues: { searchQuery: injection } }
|
|
87
|
+
)
|
|
88
|
+
// resolveConfig substitutes the {{…}} token to the concrete value in params.
|
|
89
|
+
expect(resolved.params).toEqual([injection])
|
|
90
|
+
// The SQL text is untouched — still just the $1 placeholder.
|
|
91
|
+
expect(resolved.query).toBe('SELECT * FROM products WHERE name ILIKE $1')
|
|
92
|
+
|
|
93
|
+
const body = await captureRawQueryFetchBody(resolved)
|
|
94
|
+
expect(body.query).toBe('SELECT * FROM products WHERE name ILIKE $1')
|
|
95
|
+
expect(body.params).toEqual([injection])
|
|
96
|
+
expect(body.query).not.toContain('DROP TABLE')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('data-raw-query stays backward-compatible: a static query with no params sends params: []', async () => {
|
|
100
|
+
const body = await captureRawQueryFetchBody({
|
|
101
|
+
dataSourceId: 'ds1',
|
|
102
|
+
query: 'SELECT COUNT(*) FROM products',
|
|
103
|
+
})
|
|
104
|
+
expect(body.query).toBe('SELECT COUNT(*) FROM products')
|
|
105
|
+
expect(body.params).toEqual([])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('data-select forwards rawQueryUserPartParams alongside a $N rawQueryUserPart override', async () => {
|
|
109
|
+
const body = await captureSelectFetchBody({
|
|
110
|
+
dataSourceId: 'ds1',
|
|
111
|
+
tableName: 'products',
|
|
112
|
+
rawQueryUserPart: 'SELECT * FROM products WHERE owner = $1',
|
|
113
|
+
rawQueryUserPartParams: ['user-42'],
|
|
114
|
+
})
|
|
115
|
+
expect(body.rawQueryUserPart).toBe('SELECT * FROM products WHERE owner = $1')
|
|
116
|
+
expect(body.rawQueryUserPartParams).toEqual(['user-42'])
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('raw-query param binding — generated data-API route binds, never interpolates', () => {
|
|
121
|
+
const code = generateDataAPIRoute()
|
|
122
|
+
|
|
123
|
+
it('handleRawQuery extracts body.params and binds them through safeQuery', () => {
|
|
124
|
+
expect(code).toMatch(
|
|
125
|
+
/var\s+params\s*=\s*Array\.isArray\(body\.params\)\s*\?\s*body\.params\s*:\s*\[\s*\]/
|
|
126
|
+
)
|
|
127
|
+
expect(code).toMatch(/safeQuery\(client,\s*query,\s*params,\s*'raw-query'\)/)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('handleRawQuery derives its SQL ONLY from body.query — no param value is spliced into the text', () => {
|
|
131
|
+
const start = code.indexOf('async function handleRawQuery')
|
|
132
|
+
const end = code.indexOf('async function assertSessionOwnsUsersRow')
|
|
133
|
+
const section = code.slice(start, end)
|
|
134
|
+
// query is assigned verbatim from the body and never re-concatenated with params.
|
|
135
|
+
expect(section).toContain('var query = body.query;')
|
|
136
|
+
expect(section).not.toMatch(/query\s*\+\s*params/)
|
|
137
|
+
expect(section).not.toMatch(/params.*\+\s*query/)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("handleSelect's raw override binds rawQueryUserPartParams instead of a hardcoded empty array", () => {
|
|
141
|
+
expect(code).toMatch(
|
|
142
|
+
/queryParams\s*=\s*Array\.isArray\(body\.rawQueryUserPartParams\)\s*\?\s*body\.rawQueryUserPartParams\s*:\s*\[\s*\]/
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('handleRawQuery actually passes params to a parameterized client.query at runtime', async () => {
|
|
147
|
+
// Eval just the safeQuery helper (as data-api-safe-query.test.ts does) and
|
|
148
|
+
// confirm it forwards (sql, params) to client.query verbatim.
|
|
149
|
+
const start = code.indexOf('async function safeQuery')
|
|
150
|
+
let depth = 0
|
|
151
|
+
let i = code.indexOf('{', start)
|
|
152
|
+
for (; i < code.length; i++) {
|
|
153
|
+
if (code[i] === '{') {
|
|
154
|
+
depth++
|
|
155
|
+
} else if (code[i] === '}') {
|
|
156
|
+
depth--
|
|
157
|
+
if (depth === 0) {
|
|
158
|
+
break
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const fnSrc = code.slice(start, i + 1)
|
|
163
|
+
const codesStart = code.indexOf('var SAFE_COERCION_ERROR_CODES')
|
|
164
|
+
const codesEnd = code.indexOf(';', codesStart)
|
|
165
|
+
const codesSrc = code.slice(codesStart, codesEnd + 1)
|
|
166
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
167
|
+
const safeQuery = new Function(codesSrc + '\n' + fnSrc + '\nreturn safeQuery;')() as any
|
|
168
|
+
|
|
169
|
+
const calls: Array<{ sql: string; params: unknown[] }> = []
|
|
170
|
+
const fakeClient = {
|
|
171
|
+
query: async (sql: string, params: unknown[]) => {
|
|
172
|
+
calls.push({ sql, params })
|
|
173
|
+
return { rows: [], rowCount: 0 }
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
await safeQuery(
|
|
177
|
+
fakeClient,
|
|
178
|
+
'SELECT * FROM products WHERE name ILIKE $1',
|
|
179
|
+
["x'; DROP --"],
|
|
180
|
+
'raw-query'
|
|
181
|
+
)
|
|
182
|
+
expect(calls).toHaveLength(1)
|
|
183
|
+
expect(calls[0].sql).toBe('SELECT * FROM products WHERE name ILIKE $1')
|
|
184
|
+
expect(calls[0].params).toEqual(["x'; DROP --"])
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('sql-query-validator — analyzeRawQueryParameterization', () => {
|
|
189
|
+
it('treats a $N-parameterized query as SAFE (zero warnings)', () => {
|
|
190
|
+
const result = analyzeRawQueryParameterization(
|
|
191
|
+
'SELECT * FROM products WHERE name ILIKE $1 AND owner = $2'
|
|
192
|
+
)
|
|
193
|
+
expect(result.isSafe).toBe(true)
|
|
194
|
+
expect(result.warnings).toEqual([])
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('does NOT warn merely because the state lives in the params array', () => {
|
|
198
|
+
// The function inspects only the SQL text; a {{…}} token in params is the
|
|
199
|
+
// correct, safe home for it. A correctly-parameterized workflow → 0 warnings.
|
|
200
|
+
expect(analyzeRawQueryParameterization('SELECT 1').isSafe).toBe(true)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('WARNS on a legacy {{...}}-in-SQL-text interpolation', () => {
|
|
204
|
+
const result = analyzeRawQueryParameterization(
|
|
205
|
+
"SELECT * FROM products WHERE name = '{{state.searchQuery}}'"
|
|
206
|
+
)
|
|
207
|
+
expect(result.isSafe).toBe(false)
|
|
208
|
+
expect(result.warnings).toHaveLength(1)
|
|
209
|
+
expect(result.warnings[0].ruleId).toBe('sql-unparameterized-context')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('handles non-string / empty input as safe', () => {
|
|
213
|
+
expect(analyzeRawQueryParameterization(undefined).isSafe).toBe(true)
|
|
214
|
+
expect(analyzeRawQueryParameterization('').isSafe).toBe(true)
|
|
215
|
+
})
|
|
216
|
+
})
|