@teleporthq/teleport-plugin-next-workflows 0.44.8 → 0.44.10

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 (172) hide show
  1. package/__tests__/_helpers/load-server-runtime.ts +6 -2
  2. package/__tests__/_helpers/run-account-delete-route.ts +140 -0
  3. package/__tests__/account-delete-current-password.test.ts +73 -0
  4. package/__tests__/account-delete-password-check.test.ts +113 -0
  5. package/__tests__/account-delete-visitor-details.test.ts +206 -0
  6. package/__tests__/ai-error-contract.test.ts +10 -0
  7. package/__tests__/auth-password-change-ends-sessions.test.ts +178 -0
  8. package/__tests__/continue-on-error.test.ts +27 -0
  9. package/__tests__/data-api-invalid-body.test.ts +110 -0
  10. package/__tests__/data-node-not-awaited.test.ts +7 -6
  11. package/__tests__/email-node-recipients.test.ts +288 -0
  12. package/__tests__/invoice-payment-line.test.ts +68 -0
  13. package/__tests__/invoice-tender.test.ts +116 -0
  14. package/__tests__/pending-work-after-reply.test.ts +294 -0
  15. package/__tests__/runtime-condition-operands.test.ts +86 -0
  16. package/__tests__/sent-email-log-module.test.ts +54 -0
  17. package/__tests__/webhook-delivery-lease.test.ts +68 -6
  18. package/dist/cjs/account-delete-route-generator.d.ts +7 -2
  19. package/dist/cjs/account-delete-route-generator.d.ts.map +1 -1
  20. package/dist/cjs/account-delete-route-generator.js +90 -43
  21. package/dist/cjs/account-delete-route-generator.js.map +1 -1
  22. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  23. package/dist/cjs/api-route-generator.js +12 -13
  24. package/dist/cjs/api-route-generator.js.map +1 -1
  25. package/dist/cjs/auth-generator.d.ts.map +1 -1
  26. package/dist/cjs/auth-generator.js +34 -1
  27. package/dist/cjs/auth-generator.js.map +1 -1
  28. package/dist/cjs/await-result.d.ts +13 -5
  29. package/dist/cjs/await-result.d.ts.map +1 -1
  30. package/dist/cjs/await-result.js +19 -8
  31. package/dist/cjs/await-result.js.map +1 -1
  32. package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
  33. package/dist/cjs/data-api-route-generator.js +9 -1
  34. package/dist/cjs/data-api-route-generator.js.map +1 -1
  35. package/dist/cjs/executor-generator.d.ts.map +1 -1
  36. package/dist/cjs/executor-generator.js +22 -7
  37. package/dist/cjs/executor-generator.js.map +1 -1
  38. package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
  39. package/dist/cjs/invoice/invoice-html-code.js +96 -5
  40. package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
  41. package/dist/cjs/nodes/account/account-delete-current.d.ts.map +1 -1
  42. package/dist/cjs/nodes/account/account-delete-current.js +4 -1
  43. package/dist/cjs/nodes/account/account-delete-current.js.map +1 -1
  44. package/dist/cjs/nodes/email/email-mailersend.d.ts.map +1 -1
  45. package/dist/cjs/nodes/email/email-mailersend.js +55 -48
  46. package/dist/cjs/nodes/email/email-mailersend.js.map +1 -1
  47. package/dist/cjs/nodes/email/email-mailgun.d.ts.map +1 -1
  48. package/dist/cjs/nodes/email/email-mailgun.js +44 -32
  49. package/dist/cjs/nodes/email/email-mailgun.js.map +1 -1
  50. package/dist/cjs/nodes/email/email-postmark.d.ts.map +1 -1
  51. package/dist/cjs/nodes/email/email-postmark.js +44 -31
  52. package/dist/cjs/nodes/email/email-postmark.js.map +1 -1
  53. package/dist/cjs/nodes/email/email-recipients.d.ts +28 -0
  54. package/dist/cjs/nodes/email/email-recipients.d.ts.map +1 -0
  55. package/dist/cjs/nodes/email/email-recipients.js +74 -0
  56. package/dist/cjs/nodes/email/email-recipients.js.map +1 -0
  57. package/dist/cjs/nodes/email/email-resend.d.ts.map +1 -1
  58. package/dist/cjs/nodes/email/email-resend.js +42 -28
  59. package/dist/cjs/nodes/email/email-resend.js.map +1 -1
  60. package/dist/cjs/nodes/email/email-sendgrid.d.ts.map +1 -1
  61. package/dist/cjs/nodes/email/email-sendgrid.js +56 -41
  62. package/dist/cjs/nodes/email/email-sendgrid.js.map +1 -1
  63. package/dist/cjs/sent-email-log/sent-email-log-code.d.ts.map +1 -1
  64. package/dist/cjs/sent-email-log/sent-email-log-code.js +17 -1
  65. package/dist/cjs/sent-email-log/sent-email-log-code.js.map +1 -1
  66. package/dist/cjs/server-runtime-code.d.ts.map +1 -1
  67. package/dist/cjs/server-runtime-code.js +21 -0
  68. package/dist/cjs/server-runtime-code.js.map +1 -1
  69. package/dist/cjs/transactional-email-code.d.ts +7 -0
  70. package/dist/cjs/transactional-email-code.d.ts.map +1 -1
  71. package/dist/cjs/transactional-email-code.js +5 -5
  72. package/dist/cjs/transactional-email-code.js.map +1 -1
  73. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  74. package/dist/cjs/vercel-functions.d.ts +34 -0
  75. package/dist/cjs/vercel-functions.d.ts.map +1 -0
  76. package/dist/cjs/vercel-functions.js +62 -0
  77. package/dist/cjs/vercel-functions.js.map +1 -0
  78. package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.d.ts +6 -0
  79. package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.d.ts.map +1 -1
  80. package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.js +26 -11
  81. package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.js.map +1 -1
  82. package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
  83. package/dist/cjs/workflow-project-plugin.js +3 -0
  84. package/dist/cjs/workflow-project-plugin.js.map +1 -1
  85. package/dist/esm/account-delete-route-generator.d.ts +7 -2
  86. package/dist/esm/account-delete-route-generator.d.ts.map +1 -1
  87. package/dist/esm/account-delete-route-generator.js +90 -43
  88. package/dist/esm/account-delete-route-generator.js.map +1 -1
  89. package/dist/esm/api-route-generator.d.ts.map +1 -1
  90. package/dist/esm/api-route-generator.js +12 -13
  91. package/dist/esm/api-route-generator.js.map +1 -1
  92. package/dist/esm/auth-generator.d.ts.map +1 -1
  93. package/dist/esm/auth-generator.js +34 -1
  94. package/dist/esm/auth-generator.js.map +1 -1
  95. package/dist/esm/await-result.d.ts +13 -5
  96. package/dist/esm/await-result.d.ts.map +1 -1
  97. package/dist/esm/await-result.js +18 -7
  98. package/dist/esm/await-result.js.map +1 -1
  99. package/dist/esm/data-api-route-generator.d.ts.map +1 -1
  100. package/dist/esm/data-api-route-generator.js +9 -1
  101. package/dist/esm/data-api-route-generator.js.map +1 -1
  102. package/dist/esm/executor-generator.d.ts.map +1 -1
  103. package/dist/esm/executor-generator.js +22 -7
  104. package/dist/esm/executor-generator.js.map +1 -1
  105. package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
  106. package/dist/esm/invoice/invoice-html-code.js +96 -5
  107. package/dist/esm/invoice/invoice-html-code.js.map +1 -1
  108. package/dist/esm/nodes/account/account-delete-current.d.ts.map +1 -1
  109. package/dist/esm/nodes/account/account-delete-current.js +4 -1
  110. package/dist/esm/nodes/account/account-delete-current.js.map +1 -1
  111. package/dist/esm/nodes/email/email-mailersend.d.ts.map +1 -1
  112. package/dist/esm/nodes/email/email-mailersend.js +55 -48
  113. package/dist/esm/nodes/email/email-mailersend.js.map +1 -1
  114. package/dist/esm/nodes/email/email-mailgun.d.ts.map +1 -1
  115. package/dist/esm/nodes/email/email-mailgun.js +44 -32
  116. package/dist/esm/nodes/email/email-mailgun.js.map +1 -1
  117. package/dist/esm/nodes/email/email-postmark.d.ts.map +1 -1
  118. package/dist/esm/nodes/email/email-postmark.js +44 -31
  119. package/dist/esm/nodes/email/email-postmark.js.map +1 -1
  120. package/dist/esm/nodes/email/email-recipients.d.ts +28 -0
  121. package/dist/esm/nodes/email/email-recipients.d.ts.map +1 -0
  122. package/dist/esm/nodes/email/email-recipients.js +71 -0
  123. package/dist/esm/nodes/email/email-recipients.js.map +1 -0
  124. package/dist/esm/nodes/email/email-resend.d.ts.map +1 -1
  125. package/dist/esm/nodes/email/email-resend.js +42 -28
  126. package/dist/esm/nodes/email/email-resend.js.map +1 -1
  127. package/dist/esm/nodes/email/email-sendgrid.d.ts.map +1 -1
  128. package/dist/esm/nodes/email/email-sendgrid.js +56 -41
  129. package/dist/esm/nodes/email/email-sendgrid.js.map +1 -1
  130. package/dist/esm/sent-email-log/sent-email-log-code.d.ts.map +1 -1
  131. package/dist/esm/sent-email-log/sent-email-log-code.js +17 -1
  132. package/dist/esm/sent-email-log/sent-email-log-code.js.map +1 -1
  133. package/dist/esm/server-runtime-code.d.ts.map +1 -1
  134. package/dist/esm/server-runtime-code.js +21 -0
  135. package/dist/esm/server-runtime-code.js.map +1 -1
  136. package/dist/esm/transactional-email-code.d.ts +7 -0
  137. package/dist/esm/transactional-email-code.d.ts.map +1 -1
  138. package/dist/esm/transactional-email-code.js +5 -5
  139. package/dist/esm/transactional-email-code.js.map +1 -1
  140. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  141. package/dist/esm/vercel-functions.d.ts +34 -0
  142. package/dist/esm/vercel-functions.d.ts.map +1 -0
  143. package/dist/esm/vercel-functions.js +57 -0
  144. package/dist/esm/vercel-functions.js.map +1 -0
  145. package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.d.ts +6 -0
  146. package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.d.ts.map +1 -1
  147. package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.js +26 -11
  148. package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.js.map +1 -1
  149. package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
  150. package/dist/esm/workflow-project-plugin.js +3 -0
  151. package/dist/esm/workflow-project-plugin.js.map +1 -1
  152. package/package.json +2 -2
  153. package/src/account-delete-route-generator.ts +97 -45
  154. package/src/api-route-generator.ts +12 -13
  155. package/src/auth-generator.ts +34 -1
  156. package/src/await-result.ts +19 -7
  157. package/src/data-api-route-generator.ts +9 -1
  158. package/src/executor-generator.ts +22 -7
  159. package/src/invoice/invoice-html-code.ts +96 -5
  160. package/src/nodes/account/account-delete-current.ts +4 -1
  161. package/src/nodes/email/email-mailersend.ts +72 -55
  162. package/src/nodes/email/email-mailgun.ts +51 -36
  163. package/src/nodes/email/email-postmark.ts +51 -35
  164. package/src/nodes/email/email-recipients.ts +86 -0
  165. package/src/nodes/email/email-resend.ts +49 -31
  166. package/src/nodes/email/email-sendgrid.ts +63 -44
  167. package/src/sent-email-log/sent-email-log-code.ts +17 -1
  168. package/src/server-runtime-code.ts +22 -0
  169. package/src/transactional-email-code.ts +12 -5
  170. package/src/vercel-functions.ts +67 -0
  171. package/src/webhook-delivery-lease/webhook-delivery-lease-code.ts +26 -11
  172. package/src/workflow-project-plugin.ts +3 -0
