@dooer/dooer-test-env 1.13.0 → 1.14.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.
- package/lib/cli.js +2 -0
- package/lib/command/partner.js +120 -0
- package/lib/command/target.js +31 -0
- package/lib/command/user.js +172 -0
- package/lib/users.js +285 -0
- package/package.json +2 -1
- package/readme.md +59 -4
package/lib/cli.js
CHANGED
|
@@ -15,6 +15,8 @@ module.exports = async function cli() {
|
|
|
15
15
|
.command(require('./command/service')) // service start|stop|restart|deploy|local|unlocal|version
|
|
16
16
|
.command(require('./command/db')) // db build|pull|snapshot|rollback
|
|
17
17
|
.command(require('./command/customer')) // customer copy|purge
|
|
18
|
+
.command(require('./command/user')) // user search|get|create|partners
|
|
19
|
+
.command(require('./command/partner')) // partner list|users|assign|unassign
|
|
18
20
|
.command(require('./command/shred')) // shred (localhost only)
|
|
19
21
|
.command(require('./command/bankid')) // bankid pull|status|clear (staging certs → keychain)
|
|
20
22
|
.command(require('./command/validation')) // validation on|off|status (output-schema checks)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const chalk = require('chalk')
|
|
2
|
+
const u = require('../users')
|
|
3
|
+
const { targetOptions, writeOptions, label, printTable } = require('./target')
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
command: 'partner <command>',
|
|
7
|
+
describe: 'list partners and manage their user membership (local env by default)',
|
|
8
|
+
builder: (y) =>
|
|
9
|
+
y
|
|
10
|
+
.command({
|
|
11
|
+
command: 'list',
|
|
12
|
+
describe: 'list every partner in the environment',
|
|
13
|
+
builder: targetOptions,
|
|
14
|
+
handler: async (argv) => {
|
|
15
|
+
const ns = u.targetOf(argv)
|
|
16
|
+
const rows = await u.withDb(ns, (db) => u.listPartners(db))
|
|
17
|
+
console.log(chalk.bold(`\n${rows.length} partner(s) in ${label(ns)}\n`))
|
|
18
|
+
printTable(rows, [
|
|
19
|
+
['NAME', (r) => r.name],
|
|
20
|
+
['DOMAIN', (r) => r.domain],
|
|
21
|
+
['USERS', (r) => r.users],
|
|
22
|
+
['STATUS', (r) => r.status],
|
|
23
|
+
['DEFAULT', (r) => (r.isDefaultPartner ? 'yes' : '')],
|
|
24
|
+
['ID', (r) => r.id],
|
|
25
|
+
])
|
|
26
|
+
console.log()
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
.command({
|
|
30
|
+
command: 'users <partner>',
|
|
31
|
+
describe: 'list the users of one partner and their level',
|
|
32
|
+
builder: (y2) =>
|
|
33
|
+
targetOptions(y2).positional('partner', { type: 'string', describe: 'partner id, name or domain' }),
|
|
34
|
+
handler: async (argv) => {
|
|
35
|
+
const ns = u.targetOf(argv)
|
|
36
|
+
const { partner, rows } = await u.withDb(ns, async (db) => {
|
|
37
|
+
const partner = await u.findPartner(db, argv.partner)
|
|
38
|
+
const { rows } = await db.query(
|
|
39
|
+
`SELECT us.users_pk AS id, us.email, us.first_name, us.last_name,
|
|
40
|
+
pu.role AS level, pu.status, pu."defaultRepresentative"
|
|
41
|
+
FROM "service_accounts"."partnerUser" pu
|
|
42
|
+
JOIN "service_accounts".users us ON us.users_pk = pu."userId"
|
|
43
|
+
WHERE pu."partnerId" = $1 ORDER BY us.email`,
|
|
44
|
+
[partner.id]
|
|
45
|
+
)
|
|
46
|
+
return { partner, rows }
|
|
47
|
+
})
|
|
48
|
+
console.log(chalk.bold(`\n${partner.name} — ${rows.length} user(s)`) + chalk.gray(` (${label(ns)})\n`))
|
|
49
|
+
printTable(rows, [
|
|
50
|
+
['EMAIL', (r) => r.email],
|
|
51
|
+
['NAME', (r) => `${r.first_name} ${r.last_name}`],
|
|
52
|
+
['LEVEL', (r) => r.level],
|
|
53
|
+
['STATUS', (r) => r.status],
|
|
54
|
+
['DEFAULT REP', (r) => (r.defaultRepresentative ? 'yes' : 'no')],
|
|
55
|
+
['ID', (r) => r.id],
|
|
56
|
+
])
|
|
57
|
+
console.log()
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
.command({
|
|
61
|
+
command: 'assign <user> <partner>',
|
|
62
|
+
describe: 'add a user to a partner (or change their level). Dry-run by default.',
|
|
63
|
+
builder: (y2) =>
|
|
64
|
+
writeOptions(y2)
|
|
65
|
+
.positional('user', { type: 'string', describe: 'user id or email' })
|
|
66
|
+
.positional('partner', { type: 'string', describe: 'partner id, name or domain' })
|
|
67
|
+
.option('level', { choices: u.PARTNER_LEVELS, default: 'member', describe: 'level within the partner' }),
|
|
68
|
+
handler: async (argv) => {
|
|
69
|
+
const ns = u.targetOf(argv)
|
|
70
|
+
u.guardWrite(ns, argv)
|
|
71
|
+
const { user, partner, res } = await u.withDb(ns, async (db) => {
|
|
72
|
+
const user = await u.findUser(db, argv.user)
|
|
73
|
+
const partner = await u.findPartner(db, argv.partner)
|
|
74
|
+
const res = await u.assignPartner(db, { user, partner, level: argv.level, execute: argv.execute })
|
|
75
|
+
return { user, partner, res }
|
|
76
|
+
})
|
|
77
|
+
const what =
|
|
78
|
+
res.previous && res.previous !== argv.level
|
|
79
|
+
? `level ${res.previous} → ${argv.level}`
|
|
80
|
+
: res.previous
|
|
81
|
+
? `already a ${argv.level} (no change)`
|
|
82
|
+
: `added as ${argv.level}`
|
|
83
|
+
console.log(
|
|
84
|
+
(argv.execute ? chalk.green('\n' + what) : chalk.yellow(`\nDRY RUN — would be ${what}`)) +
|
|
85
|
+
`: ${user.email} @ ${partner.name}` +
|
|
86
|
+
chalk.gray(` (${label(ns)})`)
|
|
87
|
+
)
|
|
88
|
+
if (!argv.execute) console.log(chalk.gray(' Re-run with --execute to apply.'))
|
|
89
|
+
console.log()
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
.command({
|
|
93
|
+
command: 'unassign <user> <partner>',
|
|
94
|
+
describe: 'remove a user from a partner. Dry-run by default.',
|
|
95
|
+
builder: (y2) =>
|
|
96
|
+
writeOptions(y2)
|
|
97
|
+
.positional('user', { type: 'string', describe: 'user id or email' })
|
|
98
|
+
.positional('partner', { type: 'string', describe: 'partner id, name or domain' }),
|
|
99
|
+
handler: async (argv) => {
|
|
100
|
+
const ns = u.targetOf(argv)
|
|
101
|
+
u.guardWrite(ns, argv)
|
|
102
|
+
const { user, partner, res } = await u.withDb(ns, async (db) => {
|
|
103
|
+
const user = await u.findUser(db, argv.user)
|
|
104
|
+
const partner = await u.findPartner(db, argv.partner)
|
|
105
|
+
const res = await u.unassignPartner(db, { user, partner, execute: argv.execute })
|
|
106
|
+
return { user, partner, res }
|
|
107
|
+
})
|
|
108
|
+
console.log(
|
|
109
|
+
(argv.execute ? chalk.green('\nremoved') : chalk.yellow('\nDRY RUN — would remove')) +
|
|
110
|
+
` ${user.email} (${res.level}) from ${partner.name}` +
|
|
111
|
+
chalk.gray(` (${label(ns)})`)
|
|
112
|
+
)
|
|
113
|
+
if (!argv.execute) console.log(chalk.gray(' Re-run with --execute to apply.'))
|
|
114
|
+
console.log()
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
.demandCommand(1, 'Use: partner list | partner users | partner assign | partner unassign')
|
|
118
|
+
.strict(),
|
|
119
|
+
handler: () => {},
|
|
120
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const chalk = require('chalk')
|
|
2
|
+
|
|
3
|
+
// Shared bits for the environment-addressed commands (user / partner). The LOCAL env is the default —
|
|
4
|
+
// these are day-to-day local-dev commands — and a k8s namespace is the deliberate opt-in.
|
|
5
|
+
const targetOptions = (y) =>
|
|
6
|
+
y
|
|
7
|
+
.option('target-local', { type: 'boolean', describe: 'act on the LOCAL env (default)' })
|
|
8
|
+
.option('target-namespace', { type: 'string', describe: 'act on a k8s namespace instead, e.g. dooer-staging' })
|
|
9
|
+
|
|
10
|
+
const writeOptions = (y) =>
|
|
11
|
+
targetOptions(y)
|
|
12
|
+
.option('confirm-production', { type: 'boolean', describe: 'REQUIRED to write to dooer-production' })
|
|
13
|
+
.option('execute', { type: 'boolean', default: false, describe: 'actually write (default: dry-run)' })
|
|
14
|
+
|
|
15
|
+
const label = (ns) => (ns === 'local' ? 'local env' : `namespace ${ns}`)
|
|
16
|
+
|
|
17
|
+
// Minimal column-aligned printer: `columns` is [[header, rowFn], …]. Values are stringified, null shown
|
|
18
|
+
// as a dim dash so an empty column still lines up.
|
|
19
|
+
function printTable(rows, columns) {
|
|
20
|
+
if (!rows.length) {
|
|
21
|
+
console.log(chalk.gray(' (none)'))
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
const cell = (v) => (v === null || v === undefined || v === '' ? '-' : String(v))
|
|
25
|
+
const body = rows.map((r) => columns.map(([, fn]) => cell(fn(r))))
|
|
26
|
+
const widths = columns.map(([h], i) => Math.max(h.length, ...body.map((b) => b[i].length)))
|
|
27
|
+
console.log(' ' + columns.map(([h], i) => chalk.bold(h.padEnd(widths[i]))).join(' '))
|
|
28
|
+
for (const b of body) console.log(' ' + b.map((v, i) => v.padEnd(widths[i])).join(' '))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { targetOptions, writeOptions, label, printTable }
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
const chalk = require('chalk')
|
|
2
|
+
const u = require('../users')
|
|
3
|
+
const { targetOptions, writeOptions, label, printTable } = require('./target')
|
|
4
|
+
|
|
5
|
+
const date = (d) => (d ? new Date(d).toISOString().slice(0, 10) : null)
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
command: 'user <command>',
|
|
9
|
+
describe: 'find, inspect and create users (local env by default)',
|
|
10
|
+
builder: (y) =>
|
|
11
|
+
y
|
|
12
|
+
.command({
|
|
13
|
+
command: 'search [query]',
|
|
14
|
+
describe: 'find users by email, name, personnummer or id (no query = the most recent users)',
|
|
15
|
+
builder: (y2) =>
|
|
16
|
+
targetOptions(y2)
|
|
17
|
+
.positional('query', { type: 'string', describe: 'substring to match' })
|
|
18
|
+
.option('role', { choices: u.USER_ROLES, describe: 'only users of this role' })
|
|
19
|
+
.option('limit', { type: 'number', default: 20, describe: 'max rows' }),
|
|
20
|
+
handler: async (argv) => {
|
|
21
|
+
const ns = u.targetOf(argv)
|
|
22
|
+
const rows = await u.withDb(ns, (db) =>
|
|
23
|
+
u.searchUsers(db, { query: argv.query, role: argv.role, limit: argv.limit })
|
|
24
|
+
)
|
|
25
|
+
console.log(
|
|
26
|
+
chalk.bold(`\n${rows.length} user(s) in ${label(ns)}`) +
|
|
27
|
+
(argv.query ? chalk.gray(` matching "${argv.query}"`) : '') +
|
|
28
|
+
'\n'
|
|
29
|
+
)
|
|
30
|
+
printTable(rows, [
|
|
31
|
+
['ID', (r) => r.id],
|
|
32
|
+
['EMAIL', (r) => r.email],
|
|
33
|
+
['NAME', (r) => `${r.first_name} ${r.last_name}`],
|
|
34
|
+
['ROLE', (r) => r.role],
|
|
35
|
+
['CREATED', (r) => date(r.inserted_at)],
|
|
36
|
+
['STATUS', (r) => (r.inactivated_at ? `inactivated ${date(r.inactivated_at)}` : 'active')],
|
|
37
|
+
])
|
|
38
|
+
console.log()
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
.command({
|
|
42
|
+
command: 'get <user>',
|
|
43
|
+
describe: 'show one user (by id or email) and the partners they belong to',
|
|
44
|
+
builder: (y2) => targetOptions(y2).positional('user', { type: 'string', describe: 'user id or email' }),
|
|
45
|
+
handler: async (argv) => {
|
|
46
|
+
const ns = u.targetOf(argv)
|
|
47
|
+
const { user, partners } = await u.withDb(ns, async (db) => {
|
|
48
|
+
const user = await u.findUser(db, argv.user)
|
|
49
|
+
return { user, partners: await u.partnersForUser(db, user.id) }
|
|
50
|
+
})
|
|
51
|
+
console.log(chalk.bold(`\n${user.first_name} ${user.last_name}`) + chalk.gray(` (${label(ns)})\n`))
|
|
52
|
+
const line = (k, v) => console.log(` ${k.padEnd(14)}${v === null || v === undefined ? '-' : v}`)
|
|
53
|
+
line('id', user.id)
|
|
54
|
+
line(
|
|
55
|
+
'email',
|
|
56
|
+
`${user.email}${user.email_verified ? chalk.gray(' (verified)') : chalk.yellow(' (unverified)')}`
|
|
57
|
+
)
|
|
58
|
+
line('role', user.role)
|
|
59
|
+
line('personnummer', user.personnummer)
|
|
60
|
+
line('password', user.has_password ? 'set' : chalk.yellow('none — cannot log in with a password'))
|
|
61
|
+
line('language', user.language)
|
|
62
|
+
line('created', date(user.inserted_at))
|
|
63
|
+
line('status', user.inactivated_at ? chalk.yellow(`inactivated ${date(user.inactivated_at)}`) : 'active')
|
|
64
|
+
if (user.internal) line('internal', 'yes')
|
|
65
|
+
console.log(chalk.bold(`\n partners (${partners.length})`))
|
|
66
|
+
printTable(partners, [
|
|
67
|
+
['PARTNER', (r) => r.name],
|
|
68
|
+
['LEVEL', (r) => r.level],
|
|
69
|
+
['STATUS', (r) => r.status],
|
|
70
|
+
['LAST LOGIN', (r) => date(r.lastLoginAt)],
|
|
71
|
+
['ID', (r) => r.id],
|
|
72
|
+
])
|
|
73
|
+
console.log()
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
.command({
|
|
77
|
+
command: 'create',
|
|
78
|
+
describe: 'create a user (password generated unless given). Dry-run by default.',
|
|
79
|
+
builder: (y2) =>
|
|
80
|
+
writeOptions(y2)
|
|
81
|
+
.option('email', { type: 'string', demandOption: true, describe: 'email address (the login)' })
|
|
82
|
+
.option('password', { type: 'string', describe: 'password to set (default: generated, printed once)' })
|
|
83
|
+
.option('role', { choices: u.USER_ROLES, default: 'hi', describe: 'user role at dooer' })
|
|
84
|
+
.option('personnummer', { type: 'string', describe: 'Swedish personnummer, 10 or 12 digits' })
|
|
85
|
+
.option('first-name', { type: 'string', describe: 'default: derived from the email' })
|
|
86
|
+
.option('last-name', { type: 'string', describe: 'default: derived from the email' })
|
|
87
|
+
.option('partner', { type: 'string', describe: 'also add to this partner (id, name or domain)' })
|
|
88
|
+
.option('level', { choices: u.PARTNER_LEVELS, default: 'member', describe: 'level in --partner' }),
|
|
89
|
+
handler: async (argv) => {
|
|
90
|
+
const ns = u.targetOf(argv)
|
|
91
|
+
u.guardWrite(ns, argv)
|
|
92
|
+
const derived = u.namesFromEmail(argv.email)
|
|
93
|
+
const firstName = argv.firstName || derived.firstName
|
|
94
|
+
const lastName = argv.lastName || derived.lastName
|
|
95
|
+
const password = argv.password || u.generatePassword()
|
|
96
|
+
const generated = !argv.password
|
|
97
|
+
const personnummer = argv.personnummer ? u.normalizePersonnummer(argv.personnummer) : null
|
|
98
|
+
|
|
99
|
+
const res = await u.withDb(ns, async (db) => {
|
|
100
|
+
// Resolve the partner BEFORE creating anything, so a typo fails without leaving a half-made user.
|
|
101
|
+
const partner = argv.partner ? await u.findPartner(db, argv.partner) : null
|
|
102
|
+
if (partner && argv.role !== 'hi') {
|
|
103
|
+
throw new Error(`--partner needs a "hi" user; role "${argv.role}" cannot belong to a partner`)
|
|
104
|
+
}
|
|
105
|
+
const created = await u.createUser(db, {
|
|
106
|
+
email: argv.email,
|
|
107
|
+
password,
|
|
108
|
+
role: argv.role,
|
|
109
|
+
personnummer,
|
|
110
|
+
firstName,
|
|
111
|
+
lastName,
|
|
112
|
+
execute: argv.execute,
|
|
113
|
+
})
|
|
114
|
+
// Only on --execute: a dry-run has no user id to attach the membership to, and the partner
|
|
115
|
+
// itself (plus the hi-only rule) has already been validated above.
|
|
116
|
+
if (partner && argv.execute) {
|
|
117
|
+
await u.assignPartner(db, {
|
|
118
|
+
user: { id: created.id, email: argv.email, role: argv.role },
|
|
119
|
+
partner,
|
|
120
|
+
level: argv.level,
|
|
121
|
+
execute: argv.execute,
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
return { created, partner }
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
console.log(
|
|
128
|
+
(argv.execute ? chalk.green('\ncreated user') : chalk.yellow('\nDRY RUN — nothing written')) +
|
|
129
|
+
chalk.gray(` (${label(ns)})\n`)
|
|
130
|
+
)
|
|
131
|
+
const line = (k, v) => console.log(` ${k.padEnd(14)}${v}`)
|
|
132
|
+
if (argv.execute) line('id', res.created.id)
|
|
133
|
+
line('email', argv.email)
|
|
134
|
+
line('name', `${firstName} ${lastName}`)
|
|
135
|
+
line('role', argv.role)
|
|
136
|
+
if (personnummer) line('personnummer', personnummer)
|
|
137
|
+
line('password', chalk.bold(password) + (generated ? chalk.gray(' (generated)') : ''))
|
|
138
|
+
if (res.partner) line('partner', `${res.partner.name} — level ${argv.level}`)
|
|
139
|
+
if (generated && argv.execute) {
|
|
140
|
+
console.log(chalk.gray('\n The password is stored as a bcrypt hash — this is the only time it is shown.'))
|
|
141
|
+
}
|
|
142
|
+
if (!argv.execute) console.log(chalk.gray('\n Re-run with --execute to create it.'))
|
|
143
|
+
console.log()
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
.command({
|
|
147
|
+
command: 'partners <user>',
|
|
148
|
+
describe: 'list the partners a user belongs to, and at what level',
|
|
149
|
+
builder: (y2) => targetOptions(y2).positional('user', { type: 'string', describe: 'user id or email' }),
|
|
150
|
+
handler: async (argv) => {
|
|
151
|
+
const ns = u.targetOf(argv)
|
|
152
|
+
const { user, partners } = await u.withDb(ns, async (db) => {
|
|
153
|
+
const user = await u.findUser(db, argv.user)
|
|
154
|
+
return { user, partners: await u.partnersForUser(db, user.id) }
|
|
155
|
+
})
|
|
156
|
+
console.log(
|
|
157
|
+
chalk.bold(`\n${user.email} belongs to ${partners.length} partner(s)`) + chalk.gray(` (${label(ns)})\n`)
|
|
158
|
+
)
|
|
159
|
+
printTable(partners, [
|
|
160
|
+
['PARTNER', (r) => r.name],
|
|
161
|
+
['LEVEL', (r) => r.level],
|
|
162
|
+
['STATUS', (r) => r.status],
|
|
163
|
+
['DEFAULT REP', (r) => (r.defaultRepresentative ? 'yes' : 'no')],
|
|
164
|
+
['ID', (r) => r.id],
|
|
165
|
+
])
|
|
166
|
+
console.log()
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
.demandCommand(1, 'Use: user search | user get | user create | user partners')
|
|
170
|
+
.strict(),
|
|
171
|
+
handler: () => {},
|
|
172
|
+
}
|
package/lib/users.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// users.js — user + partner-membership operations against a target environment (the local env by
|
|
2
|
+
// default, or any k8s namespace). Reads and writes go straight to the service_accounts schema over the
|
|
3
|
+
// same self-healing connection the copy engine uses (lib/engine/seed.js `connect`), which already knows
|
|
4
|
+
// how to reach both the local Postgres and a namespace's dooer-database.
|
|
5
|
+
//
|
|
6
|
+
// Why SQL and not the service-accounts HTTP API:
|
|
7
|
+
// * there is NO user-create route at all — users only ever appear via the signup flows, which also
|
|
8
|
+
// create a company; that is exactly what we do NOT want here.
|
|
9
|
+
// * nothing subscribes to a user-created event (checked across every service's eventStream.handle),
|
|
10
|
+
// so a plain INSERT leaves no consumer un-notified. Contrast `customer new`, which goes through the
|
|
11
|
+
// ledger API precisely because service-closing builds its periods off the emitted event.
|
|
12
|
+
// * the partner-membership route (PUT /v1/partners/:partnerId/users/:userId) is a bare upsert with one
|
|
13
|
+
// guard — the user must be of type `hi`. We reproduce that guard below rather than dropping it, so
|
|
14
|
+
// the CLI cannot create rows the product would have refused.
|
|
15
|
+
const crypto = require('crypto')
|
|
16
|
+
const bcrypt = require('bcryptjs')
|
|
17
|
+
const { connect } = require('./engine/seed')
|
|
18
|
+
|
|
19
|
+
const SCHEMA = 'service_accounts'
|
|
20
|
+
const T_USERS = `"${SCHEMA}".users`
|
|
21
|
+
const T_PARTNER = `"${SCHEMA}".partner`
|
|
22
|
+
const T_PARTNER_USER = `"${SCHEMA}"."partnerUser"`
|
|
23
|
+
|
|
24
|
+
// service_accounts.user_roles_at_dooer. `hi` = the accounting-professional user a partner can employ;
|
|
25
|
+
// `hiab` exists in the table but is legacy — not offered as a choice.
|
|
26
|
+
const USER_ROLES = ['hi', 'customer', 'admin']
|
|
27
|
+
// service_accounts."partnerUser_role_enum" — the "level" within a partner.
|
|
28
|
+
const PARTNER_LEVELS = ['member', 'admin']
|
|
29
|
+
|
|
30
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
31
|
+
|
|
32
|
+
// ── target environment ───────────────────────────────────────────────────────
|
|
33
|
+
// Default is the LOCAL env. `local` is a pseudo-namespace inside the engine, not a k8s one, so it is
|
|
34
|
+
// addressed with --target-local and rejected as a --target-namespace value (same split as customer copy).
|
|
35
|
+
function targetOf(argv) {
|
|
36
|
+
if (argv.targetNamespace === 'local') {
|
|
37
|
+
throw new Error('use --target-local for the local env, not --target-namespace local')
|
|
38
|
+
}
|
|
39
|
+
if (argv.targetLocal && argv.targetNamespace) {
|
|
40
|
+
throw new Error('pass either --target-local or --target-namespace, not both')
|
|
41
|
+
}
|
|
42
|
+
return argv.targetNamespace || 'local'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Writing users into the production cluster is possible but must never be a slip of the shell.
|
|
46
|
+
function guardWrite(namespace, argv) {
|
|
47
|
+
if (namespace === 'dooer-production' && !argv.confirmProduction) {
|
|
48
|
+
throw new Error('refusing to write to dooer-production without --confirm-production')
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── password ────────────────────────────────────────────────────────────────
|
|
53
|
+
// Ambiguous glyphs (0/O, 1/l/I) are left out — these get read off a screen and typed into a login form.
|
|
54
|
+
const ALPHABET = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789'
|
|
55
|
+
|
|
56
|
+
function generatePassword(groups = 4, size = 5) {
|
|
57
|
+
const chunk = () => Array.from({ length: size }, () => ALPHABET[crypto.randomInt(ALPHABET.length)]).join('')
|
|
58
|
+
return Array.from({ length: groups }, chunk).join('-')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// service-accounts' own rule (lib/verify-password.js) is length >= 8 and nothing else; enforce exactly
|
|
62
|
+
// that so a password this CLI accepts is a password the login accepts.
|
|
63
|
+
function hashPassword(password) {
|
|
64
|
+
if (password.length < 8) throw new Error('password must be at least 8 characters (service-accounts rule)')
|
|
65
|
+
// bcryptjs, not bcrypt: identical wire format, but pure JS — a native build would break `npx`.
|
|
66
|
+
// get-login-user.js rewrites a stored `$2b$` prefix to `$2a$` before comparing, so either emits fine.
|
|
67
|
+
return bcrypt.hashSync(password, 10)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── personnummer ────────────────────────────────────────────────────────────
|
|
71
|
+
// Stored as 12 bare digits (verified against staging: every non-null id_number is length 12).
|
|
72
|
+
function normalizePersonnummer(input) {
|
|
73
|
+
const digits = String(input).replace(/\D/g, '')
|
|
74
|
+
if (digits.length === 12) return digits
|
|
75
|
+
if (digits.length === 10) {
|
|
76
|
+
// YY > current two-digit year means last century — the usual short-form expansion.
|
|
77
|
+
const yy = Number(digits.slice(0, 2))
|
|
78
|
+
const nowYY = new Date().getFullYear() % 100
|
|
79
|
+
return (yy > nowYY ? '19' : '20') + digits
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`personnummer must be 10 or 12 digits (got ${digits.length}): ${input}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── name defaults ───────────────────────────────────────────────────────────
|
|
85
|
+
// first_name/last_name are NOT NULL with no default. Derive something sane from the address so the
|
|
86
|
+
// common case is just `--email`.
|
|
87
|
+
function namesFromEmail(email) {
|
|
88
|
+
const local = email.split('@')[0]
|
|
89
|
+
const parts = local.split(/[._-]+/).filter(Boolean)
|
|
90
|
+
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1)
|
|
91
|
+
return {
|
|
92
|
+
firstName: cap(parts[0] || 'Test'),
|
|
93
|
+
lastName: cap(parts.slice(1).join(' ') || 'User'),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── reads ───────────────────────────────────────────────────────────────────
|
|
98
|
+
const USER_COLUMNS = `users_pk AS id, email, first_name, last_name,
|
|
99
|
+
fk_user_roles_at_dooer_pk AS role, id_number AS personnummer, email_verified,
|
|
100
|
+
inactivated_at, internal, language, inserted_at, (password_hash IS NOT NULL) AS has_password`
|
|
101
|
+
|
|
102
|
+
// A user is addressed by uuid or by email — nobody remembers uuids.
|
|
103
|
+
async function findUser(db, ref) {
|
|
104
|
+
const byId = UUID_RE.test(ref)
|
|
105
|
+
const { rows } = await db.query(
|
|
106
|
+
`SELECT ${USER_COLUMNS} FROM ${T_USERS} WHERE ${byId ? 'users_pk = $1' : 'lower(email) = lower($1)'}
|
|
107
|
+
ORDER BY inserted_at`,
|
|
108
|
+
[ref]
|
|
109
|
+
)
|
|
110
|
+
if (!rows.length) throw new Error(`no user matching "${ref}"`)
|
|
111
|
+
if (rows.length > 1) {
|
|
112
|
+
// email is unique per ROLE (users_email_fk_user_roles_at_dooer_pk_key), so one address can legitimately
|
|
113
|
+
// exist as both a `customer` and an `hi`. Make the caller say which.
|
|
114
|
+
throw new Error(
|
|
115
|
+
`"${ref}" matches ${rows.length} users (one per role: ${rows.map((r) => r.role).join(', ')}) — ` +
|
|
116
|
+
`address it by id: ${rows.map((r) => r.id).join(', ')}`
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
return rows[0]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function searchUsers(db, { query, role, limit }) {
|
|
123
|
+
const where = []
|
|
124
|
+
const params = []
|
|
125
|
+
if (query) {
|
|
126
|
+
params.push(`%${query}%`)
|
|
127
|
+
where.push(`(email ILIKE $${params.length} OR first_name ILIKE $${params.length}
|
|
128
|
+
OR last_name ILIKE $${params.length} OR id_number ILIKE $${params.length}
|
|
129
|
+
OR users_pk::text ILIKE $${params.length})`)
|
|
130
|
+
}
|
|
131
|
+
if (role) {
|
|
132
|
+
params.push(role)
|
|
133
|
+
where.push(`fk_user_roles_at_dooer_pk = $${params.length}`)
|
|
134
|
+
}
|
|
135
|
+
params.push(limit)
|
|
136
|
+
const { rows } = await db.query(
|
|
137
|
+
`SELECT ${USER_COLUMNS} FROM ${T_USERS}
|
|
138
|
+
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
|
139
|
+
ORDER BY inserted_at DESC LIMIT $${params.length}`,
|
|
140
|
+
params
|
|
141
|
+
)
|
|
142
|
+
return rows
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function listPartners(db) {
|
|
146
|
+
const { rows } = await db.query(
|
|
147
|
+
`SELECT p.id, p.name, p.domain, p.status, p."isDefaultPartner",
|
|
148
|
+
count(pu.id)::int AS users
|
|
149
|
+
FROM ${T_PARTNER} p LEFT JOIN ${T_PARTNER_USER} pu ON pu."partnerId" = p.id
|
|
150
|
+
GROUP BY p.id ORDER BY p.name`
|
|
151
|
+
)
|
|
152
|
+
return rows
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Same shape as GET /v1/partners/views/by-user/:userId, plus the membership level.
|
|
156
|
+
async function partnersForUser(db, userId) {
|
|
157
|
+
const { rows } = await db.query(
|
|
158
|
+
`SELECT p.id, p.name, p.domain, pu.role AS level, pu.status,
|
|
159
|
+
pu."inProduction", pu."defaultRepresentative", pu."lastLoginAt"
|
|
160
|
+
FROM ${T_PARTNER_USER} pu JOIN ${T_PARTNER} p ON p.id = pu."partnerId"
|
|
161
|
+
WHERE pu."userId" = $1 ORDER BY p.name`,
|
|
162
|
+
[userId]
|
|
163
|
+
)
|
|
164
|
+
return rows
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function findPartner(db, ref) {
|
|
168
|
+
const byId = UUID_RE.test(ref)
|
|
169
|
+
const { rows } = await db.query(
|
|
170
|
+
`SELECT id, name, domain, status FROM ${T_PARTNER}
|
|
171
|
+
WHERE ${byId ? 'id = $1' : '(lower(name) = lower($1) OR lower(domain) = lower($1))'} ORDER BY name`,
|
|
172
|
+
[ref]
|
|
173
|
+
)
|
|
174
|
+
if (!rows.length) throw new Error(`no partner matching "${ref}" (see: dooer-test-env partner list)`)
|
|
175
|
+
if (rows.length > 1) {
|
|
176
|
+
throw new Error(`"${ref}" matches ${rows.length} partners — address it by id: ${rows.map((r) => r.id).join(', ')}`)
|
|
177
|
+
}
|
|
178
|
+
return rows[0]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── writes ──────────────────────────────────────────────────────────────────
|
|
182
|
+
async function createUser(db, { email, password, role, personnummer, firstName, lastName, execute }) {
|
|
183
|
+
// Enforced by users_email_fk_user_roles_at_dooer_pk_key — check first so the failure is a sentence
|
|
184
|
+
// rather than a constraint-violation dump.
|
|
185
|
+
const {
|
|
186
|
+
rows: clash,
|
|
187
|
+
} = await db.query(
|
|
188
|
+
`SELECT users_pk FROM ${T_USERS} WHERE lower(email) = lower($1) AND fk_user_roles_at_dooer_pk = $2`,
|
|
189
|
+
[email, role]
|
|
190
|
+
)
|
|
191
|
+
if (clash.length) throw new Error(`a "${role}" user with email ${email} already exists (${clash[0].users_pk})`)
|
|
192
|
+
|
|
193
|
+
const hash = hashPassword(password)
|
|
194
|
+
const apiUuid = crypto.randomUUID()
|
|
195
|
+
// email_verified: these are test accounts — an unverified address puts a verification wall in front of
|
|
196
|
+
// every flow you actually wanted to exercise.
|
|
197
|
+
const values = [role, firstName, lastName, email, personnummer || null, hash, apiUuid]
|
|
198
|
+
// No id yet on a dry-run — the caller must not go on to use it as one (a placeholder string blew up the
|
|
199
|
+
// partner lookup's uuid parameter).
|
|
200
|
+
if (!execute) return { id: null, apiUuid }
|
|
201
|
+
const { rows } = await db.query(
|
|
202
|
+
`INSERT INTO ${T_USERS}
|
|
203
|
+
(fk_user_roles_at_dooer_pk, first_name, last_name, email, id_number, password_hash, api_uuid,
|
|
204
|
+
email_verified)
|
|
205
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, true)
|
|
206
|
+
RETURNING users_pk AS id, api_uuid`,
|
|
207
|
+
values
|
|
208
|
+
)
|
|
209
|
+
return { id: rows[0].id, apiUuid: rows[0].api_uuid }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function assignPartner(db, { user, partner, level, execute }) {
|
|
213
|
+
// The guard from setPartnerUser: partner membership is for accounting professionals only. Without it
|
|
214
|
+
// the CLI would happily write a row that HQ then trips over.
|
|
215
|
+
if (user.role !== 'hi') {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`only "hi" users can belong to a partner (${user.email} is "${user.role}") — ` +
|
|
218
|
+
`this is the same invalid-user-type check the service-accounts API applies`
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
const {
|
|
222
|
+
rows: existing,
|
|
223
|
+
} = await db.query(`SELECT role AS level FROM ${T_PARTNER_USER} WHERE "partnerId" = $1 AND "userId" = $2`, [
|
|
224
|
+
partner.id,
|
|
225
|
+
user.id,
|
|
226
|
+
])
|
|
227
|
+
if (!execute) return { previous: existing[0] ? existing[0].level : null }
|
|
228
|
+
const { rows } = await db.query(
|
|
229
|
+
`INSERT INTO ${T_PARTNER_USER} ("partnerId", "userId", role, status)
|
|
230
|
+
VALUES ($1, $2, $3, 'enabled')
|
|
231
|
+
ON CONFLICT ("partnerId", "userId")
|
|
232
|
+
DO UPDATE SET role = EXCLUDED.role, status = EXCLUDED.status, "updatedAt" = now()
|
|
233
|
+
RETURNING role AS level`,
|
|
234
|
+
[partner.id, user.id, level]
|
|
235
|
+
)
|
|
236
|
+
return { previous: existing[0] ? existing[0].level : null, level: rows[0].level }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function unassignPartner(db, { user, partner, execute }) {
|
|
240
|
+
const {
|
|
241
|
+
rows: existing,
|
|
242
|
+
} = await db.query(`SELECT role AS level FROM ${T_PARTNER_USER} WHERE "partnerId" = $1 AND "userId" = $2`, [
|
|
243
|
+
partner.id,
|
|
244
|
+
user.id,
|
|
245
|
+
])
|
|
246
|
+
if (!existing.length) throw new Error(`${user.email} is not a member of ${partner.name}`)
|
|
247
|
+
if (execute) {
|
|
248
|
+
await db.query(`DELETE FROM ${T_PARTNER_USER} WHERE "partnerId" = $1 AND "userId" = $2`, [partner.id, user.id])
|
|
249
|
+
}
|
|
250
|
+
return { level: existing[0].level }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Every command opens one connection and closes it, so the CLI never leaves a socket to the cluster open.
|
|
254
|
+
async function withDb(namespace, fn) {
|
|
255
|
+
const db = await connect(namespace)
|
|
256
|
+
try {
|
|
257
|
+
return await fn(db)
|
|
258
|
+
} finally {
|
|
259
|
+
try {
|
|
260
|
+
await db.client.end()
|
|
261
|
+
} catch (_) {
|
|
262
|
+
/* already gone */
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = {
|
|
268
|
+
USER_ROLES,
|
|
269
|
+
PARTNER_LEVELS,
|
|
270
|
+
targetOf,
|
|
271
|
+
guardWrite,
|
|
272
|
+
generatePassword,
|
|
273
|
+
hashPassword,
|
|
274
|
+
normalizePersonnummer,
|
|
275
|
+
namesFromEmail,
|
|
276
|
+
findUser,
|
|
277
|
+
searchUsers,
|
|
278
|
+
listPartners,
|
|
279
|
+
partnersForUser,
|
|
280
|
+
findPartner,
|
|
281
|
+
createUser,
|
|
282
|
+
assignPartner,
|
|
283
|
+
unassignPartner,
|
|
284
|
+
withDb,
|
|
285
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dooer/dooer-test-env",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"description": "Run the whole Dooer backend locally (staging DB minus customers), copy/purge customers between environments, and shred — one CLI.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": "Dooer/cli-dooer-test-env",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@aws-sdk/client-s3": "^3.600.0",
|
|
29
29
|
"@dooer/package-info": "^1.6.7",
|
|
30
|
+
"bcryptjs": "^3.0.3",
|
|
30
31
|
"chalk": "^4.1.2",
|
|
31
32
|
"js-yaml": "^3.14.1",
|
|
32
33
|
"pg": "^8.23.0",
|
package/readme.md
CHANGED
|
@@ -53,6 +53,8 @@ db roles (re)create per-service DB role
|
|
|
53
53
|
db snapshot <name> | db rollback <name> local restore points
|
|
54
54
|
customer new "<name>" --owner <userId> --execute empty functional account: company + Owner + subscriptions + partner dooer
|
|
55
55
|
customer copy | customer purge copy / delete one org between environments (emails anonymized)
|
|
56
|
+
user search | get | create | partners find/inspect/create users (local env unless --target-namespace)
|
|
57
|
+
partner list | users | assign | unassign partners and who belongs to them, at which level
|
|
56
58
|
shred anonymize the LOCAL db (localhost only)
|
|
57
59
|
bankid pull | status | clear BankID cert (staging → keychain), used by service-accounts
|
|
58
60
|
validation on | off | status output-schema validation (default OFF, like staging/live)
|
|
@@ -109,13 +111,66 @@ reads (`fiscalYear`, `hasVatRegistration`, `hasCompanyTax`, `hasEmployeeRegistra
|
|
|
109
111
|
`vatPeriod`/`vatDue`). `--no-fiscal-year` leaves all of that blank.
|
|
110
112
|
|
|
111
113
|
The owner must be a **`customer`** user — a `hi`/admin owner cannot accept Terms of Service and the command
|
|
112
|
-
warns you. Find one
|
|
114
|
+
warns you. Find one:
|
|
113
115
|
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
|
|
116
|
+
```bash
|
|
117
|
+
npx @dooer/dooer-test-env@latest user search --role customer
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Create a user you can actually log in as
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
# an accounting professional (role `hi`) in the local env, password generated and printed once
|
|
124
|
+
npx @dooer/dooer-test-env@latest user create --email anna@dooer.com --execute
|
|
125
|
+
|
|
126
|
+
# …a customer instead, with your own password and a personnummer
|
|
127
|
+
npx @dooer/dooer-test-env@latest user create --email kim@example.com \
|
|
128
|
+
--role customer --password 'hunter2hunter2' --personnummer 19900101-1234 --execute
|
|
129
|
+
|
|
130
|
+
# …or an `hi` user placed straight into a partner
|
|
131
|
+
npx @dooer/dooer-test-env@latest user create --email anna@dooer.com \
|
|
132
|
+
--partner "Dooer Devteam" --level admin --execute
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The password is stored the way signup stores it (bcrypt, cost 10), so it works on the ordinary
|
|
136
|
+
email+password login — no BankID needed. It is shown **once**; there is no way to read it back. `--role`
|
|
137
|
+
defaults to `hi` (other choices: `customer`, `admin`), names default to something derived from the address,
|
|
138
|
+
and the account is created with its email pre-verified so no verification wall gets in the way.
|
|
139
|
+
|
|
140
|
+
Look users up by email **or** id, and see where they belong:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
npx @dooer/dooer-test-env@latest user search anna # email, name, personnummer or id
|
|
144
|
+
npx @dooer/dooer-test-env@latest user get anna@dooer.com # details + partner memberships
|
|
117
145
|
```
|
|
118
146
|
|
|
147
|
+
### Partners and who belongs to them
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
npx @dooer/dooer-test-env@latest partner list # every partner + user counts
|
|
151
|
+
npx @dooer/dooer-test-env@latest partner users "Dooer Devteam" # its users and their level
|
|
152
|
+
|
|
153
|
+
# add / promote / remove (a partner is addressable by id, name or domain)
|
|
154
|
+
npx @dooer/dooer-test-env@latest partner assign anna@dooer.com "Dooer Devteam" --level admin --execute
|
|
155
|
+
npx @dooer/dooer-test-env@latest partner unassign anna@dooer.com "Dooer Devteam" --execute
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`--level` is the membership level inside the partner (`member` or `admin`) — not the same thing as the
|
|
159
|
+
user's `--role` at Dooer. Only **`hi`** users can belong to a partner; the CLI applies the same
|
|
160
|
+
`invalid-user-type` check the service-accounts API does rather than writing a row HQ would choke on.
|
|
161
|
+
|
|
162
|
+
### Look at (or fix) users in a real environment
|
|
163
|
+
|
|
164
|
+
These commands default to the local env. Point them at a cluster with `--target-namespace`:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
npx @dooer/dooer-test-env@latest user search someone@dooer.com --target-namespace dooer-staging
|
|
168
|
+
npx @dooer/dooer-test-env@latest partner list --target-namespace dooer-production
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Writes there are still dry-run until `--execute`, and `dooer-production` additionally demands
|
|
172
|
+
`--confirm-production`.
|
|
173
|
+
|
|
119
174
|
### Copy a real customer into the local env
|
|
120
175
|
|
|
121
176
|
`--target-local` addresses this stack (Postgres on 55432 + MinIO); the source can be any k8s namespace
|