@teleporthq/teleport-plugin-next-workflows 0.43.45 → 0.43.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/__tests__/_helpers/run-generated-middleware.ts +130 -0
  2. package/__tests__/auth-env-secret-preservation.test.ts +35 -0
  3. package/__tests__/auth-nextauth-url.test.ts +28 -0
  4. package/__tests__/auth-session-cost.test.ts +614 -0
  5. package/__tests__/custom-js-params-env-invariance.test.ts +134 -0
  6. package/__tests__/data-node-not-awaited.test.ts +644 -0
  7. package/__tests__/element-trigger-static-attr-replace.test.ts +99 -0
  8. package/__tests__/element-visible-trigger-scoping.test.ts +296 -0
  9. package/__tests__/form-submitted-trigger-context.test.ts +100 -0
  10. package/__tests__/middleware-guest-order-details.test.ts +258 -0
  11. package/__tests__/middleware-row-owned-pages.test.ts +32 -9
  12. package/__tests__/payment-charge-user-handler-serialization.test.ts +7 -5
  13. package/__tests__/payment-redirect-and-order-notification.test.ts +6 -4
  14. package/__tests__/raw-sql-param-binding.test.ts +82 -0
  15. package/__tests__/reserved-word-state-binding.test.ts +112 -0
  16. package/__tests__/strip-event-handler-attrs.test.ts +59 -0
  17. package/__tests__/workflow-auth-guard.test.ts +277 -0
  18. package/dist/cjs/api-route-generator.d.ts +3 -3
  19. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  20. package/dist/cjs/api-route-generator.js +136 -11
  21. package/dist/cjs/api-route-generator.js.map +1 -1
  22. package/dist/cjs/auth-generator.d.ts +67 -1
  23. package/dist/cjs/auth-generator.d.ts.map +1 -1
  24. package/dist/cjs/auth-generator.js +573 -31
  25. package/dist/cjs/auth-generator.js.map +1 -1
  26. package/dist/cjs/await-result.d.ts +31 -0
  27. package/dist/cjs/await-result.d.ts.map +1 -0
  28. package/dist/cjs/await-result.js +50 -0
  29. package/dist/cjs/await-result.js.map +1 -0
  30. package/dist/cjs/executor-generator.d.ts.map +1 -1
  31. package/dist/cjs/executor-generator.js +150 -1
  32. package/dist/cjs/executor-generator.js.map +1 -1
  33. package/dist/cjs/nodes/account/account-get-current.d.ts.map +1 -1
  34. package/dist/cjs/nodes/account/account-get-current.js +44 -11
  35. package/dist/cjs/nodes/account/account-get-current.js.map +1 -1
  36. package/dist/cjs/nodes/account/account-login.d.ts.map +1 -1
  37. package/dist/cjs/nodes/account/account-login.js +10 -1
  38. package/dist/cjs/nodes/account/account-login.js.map +1 -1
  39. package/dist/cjs/raw-sql-param-binding.d.ts.map +1 -1
  40. package/dist/cjs/raw-sql-param-binding.js +32 -0
  41. package/dist/cjs/raw-sql-param-binding.js.map +1 -1
  42. package/dist/cjs/segment-splitter.d.ts.map +1 -1
  43. package/dist/cjs/segment-splitter.js +15 -7
  44. package/dist/cjs/segment-splitter.js.map +1 -1
  45. package/dist/cjs/trigger-generator.js +7 -1
  46. package/dist/cjs/trigger-generator.js.map +1 -1
  47. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  48. package/dist/cjs/workflow-auth-generator.d.ts +26 -0
  49. package/dist/cjs/workflow-auth-generator.d.ts.map +1 -0
  50. package/dist/cjs/workflow-auth-generator.js +166 -0
  51. package/dist/cjs/workflow-auth-generator.js.map +1 -0
  52. package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
  53. package/dist/cjs/workflow-component-plugin.js +124 -17
  54. package/dist/cjs/workflow-component-plugin.js.map +1 -1
  55. package/dist/cjs/workflow-project-plugin.d.ts +2 -1
  56. package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
  57. package/dist/cjs/workflow-project-plugin.js +170 -20
  58. package/dist/cjs/workflow-project-plugin.js.map +1 -1
  59. package/dist/esm/api-route-generator.d.ts +3 -3
  60. package/dist/esm/api-route-generator.d.ts.map +1 -1
  61. package/dist/esm/api-route-generator.js +136 -11
  62. package/dist/esm/api-route-generator.js.map +1 -1
  63. package/dist/esm/auth-generator.d.ts +67 -1
  64. package/dist/esm/auth-generator.d.ts.map +1 -1
  65. package/dist/esm/auth-generator.js +568 -30
  66. package/dist/esm/auth-generator.js.map +1 -1
  67. package/dist/esm/await-result.d.ts +31 -0
  68. package/dist/esm/await-result.d.ts.map +1 -0
  69. package/dist/esm/await-result.js +45 -0
  70. package/dist/esm/await-result.js.map +1 -0
  71. package/dist/esm/executor-generator.d.ts.map +1 -1
  72. package/dist/esm/executor-generator.js +150 -1
  73. package/dist/esm/executor-generator.js.map +1 -1
  74. package/dist/esm/nodes/account/account-get-current.d.ts.map +1 -1
  75. package/dist/esm/nodes/account/account-get-current.js +44 -11
  76. package/dist/esm/nodes/account/account-get-current.js.map +1 -1
  77. package/dist/esm/nodes/account/account-login.d.ts.map +1 -1
  78. package/dist/esm/nodes/account/account-login.js +10 -1
  79. package/dist/esm/nodes/account/account-login.js.map +1 -1
  80. package/dist/esm/raw-sql-param-binding.d.ts.map +1 -1
  81. package/dist/esm/raw-sql-param-binding.js +32 -0
  82. package/dist/esm/raw-sql-param-binding.js.map +1 -1
  83. package/dist/esm/segment-splitter.d.ts.map +1 -1
  84. package/dist/esm/segment-splitter.js +15 -7
  85. package/dist/esm/segment-splitter.js.map +1 -1
  86. package/dist/esm/trigger-generator.js +7 -1
  87. package/dist/esm/trigger-generator.js.map +1 -1
  88. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  89. package/dist/esm/workflow-auth-generator.d.ts +26 -0
  90. package/dist/esm/workflow-auth-generator.d.ts.map +1 -0
  91. package/dist/esm/workflow-auth-generator.js +161 -0
  92. package/dist/esm/workflow-auth-generator.js.map +1 -0
  93. package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
  94. package/dist/esm/workflow-component-plugin.js +125 -18
  95. package/dist/esm/workflow-component-plugin.js.map +1 -1
  96. package/dist/esm/workflow-project-plugin.d.ts +2 -1
  97. package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
  98. package/dist/esm/workflow-project-plugin.js +169 -20
  99. package/dist/esm/workflow-project-plugin.js.map +1 -1
  100. package/package.json +4 -4
  101. package/src/api-route-generator.ts +139 -11
  102. package/src/auth-generator.ts +600 -30
  103. package/src/await-result.ts +52 -0
  104. package/src/executor-generator.ts +150 -1
  105. package/src/nodes/account/account-get-current.ts +44 -9
  106. package/src/nodes/account/account-login.ts +12 -1
  107. package/src/raw-sql-param-binding.ts +33 -0
  108. package/src/segment-splitter.ts +15 -7
  109. package/src/trigger-generator.ts +7 -1
  110. package/src/workflow-auth-generator.ts +187 -0
  111. package/src/workflow-component-plugin.ts +146 -21
  112. package/src/workflow-project-plugin.ts +186 -19
