@teleporthq/teleport-plugin-next-workflows 0.43.33 → 0.43.35
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__/global-workflows-handler-map-integrity.test.ts +85 -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 +39 -2
- 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 +9 -0
- 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/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 +29 -3
- 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 +39 -2
- 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 +9 -0
- 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/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 +30 -4
- package/dist/esm/workflow-project-plugin.js.map +1 -1
- package/package.json +4 -4
- package/src/data-api-route-generator.ts +39 -2
- 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 +9 -0
- package/src/nodes/data/data-update-item.ts +62 -5
- 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 +39 -4
|
@@ -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,85 @@
|
|
|
1
|
+
import { NextWorkflowProjectPlugin } from '../src/workflow-project-plugin'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Regression: `generateGlobalWorkflowsHook` must never emit a `clientNodeHandlers`
|
|
5
|
+
* map entry that references a handler function it did not also define.
|
|
6
|
+
*
|
|
7
|
+
* Bug: `handlers` (the function definitions) was gated on `if (nodeRegistry[t])`,
|
|
8
|
+
* while `handlerEntries` (the map) mapped over EVERY collected client node type.
|
|
9
|
+
* A client-segment node whose type has no registry generator — e.g. a
|
|
10
|
+
* `general-comment`, or an `event-*` trigger node that got collected into the
|
|
11
|
+
* type set — therefore produced a map value `'general-comment': general_comment`
|
|
12
|
+
* with no `general_comment` defined anywhere in the file. That is a bare
|
|
13
|
+
* reference to an undefined identifier; it throws `ReferenceError: general_comment
|
|
14
|
+
* is not defined` when the module/hook is evaluated in production, crashing the
|
|
15
|
+
* Vercel build (global-workflows.js is pulled into the global client bundle for
|
|
16
|
+
* every page via `useGlobalWorkflows()`). The two lists must stay paired.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// A GLOBAL workflow (custom-triggered) whose client segment mixes a REGISTERED
|
|
20
|
+
// handler node (`state-update-local-state`) with an UNREGISTERED node type
|
|
21
|
+
// (`general-comment`, which has no entry in `nodeRegistry`). `general-comment`
|
|
22
|
+
// resolves to a client segment by default, so it lands in the handler type set.
|
|
23
|
+
const globalWorkflowWithUnregisteredNode = {
|
|
24
|
+
id: 'global-wf-unregistered',
|
|
25
|
+
trigger: { type: 'event-custom-triggered', config: { eventName: 'store-locations-loaded' } },
|
|
26
|
+
nodes: [
|
|
27
|
+
{
|
|
28
|
+
id: 'node-registered',
|
|
29
|
+
type: 'state-update-local-state',
|
|
30
|
+
config: { property: 'selectedPickupStore', value: '' },
|
|
31
|
+
stepNumber: 1,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'node-unregistered',
|
|
35
|
+
type: 'general-comment',
|
|
36
|
+
config: { text: 'a designer note that must not become a handler' },
|
|
37
|
+
stepNumber: 2,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
edges: [{ id: 'edge-1', source: 'node-registered', target: 'node-unregistered' }],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function generateHook(): string {
|
|
44
|
+
const plugin = new NextWorkflowProjectPlugin()
|
|
45
|
+
// generateGlobalWorkflowsHook is private; call it directly for a unit test.
|
|
46
|
+
return (plugin as any).generateGlobalWorkflowsHook([globalWorkflowWithUnregisteredNode])
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('generateGlobalWorkflowsHook — clientNodeHandlers map integrity', () => {
|
|
50
|
+
const code = generateHook()
|
|
51
|
+
|
|
52
|
+
it('defines a function for every identifier referenced in the handler map', () => {
|
|
53
|
+
// Match handler-map entries of the shape `'node-type': node_type` (the value
|
|
54
|
+
// identifier is the key with hyphens turned to underscores).
|
|
55
|
+
const entry = /['"]([a-z0-9]+(?:-[a-z0-9]+)+)['"]\s*:\s*([a-z0-9]+(?:_[a-z0-9]+)+)\b/g
|
|
56
|
+
const dangling: string[] = []
|
|
57
|
+
let m: RegExpExecArray | null = entry.exec(code)
|
|
58
|
+
while (m !== null) {
|
|
59
|
+
const [, key, ident] = m
|
|
60
|
+
if (key.replace(/-/g, '_') !== ident) {
|
|
61
|
+
m = entry.exec(code)
|
|
62
|
+
continue // not a handler-map entry
|
|
63
|
+
}
|
|
64
|
+
const isDefined = new RegExp(
|
|
65
|
+
`(?:async\\s+function|function|const|let|var)\\s+${ident}\\b`
|
|
66
|
+
).test(code)
|
|
67
|
+
if (!isDefined) {
|
|
68
|
+
dangling.push(ident)
|
|
69
|
+
}
|
|
70
|
+
m = entry.exec(code)
|
|
71
|
+
}
|
|
72
|
+
expect(dangling).toEqual([])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('keeps the registered handler in the map with its definition', () => {
|
|
76
|
+
expect(code).toContain("'state-update-local-state': state_update_local_state")
|
|
77
|
+
expect(code).toMatch(/(?:async\s+)?function state_update_local_state\s*\(/)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('omits an unregistered node type (general-comment) entirely — no dangling reference', () => {
|
|
81
|
+
// Neither a definition nor a map entry for the unregistered type may appear.
|
|
82
|
+
expect(code).not.toContain('general_comment')
|
|
83
|
+
expect(code).not.toContain("'general-comment'")
|
|
84
|
+
})
|
|
85
|
+
})
|