@teleporthq/teleport-plugin-next-workflows 0.45.2 → 0.45.5
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__/analytics-outcome-visit-ids.test.ts +583 -0
- package/__tests__/analytics-track-outcome.test.ts +71 -3
- package/__tests__/auth-password-security.test.ts +101 -0
- package/__tests__/automations-bus.test.ts +439 -2
- package/__tests__/security-lists-parity.test.ts +2 -2
- package/__tests__/security-runtime.test.ts +37 -20
- package/dist/cjs/api-route-generator.d.ts +2 -0
- package/dist/cjs/api-route-generator.d.ts.map +1 -1
- package/dist/cjs/api-route-generator.js +43 -1
- 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 +48 -19
- package/dist/cjs/auth-generator.js.map +1 -1
- package/dist/cjs/automations/dispatcher-source.d.ts.map +1 -1
- package/dist/cjs/automations/dispatcher-source.js +13 -5
- package/dist/cjs/automations/dispatcher-source.js.map +1 -1
- package/dist/cjs/automations/event-bus-source.d.ts.map +1 -1
- package/dist/cjs/automations/event-bus-source.js +37 -23
- package/dist/cjs/automations/event-bus-source.js.map +1 -1
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +4 -0
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/nodes/account/account-request-password-reset.d.ts.map +1 -1
- package/dist/cjs/nodes/account/account-request-password-reset.js +3 -2
- package/dist/cjs/nodes/account/account-request-password-reset.js.map +1 -1
- package/dist/cjs/nodes/analytics/analytics-track-outcome.d.ts.map +1 -1
- package/dist/cjs/nodes/analytics/analytics-track-outcome.js +17 -4
- package/dist/cjs/nodes/analytics/analytics-track-outcome.js.map +1 -1
- package/dist/cjs/password-reset-route-generator.d.ts +2 -0
- package/dist/cjs/password-reset-route-generator.d.ts.map +1 -1
- package/dist/cjs/password-reset-route-generator.js +17 -10
- package/dist/cjs/password-reset-route-generator.js.map +1 -1
- package/dist/cjs/security-runtime/security-runtime-code.d.ts +3 -2
- package/dist/cjs/security-runtime/security-runtime-code.d.ts.map +1 -1
- package/dist/cjs/security-runtime/security-runtime-code.js +17 -13
- package/dist/cjs/security-runtime/security-runtime-code.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/api-route-generator.d.ts +2 -0
- package/dist/esm/api-route-generator.d.ts.map +1 -1
- package/dist/esm/api-route-generator.js +44 -2
- 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 +48 -19
- package/dist/esm/auth-generator.js.map +1 -1
- package/dist/esm/automations/dispatcher-source.d.ts.map +1 -1
- package/dist/esm/automations/dispatcher-source.js +13 -5
- package/dist/esm/automations/dispatcher-source.js.map +1 -1
- package/dist/esm/automations/event-bus-source.d.ts.map +1 -1
- package/dist/esm/automations/event-bus-source.js +37 -23
- package/dist/esm/automations/event-bus-source.js.map +1 -1
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +4 -0
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/nodes/account/account-request-password-reset.d.ts.map +1 -1
- package/dist/esm/nodes/account/account-request-password-reset.js +3 -2
- package/dist/esm/nodes/account/account-request-password-reset.js.map +1 -1
- package/dist/esm/nodes/analytics/analytics-track-outcome.d.ts.map +1 -1
- package/dist/esm/nodes/analytics/analytics-track-outcome.js +17 -4
- package/dist/esm/nodes/analytics/analytics-track-outcome.js.map +1 -1
- package/dist/esm/password-reset-route-generator.d.ts +2 -0
- package/dist/esm/password-reset-route-generator.d.ts.map +1 -1
- package/dist/esm/password-reset-route-generator.js +16 -9
- package/dist/esm/password-reset-route-generator.js.map +1 -1
- package/dist/esm/security-runtime/security-runtime-code.d.ts +3 -2
- package/dist/esm/security-runtime/security-runtime-code.d.ts.map +1 -1
- package/dist/esm/security-runtime/security-runtime-code.js +17 -13
- package/dist/esm/security-runtime/security-runtime-code.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/api-route-generator.ts +52 -2
- package/src/auth-generator.ts +48 -19
- package/src/automations/dispatcher-source.ts +13 -5
- package/src/automations/event-bus-source.ts +37 -23
- package/src/executor-generator.ts +4 -0
- package/src/nodes/account/account-request-password-reset.ts +3 -2
- package/src/nodes/analytics/analytics-track-outcome.ts +17 -4
- package/src/password-reset-route-generator.ts +18 -9
- package/src/security-runtime/security-runtime-code.ts +17 -13
|
@@ -74,6 +74,10 @@ class FakePostgres {
|
|
|
74
74
|
users: Row[] = []
|
|
75
75
|
resets: Reset[] = []
|
|
76
76
|
resetsTableExists = false
|
|
77
|
+
/** Off, as the site's role connects: it may read and write tables, and create none, whether or not one exists. */
|
|
78
|
+
mayCreateTables = true
|
|
79
|
+
/** Fails the statement that stores a link: the database went away between two calls. */
|
|
80
|
+
failResetWrites = false
|
|
77
81
|
now = Date.UTC(2026, 9, 25, 12)
|
|
78
82
|
statements: string[] = []
|
|
79
83
|
// The account store cannot be read (a connection refused) for lookups by id.
|
|
@@ -85,6 +89,9 @@ class FakePostgres {
|
|
|
85
89
|
return { rows: [] }
|
|
86
90
|
}
|
|
87
91
|
if (sql.startsWith('CREATE TABLE IF NOT EXISTS teleport_password_resets')) {
|
|
92
|
+
if (!this.mayCreateTables) {
|
|
93
|
+
throw Object.assign(new Error('permission denied for schema public'), { code: '42501' })
|
|
94
|
+
}
|
|
88
95
|
this.resetsTableExists = true
|
|
89
96
|
return { rows: [] }
|
|
90
97
|
}
|
|
@@ -134,6 +141,12 @@ class FakePostgres {
|
|
|
134
141
|
code: '42P01',
|
|
135
142
|
})
|
|
136
143
|
}
|
|
144
|
+
if (sql === 'SELECT 1 FROM teleport_password_resets LIMIT 1') {
|
|
145
|
+
return { rows: [] }
|
|
146
|
+
}
|
|
147
|
+
if (this.failResetWrites && sql.startsWith('INSERT INTO teleport_password_resets')) {
|
|
148
|
+
throw Object.assign(new Error('Connection terminated unexpectedly'), { code: '57P01' })
|
|
149
|
+
}
|
|
137
150
|
if (sql.startsWith('UPDATE teleport_password_resets SET used_at = NOW() WHERE user_id = $1')) {
|
|
138
151
|
for (const reset of this.resets) {
|
|
139
152
|
if (reset.user_id === values[0] && reset.used_at === null) {
|
|
@@ -428,6 +441,7 @@ const loadApp = (
|
|
|
428
441
|
}
|
|
429
442
|
|
|
430
443
|
const VICTIM = { id: 'u-victim', email: 'victim@site.test', name: 'Vic', role: 'admin' }
|
|
444
|
+
const FORGOT_PASSWORD_NOT_SENT = 'We could not send the reset link. Please try again in a moment.'
|
|
431
445
|
|
|
432
446
|
const tokenFromEmail = (sent: Row[]): string => {
|
|
433
447
|
const mail = sent.find((entry) => entry.to)
|
|
@@ -576,6 +590,93 @@ describe('A3-C1: forgot / reset password are decided on the server', () => {
|
|
|
576
590
|
expect(app.delays.length).toBeGreaterThan(0)
|
|
577
591
|
})
|
|
578
592
|
|
|
593
|
+
const asksToCreate = (db: FakePostgres) =>
|
|
594
|
+
db.statements.filter((sql) => /\bCREATE\b|pg_advisory_xact_lock/.test(sql))
|
|
595
|
+
|
|
596
|
+
it('stores a link in the table the editor made, never asking to create it', async () => {
|
|
597
|
+
const db = victimDb()
|
|
598
|
+
db.resetsTableExists = true
|
|
599
|
+
db.mayCreateTables = false
|
|
600
|
+
const app = loadApp({ db })
|
|
601
|
+
const res = fakeResponse()
|
|
602
|
+
|
|
603
|
+
await app.load('forgot')(request({ email: VICTIM.email }), res)
|
|
604
|
+
|
|
605
|
+
expect(res.statusCode).toBe(200)
|
|
606
|
+
expect(res.body).toEqual({ success: true, message: FORGOT_PASSWORD_ANSWER })
|
|
607
|
+
expect(db.resets).toHaveLength(1)
|
|
608
|
+
expect(app.sent.filter((entry) => entry.to)).toHaveLength(1)
|
|
609
|
+
expect(asksToCreate(db)).toEqual([])
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
it('creates the table only when it is really missing, then stores the link in it', async () => {
|
|
613
|
+
const db = victimDb()
|
|
614
|
+
const app = loadApp({ db })
|
|
615
|
+
await app.load('forgot')(request({ email: VICTIM.email }), fakeResponse())
|
|
616
|
+
|
|
617
|
+
expect(asksToCreate(db).map((sql) => sql.split(' (')[0])).toEqual([
|
|
618
|
+
'SELECT pg_advisory_xact_lock(hashtext($1))',
|
|
619
|
+
'CREATE TABLE IF NOT EXISTS teleport_password_resets',
|
|
620
|
+
'CREATE INDEX IF NOT EXISTS teleport_password_resets_user_id_idx ON teleport_password_resets',
|
|
621
|
+
])
|
|
622
|
+
expect(db.resets).toHaveLength(1)
|
|
623
|
+
expect(app.sent.filter((entry) => entry.to)).toHaveLength(1)
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
it('stores a sign-up set-password link the same way when the table went missing', async () => {
|
|
627
|
+
const db = victimDb()
|
|
628
|
+
const app = loadApp({ db })
|
|
629
|
+
await app.load('options').storePasswordReset(VICTIM.id, 'hash', 1800)
|
|
630
|
+
expect(db.resets).toHaveLength(1)
|
|
631
|
+
|
|
632
|
+
const refused = victimDb()
|
|
633
|
+
refused.mayCreateTables = false
|
|
634
|
+
const refusedApp = loadApp({ db: refused })
|
|
635
|
+
await expect(
|
|
636
|
+
refusedApp.load('options').storePasswordReset(VICTIM.id, 'hash', 1800)
|
|
637
|
+
).rejects.toThrow(
|
|
638
|
+
'the table teleport_password_resets is missing and could not be created: permission denied for schema public'
|
|
639
|
+
)
|
|
640
|
+
expect(refused.statements).toContain('ROLLBACK')
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
it('tells no one a link was sent when the table is missing and cannot be made, naming it in the log', async () => {
|
|
644
|
+
const db = victimDb()
|
|
645
|
+
db.mayCreateTables = false
|
|
646
|
+
const app = loadApp({ db })
|
|
647
|
+
const known = fakeResponse()
|
|
648
|
+
await app.load('forgot')(request({ email: VICTIM.email }), known)
|
|
649
|
+
const unknown = fakeResponse()
|
|
650
|
+
await app.load('forgot')(request({ email: 'nobody@site.test' }), unknown)
|
|
651
|
+
|
|
652
|
+
for (const res of [known, unknown]) {
|
|
653
|
+
expect(res.statusCode).toBe(503)
|
|
654
|
+
expect(res.body.error).toBe(FORGOT_PASSWORD_NOT_SENT)
|
|
655
|
+
expect(res.body).not.toHaveProperty('success')
|
|
656
|
+
}
|
|
657
|
+
expect(db.resets).toEqual([])
|
|
658
|
+
expect(app.sent).toEqual([])
|
|
659
|
+
const logged = app.logs.map((args) => args.map(String).join(' ')).join('\n')
|
|
660
|
+
expect(logged).toContain('teleport_password_resets')
|
|
661
|
+
expect(logged).toContain('permission denied for schema public')
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
it('tells no one a link was sent when storing it fails', async () => {
|
|
665
|
+
const db = victimDb()
|
|
666
|
+
db.resetsTableExists = true
|
|
667
|
+
db.failResetWrites = true
|
|
668
|
+
const app = loadApp({ db })
|
|
669
|
+
const res = fakeResponse()
|
|
670
|
+
|
|
671
|
+
await app.load('forgot')(request({ email: VICTIM.email }), res)
|
|
672
|
+
|
|
673
|
+
expect(res.statusCode).toBe(503)
|
|
674
|
+
expect(res.body.error).toBe(FORGOT_PASSWORD_NOT_SENT)
|
|
675
|
+
expect(app.sent).toEqual([])
|
|
676
|
+
// Waits out the same minimum, so the time tells nothing either.
|
|
677
|
+
expect(app.delays.length).toBeGreaterThan(0)
|
|
678
|
+
})
|
|
679
|
+
|
|
579
680
|
it('sends one address at most 3 links an hour, answering the same', async () => {
|
|
580
681
|
const app = loadApp({ db: victimDb() })
|
|
581
682
|
for (let i = 0; i < 5; i++) {
|
|
@@ -359,6 +359,10 @@ class FakeDatabase {
|
|
|
359
359
|
signedRequests = new Map<string, number>()
|
|
360
360
|
/** Fails every statement on the signed-request table. */
|
|
361
361
|
signedRequestsDown = false
|
|
362
|
+
/** The editor did not make the signed-request table. */
|
|
363
|
+
signedRequestsMissing = false
|
|
364
|
+
/** As the site's role connects: it may read and write tables, and create none, whether or not one exists. */
|
|
365
|
+
mayCreateTables = false
|
|
362
366
|
private serial = 0
|
|
363
367
|
|
|
364
368
|
query(sql: string, values: unknown[] = []): { rows: Row[]; rowCount?: number } {
|
|
@@ -381,8 +385,17 @@ class FakeDatabase {
|
|
|
381
385
|
text ===
|
|
382
386
|
'CREATE INDEX IF NOT EXISTS teleport_automation_requests_expires_at ON teleport_automation_requests (expires_at)'
|
|
383
387
|
) {
|
|
388
|
+
if (!this.mayCreateTables) {
|
|
389
|
+
throw Object.assign(new Error('permission denied for schema public'), { code: '42501' })
|
|
390
|
+
}
|
|
391
|
+
this.signedRequestsMissing = false
|
|
384
392
|
return { rows: [] }
|
|
385
393
|
}
|
|
394
|
+
if (this.signedRequestsMissing) {
|
|
395
|
+
throw Object.assign(new Error('relation "teleport_automation_requests" does not exist'), {
|
|
396
|
+
code: '42P01',
|
|
397
|
+
})
|
|
398
|
+
}
|
|
386
399
|
if (
|
|
387
400
|
text ===
|
|
388
401
|
'DELETE FROM teleport_automation_requests WHERE request_key IN (SELECT request_key FROM teleport_automation_requests WHERE expires_at < $1 LIMIT 1000)'
|
|
@@ -2874,6 +2887,360 @@ describe('in-app event bus — the mail automations send', () => {
|
|
|
2874
2887
|
})
|
|
2875
2888
|
})
|
|
2876
2889
|
|
|
2890
|
+
describe('in-app event bus — a run that sent nothing', () => {
|
|
2891
|
+
const mail = (id: string, to: unknown, label: string, stepNumber: number, extra: Row = {}) => ({
|
|
2892
|
+
id,
|
|
2893
|
+
type: 'email-resend',
|
|
2894
|
+
config: {
|
|
2895
|
+
apiKey: 'k',
|
|
2896
|
+
from: 'shop@example.com',
|
|
2897
|
+
to,
|
|
2898
|
+
subject: 'Order paid',
|
|
2899
|
+
body: '<p>Order paid</p>',
|
|
2900
|
+
...extra,
|
|
2901
|
+
},
|
|
2902
|
+
stepNumber,
|
|
2903
|
+
label,
|
|
2904
|
+
})
|
|
2905
|
+
const gate = (stepNumber: number, left: unknown, rightValue: string) => ({
|
|
2906
|
+
id: 'gate',
|
|
2907
|
+
type: 'general-if-statement',
|
|
2908
|
+
config: { leftValue: left, operator: 'equals', rightValue },
|
|
2909
|
+
stepNumber,
|
|
2910
|
+
label: 'Check',
|
|
2911
|
+
})
|
|
2912
|
+
const OWNER = 'owner@shop.test'
|
|
2913
|
+
const orderNumber = ref('t', ['order', 'number'])
|
|
2914
|
+
const userEmail = ref('t', ['user', 'email'])
|
|
2915
|
+
const onPaid = (id: string, nodes: Row[], edges: Row[]) => ({
|
|
2916
|
+
id,
|
|
2917
|
+
name: id,
|
|
2918
|
+
trigger: trigger('order-paid'),
|
|
2919
|
+
nodes,
|
|
2920
|
+
edges,
|
|
2921
|
+
})
|
|
2922
|
+
const toGate = { id: 'e1', source: 't', target: 'gate' }
|
|
2923
|
+
const whenTrue = { id: 'e2', source: 'gate', target: 'send', sourceHandle: 'true' }
|
|
2924
|
+
const guarded = (id: string, rightValue: string) =>
|
|
2925
|
+
onPaid(
|
|
2926
|
+
id,
|
|
2927
|
+
[gate(1, orderNumber, rightValue), mail('send', OWNER, 'Send Email to Owner', 2)],
|
|
2928
|
+
[toGate, whenTrue]
|
|
2929
|
+
)
|
|
2930
|
+
const afterPost = { id: 'e0', source: 't', target: 'hold' }
|
|
2931
|
+
const holdToGate = { id: 'e1', source: 'hold', target: 'gate' }
|
|
2932
|
+
const parks = {
|
|
2933
|
+
id: 'wait',
|
|
2934
|
+
type: 'general-wait',
|
|
2935
|
+
config: { amount: 1, unit: 'days' },
|
|
2936
|
+
label: 'Wait a day',
|
|
2937
|
+
}
|
|
2938
|
+
const WORKFLOWS: Record<string, Row> = {
|
|
2939
|
+
'wf-skipped': guarded('wf-skipped', 'NOT-THIS-ORDER'),
|
|
2940
|
+
'wf-sent': guarded('wf-sent', 'ORD-1042'),
|
|
2941
|
+
'wf-no-recipient': onPaid(
|
|
2942
|
+
'wf-no-recipient',
|
|
2943
|
+
[mail('send', ref('t', ['order', 'nobody']), 'Send', 1)],
|
|
2944
|
+
[{ id: 'e1', source: 't', target: 'send' }]
|
|
2945
|
+
),
|
|
2946
|
+
'wf-failed-and-went-on': onPaid(
|
|
2947
|
+
'wf-failed-and-went-on',
|
|
2948
|
+
[mail('send', ref('t', ['order', 'nobody']), 'Send', 1, { continueOnError: true })],
|
|
2949
|
+
[{ id: 'e1', source: 't', target: 'send' }]
|
|
2950
|
+
),
|
|
2951
|
+
'wf-one-of-two': onPaid(
|
|
2952
|
+
'wf-one-of-two',
|
|
2953
|
+
[
|
|
2954
|
+
gate(1, orderNumber, 'NOT-THIS-ORDER'),
|
|
2955
|
+
mail('send', OWNER, 'Skipped', 2),
|
|
2956
|
+
mail('other', OWNER, 'Sent', 3),
|
|
2957
|
+
],
|
|
2958
|
+
[toGate, whenTrue, { id: 'e3', source: 'gate', target: 'other', sourceHandle: 'false' }]
|
|
2959
|
+
),
|
|
2960
|
+
'wf-slow-skipped': onPaid(
|
|
2961
|
+
'wf-slow-skipped',
|
|
2962
|
+
[
|
|
2963
|
+
post('hold', '/hold', 1),
|
|
2964
|
+
gate(2, orderNumber, 'NOT-THIS-ORDER'),
|
|
2965
|
+
mail('send', OWNER, 'Send Email to Owner', 3),
|
|
2966
|
+
],
|
|
2967
|
+
[afterPost, holdToGate, whenTrue]
|
|
2968
|
+
),
|
|
2969
|
+
'wf-wait-then-skipped': {
|
|
2970
|
+
id: 'wf-wait-then-skipped',
|
|
2971
|
+
name: 'wf-wait-then-skipped',
|
|
2972
|
+
trigger: trigger('user-signed-up'),
|
|
2973
|
+
nodes: [
|
|
2974
|
+
{ ...parks, stepNumber: 1 },
|
|
2975
|
+
post('hold', '/hold', 2),
|
|
2976
|
+
gate(3, userEmail, 'nobody@example.com'),
|
|
2977
|
+
mail('send', OWNER, 'Send Email to Owner', 4),
|
|
2978
|
+
],
|
|
2979
|
+
edges: [
|
|
2980
|
+
{ id: 'e0', source: 't', target: 'wait' },
|
|
2981
|
+
{ id: 'e1', source: 'wait', target: 'hold' },
|
|
2982
|
+
holdToGate,
|
|
2983
|
+
whenTrue,
|
|
2984
|
+
],
|
|
2985
|
+
},
|
|
2986
|
+
'wf-mail-then-wait': {
|
|
2987
|
+
id: 'wf-mail-then-wait',
|
|
2988
|
+
name: 'wf-mail-then-wait',
|
|
2989
|
+
trigger: trigger('user-signed-up'),
|
|
2990
|
+
nodes: [
|
|
2991
|
+
mail('send', OWNER, 'Welcome', 1),
|
|
2992
|
+
{ ...parks, stepNumber: 2 },
|
|
2993
|
+
post('after', '/after', 3),
|
|
2994
|
+
],
|
|
2995
|
+
edges: [
|
|
2996
|
+
{ id: 'e0', source: 't', target: 'send' },
|
|
2997
|
+
{ id: 'e1', source: 'send', target: 'wait' },
|
|
2998
|
+
{ id: 'e2', source: 'wait', target: 'after' },
|
|
2999
|
+
],
|
|
3000
|
+
},
|
|
3001
|
+
}
|
|
3002
|
+
const NOTE = JSON.stringify({ sentNothing: ['Send Email to Owner'] })
|
|
3003
|
+
let files: Map<string, string>
|
|
3004
|
+
|
|
3005
|
+
beforeAll(async () => {
|
|
3006
|
+
files = sources(await generate(WORKFLOWS))
|
|
3007
|
+
})
|
|
3008
|
+
|
|
3009
|
+
const paidOrder = (workflowId: string) => {
|
|
3010
|
+
const db = new FakeDatabase()
|
|
3011
|
+
seedOrderPaid(db)
|
|
3012
|
+
db.automations = [automationRow(workflowId, 'order-paid')]
|
|
3013
|
+
return db
|
|
3014
|
+
}
|
|
3015
|
+
const signup = (workflowId: string) => {
|
|
3016
|
+
const db = new FakeDatabase()
|
|
3017
|
+
db.users.push({ id: 'user-1', email: 'noah@example.com', name: 'Noah', created_at: ago(1000) })
|
|
3018
|
+
db.automations = [automationRow(workflowId, 'user-signed-up')]
|
|
3019
|
+
db.events.push(
|
|
3020
|
+
eventRow({
|
|
3021
|
+
id: 'evt-signup',
|
|
3022
|
+
event_type: 'user-signed-up',
|
|
3023
|
+
entity_type: 'user',
|
|
3024
|
+
entity_id: 'user-1',
|
|
3025
|
+
actor: 'noah@example.com',
|
|
3026
|
+
actor_type: 'customer',
|
|
3027
|
+
})
|
|
3028
|
+
)
|
|
3029
|
+
return db
|
|
3030
|
+
}
|
|
3031
|
+
const dispatch = (db: FakeDatabase) =>
|
|
3032
|
+
withEcho(() =>
|
|
3033
|
+
silenced(() => (createApp(files, db)('lib/automations/event-bus.js') as Bus).dispatch({}))
|
|
3034
|
+
)
|
|
3035
|
+
/** A dispatch waiting 20 ms for a handler held at its first fetch, released afterwards. */
|
|
3036
|
+
const dispatchHeldOpen = async (db: FakeDatabase) => {
|
|
3037
|
+
const held: Array<() => void> = []
|
|
3038
|
+
const scope = globalThis as Record<string, unknown>
|
|
3039
|
+
const previousFetch = scope.fetch
|
|
3040
|
+
scope.fetch = () =>
|
|
3041
|
+
new Promise((resolve) => {
|
|
3042
|
+
held.push(() =>
|
|
3043
|
+
resolve({
|
|
3044
|
+
ok: true,
|
|
3045
|
+
status: 200,
|
|
3046
|
+
headers: new Map([['content-type', 'application/json']]),
|
|
3047
|
+
json: async () => ({}),
|
|
3048
|
+
text: async () => '{}',
|
|
3049
|
+
})
|
|
3050
|
+
)
|
|
3051
|
+
})
|
|
3052
|
+
try {
|
|
3053
|
+
await withEnv(ENV, () =>
|
|
3054
|
+
silenced(async () => {
|
|
3055
|
+
await (createApp(files, db)('lib/automations/event-bus.js') as Bus).dispatch({
|
|
3056
|
+
handlerTimeoutMs: 20,
|
|
3057
|
+
})
|
|
3058
|
+
expect(held).toHaveLength(1)
|
|
3059
|
+
held[0]()
|
|
3060
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
3061
|
+
})
|
|
3062
|
+
)
|
|
3063
|
+
} finally {
|
|
3064
|
+
scope.fetch = previousFetch
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
it('records the send steps a condition skipped, and the run stays delivered', async () => {
|
|
3069
|
+
const db = paidOrder('wf-skipped')
|
|
3070
|
+
|
|
3071
|
+
const result = await dispatch(db)
|
|
3072
|
+
|
|
3073
|
+
expect(result).toEqual({ claimed: 1, delivered: 1, failed: 0, resumed: 0 })
|
|
3074
|
+
expect(db.mailSent).toEqual([])
|
|
3075
|
+
expect(db.delivery('wf-skipped')).toMatchObject({ status: 'delivered', error: NOTE })
|
|
3076
|
+
expect(db.events[0]).toMatchObject({ status: 'delivered', attempts: 1 })
|
|
3077
|
+
})
|
|
3078
|
+
|
|
3079
|
+
it('records nothing when a send step sent, or when the automation has none', async () => {
|
|
3080
|
+
const sent = paidOrder('wf-sent')
|
|
3081
|
+
await dispatch(sent)
|
|
3082
|
+
expect(sent.mailSent).toHaveLength(1)
|
|
3083
|
+
expect(sent.delivery('wf-sent')).toMatchObject({ status: 'delivered', error: null })
|
|
3084
|
+
|
|
3085
|
+
const oneOfTwo = paidOrder('wf-one-of-two')
|
|
3086
|
+
await dispatch(oneOfTwo)
|
|
3087
|
+
expect(oneOfTwo.mailSent).toHaveLength(1)
|
|
3088
|
+
expect(oneOfTwo.delivery('wf-one-of-two')).toMatchObject({ status: 'delivered', error: null })
|
|
3089
|
+
})
|
|
3090
|
+
|
|
3091
|
+
it('counts a send that failed and went on as unsent', async () => {
|
|
3092
|
+
const db = paidOrder('wf-failed-and-went-on')
|
|
3093
|
+
await dispatch(db)
|
|
3094
|
+
expect(db.delivery('wf-failed-and-went-on')).toMatchObject({
|
|
3095
|
+
status: 'delivered',
|
|
3096
|
+
error: JSON.stringify({ sentNothing: ['Send'] }),
|
|
3097
|
+
})
|
|
3098
|
+
})
|
|
3099
|
+
|
|
3100
|
+
it('leaves a send that stops the run failing as before, with its own error', async () => {
|
|
3101
|
+
const db = paidOrder('wf-no-recipient')
|
|
3102
|
+
const result = await dispatch(db)
|
|
3103
|
+
expect(result).toEqual({ claimed: 1, delivered: 0, failed: 1, resumed: 0 })
|
|
3104
|
+
expect(db.mailSent).toEqual([])
|
|
3105
|
+
expect(db.delivery('wf-no-recipient')).toMatchObject({
|
|
3106
|
+
status: 'failed',
|
|
3107
|
+
error: 'No recipient',
|
|
3108
|
+
})
|
|
3109
|
+
})
|
|
3110
|
+
|
|
3111
|
+
it('records it for a run that ends after the dispatcher stopped waiting', async () => {
|
|
3112
|
+
const db = paidOrder('wf-slow-skipped')
|
|
3113
|
+
await dispatchHeldOpen(db)
|
|
3114
|
+
expect(db.delivery('wf-slow-skipped')).toMatchObject({ status: 'delivered', error: NOTE })
|
|
3115
|
+
})
|
|
3116
|
+
|
|
3117
|
+
it('records it for a run resumed after its Wait', async () => {
|
|
3118
|
+
const db = signup('wf-wait-then-skipped')
|
|
3119
|
+
await dispatch(db)
|
|
3120
|
+
expect(db.delivery('wf-wait-then-skipped')).toMatchObject({ status: 'waiting', error: null })
|
|
3121
|
+
|
|
3122
|
+
db.waits[0].run_at = ago(1000)
|
|
3123
|
+
await dispatch(db)
|
|
3124
|
+
|
|
3125
|
+
expect(db.delivery('wf-wait-then-skipped')).toMatchObject({
|
|
3126
|
+
status: 'delivered',
|
|
3127
|
+
error: NOTE,
|
|
3128
|
+
})
|
|
3129
|
+
})
|
|
3130
|
+
|
|
3131
|
+
it('records it for a resume that ends after the dispatcher stopped waiting', async () => {
|
|
3132
|
+
const db = signup('wf-wait-then-skipped')
|
|
3133
|
+
await dispatch(db)
|
|
3134
|
+
db.waits[0].run_at = ago(1000)
|
|
3135
|
+
await dispatchHeldOpen(db)
|
|
3136
|
+
expect(db.delivery('wf-wait-then-skipped')).toMatchObject({
|
|
3137
|
+
status: 'delivered',
|
|
3138
|
+
error: NOTE,
|
|
3139
|
+
})
|
|
3140
|
+
})
|
|
3141
|
+
|
|
3142
|
+
it('counts a mail sent before the Wait when the run resumes after it', async () => {
|
|
3143
|
+
const db = signup('wf-mail-then-wait')
|
|
3144
|
+
await dispatch(db)
|
|
3145
|
+
db.waits[0].run_at = ago(1000)
|
|
3146
|
+
await dispatch(db)
|
|
3147
|
+
expect(db.mailSent).toHaveLength(1)
|
|
3148
|
+
expect(db.delivery('wf-mail-then-wait')).toMatchObject({ status: 'delivered', error: null })
|
|
3149
|
+
})
|
|
3150
|
+
|
|
3151
|
+
it('records it for a test event, whose row stays a test', async () => {
|
|
3152
|
+
const db = paidOrder('wf-skipped')
|
|
3153
|
+
db.events = []
|
|
3154
|
+
|
|
3155
|
+
const result = await withEcho(() =>
|
|
3156
|
+
silenced(() => {
|
|
3157
|
+
const { runTest } = createApp(files, db)('lib/automations/dispatcher.js') as {
|
|
3158
|
+
runTest(input: Row, options: Row): Promise<Totals>
|
|
3159
|
+
}
|
|
3160
|
+
return runTest(
|
|
3161
|
+
{
|
|
3162
|
+
workflowId: 'wf-skipped',
|
|
3163
|
+
event: { order: { number: 'ORD-TEST', customer: { email: 'test@example.com' } } },
|
|
3164
|
+
},
|
|
3165
|
+
{}
|
|
3166
|
+
)
|
|
3167
|
+
})
|
|
3168
|
+
)
|
|
3169
|
+
|
|
3170
|
+
expect(result).toEqual({ claimed: 1, delivered: 1, failed: 0, resumed: 0 })
|
|
3171
|
+
expect(db.delivery('wf-skipped')).toMatchObject({ status: 'test', error: NOTE })
|
|
3172
|
+
})
|
|
3173
|
+
|
|
3174
|
+
describe('which steps a run can report', () => {
|
|
3175
|
+
const sendStepsOf = async (workflow: Row): Promise<Row[]> => {
|
|
3176
|
+
const { generateHandlerFunction } = await import('../src/api-route-generator')
|
|
3177
|
+
const source = generateHandlerFunction(JSON.parse(JSON.stringify(workflow)))
|
|
3178
|
+
return JSON.parse(/var SEND_STEPS = (.*);/.exec(source)![1])
|
|
3179
|
+
}
|
|
3180
|
+
const sms = {
|
|
3181
|
+
id: 'text',
|
|
3182
|
+
type: 'sms-twilio',
|
|
3183
|
+
config: { to: '+15550142', message: 'Paid' },
|
|
3184
|
+
stepNumber: 3,
|
|
3185
|
+
label: 'Text the owner',
|
|
3186
|
+
}
|
|
3187
|
+
const email = mail('send', OWNER, 'Send Email to Owner', 2)
|
|
3188
|
+
const steps = [
|
|
3189
|
+
{ id: 'send', label: 'Send Email to Owner' },
|
|
3190
|
+
{ id: 'text', label: 'Text the owner' },
|
|
3191
|
+
]
|
|
3192
|
+
|
|
3193
|
+
it('lists the email and SMS steps, and nothing else', async () => {
|
|
3194
|
+
expect(await sendStepsOf(onPaid('w', [post('p', '/p', 1), email, sms], []))).toEqual(steps)
|
|
3195
|
+
expect(await sendStepsOf(onPaid('w', [post('p', '/p', 1)], []))).toEqual([])
|
|
3196
|
+
})
|
|
3197
|
+
|
|
3198
|
+
it('leaves out the steps of the error branch, which a run that ends well never reaches', async () => {
|
|
3199
|
+
const onError = mail('alarm', OWNER, 'Tell me it broke', 4)
|
|
3200
|
+
const workflow = {
|
|
3201
|
+
...onPaid(
|
|
3202
|
+
'w',
|
|
3203
|
+
[
|
|
3204
|
+
email,
|
|
3205
|
+
{ id: 'err', type: 'event-workflow-error', config: {}, stepNumber: 3, label: 'Error' },
|
|
3206
|
+
onError,
|
|
3207
|
+
],
|
|
3208
|
+
[{ id: 'e1', source: 'err', target: 'alarm' }]
|
|
3209
|
+
),
|
|
3210
|
+
errorHandler: { nodeId: 'err' },
|
|
3211
|
+
}
|
|
3212
|
+
expect(await sendStepsOf(workflow)).toEqual([{ id: 'send', label: 'Send Email to Owner' }])
|
|
3213
|
+
})
|
|
3214
|
+
|
|
3215
|
+
it('reports nothing when a step it cannot see may have sent', async () => {
|
|
3216
|
+
const customNode = {
|
|
3217
|
+
id: 'custom',
|
|
3218
|
+
type: 'general-custom-node',
|
|
3219
|
+
config: {},
|
|
3220
|
+
stepNumber: 1,
|
|
3221
|
+
label: 'Custom',
|
|
3222
|
+
}
|
|
3223
|
+
const parallel = (config: Row) => ({
|
|
3224
|
+
id: 'both',
|
|
3225
|
+
type: 'general-parallel',
|
|
3226
|
+
config,
|
|
3227
|
+
stepNumber: 1,
|
|
3228
|
+
label: 'Both',
|
|
3229
|
+
})
|
|
3230
|
+
expect(await sendStepsOf(onPaid('w', [customNode, email], []))).toEqual([])
|
|
3231
|
+
expect(await sendStepsOf(onPaid('w', [parallel({ waitForAll: false }), email], []))).toEqual(
|
|
3232
|
+
[]
|
|
3233
|
+
)
|
|
3234
|
+
expect(await sendStepsOf(onPaid('w', [parallel({ waitForAll: true }), email], []))).toEqual([
|
|
3235
|
+
{ id: 'send', label: 'Send Email to Owner' },
|
|
3236
|
+
])
|
|
3237
|
+
expect(await sendStepsOf(onPaid('w', [parallel({}), email], []))).toEqual([
|
|
3238
|
+
{ id: 'send', label: 'Send Email to Owner' },
|
|
3239
|
+
])
|
|
3240
|
+
})
|
|
3241
|
+
})
|
|
3242
|
+
})
|
|
3243
|
+
|
|
2877
3244
|
describe('in-app event bus — emitting and the dispatch route', () => {
|
|
2878
3245
|
let files: Map<string, string>
|
|
2879
3246
|
|
|
@@ -3055,7 +3422,12 @@ describe('in-app event bus — emitting and the dispatch route', () => {
|
|
|
3055
3422
|
})
|
|
3056
3423
|
|
|
3057
3424
|
// A request sent exactly as it was captured: the same headers, the same body.
|
|
3058
|
-
const resend = async (
|
|
3425
|
+
const resend = async (
|
|
3426
|
+
db: FakeDatabase,
|
|
3427
|
+
headers: Record<string, string>,
|
|
3428
|
+
rawBody: string,
|
|
3429
|
+
logging: typeof silenced = silenced
|
|
3430
|
+
) => {
|
|
3059
3431
|
const reply = { status: 0, body: null as unknown }
|
|
3060
3432
|
const res = {
|
|
3061
3433
|
setHeader: () => undefined,
|
|
@@ -3082,7 +3454,7 @@ describe('in-app event bus — emitting and the dispatch route', () => {
|
|
|
3082
3454
|
const calls = await withEcho(
|
|
3083
3455
|
async (recorded) => {
|
|
3084
3456
|
const handler = createApp(files, db)('pages/api/automations/dispatch.js') as RouteHandler
|
|
3085
|
-
await
|
|
3457
|
+
await logging(() => handler(req, res))
|
|
3086
3458
|
return recorded
|
|
3087
3459
|
},
|
|
3088
3460
|
{ TELEPORT_CRON_SECRET: CRON_KEY }
|
|
@@ -3145,6 +3517,71 @@ describe('in-app event bus — emitting and the dispatch route', () => {
|
|
|
3145
3517
|
expect(down.deliveries).toEqual([])
|
|
3146
3518
|
})
|
|
3147
3519
|
|
|
3520
|
+
const asksToCreate = (db: FakeDatabase) =>
|
|
3521
|
+
db.statements.filter((statement) => /\bCREATE\b|pg_advisory_xact_lock/.test(statement))
|
|
3522
|
+
|
|
3523
|
+
it('takes a signed request in the table the editor made, never asking to create it', async () => {
|
|
3524
|
+
const db = new FakeDatabase()
|
|
3525
|
+
seedOrderPaid(db)
|
|
3526
|
+
|
|
3527
|
+
const { reply } = await resend(db, signedHeaders('{}', false), '{}')
|
|
3528
|
+
|
|
3529
|
+
expect(reply.status).toBe(200)
|
|
3530
|
+
expect(db.signedRequests.size).toBe(1)
|
|
3531
|
+
expect(asksToCreate(db)).toEqual([])
|
|
3532
|
+
})
|
|
3533
|
+
|
|
3534
|
+
it('creates the table only when it is really missing, then takes the request in it', async () => {
|
|
3535
|
+
const db = new FakeDatabase()
|
|
3536
|
+
seedOrderPaid(db)
|
|
3537
|
+
db.signedRequestsMissing = true
|
|
3538
|
+
db.mayCreateTables = true
|
|
3539
|
+
const headers = signedHeaders('{}', false)
|
|
3540
|
+
|
|
3541
|
+
expect((await resend(db, headers, '{}')).reply.status).toBe(200)
|
|
3542
|
+
expect(asksToCreate(db)).toEqual([
|
|
3543
|
+
'SELECT pg_advisory_xact_lock(hashtext($1))',
|
|
3544
|
+
'CREATE TABLE IF NOT EXISTS teleport_automation_requests (request_key TEXT PRIMARY KEY, expires_at BIGINT NOT NULL)',
|
|
3545
|
+
'CREATE INDEX IF NOT EXISTS teleport_automation_requests_expires_at ON teleport_automation_requests (expires_at)',
|
|
3546
|
+
])
|
|
3547
|
+
expect(db.signedRequests.size).toBe(1)
|
|
3548
|
+
|
|
3549
|
+
// Replays are still refused, and the table is not asked for again.
|
|
3550
|
+
db.statements.length = 0
|
|
3551
|
+
expect((await resend(db, headers, '{}')).reply.status).toBe(409)
|
|
3552
|
+
expect(asksToCreate(db)).toEqual([])
|
|
3553
|
+
})
|
|
3554
|
+
|
|
3555
|
+
it('refuses a request when its table is missing and cannot be made, naming the table in the log', async () => {
|
|
3556
|
+
const db = new FakeDatabase()
|
|
3557
|
+
seedOrderPaid(db)
|
|
3558
|
+
db.signedRequestsMissing = true
|
|
3559
|
+
const logged: unknown[][] = []
|
|
3560
|
+
const capturingErrors = async <T>(run: () => Promise<T>): Promise<T> => {
|
|
3561
|
+
const sink = jest.spyOn(console, 'error').mockImplementation((...args) => {
|
|
3562
|
+
logged.push(args)
|
|
3563
|
+
})
|
|
3564
|
+
try {
|
|
3565
|
+
return await run()
|
|
3566
|
+
} finally {
|
|
3567
|
+
sink.mockRestore()
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3571
|
+
const { reply } = await resend(db, signedHeaders('{}', false), '{}', capturingErrors)
|
|
3572
|
+
|
|
3573
|
+
expect(reply).toEqual({
|
|
3574
|
+
status: 503,
|
|
3575
|
+
body: { error: 'The request could not be checked. Try again.' },
|
|
3576
|
+
})
|
|
3577
|
+
expect(db.deliveries).toEqual([])
|
|
3578
|
+
expect(db.signedRequests.size).toBe(0)
|
|
3579
|
+
expect(db.statements).toContain('ROLLBACK')
|
|
3580
|
+
const said = logged.map((args) => args.map(String).join(' ')).join('\n')
|
|
3581
|
+
expect(said).toContain('teleport_automation_requests')
|
|
3582
|
+
expect(said).toContain('permission denied for schema public')
|
|
3583
|
+
})
|
|
3584
|
+
|
|
3148
3585
|
it('refuses a caller without the app secret or a cron signature', async () => {
|
|
3149
3586
|
const db = new FakeDatabase()
|
|
3150
3587
|
seedOrderPaid(db)
|
|
@@ -176,7 +176,7 @@ describe('the runtime process shadow in general-custom-js', () => {
|
|
|
176
176
|
describeCopiesOf('teleport-gui', GUI)('the editor copies (teleport-gui)', () => {
|
|
177
177
|
it('protect the same env names and credential pattern', () => {
|
|
178
178
|
const copy = loadTs(GUI_FILES.protectedNames)
|
|
179
|
-
expect([...copy.PROTECTED_ENV_NAMES]).toEqual(CODEGEN.names)
|
|
179
|
+
expect([...copy.PROTECTED_ENV_NAMES].sort()).toEqual([...CODEGEN.names].sort())
|
|
180
180
|
expect(patternOf(copy.PROTECTED_ENV_NAME_PATTERN)).toEqual(CODEGEN.pattern)
|
|
181
181
|
expect(patternOf(copy.PLATFORM_ENV_NAME_PATTERN)).toEqual(CODEGEN.platformPattern)
|
|
182
182
|
expect(patternOf(copy.INTEGRATION_ENV_NAME_PATTERN)).toEqual(CODEGEN.integrationPattern)
|
|
@@ -212,7 +212,7 @@ describeCopiesOf('teleport-services-worker', WORKER)(
|
|
|
212
212
|
() => {
|
|
213
213
|
it('protect the same env names and credential pattern', () => {
|
|
214
214
|
const copy = loadTs(WORKER_FILES.protectedNames)
|
|
215
|
-
expect([...copy.PROTECTED_ENV_NAMES]).toEqual(CODEGEN.names)
|
|
215
|
+
expect([...copy.PROTECTED_ENV_NAMES].sort()).toEqual([...CODEGEN.names].sort())
|
|
216
216
|
expect(patternOf(copy.PROTECTED_ENV_NAME_PATTERN)).toEqual(CODEGEN.pattern)
|
|
217
217
|
expect(patternOf(copy.PLATFORM_ENV_NAME_PATTERN)).toEqual(CODEGEN.platformPattern)
|
|
218
218
|
expect(patternOf(copy.INTEGRATION_ENV_NAME_PATTERN)).toEqual(CODEGEN.integrationPattern)
|