@@ -0,0 +1,178 @@
1
+ import * as crypto from 'crypto'
2
+ import { UIDLAuthentication } from '@teleporthq/teleport-types'
3
+ import { generateAuthOptionsFile, USER_REFRESH_INTERVAL_MS } from '../src/auth-generator'
4
+
5
+ /**
6
+ * A password change, a password reset included, signs the account out of every
7
+ * session opened before it. The session token carries a short fingerprint of
8
+ * the stored password hash (never the hash); the periodic refresh compares it
9
+ * with the account's current hash and ends a session whose fingerprint no
10
+ * longer matches. Sign-in with the new password works as before. Run on the
11
+ * EMITTED auth options.
12
+ */
13
+
14
+ const EMAIL = 'ada@example.com'
15
+ const FINGERPRINT = '__passwordFingerprint'
16
+
17
+ const auth = {
18
+ enabled: true,
19
+ dataSourceId: 'ds-1',
20
+ dataSourceType: 'postgresql',
21
+ passwordAuthEnabled: true,
22
+ providers: [],
23
+ roles: ['user'],
24
+ tables: {
25
+ users: [
26
+ { name: 'id', type: 'UUID', nullable: false, isPrimaryKey: true },
27
+ { name: 'name', type: 'VARCHAR(255)', nullable: true },
28
+ { name: 'email', type: 'VARCHAR(255)', nullable: true },
29
+ { name: 'password', type: 'TEXT', nullable: true },
30
+ { name: 'role', type: 'VARCHAR(255)', nullable: false },
31
+ ],
32
+ },
33
+ pageProtection: {},
34
+ folderProtection: {},
35
+ authPages: {},
36
+ callbackBaseUrl: '/api/auth/callback',
37
+ envKeys: {},
38
+ customUserProperties: [],
39
+ } as unknown as UIDLAuthentication
40
+
41
+ // The stand-in for utils/auth/hash-password.js: a "hash" the test can name.
42
+ const hashOf = (password: string) => `hash:${password}`
43
+
44
+ const bootAuthOptions = () => {
45
+ const store: { row: Record<string, unknown> | null; failReads: boolean } = {
46
+ row: { id: 'u-1', email: EMAIL, name: 'Ada', role: 'user', password: hashOf('old secret') },
47
+ failReads: false,
48
+ }
49
+ class FakeClient {
50
+ public async connect(): Promise<void> {
51
+ return undefined
52
+ }
53
+ public async query(): Promise<{ rows: Array<Record<string, unknown>> }> {
54
+ if (store.failReads) {
55
+ throw new Error('connection refused')
56
+ }
57
+ return { rows: store.row ? [{ ...store.row }] : [] }
58
+ }
59
+ public async end(): Promise<void> {
60
+ return undefined
61
+ }
62
+ }
63
+ const fakeRequire = (name: string): unknown => {
64
+ if (name === 'pg') {
65
+ return { Client: FakeClient }
66
+ }
67
+ if (name === 'crypto') {
68
+ return crypto
69
+ }
70
+ if (name === './hash-password') {
71
+ return { verifyPassword: (password: string, hash: string) => hash === hashOf(password) }
72
+ }
73
+ if (name === './db-health') {
74
+ return { classifyAuthInfrastructureError: () => 'unknown' }
75
+ }
76
+ const provider = (config: unknown) => config
77
+ ;(provider as any).default = provider
78
+ return provider
79
+ }
80
+ const moduleObject: { exports: any } = { exports: {} }
81
+ // tslint:disable-next-line:function-constructor
82
+ new Function('require', 'module', 'exports', 'process', generateAuthOptionsFile(auth, null))(
83
+ fakeRequire,
84
+ moduleObject,
85
+ moduleObject.exports,
86
+ {
87
+ env: { TELEPORT_DB_CONNECTION_STRING: 'postgres://stub', NEXTAUTH_SECRET: 'secret-1' },
88
+ }
89
+ )
90
+ const authOptions = moduleObject.exports
91
+ const authorize = (password: string) =>
92
+ authOptions.providers[0].authorize({ email: EMAIL, password })
93
+ const signIn = async (password: string) =>
94
+ authOptions.callbacks.jwt({ token: {}, user: await authorize(password) })
95
+ // The refresh /api/auth/session runs once the token's interval has passed.
96
+ const refresh = (token: Record<string, unknown>) =>
97
+ authOptions.callbacks.jwt({
98
+ token: { ...token, __userRefreshedAt: Date.now() - USER_REFRESH_INTERVAL_MS - 1 },
99
+ })
100
+ return { authOptions, store, authorize, signIn, refresh }
101
+ }
102
+
103
+ describe('a password change ends the sessions opened before it', () => {
104
+ it('keeps a session whose password has not changed', async () => {
105
+ const site = bootAuthOptions()
106
+ const token = await site.signIn('old secret')
107
+
108
+ expect(typeof token[FINGERPRINT]).toBe('string')
109
+ const refreshed = await site.refresh(token)
110
+ expect(refreshed.email).toBe(EMAIL)
111
+ expect(refreshed[FINGERPRINT]).toBe(token[FINGERPRINT])
112
+ })
113
+
114
+ it('refuses the older session at its next refresh once the hash changed', async () => {
115
+ const site = bootAuthOptions()
116
+ const before = await site.signIn('old secret')
117
+
118
+ site.store.row!.password = hashOf('new secret')
119
+
120
+ await expect(site.refresh(before)).rejects.toThrow()
121
+ // A profile save refreshes at once, and is refused the same way.
122
+ await expect(
123
+ site.authOptions.callbacks.jwt({ token: { ...before }, trigger: 'update' })
124
+ ).rejects.toThrow()
125
+ })
126
+
127
+ it('signs in with the new password, and keeps that session', async () => {
128
+ const site = bootAuthOptions()
129
+ site.store.row!.password = hashOf('new secret')
130
+
131
+ await expect(site.authorize('old secret')).rejects.toThrow('InvalidCredentials')
132
+ const after = await site.signIn('new secret')
133
+ const refreshed = await site.refresh(after)
134
+ expect(refreshed.email).toBe(EMAIL)
135
+ })
136
+
137
+ it('fingerprints a session that has none at its first refresh, then holds it to that', async () => {
138
+ const site = bootAuthOptions()
139
+ const legacy = { id: 'u-1', email: EMAIL, name: 'Ada', role: 'user' }
140
+
141
+ const adopted = await site.refresh(legacy)
142
+ expect(typeof adopted[FINGERPRINT]).toBe('string')
143
+
144
+ site.store.row!.password = hashOf('new secret')
145
+ await expect(site.refresh(adopted)).rejects.toThrow()
146
+ })
147
+
148
+ it('never carries the hash, and never shows the fingerprint on session.user', async () => {
149
+ const site = bootAuthOptions()
150
+ const token = await site.signIn('old secret')
151
+
152
+ expect(JSON.stringify(token)).not.toContain(hashOf('old secret'))
153
+ const session = await site.authOptions.callbacks.session({
154
+ session: { user: {}, expires: '' },
155
+ token,
156
+ })
157
+ expect(session.user.email).toBe(EMAIL)
158
+ expect(FINGERPRINT in session.user).toBe(false)
159
+ })
160
+
161
+ it('keeps the session when the database cannot be read', async () => {
162
+ const site = bootAuthOptions()
163
+ const token = await site.signIn('old secret')
164
+
165
+ site.store.failReads = true
166
+ const kept = await site.refresh(token)
167
+ expect(kept.email).toBe(EMAIL)
168
+ })
169
+
170
+ it('reads nothing within the refresh interval', async () => {
171
+ const site = bootAuthOptions()
172
+ const token = await site.refresh(await site.signIn('old secret'))
173
+
174
+ site.store.row!.password = hashOf('new secret')
175
+ const within = await site.authOptions.callbacks.jwt({ token: { ...token } })
176
+ expect(within.email).toBe(EMAIL)
177
+ })
178
+ })
@@ -102,6 +102,33 @@ describe('executeNodes — a refused result on a continueOnError node', () => {
102
102
  })