@@ -0,0 +1,258 @@
1
+ import { compileGeneratedMiddleware } from './_helpers/run-generated-middleware'
2
+
3
+ /**
4
+ * Guest checkout regression, end to end through the GENERATED middleware.
5
+ *
6
+ * The e-commerce list/details pair deliberately shares a static base — the
7
+ * order history is served from `/orders` and a single order from
8
+ * `/orders/[order_number]` — so a reader can drop the last path segment to get
9
+ * back to the listing. The listing legitimately requires auth (a guest has no
10
+ * order history), the details page does not: its page-load SQL matches
11
+ * `user_id = <session user>` OR `user_id = <anonymous localStorage UUID>`, and
12
+ * a guest order is stamped with the latter.
13
+ *
14
+ * `generateMiddlewareFile` already skipped row-owned self-guarded pages from
15
+ * `protectedRoutes`, but that was not enough: the middleware matches a request
16
+ * against every protected route as a PREFIX, so `/orders/ORD-42` matched the
17
+ * listing's `/orders` entry and every guest was redirected to
18
+ * `/sign-in?callbackUrl=/orders/ORD-42` — right after paying, and right after
19
+ * the COD "order placed" toast. Buyers with a stale session cookie never saw
20
+ * it, which is why it looked intermittent.
21
+ *
22
+ * The fix emits the self-guarded page's full route pattern and waives ONLY
23
+ * inherited, role-free protection for paths that pattern matches.
24
+ */
25
+
26
+ const baseAuth: any = {
27
+ enabled: true,
28
+ dataSourceId: 'ds-1',
29
+ dataSourceType: 'postgres',
30
+ passwordAuthEnabled: true,
31
+ providers: [],
32
+ roles: ['admin'],
33
+ tables: {},
34
+ folderProtection: {},
35
+ authPages: {
36
+ signIn: { pageId: 'sign-in', pageName: 'sign-in', route: '/sign-in' },
37
+ signUp: { pageId: 'sign-up', pageName: 'sign-up', route: '/sign-up' },
38
+ },
39
+ callbackBaseUrl: '',
40
+ envKeys: {},
41
+ customUserProperties: [],
42
+ }
43
+
44
+ const ordersListProtection = {
45
+ requiresAuth: true,
46
+ allowedRoles: [] as string[],
47
+ pageName: 'orders-list',
48
+ route: '/orders',
49
+ }
50
+
51
+ const orderDetailsProtection = {
52
+ requiresAuth: true,
53
+ allowedRoles: [] as string[],
54
+ pageName: 'order-details',
55
+ route: '/orders',
56
+ routePattern: '/orders/[order_number]',
57
+ rowOwnerColumn: 'user_id',
58
+ rowOwnerTable: 'teleport_orders',
59
+ rowOwnerDifferentiator: 'order_number',
60
+ }
61
+
62
+ const storeAuth = (overrides: Record<string, unknown> = {}) =>
63
+ ({
64
+ ...baseAuth,
65
+ pageProtection: {
66
+ OL: ordersListProtection,
67
+ OD: orderDetailsProtection,
68
+ ADMIN: {
69
+ requiresAuth: true,
70
+ allowedRoles: ['admin'],
71
+ pageName: 'admin',
72
+ route: '/admin',
73
+ },
74
+ },
75
+ ...overrides,
76
+ } as any)
77
+
78
+ describe('generated middleware: guest access to a row-owned order-details route', () => {
79
+ const run = compileGeneratedMiddleware(storeAuth())
80
+
81
+ it('lets an anonymous buyer reach the order they just placed', async () => {
82
+ // The single path both checkout branches land on: the COD branch navigates
83
+ // here directly, and Stripe/PayPal return to `<this>?payment=success`
84
+ // (`nextUrl.pathname` excludes the query, so the decision is made here).
85
+ expect(await run('/orders/ORD-42')).toEqual({ kind: 'next' })
86
+ })
87
+
88
+ it('matches the same order route with a trailing slash', async () => {
89
+ // Next.js serves both forms; matching the raw pathname would make the
90
+ // slashed variant miss the self-guarded pattern and inherit the listing's
91
+ // protection.
92
+ expect(await run('/orders/ORD-42/')).toEqual({ kind: 'next' })
93
+ })
94
+
95
+ it('URL-encoded order numbers still resolve to the details route', async () => {
96
+ expect(await run('/orders/ORD%2F42')).toEqual({ kind: 'next' })
97
+ })
98
+
99
+ it('still forces sign-in on the orders LISTING itself', async () => {
100
+ const result = await run('/orders')
101
+ expect(result.kind).toBe('redirect')
102
+ expect(result.location).toContain('/sign-in')
103
+ })
104
+
105
+ it('does not waive protection for a deeper path the details page never serves', async () => {
106
+ // `/orders/[order_number]` compiles to a single-segment matcher, so a
107
+ // crafted `/orders/ORD-42/anything` keeps the listing's protection.
108
+ const result = await run('/orders/ORD-42/edit')
109
+ expect(result.kind).toBe('redirect')
110
+ expect(result.location).toContain('/sign-in')
111
+ })
112
+
113
+ it('keeps the admin subtree gated', async () => {
114
+ expect((await run('/admin')).kind).toBe('redirect')
115
+ expect((await run('/admin/orders')).kind).toBe('redirect')
116
+ })
117
+
118
+ it('lets a signed-in buyer reach their order history', async () => {
119
+ expect(await run('/orders', { sessionUser: { id: 'u-1', role: 'user' } })).toEqual({
120
+ kind: 'next',
121
+ })
122
+ })
123
+
124
+ it('still bounces a non-admin off the admin subtree', async () => {
125
+ const result = await run('/admin/orders', { sessionUser: { id: 'u-1', role: 'user' } })
126
+ expect(result.kind).toBe('redirect')
127
+ })
128
+ })
129
+
130
+ describe('generated middleware: self-guard waiver stays narrow', () => {
131
+ it('does not waive protection when the ancestor route demands a role', async () => {
132
+ // A staff-only subtree is an absolute gate the row-level WHERE clause
133
+ // cannot reproduce, so the guest must NOT slip through it.
134
+ const run = compileGeneratedMiddleware(
135
+ storeAuth({
136
+ pageProtection: {
137
+ STAFF_LIST: {
138
+ requiresAuth: true,
139
+ allowedRoles: ['staff'],
140
+ pageName: 'staff-orders',
141
+ route: '/staff-orders',
142
+ },
143
+ STAFF_DETAILS: {
144
+ requiresAuth: true,
145
+ allowedRoles: [],
146
+ pageName: 'staff-order-details',
147
+ route: '/staff-orders',
148
+ routePattern: '/staff-orders/[order_number]',
149
+ rowOwnerColumn: 'user_id',
150
+ rowOwnerDifferentiator: 'order_number',
151
+ },
152
+ },
153
+ })
154
+ )
155
+
156
+ const result = await run('/staff-orders/ORD-42')
157
+ expect(result.kind).toBe('redirect')
158
+ expect(result.location).toContain('/sign-in')
159
+ })
160
+
161
+ it("does not waive a page's OWN exact protection", async () => {
162
+ // Contrived but load-bearing: if some other protected page is published at
163
+ // exactly the path a self-guarded pattern matches, that page's own entry
164
+ // wins. Only INHERITED protection is waivable.
165
+ const run = compileGeneratedMiddleware(
166
+ storeAuth({
167
+ pageProtection: {
168
+ OL: ordersListProtection,
169
+ OD: orderDetailsProtection,
170
+ CLASH: {
171
+ requiresAuth: true,
172
+ allowedRoles: [],
173
+ pageName: 'orders-archive',
174
+ route: '/orders/archive',
175
+ },
176
+ },
177
+ })
178
+ )
179
+
180
+ const result = await run('/orders/archive')
181
+ expect(result.kind).toBe('redirect')
182
+ expect(result.location).toContain('/sign-in')
183
+ })
184
+
185
+ it('reconstructs the pattern for UIDLs emitted before `routePattern` existed', async () => {
186
+ // Older projects carry only `route` + `rowOwnerDifferentiator`. As long as
187
+ // the route is the real static base, the pattern is derivable.
188
+ const run = compileGeneratedMiddleware(
189
+ storeAuth({
190
+ pageProtection: {
191
+ OL: ordersListProtection,
192
+ OD: {
193
+ requiresAuth: true,
194
+ allowedRoles: [],
195
+ pageName: 'order-details',
196
+ route: '/orders',
197
+ rowOwnerColumn: 'user_id',
198
+ rowOwnerDifferentiator: 'order_number',
199
+ },
200
+ },
201
+ })
202
+ )
203
+
204
+ expect(await run('/orders/ORD-42')).toEqual({ kind: 'next' })
205
+ expect((await run('/orders')).kind).toBe('redirect')
206
+ })
207
+
208
+ it('leaves projects without any self-guarded page completely unchanged', async () => {
209
+ const run = compileGeneratedMiddleware({
210
+ ...baseAuth,
211
+ pageProtection: {
212
+ ACCOUNT: {
213
+ requiresAuth: true,
214
+ allowedRoles: [],
215
+ pageName: 'account',
216
+ route: '/account',
217
+ },
218
+ },
219
+ } as any)
220
+
221
+ expect((await run('/account')).kind).toBe('redirect')
222
+ expect((await run('/account/settings')).kind).toBe('redirect')
223
+ expect(await run('/about')).toEqual({ kind: 'next' })
224
+ })
225
+ })
226
+
227
+ describe('generated middleware: two pages claiming one route key', () => {
228
+ it('merges their rules instead of letting document order pick a winner', async () => {
229
+ // A list/details pair shares its static base by design, and a user can also
230
+ // point two pages at the same custom URL. One prefix key gates the whole
231
+ // subtree, so the stricter rule has to survive — otherwise whichever page
232
+ // the document happened to list last silently decided the access rules.
233
+ const run = compileGeneratedMiddleware({
234
+ ...baseAuth,
235
+ pageProtection: {
236
+ PUBLIC_ISH: {
237
+ requiresAuth: true,
238
+ allowedRoles: [],
239
+ pageName: 'reports',
240
+ route: '/reports',
241
+ },
242
+ ADMIN_ONLY: {
243
+ requiresAuth: true,
244
+ allowedRoles: ['admin'],
245
+ pageName: 'reports-admin',
246
+ route: '/reports',
247
+ },
248
+ },
249
+ } as any)
250
+
251
+ expect((await run('/reports', { sessionUser: { id: 'u-1', role: 'user' } })).kind).toBe(
252
+ 'redirect'
253
+ )
254
+ expect(await run('/reports', { sessionUser: { id: 'u-2', role: 'admin' } })).toEqual({
255
+ kind: 'next',
256
+ })
257
+ })
258
+ })
@@ -1,4 +1,8 @@
1
1
  import { generateMiddlewareFile } from '../src/auth-generator'
