@teleporthq/teleport-plugin-next-workflows 0.44.8 → 0.44.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__tests__/_helpers/load-server-runtime.ts +6 -2
- package/__tests__/_helpers/run-account-delete-route.ts +140 -0
- package/__tests__/account-delete-current-password.test.ts +73 -0
- package/__tests__/account-delete-password-check.test.ts +113 -0
- package/__tests__/account-delete-visitor-details.test.ts +206 -0
- package/__tests__/ai-error-contract.test.ts +10 -0
- package/__tests__/auth-password-change-ends-sessions.test.ts +178 -0
- package/__tests__/continue-on-error.test.ts +27 -0
- package/__tests__/data-api-invalid-body.test.ts +110 -0
- package/__tests__/data-node-not-awaited.test.ts +7 -6
- package/__tests__/email-node-recipients.test.ts +288 -0
- package/__tests__/invoice-payment-line.test.ts +68 -0
- package/__tests__/invoice-tender.test.ts +116 -0
- package/__tests__/pending-work-after-reply.test.ts +294 -0
- package/__tests__/runtime-condition-operands.test.ts +86 -0
- package/__tests__/sent-email-log-module.test.ts +54 -0
- package/__tests__/webhook-delivery-lease.test.ts +68 -6
- package/dist/cjs/account-delete-route-generator.d.ts +7 -2
- package/dist/cjs/account-delete-route-generator.d.ts.map +1 -1
- package/dist/cjs/account-delete-route-generator.js +90 -43
- package/dist/cjs/account-delete-route-generator.js.map +1 -1
- package/dist/cjs/api-route-generator.d.ts.map +1 -1
- package/dist/cjs/api-route-generator.js +12 -13
- package/dist/cjs/api-route-generator.js.map +1 -1
- package/dist/cjs/auth-generator.d.ts.map +1 -1
- package/dist/cjs/auth-generator.js +34 -1
- package/dist/cjs/auth-generator.js.map +1 -1
- package/dist/cjs/await-result.d.ts +13 -5
- package/dist/cjs/await-result.d.ts.map +1 -1
- package/dist/cjs/await-result.js +19 -8
- package/dist/cjs/await-result.js.map +1 -1
- package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
- package/dist/cjs/data-api-route-generator.js +9 -1
- package/dist/cjs/data-api-route-generator.js.map +1 -1
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +22 -7
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
- package/dist/cjs/invoice/invoice-html-code.js +96 -5
- package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
- package/dist/cjs/nodes/account/account-delete-current.d.ts.map +1 -1
- package/dist/cjs/nodes/account/account-delete-current.js +4 -1
- package/dist/cjs/nodes/account/account-delete-current.js.map +1 -1
- package/dist/cjs/nodes/email/email-mailersend.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-mailersend.js +55 -48
- package/dist/cjs/nodes/email/email-mailersend.js.map +1 -1
- package/dist/cjs/nodes/email/email-mailgun.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-mailgun.js +44 -32
- package/dist/cjs/nodes/email/email-mailgun.js.map +1 -1
- package/dist/cjs/nodes/email/email-postmark.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-postmark.js +44 -31
- package/dist/cjs/nodes/email/email-postmark.js.map +1 -1
- package/dist/cjs/nodes/email/email-recipients.d.ts +28 -0
- package/dist/cjs/nodes/email/email-recipients.d.ts.map +1 -0
- package/dist/cjs/nodes/email/email-recipients.js +74 -0
- package/dist/cjs/nodes/email/email-recipients.js.map +1 -0
- package/dist/cjs/nodes/email/email-resend.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-resend.js +42 -28
- package/dist/cjs/nodes/email/email-resend.js.map +1 -1
- package/dist/cjs/nodes/email/email-sendgrid.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-sendgrid.js +56 -41
- package/dist/cjs/nodes/email/email-sendgrid.js.map +1 -1
- package/dist/cjs/sent-email-log/sent-email-log-code.d.ts.map +1 -1
- package/dist/cjs/sent-email-log/sent-email-log-code.js +17 -1
- package/dist/cjs/sent-email-log/sent-email-log-code.js.map +1 -1
- package/dist/cjs/server-runtime-code.d.ts.map +1 -1
- package/dist/cjs/server-runtime-code.js +21 -0
- package/dist/cjs/server-runtime-code.js.map +1 -1
- package/dist/cjs/transactional-email-code.d.ts +7 -0
- package/dist/cjs/transactional-email-code.d.ts.map +1 -1
- package/dist/cjs/transactional-email-code.js +5 -5
- package/dist/cjs/transactional-email-code.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/vercel-functions.d.ts +34 -0
- package/dist/cjs/vercel-functions.d.ts.map +1 -0
- package/dist/cjs/vercel-functions.js +62 -0
- package/dist/cjs/vercel-functions.js.map +1 -0
- package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.d.ts +6 -0
- package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.d.ts.map +1 -1
- package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.js +26 -11
- package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.js.map +1 -1
- package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-project-plugin.js +3 -0
- package/dist/cjs/workflow-project-plugin.js.map +1 -1
- package/dist/esm/account-delete-route-generator.d.ts +7 -2
- package/dist/esm/account-delete-route-generator.d.ts.map +1 -1
- package/dist/esm/account-delete-route-generator.js +90 -43
- package/dist/esm/account-delete-route-generator.js.map +1 -1
- package/dist/esm/api-route-generator.d.ts.map +1 -1
- package/dist/esm/api-route-generator.js +12 -13
- package/dist/esm/api-route-generator.js.map +1 -1
- package/dist/esm/auth-generator.d.ts.map +1 -1
- package/dist/esm/auth-generator.js +34 -1
- package/dist/esm/auth-generator.js.map +1 -1
- package/dist/esm/await-result.d.ts +13 -5
- package/dist/esm/await-result.d.ts.map +1 -1
- package/dist/esm/await-result.js +18 -7
- package/dist/esm/await-result.js.map +1 -1
- package/dist/esm/data-api-route-generator.d.ts.map +1 -1
- package/dist/esm/data-api-route-generator.js +9 -1
- package/dist/esm/data-api-route-generator.js.map +1 -1
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +22 -7
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
- package/dist/esm/invoice/invoice-html-code.js +96 -5
- package/dist/esm/invoice/invoice-html-code.js.map +1 -1
- package/dist/esm/nodes/account/account-delete-current.d.ts.map +1 -1
- package/dist/esm/nodes/account/account-delete-current.js +4 -1
- package/dist/esm/nodes/account/account-delete-current.js.map +1 -1
- package/dist/esm/nodes/email/email-mailersend.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-mailersend.js +55 -48
- package/dist/esm/nodes/email/email-mailersend.js.map +1 -1
- package/dist/esm/nodes/email/email-mailgun.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-mailgun.js +44 -32
- package/dist/esm/nodes/email/email-mailgun.js.map +1 -1
- package/dist/esm/nodes/email/email-postmark.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-postmark.js +44 -31
- package/dist/esm/nodes/email/email-postmark.js.map +1 -1
- package/dist/esm/nodes/email/email-recipients.d.ts +28 -0
- package/dist/esm/nodes/email/email-recipients.d.ts.map +1 -0
- package/dist/esm/nodes/email/email-recipients.js +71 -0
- package/dist/esm/nodes/email/email-recipients.js.map +1 -0
- package/dist/esm/nodes/email/email-resend.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-resend.js +42 -28
- package/dist/esm/nodes/email/email-resend.js.map +1 -1
- package/dist/esm/nodes/email/email-sendgrid.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-sendgrid.js +56 -41
- package/dist/esm/nodes/email/email-sendgrid.js.map +1 -1
- package/dist/esm/sent-email-log/sent-email-log-code.d.ts.map +1 -1
- package/dist/esm/sent-email-log/sent-email-log-code.js +17 -1
- package/dist/esm/sent-email-log/sent-email-log-code.js.map +1 -1
- package/dist/esm/server-runtime-code.d.ts.map +1 -1
- package/dist/esm/server-runtime-code.js +21 -0
- package/dist/esm/server-runtime-code.js.map +1 -1
- package/dist/esm/transactional-email-code.d.ts +7 -0
- package/dist/esm/transactional-email-code.d.ts.map +1 -1
- package/dist/esm/transactional-email-code.js +5 -5
- package/dist/esm/transactional-email-code.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/vercel-functions.d.ts +34 -0
- package/dist/esm/vercel-functions.d.ts.map +1 -0
- package/dist/esm/vercel-functions.js +57 -0
- package/dist/esm/vercel-functions.js.map +1 -0
- package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.d.ts +6 -0
- package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.d.ts.map +1 -1
- package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.js +26 -11
- package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.js.map +1 -1
- package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-project-plugin.js +3 -0
- package/dist/esm/workflow-project-plugin.js.map +1 -1
- package/package.json +2 -2
- package/src/account-delete-route-generator.ts +97 -45
- package/src/api-route-generator.ts +12 -13
- package/src/auth-generator.ts +34 -1
- package/src/await-result.ts +19 -7
- package/src/data-api-route-generator.ts +9 -1
- package/src/executor-generator.ts +22 -7
- package/src/invoice/invoice-html-code.ts +96 -5
- package/src/nodes/account/account-delete-current.ts +4 -1
- package/src/nodes/email/email-mailersend.ts +72 -55
- package/src/nodes/email/email-mailgun.ts +51 -36
- package/src/nodes/email/email-postmark.ts +51 -35
- package/src/nodes/email/email-recipients.ts +86 -0
- package/src/nodes/email/email-resend.ts +49 -31
- package/src/nodes/email/email-sendgrid.ts +63 -44
- package/src/sent-email-log/sent-email-log-code.ts +17 -1
- package/src/server-runtime-code.ts +22 -0
- package/src/transactional-email-code.ts +12 -5
- package/src/vercel-functions.ts +67 -0
- package/src/webhook-delivery-lease/webhook-delivery-lease-code.ts +26 -11
- package/src/workflow-project-plugin.ts +3 -0
|
@@ -5,9 +5,10 @@ import { generateServerRuntimeCode } from '../../src/server-runtime-code'
|
|
|
5
5
|
/**
|
|
6
6
|
* The generated `utils/workflows/server-runtime.js` exactly as a route loads
|
|
7
7
|
* it: the shared runtime utilities plus the server-only signed hand-off,
|
|
8
|
-
* internal-call token and trusted base URL.
|
|
8
|
+
* internal-call token and trusted base URL. `modules` answers the packages a
|
|
9
|
+
* project may have installed (`@vercel/functions`); any other is missing.
|
|
9
10
|
*/
|
|
10
|
-
export function loadServerRuntime(): Record<string, any> {
|
|
11
|
+
export function loadServerRuntime(modules: Record<string, unknown> = {}): Record<string, any> {
|
|
11
12
|
const shared: { exports: Record<string, unknown> } = { exports: {} }
|
|
12
13
|
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
13
14
|
new Function('module', 'exports', generateSharedRuntimeUtilsCode())(shared, shared.exports)
|
|
@@ -19,6 +20,9 @@ export function loadServerRuntime(): Record<string, any> {
|
|
|
19
20
|
if (id === './runtime-utils') {
|
|
20
21
|
return shared.exports
|
|
21
22
|
}
|
|
23
|
+
if (id in modules) {
|
|
24
|
+
return modules[id]
|
|
25
|
+
}
|
|
22
26
|
throw new Error(`Unexpected require in server runtime: ${id}`)
|
|
23
27
|
}
|
|
24
28
|
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import * as crypto from 'crypto'
|
|
2
|
+
import {
|
|
3
|
+
AccountDeleteRouteOptions,
|
|
4
|
+
generateAccountDeleteRoute,
|
|
5
|
+
} from '../../src/account-delete-route-generator'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Runs the generated `pages/api/account/delete-current.js` for a signed-in
|
|
9
|
+
* visitor against a stubbed `pg` client: the user row and the
|
|
10
|
+
* `information_schema` column lists come from `tables`, every statement is
|
|
11
|
+
* recorded, and the project's own-table discovery answers no tables.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface DeleteRouteRun {
|
|
15
|
+
status: number
|
|
16
|
+
body: any
|
|
17
|
+
statements: Array<{ sql: string; params: unknown[] }>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DeleteRouteSetup {
|
|
21
|
+
userRow: Record<string, unknown>
|
|
22
|
+
/** Platform table name -> its columns. A table not listed does not exist. */
|
|
23
|
+
tables: Record<string, string[]>
|
|
24
|
+
requestBody?: unknown
|
|
25
|
+
env?: Record<string, string>
|
|
26
|
+
options?: AccountDeleteRouteOptions
|
|
27
|
+
/** Further modules the route may require, by the name it requires them with. */
|
|
28
|
+
modules?: Record<string, unknown>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// bcryptjs is not installed in this repository; this stand-in keeps bcrypt's
|
|
32
|
+
// contract (compare a password against the hash it produced) deterministically.
|
|
33
|
+
export const fakeBcrypt = {
|
|
34
|
+
hashSync: (text: string): string =>
|
|
35
|
+
'$2a$12$' + crypto.createHash('sha256').update(text).digest('hex').slice(0, 53),
|
|
36
|
+
compareSync: (text: string, hash: string): boolean => fakeBcrypt.hashSync(text) === hash,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What the sign-up stores for a password: bcrypt over SHA-256(password + pepper), base64. */
|
|
40
|
+
export const storedHashOf = (password: string, pepper = ''): string =>
|
|
41
|
+
fakeBcrypt.hashSync(
|
|
42
|
+
crypto
|
|
43
|
+
.createHash('sha256')
|
|
44
|
+
.update(password + pepper)
|
|
45
|
+
.digest('base64')
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const USER_ROW_SQL = /^SELECT \* FROM \S+ WHERE id = \$1$/
|
|
49
|
+
const COLUMNS_OF_ONE_TABLE =
|
|
50
|
+
"SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1"
|
|
51
|
+
|
|
52
|
+
export const runAccountDeleteRoute = async (setup: DeleteRouteSetup): Promise<DeleteRouteRun> => {
|
|
53
|
+
const run: DeleteRouteRun = { status: 0, body: undefined, statements: [] }
|
|
54
|
+
|
|
55
|
+
class Client {
|
|
56
|
+
public async connect() {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
public async query(sql: string, params: unknown[] = []) {
|
|
60
|
+
run.statements.push({ sql, params })
|
|
61
|
+
if (USER_ROW_SQL.test(sql)) {
|
|
62
|
+
return { rows: [setup.userRow] }
|
|
63
|
+
}
|
|
64
|
+
if (sql === COLUMNS_OF_ONE_TABLE) {
|
|
65
|
+
const columns = setup.tables[String(params[0])]
|
|
66
|
+
return { rows: (columns || []).map((column_name) => ({ column_name })) }
|
|
67
|
+
}
|
|
68
|
+
return { rows: [] }
|
|
69
|
+
}
|
|
70
|
+
public async end() {
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const fakeRequire = (name: string): unknown => {
|
|
76
|
+
if (name === 'pg') {
|
|
77
|
+
return { Client }
|
|
78
|
+
}
|
|
79
|
+
if (name === 'bcryptjs') {
|
|
80
|
+
return fakeBcrypt
|
|
81
|
+
}
|
|
82
|
+
if (name === 'crypto') {
|
|
83
|
+
return crypto
|
|
84
|
+
}
|
|
85
|
+
if (name === 'next-auth/jwt') {
|
|
86
|
+
return { getToken: async () => ({ id: setup.userRow.id }) }
|
|
87
|
+
}
|
|
88
|
+
if (name.endsWith('/utils/email/email-locale')) {
|
|
89
|
+
return { pickLocalizedTemplate: (base: unknown) => base, resolveRequestLocale: () => 'en' }
|
|
90
|
+
}
|
|
91
|
+
if (setup.modules && name in setup.modules) {
|
|
92
|
+
return setup.modules[name]
|
|
93
|
+
}
|
|
94
|
+
throw new Error(`unexpected require: ${name}`)
|
|
95
|
+
}
|
|
96
|
+
const moduleObj: { exports: any } = { exports: {} }
|
|
97
|
+
// tslint:disable-next-line:function-constructor
|
|
98
|
+
new Function(
|
|
99
|
+
'require',
|
|
100
|
+
'module',
|
|
101
|
+
'exports',
|
|
102
|
+
'process',
|
|
103
|
+
'console',
|
|
104
|
+
generateAccountDeleteRoute({ authUsersTableName: 'users', ...(setup.options || {}) })
|
|
105
|
+
)(
|
|
106
|
+
fakeRequire,
|
|
107
|
+
moduleObj,
|
|
108
|
+
moduleObj.exports,
|
|
109
|
+
{
|
|
110
|
+
env: {
|
|
111
|
+
TELEPORT_DB_CONNECTION_STRING: 'postgresql://user:pw@db.test/site',
|
|
112
|
+
NEXTAUTH_SECRET: 'secret',
|
|
113
|
+
...(setup.env || {}),
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{ error: () => undefined, warn: () => undefined, info: () => undefined }
|
|
117
|
+
)
|
|
118
|
+
const handler = moduleObj.exports as (req: unknown, res: unknown) => Promise<unknown>
|
|
119
|
+
const res = {
|
|
120
|
+
status(code: number) {
|
|
121
|
+
run.status = code
|
|
122
|
+
return res
|
|
123
|
+
},
|
|
124
|
+
json(body: unknown) {
|
|
125
|
+
run.body = body
|
|
126
|
+
return res
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
await handler(
|
|
130
|
+
{
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers: {},
|
|
133
|
+
...(setup.requestBody === undefined ? {} : { body: setup.requestBody }),
|
|
134
|
+
},
|
|
135
|
+
res
|
|
136
|
+
)
|
|
137
|
+
return run
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const sqlOf = (run: DeleteRouteRun): string[] => run.statements.map((s) => s.sql)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { loadHandler, HandlerFn } from './_helpers/load-handler'
|
|
2
|
+
import { bindWorkflowUtils } from './_helpers/bind-workflow-utils'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The delete-account handler posts to the site's delete route. A template that
|
|
6
|
+
* asks for the password passes what the visitor typed, and the route checks it
|
|
7
|
+
* before deleting anything; a template without a password posts an empty body,
|
|
8
|
+
* as the store's own Delete account always has.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const installBrowser = () => {
|
|
12
|
+
const element = { textContent: '', setAttribute: jest.fn() }
|
|
13
|
+
;(globalThis as any).window = {
|
|
14
|
+
location: { href: '' },
|
|
15
|
+
localStorage: { setItem: jest.fn(), removeItem: jest.fn() },
|
|
16
|
+
dispatchEvent: jest.fn(),
|
|
17
|
+
setTimeout: (fn: () => void) => fn(),
|
|
18
|
+
document: { createElement: () => element, body: { appendChild: jest.fn() } },
|
|
19
|
+
__teleportNextAuth: { signOut: jest.fn(async () => undefined) },
|
|
20
|
+
}
|
|
21
|
+
;(globalThis as any).CustomEvent = class {
|
|
22
|
+
constructor(public type: string, public init?: unknown) {}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('account-delete-current sends the password it is given', () => {
|
|
27
|
+
let unbindWorkflowUtils: () => void
|
|
28
|
+
let fetchMock: jest.Mock
|
|
29
|
+
|
|
30
|
+
beforeAll(() => {
|
|
31
|
+
unbindWorkflowUtils = bindWorkflowUtils()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
afterAll(() => {
|
|
35
|
+
unbindWorkflowUtils()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
installBrowser()
|
|
40
|
+
fetchMock = jest.fn(async () => ({ ok: true, json: async () => ({ success: true }) }))
|
|
41
|
+
;(globalThis as any).fetch = fetchMock
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
delete (globalThis as any).window
|
|
46
|
+
delete (globalThis as any).CustomEvent
|
|
47
|
+
delete (globalThis as any).fetch
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const postedBody = () => JSON.parse(fetchMock.mock.calls[0][1].body)
|
|
51
|
+
|
|
52
|
+
it('posts the typed password to the delete route', async () => {
|
|
53
|
+
const handler: HandlerFn = loadHandler('account-delete-current')
|
|
54
|
+
await handler({ password: 'typed-secret' }, {})
|
|
55
|
+
expect(fetchMock.mock.calls[0][0]).toBe('/api/account/delete-current')
|
|
56
|
+
expect(postedBody()).toEqual({ password: 'typed-secret' })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('posts an empty body when the template asks for no password', async () => {
|
|
60
|
+
const handler: HandlerFn = loadHandler('account-delete-current')
|
|
61
|
+
await handler({ redirectTo: '/' }, {})
|
|
62
|
+
expect(postedBody()).toEqual({})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("throws the route's message when the password is refused", async () => {
|
|
66
|
+
fetchMock.mockImplementationOnce(async () => ({
|
|
67
|
+
ok: false,
|
|
68
|
+
json: async () => ({ error: 'Incorrect password' }),
|
|
69
|
+
}))
|
|
70
|
+
const handler: HandlerFn = loadHandler('account-delete-current')
|
|
71
|
+
await expect(handler({ password: 'wrong' }, {})).rejects.toThrow('Incorrect password')
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { runAccountDeleteRoute, sqlOf, storedHashOf } from './_helpers/run-account-delete-route'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The editor's delete-account form asks the visitor for their password. The
|
|
5
|
+
* site's delete route checks a password the request carries against the
|
|
6
|
+
* account's stored hash, the way sign-in does, and deletes nothing when it is
|
|
7
|
+
* not theirs. A request without a password deletes as before.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const USER_ID = 'user-1'
|
|
11
|
+
const PASSWORD = 'correct horse battery staple'
|
|
12
|
+
|
|
13
|
+
const userRow = (password: string | null = storedHashOf(PASSWORD)) => ({
|
|
14
|
+
id: USER_ID,
|
|
15
|
+
email: 'ana@example.com',
|
|
16
|
+
name: 'Ana',
|
|
17
|
+
password,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const TABLES = {
|
|
21
|
+
users: ['id', 'email', 'name', 'password'],
|
|
22
|
+
teleport_cart: ['id', 'user_id'],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const deletedTheAccount = (sql: string[]) =>
|
|
26
|
+
sql.includes('DELETE FROM users WHERE id = $1') && sql.includes('COMMIT')
|
|
27
|
+
|
|
28
|
+
const changedAnything = (sql: string[]) =>
|
|
29
|
+
sql.some((statement) => /^(BEGIN|UPDATE|DELETE|INSERT)/.test(statement))
|
|
30
|
+
|
|
31
|
+
describe('deleting an account with a password', () => {
|
|
32
|
+
it('deletes the account when the password is the account’s own', async () => {
|
|
33
|
+
const run = await runAccountDeleteRoute({
|
|
34
|
+
userRow: userRow(),
|
|
35
|
+
tables: TABLES,
|
|
36
|
+
requestBody: { password: PASSWORD },
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
expect(run.status).toBe(200)
|
|
40
|
+
expect(deletedTheAccount(sqlOf(run))).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('answers 403 and changes nothing when the password is not', async () => {
|
|
44
|
+
const run = await runAccountDeleteRoute({
|
|
45
|
+
userRow: userRow(),
|
|
46
|
+
tables: TABLES,
|
|
47
|
+
requestBody: { password: 'not it' },
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
expect(run.status).toBe(403)
|
|
51
|
+
expect(run.body).toEqual({ error: 'Incorrect password' })
|
|
52
|
+
expect(changedAnything(sqlOf(run))).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('reads a body that arrives as a JSON string', async () => {
|
|
56
|
+
const refused = await runAccountDeleteRoute({
|
|
57
|
+
userRow: userRow(),
|
|
58
|
+
tables: TABLES,
|
|
59
|
+
requestBody: JSON.stringify({ password: 'not it' }),
|
|
60
|
+
})
|
|
61
|
+
expect(refused.status).toBe(403)
|
|
62
|
+
|
|
63
|
+
const accepted = await runAccountDeleteRoute({
|
|
64
|
+
userRow: userRow(),
|
|
65
|
+
tables: TABLES,
|
|
66
|
+
requestBody: JSON.stringify({ password: PASSWORD }),
|
|
67
|
+
})
|
|
68
|
+
expect(accepted.status).toBe(200)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('checks with the project’s pepper, as sign-in does', async () => {
|
|
72
|
+
const peppered = userRow(storedHashOf(PASSWORD, 'pepper-1'))
|
|
73
|
+
const withPepper = await runAccountDeleteRoute({
|
|
74
|
+
userRow: peppered,
|
|
75
|
+
tables: TABLES,
|
|
76
|
+
requestBody: { password: PASSWORD },
|
|
77
|
+
env: { AUTH_PEPPER: 'pepper-1' },
|
|
78
|
+
})
|
|
79
|
+
expect(withPepper.status).toBe(200)
|
|
80
|
+
|
|
81
|
+
const withoutPepper = await runAccountDeleteRoute({
|
|
82
|
+
userRow: peppered,
|
|
83
|
+
tables: TABLES,
|
|
84
|
+
requestBody: { password: PASSWORD },
|
|
85
|
+
})
|
|
86
|
+
expect(withoutPepper.status).toBe(403)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('refuses any password for an account that has none stored (a social sign-in)', async () => {
|
|
90
|
+
const run = await runAccountDeleteRoute({
|
|
91
|
+
userRow: userRow(null),
|
|
92
|
+
tables: TABLES,
|
|
93
|
+
requestBody: { password: '' },
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
expect(run.status).toBe(403)
|
|
97
|
+
expect(changedAnything(sqlOf(run))).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('deleting an account without a password', () => {
|
|
102
|
+
it.each([
|
|
103
|
+
['no body', undefined],
|
|
104
|
+
['an empty object', {}],
|
|
105
|
+
['the node’s "{}" string', '{}'],
|
|
106
|
+
['a null password', { password: null }],
|
|
107
|
+
])('deletes as before with %s', async (_label, requestBody) => {
|
|
108
|
+
const run = await runAccountDeleteRoute({ userRow: userRow(), tables: TABLES, requestBody })
|
|
109
|
+
|
|
110
|
+
expect(run.status).toBe(200)
|
|
111
|
+
expect(deletedTheAccount(sqlOf(run))).toBe(true)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { DeleteRouteRun, runAccountDeleteRoute, sqlOf } from './_helpers/run-account-delete-route'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Closing an account takes the visitor's details out of the site's own records
|
|
5
|
+
* too: the activity log no longer names them, a download no longer says who
|
|
6
|
+
* fetched it from where, a gift card they bought or received keeps working
|
|
7
|
+
* under the anonymised address, and the emails the site sent them stay on
|
|
8
|
+
* record without the address, the body or the values they were filled with.
|
|
9
|
+
* The invoices already issued to them are the exception: an invoice must keep
|
|
10
|
+
* naming its buyer. A table the site does not have is left alone.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const USER_ID = 'user-1'
|
|
14
|
+
const USER_EMAIL = 'Ana@Example.com'
|
|
15
|
+
const ANONYMISED = `deleted-user-${USER_ID}@deleted.invalid`
|
|
16
|
+
|
|
17
|
+
const userRow = { id: USER_ID, email: USER_EMAIL, name: 'Ana' }
|
|
18
|
+
|
|
19
|
+
const TABLES = {
|
|
20
|
+
users: ['id', 'email', 'name', 'password'],
|
|
21
|
+
teleport_events: ['id', 'event_type', 'dedupe_key', 'order_id', 'payload', 'actor'],
|
|
22
|
+
teleport_downloads: [
|
|
23
|
+
'id',
|
|
24
|
+
'order_id',
|
|
25
|
+
'order_item_id',
|
|
26
|
+
'product_file_id',
|
|
27
|
+
'user_id',
|
|
28
|
+
'ip_address',
|
|
29
|
+
],
|
|
30
|
+
teleport_gift_cards: ['id', 'code', 'balance', 'recipient_email', 'purchaser_email'],
|
|
31
|
+
teleport_sent_emails: ['id', 'recipient_email', 'recipients', 'body_html', 'payload', 'subject'],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const updatesOf = (run: DeleteRouteRun, table: string) =>
|
|
35
|
+
run.statements.filter((statement) => statement.sql.indexOf('UPDATE ' + table + ' ') === 0)
|
|
36
|
+
|
|
37
|
+
describe('deleting an account removes the visitor from the site’s records', () => {
|
|
38
|
+
let run: DeleteRouteRun
|
|
39
|
+
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
run = await runAccountDeleteRoute({ userRow, tables: TABLES })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('still deletes the account', () => {
|
|
45
|
+
expect(run.status).toBe(200)
|
|
46
|
+
expect(sqlOf(run)).toContain('DELETE FROM users WHERE id = $1')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('blanks the activity log’s actor where it is their email', () => {
|
|
50
|
+
expect(updatesOf(run, 'teleport_events')).toEqual([
|
|
51
|
+
{
|
|
52
|
+
sql: 'UPDATE teleport_events SET actor = $1 WHERE lower(actor) = lower($2)',
|
|
53
|
+
params: ['', USER_EMAIL],
|
|
54
|
+
},
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('clears who downloaded and from where, keeping the download counted', () => {
|
|
59
|
+
expect(updatesOf(run, 'teleport_downloads')).toEqual([
|
|
60
|
+
{
|
|
61
|
+
sql: 'UPDATE teleport_downloads SET user_id = NULL, ip_address = NULL WHERE user_id = $1',
|
|
62
|
+
params: [USER_ID],
|
|
63
|
+
},
|
|
64
|
+
])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('moves the gift cards they bought or received to the anonymised address', () => {
|
|
68
|
+
expect(updatesOf(run, 'teleport_gift_cards')).toEqual([
|
|
69
|
+
{
|
|
70
|
+
sql: 'UPDATE teleport_gift_cards SET purchaser_email = $1 WHERE lower(purchaser_email) = lower($2)',
|
|
71
|
+
params: [ANONYMISED, USER_EMAIL],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
sql: 'UPDATE teleport_gift_cards SET recipient_email = $1 WHERE lower(recipient_email) = lower($2)',
|
|
75
|
+
params: [ANONYMISED, USER_EMAIL],
|
|
76
|
+
},
|
|
77
|
+
])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('keeps the emails sent to them on record without address, body or values', () => {
|
|
81
|
+
expect(updatesOf(run, 'teleport_sent_emails')).toEqual([
|
|
82
|
+
{
|
|
83
|
+
sql: "UPDATE teleport_sent_emails SET recipient_email = '', recipients = NULL, body_html = '', payload = NULL WHERE lower(recipient_email) = lower($1)",
|
|
84
|
+
params: [USER_EMAIL],
|
|
85
|
+
},
|
|
86
|
+
])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('does all of it inside the deletion’s transaction', () => {
|
|
90
|
+
const sql = sqlOf(run)
|
|
91
|
+
const begin = sql.indexOf('BEGIN')
|
|
92
|
+
const commit = sql.indexOf('COMMIT')
|
|
93
|
+
for (const table of Object.keys(TABLES).filter((name) => name !== 'users')) {
|
|
94
|
+
const at = sql.findIndex((statement) => statement.indexOf('UPDATE ' + table + ' ') === 0)
|
|
95
|
+
expect(at).toBeGreaterThan(begin)
|
|
96
|
+
expect(at).toBeLessThan(commit)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('the invoices already issued to them', () => {
|
|
102
|
+
const SHOP_TABLES = {
|
|
103
|
+
users: TABLES.users,
|
|
104
|
+
teleport_orders: ['id', 'user_id', 'customer_email', 'billing_email', 'invoice_pdf_url'],
|
|
105
|
+
teleport_invoices: [
|
|
106
|
+
'id',
|
|
107
|
+
'order_id',
|
|
108
|
+
'customer_name',
|
|
109
|
+
'customer_address',
|
|
110
|
+
'customer_city',
|
|
111
|
+
'customer_country',
|
|
112
|
+
'customer_vat',
|
|
113
|
+
'customer_email',
|
|
114
|
+
'pdf_url',
|
|
115
|
+
],
|
|
116
|
+
teleport_invoice_items: ['id', 'invoice_id', 'description', 'amount'],
|
|
117
|
+
}
|
|
118
|
+
let run: DeleteRouteRun
|
|
119
|
+
|
|
120
|
+
beforeAll(async () => {
|
|
121
|
+
run = await runAccountDeleteRoute({ userRow, tables: SHOP_TABLES })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('keep the buyer’s name, address, tax number and email', () => {
|
|
125
|
+
expect(run.status).toBe(200)
|
|
126
|
+
expect(sqlOf(run).filter((sql) => sql.indexOf('teleport_invoice') !== -1)).toEqual([])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('while their orders still drop the account and its email', () => {
|
|
130
|
+
expect(updatesOf(run, 'teleport_orders')).toEqual([
|
|
131
|
+
{
|
|
132
|
+
sql: 'UPDATE teleport_orders SET user_id = NULL, billing_email = $2, customer_email = $3 WHERE user_id = $1',
|
|
133
|
+
params: [USER_ID, ANONYMISED, ANONYMISED],
|
|
134
|
+
},
|
|
135
|
+
])
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('a site without those tables', () => {
|
|
140
|
+
it('deletes the account and touches none of them', async () => {
|
|
141
|
+
const run = await runAccountDeleteRoute({
|
|
142
|
+
userRow,
|
|
143
|
+
tables: { users: TABLES.users },
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
expect(run.status).toBe(200)
|
|
147
|
+
expect(sqlOf(run).filter((sql) => sql.indexOf('UPDATE') === 0)).toEqual([])
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('leaves a table alone when it lacks the column', async () => {
|
|
151
|
+
const run = await runAccountDeleteRoute({
|
|
152
|
+
userRow,
|
|
153
|
+
tables: { users: TABLES.users, teleport_events: ['id', 'event_type'] },
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
expect(updatesOf(run, 'teleport_events')).toEqual([])
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('the farewell email', () => {
|
|
161
|
+
it('is recorded without the visitor’s address, body or values', async () => {
|
|
162
|
+
const recorded: any[] = []
|
|
163
|
+
const sent: any[] = []
|
|
164
|
+
class Resend {
|
|
165
|
+
public emails = {
|
|
166
|
+
send: async (message: unknown) => {
|
|
167
|
+
sent.push(message)
|
|
168
|
+
return {}
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const run = await runAccountDeleteRoute({
|
|
173
|
+
userRow,
|
|
174
|
+
tables: TABLES,
|
|
175
|
+
env: { RESEND_KEY: 're_test' },
|
|
176
|
+
options: {
|
|
177
|
+
emailProvider: 'resend',
|
|
178
|
+
fromEmail: 'hello@shop.test',
|
|
179
|
+
emailSecretEnvName: 'RESEND_KEY',
|
|
180
|
+
emailBodyHtml: '<p>Goodbye {{userName}}</p>',
|
|
181
|
+
},
|
|
182
|
+
modules: {
|
|
183
|
+
resend: { Resend },
|
|
184
|
+
'../../../utils/email/sent-email-log': {
|
|
185
|
+
recordSentEmail: (entry: unknown) => recorded.push(entry),
|
|
186
|
+
settleSentEmailLog: async () => undefined,
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
expect(run.status).toBe(200)
|
|
192
|
+
// The visitor still gets it…
|
|
193
|
+
expect(sent).toHaveLength(1)
|
|
194
|
+
expect(sent[0].to).toEqual([USER_EMAIL])
|
|
195
|
+
expect(sent[0].html).toBe('<p>Goodbye Ana</p>')
|
|
196
|
+
// …and the ledger keeps only that it was sent.
|
|
197
|
+
expect(recorded).toHaveLength(1)
|
|
198
|
+
expect(recorded[0]).toMatchObject({
|
|
199
|
+
emailType: 'account-deleted',
|
|
200
|
+
to: ANONYMISED,
|
|
201
|
+
html: '',
|
|
202
|
+
payload: null,
|
|
203
|
+
status: 'sent',
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
})
|
|
@@ -54,6 +54,16 @@ describe('isFatalNodeResult — error contract normalization', () => {
|
|
|
54
54
|
expect(utils.isFatalNodeResult({ success: false })).toBeTruthy()
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
+
it('lets a result that says success: true through, even one carrying an error note', () => {
|
|
58
|
+
expect(
|
|
59
|
+
utils.isFatalNodeResult({
|
|
60
|
+
id: 're-1',
|
|
61
|
+
success: true,
|
|
62
|
+
error: 'refused for blocked@lumen.test (only the owner)',
|
|
63
|
+
})
|
|
64
|
+
).toBeFalsy()
|
|
65
|
+
})
|
|
66
|
+
|
|
57
67
|
it('does not flag successful results', () => {
|
|
58
68
|
expect(utils.isFatalNodeResult({ result: 'ok' })).toBeFalsy()
|
|
59
69
|
expect(utils.isFatalNodeResult({ error: false })).toBeFalsy()
|