@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.
- package/__tests__/_helpers/load-server-runtime.ts +6 -2
- package/__tests__/_helpers/run-account-delete-route.ts +140 -0
- package/__tests__/account-delete-current-password.test.ts +73 -0
- package/__tests__/account-delete-password-check.test.ts +113 -0
- package/__tests__/account-delete-visitor-details.test.ts +206 -0
- package/__tests__/ai-error-contract.test.ts +10 -0
- package/__tests__/auth-password-change-ends-sessions.test.ts +178 -0
- package/__tests__/continue-on-error.test.ts +27 -0
- package/__tests__/data-api-invalid-body.test.ts +110 -0
- package/__tests__/data-node-not-awaited.test.ts +7 -6
- package/__tests__/email-node-recipients.test.ts +288 -0
- package/__tests__/invoice-payment-line.test.ts +68 -0
- package/__tests__/invoice-tender.test.ts +116 -0
- package/__tests__/pending-work-after-reply.test.ts +294 -0
- package/__tests__/runtime-condition-operands.test.ts +86 -0
- package/__tests__/sent-email-log-module.test.ts +54 -0
- package/__tests__/webhook-delivery-lease.test.ts +68 -6
- package/dist/cjs/account-delete-route-generator.d.ts +7 -2
- package/dist/cjs/account-delete-route-generator.d.ts.map +1 -1
- package/dist/cjs/account-delete-route-generator.js +90 -43
- package/dist/cjs/account-delete-route-generator.js.map +1 -1
- package/dist/cjs/api-route-generator.d.ts.map +1 -1
- package/dist/cjs/api-route-generator.js +12 -13
- package/dist/cjs/api-route-generator.js.map +1 -1
- package/dist/cjs/auth-generator.d.ts.map +1 -1
- package/dist/cjs/auth-generator.js +34 -1
- package/dist/cjs/auth-generator.js.map +1 -1
- package/dist/cjs/await-result.d.ts +13 -5
- package/dist/cjs/await-result.d.ts.map +1 -1
- package/dist/cjs/await-result.js +19 -8
- package/dist/cjs/await-result.js.map +1 -1
- package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
- package/dist/cjs/data-api-route-generator.js +9 -1
- package/dist/cjs/data-api-route-generator.js.map +1 -1
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +22 -7
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
- package/dist/cjs/invoice/invoice-html-code.js +96 -5
- package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
- package/dist/cjs/nodes/account/account-delete-current.d.ts.map +1 -1
- package/dist/cjs/nodes/account/account-delete-current.js +4 -1
- package/dist/cjs/nodes/account/account-delete-current.js.map +1 -1
- package/dist/cjs/nodes/email/email-mailersend.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-mailersend.js +55 -48
- package/dist/cjs/nodes/email/email-mailersend.js.map +1 -1
- package/dist/cjs/nodes/email/email-mailgun.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-mailgun.js +44 -32
- package/dist/cjs/nodes/email/email-mailgun.js.map +1 -1
- package/dist/cjs/nodes/email/email-postmark.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-postmark.js +44 -31
- package/dist/cjs/nodes/email/email-postmark.js.map +1 -1
- package/dist/cjs/nodes/email/email-recipients.d.ts +28 -0
- package/dist/cjs/nodes/email/email-recipients.d.ts.map +1 -0
- package/dist/cjs/nodes/email/email-recipients.js +74 -0
- package/dist/cjs/nodes/email/email-recipients.js.map +1 -0
- package/dist/cjs/nodes/email/email-resend.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-resend.js +42 -28
- package/dist/cjs/nodes/email/email-resend.js.map +1 -1
- package/dist/cjs/nodes/email/email-sendgrid.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-sendgrid.js +56 -41
- package/dist/cjs/nodes/email/email-sendgrid.js.map +1 -1
- package/dist/cjs/sent-email-log/sent-email-log-code.d.ts.map +1 -1
- package/dist/cjs/sent-email-log/sent-email-log-code.js +17 -1
- package/dist/cjs/sent-email-log/sent-email-log-code.js.map +1 -1
- package/dist/cjs/server-runtime-code.d.ts.map +1 -1
- package/dist/cjs/server-runtime-code.js +21 -0
- package/dist/cjs/server-runtime-code.js.map +1 -1
- package/dist/cjs/transactional-email-code.d.ts +7 -0
- package/dist/cjs/transactional-email-code.d.ts.map +1 -1
- package/dist/cjs/transactional-email-code.js +5 -5
- package/dist/cjs/transactional-email-code.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/vercel-functions.d.ts +34 -0
- package/dist/cjs/vercel-functions.d.ts.map +1 -0
- package/dist/cjs/vercel-functions.js +62 -0
- package/dist/cjs/vercel-functions.js.map +1 -0
- package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.d.ts +6 -0
- package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.d.ts.map +1 -1
- package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.js +26 -11
- package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.js.map +1 -1
- package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-project-plugin.js +3 -0
- package/dist/cjs/workflow-project-plugin.js.map +1 -1
- package/dist/esm/account-delete-route-generator.d.ts +7 -2
- package/dist/esm/account-delete-route-generator.d.ts.map +1 -1
- package/dist/esm/account-delete-route-generator.js +90 -43
- package/dist/esm/account-delete-route-generator.js.map +1 -1
- package/dist/esm/api-route-generator.d.ts.map +1 -1
- package/dist/esm/api-route-generator.js +12 -13
- package/dist/esm/api-route-generator.js.map +1 -1
- package/dist/esm/auth-generator.d.ts.map +1 -1
- package/dist/esm/auth-generator.js +34 -1
- package/dist/esm/auth-generator.js.map +1 -1
- package/dist/esm/await-result.d.ts +13 -5
- package/dist/esm/await-result.d.ts.map +1 -1
- package/dist/esm/await-result.js +18 -7
- package/dist/esm/await-result.js.map +1 -1
- package/dist/esm/data-api-route-generator.d.ts.map +1 -1
- package/dist/esm/data-api-route-generator.js +9 -1
- package/dist/esm/data-api-route-generator.js.map +1 -1
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +22 -7
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
- package/dist/esm/invoice/invoice-html-code.js +96 -5
- package/dist/esm/invoice/invoice-html-code.js.map +1 -1
- package/dist/esm/nodes/account/account-delete-current.d.ts.map +1 -1
- package/dist/esm/nodes/account/account-delete-current.js +4 -1
- package/dist/esm/nodes/account/account-delete-current.js.map +1 -1
- package/dist/esm/nodes/email/email-mailersend.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-mailersend.js +55 -48
- package/dist/esm/nodes/email/email-mailersend.js.map +1 -1
- package/dist/esm/nodes/email/email-mailgun.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-mailgun.js +44 -32
- package/dist/esm/nodes/email/email-mailgun.js.map +1 -1
- package/dist/esm/nodes/email/email-postmark.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-postmark.js +44 -31
- package/dist/esm/nodes/email/email-postmark.js.map +1 -1
- package/dist/esm/nodes/email/email-recipients.d.ts +28 -0
- package/dist/esm/nodes/email/email-recipients.d.ts.map +1 -0
- package/dist/esm/nodes/email/email-recipients.js +71 -0
- package/dist/esm/nodes/email/email-recipients.js.map +1 -0
- package/dist/esm/nodes/email/email-resend.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-resend.js +42 -28
- package/dist/esm/nodes/email/email-resend.js.map +1 -1
- package/dist/esm/nodes/email/email-sendgrid.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-sendgrid.js +56 -41
- package/dist/esm/nodes/email/email-sendgrid.js.map +1 -1
- package/dist/esm/sent-email-log/sent-email-log-code.d.ts.map +1 -1
- package/dist/esm/sent-email-log/sent-email-log-code.js +17 -1
- package/dist/esm/sent-email-log/sent-email-log-code.js.map +1 -1
- package/dist/esm/server-runtime-code.d.ts.map +1 -1
- package/dist/esm/server-runtime-code.js +21 -0
- package/dist/esm/server-runtime-code.js.map +1 -1
- package/dist/esm/transactional-email-code.d.ts +7 -0
- package/dist/esm/transactional-email-code.d.ts.map +1 -1
- package/dist/esm/transactional-email-code.js +5 -5
- package/dist/esm/transactional-email-code.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/vercel-functions.d.ts +34 -0
- package/dist/esm/vercel-functions.d.ts.map +1 -0
- package/dist/esm/vercel-functions.js +57 -0
- package/dist/esm/vercel-functions.js.map +1 -0
- package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.d.ts +6 -0
- package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.d.ts.map +1 -1
- package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.js +26 -11
- package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.js.map +1 -1
- package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-project-plugin.js +3 -0
- package/dist/esm/workflow-project-plugin.js.map +1 -1
- package/package.json +2 -2
- package/src/account-delete-route-generator.ts +97 -45
- package/src/api-route-generator.ts +12 -13
- package/src/auth-generator.ts +34 -1
- package/src/await-result.ts +19 -7
- package/src/data-api-route-generator.ts +9 -1
- package/src/executor-generator.ts +22 -7
- package/src/invoice/invoice-html-code.ts +96 -5
- package/src/nodes/account/account-delete-current.ts +4 -1
- package/src/nodes/email/email-mailersend.ts +72 -55
- package/src/nodes/email/email-mailgun.ts +51 -36
- package/src/nodes/email/email-postmark.ts +51 -35
- package/src/nodes/email/email-recipients.ts +86 -0
- package/src/nodes/email/email-resend.ts +49 -31
- package/src/nodes/email/email-sendgrid.ts +63 -44
- package/src/sent-email-log/sent-email-log-code.ts +17 -1
- package/src/server-runtime-code.ts +22 -0
- package/src/transactional-email-code.ts +12 -5
- package/src/vercel-functions.ts +67 -0
- package/src/webhook-delivery-lease/webhook-delivery-lease-code.ts +26 -11
- 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
|
|
22
|
-
//
|
|
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('
|
|
452
|
-
expect(route).toContain('await utils.
|
|
453
|
-
const settleAt = route.indexOf('await utils.
|
|
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.
|
|
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
|
+
})
|