@dooer/dooer-test-env 1.13.0 → 1.15.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/remote.js ADDED
@@ -0,0 +1,357 @@
1
+ /* global fetch */
2
+ // remote.js — talking to a Dooer environment through its PUBLIC GraphQL endpoint.
3
+ //
4
+ // Everything here goes over ordinary HTTPS to the public facade, so none of it needs kubectl, a VPN or
5
+ // cluster credentials — the same entrypoint the frontends use. (Jimmy 2026-09-04: "use graphql instead
6
+ // since that does not require any k8s, just call the public api endpoint". The k8s-based paths elsewhere
7
+ // in this CLI are expected to move to another mechanism later.)
8
+ //
9
+ // A session is one JWT per environment, kept in the macOS keychain, obtained exactly the way the products
10
+ // obtain it:
11
+ // * password — `loginWithEmail`, what back-office's shared <Login> component calls
12
+ // (@dooer/react-internal-components/dist/components/login/mutations.js)
13
+ // * BankID — `loginWithBankIdV2` + polling `bankIdSessionV2`, the same 1000 ms cadence HQ uses; the
14
+ // `bankIdQRImage` field is the raw animated-QR payload (`bankid.<token>.<seconds>.<hmac>`), which we
15
+ // render as a real QR in the terminal and refresh each poll.
16
+ const { spawnSync } = require('child_process')
17
+
18
+ const KEYCHAIN_ACCOUNT = require('os').userInfo().username
19
+
20
+ // Public GraphQL endpoints, from the environments' own manifests (DOOER_PUBLIC_FACADE_HOST /
21
+ // public-facade route hostnames). `local` is this CLI's own stack on the host.
22
+ const ENVIRONMENTS = {
23
+ local: { url: 'http://localhost:4000/graphql', label: 'local env', production: false },
24
+ staging: { url: 'https://api.s-e032.com/graphql', label: 'staging (s-e032)', production: false },
25
+ production: { url: 'https://api.dooer.com/graphql', label: 'PRODUCTION (l-e033)', production: true },
26
+ }
27
+ // Accept the names used elsewhere in this CLI and in conversation, so nobody has to remember a new word.
28
+ const ALIASES = {
29
+ live: 'production',
30
+ prod: 'production',
31
+ 'dooer-production': 'production',
32
+ 'l-e033': 'production',
33
+ 'dooer-staging': 'staging',
34
+ 's-e032': 'staging',
35
+ stage: 'staging',
36
+ }
37
+
38
+ function resolveEnv(name) {
39
+ const key = ALIASES[name] || name || 'local'
40
+ const env = ENVIRONMENTS[key]
41
+ if (!env) {
42
+ throw new Error(`unknown environment "${name}" — one of: ${Object.keys(ENVIRONMENTS).join(', ')}`)
43
+ }
44
+ return { name: key, ...env }
45
+ }
46
+
47
+ const envNames = () => Object.keys(ENVIRONMENTS)
48
+
49
+ // ── keychain ────────────────────────────────────────────────────────────────
50
+ function sh(cmd, args, input) {
51
+ const r = spawnSync(cmd, args, { encoding: 'utf8', input })
52
+ return { status: r.status, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() }
53
+ }
54
+
55
+ const keychainService = (envName) => `dooer-test-env-session:${envName}`
56
+
57
+ // `security -w` prints longer values as lowercase hex (the same behaviour that bit the BankID PFX), so
58
+ // decode when the output is pure even-length hex. A JWT is base64url with dots — never pure hex.
59
+ function tokenGet(envName) {
60
+ const r = sh('security', ['find-generic-password', '-a', KEYCHAIN_ACCOUNT, '-s', keychainService(envName), '-w'])
61
+ if (r.status !== 0) return null
62
+ const out = r.stdout
63
+ if (out && out.length % 2 === 0 && /^[0-9a-f]+$/.test(out)) return Buffer.from(out, 'hex').toString('utf8')
64
+ return out || null
65
+ }
66
+
67
+ // -U upserts. The value goes on argv, which is briefly visible in the process list — same trade-off the
68
+ // BankID store already makes on a single-user dev machine, and the keychain is the durable store.
69
+ function tokenSet(envName, token) {
70
+ const r = sh('security', [
71
+ 'add-generic-password',
72
+ '-U',
73
+ '-a',
74
+ KEYCHAIN_ACCOUNT,
75
+ '-s',
76
+ keychainService(envName),
77
+ '-D',
78
+ 'dooer-test-env session token',
79
+ '-w',
80
+ token,
81
+ ])
82
+ if (r.status !== 0) throw new Error(`keychain store failed for ${envName}: ${r.stderr}`)
83
+ }
84
+
85
+ function tokenDelete(envName) {
86
+ return (
87
+ sh('security', ['delete-generic-password', '-a', KEYCHAIN_ACCOUNT, '-s', keychainService(envName)]).status === 0
88
+ )
89
+ }
90
+
91
+ // ── JWT ─────────────────────────────────────────────────────────────────────
92
+ function decodeJwt(token) {
93
+ try {
94
+ const [, payload] = token.split('.')
95
+ return JSON.parse(Buffer.from(payload.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'))
96
+ } catch (_) {
97
+ return null
98
+ }
99
+ }
100
+
101
+ function expiry(token) {
102
+ const p = decodeJwt(token)
103
+ if (!p || !p.exp) return null
104
+ return { at: new Date(p.exp * 1000), expired: p.exp * 1000 <= Date.now() }
105
+ }
106
+
107
+ // ── the token a command should use ──────────────────────────────────────────
108
+ // Stored session first. For `local` only, fall back to minting a service token with the shared dev
109
+ // keypair the local stack is generated with — logging in to your own throwaway stack is pointless
110
+ // friction, and that keypair is worthless anywhere else.
111
+ function tokenFor(envName) {
112
+ const stored = tokenGet(envName)
113
+ if (stored) {
114
+ const exp = expiry(stored)
115
+ if (exp && exp.expired) {
116
+ throw new Error(
117
+ `your ${envName} session expired ${exp.at.toISOString().slice(0, 16).replace('T', ' ')} — ` +
118
+ `run: dooer-test-env remote-environment login --env ${envName}`
119
+ )
120
+ }
121
+ return stored
122
+ }
123
+ if (envName === 'local') return require('./api').serviceToken()
124
+ throw new Error(`not logged in to ${envName} — run: dooer-test-env remote-environment login --env ${envName}`)
125
+ }
126
+
127
+ // ── GraphQL ─────────────────────────────────────────────────────────────────
128
+ // One transport for every remote call. Errors come back as a readable sentence rather than a JSON dump.
129
+ async function gql(env, query, variables, { token, anonymous } = {}) {
130
+ const headers = { 'content-type': 'application/json', 'x-dooer-client': 'dooer-test-env@0' }
131
+ if (!anonymous) headers.authorization = `Bearer ${token || tokenFor(env.name)}`
132
+ let res
133
+ try {
134
+ res = await fetch(env.url, { method: 'POST', headers, body: JSON.stringify({ query, variables }) })
135
+ } catch (e) {
136
+ throw new Error(`cannot reach ${env.label} at ${env.url}: ${e.message}`)
137
+ }
138
+ const text = await res.text()
139
+ let json
140
+ try {
141
+ json = JSON.parse(text)
142
+ } catch (_) {
143
+ throw new Error(`${env.label} returned ${res.status} (not JSON): ${text.slice(0, 200)}`)
144
+ }
145
+ if (json.errors && json.errors.length) {
146
+ const first = json.errors[0]
147
+ const code = first.code || (first.extensions && first.extensions.code) || ''
148
+ const err = new Error(`${env.label}${code ? ` [${code}]` : ''}: ${first.message}`)
149
+ // Keep the machine-readable parts. A denial carries `extra.resourceType` ("admin"/"partner"/…) and the
150
+ // gateway stamps a transactionId — callers should branch on those rather than re-parse the prose, and
151
+ // the transactionId is what you quote when asking why a call was refused.
152
+ err.code = code
153
+ err.resourceType = (first.extra && first.extra.resourceType) || null
154
+ err.transactionId = (first.extensions && first.extensions.transactionId) || null
155
+ err.graphqlPath = Array.isArray(first.path) ? first.path.join('.') : null
156
+ throw err
157
+ }
158
+ return json.data
159
+ }
160
+
161
+ // ── login: password ─────────────────────────────────────────────────────────
162
+ const LOGIN_WITH_EMAIL = `mutation ($email: String!, $password: String!) {
163
+ loginWithEmail(email: $email, password: $password)
164
+ }`
165
+
166
+ async function loginWithPassword(env, email, password) {
167
+ const data = await gql(env, LOGIN_WITH_EMAIL, { email, password }, { anonymous: true })
168
+ return data.loginWithEmail
169
+ }
170
+
171
+ // ── login: BankID ───────────────────────────────────────────────────────────
172
+ // sePersonnummer is optional: omitted, BankID resolves the identity from whichever device scans the QR.
173
+ const LOGIN_WITH_BANKID = `mutation ($sePersonnummer: SwedishPersonalIdentificationNumber) {
174
+ loginWithBankIdV2(sePersonnummer: $sePersonnummer) {
175
+ id
176
+ bankIdQRImage
177
+ autoStartToken
178
+ status
179
+ hintCode
180
+ }
181
+ }`
182
+
183
+ const POLL_BANKID = `query ($id: ID!) {
184
+ bankIdSessionV2(id: $id) {
185
+ id
186
+ bankIdQRImage
187
+ status
188
+ hintCode
189
+ token
190
+ }
191
+ }`
192
+
193
+ // Human wording for the BankID hint codes that actually show up while waiting.
194
+ const HINTS = {
195
+ outstandingTransaction: 'waiting for the BankID app',
196
+ noClient: 'waiting for the BankID app',
197
+ started: 'BankID app started — choose your ID',
198
+ userSign: 'confirm in the BankID app',
199
+ expiredTransaction: 'the BankID order expired',
200
+ userCancel: 'you cancelled in the BankID app',
201
+ cancelled: 'the order was cancelled',
202
+ startFailed: 'the BankID app did not start',
203
+ certificateErr: 'that BankID cannot be used (revoked, invalid, or too many wrong codes)',
204
+ }
205
+
206
+ // Start an order and poll it to completion. `onFrame({ qr, status, hintCode })` fires once per poll so the
207
+ // caller can redraw the QR — it changes every second by design (the payload is an HMAC over elapsed
208
+ // seconds), so a static QR goes stale and stops scanning.
209
+ async function loginWithBankId(env, { sePersonnummer, onFrame, intervalMs = 1000, timeoutMs = 180000 }) {
210
+ const start = await gql(env, LOGIN_WITH_BANKID, { sePersonnummer: sePersonnummer || null }, { anonymous: true })
211
+ const session = start.loginWithBankIdV2
212
+ if (onFrame) onFrame({ qr: session.bankIdQRImage, status: session.status, hintCode: session.hintCode })
213
+
214
+ const deadline = Date.now() + timeoutMs
215
+ for (;;) {
216
+ if (Date.now() > deadline) throw new Error('BankID login timed out after 3 minutes')
217
+ await new Promise((r) => setTimeout(r, intervalMs))
218
+ const data = await gql(env, POLL_BANKID, { id: session.id }, { anonymous: true })
219
+ const s = data.bankIdSessionV2
220
+ if (!s) throw new Error('the BankID session disappeared')
221
+ if (onFrame) onFrame({ qr: s.bankIdQRImage, status: s.status, hintCode: s.hintCode })
222
+ if (s.status === 'complete') {
223
+ if (!s.token) throw new Error('BankID completed but returned no token')
224
+ return s.token
225
+ }
226
+ if (s.status === 'failed') {
227
+ throw new Error(`BankID failed: ${HINTS[s.hintCode] || s.hintCode || 'unknown reason'}`)
228
+ }
229
+ }
230
+ }
231
+
232
+ // ── login: BankID via the partner flow (what HQ does) ───────────────────────
233
+ // `loginWithBankIdV2` hardcodes `user_type: 'customer'` in service-accounts
234
+ // (lib/login-with-bank-id.js:199), so it can ONLY ever return the customer account — which is why a
235
+ // BankID login lands on your customer user even when you meant your professional one. HQ authenticates
236
+ // through the PARTNER session instead, which resolves the `hi` user belonging to that partner. It needs
237
+ // the partner's `domain` (e.g. `dooer`, `dooer-devteam`) to know which partner you are signing in to.
238
+ const LOGIN_PARTNER = `mutation ($input: PartnerAuthenticationSessionInput!) {
239
+ partners {
240
+ login(input: $input) {
241
+ id
242
+ bankIdQRImage
243
+ autoStartToken
244
+ status
245
+ bankidProgressStatus
246
+ }
247
+ }
248
+ }`
249
+
250
+ const POLL_PARTNER = `query ($id: ID!) {
251
+ partnerAuthenticationSession(id: $id) {
252
+ session {
253
+ id
254
+ status
255
+ bankidProgressStatus
256
+ bankIdQRImage
257
+ }
258
+ token
259
+ }
260
+ }`
261
+
262
+ // `domain` is '' because that is exactly what HQ sends: its login form submits `{ domain: '' }`
263
+ // (frontend-hq/src/apps/auth/components/partner-login-form.tsx) and never asks which partner you are
264
+ // signing in to — the server resolves that from the BankID identity.
265
+ async function loginWithPartnerBankId(env, { idNumber, onFrame, intervalMs = 1000, timeoutMs = 180000 } = {}) {
266
+ const start = await gql(
267
+ env,
268
+ LOGIN_PARTNER,
269
+ { input: { domain: '', idNumber: idNumber || null } },
270
+ { anonymous: true }
271
+ )
272
+ const session = start.partners.login
273
+ if (onFrame) onFrame({ qr: session.bankIdQRImage, hintCode: session.bankidProgressStatus })
274
+
275
+ const deadline = Date.now() + timeoutMs
276
+ for (;;) {
277
+ if (Date.now() > deadline) throw new Error('BankID login timed out after 3 minutes')
278
+ await new Promise((r) => setTimeout(r, intervalMs))
279
+ const data = await gql(env, POLL_PARTNER, { id: session.id }, { anonymous: true })
280
+ const res = data.partnerAuthenticationSession
281
+ if (!res || !res.session) throw new Error('the partner authentication session disappeared')
282
+ const s = res.session
283
+ if (onFrame) onFrame({ qr: s.bankIdQRImage, hintCode: s.bankidProgressStatus })
284
+ // This flow's own status enum: pending | fulfilled | rejected.
285
+ if (s.status === 'fulfilled') {
286
+ if (!res.token) throw new Error('partner login completed but returned no token')
287
+ return res.token
288
+ }
289
+ if (s.status === 'rejected') {
290
+ throw new Error(
291
+ `partner BankID login rejected: ${HINTS[s.bankidProgressStatus] || s.bankidProgressStatus || 'unknown reason'}`
292
+ )
293
+ }
294
+ }
295
+ }
296
+
297
+ // ── become a partner user (back-office "Become user in X") ──────────────────
298
+ // The other way to hold a partner-level session: sign in with your ADMIN account and assume a partner
299
+ // user, rather than authenticating as them with BankID. `PartnerUserMutation.become` →
300
+ // POST /v1/partners/actions/become, which is itself `levels: ['admin']`, and hands back a real token for
301
+ // that partner user.
302
+ const BECOME_PARTNER_USER = `mutation ($pid: ID!, $uid: ID!) {
303
+ partner(id: $pid) { user(userId: $uid) { become { redirectUrl token } } }
304
+ }`
305
+
306
+ async function becomePartnerUser(env, { partnerId, userId }) {
307
+ const data = await gql(env, BECOME_PARTNER_USER, { pid: partnerId, uid: userId })
308
+ return data.partner.user.become
309
+ }
310
+
311
+ // Becoming replaces the stored session, so the admin token that authorised it is stashed first —
312
+ // otherwise you would have to log in again just to get back to where you were.
313
+ const stashService = (envName) => `${envName}:before-become`
314
+
315
+ function stashToken(envName) {
316
+ const current = tokenGet(envName)
317
+ if (current) tokenSet(stashService(envName), current)
318
+ }
319
+
320
+ function unstashToken(envName) {
321
+ const previous = tokenGet(stashService(envName))
322
+ if (!previous) return null
323
+ tokenSet(envName, previous)
324
+ tokenDelete(stashService(envName))
325
+ return previous
326
+ }
327
+
328
+ // ── whoami ──────────────────────────────────────────────────────────────────
329
+ // userType is included deliberately: one person has several user rows (customer / hi / admin) and each
330
+ // login route resolves to a different one, so "who am I" is not complete without "as which account".
331
+ const CURRENT_USER = `query { currentUser { id firstName lastName email userType } }`
332
+
333
+ async function currentUser(env, token) {
334
+ const data = await gql(env, CURRENT_USER, {}, token ? { token } : {})
335
+ return data.currentUser
336
+ }
337
+
338
+ module.exports = {
339
+ ENVIRONMENTS,
340
+ envNames,
341
+ resolveEnv,
342
+ tokenGet,
343
+ tokenSet,
344
+ tokenDelete,
345
+ decodeJwt,
346
+ expiry,
347
+ tokenFor,
348
+ gql,
349
+ loginWithPassword,
350
+ loginWithBankId,
351
+ loginWithPartnerBankId,
352
+ becomePartnerUser,
353
+ stashToken,
354
+ unstashToken,
355
+ currentUser,
356
+ HINTS,
357
+ }
package/lib/users.js ADDED
@@ -0,0 +1,279 @@
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
+ //
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
9
+ const crypto = require('crypto')
10
+ const remote = require('./remote')
11
+
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.
14
+ const USER_ROLES = ['hi', 'customer', 'admin']
15
+ // PartnerUserRole — the "level" within a partner, distinct from the user's role at Dooer.
16
+ const PARTNER_LEVELS = ['member', 'admin']
17
+
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
19
+
20
+ // ── password ────────────────────────────────────────────────────────────────
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.
23
+ const ALPHABET = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789'
24
+
25
+ function generatePassword(groups = 4, size = 5) {
26
+ const chunk = () => Array.from({ length: size }, () => ALPHABET[crypto.randomInt(ALPHABET.length)]).join('')
27
+ return Array.from({ length: groups }, chunk).join('-')
28
+ }
29
+
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
44
+ }
45
+
46
+ function normalizePersonnummer(input) {
47
+ const digits = String(input).replace(/\D/g, '')
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.
52
+ const yy = Number(digits.slice(0, 2))
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
+ )
65
+ }
66
+ return full
67
+ }
68
+
69
+ // ── name defaults ───────────────────────────────────────────────────────────
70
+ // firstName/lastName are required by UserInput; derive something sane so the common case is just --email.
71
+ function namesFromEmail(email) {
72
+ const parts = email
73
+ .split('@')[0]
74
+ .split(/[._+-]+/)
75
+ .filter(Boolean)
76
+ const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1)
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
+ )
109
+ }
110
+ }
111
+
112
+ // ── reads ───────────────────────────────────────────────────────────────────
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
+ }
129
+
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
+ )
145
+ )
146
+ const rows = data.users.edges.map((e) => e.node)
147
+ if (!rows.length) throw new Error(`no user matching "${ref}"`)
148
+ if (rows.length > 1) {
149
+ // One address can exist once per user type, so an ambiguous email must be resolved by the caller.
150
+ throw new Error(
151
+ `"${ref}" matches ${rows.length} users (${rows.map((r) => r.userType).join(', ')}) — ` +
152
+ `address it by id: ${rows.map((r) => r.id).join(', ')}`
153
+ )
154
+ }
155
+ return rows[0]
156
+ }
157
+
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 } } } }`)
161
+ )
162
+ return data.partners.edges.map((e) => e.node)
163
+ }
164
+
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
+ })
191
+ )
192
+ }
193
+
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()
205
+ )
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]
209
+ }
210
+
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
+ )
222
+ )
223
+ return data.partner.users.edges.map((e) => e.node)
224
+ }
225
+
226
+ // ── writes ──────────────────────────────────────────────────────────────────
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
+ )
238
+ )
239
+ return data.users.create
240
+ }
241
+
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' } }
250
+ )
251
+ )
252
+ return data.partner.users.set
253
+ }
254
+
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
+ )
262
+ }
263
+
264
+ module.exports = {
265
+ USER_ROLES,
266
+ PARTNER_LEVELS,
267
+ generatePassword,
268
+ normalizePersonnummer,
269
+ namesFromEmail,
270
+ searchUsers,
271
+ findUser,
272
+ listPartners,
273
+ partnersForUser,
274
+ findPartner,
275
+ partnerUsers,
276
+ createUser,
277
+ assignPartner,
278
+ unassignPartner,
279
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dooer/dooer-test-env",
3
- "version": "1.13.0",
3
+ "version": "1.15.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",
@@ -32,6 +32,7 @@
32
32
  "pg": "^8.23.0",
33
33
  "pg-copy-streams": "^6.0.0",
34
34
  "pg-cursor": "^2.11.0",
35
+ "qrcode-terminal": "^0.12.0",
35
36
  "yargs": "^17.7.2"
36
37
  },
37
38
  "devDependencies": {