@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.
Files changed (78) hide show
  1. package/__tests__/analytics-outcome-visit-ids.test.ts +583 -0
  2. package/__tests__/analytics-track-outcome.test.ts +71 -3
  3. package/__tests__/auth-password-security.test.ts +101 -0
  4. package/__tests__/automations-bus.test.ts +439 -2
  5. package/__tests__/security-lists-parity.test.ts +2 -2
  6. package/__tests__/security-runtime.test.ts +37 -20
  7. package/dist/cjs/api-route-generator.d.ts +2 -0
  8. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  9. package/dist/cjs/api-route-generator.js +43 -1
  10. package/dist/cjs/api-route-generator.js.map +1 -1
  11. package/dist/cjs/auth-generator.d.ts.map +1 -1
  12. package/dist/cjs/auth-generator.js +48 -19
  13. package/dist/cjs/auth-generator.js.map +1 -1
  14. package/dist/cjs/automations/dispatcher-source.d.ts.map +1 -1
  15. package/dist/cjs/automations/dispatcher-source.js +13 -5
  16. package/dist/cjs/automations/dispatcher-source.js.map +1 -1
  17. package/dist/cjs/automations/event-bus-source.d.ts.map +1 -1
  18. package/dist/cjs/automations/event-bus-source.js +37 -23
  19. package/dist/cjs/automations/event-bus-source.js.map +1 -1
  20. package/dist/cjs/executor-generator.d.ts.map +1 -1
  21. package/dist/cjs/executor-generator.js +4 -0
  22. package/dist/cjs/executor-generator.js.map +1 -1
  23. package/dist/cjs/nodes/account/account-request-password-reset.d.ts.map +1 -1
  24. package/dist/cjs/nodes/account/account-request-password-reset.js +3 -2
  25. package/dist/cjs/nodes/account/account-request-password-reset.js.map +1 -1
  26. package/dist/cjs/nodes/analytics/analytics-track-outcome.d.ts.map +1 -1
  27. package/dist/cjs/nodes/analytics/analytics-track-outcome.js +17 -4
  28. package/dist/cjs/nodes/analytics/analytics-track-outcome.js.map +1 -1
  29. package/dist/cjs/password-reset-route-generator.d.ts +2 -0
  30. package/dist/cjs/password-reset-route-generator.d.ts.map +1 -1
  31. package/dist/cjs/password-reset-route-generator.js +17 -10
  32. package/dist/cjs/password-reset-route-generator.js.map +1 -1
  33. package/dist/cjs/security-runtime/security-runtime-code.d.ts +3 -2
  34. package/dist/cjs/security-runtime/security-runtime-code.d.ts.map +1 -1
  35. package/dist/cjs/security-runtime/security-runtime-code.js +17 -13
  36. package/dist/cjs/security-runtime/security-runtime-code.js.map +1 -1
  37. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  38. package/dist/esm/api-route-generator.d.ts +2 -0
  39. package/dist/esm/api-route-generator.d.ts.map +1 -1
  40. package/dist/esm/api-route-generator.js +44 -2
  41. package/dist/esm/api-route-generator.js.map +1 -1
  42. package/dist/esm/auth-generator.d.ts.map +1 -1
  43. package/dist/esm/auth-generator.js +48 -19
  44. package/dist/esm/auth-generator.js.map +1 -1
  45. package/dist/esm/automations/dispatcher-source.d.ts.map +1 -1
  46. package/dist/esm/automations/dispatcher-source.js +13 -5
  47. package/dist/esm/automations/dispatcher-source.js.map +1 -1
  48. package/dist/esm/automations/event-bus-source.d.ts.map +1 -1
  49. package/dist/esm/automations/event-bus-source.js +37 -23
  50. package/dist/esm/automations/event-bus-source.js.map +1 -1
  51. package/dist/esm/executor-generator.d.ts.map +1 -1
  52. package/dist/esm/executor-generator.js +4 -0
  53. package/dist/esm/executor-generator.js.map +1 -1
  54. package/dist/esm/nodes/account/account-request-password-reset.d.ts.map +1 -1
  55. package/dist/esm/nodes/account/account-request-password-reset.js +3 -2
  56. package/dist/esm/nodes/account/account-request-password-reset.js.map +1 -1
  57. package/dist/esm/nodes/analytics/analytics-track-outcome.d.ts.map +1 -1
  58. package/dist/esm/nodes/analytics/analytics-track-outcome.js +17 -4
  59. package/dist/esm/nodes/analytics/analytics-track-outcome.js.map +1 -1
  60. package/dist/esm/password-reset-route-generator.d.ts +2 -0
  61. package/dist/esm/password-reset-route-generator.d.ts.map +1 -1
  62. package/dist/esm/password-reset-route-generator.js +16 -9
  63. package/dist/esm/password-reset-route-generator.js.map +1 -1
  64. package/dist/esm/security-runtime/security-runtime-code.d.ts +3 -2
  65. package/dist/esm/security-runtime/security-runtime-code.d.ts.map +1 -1
  66. package/dist/esm/security-runtime/security-runtime-code.js +17 -13
  67. package/dist/esm/security-runtime/security-runtime-code.js.map +1 -1
  68. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  69. package/package.json +2 -2
  70. package/src/api-route-generator.ts +52 -2
  71. package/src/auth-generator.ts +48 -19
  72. package/src/automations/dispatcher-source.ts +13 -5
  73. package/src/automations/event-bus-source.ts +37 -23
  74. package/src/executor-generator.ts +4 -0
  75. package/src/nodes/account/account-request-password-reset.ts +3 -2
  76. package/src/nodes/analytics/analytics-track-outcome.ts +17 -4
  77. package/src/password-reset-route-generator.ts +18 -9
  78. 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 (db: FakeDatabase, headers: Record<string, string>, rawBody: string) => {
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 silenced(() => handler(req, res))
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)