@teleporthq/teleport-plugin-next-workflows 0.44.7 → 0.44.9
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__/auth-password-change-ends-sessions.test.ts +178 -0
- package/__tests__/credential-result-reply.test.ts +509 -0
- package/__tests__/data-api-invalid-body.test.ts +110 -0
- package/__tests__/data-node-not-awaited.test.ts +7 -6
- 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__/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 +17 -15
- 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/credential-results.d.ts +18 -0
- package/dist/cjs/credential-results.d.ts.map +1 -0
- package/dist/cjs/credential-results.js +72 -0
- package/dist/cjs/credential-results.js.map +1 -0
- 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 +26 -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/account/account-hash-password.d.ts.map +1 -1
- package/dist/cjs/nodes/account/account-hash-password.js +1 -0
- package/dist/cjs/nodes/account/account-hash-password.js.map +1 -1
- package/dist/cjs/nodes/types.d.ts +6 -0
- package/dist/cjs/nodes/types.d.ts.map +1 -1
- package/dist/cjs/server-runtime-code.d.ts +8 -0
- package/dist/cjs/server-runtime-code.d.ts.map +1 -1
- package/dist/cjs/server-runtime-code.js +127 -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 +17 -15
- 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/credential-results.d.ts +18 -0
- package/dist/esm/credential-results.d.ts.map +1 -0
- package/dist/esm/credential-results.js +68 -0
- package/dist/esm/credential-results.js.map +1 -0
- 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 +26 -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/account/account-hash-password.d.ts.map +1 -1
- package/dist/esm/nodes/account/account-hash-password.js +1 -0
- package/dist/esm/nodes/account/account-hash-password.js.map +1 -1
- package/dist/esm/nodes/types.d.ts +6 -0
- package/dist/esm/nodes/types.d.ts.map +1 -1
- package/dist/esm/server-runtime-code.d.ts +8 -0
- package/dist/esm/server-runtime-code.d.ts.map +1 -1
- package/dist/esm/server-runtime-code.js +127 -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 +17 -15
- package/src/auth-generator.ts +34 -1
- package/src/await-result.ts +19 -7
- package/src/credential-results.ts +71 -0
- package/src/data-api-route-generator.ts +9 -1
- package/src/executor-generator.ts +26 -7
- package/src/invoice/invoice-html-code.ts +96 -5
- package/src/nodes/account/account-delete-current.ts +4 -1
- package/src/nodes/account/account-hash-password.ts +1 -0
- package/src/nodes/types.ts +6 -0
- package/src/server-runtime-code.ts +128 -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
|
+
})
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
/* tslint:disable:function-constructor max-classes-per-file */
|
|
2
|
+
import * as crypto from 'crypto'
|
|
3
|
+
import {
|
|
4
|
+
generateServerSegmentAPIRoute,
|
|
5
|
+
generateStreamingServerSegmentAPIRoute,
|
|
6
|
+
hasStreamingAINode,
|
|
7
|
+
} from '../src/api-route-generator'
|
|
8
|
+
import { nodeRegistry } from '../src/nodes'
|
|
9
|
+
import { splitIntoSegments } from '../src/segment-splitter'
|
|
10
|
+
import { buildSegmentTrust } from '../src/segment-trust'
|
|
11
|
+
import type { WorkflowSegment } from '../src/types'
|
|
12
|
+
import { loadServerRuntime } from './_helpers/load-server-runtime'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The result of the password-hashing node is a credential: a server segment
|
|
16
|
+
* replies it to the browser sealed, never as it is, and a later server
|
|
17
|
+
* segment still opens it and uses it. These run the GENERATED routes of each
|
|
18
|
+
* flow that hashes a password, split the way the project plugin splits them.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const SECRET = 'credential-results-test-secret'
|
|
22
|
+
const BCRYPT_SHAPED = /\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}/
|
|
23
|
+
|
|
24
|
+
// bcryptjs is not installed in this repository; this stand-in hashes into the
|
|
25
|
+
// same 60-character shape, deterministically, so a test can name the hash.
|
|
26
|
+
const fakeBcrypt = {
|
|
27
|
+
hashSync: (text: string, rounds: number): string =>
|
|
28
|
+
`$2a$${String(rounds).padStart(2, '0')}$` +
|
|
29
|
+
crypto.createHash('sha256').update(text).digest('hex').slice(0, 53),
|
|
30
|
+
compareSync: (text: string, hash: string): boolean =>
|
|
31
|
+
fakeBcrypt.hashSync(text, Number(hash.slice(4, 6))) === hash,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What the password-hashing node stores for a password (no pepper, 12 rounds). */
|
|
35
|
+
const hashOf = (password: string): string =>
|
|
36
|
+
fakeBcrypt.hashSync(crypto.createHash('sha256').update(password).digest('base64'), 12)
|
|
37
|
+
|
|
38
|
+
class FakeOpenAI {
|
|
39
|
+
public chat = {
|
|
40
|
+
completions: {
|
|
41
|
+
create: async () =>
|
|
42
|
+
(async function* stream() {
|
|
43
|
+
yield { choices: [{ delta: { content: 'Welcome' } }] }
|
|
44
|
+
})(),
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type Context = Record<string, any>
|
|
50
|
+
|
|
51
|
+
interface RouteReply {
|
|
52
|
+
status: number
|
|
53
|
+
body: { success?: boolean; results?: Context; error?: string } | undefined
|
|
54
|
+
/** Everything the route sent the browser: the JSON body or every streamed frame. */
|
|
55
|
+
text: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface DataCall {
|
|
59
|
+
operation: string
|
|
60
|
+
body: any
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Graph {
|
|
64
|
+
nodes: any[]
|
|
65
|
+
edges: any[]
|
|
66
|
+
segments: WorkflowSegment[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const step = (
|
|
70
|
+
id: string,
|
|
71
|
+
type: string,
|
|
72
|
+
stepNumber: number,
|
|
73
|
+
config: Record<string, unknown> = {}
|
|
74
|
+
) => ({ id, type, label: id, stepNumber, config })
|
|
75
|
+
|
|
76
|
+
const edge = (source: string, target: string, sourceHandle?: string) => ({
|
|
77
|
+
id: `${source}-${target}`,
|
|
78
|
+
source,
|
|
79
|
+
target,
|
|
80
|
+
...(sourceHandle ? { sourceHandle } : {}),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const ref = (nodeId: string, ...path: string[]) => ({
|
|
84
|
+
type: 'workflowContext',
|
|
85
|
+
nodeId,
|
|
86
|
+
path: [nodeId, ...path],
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
function graphOf(nodes: any[], edges: any[]): Graph {
|
|
90
|
+
const segments = splitIntoSegments({
|
|
91
|
+
nodes,
|
|
92
|
+
edges,
|
|
93
|
+
trigger: { type: 'event-element-clicked' },
|
|
94
|
+
} as any)
|
|
95
|
+
return { nodes, edges, segments }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const serverSegments = (graph: Graph) => graph.segments.filter((s) => s.env === 'server')
|
|
99
|
+
|
|
100
|
+
function bootRoute(segment: WorkflowSegment, graph: Graph) {
|
|
101
|
+
const options = { trust: buildSegmentTrust(segment, graph) }
|
|
102
|
+
const source = hasStreamingAINode(segment)
|
|
103
|
+
? generateStreamingServerSegmentAPIRoute(segment, 'Flow', undefined, options)
|
|
104
|
+
: generateServerSegmentAPIRoute(segment, 'Flow', undefined, options)
|
|
105
|
+
const runtime = loadServerRuntime()
|
|
106
|
+
const modules: Record<string, unknown> = { bcryptjs: fakeBcrypt, crypto, openai: FakeOpenAI }
|
|
107
|
+
const routeModule: { exports: unknown } = { exports: {} }
|
|
108
|
+
new Function('module', 'exports', 'require', source)(
|
|
109
|
+
routeModule,
|
|
110
|
+
routeModule.exports,
|
|
111
|
+
(id: string) => (id.endsWith('server-runtime') ? runtime : modules[id] || {})
|
|
112
|
+
)
|
|
113
|
+
const handler = routeModule.exports as (req: unknown, res: unknown) => Promise<void>
|
|
114
|
+
return async (context: Context): Promise<RouteReply> => {
|
|
115
|
+
const reply: RouteReply = { status: 0, body: undefined, text: '' }
|
|
116
|
+
const res = {
|
|
117
|
+
status(code: number) {
|
|
118
|
+
reply.status = code
|
|
119
|
+
return this
|
|
120
|
+
},
|
|
121
|
+
json(payload: RouteReply['body']) {
|
|
122
|
+
reply.body = payload
|
|
123
|
+
reply.text += JSON.stringify(payload)
|
|
124
|
+
return this
|
|
125
|
+
},
|
|
126
|
+
setHeader: (): void => undefined,
|
|
127
|
+
writeHead(code: number) {
|
|
128
|
+
reply.status = code
|
|
129
|
+
},
|
|
130
|
+
write(frame: string) {
|
|
131
|
+
reply.text += frame
|
|
132
|
+
const data = frame.replace(/^data: /, '').trim()
|
|
133
|
+
const event = JSON.parse(data)
|
|
134
|
+
if (event.type === 'done') {
|
|
135
|
+
reply.body = { success: event.success, results: event.results }
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
end: (): void => undefined,
|
|
139
|
+
}
|
|
140
|
+
await handler(
|
|
141
|
+
{
|
|
142
|
+
method: 'POST',
|
|
143
|
+
body: { context: JSON.parse(JSON.stringify(context)) },
|
|
144
|
+
headers: { host: 'shop.example' },
|
|
145
|
+
},
|
|
146
|
+
res
|
|
147
|
+
)
|
|
148
|
+
return reply
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The browser's side of a hop: merge the reply the way the client runtime does. */
|
|
153
|
+
function merge(context: Context, results: Context = {}): Context {
|
|
154
|
+
return { ...context, ...results }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let dataCalls: DataCall[] = []
|
|
158
|
+
const originalFetch = globalThis.fetch
|
|
159
|
+
const savedSecret = process.env.NEXTAUTH_SECRET
|
|
160
|
+
|
|
161
|
+
function answerDataCalls(replies: Record<string, unknown>): void {
|
|
162
|
+
dataCalls = []
|
|
163
|
+
globalThis.fetch = (async (url: string, init?: { body?: string }) => {
|
|
164
|
+
const operation = String(url).split('/').pop() as string
|
|
165
|
+
dataCalls.push({ operation, body: init && init.body ? JSON.parse(init.body) : undefined })
|
|
166
|
+
return { ok: true, status: 200, json: async () => replies[operation] }
|
|
167
|
+
}) as unknown as typeof fetch
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
beforeEach(() => {
|
|
171
|
+
process.env.NEXTAUTH_SECRET = SECRET
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
afterEach(() => {
|
|
175
|
+
globalThis.fetch = originalFetch
|
|
176
|
+
if (savedSecret === undefined) {
|
|
177
|
+
delete process.env.NEXTAUTH_SECRET
|
|
178
|
+
} else {
|
|
179
|
+
process.env.NEXTAUTH_SECRET = savedSecret
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('the password-hashing node', () => {
|
|
184
|
+
it('is the one node the registry marks as having a credential result', () => {
|
|
185
|
+
const credentialTypes = Object.keys(nodeRegistry).filter(
|
|
186
|
+
(nodeType) => (nodeRegistry[nodeType] as { resultIsCredential?: boolean }).resultIsCredential
|
|
187
|
+
)
|
|
188
|
+
expect(credentialTypes).toEqual(['account-hash-password'])
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
describe('reset password: the new password hash stays on the server', () => {
|
|
193
|
+
// The shape the editor builds for /reset-password (password-reset-workflows.ts).
|
|
194
|
+
const NEW_PASSWORD = 'N3w-secret!'
|
|
195
|
+
const reset = graphOf(
|
|
196
|
+
[
|
|
197
|
+
step('set-loading', 'state-update-local-state', 1, { property: 'busy', value: true }),
|
|
198
|
+
step('form', 'general-extract-form-data', 2, { formNodeId: 'reset-form' }),
|
|
199
|
+
step('passwords-match', 'general-if-statement', 3, {
|
|
200
|
+
conditionType: 'simple-comparison',
|
|
201
|
+
leftValue: ref('form', 'password'),
|
|
202
|
+
operator: '===',
|
|
203
|
+
rightValue: ref('form', 'confirmPassword'),
|
|
204
|
+
}),
|
|
205
|
+
step('token', 'url-get-query-parameter', 4, { parameterName: 'token', defaultValue: '' }),
|
|
206
|
+
step('hash', 'account-hash-password', 5, { password: ref('form', 'password') }),
|
|
207
|
+
step('reset', 'data-raw-query', 6, {
|
|
208
|
+
dataSourceId: 'ds-1',
|
|
209
|
+
query: 'SELECT $1::text AS token, $2::text AS password',
|
|
210
|
+
params: [ref('token', 'value'), ref('hash', 'hash')],
|
|
211
|
+
}),
|
|
212
|
+
step('changed', 'general-if-statement', 7, {
|
|
213
|
+
conditionType: 'simple-comparison',
|
|
214
|
+
leftValue: ref('reset', 'result', '0', 'changed'),
|
|
215
|
+
operator: '===',
|
|
216
|
+
rightValue: 'true',
|
|
217
|
+
}),
|
|
218
|
+
step('done', 'state-update-local-state', 8, { property: 'busy', value: false }),
|
|
219
|
+
step('invalid', 'state-update-local-state', 9, { property: 'hasError', value: true }),
|
|
220
|
+
step('mismatch', 'state-update-local-state', 10, { property: 'hasError', value: true }),
|
|
221
|
+
],
|
|
222
|
+
[
|
|
223
|
+
edge('set-loading', 'form'),
|
|
224
|
+
edge('form', 'passwords-match'),
|
|
225
|
+
edge('passwords-match', 'token', 'true'),
|
|
226
|
+
edge('passwords-match', 'mismatch', 'false'),
|
|
227
|
+
edge('token', 'hash'),
|
|
228
|
+
edge('hash', 'reset'),
|
|
229
|
+
edge('reset', 'changed'),
|
|
230
|
+
edge('changed', 'done', 'true'),
|
|
231
|
+
edge('changed', 'invalid', 'false'),
|
|
232
|
+
]
|
|
233
|
+
)
|
|
234
|
+
const browserContext: Context = {
|
|
235
|
+
'set-loading': { updated: true },
|
|
236
|
+
form: { password: NEW_PASSWORD, confirmPassword: NEW_PASSWORD },
|
|
237
|
+
'passwords-match': { result: true },
|
|
238
|
+
token: { value: 'reset-token' },
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
it('hashes, writes and checks in one server segment', () => {
|
|
242
|
+
expect(serverSegments(reset).map((s) => s.nodes.map((n) => n.id))).toEqual([
|
|
243
|
+
['hash', 'reset', 'changed'],
|
|
244
|
+
])
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('writes the hash, and replies the write and the check without it', async () => {
|
|
248
|
+
answerDataCalls({ 'raw-query': { rows: [{ changed: 'true' }] } })
|
|
249
|
+
const reply = await bootRoute(serverSegments(reset)[0], reset)(browserContext)
|
|
250
|
+
|
|
251
|
+
expect(reply.status).toBe(200)
|
|
252
|
+
expect(dataCalls).toHaveLength(1)
|
|
253
|
+
expect(JSON.stringify(dataCalls[0].body)).toContain(hashOf(NEW_PASSWORD))
|
|
254
|
+
|
|
255
|
+
const results = reply.body?.results || {}
|
|
256
|
+
expect(results.reset).toEqual({ rows: [{ changed: 'true' }], result: [{ changed: 'true' }] })
|
|
257
|
+
expect(results.changed).toEqual({ result: true })
|
|
258
|
+
expect(reply.text).not.toContain(hashOf(NEW_PASSWORD))
|
|
259
|
+
expect(reply.text).not.toMatch(BCRYPT_SHAPED)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('replies the hash node sealed and signed, like every other server result', async () => {
|
|
263
|
+
answerDataCalls({ 'raw-query': { rows: [{ changed: 'true' }] } })
|
|
264
|
+
const reply = await bootRoute(serverSegments(reset)[0], reset)(browserContext)
|
|
265
|
+
const results = reply.body?.results || {}
|
|
266
|
+
expect(results.hash).toEqual({ __sealed: expect.any(String) })
|
|
267
|
+
expect(Object.keys(results.__sig).sort()).toEqual(['changed', 'hash', 'reset'])
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
describe('a later server segment still receives the hash', () => {
|
|
272
|
+
const PASSWORD = 'Admin-made-1'
|
|
273
|
+
// Hash, a browser step, then the write: two server segments.
|
|
274
|
+
const split = graphOf(
|
|
275
|
+
[
|
|
276
|
+
step('form', 'general-extract-form-data', 1, { formNodeId: 'user-form' }),
|
|
277
|
+
step('hash', 'account-hash-password', 2, { password: ref('form', 'password') }),
|
|
278
|
+
step('busy', 'state-update-local-state', 3, { property: 'busy', value: true }),
|
|
279
|
+
step('save', 'data-update-item', 4, {
|
|
280
|
+
dataSourceId: 'ds-1',
|
|
281
|
+
tableName: 'users',
|
|
282
|
+
filters: [{ field: 'id', operator: '=', value: 'u-1' }],
|
|
283
|
+
columnMappings: [{ column: 'password', value: ref('hash', 'hash') }],
|
|
284
|
+
}),
|
|
285
|
+
step('saved', 'state-update-local-state', 5, { property: 'busy', value: false }),
|
|
286
|
+
],
|
|
287
|
+
[edge('form', 'hash'), edge('hash', 'busy'), edge('busy', 'save'), edge('save', 'saved')]
|
|
288
|
+
)
|
|
289
|
+
const [hashSegment, saveSegment] = serverSegments(split)
|
|
290
|
+
|
|
291
|
+
it('splits into a hash segment and a write segment', () => {
|
|
292
|
+
expect(split.segments.map((s) => s.env)).toEqual([
|
|
293
|
+
'client',
|
|
294
|
+
'server',
|
|
295
|
+
'client',
|
|
296
|
+
'server',
|
|
297
|
+
'client',
|
|
298
|
+
])
|
|
299
|
+
expect(hashSegment.nodes.map((n) => n.id)).toEqual(['hash'])
|
|
300
|
+
expect(saveSegment.nodes.map((n) => n.id)).toEqual(['save'])
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('writes the hash the earlier segment made, and neither reply carries it', async () => {
|
|
304
|
+
answerDataCalls({ update: { item: { id: 'u-1', email: 'member@example.com' }, id: 'u-1' } })
|
|
305
|
+
const start: Context = { form: { password: PASSWORD } }
|
|
306
|
+
const first = await bootRoute(hashSegment, split)(start)
|
|
307
|
+
const held = merge(start, first.body?.results)
|
|
308
|
+
expect(JSON.stringify(held)).not.toMatch(BCRYPT_SHAPED)
|
|
309
|
+
|
|
310
|
+
const second = await bootRoute(saveSegment, split)(merge(held, { busy: { updated: true } }))
|
|
311
|
+
expect(second.status).toBe(200)
|
|
312
|
+
expect(dataCalls).toHaveLength(1)
|
|
313
|
+
expect(dataCalls[0].body.columnMappings).toEqual([
|
|
314
|
+
{ column: 'password', value: hashOf(PASSWORD) },
|
|
315
|
+
])
|
|
316
|
+
expect(second.text).not.toMatch(BCRYPT_SHAPED)
|
|
317
|
+
expect(second.body?.results?.save).toMatchObject({ id: 'u-1', email: 'member@example.com' })
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
describe('the hash as the previous result of the next server segment', () => {
|
|
322
|
+
const HASH = hashOf('previous-step')
|
|
323
|
+
|
|
324
|
+
it('reaches the next segment opened, and goes back to the browser sealed', () => {
|
|
325
|
+
const runtime = loadServerRuntime()
|
|
326
|
+
const first = runtime.segmentReply(
|
|
327
|
+
{ nodes: [{ id: 'hash', type: 'account-hash-password' }], edges: [] },
|
|
328
|
+
{ hash: { hash: HASH } },
|
|
329
|
+
{}
|
|
330
|
+
)
|
|
331
|
+
expect(JSON.stringify(first)).not.toContain(HASH)
|
|
332
|
+
|
|
333
|
+
const relayed = JSON.parse(JSON.stringify(first))
|
|
334
|
+
const snapshot = runtime.snapshotIncomingContext(relayed)
|
|
335
|
+
const next = {
|
|
336
|
+
nodes: [{ id: 'next', type: 'general-custom-js', config: {} }],
|
|
337
|
+
edges: [] as unknown[],
|
|
338
|
+
entryNodeIds: ['next'],
|
|
339
|
+
signedNodeIds: ['hash'],
|
|
340
|
+
previousFrom: { nodeId: 'hash', fireAndForget: false },
|
|
341
|
+
}
|
|
342
|
+
runtime.claimSegmentContext(next, relayed)
|
|
343
|
+
expect(relayed.hash).toEqual({ hash: HASH })
|
|
344
|
+
expect(relayed.__previousNodeResult).toEqual({ hash: HASH })
|
|
345
|
+
|
|
346
|
+
relayed.next = { done: true }
|
|
347
|
+
const second = runtime.segmentReply(next, relayed, snapshot)
|
|
348
|
+
expect(JSON.stringify(second)).not.toContain(HASH)
|
|
349
|
+
expect(second.next).toEqual({ done: true })
|
|
350
|
+
expect(second.hash).toEqual(first.hash)
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('is not replied at all by an app without a secret', () => {
|
|
354
|
+
delete process.env.NEXTAUTH_SECRET
|
|
355
|
+
const runtime = loadServerRuntime()
|
|
356
|
+
const reply = runtime.segmentReply(
|
|
357
|
+
{ nodes: [{ id: 'hash', type: 'account-hash-password' }], edges: [] },
|
|
358
|
+
{ hash: { hash: HASH } },
|
|
359
|
+
{}
|
|
360
|
+
)
|
|
361
|
+
expect(reply.hash).toBeUndefined()
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('opens under any step the browser holds it as (a custom node output), and only what the app sealed', () => {
|
|
365
|
+
const runtime = loadServerRuntime()
|
|
366
|
+
const sealed = runtime.segmentReply(
|
|
367
|
+
{ nodes: [{ id: 'hash', type: 'account-hash-password' }], edges: [] },
|
|
368
|
+
{ hash: { hash: HASH } },
|
|
369
|
+
{}
|
|
370
|
+
).hash
|
|
371
|
+
expect(sealed).toEqual({ __sealed: expect.any(String) })
|
|
372
|
+
const lookalike = { __sealed: Buffer.from('typed by the page').toString('base64') }
|
|
373
|
+
const context: Context = { 'custom-node': sealed, typed: lookalike }
|
|
374
|
+
const snapshot = runtime.snapshotIncomingContext(context)
|
|
375
|
+
const config = { nodes: [] as unknown[], edges: [] as unknown[], entryNodeIds: [] as string[] }
|
|
376
|
+
|
|
377
|
+
runtime.claimSegmentContext(config, context)
|
|
378
|
+
expect(context['custom-node']).toEqual({ hash: HASH })
|
|
379
|
+
expect(context.typed).toEqual(lookalike)
|
|
380
|
+
expect(JSON.stringify(runtime.segmentReply(config, context, snapshot))).not.toContain(HASH)
|
|
381
|
+
})
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
describe('change password: the stored hash and the new one stay on the server', () => {
|
|
385
|
+
const CURRENT = 'Old-pass-1'
|
|
386
|
+
const NEXT = 'New-pass-2'
|
|
387
|
+
const change = graphOf(
|
|
388
|
+
[
|
|
389
|
+
step('form', 'general-extract-form-data', 1, { formNodeId: 'change-form' }),
|
|
390
|
+
step('account', 'data-select', 2, {
|
|
391
|
+
dataSourceId: 'ds-1',
|
|
392
|
+
tableName: 'users',
|
|
393
|
+
filters: [{ field: 'id', operator: '=', value: 'u-1' }],
|
|
394
|
+
}),
|
|
395
|
+
step('verify', 'account-compare-passwords', 3, {
|
|
396
|
+
password: ref('form', 'currentPassword'),
|
|
397
|
+
hash: ref('account', 'rows', '0', 'password'),
|
|
398
|
+
}),
|
|
399
|
+
step('correct', 'general-if-statement', 4, {
|
|
400
|
+
conditionType: 'simple-comparison',
|
|
401
|
+
leftValue: ref('verify', 'isMatch'),
|
|
402
|
+
operator: '===',
|
|
403
|
+
rightValue: true,
|
|
404
|
+
}),
|
|
405
|
+
step('hash', 'account-hash-password', 5, { password: ref('form', 'newPassword') }),
|
|
406
|
+
step('save', 'data-update-item', 6, {
|
|
407
|
+
dataSourceId: 'ds-1',
|
|
408
|
+
tableName: 'users',
|
|
409
|
+
filters: [{ field: 'id', operator: '=', value: 'u-1' }],
|
|
410
|
+
columnMappings: [{ column: 'password', value: ref('hash', 'hash') }],
|
|
411
|
+
}),
|
|
412
|
+
step('toast', 'toast-show', 7, { message: 'Password changed' }),
|
|
413
|
+
],
|
|
414
|
+
[
|
|
415
|
+
edge('form', 'account'),
|
|
416
|
+
edge('account', 'verify'),
|
|
417
|
+
edge('verify', 'correct'),
|
|
418
|
+
edge('correct', 'hash', 'true'),
|
|
419
|
+
edge('hash', 'save'),
|
|
420
|
+
edge('save', 'toast'),
|
|
421
|
+
]
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
it('checks the current password and writes the new hash, replying neither hash', async () => {
|
|
425
|
+
const user = { id: 'u-1', email: 'member@example.com', password: hashOf(CURRENT) }
|
|
426
|
+
answerDataCalls({
|
|
427
|
+
select: { rows: [user], count: 1 },
|
|
428
|
+
update: { item: user, id: 'u-1', updatedCount: 1 },
|
|
429
|
+
})
|
|
430
|
+
const [segment] = serverSegments(change)
|
|
431
|
+
expect(segment.nodes.map((n) => n.id)).toEqual(['account', 'verify', 'correct', 'hash', 'save'])
|
|
432
|
+
|
|
433
|
+
const reply = await bootRoute(
|
|
434
|
+
segment,
|
|
435
|
+
change
|
|
436
|
+
)({
|
|
437
|
+
form: { currentPassword: CURRENT, newPassword: NEXT },
|
|
438
|
+
})
|
|
439
|
+
expect(reply.status).toBe(200)
|
|
440
|
+
expect(reply.body?.results?.verify).toEqual({ isMatch: true, match: true })
|
|
441
|
+
const update = dataCalls.find((call) => call.operation === 'update')
|
|
442
|
+
expect(update?.body.columnMappings).toEqual([{ column: 'password', value: hashOf(NEXT) }])
|
|
443
|
+
expect(reply.text).not.toMatch(BCRYPT_SHAPED)
|
|
444
|
+
})
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
describe('the other ways a hash could reach the browser', () => {
|
|
448
|
+
it('a streaming segment never streams it, and its closing reply carries it sealed', async () => {
|
|
449
|
+
const streaming = graphOf(
|
|
450
|
+
[
|
|
451
|
+
step('welcome', 'ai-custom-prompt', 1, {
|
|
452
|
+
prompt: 'Say welcome',
|
|
453
|
+
model: 'gpt-4o-mini',
|
|
454
|
+
token: 'sk-test',
|
|
455
|
+
streaming: true,
|
|
456
|
+
}),
|
|
457
|
+
step('hash', 'account-hash-password', 2, { password: 'Streamed-1' }),
|
|
458
|
+
],
|
|
459
|
+
[edge('welcome', 'hash')]
|
|
460
|
+
)
|
|
461
|
+
const [segment] = serverSegments(streaming)
|
|
462
|
+
expect(hasStreamingAINode(segment)).toBe(true)
|
|
463
|
+
|
|
464
|
+
const reply = await bootRoute(segment, streaming)({})
|
|
465
|
+
expect(reply.text).toContain('"type":"chunk"')
|
|
466
|
+
expect(reply.text).not.toMatch(BCRYPT_SHAPED)
|
|
467
|
+
expect(reply.body?.results?.hash).toEqual({ __sealed: expect.any(String) })
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it('a loop that maps passwords to hashes replies its results sealed', async () => {
|
|
471
|
+
const loop = graphOf(
|
|
472
|
+
[
|
|
473
|
+
step('each', 'general-loop', 1, { collection: ['First-1', 'Second-2'], loopType: 'map' }),
|
|
474
|
+
step('hash', 'account-hash-password', 2, { password: ref('each', 'currentItem') }),
|
|
475
|
+
],
|
|
476
|
+
[edge('each', 'hash', 'loop')]
|
|
477
|
+
)
|
|
478
|
+
const [segment] = serverSegments(loop)
|
|
479
|
+
const reply = await bootRoute(segment, loop)({})
|
|
480
|
+
expect(reply.status).toBe(200)
|
|
481
|
+
expect(reply.text).not.toMatch(BCRYPT_SHAPED)
|
|
482
|
+
expect(reply.body?.results?.each).toEqual({ __sealed: expect.any(String) })
|
|
483
|
+
|
|
484
|
+
const runtime = loadServerRuntime()
|
|
485
|
+
const relayed = JSON.parse(JSON.stringify(reply.body?.results))
|
|
486
|
+
runtime.claimSegmentContext(
|
|
487
|
+
{ nodes: [], edges: [], entryNodeIds: [], signedNodeIds: ['each', 'hash'] },
|
|
488
|
+
relayed
|
|
489
|
+
)
|
|
490
|
+
expect(relayed.each.results).toEqual([
|
|
491
|
+
{ hash: hashOf('First-1') },
|
|
492
|
+
{ hash: hashOf('Second-2') },
|
|
493
|
+
])
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
it('a loop that only repeats the hash keeps its count readable', async () => {
|
|
497
|
+
const loop = graphOf(
|
|
498
|
+
[
|
|
499
|
+
step('each', 'general-loop', 1, { collection: ['First-1'], loopType: 'forEach' }),
|
|
500
|
+
step('hash', 'account-hash-password', 2, { password: ref('each', 'currentItem') }),
|
|
501
|
+
],
|
|
502
|
+
[edge('each', 'hash', 'loop')]
|
|
503
|
+
)
|
|
504
|
+
const [segment] = serverSegments(loop)
|
|
505
|
+
const reply = await bootRoute(segment, loop)({})
|
|
506
|
+
expect(reply.body?.results?.each).toEqual({ completed: true, iterations: 1, results: [] })
|
|
507
|
+
expect(reply.text).not.toMatch(BCRYPT_SHAPED)
|
|
508
|
+
})
|
|
509
|
+
})
|