@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/lib/users.js CHANGED
@@ -1,56 +1,25 @@
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.
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
- // 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.
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 bcrypt = require('bcryptjs')
17
- const { connect } = require('./engine/seed')
10
+ const remote = require('./remote')
18
11
 
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.
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
- // service_accounts."partnerUser_role_enum" — the "level" within a partner.
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
- // 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)
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
- 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.
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
- const nowYY = new Date().getFullYear() % 100
79
- return (yy > nowYY ? '19' : '20') + digits
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
- throw new Error(`personnummer must be 10 or 12 digits (got ${digits.length}): ${input}`)
66
+ return full
82
67
  }
83
68
 
84
69
  // ── 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`.
70
+ // firstName/lastName are required by UserInput; derive something sane so the common case is just --email.
87
71
  function namesFromEmail(email) {
88
- const local = email.split('@')[0]
89
- const parts = local.split(/[._-]+/).filter(Boolean)
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
- firstName: cap(parts[0] || 'Test'),
93
- lastName: cap(parts.slice(1).join(' ') || 'User'),
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 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`
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
- // 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]
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
- // 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.
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 (one per role: ${rows.map((r) => r.role).join(', ')}) — ` +
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 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
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 rows
162
+ return data.partners.edges.map((e) => e.node)
143
163
  }
144
164
 
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`
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
- // 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]
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
- return rows
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 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]
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
- 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]
223
+ return data.partner.users.edges.map((e) => e.node)
179
224
  }
180
225
 
181
226
  // ── 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
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 { id: rows[0].id, apiUuid: rows[0].api_uuid }
239
+ return data.users.create
210
240
  }
211
241
 
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`
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 { previous: existing[0] ? existing[0].level : null, level: rows[0].level }
252
+ return data.partner.users.set
237
253
  }
238
254
 
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
- }
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.14.0",
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
- user search | get | create | partners find/inspect/create users (local env unless --target-namespace)
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-1234 --execute
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
- 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
+ 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 + user counts
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
- These commands default to the local env. Point them at a cluster with `--target-namespace`:
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 --target-namespace dooer-staging
168
- npx @dooer/dooer-test-env@latest partner list --target-namespace dooer-production
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
- Writes there are still dry-run until `--execute`, and `dooer-production` additionally demands
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