@teleporthq/teleport-plugin-next-workflows 0.45.1 → 0.45.4
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-track-outcome.test.ts +71 -3
- package/__tests__/auth-password-security.test.ts +101 -0
- package/__tests__/automations-bus.test.ts +85 -2
- package/__tests__/resolve-handler-entry-name.test.ts +21 -0
- package/__tests__/security-lists-parity.test.ts +2 -2
- package/__tests__/security-runtime.test.ts +37 -20
- 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/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/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/nodes/email/outbound-message-guard.d.ts +6 -3
- package/dist/cjs/nodes/email/outbound-message-guard.d.ts.map +1 -1
- package/dist/cjs/nodes/email/outbound-message-guard.js +8 -4
- package/dist/cjs/nodes/email/outbound-message-guard.js.map +1 -1
- package/dist/cjs/nodes/sms/sms-infobip.js +1 -1
- package/dist/cjs/nodes/sms/sms-infobip.js.map +1 -1
- package/dist/cjs/nodes/sms/sms-smsapi.js +1 -1
- package/dist/cjs/nodes/sms/sms-smsapi.js.map +1 -1
- package/dist/cjs/nodes/sms/sms-textmagic.js +1 -1
- package/dist/cjs/nodes/sms/sms-textmagic.js.map +1 -1
- package/dist/cjs/nodes/sms/sms-twilio.js +1 -1
- package/dist/cjs/nodes/sms/sms-twilio.js.map +1 -1
- package/dist/cjs/nodes/types.d.ts +1 -1
- package/dist/cjs/nodes/types.d.ts.map +1 -1
- package/dist/cjs/nodes/types.js +7 -2
- package/dist/cjs/nodes/types.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/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/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/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/nodes/email/outbound-message-guard.d.ts +6 -3
- package/dist/esm/nodes/email/outbound-message-guard.d.ts.map +1 -1
- package/dist/esm/nodes/email/outbound-message-guard.js +8 -4
- package/dist/esm/nodes/email/outbound-message-guard.js.map +1 -1
- package/dist/esm/nodes/sms/sms-infobip.js +1 -1
- package/dist/esm/nodes/sms/sms-infobip.js.map +1 -1
- package/dist/esm/nodes/sms/sms-smsapi.js +1 -1
- package/dist/esm/nodes/sms/sms-smsapi.js.map +1 -1
- package/dist/esm/nodes/sms/sms-textmagic.js +1 -1
- package/dist/esm/nodes/sms/sms-textmagic.js.map +1 -1
- package/dist/esm/nodes/sms/sms-twilio.js +1 -1
- package/dist/esm/nodes/sms/sms-twilio.js.map +1 -1
- package/dist/esm/nodes/types.d.ts +1 -1
- package/dist/esm/nodes/types.d.ts.map +1 -1
- package/dist/esm/nodes/types.js +7 -2
- package/dist/esm/nodes/types.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/auth-generator.ts +48 -19
- package/src/automations/event-bus-source.ts +37 -23
- package/src/nodes/account/account-request-password-reset.ts +3 -2
- package/src/nodes/analytics/analytics-track-outcome.ts +17 -4
- package/src/nodes/email/outbound-message-guard.ts +13 -7
- package/src/nodes/sms/sms-infobip.ts +1 -1
- package/src/nodes/sms/sms-smsapi.ts +1 -1
- package/src/nodes/sms/sms-textmagic.ts +1 -1
- package/src/nodes/sms/sms-twilio.ts +1 -1
- package/src/nodes/types.ts +7 -2
- package/src/password-reset-route-generator.ts +18 -9
- package/src/security-runtime/security-runtime-code.ts +17 -13
|
@@ -88,7 +88,7 @@ describe('analytics-track-outcome node', () => {
|
|
|
88
88
|
value: '49.90',
|
|
89
89
|
currency: 'EUR',
|
|
90
90
|
outcomeId: 'ord-1',
|
|
91
|
-
attribution: { utm_source: 'facebook', visitor_id: 'aid
|
|
91
|
+
attribution: { utm_source: 'facebook', utm_content: 'hero', visitor_id: 'stale-aid' },
|
|
92
92
|
},
|
|
93
93
|
{ __tpa: { sessionId: 'sid-1', visitorId: null, path: '/checkout' } }
|
|
94
94
|
)
|
|
@@ -102,9 +102,77 @@ describe('analytics-track-outcome node', () => {
|
|
|
102
102
|
currency: 'EUR',
|
|
103
103
|
outcomeId: 'ord-1',
|
|
104
104
|
sessionId: 'sid-1',
|
|
105
|
-
visitorId:
|
|
105
|
+
visitorId: null,
|
|
106
106
|
path: '/checkout',
|
|
107
|
-
attribution: { utmSource: 'facebook' },
|
|
107
|
+
attribution: { utmSource: 'facebook', utmContent: 'hero' },
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('server: an order read back outside the visit that placed it', () => {
|
|
112
|
+
const configured = (calls: Array<{ body: string }>) =>
|
|
113
|
+
evalHandler(generator.generateServerHandler?.() ?? '', {
|
|
114
|
+
globalThis: {
|
|
115
|
+
process: {
|
|
116
|
+
env: {
|
|
117
|
+
NEXT_PUBLIC_TELEPORT_ANALYTICS_URL: 'https://analytics.example',
|
|
118
|
+
NEXT_PUBLIC_TELEPORT_ANALYTICS_KEY: 'tpa_key',
|
|
119
|
+
TELEPORT_ANALYTICS_SERVER_KEY: 'tpas_secret',
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
fetch: (_url: string, init: { body: string }) => {
|
|
124
|
+
calls.push(init)
|
|
125
|
+
return Promise.resolve({ status: 202 })
|
|
126
|
+
},
|
|
127
|
+
AbortController: undefined,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('credits the visit the order names, with no ids on the context', async () => {
|
|
131
|
+
const calls: Array<{ body: string }> = []
|
|
132
|
+
await configured(calls)(
|
|
133
|
+
{
|
|
134
|
+
name: 'purchase',
|
|
135
|
+
value: '20.00',
|
|
136
|
+
currency: 'EUR',
|
|
137
|
+
outcomeId: 'ord-2',
|
|
138
|
+
attribution: {
|
|
139
|
+
session_id: 'sid-shopper',
|
|
140
|
+
visitor_id: 'aid-shopper',
|
|
141
|
+
exp: 'hero:b',
|
|
142
|
+
landing_path: '/sale',
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{}
|
|
146
|
+
)
|
|
147
|
+
expect(JSON.parse(calls[0].body).outcomes[0]).toMatchObject({
|
|
148
|
+
outcomeId: 'ord-2',
|
|
149
|
+
sessionId: 'sid-shopper',
|
|
150
|
+
visitorId: 'aid-shopper',
|
|
151
|
+
exp: 'hero:b',
|
|
152
|
+
attribution: { landingPath: '/sale' },
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('never credits the visit of whoever runs the step', async () => {
|
|
157
|
+
const calls: Array<{ body: string }> = []
|
|
158
|
+
await configured(calls)(
|
|
159
|
+
{ name: 'purchase', outcomeId: 'ord-3', attribution: { session_id: 'sid-shopper' } },
|
|
160
|
+
{ __tpa: { sessionId: 'sid-merchant', visitorId: 'aid-merchant', exp: 'hero:a' } }
|
|
161
|
+
)
|
|
162
|
+
expect(JSON.parse(calls[0].body).outcomes[0]).toMatchObject({
|
|
163
|
+
sessionId: 'sid-shopper',
|
|
164
|
+
visitorId: null,
|
|
165
|
+
exp: null,
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('sends nothing for an order that resolved to no id', async () => {
|
|
170
|
+
const calls: Array<{ body: string }> = []
|
|
171
|
+
expect(await configured(calls)({ name: 'purchase', value: '20.00' }, {})).toEqual({
|
|
172
|
+
sent: false,
|
|
173
|
+
reason: 'no-outcome-id',
|
|
174
|
+
})
|
|
175
|
+
expect(calls).toEqual([])
|
|
108
176
|
})
|
|
109
177
|
})
|
|
110
178
|
|
|
@@ -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)'
|
|
@@ -3055,7 +3068,12 @@ describe('in-app event bus — emitting and the dispatch route', () => {
|
|
|
3055
3068
|
})
|
|
3056
3069
|
|
|
3057
3070
|
// A request sent exactly as it was captured: the same headers, the same body.
|
|
3058
|
-
const resend = async (
|
|
3071
|
+
const resend = async (
|
|
3072
|
+
db: FakeDatabase,
|
|
3073
|
+
headers: Record<string, string>,
|
|
3074
|
+
rawBody: string,
|
|
3075
|
+
logging: typeof silenced = silenced
|
|
3076
|
+
) => {
|
|
3059
3077
|
const reply = { status: 0, body: null as unknown }
|
|
3060
3078
|
const res = {
|
|
3061
3079
|
setHeader: () => undefined,
|
|
@@ -3082,7 +3100,7 @@ describe('in-app event bus — emitting and the dispatch route', () => {
|
|
|
3082
3100
|
const calls = await withEcho(
|
|
3083
3101
|
async (recorded) => {
|
|
3084
3102
|
const handler = createApp(files, db)('pages/api/automations/dispatch.js') as RouteHandler
|
|
3085
|
-
await
|
|
3103
|
+
await logging(() => handler(req, res))
|
|
3086
3104
|
return recorded
|
|
3087
3105
|
},
|
|
3088
3106
|
{ TELEPORT_CRON_SECRET: CRON_KEY }
|
|
@@ -3145,6 +3163,71 @@ describe('in-app event bus — emitting and the dispatch route', () => {
|
|
|
3145
3163
|
expect(down.deliveries).toEqual([])
|
|
3146
3164
|
})
|
|
3147
3165
|
|
|
3166
|
+
const asksToCreate = (db: FakeDatabase) =>
|
|
3167
|
+
db.statements.filter((statement) => /\bCREATE\b|pg_advisory_xact_lock/.test(statement))
|
|
3168
|
+
|
|
3169
|
+
it('takes a signed request in the table the editor made, never asking to create it', async () => {
|
|
3170
|
+
const db = new FakeDatabase()
|
|
3171
|
+
seedOrderPaid(db)
|
|
3172
|
+
|
|
3173
|
+
const { reply } = await resend(db, signedHeaders('{}', false), '{}')
|
|
3174
|
+
|
|
3175
|
+
expect(reply.status).toBe(200)
|
|
3176
|
+
expect(db.signedRequests.size).toBe(1)
|
|
3177
|
+
expect(asksToCreate(db)).toEqual([])
|
|
3178
|
+
})
|
|
3179
|
+
|
|
3180
|
+
it('creates the table only when it is really missing, then takes the request in it', async () => {
|
|
3181
|
+
const db = new FakeDatabase()
|
|
3182
|
+
seedOrderPaid(db)
|
|
3183
|
+
db.signedRequestsMissing = true
|
|
3184
|
+
db.mayCreateTables = true
|
|
3185
|
+
const headers = signedHeaders('{}', false)
|
|
3186
|
+
|
|
3187
|
+
expect((await resend(db, headers, '{}')).reply.status).toBe(200)
|
|
3188
|
+
expect(asksToCreate(db)).toEqual([
|
|
3189
|
+
'SELECT pg_advisory_xact_lock(hashtext($1))',
|
|
3190
|
+
'CREATE TABLE IF NOT EXISTS teleport_automation_requests (request_key TEXT PRIMARY KEY, expires_at BIGINT NOT NULL)',
|
|
3191
|
+
'CREATE INDEX IF NOT EXISTS teleport_automation_requests_expires_at ON teleport_automation_requests (expires_at)',
|
|
3192
|
+
])
|
|
3193
|
+
expect(db.signedRequests.size).toBe(1)
|
|
3194
|
+
|
|
3195
|
+
// Replays are still refused, and the table is not asked for again.
|
|
3196
|
+
db.statements.length = 0
|
|
3197
|
+
expect((await resend(db, headers, '{}')).reply.status).toBe(409)
|
|
3198
|
+
expect(asksToCreate(db)).toEqual([])
|
|
3199
|
+
})
|
|
3200
|
+
|
|
3201
|
+
it('refuses a request when its table is missing and cannot be made, naming the table in the log', async () => {
|
|
3202
|
+
const db = new FakeDatabase()
|
|
3203
|
+
seedOrderPaid(db)
|
|
3204
|
+
db.signedRequestsMissing = true
|
|
3205
|
+
const logged: unknown[][] = []
|
|
3206
|
+
const capturingErrors = async <T>(run: () => Promise<T>): Promise<T> => {
|
|
3207
|
+
const sink = jest.spyOn(console, 'error').mockImplementation((...args) => {
|
|
3208
|
+
logged.push(args)
|
|
3209
|
+
})
|
|
3210
|
+
try {
|
|
3211
|
+
return await run()
|
|
3212
|
+
} finally {
|
|
3213
|
+
sink.mockRestore()
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
const { reply } = await resend(db, signedHeaders('{}', false), '{}', capturingErrors)
|
|
3218
|
+
|
|
3219
|
+
expect(reply).toEqual({
|
|
3220
|
+
status: 503,
|
|
3221
|
+
body: { error: 'The request could not be checked. Try again.' },
|
|
3222
|
+
})
|
|
3223
|
+
expect(db.deliveries).toEqual([])
|
|
3224
|
+
expect(db.signedRequests.size).toBe(0)
|
|
3225
|
+
expect(db.statements).toContain('ROLLBACK')
|
|
3226
|
+
const said = logged.map((args) => args.map(String).join(' ')).join('\n')
|
|
3227
|
+
expect(said).toContain('teleport_automation_requests')
|
|
3228
|
+
expect(said).toContain('permission denied for schema public')
|
|
3229
|
+
})
|
|
3230
|
+
|
|
3148
3231
|
it('refuses a caller without the app secret or a cron signature', async () => {
|
|
3149
3232
|
const db = new FakeDatabase()
|
|
3150
3233
|
seedOrderPaid(db)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolveHandlerEntryName } from '../src/nodes/types'
|
|
2
2
|
import { nodeRegistry } from '../src/nodes'
|
|
3
|
+
import { outboundMessageHandlerSources } from '../src/nodes/email/outbound-message-guard'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Regression coverage for resolveHandlerEntryName, which exists because a
|
|
@@ -97,6 +98,26 @@ function(e){try{const t=globalThis.tpTrackCommerce;"function"==typeof t&&t(e.nam
|
|
|
97
98
|
)
|
|
98
99
|
})
|
|
99
100
|
|
|
101
|
+
/**
|
|
102
|
+
* An email/SMS node hands its handler by value to
|
|
103
|
+
* `outboundMessageHandlerSources` at module level; the editor's production
|
|
104
|
+
* bundle (Terser, Next 12 options) inlines that single-use function into the
|
|
105
|
+
* call as an anonymous expression, so publishing failed with the error above
|
|
106
|
+
* for "email-postmark" and "email-resend".
|
|
107
|
+
*/
|
|
108
|
+
it('names an email/SMS handler a minifier inlined anonymous into its sources call', () => {
|
|
109
|
+
const inlined = outboundMessageHandlerSources(
|
|
110
|
+
async function (e: unknown, t: Record<string, unknown>) {
|
|
111
|
+
return { e, t }
|
|
112
|
+
},
|
|
113
|
+
{ nodeType: 'email-resend' }
|
|
114
|
+
)
|
|
115
|
+
for (const source of [inlined.generateHandler(), inlined.generateServerHandler!()]) {
|
|
116
|
+
expect(resolveHandlerEntryName(source.trim(), 'email-resend')).toBe('email_resend')
|
|
117
|
+
expect(typeof evalEntryWrapper(source.trim(), 'email_resend')).toBe('function')
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
100
121
|
it('accepts an anonymous function that is an expression, not a statement', () => {
|
|
101
122
|
// `wrapWithGuard`'s re-declaration guard and any inline callback keep the
|
|
102
123
|
// `function` keyword off the start of a line — those are valid and common.
|
|
@@ -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)
|
|
@@ -61,7 +61,9 @@ class FakeDatabase {
|
|
|
61
61
|
/** `connect`: connect() never settles; `query`: no query settles; `cleanup`: the DELETE never settles. */
|
|
62
62
|
hang: 'connect' | 'query' | 'cleanup' | null = null
|
|
63
63
|
connectDelayMs = 0
|
|
64
|
-
tableExists =
|
|
64
|
+
tableExists = true
|
|
65
|
+
/** Off, as the site's role connects: it may read and write tables, and create none, whether or not one exists. */
|
|
66
|
+
mayCreateTables = true
|
|
65
67
|
|
|
66
68
|
rowKey(key: unknown, windowStart: unknown): string {
|
|
67
69
|
return `${key} @ ${windowStart}`
|
|
@@ -93,6 +95,9 @@ class FakeDatabase {
|
|
|
93
95
|
return none
|
|
94
96
|
}
|
|
95
97
|
if (sql === CREATE_SQL) {
|
|
98
|
+
if (!this.mayCreateTables) {
|
|
99
|
+
throw Object.assign(new Error('permission denied for schema public'), { code: '42501' })
|
|
100
|
+
}
|
|
96
101
|
this.tableExists = true
|
|
97
102
|
return none
|
|
98
103
|
}
|
|
@@ -287,44 +292,56 @@ describe('rateLimit — Postgres-backed', () => {
|
|
|
287
292
|
expect(wire).not.toContain('203.0.113.7')
|
|
288
293
|
})
|
|
289
294
|
|
|
290
|
-
it('
|
|
295
|
+
it('counts in the table the editor made, never asking to create it', async () => {
|
|
291
296
|
const db = new FakeDatabase()
|
|
292
|
-
|
|
293
|
-
const { runtime } = loadRuntime({ db, clock })
|
|
297
|
+
db.mayCreateTables = false
|
|
298
|
+
const { runtime, log } = loadRuntime({ db, clock: { now: MIDNIGHT + 5000 } })
|
|
294
299
|
await runtime.rateLimit({ key: 'k', limit: 5, windowSeconds: 60 })
|
|
295
300
|
await settle()
|
|
296
301
|
await runtime.rateLimit({ key: 'k', limit: 5, windowSeconds: 60 })
|
|
297
|
-
expect(db.queries.map((query) => query.sql)).toEqual([
|
|
302
|
+
expect(db.queries.map((query) => query.sql)).toEqual([HIT_SQL, CLEANUP_SQL, HIT_SQL])
|
|
303
|
+
expect(Array.from(db.rows.values()).map((row) => row.count)).toEqual([2])
|
|
304
|
+
expect(log.error).not.toHaveBeenCalled()
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('creates the table when it is really missing, under an advisory lock, within the same request', async () => {
|
|
308
|
+
const db = new FakeDatabase()
|
|
309
|
+
const { runtime, log } = loadRuntime({ db, clock: { now: MIDNIGHT + 5000 } })
|
|
310
|
+
await runtime.rateLimit({ key: 'k', limit: 5, windowSeconds: 60 })
|
|
311
|
+
// Dropped, or the database restored from a backup taken before it existed.
|
|
312
|
+
db.dropTable()
|
|
313
|
+
db.queries.length = 0
|
|
314
|
+
expect(await runtime.rateLimit({ key: 'k', limit: 5, windowSeconds: 60 })).toEqual({
|
|
315
|
+
allowed: true,
|
|
316
|
+
retryAfter: 0,
|
|
317
|
+
})
|
|
318
|
+
expect(db.queries.map((query) => query.sql).slice(0, 7)).toEqual([
|
|
319
|
+
HIT_SQL,
|
|
298
320
|
'BEGIN',
|
|
299
321
|
LOCK_SQL,
|
|
300
322
|
CREATE_SQL,
|
|
301
323
|
INDEX_SQL,
|
|
302
324
|
'COMMIT',
|
|
303
325
|
HIT_SQL,
|
|
304
|
-
CLEANUP_SQL,
|
|
305
|
-
HIT_SQL,
|
|
306
326
|
])
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
expect(
|
|
310
|
-
expect(db.statements(INDEX_SQL)).toHaveLength(2)
|
|
311
|
-
expect(db.statements(LOCK_SQL)).toHaveLength(2)
|
|
327
|
+
// Counted in the database, not in memory.
|
|
328
|
+
expect(Array.from(db.rows.values()).map((row) => row.count)).toEqual([1])
|
|
329
|
+
expect(log.error).not.toHaveBeenCalled()
|
|
312
330
|
})
|
|
313
331
|
|
|
314
|
-
it('
|
|
332
|
+
it('counts in memory, naming the table in the log, when it is missing and cannot be made', async () => {
|
|
315
333
|
const db = new FakeDatabase()
|
|
316
|
-
const { runtime, log } = loadRuntime({ db, clock: { now: MIDNIGHT + 5000 } })
|
|
317
|
-
await runtime.rateLimit({ key: 'k', limit: 5, windowSeconds: 60 })
|
|
318
|
-
// Dropped, or the database restored from a backup taken before it existed.
|
|
319
334
|
db.dropTable()
|
|
335
|
+
db.mayCreateTables = false
|
|
336
|
+
const { runtime, log } = loadRuntime({ db, clock: { now: MIDNIGHT + 5000 } })
|
|
320
337
|
expect(await runtime.rateLimit({ key: 'k', limit: 5, windowSeconds: 60 })).toEqual({
|
|
321
338
|
allowed: true,
|
|
322
339
|
retryAfter: 0,
|
|
323
340
|
})
|
|
324
|
-
expect(
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
341
|
+
expect(log.error).toHaveBeenCalledTimes(1)
|
|
342
|
+
expect(String(log.error.mock.calls[0][0])).toContain(
|
|
343
|
+
'the table teleport_rate_limits is missing and could not be created: permission denied for schema public'
|
|
344
|
+
)
|
|
328
345
|
})
|
|
329
346
|
|
|
330
347
|
it('keeps one counter per window length, so a minute and a day limit never share a row', async () => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-generator.d.ts","sourceRoot":"","sources":["../../src/auth-generator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,kBAAkB,EAClB,mBAAmB,EACnB,sBAAsB,EACtB,2BAA2B,EAC3B,cAAc,EACf,MAAM,4BAA4B,CAAA;AAsBnC;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,KAAK,CAAA;AACvC,eAAO,MAAM,qBAAqB,KAAK,CAAA;AACvC,eAAO,MAAM,sBAAsB,MAAM,CAAA;AACzC,6EAA6E;AAC7E,eAAO,MAAM,6BAA6B,KAAK,CAAA;AAC/C,qCAAqC;AACrC,eAAO,MAAM,aAAa,KAAK,CAAA;AAC/B;;;GAGG;AACH,eAAO,MAAM,4BAA4B,IAAI,CAAA;AAC7C,eAAO,MAAM,sBAAsB,OAAO,CAAA;AAY1C,eAAO,MAAM,6BAA6B,mBACxB,cAAc,GAAG,IAAI,KACpC,OAAO,MAAM,EAAE,MAAM,CA0BvB,CAAA;AAwID;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,QAAQ,CAAA;AAE7C;;;;;;;;;;GAUG;AACH,eAAO,MAAM,wBAAwB,EAAE,SAAS,MAAM,EAQrD,CAAA;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB,EAAE,SAAS,MAAM,EAalD,CAAA;AAkBD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,WACzB,OAAO,MAAM,EAAE,mBAAmB,EAAE,CAAC,GAAG,SAAS,eAC5C,sBAAsB,EAAE,KACpC,MAAM,EAUR,CAAA;AA8FD;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,EAAE,SAAS,MAAM,EAAoC,CAAA;AAqTpF,+EAA+E;AAC/E,eAAO,MAAM,qBAAqB,6BAA6B,CAAA;
|
|
1
|
+
{"version":3,"file":"auth-generator.d.ts","sourceRoot":"","sources":["../../src/auth-generator.ts"],"names":[],"mappings":"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"}
|
|
@@ -665,13 +665,15 @@ exports.PASSWORD_RESETS_TABLE = 'teleport_password_resets';
|
|
|
665
665
|
const PG_PASSWORD_RESET_FUNCTIONS = `
|
|
666
666
|
// Password-reset links (pages/api/auth/forgot-password + reset-password). Only
|
|
667
667
|
// the SHA-256 of a link's token is stored, bound to the account id, with its
|
|
668
|
-
// expiry
|
|
669
|
-
//
|
|
668
|
+
// expiry. The editor makes the table with the other account tables, and a site's
|
|
669
|
+
// database role may read and write it without being allowed to create one
|
|
670
|
+
// (CREATE ... IF NOT EXISTS asks for that permission even when the table is
|
|
671
|
+
// there), so a create is attempted only for a table that really is missing. The
|
|
672
|
+
// earlier password_reset_tokens table and its rows are never read.
|
|
670
673
|
var PASSWORD_RESETS_TABLE = '${exports.PASSWORD_RESETS_TABLE}';
|
|
671
|
-
var
|
|
674
|
+
var UNDEFINED_TABLE_CODE = '42P01';
|
|
672
675
|
|
|
673
|
-
async function
|
|
674
|
-
if (__passwordResetsTableReady) return;
|
|
676
|
+
async function createPasswordResetsTable(client) {
|
|
675
677
|
await client.query('BEGIN');
|
|
676
678
|
try {
|
|
677
679
|
await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [PASSWORD_RESETS_TABLE]);
|
|
@@ -683,28 +685,53 @@ async function ensurePasswordResetsTable(client) {
|
|
|
683
685
|
await client.query('COMMIT');
|
|
684
686
|
} catch (err) {
|
|
685
687
|
try { await client.query('ROLLBACK'); } catch (_e) {}
|
|
686
|
-
throw err;
|
|
688
|
+
throw new Error('[password-reset] the table ' + PASSWORD_RESETS_TABLE + ' is missing and could not be created: ' + (err && err.message ? err.message : String(err)));
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Throws when a link could not be kept. Asked for every address, with an account
|
|
693
|
+
// or without, so a table that cannot be used refuses them all alike.
|
|
694
|
+
async function ensurePasswordResetsUsable() {
|
|
695
|
+
const client = getClient();
|
|
696
|
+
try {
|
|
697
|
+
await client.connect();
|
|
698
|
+
try {
|
|
699
|
+
await client.query('SELECT 1 FROM ' + PASSWORD_RESETS_TABLE + ' LIMIT 1');
|
|
700
|
+
} catch (err) {
|
|
701
|
+
if (!err || err.code !== UNDEFINED_TABLE_CODE) throw err;
|
|
702
|
+
await createPasswordResetsTable(client);
|
|
703
|
+
}
|
|
704
|
+
} finally {
|
|
705
|
+
try { await client.end(); } catch (_e) {}
|
|
687
706
|
}
|
|
688
|
-
__passwordResetsTableReady = true;
|
|
689
707
|
}
|
|
690
708
|
|
|
691
709
|
// One live link per account: a new one ends the ones sent before it.
|
|
710
|
+
async function writePasswordReset(client, userId, tokenHash, ttlSeconds) {
|
|
711
|
+
await client.query(
|
|
712
|
+
'UPDATE ' + PASSWORD_RESETS_TABLE + ' SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL',
|
|
713
|
+
[String(userId)]
|
|
714
|
+
);
|
|
715
|
+
await client.query(
|
|
716
|
+
'INSERT INTO ' + PASSWORD_RESETS_TABLE + " (token_hash, user_id, expires_at) VALUES ($1, $2, NOW() + ($3::int * INTERVAL '1 second'))",
|
|
717
|
+
[tokenHash, String(userId), ttlSeconds]
|
|
718
|
+
);
|
|
719
|
+
await client.query(
|
|
720
|
+
'DELETE FROM ' + PASSWORD_RESETS_TABLE + " WHERE expires_at < NOW() - INTERVAL '1 day'"
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
|
|
692
724
|
async function storePasswordReset(userId, tokenHash, ttlSeconds) {
|
|
693
725
|
const client = getClient();
|
|
694
726
|
try {
|
|
695
727
|
await client.connect();
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
[tokenHash, String(userId), ttlSeconds]
|
|
704
|
-
);
|
|
705
|
-
await client.query(
|
|
706
|
-
'DELETE FROM ' + PASSWORD_RESETS_TABLE + " WHERE expires_at < NOW() - INTERVAL '1 day'"
|
|
707
|
-
);
|
|
728
|
+
try {
|
|
729
|
+
await writePasswordReset(client, userId, tokenHash, ttlSeconds);
|
|
730
|
+
} catch (err) {
|
|
731
|
+
if (!err || err.code !== UNDEFINED_TABLE_CODE) throw err;
|
|
732
|
+
await createPasswordResetsTable(client);
|
|
733
|
+
await writePasswordReset(client, userId, tokenHash, ttlSeconds);
|
|
734
|
+
}
|
|
708
735
|
} finally {
|
|
709
736
|
try { await client.end(); } catch (_e) {}
|
|
710
737
|
}
|
|
@@ -759,6 +786,7 @@ const PASSWORD_RESET_SUPPORTED = true;`;
|
|
|
759
786
|
const UNSUPPORTED_PASSWORD_RESET_FUNCTIONS = `
|
|
760
787
|
// Password-reset links need a Postgres database; this project's user store is
|
|
761
788
|
// not one, so the reset routes answer without ever sending a link.
|
|
789
|
+
async function ensurePasswordResetsUsable() { throw new Error('Password reset needs a Postgres database'); }
|
|
762
790
|
async function storePasswordReset() { throw new Error('Password reset needs a Postgres database'); }
|
|
763
791
|
async function consumePasswordReset() { return null; }
|
|
764
792
|
async function passwordResetIsLive() { return false; }
|
|
@@ -1248,6 +1276,7 @@ ${auth.passwordAuthEnabled
|
|
|
1248
1276
|
module.exports.checkSignInAllowed = checkSignInAllowed;
|
|
1249
1277
|
module.exports.countSignInFailure = countSignInFailure;
|
|
1250
1278
|
module.exports.SIGN_IN_VISITOR_HEADER = SIGN_IN_VISITOR_HEADER;
|
|
1279
|
+
module.exports.ensurePasswordResetsUsable = ensurePasswordResetsUsable;
|
|
1251
1280
|
module.exports.storePasswordReset = storePasswordReset;
|
|
1252
1281
|
module.exports.consumePasswordReset = consumePasswordReset;
|
|
1253
1282
|
module.exports.passwordResetIsLive = passwordResetIsLive;
|