@dooer/dooer-test-env 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/bin/index.js +7 -0
  2. package/discovery-router/Dockerfile +18 -0
  3. package/discovery-router/README.md +99 -0
  4. package/discovery-router/package.json +13 -0
  5. package/discovery-router/registry.example.json +5 -0
  6. package/discovery-router/server.js +272 -0
  7. package/lib/account.js +120 -0
  8. package/lib/auth-dev-keys.js +12 -0
  9. package/lib/bankid.js +130 -0
  10. package/lib/cli.js +27 -0
  11. package/lib/command/bankid.js +45 -0
  12. package/lib/command/customer.js +108 -0
  13. package/lib/command/db.js +156 -0
  14. package/lib/command/env.js +114 -0
  15. package/lib/command/logs.js +143 -0
  16. package/lib/command/measure.js +81 -0
  17. package/lib/command/service.js +166 -0
  18. package/lib/command/setup.js +92 -0
  19. package/lib/command/shred.js +60 -0
  20. package/lib/compose/README.md +98 -0
  21. package/lib/compose/generate.js +375 -0
  22. package/lib/compose/manifests.js +108 -0
  23. package/lib/db/roles.js +118 -0
  24. package/lib/discovery/client.js +40 -0
  25. package/lib/engine/GUIDE.md +176 -0
  26. package/lib/engine/PROCESS.md +571 -0
  27. package/lib/engine/dbbuild.js +325 -0
  28. package/lib/engine/gen-schema-map.js +479 -0
  29. package/lib/engine/purge.js +137 -0
  30. package/lib/engine/schema-map.json +11016 -0
  31. package/lib/engine/seed.js +1045 -0
  32. package/lib/obc.js +72 -0
  33. package/lib/registry.js +123 -0
  34. package/lib/runtime.js +101 -0
  35. package/lib/service-token.js +40 -0
  36. package/lib/shred/README.md +118 -0
  37. package/lib/shred/audit.js +128 -0
  38. package/lib/shred/faker.js +545 -0
  39. package/lib/shred/index.js +126 -0
  40. package/lib/shred/scripts/base-partner-emails.sql +9 -0
  41. package/lib/shred/scripts/dev-accounts.sql +195 -0
  42. package/lib/shred/scripts/emails.sql +48 -0
  43. package/lib/shred/scripts/institution-browser.sql +3 -0
  44. package/lib/shred/scripts/notification-targets.sql +5 -0
  45. package/lib/shred/scripts/partners.sql +2 -0
  46. package/lib/shred/scripts/passwords.sql +8 -0
  47. package/lib/shred/scripts/personal-numbers.sql +177 -0
  48. package/lib/shred/scripts/phone-numbers.sql +22 -0
  49. package/lib/shred/scripts/salary-spec-reports.sql +5 -0
  50. package/lib/shred/scripts/service-activity-tracker-data.sql +4 -0
  51. package/lib/shred/scripts/service-core-objects.sql +19 -0
  52. package/lib/shred/scripts/service-event-stream.sql +2 -0
  53. package/lib/shred/scripts/service-integrations.sql +4 -0
  54. package/lib/shred/scripts/template.sql +4 -0
  55. package/lib/shred/scripts/x-service-billing.sql +34 -0
  56. package/lib/shred/scripts/xxx-history-tables.sql +25 -0
  57. package/lib/stub.js +8 -0
  58. package/local-postgres/Dockerfile +11 -0
  59. package/package.json +46 -0
  60. package/readme.md +92 -0