2
+ import {
3
+ extractProtectedRoutes,
4
+ extractSelfGuardedRoutes,
5
+ } from './_helpers/run-generated-middleware'
2
6
 
3
7
  // Guarding the regression where a guest checkout buyer lands on
4
8
  // `/order-details/<order_number>` after Stripe redirect and the
@@ -51,8 +55,11 @@ describe('generateMiddlewareFile: row-owned self-guarded pages', () => {
51
55
 
52
56
  // /order-details must NOT appear in protectedRoutes — the
53
57
  // page-load SQL `user_id = userId OR user_id = anonymousUserId`
54
- // is the source of truth for ownership.
55
- expect(code).not.toContain('"/order-details"')
58
+ // is the source of truth for ownership. It DOES appear in
59
+ // selfGuardedRoutes, which is how the middleware recognises the
60
+ // requests it must let through when an ancestor route is protected.
61
+ expect(extractProtectedRoutes(code)).toEqual({})
62
+ expect(extractSelfGuardedRoutes(code)).toEqual(['/order-details/[order_number]'])
56
63
  })
57
64
 
58
65
  it('still protects a row-owned page that requires a specific role', () => {
@@ -74,8 +81,11 @@ describe('generateMiddlewareFile: row-owned self-guarded pages', () => {
74
81
  },
75
82
  } as any)
76
83
 
77
- expect(code).toContain('"/admin/orders/[id]"')
78
- expect(code).toContain('"admin"')
84
+ expect(extractProtectedRoutes(code)['/admin/orders/[id]']).toEqual({
85
+ requiresAuth: true,
86
+ allowedRoles: ['admin'],
87
+ })
88
+ expect(extractSelfGuardedRoutes(code)).toEqual([])
79
89
  })
