@dooer/dooer-test-env 1.14.0 → 1.16.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/docs/user-management.md +246 -0
- package/lib/cli.js +36 -21
- package/lib/command/partner.js +34 -57
- package/lib/command/remote-environment.js +292 -0
- package/lib/command/target.js +18 -15
- package/lib/command/user.js +53 -77
- package/lib/remote.js +357 -0
- package/lib/users.js +200 -206
- package/package.json +2 -2
- package/readme.md +53 -12
package/lib/users.js
CHANGED
|
@@ -1,56 +1,25 @@
|
|
|
1
|
-
// users.js — user
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// users.js — user and partner-membership operations against any environment, over its PUBLIC GraphQL
|
|
2
|
+
// endpoint (see lib/remote.js). No SQL, no kubectl: these are the same mutations the products call, so
|
|
3
|
+
// every rule the API enforces — input validation, password hashing, the `hi`-only partner check — applies
|
|
4
|
+
// here for free rather than being reimplemented and drifting.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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.
|
|
6
|
+
// users.create back-office /users/new (UsersMutation.create → POST /v1/users)
|
|
7
|
+
// partner.users.set PartnerUsersMutation.set → PUT /v1/partners/:id/users/:userId
|
|
8
|
+
// partner.user.delete PartnerUserMutation.delete
|
|
15
9
|
const crypto = require('crypto')
|
|
16
|
-
const
|
|
17
|
-
const { connect } = require('./engine/seed')
|
|
10
|
+
const remote = require('./remote')
|
|
18
11
|
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
12
|
+
// service_accounts.user_roles_at_dooer, as exposed by the GraphQL UserType enum. `hiab` exists in the
|
|
13
|
+
// table but is legacy — not offered as a choice.
|
|
26
14
|
const USER_ROLES = ['hi', 'customer', 'admin']
|
|
27
|
-
//
|
|
15
|
+
// PartnerUserRole — the "level" within a partner, distinct from the user's role at Dooer.
|
|
28
16
|
const PARTNER_LEVELS = ['member', 'admin']
|
|
29
17
|
|
|
30
18
|
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
19
|
|
|
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
20
|
// ── password ────────────────────────────────────────────────────────────────
|
|
53
21
|
// Ambiguous glyphs (0/O, 1/l/I) are left out — these get read off a screen and typed into a login form.
|
|
22
|
+
// The service hashes it; this CLI never sees a hash.
|
|
54
23
|
const ALPHABET = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789'
|
|
55
24
|
|
|
56
25
|
function generatePassword(groups = 4, size = 5) {
|
|
@@ -58,228 +27,253 @@ function generatePassword(groups = 4, size = 5) {
|
|
|
58
27
|
return Array.from({ length: groups }, chunk).join('-')
|
|
59
28
|
}
|
|
60
29
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
30
|
+
// ── personnummer ────────────────────────────────────────────────────────────
|
|
31
|
+
// The API's `SwedishPersonalIdentificationNumber` scalar wants 12 bare digits AND a correct Luhn check
|
|
32
|
+
// digit over the last 10 (@dooer/json-validator: is-my-svenskt-personnummer-valid). Checking both here
|
|
33
|
+
// turns "is not svenskt-personnummer format" — a GraphQL variable-coercion dump that does not say WHICH
|
|
34
|
+
// rule failed — into a sentence, and catches an invented number before it costs a round trip.
|
|
35
|
+
function luhnOk(tenDigits) {
|
|
36
|
+
let sum = 0
|
|
37
|
+
for (let i = 0; i < 10; i++) {
|
|
38
|
+
const double = i % 2 === 0
|
|
39
|
+
let d = Number(tenDigits[i]) * (double ? 2 : 1)
|
|
40
|
+
if (d > 9) d -= 9
|
|
41
|
+
sum += d
|
|
42
|
+
}
|
|
43
|
+
return sum % 10 === 0
|
|
68
44
|
}
|
|
69
45
|
|
|
70
|
-
// ── personnummer ────────────────────────────────────────────────────────────
|
|
71
|
-
// Stored as 12 bare digits (verified against staging: every non-null id_number is length 12).
|
|
72
46
|
function normalizePersonnummer(input) {
|
|
73
47
|
const digits = String(input).replace(/\D/g, '')
|
|
74
|
-
|
|
75
|
-
if (digits.length ===
|
|
76
|
-
|
|
48
|
+
let full
|
|
49
|
+
if (digits.length === 12) full = digits
|
|
50
|
+
else if (digits.length === 10) {
|
|
51
|
+
// YY greater than the current two-digit year means last century — the usual short-form expansion.
|
|
77
52
|
const yy = Number(digits.slice(0, 2))
|
|
78
|
-
|
|
79
|
-
|
|
53
|
+
full = (yy > new Date().getFullYear() % 100 ? '19' : '20') + digits
|
|
54
|
+
} else {
|
|
55
|
+
throw new Error(`personnummer must be 10 or 12 digits (got ${digits.length}): ${input}`)
|
|
56
|
+
}
|
|
57
|
+
const [, m, d] = /^\d{4}(\d{2})(\d{2})/.exec(full)
|
|
58
|
+
if (Number.isNaN(Date.parse(`${full.slice(0, 4)}-${m}-${d}`))) {
|
|
59
|
+
throw new Error(`personnummer ${full} is not a real date`)
|
|
60
|
+
}
|
|
61
|
+
if (!luhnOk(full.slice(2))) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`personnummer ${full} has an invalid check digit — the API validates it, so an invented number is rejected`
|
|
64
|
+
)
|
|
80
65
|
}
|
|
81
|
-
|
|
66
|
+
return full
|
|
82
67
|
}
|
|
83
68
|
|
|
84
69
|
// ── name defaults ───────────────────────────────────────────────────────────
|
|
85
|
-
//
|
|
86
|
-
// common case is just `--email`.
|
|
70
|
+
// firstName/lastName are required by UserInput; derive something sane so the common case is just --email.
|
|
87
71
|
function namesFromEmail(email) {
|
|
88
|
-
const
|
|
89
|
-
|
|
72
|
+
const parts = email
|
|
73
|
+
.split('@')[0]
|
|
74
|
+
.split(/[._+-]+/)
|
|
75
|
+
.filter(Boolean)
|
|
90
76
|
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1)
|
|
91
|
-
return {
|
|
92
|
-
|
|
93
|
-
|
|
77
|
+
return { firstName: cap(parts[0] || 'Test'), lastName: cap(parts.slice(1).join(' ') || 'User') }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── permission errors ───────────────────────────────────────────────────────
|
|
81
|
+
// A denial comes back as `Forbidden: No access allowed for resource 'admin:<uuid>'`, which says nothing
|
|
82
|
+
// about what to do next. Rewrite it into the actual problem: this operation needs a different one of your
|
|
83
|
+
// accounts than the one you are signed in as.
|
|
84
|
+
//
|
|
85
|
+
// The branch is on the server's STRUCTURED `extra.resourceType`, not on the prose — the sentence is a
|
|
86
|
+
// human-facing string that can be reworded, and matching it would silently stop working when it is.
|
|
87
|
+
async function withLevel(env, need, fn) {
|
|
88
|
+
try {
|
|
89
|
+
return await fn()
|
|
90
|
+
} catch (e) {
|
|
91
|
+
const required = e.code === 'forbidden' ? e.resourceType : null
|
|
92
|
+
if (!required) throw e
|
|
93
|
+
let asWho = ''
|
|
94
|
+
try {
|
|
95
|
+
const me = await remote.currentUser(env)
|
|
96
|
+
if (me) asWho = ` You are signed in to ${env.name} as ${me.email} (${me.userType}).`
|
|
97
|
+
} catch (_) {
|
|
98
|
+
/* whoami is a nicety; never let it mask the real error */
|
|
99
|
+
}
|
|
100
|
+
const how =
|
|
101
|
+
required === 'admin'
|
|
102
|
+
? `dooer-test-env remote-environment login --env ${env.name} --email <your-admin-email>`
|
|
103
|
+
: `dooer-test-env remote-environment login --env ${env.name}`
|
|
104
|
+
throw new Error(
|
|
105
|
+
`${need} needs ${
|
|
106
|
+
/^[aeiou]/.test(required) ? 'an' : 'a'
|
|
107
|
+
} "${required}" session.${asWho}\n\n Log in as an account that has it:\n ${how}\n`
|
|
108
|
+
)
|
|
94
109
|
}
|
|
95
110
|
}
|
|
96
111
|
|
|
97
112
|
// ── reads ───────────────────────────────────────────────────────────────────
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
113
|
+
const USER_FIELDS = `id email firstName lastName userType sePersonnummer isEmailVerified
|
|
114
|
+
isInactivated inactivatedAt createdAt language internal`
|
|
115
|
+
|
|
116
|
+
async function searchUsers(env, { query, role, limit }) {
|
|
117
|
+
const filter = role ? `userType eq "${role}"` : null
|
|
118
|
+
const data = await withLevel(env, 'listing users', () =>
|
|
119
|
+
remote.gql(
|
|
120
|
+
env,
|
|
121
|
+
`query ($q: String, $filter: String, $first: Int) {
|
|
122
|
+
users(q: $q, filter: $filter, first: $first) { edges { node { ${USER_FIELDS} } } }
|
|
123
|
+
}`,
|
|
124
|
+
{ q: query || null, filter, first: limit }
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return data.users.edges.map((e) => e.node)
|
|
128
|
+
}
|
|
101
129
|
|
|
102
|
-
//
|
|
103
|
-
async function findUser(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
130
|
+
// Addressable by uuid or email — nobody remembers uuids.
|
|
131
|
+
async function findUser(env, ref) {
|
|
132
|
+
if (UUID_RE.test(ref)) {
|
|
133
|
+
const data = await withLevel(env, 'reading a user', () =>
|
|
134
|
+
remote.gql(env, `query ($id: ID!) { user(id: $id) { ${USER_FIELDS} } }`, { id: ref })
|
|
135
|
+
)
|
|
136
|
+
if (!data.user) throw new Error(`no user with id ${ref}`)
|
|
137
|
+
return data.user
|
|
138
|
+
}
|
|
139
|
+
const data = await withLevel(env, 'looking a user up by email', () =>
|
|
140
|
+
remote.gql(
|
|
141
|
+
env,
|
|
142
|
+
`query ($filter: String) { users(filter: $filter, first: 10) { edges { node { ${USER_FIELDS} } } } }`,
|
|
143
|
+
{ filter: `email eq "${ref.replace(/"/g, '\\"')}"` }
|
|
144
|
+
)
|
|
109
145
|
)
|
|
146
|
+
const rows = data.users.edges.map((e) => e.node)
|
|
110
147
|
if (!rows.length) throw new Error(`no user matching "${ref}"`)
|
|
111
148
|
if (rows.length > 1) {
|
|
112
|
-
//
|
|
113
|
-
// exist as both a `customer` and an `hi`. Make the caller say which.
|
|
149
|
+
// One address can exist once per user type, so an ambiguous email must be resolved by the caller.
|
|
114
150
|
throw new Error(
|
|
115
|
-
`"${ref}" matches ${rows.length} users (
|
|
151
|
+
`"${ref}" matches ${rows.length} users (${rows.map((r) => r.userType).join(', ')}) — ` +
|
|
116
152
|
`address it by id: ${rows.map((r) => r.id).join(', ')}`
|
|
117
153
|
)
|
|
118
154
|
}
|
|
119
155
|
return rows[0]
|
|
120
156
|
}
|
|
121
157
|
|
|
122
|
-
async function
|
|
123
|
-
const
|
|
124
|
-
|
|
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
|
|
158
|
+
async function listPartners(env) {
|
|
159
|
+
const data = await withLevel(env, 'listing every partner', () =>
|
|
160
|
+
remote.gql(env, `query { partners(first: 200) { edges { node { id name domain status } } } }`)
|
|
141
161
|
)
|
|
142
|
-
return
|
|
162
|
+
return data.partners.edges.map((e) => e.node)
|
|
143
163
|
}
|
|
144
164
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
165
|
+
// The partners one user belongs to, with the level from each partner's membership row.
|
|
166
|
+
async function partnersForUser(env, userId) {
|
|
167
|
+
const data = await withLevel(env, 'reading a user’s partners', () =>
|
|
168
|
+
remote.gql(env, `query ($id: ID!) { user(id: $id) { partners { id name domain } } }`, { id: userId })
|
|
169
|
+
)
|
|
170
|
+
const partners = (data.user && data.user.partners) || []
|
|
171
|
+
return Promise.all(
|
|
172
|
+
partners.map(async (p) => {
|
|
173
|
+
try {
|
|
174
|
+
const d = await remote.gql(
|
|
175
|
+
env,
|
|
176
|
+
`query ($pid: ID!, $uid: ID!) { partner(id: $pid) { user(userId: $uid) { role status defaultRepresentative } } }`,
|
|
177
|
+
{ pid: p.id, uid: userId }
|
|
178
|
+
)
|
|
179
|
+
const pu = d.partner && d.partner.user
|
|
180
|
+
return {
|
|
181
|
+
...p,
|
|
182
|
+
level: pu && pu.role,
|
|
183
|
+
status: pu && pu.status,
|
|
184
|
+
defaultRepresentative: pu && pu.defaultRepresentative,
|
|
185
|
+
}
|
|
186
|
+
} catch (_) {
|
|
187
|
+
// Membership detail needs partner level for THAT partner; the membership itself is still true.
|
|
188
|
+
return { ...p, level: null, status: null, defaultRepresentative: null }
|
|
189
|
+
}
|
|
190
|
+
})
|
|
151
191
|
)
|
|
152
|
-
return rows
|
|
153
192
|
}
|
|
154
193
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
194
|
+
async function findPartner(env, ref) {
|
|
195
|
+
if (UUID_RE.test(ref)) {
|
|
196
|
+
const data = await withLevel(env, 'reading a partner', () =>
|
|
197
|
+
remote.gql(env, `query ($id: ID!) { partner(id: $id) { id name domain status } }`, { id: ref })
|
|
198
|
+
)
|
|
199
|
+
if (!data.partner) throw new Error(`no partner with id ${ref}`)
|
|
200
|
+
return data.partner
|
|
201
|
+
}
|
|
202
|
+
const all = await listPartners(env)
|
|
203
|
+
const hit = all.filter(
|
|
204
|
+
(p) => p.name.toLowerCase() === ref.toLowerCase() || p.domain.toLowerCase() === ref.toLowerCase()
|
|
163
205
|
)
|
|
164
|
-
|
|
206
|
+
if (!hit.length) throw new Error(`no partner matching "${ref}" (see: dooer-test-env partner list --env ${env.name})`)
|
|
207
|
+
if (hit.length > 1) throw new Error(`"${ref}" matches ${hit.length} partners — address it by id`)
|
|
208
|
+
return hit[0]
|
|
165
209
|
}
|
|
166
210
|
|
|
167
|
-
async function
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
211
|
+
async function partnerUsers(env, partnerId) {
|
|
212
|
+
const data = await withLevel(env, 'listing a partner’s users', () =>
|
|
213
|
+
remote.gql(
|
|
214
|
+
env,
|
|
215
|
+
`query ($id: ID!) {
|
|
216
|
+
partner(id: $id) { users(first: 500) { edges { node {
|
|
217
|
+
userId role status defaultRepresentative user { email firstName lastName }
|
|
218
|
+
} } } }
|
|
219
|
+
}`,
|
|
220
|
+
{ id: partnerId }
|
|
221
|
+
)
|
|
173
222
|
)
|
|
174
|
-
|
|
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]
|
|
223
|
+
return data.partner.users.edges.map((e) => e.node)
|
|
179
224
|
}
|
|
180
225
|
|
|
181
226
|
// ── writes ──────────────────────────────────────────────────────────────────
|
|
182
|
-
async function createUser(
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
227
|
+
async function createUser(env, { email, password, role, personnummer, firstName, lastName }) {
|
|
228
|
+
const input = { email, password, userType: role, firstName, lastName }
|
|
229
|
+
if (personnummer) input.sePersonnummer = personnummer
|
|
230
|
+
const data = await withLevel(env, 'creating a user', () =>
|
|
231
|
+
remote.gql(
|
|
232
|
+
env,
|
|
233
|
+
`mutation ($input: UserInput!) {
|
|
234
|
+
users { create(input: $input) { id email userType } }
|
|
235
|
+
}`,
|
|
236
|
+
{ input }
|
|
237
|
+
)
|
|
208
238
|
)
|
|
209
|
-
return
|
|
239
|
+
return data.users.create
|
|
210
240
|
}
|
|
211
241
|
|
|
212
|
-
async function assignPartner(
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
242
|
+
async function assignPartner(env, { userId, partnerId, level }) {
|
|
243
|
+
const data = await withLevel(env, 'changing partner membership', () =>
|
|
244
|
+
remote.gql(
|
|
245
|
+
env,
|
|
246
|
+
`mutation ($pid: ID!, $uid: ID!, $input: PartnerUserInput!) {
|
|
247
|
+
partner(id: $pid) { users { set(userId: $uid, input: $input) { userId role status } } }
|
|
248
|
+
}`,
|
|
249
|
+
{ pid: partnerId, uid: userId, input: { role: level, status: 'enabled' } }
|
|
219
250
|
)
|
|
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
251
|
)
|
|
236
|
-
return
|
|
252
|
+
return data.partner.users.set
|
|
237
253
|
}
|
|
238
254
|
|
|
239
|
-
async function unassignPartner(
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
}
|
|
255
|
+
async function unassignPartner(env, { userId, partnerId }) {
|
|
256
|
+
await withLevel(env, 'removing partner membership', () =>
|
|
257
|
+
remote.gql(env, `mutation ($pid: ID!, $uid: ID!) { partner(id: $pid) { user(userId: $uid) { delete } } }`, {
|
|
258
|
+
pid: partnerId,
|
|
259
|
+
uid: userId,
|
|
260
|
+
})
|
|
261
|
+
)
|
|
265
262
|
}
|
|
266
263
|
|
|
267
264
|
module.exports = {
|
|
268
265
|
USER_ROLES,
|
|
269
266
|
PARTNER_LEVELS,
|
|
270
|
-
targetOf,
|
|
271
|
-
guardWrite,
|
|
272
267
|
generatePassword,
|
|
273
|
-
hashPassword,
|
|
274
268
|
normalizePersonnummer,
|
|
275
269
|
namesFromEmail,
|
|
276
|
-
findUser,
|
|
277
270
|
searchUsers,
|
|
271
|
+
findUser,
|
|
278
272
|
listPartners,
|
|
279
273
|
partnersForUser,
|
|
280
274
|
findPartner,
|
|
275
|
+
partnerUsers,
|
|
281
276
|
createUser,
|
|
282
277
|
assignPartner,
|
|
283
278
|
unassignPartner,
|
|
284
|
-
withDb,
|
|
285
279
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dooer/dooer-test-env",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.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,12 +27,12 @@
|
|
|
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",
|
|
31
30
|
"chalk": "^4.1.2",
|
|
32
31
|
"js-yaml": "^3.14.1",
|
|
33
32
|
"pg": "^8.23.0",
|
|
34
33
|
"pg-copy-streams": "^6.0.0",
|
|
35
34
|
"pg-cursor": "^2.11.0",
|
|
35
|
+
"qrcode-terminal": "^0.12.0",
|
|
36
36
|
"yargs": "^17.7.2"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
package/readme.md
CHANGED
|
@@ -21,6 +21,9 @@ logins and the BankID cert all come from there.
|
|
|
21
21
|
certificate, and verify both actually work.
|
|
22
22
|
- **[Issuing access](./docs/issuing-access.md)** — admin: sign someone's certificate, bind their
|
|
23
23
|
permissions, and revoke them again.
|
|
24
|
+
- **[Logins and user management](./docs/user-management.md)** — signing in to an environment as a customer,
|
|
25
|
+
partner or admin user; creating users; partner membership; using the token from scripts. Needs no VPN or
|
|
26
|
+
kubectl at all.
|
|
24
27
|
|
|
25
28
|
Already have both? Straight to the quick start.
|
|
26
29
|
|
|
@@ -53,7 +56,8 @@ db roles (re)create per-service DB role
|
|
|
53
56
|
db snapshot <name> | db rollback <name> local restore points
|
|
54
57
|
customer new "<name>" --owner <userId> --execute empty functional account: company + Owner + subscriptions + partner dooer
|
|
55
58
|
customer copy | customer purge copy / delete one org between environments (emails anonymized)
|
|
56
|
-
|
|
59
|
+
remote-environment login|become|token|status|logout sign in to an environment; session → keychain
|
|
60
|
+
user search | get | create | partners find/inspect/create users (local env unless --env)
|
|
57
61
|
partner list | users | assign | unassign partners and who belongs to them, at which level
|
|
58
62
|
shred anonymize the LOCAL db (localhost only)
|
|
59
63
|
bankid pull | status | clear BankID cert (staging → keychain), used by service-accounts
|
|
@@ -125,17 +129,22 @@ npx @dooer/dooer-test-env@latest user create --email anna@dooer.com --execute
|
|
|
125
129
|
|
|
126
130
|
# …a customer instead, with your own password and a personnummer
|
|
127
131
|
npx @dooer/dooer-test-env@latest user create --email kim@example.com \
|
|
128
|
-
--role customer --password 'hunter2hunter2' --personnummer 19900101-
|
|
132
|
+
--role customer --password 'hunter2hunter2' --personnummer 19900101-1239 --execute
|
|
129
133
|
|
|
130
134
|
# …or an `hi` user placed straight into a partner
|
|
131
135
|
npx @dooer/dooer-test-env@latest user create --email anna@dooer.com \
|
|
132
136
|
--partner "Dooer Devteam" --level admin --execute
|
|
133
137
|
```
|
|
134
138
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
+
This goes through `users.create` — the same mutation back-office's `/users/new` calls — so the service
|
|
140
|
+
hashes the password itself and applies its own input validation. The password works on the ordinary
|
|
141
|
+
email+password login, no BankID needed, and is shown **once**; there is no way to read it back. `--role`
|
|
142
|
+
defaults to `hi` (other choices: `customer`, `admin`) and names default to something derived from the
|
|
143
|
+
address.
|
|
144
|
+
|
|
145
|
+
The personnummer must be a **real** one: the API checks the Luhn digit, so an invented number is rejected
|
|
146
|
+
(the CLI checks it first and tells you which rule failed). Creating a user needs an **admin** session in a
|
|
147
|
+
remote environment — see below.
|
|
139
148
|
|
|
140
149
|
Look users up by email **or** id, and see where they belong:
|
|
141
150
|
|
|
@@ -147,7 +156,7 @@ npx @dooer/dooer-test-env@latest user get anna@dooer.com # details + partner m
|
|
|
147
156
|
### Partners and who belongs to them
|
|
148
157
|
|
|
149
158
|
```bash
|
|
150
|
-
npx @dooer/dooer-test-env@latest partner list # every partner
|
|
159
|
+
npx @dooer/dooer-test-env@latest partner list # every partner in the environment
|
|
151
160
|
npx @dooer/dooer-test-env@latest partner users "Dooer Devteam" # its users and their level
|
|
152
161
|
|
|
153
162
|
# add / promote / remove (a partner is addressable by id, name or domain)
|
|
@@ -161,15 +170,47 @@ user's `--role` at Dooer. Only **`hi`** users can belong to a partner; the CLI a
|
|
|
161
170
|
|
|
162
171
|
### Look at (or fix) users in a real environment
|
|
163
172
|
|
|
164
|
-
|
|
173
|
+
The `user` and `partner` commands reach an environment through its **public GraphQL endpoint** — the same
|
|
174
|
+
one the frontends use — so staging and live need no VPN, no kubectl and no cluster credentials. They do
|
|
175
|
+
need a session:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
# BankID — what HQ does, so you get your professional (hi) account
|
|
179
|
+
npx @dooer/dooer-test-env@latest remote-environment login --env staging
|
|
180
|
+
|
|
181
|
+
# email + password — the only route to an ADMIN account (BankID cannot reach one)
|
|
182
|
+
npx @dooer/dooer-test-env@latest remote-environment login --env staging --email you+admin@dooer.com
|
|
183
|
+
|
|
184
|
+
npx @dooer/dooer-test-env@latest remote-environment status --all
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Then point any command at it with `--env` (`local` is the default; `staging` / `production` and aliases
|
|
188
|
+
like `dooer-staging` / `live` resolve too):
|
|
165
189
|
|
|
166
190
|
```bash
|
|
167
|
-
npx @dooer/dooer-test-env@latest user search someone@dooer.com --
|
|
168
|
-
npx @dooer/dooer-test-env@latest partner list --
|
|
191
|
+
npx @dooer/dooer-test-env@latest user search someone@dooer.com --env staging
|
|
192
|
+
npx @dooer/dooer-test-env@latest partner list --env production
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**Which of your accounts you sign in as decides what you can do** — reads work from any session, partner
|
|
196
|
+
membership needs a partner session, and creating a user needs an admin one. See
|
|
197
|
+
**[Logins and user management](./docs/user-management.md)** for the full picture: the three kinds of user,
|
|
198
|
+
`become`, and the permission matrix.
|
|
199
|
+
|
|
200
|
+
### Use the session token in your own scripts
|
|
201
|
+
|
|
202
|
+
`remote-environment token` prints the token and nothing else — no colour, no label — so it drops straight
|
|
203
|
+
into a variable or a pipe:
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
TOKEN=$(npx @dooer/dooer-test-env@latest remote-environment token --env staging)
|
|
207
|
+
|
|
208
|
+
curl -s https://api.s-e032.com/graphql \
|
|
209
|
+
-H "content-type: application/json" -H "authorization: Bearer $TOKEN" \
|
|
210
|
+
-d '{"query":"{ currentUser { id email userType } }"}'
|
|
169
211
|
```
|
|
170
212
|
|
|
171
|
-
|
|
172
|
-
`--confirm-production`.
|
|
213
|
+
The local env needs no login for this — with no stored session it mints one from the shared dev keypair.
|
|
173
214
|
|
|
174
215
|
### Copy a real customer into the local env
|
|
175
216
|
|