@@ -0,0 +1,545 @@
1
+ // faker.js — the JS "fake personal data" pass of the shredder, ported from cli-db-shredder's
2
+ // lib/sql.js `fakePersonalData()`, but DEPENDENCY-FREE and DETERMINISTIC.
3
+ //
4
+ // The original used the npm `faker` package seeded per-row (`faker.seed(pk)`); this reimplements the
5
+ // same idea with a small self-contained PRNG seeded from each row's primary-key hex, plus built-in
6
+ // name/street/city pools. Same pk → identical output; a different pk → different output. No npm deps.
7
+ //
8
+ // Ordering: this pass runs BEFORE the SQL scripts (see index.js) — the SQL `x-`/`xxx-` scripts rebuild
9
+ // denormalized JSON (invoice.frozenCustomerDetails) and truncate history AFTER these base rows are faked.
10
+ //
11
+ // Emails set here → `testcustomer+<pkhex>@dooer.com` (unique per row, matching the copy tool's
12
+ // convention). Protection predicates from the original are preserved verbatim: the `'test-company' =
13
+ // any(tags)` company allow-list and the `@dooer.com/@voitto.se/@steffner.nu` user-email allow-list.
14
+
15
+ // ── deterministic PRNG (xmur3 seed → mulberry32 stream). Bit ops are intentional (eslint no-bitwise off).
16
+ function xmur3(str) {
17
+ let h = 1779033703 ^ str.length
18
+ for (let i = 0; i < str.length; i++) {
19
+ h = Math.imul(h ^ str.charCodeAt(i), 3432918353)
20
+ h = (h << 13) | (h >>> 19)
21
+ }
22
+ return function next() {
23
+ h = Math.imul(h ^ (h >>> 16), 2246822507)
24
+ h = Math.imul(h ^ (h >>> 13), 3266489909)
25
+ h ^= h >>> 16
26
+ return h >>> 0
27
+ }
28
+ }
29
+
30
+ function mulberry32(a) {
31
+ return function rand() {
32
+ a |= 0
33
+ a = (a + 0x6d2b79f5) | 0
34
+ let t = Math.imul(a ^ (a >>> 15), 1 | a)
35
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
36
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
37
+ }
38
+ }
39
+
40
+ // Seed from the row pk (hyphens stripped so a full uuid is used without float precision loss — the
41
+ // original's parseInt(pk,16) silently lost precision; this keeps the whole key).
42
+ function makeRng(pk) {
43
+ const seedStr = String(pk == null ? '' : pk)
44
+ .replace(/-/g, '')
45
+ .toLowerCase()
46
+ const seed = xmur3(seedStr || '0')
47
+ return mulberry32(seed())
48
+ }
49
+
50
+ // ── small built-in pools ───────────────────────────────────────────────────────
51
+ const FIRST_NAMES = [
52
+ 'Anna',
53
+ 'Erik',
54
+ 'Maria',
55
+ 'Johan',
56
+ 'Karin',
57
+ 'Lars',
58
+ 'Eva',
59
+ 'Anders',
60
+ 'Sara',
61
+ 'Peter',
62
+ 'Emma',
63
+ 'Mikael',
64
+ 'Lena',
65
+ 'Fredrik',
66
+ 'Ingrid',
67
+ 'Nils',
68
+ 'Sofia',
69
+ 'Gustav',
70
+ 'Linnea',
71
+ 'Oskar',
72
+ ]
73
+ const LAST_NAMES = [
74
+ 'Andersson',
75
+ 'Johansson',
76
+ 'Karlsson',
77
+ 'Nilsson',
78
+ 'Eriksson',
79
+ 'Larsson',
80
+ 'Olsson',
81
+ 'Persson',
82
+ 'Svensson',
83
+ 'Gustafsson',
84
+ 'Pettersson',
85
+ 'Jonsson',
86
+ 'Jansson',
87
+ 'Hansson',
88
+ 'Bengtsson',
89
+ 'Lindberg',
90
+ ]
91
+ const STREETS = [
92
+ 'Storgatan',
93
+ 'Kungsgatan',
94
+ 'Vasagatan',
95
+ 'Drottninggatan',
96
+ 'Nygatan',
97
+ 'Skolgatan',
98
+ 'Parkvägen',
99
+ 'Björkvägen',
100
+ 'Ringvägen',
101
+ 'Industrigatan',
102
+ 'Hamngatan',
103
+ 'Järnvägsgatan',
104
+ 'Södra vägen',
105
+ 'Norra vägen',
106
+ ]
107
+ const CITIES = [
108
+ 'Stockholm',
109
+ 'Göteborg',
110
+ 'Malmö',
111
+ 'Uppsala',
112
+ 'Västerås',
113
+ 'Örebro',
114
+ 'Linköping',
115
+ 'Helsingborg',
116
+ 'Jönköping',
117
+ 'Norrköping',
118
+ 'Lund',
119
+ 'Umeå',
120
+ 'Gävle',
121
+ 'Borås',
122
+ 'Södertälje',
123
+ 'Eskilstuna',
124
+ ]
125
+ const STATES = [
126
+ 'Stockholms län',
127
+ 'Västra Götalands län',
128
+ 'Skåne län',
129
+ 'Uppsala län',
130
+ 'Östergötlands län',
131
+ 'Hallands län',
132
+ ]
133
+ const COUNTRY_CODES = ['SE', 'NO', 'DK', 'FI', 'DE', 'GB', 'NL']
134
+ const COMPANY_HEAD = [
135
+ 'Nordic',
136
+ 'Svea',
137
+ 'Baltic',
138
+ 'Aurora',
139
+ 'Granit',
140
+ 'Fjäll',
141
+ 'Björk',
142
+ 'Lykke',
143
+ 'Vinter',
144
+ 'Sommar',
145
+ 'Vega',
146
+ 'Polaris',
147
+ 'Kompass',
148
+ 'Delta',
149
+ 'Orion',
150
+ 'Tellus',
151
+ ]
152
+ const COMPANY_TAIL = ['AB', 'HB', 'Group', 'Konsult AB', 'Handel AB', 'Teknik AB', 'Förvaltning AB']
153
+ const WORDS = [
154
+ 'lorem',
155
+ 'ipsum',
156
+ 'dolor',
157
+ 'consulting',
158
+ 'service',
159
+ 'produkt',
160
+ 'faktura',
161
+ 'leverans',
162
+ 'projekt',
163
+ 'timmar',
164
+ 'material',
165
+ 'frakt',
166
+ 'rådgivning',
167
+ 'underhåll',
168
+ 'support',
169
+ 'licens',
170
+ ]
171
+ const ALPHANUM = 'abcdefghijklmnopqrstuvwxyz0123456789'
172
+
173
+ const randInt = (rng, n) => Math.floor(rng() * n)
174
+ const pick = (rng, arr) => arr[randInt(rng, arr.length)]
175
+
176
+ function alphaNumeric(rng, n) {
177
+ let out = ''
178
+ for (let i = 0; i < n; i++) out += ALPHANUM[randInt(rng, ALPHANUM.length)]
179
+ return out
180
+ }
181
+ const firstName = (rng) => pick(rng, FIRST_NAMES)
182
+ const lastName = (rng) => pick(rng, LAST_NAMES)
183
+ const companyName = (rng) => `${pick(rng, COMPANY_HEAD)} ${pick(rng, COMPANY_HEAD)} ${pick(rng, COMPANY_TAIL)}`
184
+ const streetAddress = (rng) => `${pick(rng, STREETS)} ${1 + randInt(rng, 200)}`
185
+ const secondaryAddress = (rng) => `Lgh ${1000 + randInt(rng, 2000)}`
186
+ const zipCode = (rng) => String(10000 + randInt(rng, 89999))
187
+ const city = (rng) => pick(rng, CITIES)
188
+ const state = (rng) => pick(rng, STATES)
189
+ const countryCode = (rng) => pick(rng, COUNTRY_CODES)
190
+ const word = (rng) => pick(rng, WORDS)
191
+ const words = (rng) => `${word(rng)} ${word(rng)} ${word(rng)}`
192
+ const lines = (rng) => `${words(rng)}. ${words(rng)}.`
193
+ const url = (rng) => `https://${word(rng)}${randInt(rng, 1000)}.example.com`
194
+ // SE VAT: "SE" + 10 digits org number + "01" sequence suffix
195
+ const vatNumber = (rng) => `SE${String(1000000000 + randInt(rng, 899999999))}01`
196
+ const iban = (rng) => `SE${alphaNumeric(rng, 22).toUpperCase()}`
197
+ const bic = (rng) => `${alphaNumeric(rng, 4).toUpperCase()}SESS`
198
+ const digits = (rng, n) => {
199
+ let out = ''
200
+ for (let i = 0; i < n; i++) out += String(randInt(rng, 10))
201
+ return out
202
+ }
203
+
204
+ // Rebuild a payment-method-domestic JSON with only the keys the row already had (mirrors the original's
205
+ // `getRandomPaymentMethodDomesticConfiguration(Object.keys(existing))`).
206
+ function paymentMethodDomestic(rng, keys) {
207
+ const all = {
208
+ bankgiro: `${digits(rng, 3)}-${digits(rng, 4)}`,
209
+ plusgiro: `${digits(rng, 6)}-${digits(rng, 1)}`,
210
+ bankAccountNumber: digits(rng, 10),
211
+ bankClearingNumber: digits(rng, 4),
212
+ swish: `073${digits(rng, 7)}`,
213
+ }
214
+ const out = {}
215
+ for (const k of keys || []) if (k in all) out[k] = all[k]
216
+ return out
217
+ }
218
+
219
+ // The email set for any row in this pass. Unique per row via the pk hex.
220
+ function fakeEmail(pk) {
221
+ const hex = String(pk == null ? '' : pk)
222
+ .replace(/-/g, '')
223
+ .toLowerCase()
224
+ return `testcustomer+${hex}@dooer.com`
225
+ }
226
+
227
+ // ── protection predicates (verbatim intent from the original) ────────────────────
228
+ // Companies flagged internal (a `test-company` tag) are spared.
229
+ const NOT_TEST_COMPANY = (col) =>
230
+ `${col} NOT IN (SELECT companies_pk FROM service_accounts.companies WHERE 'test-company' = any(tags))`
231
+ // Internal user emails are spared (kept real so dev logins keep working).
232
+ const USER_EMAIL_ALLOWLISTED = (email) =>
233
+ !!email && (email.endsWith('@dooer.com') || email.endsWith('@voitto.se') || email.endsWith('@steffner.nu'))
234
+
235
+ // The tables/columns this pass touches — surfaced for the dry-run listing and the audit's covered-set.
236
+ const FAKER_TABLES = [
237
+ 'service_accounts.companies',
238
+ 'service_accounts.users',
239
+ 'service_salaries.salarySpecification',
240
+ 'service_salaries_legacy.salarySpecification',
241
+ 'service_comments.message',
242
+ 'service_customer_questions.question',
243
+ 'service_employees.employee',
244
+ 'service_employees.employeeAddress',
245
+ 'service_employees_legacy.employee',
246
+ 'service_employees_legacy.employeeAddress',
247
+ 'service_billing.companyInformation',
248
+ 'service_billing.customerAddress',
249
+ 'service_billing.customerContact',
250
+ 'service_billing.invoice',
251
+ 'service_billing.customer',
252
+ 'service_billing.item',
253
+ 'service_billing.invoiceLine',
254
+ 'service_core_objects.payments',
255
+ 'service_sales.invoice',
256
+ ]
257
+
258
+ // Best-effort per-statement runner: some legacy schemas/columns may not exist in a given DB. We do NOT
259
+ // want one missing table to abort the whole faker pass, so each statement is tried and failures are
260
+ // collected (mirrors the "collect errors, report which failed" behaviour the runner uses for the SQL
261
+ // scripts). The caller wraps this in a transaction.
262
+ async function tryQuery(client, warnings, label, sql, params) {
263
+ try {
264
+ return await client.query(sql, params)
265
+ } catch (e) {
266
+ warnings.push(`${label}: ${e.message}`)
267
+ return { rows: [] }
268
+ }
269
+ }
270
+
271
+ // Port of fakePersonalData(): runs a series of deterministic UPDATEs on the given (connected) pg client.
272
+ // Returns { warnings } — non-fatal per-statement failures (e.g. a legacy table absent locally).
273
+ async function fakePersonalData(client, { verbose } = {}) {
274
+ const warnings = []
275
+ const q = (label, sql, params) => tryQuery(client, warnings, label, sql, params)
276
+
277
+ // companies (+ their salary-spec company denorm), skipping test-companies
278
+ const companies = await q(
279
+ 'companies:select',
280
+ `SELECT companies_pk FROM service_accounts.companies WHERE ${NOT_TEST_COMPANY('companies_pk')}`
281
+ )
282
+ for (const company of companies.rows) {
283
+ const rng = makeRng(company.companies_pk)
284
+ await q(
285
+ 'companies:update',
286
+ `UPDATE service_accounts.companies
287
+ SET company_name=$1, address=$2, address2=$3, postal_code=$4, city=$5, short_name=$6
288
+ WHERE companies_pk=$7`,
289
+ [
290
+ companyName(rng),
291
+ streetAddress(rng),
292
+ secondaryAddress(rng),
293
+ zipCode(rng),
294
+ city(rng),
295
+ alphaNumeric(rng, 6),
296
+ company.companies_pk,
297
+ ]
298
+ )
299
+ await q(
300
+ 'salarySpecification:company',
301
+ `UPDATE service_salaries."salarySpecification"
302
+ SET "companyName"=$1, "companyStreet"=$2, "companyPostalCode"=$3, "companyCity"=$4
303
+ WHERE "organizationId"=$5`,
304
+ [companyName(rng), streetAddress(rng), zipCode(rng), city(rng), company.companies_pk]
305
+ )
306
+ await q(
307
+ 'salarySpecification_legacy:company',
308
+ `UPDATE service_salaries_legacy."salarySpecification" SET "companyName"=$1 WHERE "organizationId"=$2`,
309
+ [companyName(rng), company.companies_pk]
310
+ )
311
+ }
312
+
313
+ // users — email/name/phone/address, skipping allow-listed internal emails
314
+ const users = await q(
315
+ 'users:select',
316
+ `SELECT users_pk, email, cell_phone, phone, address, address2 FROM service_accounts.users`
317
+ )
318
+ for (const user of users.rows) {
319
+ if (USER_EMAIL_ALLOWLISTED(user.email)) continue
320
+ const rng = makeRng(user.users_pk)
321
+ await q(
322
+ 'users:update',
323
+ `UPDATE service_accounts.users
324
+ SET email=$2, first_name=$3, last_name=$4, cell_phone=$5, phone=$6, address=$7, address2=$8, postal_code=$9, city=$10
325
+ WHERE users_pk=$1`,
326
+ [
327
+ user.users_pk,
328
+ user.email ? fakeEmail(user.users_pk) : null,
329
+ firstName(rng),
330
+ lastName(rng),
331
+ user.cell_phone ? '+46000000000' : null,
332
+ user.phone ? '+46000000000' : null,
333
+ user.address ? streetAddress(rng) : null,
334
+ user.address2 ? secondaryAddress(rng) : null,
335
+ user.address2 ? zipCode(rng) : null,
336
+ user.address2 ? city(rng) : null,
337
+ ]
338
+ )
339
+ }
340
+
341
+ // free-text bodies keyed to non-test companies
342
+ {
343
+ const rng = makeRng('service_comments.message')
344
+ await q(
345
+ 'comments:message',
346
+ `UPDATE service_comments.message SET body=$1 WHERE ${NOT_TEST_COMPANY('"organizationId"')}`,
347
+ [lines(rng)]
348
+ )
349
+ }
350
+ {
351
+ const rng = makeRng('service_customer_questions.question')
352
+ await q(
353
+ 'customer_questions:answer',
354
+ `UPDATE service_customer_questions.question SET answer=$1 WHERE ${NOT_TEST_COMPANY('"organizationId"')}`,
355
+ [lines(rng)]
356
+ )
357
+ }
358
+
359
+ // employees (current + legacy) — email/name/phone/address + salary-spec employee denorm
360
+ for (const schema of ['service_employees', 'service_employees_legacy']) {
361
+ const emps = await q(
362
+ `${schema}:select`,
363
+ `SELECT id, phone FROM ${schema}.employee WHERE ${NOT_TEST_COMPANY('"organizationId"')}`
364
+ )
365
+ for (const emp of emps.rows) {
366
+ const rng = makeRng(emp.id)
367
+ const fn = firstName(rng)
368
+ const ln = lastName(rng)
369
+ await q(
370
+ `${schema}:employee`,
371
+ `UPDATE ${schema}.employee SET email=$1, "firstName"=$2, "lastName"=$3, "phone"=$4 WHERE id=$5`,
372
+ [fakeEmail(emp.id), fn, ln, emp.phone ? '+46000000000' : null, emp.id]
373
+ )
374
+ await q(
375
+ `${schema}:employeeAddress`,
376
+ `UPDATE ${schema}."employeeAddress" SET "addressLines"=$1, "postalCode"=$2, "city"=$3, "region"=$4 WHERE "employeeId"=$5`,
377
+ [[streetAddress(rng)], zipCode(rng), city(rng), null, emp.id]
378
+ )
379
+ if (schema === 'service_employees') {
380
+ await q(
381
+ 'salarySpecification:employee',
382
+ `UPDATE service_salaries."salarySpecification" SET "employeeName"=$1, "employeeEmail"=$2 WHERE "employeeId"=$3`,
383
+ [`${fn} ${ln}`, fakeEmail(emp.id), emp.id]
384
+ )
385
+ }
386
+ }
387
+ }
388
+
389
+ // billing.companyInformation — name/vat/address/website/payment configs
390
+ const compInfos = await q(
391
+ 'companyInformation:select',
392
+ `SELECT id, "paymentMethodDomesticConfiguration", "paymentMethodInternationalConfiguration"
393
+ FROM service_billing."companyInformation" WHERE ${NOT_TEST_COMPANY('"organizationId"')}`
394
+ )
395
+ for (const ci of compInfos.rows) {
396
+ const rng = makeRng(ci.id)
397
+ const addressJson = JSON.stringify({
398
+ city: city(rng),
399
+ street: [streetAddress(rng)],
400
+ country: countryCode(rng),
401
+ zipCode: zipCode(rng),
402
+ })
403
+ const domesticKeys = ci.paymentMethodDomesticConfiguration ? Object.keys(ci.paymentMethodDomesticConfiguration) : []
404
+ const domesticJson = JSON.stringify(paymentMethodDomestic(rng, domesticKeys))
405
+ const intlEmpty =
406
+ !ci.paymentMethodInternationalConfiguration ||
407
+ Object.keys(ci.paymentMethodInternationalConfiguration).length === 0
408
+ const intlJson = JSON.stringify(intlEmpty ? {} : { iban: iban(rng), bic: bic(rng) })
409
+ await q(
410
+ 'companyInformation:update',
411
+ `UPDATE service_billing."companyInformation"
412
+ SET "companyName"=$1, "vatNumber"=$2, "address"=$3, "website"=$4,
413
+ "paymentMethodDomesticConfiguration"=$5, "paymentMethodInternationalConfiguration"=$6
414
+ WHERE id=$7`,
415
+ [companyName(rng), vatNumber(rng), addressJson, url(rng), domesticJson, intlJson, ci.id]
416
+ )
417
+ }
418
+
419
+ // billing.customerAddress
420
+ const custAddrs = await q(
421
+ 'customerAddress:select',
422
+ `SELECT id FROM service_billing."customerAddress" WHERE ${NOT_TEST_COMPANY('"organizationId"')}`
423
+ )
424
+ for (const ca of custAddrs.rows) {
425
+ const rng = makeRng(ca.id)
426
+ await q(
427
+ 'customerAddress:update',
428
+ `UPDATE service_billing."customerAddress" SET street=$1, city=$2, "zipCode"=$3, state=$4, country=$5 WHERE id=$6`,
429
+ [[streetAddress(rng)], city(rng), zipCode(rng), state(rng), countryCode(rng), ca.id]
430
+ )
431
+ }
432
+
433
+ // billing.customerContact
434
+ const custContacts = await q(
435
+ 'customerContact:select',
436
+ `SELECT id FROM service_billing."customerContact" WHERE ${NOT_TEST_COMPANY('"organizationId"')}`
437
+ )
438
+ for (const cc of custContacts.rows) {
439
+ const rng = makeRng(cc.id)
440
+ await q(
441
+ 'customerContact:update',
442
+ `UPDATE service_billing."customerContact" SET email=$1, "firstName"=$2, "surName"=$3 WHERE id=$4`,
443
+ [fakeEmail(cc.id), firstName(rng), lastName(rng), cc.id]
444
+ )
445
+ }
446
+
447
+ // billing.invoice message → blank
448
+ const billInvoices = await q(
449
+ 'billing.invoice:select',
450
+ `SELECT id FROM service_billing."invoice" WHERE ${NOT_TEST_COMPANY('"organizationId"')}`
451
+ )
452
+ for (const inv of billInvoices.rows) {
453
+ await q('billing.invoice:message', `UPDATE service_billing."invoice" SET message=$1 WHERE id=$2`, ['', inv.id])
454
+ }
455
+
456
+ // billing.customer name/vat
457
+ const customers = await q(
458
+ 'billing.customer:select',
459
+ `SELECT id FROM service_billing."customer" WHERE ${NOT_TEST_COMPANY('"organizationId"')}`
460
+ )
461
+ for (const cust of customers.rows) {
462
+ const rng = makeRng(cust.id)
463
+ await q('billing.customer:update', `UPDATE service_billing."customer" SET name=$1, "vatNumber"=$2 WHERE id=$3`, [
464
+ companyName(rng),
465
+ vatNumber(rng),
466
+ cust.id,
467
+ ])
468
+ }
469
+
470
+ // billing.item description
471
+ const items = await q(
472
+ 'billing.item:select',
473
+ `SELECT id FROM service_billing."item" WHERE ${NOT_TEST_COMPANY('"organizationId"')}`
474
+ )
475
+ for (const item of items.rows) {
476
+ const rng = makeRng(item.id)
477
+ await q('billing.item:update', `UPDATE service_billing."item" SET description=$1 WHERE id=$2`, [word(rng), item.id])
478
+ }
479
+
480
+ // billing.invoiceLine freetext description
481
+ const invLines = await q(
482
+ 'billing.invoiceLine:select',
483
+ `SELECT id FROM service_billing."invoiceLine" WHERE ${NOT_TEST_COMPANY('"organizationId"')}`
484
+ )
485
+ for (const line of invLines.rows) {
486
+ const rng = makeRng(line.id)
487
+ await q(
488
+ 'billing.invoiceLine:update',
489
+ `UPDATE service_billing."invoiceLine" SET description=$1 WHERE id=$2 AND type = 'freetext'`,
490
+ [words(rng), line.id]
491
+ )
492
+ }
493
+
494
+ // core_objects.payments receiver_name (non-tax, non-test-company)
495
+ const payments = await q(
496
+ 'payments:select',
497
+ `SELECT payments_pk, payment_type FROM service_core_objects.payments
498
+ WHERE payment_type <> 'tax' AND ${NOT_TEST_COMPANY('"fk_companies_pk"')}`
499
+ )
500
+ for (const p of payments.rows) {
501
+ const rng = makeRng(p.payments_pk)
502
+ const name = p.payment_type === 'vendor-payment' ? companyName(rng) : `${firstName(rng)} ${lastName(rng)}`
503
+ await q('payments:update', `UPDATE service_core_objects.payments SET receiver_name=$1 WHERE payments_pk=$2`, [
504
+ name,
505
+ p.payments_pk,
506
+ ])
507
+ }
508
+
509
+ // sales.invoice customerName/customerVatNumber (seeded by customerNumber, as the original did)
510
+ const salesInvoices = await q(
511
+ 'sales.invoice:select',
512
+ `SELECT id, "customerNumber" FROM service_sales."invoice" WHERE ${NOT_TEST_COMPANY('"organizationId"')}`
513
+ )
514
+ for (const inv of salesInvoices.rows) {
515
+ const rng = makeRng(inv.customerNumber)
516
+ await q(
517
+ 'sales.invoice:update',
518
+ `UPDATE service_sales."invoice" SET "customerName"=$1, "customerVatNumber"=$2 WHERE id=$3`,
519
+ [companyName(rng), vatNumber(rng), inv.id]
520
+ )
521
+ }
522
+
523
+ if (verbose && warnings.length) {
524
+ // eslint-disable-next-line no-console
525
+ console.log(`faker pass: ${warnings.length} non-fatal statement warning(s) (missing tables/cols)`)
526
+ }
527
+ return { warnings }
528
+ }
529
+
530
+ module.exports = {
531
+ fakePersonalData,
532
+ FAKER_TABLES,
533
+ fakeEmail,
534
+ // exported for unit tests / reuse
535
+ makeRng,
536
+ firstName,
537
+ lastName,
538
+ companyName,
539
+ streetAddress,
540
+ zipCode,
541
+ city,
542
+ paymentMethodDomestic,
543
+ USER_EMAIL_ALLOWLISTED,
544
+ NOT_TEST_COMPANY,
545
+ }
@@ -0,0 +1,126 @@
1
+ // index.js — the shred runner. Anonymizes PII in a Postgres via two rule layers, in this fixed order:
2
+ // 1. the JS faker pass (faker.js `fakePersonalData`) — names/addresses/phones/company names, emails →
3
+ // testcustomer+<pkhex>@dooer.com
4
+ // 2. the vendored per-table SQL scripts (./scripts/*.sql) in FILENAME order — emails, personnummer
5
+ // (Luhn, dates forced 18xx), phones, passwords, truncations, denormalized-JSON rebuilds (`x-`) and
6
+ // history truncations (`xxx-`) LAST.
7
+ // Plus the PII audit (audit.js) as the schema-drift backstop.
8
+ //
9
+ // The caller (lib/command/shred.js) owns the localhost guard + the connection; this takes an
10
+ // already-connected `pg` Client. See ENVIRONMENT-PLAN.md §1 (shredder), §5 (base build + Safety), §9.7.
11
+
12
+ const fs = require('fs')
13
+ const path = require('path')
14
+ const { fakePersonalData, FAKER_TABLES } = require('./faker')
15
+ const { auditPii } = require('./audit')
16
+
17
+ const SCRIPTS_DIR = path.join(__dirname, 'scripts')
18
+
19
+ // SQL scripts in filename order (the `x-`/`xxx-` prefixes encode denormalized/history-last ordering).
20
+ function listScripts() {
21
+ return fs
22
+ .readdirSync(SCRIPTS_DIR)
23
+ .filter((f) => /\.sql$/i.test(f))
24
+ .sort()
25
+ }
26
+
27
+ // Run one whole .sql file as a single simple-query batch. node-pg's simple query stops at the first
28
+ // erroring statement (same effect as psql ON_ERROR_STOP=1) and rejects — we wrap each script in its own
29
+ // transaction so a failing script rolls back cleanly and the NEXT script still runs (errors collected).
30
+ async function runScript(client, name, { verbose }) {
31
+ const sql = fs.readFileSync(path.join(SCRIPTS_DIR, name), 'utf8')
32
+ try {
33
+ await client.query('BEGIN')
34
+ await client.query(sql)
35
+ await client.query('COMMIT')
36
+ if (verbose) console.log(` ✅ ${name}`)
37
+ return { name, ok: true }
38
+ } catch (e) {
39
+ try {
40
+ await client.query('ROLLBACK')
41
+ } catch (_) {
42
+ /* ignore rollback failure */
43
+ }
44
+ if (verbose) console.log(` 🔴 ${name} — ${e.message}`)
45
+ return { name, ok: false, error: e.message }
46
+ }
47
+ }
48
+
49
+ // shred(pgClient, { execute, verbose })
50
+ // execute=false (default) → DRY-RUN: lists the faker tables + script order, runs the audit READ-ONLY,
51
+ // writes NOTHING.
52
+ // execute=true → runs the faker pass then every SQL script (each in its own transaction,
53
+ // errors collected not fatal), then the audit. Returns a summary.
54
+ async function shred(pgClient, { execute = false, verbose = false } = {}) {
55
+ if (!pgClient) throw new Error('shred(pgClient, opts): a connected pg Client is required')
56
+
57
+ const scripts = listScripts()
58
+
59
+ if (!execute) {
60
+ console.log('\n=== shred · DRY-RUN (no writes) ===')
61
+ console.log(`\nfaker pass would touch ${FAKER_TABLES.length} table(s):`)
62
+ for (const t of FAKER_TABLES) console.log(` · ${t}`)
63
+ console.log(`\nSQL scripts would run in this order (${scripts.length}):`)
64
+ scripts.forEach((s, i) => console.log(` ${String(i + 1).padStart(2)}. ${s}`))
65
+
66
+ const audit = await auditPii(pgClient)
67
+ printAudit(audit)
68
+ return { executed: false, faker: { tables: FAKER_TABLES }, scripts, audit }
69
+ }
70
+
71
+ console.log('\n=== shred · EXECUTE ===')
72
+
73
+ // 1. faker pass (its own transaction)
74
+ let faker
75
+ try {
76
+ await pgClient.query('BEGIN')
77
+ faker = await fakePersonalData(pgClient, { verbose })
78
+ await pgClient.query('COMMIT')
79
+ if (verbose) console.log(' ✅ faker pass')
80
+ } catch (e) {
81
+ try {
82
+ await pgClient.query('ROLLBACK')
83
+ } catch (_) {
84
+ /* ignore */
85
+ }
86
+ faker = { warnings: [], error: e.message }
87
+ console.log(` 🔴 faker pass — ${e.message}`)
88
+ }
89
+
90
+ // 2. SQL scripts, in order, errors collected
91
+ const results = []
92
+ for (const name of scripts) {
93
+ // eslint-disable-next-line no-await-in-loop
94
+ results.push(await runScript(pgClient, name, { verbose }))
95
+ }
96
+
97
+ const failed = results.filter((r) => !r.ok)
98
+ console.log(`\nscripts: ${results.length - failed.length}/${results.length} ok`)
99
+ if (failed.length) {
100
+ console.log('FAILED scripts (likely schema drift — a rule needs updating vs current schema):')
101
+ for (const f of failed) console.log(` 🔴 ${f.name} — ${f.error}`)
102
+ }
103
+
104
+ // 3. audit
105
+ const audit = await auditPii(pgClient)
106
+ printAudit(audit)
107
+
108
+ return { executed: true, faker, scripts: results, failed, audit }
109
+ }
110
+
111
+ function printAudit(audit) {
112
+ console.log(`\n=== PII audit ===`)
113
+ console.log(`covered PII-shaped columns: ${audit.covered} / ${audit.scanned} matched`)
114
+ if (audit.uncovered.length === 0) {
115
+ console.log('✅ no uncovered PII-shaped columns')
116
+ } else {
117
+ console.log(`🔴 ${audit.uncovered.length} UNCOVERED PII-shaped column(s) — db build MUST fail on these:`)
118
+ for (const u of audit.uncovered) console.log(` · [${u.category}] ${u.schema}.${u.table}.${u.column}`)
119
+ }
120
+ }
121
+
122
+ module.exports = {
123
+ shred,
124
+ listScripts,
125
+ SCRIPTS_DIR,
126
+ }
@@ -0,0 +1,9 @@
1
+ -- Base-build additions (Jimmy 2026-09-01): anonymize partner contact + invite emails that exist in the
2
+ -- customer-free base. Unique per source value (MD5), same convention as emails.sql.
3
+ UPDATE service_accounts.partner
4
+ SET "contactEmailAddress" = concat('testcustomer+', MD5("contactEmailAddress"), '@dooer.com')
5
+ WHERE "contactEmailAddress" IS NOT NULL AND "contactEmailAddress" <> '';
6
+
7
+ UPDATE service_accounts."partnerInvite"
8
+ SET "email" = concat('testcustomer+', MD5("email"), '@dooer.com')
9
+ WHERE "email" IS NOT NULL AND "email" <> '';