80
90
 
81
91
  it('keeps non-row-owned pages protected as before', () => {
@@ -94,7 +104,11 @@ describe('generateMiddlewareFile: row-owned self-guarded pages', () => {
94
104
  },
95
105
  } as any)
96
106
 
97
- expect(code).toContain('"/orders-list"')
107
+ expect(extractProtectedRoutes(code)['/orders-list']).toEqual({
108
+ requiresAuth: true,
109
+ allowedRoles: [],
110
+ })
111
+ expect(extractSelfGuardedRoutes(code)).toEqual([])
98
112
  })
99
113
 
100
114
  it('honours folder-level role gating even when a child page is row-owned', () => {
@@ -124,8 +138,12 @@ describe('generateMiddlewareFile: row-owned self-guarded pages', () => {
124
138
  },
125
139
  } as any)
126
140
 
127
- expect(code).toContain('"/tickets/[id]"')
128
- expect(code).toContain('"staff"')
141
+ expect(extractProtectedRoutes(code)['/tickets/[id]']).toEqual({
142
+ requiresAuth: true,
143
+ allowedRoles: ['staff'],
144
+ })
145
+ // The folder's role gate is absolute, so the page loses its waiver.
146
+ expect(extractSelfGuardedRoutes(code)).toEqual([])
129
147
  })
