@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.
- package/__tests__/_helpers/run-generated-middleware.ts +130 -0
- package/__tests__/auth-env-secret-preservation.test.ts +35 -0
- package/__tests__/auth-nextauth-url.test.ts +28 -0
- package/__tests__/auth-session-cost.test.ts +614 -0
- package/__tests__/custom-js-params-env-invariance.test.ts +134 -0
- package/__tests__/data-node-not-awaited.test.ts +644 -0
- package/__tests__/element-trigger-static-attr-replace.test.ts +99 -0
- package/__tests__/element-visible-trigger-scoping.test.ts +296 -0
- package/__tests__/form-submitted-trigger-context.test.ts +100 -0
- package/__tests__/middleware-guest-order-details.test.ts +258 -0
- package/__tests__/middleware-row-owned-pages.test.ts +32 -9
- package/__tests__/payment-charge-user-handler-serialization.test.ts +7 -5
- package/__tests__/payment-redirect-and-order-notification.test.ts +6 -4
- package/__tests__/raw-sql-param-binding.test.ts +82 -0
- package/__tests__/reserved-word-state-binding.test.ts +112 -0
- package/__tests__/strip-event-handler-attrs.test.ts +59 -0
- package/__tests__/workflow-auth-guard.test.ts +277 -0
- package/dist/cjs/api-route-generator.d.ts +3 -3
- package/dist/cjs/api-route-generator.d.ts.map +1 -1
- package/dist/cjs/api-route-generator.js +136 -11
- package/dist/cjs/api-route-generator.js.map +1 -1
- package/dist/cjs/auth-generator.d.ts +67 -1
- package/dist/cjs/auth-generator.d.ts.map +1 -1
- package/dist/cjs/auth-generator.js +573 -31
- package/dist/cjs/auth-generator.js.map +1 -1
- package/dist/cjs/await-result.d.ts +31 -0
- package/dist/cjs/await-result.d.ts.map +1 -0
- package/dist/cjs/await-result.js +50 -0
- package/dist/cjs/await-result.js.map +1 -0
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +150 -1
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/nodes/account/account-get-current.d.ts.map +1 -1
- package/dist/cjs/nodes/account/account-get-current.js +44 -11
- package/dist/cjs/nodes/account/account-get-current.js.map +1 -1
- package/dist/cjs/nodes/account/account-login.d.ts.map +1 -1
- package/dist/cjs/nodes/account/account-login.js +10 -1
- package/dist/cjs/nodes/account/account-login.js.map +1 -1
- package/dist/cjs/raw-sql-param-binding.d.ts.map +1 -1
- package/dist/cjs/raw-sql-param-binding.js +32 -0
- package/dist/cjs/raw-sql-param-binding.js.map +1 -1
- package/dist/cjs/segment-splitter.d.ts.map +1 -1
- package/dist/cjs/segment-splitter.js +15 -7
- package/dist/cjs/segment-splitter.js.map +1 -1
- package/dist/cjs/trigger-generator.js +7 -1
- package/dist/cjs/trigger-generator.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/workflow-auth-generator.d.ts +26 -0
- package/dist/cjs/workflow-auth-generator.d.ts.map +1 -0
- package/dist/cjs/workflow-auth-generator.js +166 -0
- package/dist/cjs/workflow-auth-generator.js.map +1 -0
- package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-component-plugin.js +124 -17
- package/dist/cjs/workflow-component-plugin.js.map +1 -1
- package/dist/cjs/workflow-project-plugin.d.ts +2 -1
- package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-project-plugin.js +170 -20
- package/dist/cjs/workflow-project-plugin.js.map +1 -1
- package/dist/esm/api-route-generator.d.ts +3 -3
- package/dist/esm/api-route-generator.d.ts.map +1 -1
- package/dist/esm/api-route-generator.js +136 -11
- package/dist/esm/api-route-generator.js.map +1 -1
- package/dist/esm/auth-generator.d.ts +67 -1
- package/dist/esm/auth-generator.d.ts.map +1 -1
- package/dist/esm/auth-generator.js +568 -30
- package/dist/esm/auth-generator.js.map +1 -1
- package/dist/esm/await-result.d.ts +31 -0
- package/dist/esm/await-result.d.ts.map +1 -0
- package/dist/esm/await-result.js +45 -0
- package/dist/esm/await-result.js.map +1 -0
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +150 -1
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/nodes/account/account-get-current.d.ts.map +1 -1
- package/dist/esm/nodes/account/account-get-current.js +44 -11
- package/dist/esm/nodes/account/account-get-current.js.map +1 -1
- package/dist/esm/nodes/account/account-login.d.ts.map +1 -1
- package/dist/esm/nodes/account/account-login.js +10 -1
- package/dist/esm/nodes/account/account-login.js.map +1 -1
- package/dist/esm/raw-sql-param-binding.d.ts.map +1 -1
- package/dist/esm/raw-sql-param-binding.js +32 -0
- package/dist/esm/raw-sql-param-binding.js.map +1 -1
- package/dist/esm/segment-splitter.d.ts.map +1 -1
- package/dist/esm/segment-splitter.js +15 -7
- package/dist/esm/segment-splitter.js.map +1 -1
- package/dist/esm/trigger-generator.js +7 -1
- package/dist/esm/trigger-generator.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/workflow-auth-generator.d.ts +26 -0
- package/dist/esm/workflow-auth-generator.d.ts.map +1 -0
- package/dist/esm/workflow-auth-generator.js +161 -0
- package/dist/esm/workflow-auth-generator.js.map +1 -0
- package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-component-plugin.js +125 -18
- package/dist/esm/workflow-component-plugin.js.map +1 -1
- package/dist/esm/workflow-project-plugin.d.ts +2 -1
- package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-project-plugin.js +169 -20
- package/dist/esm/workflow-project-plugin.js.map +1 -1
- package/package.json +4 -4
- package/src/api-route-generator.ts +139 -11
- package/src/auth-generator.ts +600 -30
- package/src/await-result.ts +52 -0
- package/src/executor-generator.ts +150 -1
- package/src/nodes/account/account-get-current.ts +44 -9
- package/src/nodes/account/account-login.ts +12 -1
- package/src/raw-sql-param-binding.ts +33 -0
- package/src/segment-splitter.ts +15 -7
- package/src/trigger-generator.ts +7 -1
- package/src/workflow-auth-generator.ts +187 -0
- package/src/workflow-component-plugin.ts +146 -21
- 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
|
-
|
|
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)
|
|
78
|
-
|
|
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)
|
|
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)
|
|
128
|
-
|
|
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).
|
|
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)
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
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
|
|
66
|
-
const body = handlerSource.slice(fnStart,
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
})
|