@teleporthq/teleport-plugin-next-workflows 0.44.4 → 0.44.6
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__/account-delete-owned-rows.test.ts +488 -0
- package/__tests__/invoice-paid-status.test.ts +382 -0
- package/__tests__/invoice-tender.test.ts +5 -3
- package/dist/cjs/account-delete-route-generator.d.ts +9 -6
- package/dist/cjs/account-delete-route-generator.d.ts.map +1 -1
- package/dist/cjs/account-delete-route-generator.js +131 -1
- package/dist/cjs/account-delete-route-generator.js.map +1 -1
- package/dist/cjs/invoice/invoice-assembly-code.d.ts.map +1 -1
- package/dist/cjs/invoice/invoice-assembly-code.js +19 -2
- package/dist/cjs/invoice/invoice-assembly-code.js.map +1 -1
- package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
- package/dist/cjs/invoice/invoice-html-code.js +100 -71
- package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/account-delete-route-generator.d.ts +9 -6
- package/dist/esm/account-delete-route-generator.d.ts.map +1 -1
- package/dist/esm/account-delete-route-generator.js +131 -1
- package/dist/esm/account-delete-route-generator.js.map +1 -1
- package/dist/esm/invoice/invoice-assembly-code.d.ts.map +1 -1
- package/dist/esm/invoice/invoice-assembly-code.js +19 -2
- package/dist/esm/invoice/invoice-assembly-code.js.map +1 -1
- package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
- package/dist/esm/invoice/invoice-html-code.js +100 -71
- package/dist/esm/invoice/invoice-html-code.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/account-delete-route-generator.ts +140 -7
- package/src/invoice/invoice-assembly-code.ts +19 -2
- package/src/invoice/invoice-html-code.ts +100 -71
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import { OwnerColumns } from '@teleporthq/teleport-shared'
|
|
2
|
+
import { generateAccountDeleteRoute } from '../src/account-delete-route-generator'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A visitor closes their account. The generated delete route is EXECUTED here
|
|
6
|
+
* against a stubbed `pg` client that answers the catalogue reads from a fixture
|
|
7
|
+
* schema and records every statement, so the proof is behaviour: which rows the
|
|
8
|
+
* route deletes, anonymises and leaves alone.
|
|
9
|
+
*
|
|
10
|
+
* THE DEFECT. The route deleted a fixed list of platform tables and the user
|
|
11
|
+
* row, and nothing in the project's own tables — saved items, requests,
|
|
12
|
+
* reviews, the tables the AI made for the site. Their owner link is
|
|
13
|
+
* `ON DELETE SET NULL`, so the rows stayed with a blank owner, and the "My …"
|
|
14
|
+
* pages read `owner = me OR owner IS NULL`: every other signed-in visitor saw
|
|
15
|
+
* them. `teleport_bookings` has no link to users at all and kept the user id
|
|
16
|
+
* with the customer's name, email and phone.
|
|
17
|
+
*
|
|
18
|
+
* THE RULE. A table's rows are attributed through exactly ONE column — the one
|
|
19
|
+
* its "My …" pages read by (teleport-shared's `resolveOwnerColumn`, shipped
|
|
20
|
+
* into the route as text) — and deleted only when that column says whose the
|
|
21
|
+
* row is, never when it says who acted on it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const USER_ID = '7d1c4c6a-2f7e-4d3b-9c1e-1a2b3c4d5e6f'
|
|
25
|
+
const USER_EMAIL = 'ana@example.com'
|
|
26
|
+
const SYNTHETIC_EMAIL = `deleted-user-${USER_ID}@deleted.invalid`
|
|
27
|
+
|
|
28
|
+
interface FixtureTable {
|
|
29
|
+
columns: string[]
|
|
30
|
+
/** column -> `table.column` it references through a real foreign key. */
|
|
31
|
+
links?: Record<string, string>
|
|
32
|
+
}
|
|
33
|
+
type FixtureSchema = Record<string, FixtureTable>
|
|
34
|
+
|
|
35
|
+
interface Statement {
|
|
36
|
+
sql: string
|
|
37
|
+
params: unknown[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Run {
|
|
41
|
+
status: number
|
|
42
|
+
body: unknown
|
|
43
|
+
statements: Statement[]
|
|
44
|
+
errors: unknown[][]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `attempt` counts the executions of that exact statement, from 1. */
|
|
48
|
+
type Refuse = (sql: string, attempt: number) => Error | null
|
|
49
|
+
|
|
50
|
+
/** The platform tables a store carries; how the route treats them is the baseline. */
|
|
51
|
+
const PLATFORM: FixtureSchema = {
|
|
52
|
+
users: { columns: ['id', 'email', 'name', 'password'] },
|
|
53
|
+
password_reset_tokens: { columns: ['id', 'email', 'token'] },
|
|
54
|
+
teleport_orders: { columns: ['id', 'user_id', 'billing_email', 'billing_name', 'total'] },
|
|
55
|
+
teleport_cart: { columns: ['id', 'user_id'], links: { user_id: 'users.id' } },
|
|
56
|
+
teleport_subscriptions: {
|
|
57
|
+
columns: ['id', 'user_id', 'customer_email', 'customer_name', 'status'],
|
|
58
|
+
},
|
|
59
|
+
teleport_abandoned_carts: { columns: ['id', 'cart_id', 'user_id', 'customer_email'] },
|
|
60
|
+
teleport_voucher_redemptions: {
|
|
61
|
+
columns: ['id', 'voucher_id', 'order_id', 'user_id', 'billing_email'],
|
|
62
|
+
},
|
|
63
|
+
teleport_push_subscriptions: { columns: ['id', 'endpoint', 'user_id'] },
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const USER_ROW = 'SELECT * FROM users WHERE id = $1'
|
|
67
|
+
const COLUMNS_OF_ONE_TABLE =
|
|
68
|
+
"SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1"
|
|
69
|
+
const COLUMNS_OF_EVERY_TABLE = 'SELECT c.table_name, c.column_name'
|
|
70
|
+
const FOREIGN_KEYS = 'FROM pg_constraint'
|
|
71
|
+
|
|
72
|
+
const columnRows = (schema: FixtureSchema) => {
|
|
73
|
+
const rows: Array<{ table_name: string; column_name: string; ordinal_position: number }> = []
|
|
74
|
+
for (const tableName of Object.keys(schema)) {
|
|
75
|
+
schema[tableName].columns.forEach((columnName, index) => {
|
|
76
|
+
rows.push({ table_name: tableName, column_name: columnName, ordinal_position: index + 1 })
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
return rows
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const linkRows = (schema: FixtureSchema) => {
|
|
83
|
+
const rows: Array<{
|
|
84
|
+
table_name: string
|
|
85
|
+
column_name: string
|
|
86
|
+
referenced_table: string
|
|
87
|
+
referenced_column: string
|
|
88
|
+
}> = []
|
|
89
|
+
for (const tableName of Object.keys(schema)) {
|
|
90
|
+
const links = schema[tableName].links || {}
|
|
91
|
+
for (const columnName of Object.keys(links)) {
|
|
92
|
+
const [referencedTable, referencedColumn] = links[columnName].split('.')
|
|
93
|
+
rows.push({
|
|
94
|
+
table_name: tableName,
|
|
95
|
+
column_name: columnName,
|
|
96
|
+
referenced_table: referencedTable,
|
|
97
|
+
referenced_column: referencedColumn,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return rows
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const runRoute = async (schema: FixtureSchema, refuse: Refuse = () => null): Promise<Run> => {
|
|
105
|
+
const run: Run = { status: 0, body: undefined, statements: [], errors: [] }
|
|
106
|
+
const attempts: Record<string, number> = {}
|
|
107
|
+
|
|
108
|
+
class Client {
|
|
109
|
+
public async connect() {
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
public async query(sql: string, params: unknown[] = []) {
|
|
113
|
+
run.statements.push({ sql, params })
|
|
114
|
+
if (sql === USER_ROW) {
|
|
115
|
+
return { rows: [{ id: USER_ID, email: USER_EMAIL, name: 'Ana' }] }
|
|
116
|
+
}
|
|
117
|
+
if (sql === COLUMNS_OF_ONE_TABLE) {
|
|
118
|
+
const table = schema[String(params[0])]
|
|
119
|
+
return { rows: table ? table.columns.map((column_name) => ({ column_name })) : [] }
|
|
120
|
+
}
|
|
121
|
+
if (sql.indexOf(COLUMNS_OF_EVERY_TABLE) === 0) {
|
|
122
|
+
return { rows: columnRows(schema) }
|
|
123
|
+
}
|
|
124
|
+
if (sql.indexOf(FOREIGN_KEYS) !== -1) {
|
|
125
|
+
return { rows: linkRows(schema) }
|
|
126
|
+
}
|
|
127
|
+
// A catalogue read this stub does not know would otherwise answer no rows
|
|
128
|
+
// and let every "left alone" scenario pass with nothing looked at.
|
|
129
|
+
if (sql.indexOf('SELECT') === 0) {
|
|
130
|
+
throw new Error(`unexpected catalogue read: ${sql}`)
|
|
131
|
+
}
|
|
132
|
+
attempts[sql] = (attempts[sql] || 0) + 1
|
|
133
|
+
const refusal = refuse(sql, attempts[sql])
|
|
134
|
+
if (refusal) {
|
|
135
|
+
throw refusal
|
|
136
|
+
}
|
|
137
|
+
return { rows: [] }
|
|
138
|
+
}
|
|
139
|
+
public async end() {
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const fakeRequire = (name: string): any => {
|
|
145
|
+
if (name === 'pg') {
|
|
146
|
+
return { Client }
|
|
147
|
+
}
|
|
148
|
+
if (name === 'next-auth/jwt') {
|
|
149
|
+
return { getToken: async () => ({ id: USER_ID }) }
|
|
150
|
+
}
|
|
151
|
+
if (name.endsWith('/utils/email/email-locale')) {
|
|
152
|
+
return { pickLocalizedTemplate: (base: unknown) => base, resolveRequestLocale: () => 'en' }
|
|
153
|
+
}
|
|
154
|
+
throw new Error(`unexpected require: ${name}`)
|
|
155
|
+
}
|
|
156
|
+
const moduleObj: { exports: any } = { exports: {} }
|
|
157
|
+
// tslint:disable-next-line:function-constructor
|
|
158
|
+
new Function(
|
|
159
|
+
'require',
|
|
160
|
+
'module',
|
|
161
|
+
'exports',
|
|
162
|
+
'process',
|
|
163
|
+
'console',
|
|
164
|
+
generateAccountDeleteRoute({ authUsersTableName: 'users' })
|
|
165
|
+
)(
|
|
166
|
+
fakeRequire,
|
|
167
|
+
moduleObj,
|
|
168
|
+
moduleObj.exports,
|
|
169
|
+
{
|
|
170
|
+
env: {
|
|
171
|
+
TELEPORT_DB_CONNECTION_STRING: 'postgresql://user:pw@db.test/site',
|
|
172
|
+
NEXTAUTH_SECRET: 'secret',
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{ error: (...args: unknown[]) => run.errors.push(args), warn: () => undefined }
|
|
176
|
+
)
|
|
177
|
+
const handler = moduleObj.exports as (req: unknown, res: unknown) => Promise<unknown>
|
|
178
|
+
const res = {
|
|
179
|
+
status(code: number) {
|
|
180
|
+
run.status = code
|
|
181
|
+
return res
|
|
182
|
+
},
|
|
183
|
+
json(body: unknown) {
|
|
184
|
+
run.body = body
|
|
185
|
+
return res
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
await handler({ method: 'POST', headers: {} }, res)
|
|
189
|
+
return run
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const sqlOf = (run: Run): string[] => run.statements.map((statement) => statement.sql)
|
|
193
|
+
|
|
194
|
+
/** The statements that change something, in order — the catalogue reads left out. */
|
|
195
|
+
const writesOf = (run: Run): string[] =>
|
|
196
|
+
sqlOf(run).filter((sql) => /^(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE|DELETE|UPDATE)/.test(sql))
|
|
197
|
+
|
|
198
|
+
/** The deletes in the site's own tables: the route quotes those identifiers. */
|
|
199
|
+
const ownDeletesOf = (run: Run): Statement[] =>
|
|
200
|
+
run.statements.filter((statement) => statement.sql.indexOf('DELETE FROM "') === 0)
|
|
201
|
+
|
|
202
|
+
interface Scenario {
|
|
203
|
+
table: string
|
|
204
|
+
columns: string[]
|
|
205
|
+
links?: Record<string, string>
|
|
206
|
+
/** The column the visitor's rows go through, or null when the table is left alone. */
|
|
207
|
+
deletedThrough: string | null
|
|
208
|
+
why: string
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const SCENARIOS: Scenario[] = [
|
|
212
|
+
{
|
|
213
|
+
table: 'saved_items',
|
|
214
|
+
columns: ['id', 'user_id', 'title'],
|
|
215
|
+
links: { user_id: 'users.id' },
|
|
216
|
+
deletedThrough: 'user_id',
|
|
217
|
+
why: 'the plain owner link',
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
table: 'requests',
|
|
221
|
+
columns: ['id', 'owner_user_id', 'subject'],
|
|
222
|
+
links: { owner_user_id: 'users.id' },
|
|
223
|
+
deletedThrough: 'owner_user_id',
|
|
224
|
+
why: 'the <role>_user_id convention, an owning role',
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
table: 'reviews',
|
|
228
|
+
columns: ['id', 'created_by', 'body'],
|
|
229
|
+
deletedThrough: 'created_by',
|
|
230
|
+
why: 'an older project whose link to users was dropped: the name is all there is',
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
table: 'posts',
|
|
234
|
+
columns: ['id', 'author_id', 'body'],
|
|
235
|
+
links: { author_id: 'users.id' },
|
|
236
|
+
deletedThrough: 'author_id',
|
|
237
|
+
why: 'the pipeline names the signed-in author this way; the page is scoped by it',
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
table: 'expenses',
|
|
241
|
+
columns: ['id', 'submitter_user_id', 'approved_by_user_id', 'amount'],
|
|
242
|
+
links: { submitter_user_id: 'users.id', approved_by_user_id: 'users.id' },
|
|
243
|
+
deletedThrough: 'submitter_user_id',
|
|
244
|
+
why: 'an approver leaving must not delete the expenses they approved',
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
table: 'tickets',
|
|
248
|
+
columns: ['id', 'user_id', 'created_by', 'subject'],
|
|
249
|
+
links: { user_id: 'users.id', created_by: 'users.id' },
|
|
250
|
+
deletedThrough: 'user_id',
|
|
251
|
+
why: "an agent leaving must not delete the customers' tickets they opened",
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
table: 'listing_inquiries',
|
|
255
|
+
columns: ['id', 'sender_user_id', 'recipient_user_id', 'listing_id', 'body'],
|
|
256
|
+
links: { sender_user_id: 'users.id', recipient_user_id: 'users.id' },
|
|
257
|
+
deletedThrough: 'sender_user_id',
|
|
258
|
+
why: "a seller leaving must not delete the buyers' inquiries",
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
table: 'wishlists',
|
|
262
|
+
columns: ['id', 'owner_user_id', 'user_id'],
|
|
263
|
+
links: { owner_user_id: 'users.id', user_id: 'users.id' },
|
|
264
|
+
deletedThrough: 'user_id',
|
|
265
|
+
why: 'one column per table — the one the page reads by, whatever the column order',
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
table: 'accounts',
|
|
269
|
+
columns: ['id', 'user_id', 'label'],
|
|
270
|
+
links: { user_id: 'users.id' },
|
|
271
|
+
deletedThrough: 'user_id',
|
|
272
|
+
why: "a table named like a platform scope is still the site's own",
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
table: 'tasks',
|
|
276
|
+
columns: ['id', 'title', 'assigned_to', 'updated_by'],
|
|
277
|
+
links: { assigned_to: 'users.id', updated_by: 'users.id' },
|
|
278
|
+
deletedThrough: null,
|
|
279
|
+
why: "actor links only: the rows are other people's",
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
table: 'approvals',
|
|
283
|
+
columns: ['id', 'approved_by_user_id', 'note'],
|
|
284
|
+
links: { approved_by_user_id: 'users.id' },
|
|
285
|
+
deletedThrough: null,
|
|
286
|
+
why: "the page reads by an acting role; what leaks there is the read side's null-inclusive predicate",
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
table: 'transactions',
|
|
290
|
+
columns: ['id', 'account_id', 'amount'],
|
|
291
|
+
links: { account_id: 'accounts.id' },
|
|
292
|
+
deletedThrough: null,
|
|
293
|
+
why: 'account_id names an account of the site, not a user: a domain link',
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
table: 'products',
|
|
297
|
+
columns: ['id', 'name', 'price'],
|
|
298
|
+
deletedThrough: null,
|
|
299
|
+
why: 'nobody owns the row',
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
table: 'status_changes',
|
|
303
|
+
columns: ['id', 'ticket_id', 'from_status', 'to_status', 'changed_by_user_id'],
|
|
304
|
+
links: { changed_by_user_id: 'users.id' },
|
|
305
|
+
deletedThrough: null,
|
|
306
|
+
why: "the leaver's history rows on everyone's tickets are not theirs: changing is not creating",
|
|
307
|
+
},
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
describe("closing an account — the rows the visitor owns in the site's own tables", () => {
|
|
311
|
+
describe.each(SCENARIOS.map((scenario) => [scenario.table, scenario] as [string, Scenario]))(
|
|
312
|
+
'%s',
|
|
313
|
+
(_table, scenario) => {
|
|
314
|
+
it(scenario.why, async () => {
|
|
315
|
+
const run = await runRoute({
|
|
316
|
+
...PLATFORM,
|
|
317
|
+
[scenario.table]: { columns: scenario.columns, links: scenario.links },
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
expect(run.status).toBe(200)
|
|
321
|
+
expect(run.body).toEqual({ success: true })
|
|
322
|
+
expect(ownDeletesOf(run)).toEqual(
|
|
323
|
+
scenario.deletedThrough === null
|
|
324
|
+
? []
|
|
325
|
+
: [
|
|
326
|
+
{
|
|
327
|
+
sql: `DELETE FROM "${scenario.table}" WHERE "${scenario.deletedThrough}" = $1`,
|
|
328
|
+
params: [USER_ID],
|
|
329
|
+
},
|
|
330
|
+
]
|
|
331
|
+
)
|
|
332
|
+
expect(run.errors).toEqual([])
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
it('deletes them under their own savepoints, inside the one transaction, before the account', async () => {
|
|
338
|
+
const run = await runRoute({
|
|
339
|
+
...PLATFORM,
|
|
340
|
+
saved_items: { columns: ['id', 'user_id', 'title'], links: { user_id: 'users.id' } },
|
|
341
|
+
posts: { columns: ['id', 'author_id', 'body'], links: { author_id: 'users.id' } },
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
const writes = writesOf(run)
|
|
345
|
+
expect(writes[0]).toBe('BEGIN')
|
|
346
|
+
expect(writes[writes.length - 1]).toBe('COMMIT')
|
|
347
|
+
const at = writes.indexOf('DELETE FROM "posts" WHERE "author_id" = $1')
|
|
348
|
+
expect(writes.slice(at - 1, at + 2)).toEqual([
|
|
349
|
+
'SAVEPOINT owned_rows',
|
|
350
|
+
'DELETE FROM "posts" WHERE "author_id" = $1',
|
|
351
|
+
'RELEASE SAVEPOINT owned_rows',
|
|
352
|
+
])
|
|
353
|
+
expect(writes.indexOf('DELETE FROM "saved_items" WHERE "user_id" = $1')).toBeLessThan(
|
|
354
|
+
writes.indexOf('DELETE FROM users WHERE id = $1')
|
|
355
|
+
)
|
|
356
|
+
expect(at).toBeLessThan(writes.indexOf('DELETE FROM users WHERE id = $1'))
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('anonymises a booking like an order: no account, no name, no phone, a synthetic email', async () => {
|
|
360
|
+
const run = await runRoute({
|
|
361
|
+
...PLATFORM,
|
|
362
|
+
teleport_bookings: {
|
|
363
|
+
columns: [
|
|
364
|
+
'id',
|
|
365
|
+
'reference',
|
|
366
|
+
'user_id',
|
|
367
|
+
'customer_name',
|
|
368
|
+
'customer_email',
|
|
369
|
+
'customer_phone',
|
|
370
|
+
'billing_name',
|
|
371
|
+
'notes',
|
|
372
|
+
'starts_at',
|
|
373
|
+
],
|
|
374
|
+
},
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
expect(run.status).toBe(200)
|
|
378
|
+
const booking = run.statements.filter((statement) =>
|
|
379
|
+
statement.sql.startsWith('UPDATE teleport_bookings')
|
|
380
|
+
)
|
|
381
|
+
expect(booking).toEqual([
|
|
382
|
+
{
|
|
383
|
+
sql: 'UPDATE teleport_bookings SET user_id = NULL, customer_email = $2, customer_name = NULL, customer_phone = NULL WHERE user_id = $1',
|
|
384
|
+
params: [USER_ID, SYNTHETIC_EMAIL],
|
|
385
|
+
},
|
|
386
|
+
])
|
|
387
|
+
const writes = writesOf(run)
|
|
388
|
+
expect(writes.indexOf(booking[0].sql)).toBeLessThan(
|
|
389
|
+
writes.indexOf('DELETE FROM users WHERE id = $1')
|
|
390
|
+
)
|
|
391
|
+
expect(sqlOf(run).join('\n')).not.toContain('DELETE FROM teleport_bookings')
|
|
392
|
+
expect(sqlOf(run).join('\n')).not.toContain('"teleport_bookings"')
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('a project with no tables of its own touches only the platform tables, in their order', async () => {
|
|
396
|
+
const run = await runRoute(PLATFORM)
|
|
397
|
+
|
|
398
|
+
expect(run.status).toBe(200)
|
|
399
|
+
expect(writesOf(run)).toEqual([
|
|
400
|
+
'BEGIN',
|
|
401
|
+
'UPDATE teleport_orders SET user_id = NULL, billing_email = $2 WHERE user_id = $1',
|
|
402
|
+
'UPDATE teleport_subscriptions SET user_id = NULL, customer_email = $2 WHERE user_id = $1',
|
|
403
|
+
'UPDATE teleport_abandoned_carts SET user_id = NULL, customer_email = $2 WHERE user_id = $1',
|
|
404
|
+
'UPDATE teleport_voucher_redemptions SET user_id = NULL, billing_email = $2 WHERE user_id = $1',
|
|
405
|
+
'DELETE FROM teleport_cart WHERE user_id = $1',
|
|
406
|
+
'DELETE FROM teleport_push_subscriptions WHERE user_id = $1',
|
|
407
|
+
'DELETE FROM password_reset_tokens WHERE email = $1',
|
|
408
|
+
'DELETE FROM users WHERE id = $1',
|
|
409
|
+
'COMMIT',
|
|
410
|
+
])
|
|
411
|
+
for (const update of run.statements.filter((statement) => statement.sql.startsWith('UPDATE'))) {
|
|
412
|
+
expect(update.params).toEqual([USER_ID, SYNTHETIC_EMAIL])
|
|
413
|
+
}
|
|
414
|
+
expect(run.errors).toEqual([])
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it('a table that refuses stops neither the other tables nor the account; its rows are retried, then named', async () => {
|
|
418
|
+
const stepsStillPointAtRecipes = new Error(
|
|
419
|
+
'update or delete on table "recipes" violates foreign key constraint "recipe_steps_recipe_id_fkey" on table "recipe_steps"'
|
|
420
|
+
)
|
|
421
|
+
const someoneElsesCommentPointsAtPost = new Error(
|
|
422
|
+
'update or delete on table "posts" violates foreign key constraint "comments_post_id_fkey" on table "comments"'
|
|
423
|
+
)
|
|
424
|
+
const run = await runRoute(
|
|
425
|
+
{
|
|
426
|
+
...PLATFORM,
|
|
427
|
+
recipes: { columns: ['id', 'user_id'], links: { user_id: 'users.id' } },
|
|
428
|
+
recipe_steps: {
|
|
429
|
+
columns: ['id', 'recipe_id', 'user_id'],
|
|
430
|
+
links: { recipe_id: 'recipes.id', user_id: 'users.id' },
|
|
431
|
+
},
|
|
432
|
+
posts: { columns: ['id', 'user_id'], links: { user_id: 'users.id' } },
|
|
433
|
+
},
|
|
434
|
+
(sql, attempt) => {
|
|
435
|
+
if (sql === 'DELETE FROM "recipes" WHERE "user_id" = $1' && attempt === 1) {
|
|
436
|
+
return stepsStillPointAtRecipes
|
|
437
|
+
}
|
|
438
|
+
if (sql === 'DELETE FROM "posts" WHERE "user_id" = $1') {
|
|
439
|
+
return someoneElsesCommentPointsAtPost
|
|
440
|
+
}
|
|
441
|
+
return null
|
|
442
|
+
}
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
expect(run.status).toBe(200)
|
|
446
|
+
const writes = writesOf(run)
|
|
447
|
+
expect(writes[writes.length - 1]).toBe('COMMIT')
|
|
448
|
+
expect(writes).toContain('DELETE FROM users WHERE id = $1')
|
|
449
|
+
expect(
|
|
450
|
+
writes.filter((sql) => sql === 'DELETE FROM "recipes" WHERE "user_id" = $1')
|
|
451
|
+
).toHaveLength(2)
|
|
452
|
+
expect(
|
|
453
|
+
writes.filter((sql) => sql === 'DELETE FROM "recipe_steps" WHERE "user_id" = $1')
|
|
454
|
+
).toHaveLength(1)
|
|
455
|
+
// Refused in the first round, in the round that freed the recipes, and once more alone.
|
|
456
|
+
expect(writes.filter((sql) => sql === 'DELETE FROM "posts" WHERE "user_id" = $1')).toHaveLength(
|
|
457
|
+
3
|
|
458
|
+
)
|
|
459
|
+
const rollbacks = writes
|
|
460
|
+
.map((sql, index) => (sql === 'ROLLBACK TO SAVEPOINT owned_rows' ? index : -1))
|
|
461
|
+
.filter((index) => index !== -1)
|
|
462
|
+
expect(rollbacks).toHaveLength(4)
|
|
463
|
+
for (const index of rollbacks) {
|
|
464
|
+
expect(writes[index + 1]).toBe('RELEASE SAVEPOINT owned_rows')
|
|
465
|
+
}
|
|
466
|
+
expect(writes).not.toContain('ROLLBACK')
|
|
467
|
+
expect(run.errors).toEqual([
|
|
468
|
+
[`[account-delete] rows kept in posts (user_id): ${someoneElsesCommentPointsAtPost.message}`],
|
|
469
|
+
])
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('reads the catalogue with the column positions, in table and column order', async () => {
|
|
473
|
+
const run = await runRoute(PLATFORM)
|
|
474
|
+
const catalogue = sqlOf(run).filter((sql) => sql.indexOf(COLUMNS_OF_EVERY_TABLE) === 0)
|
|
475
|
+
expect(catalogue).toHaveLength(1)
|
|
476
|
+
expect(catalogue[0]).toContain('SELECT c.table_name, c.column_name, c.ordinal_position FROM')
|
|
477
|
+
expect(catalogue[0]).toContain(" AND t.table_type = 'BASE TABLE'")
|
|
478
|
+
expect(catalogue[0].endsWith(' ORDER BY c.table_name, c.ordinal_position')).toBe(true)
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
it('carries the shared resolver as text, byte for byte', () => {
|
|
482
|
+
const code = generateAccountDeleteRoute({ authUsersTableName: 'users' })
|
|
483
|
+
expect(code).toContain(OwnerColumns.OWNER_COLUMN_SOURCES.resolveOwnerColumn)
|
|
484
|
+
expect(code).toContain(OwnerColumns.OWNER_COLUMN_SOURCES.attributionKind)
|
|
485
|
+
expect(code).toContain(OwnerColumns.OWNER_COLUMN_SOURCES.userIdRole)
|
|
486
|
+
expect(code).toContain(OwnerColumns.OWNER_COLUMN_SOURCES.ownerColumnPriority)
|
|
487
|
+
})
|
|
488
|
+
})
|