130
148
 
131
149
  it('skips a row-owned page even when wrapped in a role-less folder', () => {
@@ -156,7 +174,8 @@ describe('generateMiddlewareFile: row-owned self-guarded pages', () => {
156
174
  },
157
175
  } as any)
158
176
 
159
- expect(code).not.toContain('"/order-details"')
177
+ expect(extractProtectedRoutes(code)).toEqual({})
178
+ expect(extractSelfGuardedRoutes(code)).toEqual(['/order-details'])
160
179
  })
161
180
 
162
181
  it('treats `rowOwnerColumn` as the sole signal — pages without it stay protected', () => {
@@ -176,6 +195,10 @@ describe('generateMiddlewareFile: row-owned self-guarded pages', () => {
176
195
  },
177
196
  } as any)
178
197
 
179
- expect(code).toContain('"/something"')
198
+ expect(extractProtectedRoutes(code)['/something']).toEqual({
199
+ requiresAuth: true,
200
+ allowedRoles: [],
201
+ })
202
+ expect(extractSelfGuardedRoutes(code)).toEqual([])
180
203
  })
181
204
  })
@@ -44,9 +44,11 @@ describe('payment-charge-user handler serialization survives minification-style
44
44
  expect(fnStart).toBeGreaterThan(-1)
45
45
  // The next function declaration marks the end of toStripeMinorUnits's
46
46
  // body (functions are concatenated back-to-back by generateHandler()).
47
- const nextFnStart = handlerSource.indexOf('function chargeWithStripe', fnStart)
48
- expect(nextFnStart).toBeGreaterThan(fnStart)
49
- const body = handlerSource.slice(fnStart, nextFnStart)
47
+ // chargeWithStripe is `async function …` — the slice must stop BEFORE the
48
+ // `async` keyword or the extracted body ends with a dangling `async`.
49
+ const nextFn = /(?:async\s+)?function chargeWithStripe/.exec(handlerSource.slice(fnStart))
50
+ expect(nextFn).not.toBeNull()
51
+ const body = handlerSource.slice(fnStart, fnStart + nextFn!.index)
50
52
  expect(body).toContain('JPY')
51
53
  expect(body).toContain('BHD')
52
54
  })