103
103
  })
104
104
 
105
+ describe('executeNodes — an email node that reached some of its recipients', () => {
106
+ it('reports success with the refused addresses and the chain goes on, flag or not', async () => {
107
+ const utils = loadSharedRuntime()
108
+ const partlyDelivered = {
109
+ id: 're-1',
110
+ success: true,
111
+ error: 'refused for blocked@lumen.test (only the owner)',
112
+ deliveries: [
113
+ { to: 'jane@lumen.test', success: true, id: 're-1' },
114
+ { to: 'blocked@lumen.test', success: false, error: 'only the owner' },
115
+ ],
116
+ }
117
+ const ran: string[] = []
118
+ const handlers = {
119
+ 'email-resend': async () => partlyDelivered,
120
+ 'toast-show': async () => {
121
+ ran.push('toast')
122
+ return { shown: true }
123
+ },
124
+ }
125
+ const context: Record<string, unknown> = {}
126
+ await utils.executeNodes([sendNode(), afterNode], edges, context, handlers, {}, null, 'x4')
127
+ expect(context.send).toEqual(partlyDelivered)
128
+ expect(ran).toEqual(['toast'])
129
+ })
130
+ })
131
+
105
132
  describe('generated server segment route', () => {
106
133
  const segment: WorkflowSegment = {
107
134
  id: 'server-1',
@@ -0,0 +1,110 @@
1
+ /* tslint:disable:function-constructor */
2
+ import { generateDataAPIRoute } from '../src/data-api-route-generator'
3
+
4
+ /**
5
+ * The generated `/api/data/<ds>/<op>` route answers a request whose body is
6
+ * empty or is not a JSON object with 400, without opening a database
7
+ * connection; it used to fail with a 500 parsing it.
8
+ */
9
+
10
+ const SECRET = 'server-secret'
11
+
12
+ interface RouteResult {
13
+ status: number
14
+ body: any
15
+ connections: number
16
+ queries: string[]
17
+ }
18
+
19
+ function bootRoute() {
20
+ const queries: string[] = []
21
+ let connections = 0
22
+ class FakeClient {
23
+ public async connect(): Promise<void> {
24
+ connections++
25
+ }
26
+ public async end(): Promise<void> {
27
+ return
28
+ }
29
+ public async query(sql: string): Promise<{ rows: any[]; rowCount: number }> {
30
+ queries.push(sql)
31
+ if (/^SELECT COUNT/i.test(sql)) {
32
+ return { rows: [{ count: '0' }], rowCount: 1 }
33
+ }
34
+ return { rows: [], rowCount: 0 }
35
+ }
36
+ }
37
+ const moduleObj: { exports: any } = { exports: {} }
38
+ const fakeRequire = (name: string): any => {
39
+ if (name === 'pg') {
40
+ return { Client: FakeClient }
41
+ }
42
+ if (name === 'next-auth/jwt') {
43
+ return { getToken: async () => null }
44
+ }
45
+ throw new Error('unexpected require: ' + name)
46
+ }
47
+ new Function('require', 'module', 'exports', 'process', 'console', generateDataAPIRoute())(
48
+ fakeRequire,
49
+ moduleObj,
50
+ moduleObj.exports,
51
+ { env: { NEXTAUTH_SECRET: SECRET, TELEPORT_DB_CONNECTION_STRING: 'postgresql://x' } },
52
+ { error: () => undefined, warn: () => undefined, log: () => undefined }
53
+ )
54
+ const handler = moduleObj.exports
55
+ return async (body: unknown): Promise<RouteResult> => {
56
+ queries.length = 0
57
+ connections = 0
58
+ const result: RouteResult = { status: 0, body: null, connections: 0, queries: [] }
59
+ const res = {
60
+ status(statusCode: number) {
61
+ result.status = statusCode
62
+ return this
63
+ },
64
+ json(payload: any) {
65
+ result.body = payload
66
+ return this
67
+ },
68
+ }
69
+ await handler(
70
+ {
71
+ method: 'POST',
72
+ query: { params: ['ds-1', 'select'] },
73
+ headers: { 'x-internal-data-secret': SECRET },
74
+ body,
75
+ },
76
+ res
77
+ )
78
+ result.connections = connections
79
+ result.queries = queries.slice()
80
+ return result
81
+ }
82
+ }
83
+
84
+ describe('data API — a request body that is not a JSON object', () => {
85
+ const run = bootRoute()
86
+
87
+ it.each([
88
+ ['an empty body', ''],
89
+ ['text that is not JSON', 'tableName=products'],
90
+ ['truncated JSON', '{"tableName": "prod'],
91
+ ['no body', undefined],
92
+ ['a JSON null', 'null'],
93
+ ['a JSON array', '[{"tableName":"products"}]'],
94
+ ['a parsed array', [{ tableName: 'products' }]],
95
+ ])('answers 400 to %s, without touching the database', async (_label, body) => {
96
+ const result = await run(body)
97
+
98
+ expect(result.status).toBe(400)
99
+ expect(result.body).toEqual({ error: 'Invalid request body. Expected a JSON object.' })
100
+ expect(result.connections).toBe(0)
101
+ expect(result.queries).toEqual([])
102
+ })
103
+
104
+ it('still reads a JSON object sent as a string', async () => {
105
+ const result = await run(JSON.stringify({ tableName: 'products' }))
106
+
107
+ expect(result.status).toBe(200)
108
+ expect(result.queries.some((sql) => /FROM "?products"?/i.test(sql))).toBe(true)
109
+ })
110
+ })
@@ -18,8 +18,9 @@ import type { WorkflowSegment } from '../src/types'
18
18
  // 1. the workflow does not wait for the query;
19
19
  // 2. the node's context entry is `null` — never a partial or stale value;
20
20
  // 3. a failure cannot abort the workflow (it is logged, not thrown);
21
- // 4. the server route STILL settles the query before it responds, because a
22
- // serverless function may be frozen the instant it replies;
21
+ // 4. the server route STILL lands the query, because a serverless function
22
+ // may be frozen the instant it replies: on Vercel through waitUntil, after
23
+ // the reply; elsewhere before it (pending-work-after-reply.test.ts);
23
24
  // 5. the client dispatches an all-fire-and-forget SEGMENT without awaiting the
24
25
  // round trip, which is where the latency the visitor feels actually goes.
25
26
 
@@ -448,9 +449,9 @@ describe('generated server segment route', () => {
448
449
  expect(route).toContain('context[node.id] = null;')
449
450
  })
450
451
 
451
- it('settles in-flight queries BEFORE responding (the platform can freeze us)', () => {
452
- expect(route).toContain('await utils.settlePendingNodePromises(context);')
453
- const settleAt = route.indexOf('await utils.settlePendingNodePromises(context);')
452
+ it('hands in-flight queries off (or settles them) BEFORE responding (the platform can freeze us)', () => {
453
+ expect(route).toContain('await utils.handOffPendingNodePromises(context);')
454
+ const settleAt = route.indexOf('await utils.handOffPendingNodePromises(context);')
454
455
  const respondAt = route.indexOf(
455
456
  'res.status(200).json({ success: true, results: utils.segmentReply(SEGMENT_CONFIG, context, __incomingSnapshot) });'
456
457
  )
@@ -464,7 +465,7 @@ describe('generated server segment route', () => {
464
465
 
465
466
  it('drains even when the segment throws', () => {
466
467
  expect(route).toContain(
467
- 'if (__wfContext) { await utils.settlePendingNodePromises(__wfContext); }'
468
+ 'if (__wfContext) { await utils.handOffPendingNodePromises(__wfContext); }'
468
469
  )
469
470
  })
470
471
 
@@ -0,0 +1,288 @@
1
+ import { loadHandler } from './_helpers/load-handler'
2
+
3
+ /**
4
+ * A workflow email node given several recipients sends one provider call per
5
+ * address: a refused address (a sender that only delivers to the account
6
+ * owner's address, an account still pending approval) no longer blocks the
7
+ * others, and the result says who got the email. One recipient behaves as it
8
+ * always did. Executed against fake provider SDKs that refuse an address the
9
+ * way each real provider does.
10
+ */
11
+
12
+ const REASON = 'You can only send testing emails to your own email address'
13
+ const BUYER = 'buyer@lumen.test'
14
+ const ANA = 'ana@lumen.test'
15
+ const BLOCKED = 'blocked@lumen.test'
16
+ const BLOCKED_TOO = 'blocked-too@lumen.test'
17
+
18
+ const addressesOf = (to: unknown): string[] => {
19
+ const list = Array.isArray(to) ? to : String(to).split(',')
20
+ return list.map((entry: any) => (typeof entry === 'object' ? entry.email : String(entry).trim()))
21
+ }
22
+
23
+ const refuses = (to: unknown): boolean =>
24
+ addressesOf(to).some((address) => address.toLowerCase().startsWith('blocked'))
25
+
26
+ interface ProviderCall {
27
+ to: string[]
28
+ cc: string[]
29
+ }
30
+
31
+ /** Fake provider SDKs that record every call's recipients and refuse the blocked ones. */
32
+ const fakeSdks = (calls: ProviderCall[]): Record<string, unknown> => {
33
+ const record = (to: unknown, cc: unknown) => {
34
+ calls.push({ to: addressesOf(to), cc: cc ? addressesOf(cc) : [] })
35
+ return calls.length
36
+ }
37
+ const sendgrid = {
38
+ setApiKey: (): void => undefined,
39
+ send: async (msg: { to: unknown; cc?: unknown }) => {
40
+ const n = record(msg.to, msg.cc)
41
+ if (refuses(msg.to)) {
42
+ const err: any = new Error('Bad Request')
43
+ err.response = { body: { errors: [{ message: REASON }] } }
44
+ throw err
45
+ }
46
+ return [{ statusCode: 202, headers: { 'x-message-id': 'sg-' + n } }]
47
+ },
48
+ }
49
+ const chainable = () => {
50
+ const params: Record<string, unknown> = {}
51
+ for (const key of ['From', 'To', 'Subject', 'Html', 'ReplyTo', 'Cc', 'Bcc', 'Attachments']) {
52
+ params['set' + key] = (value: unknown) => {
53
+ params[key.toLowerCase()] = value
54
+ return params
55
+ }
56
+ }
57
+ return params
58
+ }
59
+ return {
60
+ postmark: {
61
+ ServerClient: function ServerClient() {
62
+ return {
63
+ sendEmail: async (payload: { To: unknown; Cc?: unknown }) => {
64
+ const n = record(payload.To, payload.Cc)
65
+ if (refuses(payload.To)) {
66
+ throw new Error(REASON)
67
+ }
68
+ return { MessageID: 'pm-' + n }
69
+ },
70
+ }
71
+ },
72
+ },
73
+ resend: {
74
+ Resend: function Resend() {
75
+ return {
76
+ emails: {
77
+ send: async (payload: {
78
+ to: unknown
79
+ cc?: unknown
80
+ }): Promise<{
81
+ data: { id: string } | null
82
+ error: { name: string; message: string } | null
83
+ }> => {
84
+ const n = record(payload.to, payload.cc)
85
+ if (refuses(payload.to)) {
86
+ return { data: null, error: { name: 'validation_error', message: REASON } }
87
+ }
88
+ return { data: { id: 're-' + n }, error: null }
89
+ },
90
+ },
91
+ }
92
+ },
93
+ },
94
+ '@sendgrid/mail': {
95
+ ...sendgrid,
96
+ MailService: function MailService() {
97
+ return sendgrid
98
+ },
99
+ },
100
+ 'mailgun.js': function Mailgun() {
101
+ return {
102
+ client: () => ({
103
+ messages: {
104
+ create: async (_domain: string, payload: { to: unknown; cc?: unknown }) => {
105
+ const n = record(payload.to, payload.cc)
106
+ if (refuses(payload.to)) {
107
+ throw new Error(REASON)
108
+ }
109
+ return { id: 'mg-' + n }
110
+ },
111
+ },
112
+ }),
113
+ }
114
+ },
115
+ 'form-data': function FormData() {
116
+ return {}
117
+ },
118
+ mailersend: {
119
+ MailerSend: function MailerSend() {
120
+ return {
121
+ email: {
122
+ send: async (params: { to: unknown; cc?: unknown }) => {
123
+ const n = record(params.to, params.cc)
124
+ if (refuses(params.to)) {
125
+ return { statusCode: 422, body: { message: REASON } }
126
+ }
127
+ return { statusCode: 202, headers: { 'x-message-id': 'ms-' + n } }
128
+ },
129
+ },
130
+ }
131
+ },
132
+ Sender: function Sender(email: string, name: string) {
133
+ return { email, name }
134
+ },
135
+ Recipient: function Recipient(email: string, name: string) {
136
+ return { email, name }
137
+ },
138
+ Attachment: function Attachment() {
139
+ return {}
140
+ },
141
+ EmailParams: function EmailParams() {
142
+ return chainable()
143
+ },
144
+ },
145
+ }
146
+ }
147
+
148
+ // The handler loads its SDK through `__non_webpack_require__` whenever that
149
+ // exists, so the fakes take the SDKs' place through the same global.
150
+ const withSdks = async <T>(sdks: Record<string, unknown>, run: () => Promise<T>): Promise<T> => {
151
+ const scope = globalThis as { __non_webpack_require__?: unknown }
152
+ scope.__non_webpack_require__ = (id: string) => {
153
+ if (!(id in sdks)) {
154
+ throw new Error('unexpected require: ' + id)
155
+ }
156
+ return sdks[id]
157
+ }
158
+ try {
159
+ return await run()
160
+ } finally {
161
+ delete scope.__non_webpack_require__
162
+ }
163
+ }
164
+
165
+ type IdKey = 'id' | 'messageId'
166
+
167
+ const NODES: Array<[string, string, IdKey]> = [
168
+ ['email-postmark', 'pm', 'messageId'],
169
+ ['email-resend', 're', 'id'],
170
+ ['email-sendgrid', 'sg', 'messageId'],
171
+ ['email-mailgun', 'mg', 'messageId'],
172
+ ['email-mailersend', 'ms', 'messageId'],
173
+ ]
174
+
175
+ const baseConfig = {
176
+ apiKey: 'key',
177
+ serverToken: 'token',
178
+ domain: 'mg.lumen.test',
179
+ from: 'shop@lumen.test',
180
+ fromName: 'Lumen Pottery',
181
+ subject: 'Your order',
182
+ body: '<p>Thanks</p>',
183
+ }
184
+
185
+ const sendThrough = async (nodeType: string, to: unknown, extra: Record<string, unknown> = {}) => {
186
+ const calls: ProviderCall[] = []
187
+ const handler = loadHandler(nodeType)
188
+ const result = (await withSdks(fakeSdks(calls), () =>
189
+ handler({ ...baseConfig, to, ...extra }, {})
190
+ )) as Record<string, unknown>
191
+ return { calls, result }
192
+ }
193
+
194
+ describe('workflow email nodes send to each recipient separately', () => {
195
+ for (const [nodeType, prefix, idKey] of NODES) {
196
+ const idOf = (n: number) => ({ [idKey]: prefix + '-' + n })
197
+
198
+ describe(nodeType, () => {
199
+ it('one recipient: one provider call and the result as before', async () => {
200
+ const asString = await sendThrough(nodeType, BUYER)
201
+ expect(asString.calls).toEqual([{ to: [BUYER], cc: [] }])
202
+ expect(asString.result).toEqual({ ...idOf(1), success: true })
203
+
204
+ const asList = await sendThrough(nodeType, [BUYER])
205
+ expect(asList.calls).toEqual([{ to: [BUYER], cc: [] }])
206
+ expect(asList.result).toEqual({ ...idOf(1), success: true })
207
+ })
208
+
209
+ it('one refused recipient: the failure as before', async () => {
210
+ const { calls, result } = await sendThrough(nodeType, BLOCKED)
211
+ expect(calls).toEqual([{ to: [BLOCKED], cc: [] }])
212
+ expect(result).toEqual({ [idKey]: '', success: false, error: REASON })
213
+ })
214
+
215
+ it('several recipients, one refused: one call per address, the others delivered', async () => {
216
+ const { calls, result } = await sendThrough(nodeType, [BUYER, BLOCKED, ANA], {
217
+ cc: 'ops@lumen.test',
218
+ })
219
+ expect(calls).toEqual([
220
+ { to: [BUYER], cc: ['ops@lumen.test'] },
221
+ { to: [BLOCKED], cc: ['ops@lumen.test'] },
222
+ { to: [ANA], cc: ['ops@lumen.test'] },
223
+ ])
224
+ expect(result).toEqual({
225
+ ...idOf(1),
226
+ success: true,
227
+ error: 'refused for ' + BLOCKED + ' (' + REASON + ')',
228
+ deliveries: [
229
+ { to: BUYER, success: true, ...idOf(1) },
230
+ { to: BLOCKED, success: false, error: REASON },
231
+ { to: ANA, success: true, ...idOf(3) },
232
+ ],
233
+ })
234
+ })
235
+
236
+ it('every recipient refused: a failed result naming each', async () => {
237
+ const { calls, result } = await sendThrough(nodeType, [BLOCKED, BLOCKED_TOO])
238
+ expect(calls).toHaveLength(2)
239
+ expect(result).toEqual({
240
+ [idKey]: '',
241
+ success: false,
242
+ error:
243
+ 'refused for ' + BLOCKED + ' (' + REASON + '); ' + BLOCKED_TOO + ' (' + REASON + ')',
244
+ deliveries: [
245
+ { to: BLOCKED, success: false, error: REASON },
246
+ { to: BLOCKED_TOO, success: false, error: REASON },
247
+ ],
248
+ })
249
+ })
250
+
251
+ it('drops empty entries and sends to an address once, whatever its case', async () => {
252
+ const { calls, result } = await sendThrough(nodeType, [
253
+ BUYER,
254
+ '',
255
+ 'Buyer@Lumen.test',
256
+ null,
257
+ ANA,
258
+ ])
259
+ expect(calls).toEqual([
260
+ { to: [BUYER], cc: [] },
261
+ { to: [ANA], cc: [] },
262
+ ])
263
+ expect((result.deliveries as unknown[]).map((d: any) => d.to)).toEqual([BUYER, ANA])
264
+ })
265
+
266
+ it('refuses to send without a recipient', async () => {
267
+ for (const to of ['', [], undefined, [null, '']]) {
268
+ const { calls, result } = await sendThrough(nodeType, to)
269
+ expect(calls).toEqual([])
270
+ expect(result).toEqual({ [idKey]: '', success: false, error: 'No recipient' })
271
+ }
272
+ })
273
+ })
274
+ }
275
+
276
+ it('mailersend keeps a named recipient and matches named entries by address', async () => {
277
+ const { calls, result } = await sendThrough('email-mailersend', [
278
+ { email: BUYER, name: 'Ana' },
279
+ { email: 'Buyer@lumen.test', name: 'Ana again' },
280
+ { email: ANA },
281
+ ])
282
+ expect(calls).toEqual([
283
+ { to: [BUYER], cc: [] },
284
+ { to: [ANA], cc: [] },
285
+ ])
286
+ expect((result.deliveries as unknown[]).map((d: any) => d.to)).toEqual([BUYER, ANA])
287
+ })
288
+ })