@@ -62,8 +64,8 @@ describe('payment-charge-user handler serialization survives minification-style
62
64
  describe('toStripeMinorUnits — extracted from the serialized handler source', () => {
63
65
  const toStripeMinorUnits = (() => {
64
66
  const fnStart = handlerSource.indexOf('function toStripeMinorUnits')
65
- const nextFnStart = handlerSource.indexOf('function chargeWithStripe', fnStart)
66
- const body = handlerSource.slice(fnStart, nextFnStart)
67
+ const nextFn = /(?:async\s+)?function chargeWithStripe/.exec(handlerSource.slice(fnStart))
68
+ const body = handlerSource.slice(fnStart, fnStart + (nextFn as RegExpExecArray).index)
67
69
  return new Function(body + '\nreturn toStripeMinorUnits;')() as (
68
70
  major: unknown,
69
71
  currency: string
@@ -51,10 +51,12 @@ describe('payment-charge-user emits __redirectUrl alongside __terminal', () => {
51
51
  // reset-isPlacingOrder) are NOT executed before the buyer
52
52
  // leaves for Stripe — the cart should only clear on the
53
53
  // payment-success return URL.
54
- const stripeBlock = handlerSource.slice(
55
- handlerSource.indexOf('checkoutUrl: checkoutUrl'),
56
- handlerSource.indexOf('checkoutUrl: checkoutUrl') + 300
57
- )
54
+ // The source uses object shorthand (`{ checkoutUrl, ... }`), so anchor on
55
+ // the explicit __redirectUrl entry and assert __terminal sits in the same
56
+ // return object.
57
+ const anchor = handlerSource.indexOf('__redirectUrl: checkoutUrl')
58
+ expect(anchor).toBeGreaterThan(-1)
59
+ const stripeBlock = handlerSource.slice(Math.max(0, anchor - 300), anchor + 300)
58
60
  expect(stripeBlock).toContain('__terminal: true')
59
61
  expect(stripeBlock).toContain('__redirectUrl: checkoutUrl')
60
62
  })
@@ -107,6 +107,88 @@ describe('parameterizeWorkflowRawSqlInterpolations', () => {
107
107
  })
108
108
  })
109
109
 
110
+ describe('CASE/WHEN/THEN/ELSE and TRIM(... FROM ...) value positions', () => {
111
+ it('binds tokens after WHEN as positional parameters', () => {
112
+ const res = parameterizeRawSqlInterpolations(
113
+ "SELECT * FROM events WHERE (CASE WHEN {{ dateRange }} = 'today' THEN start_time < CURRENT_DATE WHEN {{ dateRange }} = '7d' THEN start_time < NOW() ELSE TRUE END)",
114
+ [{ name: 'dateRange', value: ref('get-range', 'value') }]
115
+ )
116
+ expect(res.residuals).toEqual([])
117
+ expect(res.query).toContain('CASE WHEN $1 =')
118
+ expect(res.query).toContain('WHEN $2 =')
119
+ expect(res.params).toHaveLength(2)
120
+ })
121
+
122
+ it('binds the operand of TRIM(BOTH FROM {{ token }})', () => {
123
+ const value = ref('get-status', 'value')
124
+ const res = parameterizeRawSqlInterpolations(
125
+ "SELECT * FROM t WHERE TRIM(BOTH FROM {{ status }}) IN ('', 'All')",
126
+ [{ name: 'status', value }]
127
+ )
128
+ expect(res.query).toBe("SELECT * FROM t WHERE TRIM(BOTH FROM $1) IN ('', 'All')")
129
+ expect(res.params).toEqual([value])
130
+ expect(res.residuals).toEqual([])
131
+ })
132
+
133
+ it('binds TRIM(LEADING/TRAILING ... FROM {{ token }}) variants', () => {
134
+ const value = ref('n', 'v')
135
+ for (const sql of [
136
+ "SELECT TRIM(LEADING '0' FROM {{ v }})",
137
+ 'SELECT TRIM(TRAILING FROM {{ v }})',
138
+ 'SELECT TRIM(FROM {{ v }})',
139
+ ]) {
140
+ const res = parameterizeRawSqlInterpolations(sql, [{ name: 'v', value }])
141
+ expect(res.residuals).toEqual([])
142
+ expect(res.params).toEqual([value])
143
+ }
144
+ })
145
+
146
+ it('still refuses a plain FROM {{ table }} identifier position', () => {
147
+ const res = parameterizeRawSqlInterpolations('SELECT * FROM {{ table }}', [
148
+ { name: 'table', value: ref('n', 't') },
149
+ ])
150
+ expect(res.residuals).toEqual(['{{ table }}'])
151
+ })
152
+
153
+ it('parameterizes the full alerts-filter fixture query with zero residuals', () => {
154
+ // Verbatim from examples/uidl-samples/project.json node `query-alerts` — the
155
+ // query this guard refused (8 residuals) before CASE/WHEN and TRIM(... FROM ...)
156
+ // were recognised as value positions.
157
+ const query =
158
+ "SELECT a.id, a.vehicle_id, a.driver_id, a.type_field, a.severity, a.occurred_at, a.resolved FROM alerts a WHERE (TRIM(BOTH FROM {{statusFilterSegment}}) IN ('', 'All') OR (CASE WHEN {{statusFilterSegment}} = 'Active' THEN a.resolved = false WHEN {{statusFilterSegment}} = 'Resolved' THEN a.resolved = true WHEN {{statusFilterSegment}} = 'Acknowledged' THEN EXISTS (SELECT 1 FROM alert_actions aa WHERE aa.alert_id = a.id AND aa.action_field = 'Acknowledged') ELSE false END)) AND ({{severityFilterSelect}} = 'All' OR a.severity = {{severityFilterSelect}}) AND (TRIM(BOTH FROM {{searchAlertsInput}}) = '' OR a.type_field ILIKE '%' || {{searchAlertsInput}} || '%') AND ({{timeRangeSelect}} = 'All Time' OR a.occurred_at >= CASE WHEN {{timeRangeSelect}} = 'Last 24h' THEN NOW() - INTERVAL '24 hours' WHEN {{timeRangeSelect}} = 'Last 7d' THEN NOW() - INTERVAL '7 days' WHEN {{timeRangeSelect}} = 'Last 30d' THEN NOW() - INTERVAL '30 days' ELSE a.occurred_at END) ORDER BY a.occurred_at DESC"
159
+ const status = ref('get-status', 'value')
160
+ const severity = ref('get-severity', 'value')
161
+ const search = ref('get-search', 'value')
162
+ const time = ref('get-time', 'value')
163
+ const res = parameterizeRawSqlInterpolations(query, [
164
+ { name: 'statusFilterSegment', value: status },
165
+ { name: 'severityFilterSelect', value: severity },
166
+ { name: 'searchAlertsInput', value: search },
167
+ { name: 'timeRangeSelect', value: time },
168
+ ])
169
+ expect(res.residuals).toEqual([])
170
+ expect(res.query).not.toContain('{{')
171
+ expect(res.params).toEqual([
172
+ status,
173
+ status,
174
+ status,
175
+ status,
176
+ severity,
177
+ severity,
178
+ search,
179
+ search,
180
+ time,
181
+ time,
182
+ time,
183
+ time,
184
+ ])
185
+ expect(res.query).toContain('TRIM(BOTH FROM $1)')
186
+ expect(res.query).toContain('CASE WHEN $2 =')
187
+ expect(res.query).toContain("ILIKE '%' || $8 || '%'")
188
+ expect(res.query).toContain('$12')
189
+ })
190
+ })
191
+
110
192
  describe('parameterizeAllWorkflowRawSql', () => {
111
193
  it('walks both workflows and customNodes (id-keyed records)', () => {
112
194
  const value = ref('n', 'ids')
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The page-level `__createWorkflowHandlers(stateSetters, stateTypes, stateValuesRef)`
3
+ * maps are keyed BY THE UIDL STATE NAME — that is what the runtime looks up when
4
+ * a node config says `{"property": "class"}` or a `state-batch-update` carries
5
+ * `{"key": "class"}`. Both shapes appear verbatim in the run that broke.
6
+ *
7
+ * So the fix for the reserved-word crash has to be asymmetric:
8
+ * KEY → the UIDL name, ALWAYS (renaming it silently breaks every workflow
9
+ * binding written against it — `__stateNameMap` has no camel→original
10
+ * entry, so the runtime would just warn "no setter for class" and skip)
11
+ * VALUE → the sanitised React binding, because `const [class, …]` is a
12
+ * SyntaxError and the whole project build dies on it.
13
+ */
14
+
15
+ import generate from '@babel/generator'
16
+ import * as types from '@babel/types'
17
+ import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'
18
+
19
+ /** `const Page = (props) => { return null }` — the shape the plugin injects into. */
20
+ const buildComponentChunkContent = (): types.VariableDeclaration =>
21
+ types.variableDeclaration('const', [
22
+ types.variableDeclarator(
23
+ types.identifier('AddCharacter'),
24
+ types.arrowFunctionExpression(
25
+ [types.identifier('props')],
26
+ types.blockStatement([types.returnStatement(types.nullLiteral())])
27
+ )
28
+ ),
29
+ ])
30
+
31
+ const buildStructure = (stateNames: string[]): any => {
32
+ const triggerNodeId = 'trigger-1'
33
+ const workflow = {
34
+ id: 'wf-1',
35
+ name: 'Batch update from the form',
36
+ trigger: {
37
+ type: 'event-page-loaded',
38
+ nodeId: triggerNodeId,
39
+ scope: 'page',
40
+ config: { pageId: 'page-1' },
41
+ },
42
+ nodes: [
43
+ {
44
+ id: 'batch-1',
45
+ type: 'state-batch-update',
46
+ config: {
47
+ scope: 'local',
48
+ updates: stateNames.map((name) => ({ key: name, value: '' })),
49
+ },
50
+ stepNumber: 1,
51
+ label: 'Batch State Update',
52
+ },
53
+ ],
54
+ edges: [{ id: 'e', source: triggerNodeId, target: 'batch-1' }],
55
+ }
56
+ return {
57
+ uidl: {
58
+ name: 'AddCharacter',
59
+ outputOptions: { pageId: 'page-1', fileName: 'add-character' },
60
+ node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
61
+ stateDefinitions: Object.fromEntries(
62
+ stateNames.map((name) => [name, { type: 'string', defaultValue: '' }])
63
+ ),
64
+ },
65
+ chunks: [
66
+ {
67
+ type: 'chunk-type-ast',
68
+ name: 'jsx-component',
69
+ content: buildComponentChunkContent(),
70
+ },
71
+ ],
72
+ options: { workflows: { workflows: { 'wf-1': workflow }, customNodes: {} } },
73
+ dependencies: {},
74
+ }
75
+ }
76
+
77
+ const generateComponentCode = async (stateNames: string[]): Promise<string> => {
78
+ const plugin = createNextWorkflowPlugin({ isPage: true })
79
+ const structure = buildStructure(stateNames)
80
+ await plugin(structure as any)
81
+ const componentChunk = (structure.chunks as any[]).find((c: any) => c.name === 'jsx-component')
82
+ // The maps are injected into the component BODY, not the module chunk.
83
+ return generate(componentChunk.content).code
84
+ }
85
+
86
+ describe('reserved-word state in the workflow runtime maps', () => {
87
+ it('keys every map by the UIDL name so `config.property` still resolves', async () => {
88
+ const code = await generateComponentCode(['class', 'characterName'])
89
+ // `{ class: … }` is a legal object key even though `class` is reserved.
90
+ expect(code).toMatch(/class:\s*setClass/)
91
+ expect(code).toMatch(/class:\s*["']string["']/)
92
+ })
93
+
94
+ it('binds the VALUE to the sanitised identifier the state hook declared', async () => {
95
+ const code = await generateComponentCode(['class', 'characterName'])
96
+ // __wfStateRef.current = { class: class_ } — never `class: class`.
97
+ expect(code).toMatch(/class:\s*class_/)
98
+ expect(code).not.toMatch(/class:\s*class\b(?!_)/)
99
+ })
100
+
101
+ it('leaves ordinary state names byte-identical', async () => {
102
+ const code = await generateComponentCode(['class', 'characterName'])
103
+ expect(code).toMatch(/characterName:\s*characterName/)
104
+ expect(code).toMatch(/characterName:\s*setCharacterName/)
105
+ })
106
+
107
+ it('quotes a key that is not identifier syntax instead of emitting it bare', async () => {
108
+ const code = await generateComponentCode(['my key'])
109
+ expect(code).toContain('"my key"')
110
+ expect(code).not.toMatch(/[^"']my key:/)
111
+ })
112
+ })
@@ -0,0 +1,59 @@
1
+ import { stripEventHandlerAttrs } from '../src/workflow-project-plugin'
2
+
3
+ // UIDL `attrs` hold data, never functions — an `on*`-named attr (the alerts
4
+ // fixture shipped `onClick: ""` on the Resolved pill) becomes a non-function
5
+ // JSX event prop that React invokes on click: `func.apply is not a function`.
6
+ // runBefore strips them so behavior stays on `events` / workflow triggers.
7
+
8
+ const element = (name: string, attrs: Record<string, unknown>, children: unknown[] = []) => ({
9
+ type: 'element',
10
+ content: { elementType: 'button', name, attrs, children },
11
+ })
12
+
13
+ const projectUidl = (): any => ({
14
+ root: {
15
+ name: 'Root',
16
+ stateDefinitions: {},
17
+ node: element('Page', { id: { type: 'static', content: 'page' } }, [
18
+ element('ResolvedPill', {
19
+ id: { type: 'static', content: 'status-resolved-btn' },
20
+ 'data-value': { type: 'static', content: 'Resolved' },
21
+ onClick: { type: 'static', content: '' },
22
+ }),
23
+ ]),
24
+ },
25
+ components: {
26
+ Card: {
27
+ name: 'Card',
28
+ node: element('Card', {
29
+ onMouseEnter: { type: 'static', content: 'highlight()' },
30
+ // lowercase second letter — NOT an event handler pattern, must survive
31
+ onboarding: { type: 'static', content: 'step-1' },
32
+ }),
33
+ },
34
+ },
35
+ })
36
+
37
+ describe('stripEventHandlerAttrs', () => {
38
+ it('removes on[A-Z]* attrs from pages and components, keeps everything else', () => {
39
+ const uidl = projectUidl()
40
+ stripEventHandlerAttrs(uidl)
41
+
42
+ const pill = uidl.root.node.content.children[0].content
43
+ expect(pill.attrs.onClick).toBeUndefined()
44
+ expect(pill.attrs.id.content).toBe('status-resolved-btn')
45
+ expect(pill.attrs['data-value'].content).toBe('Resolved')
46
+
47
+ const card = uidl.components.Card.node.content
48
+ expect(card.attrs.onMouseEnter).toBeUndefined()
49
+ expect(card.attrs.onboarding.content).toBe('step-1')
50
+ })
51
+
52
+ it('tolerates projects without components or attrs', () => {
53
+ const uidl: any = {
54
+ root: { name: 'Root', node: element('Bare', undefined as any) },
55
+ }
56
+ delete uidl.root.node.content.attrs
57
+ expect(() => stripEventHandlerAttrs(uidl)).not.toThrow()
58
+ })
59
+ })