@things-factory/auth-base 10.1.31 → 10.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist-server/controllers/invitation.d.ts +47 -10
  2. package/dist-server/controllers/invitation.js +159 -113
  3. package/dist-server/controllers/invitation.js.map +1 -1
  4. package/dist-server/controllers/profile.d.ts +1 -0
  5. package/dist-server/router/auth-public-process-router.js +36 -13
  6. package/dist-server/router/auth-public-process-router.js.map +1 -1
  7. package/dist-server/router/oauth2/oauth2-router.js +7 -2
  8. package/dist-server/router/oauth2/oauth2-router.js.map +1 -1
  9. package/dist-server/router/oauth2/oauth2-server.js +4 -4
  10. package/dist-server/router/oauth2/oauth2-server.js.map +1 -1
  11. package/dist-server/service/app-binding/app-binding-mutation.d.ts +27 -0
  12. package/dist-server/service/app-binding/app-binding-mutation.js +39 -6
  13. package/dist-server/service/app-binding/app-binding-mutation.js.map +1 -1
  14. package/dist-server/service/app-binding/app-binding-query.js +14 -7
  15. package/dist-server/service/app-binding/app-binding-query.js.map +1 -1
  16. package/dist-server/service/app-binding/app-binding.d.ts +9 -0
  17. package/dist-server/service/app-binding/app-binding.js +10 -1
  18. package/dist-server/service/app-binding/app-binding.js.map +1 -1
  19. package/dist-server/service/appliance/appliance-mutation.js +34 -1
  20. package/dist-server/service/appliance/appliance-mutation.js.map +1 -1
  21. package/dist-server/service/appliance/appliance-query.d.ts +2 -0
  22. package/dist-server/service/appliance/appliance-query.js +48 -0
  23. package/dist-server/service/appliance/appliance-query.js.map +1 -1
  24. package/dist-server/service/appliance/appliance.d.ts +1 -0
  25. package/dist-server/service/appliance/appliance.js +33 -3
  26. package/dist-server/service/appliance/appliance.js.map +1 -1
  27. package/dist-server/service/application/application-mutation.js +51 -6
  28. package/dist-server/service/application/application-mutation.js.map +1 -1
  29. package/dist-server/service/application/application.d.ts +9 -3
  30. package/dist-server/service/application/application.js +24 -8
  31. package/dist-server/service/application/application.js.map +1 -1
  32. package/dist-server/service/auth-provider/auth-provider-mutation.js +5 -0
  33. package/dist-server/service/auth-provider/auth-provider-mutation.js.map +1 -1
  34. package/dist-server/service/domain-generator/domain-generator-mutation.js +3 -0
  35. package/dist-server/service/domain-generator/domain-generator-mutation.js.map +1 -1
  36. package/dist-server/service/invitation/invitation-mutation.d.ts +17 -15
  37. package/dist-server/service/invitation/invitation-mutation.js +83 -56
  38. package/dist-server/service/invitation/invitation-mutation.js.map +1 -1
  39. package/dist-server/service/invitation/invitation-query.d.ts +20 -5
  40. package/dist-server/service/invitation/invitation-query.js +60 -19
  41. package/dist-server/service/invitation/invitation-query.js.map +1 -1
  42. package/dist-server/service/invitation/invitation.d.ts +14 -2
  43. package/dist-server/service/invitation/invitation.js +77 -8
  44. package/dist-server/service/invitation/invitation.js.map +1 -1
  45. package/dist-server/service/login-history/login-history-query.js +3 -0
  46. package/dist-server/service/login-history/login-history-query.js.map +1 -1
  47. package/dist-server/service/role/role-mutation.js +14 -15
  48. package/dist-server/service/role/role-mutation.js.map +1 -1
  49. package/dist-server/service/role/role-query.js +26 -4
  50. package/dist-server/service/role/role-query.js.map +1 -1
  51. package/dist-server/service/role/role-types.d.ts +2 -0
  52. package/dist-server/service/role/role-types.js +8 -0
  53. package/dist-server/service/role/role-types.js.map +1 -1
  54. package/dist-server/service/role-template/role-template-mutation.d.ts +17 -1
  55. package/dist-server/service/role-template/role-template-mutation.js +14 -3
  56. package/dist-server/service/role-template/role-template-mutation.js.map +1 -1
  57. package/dist-server/service/user/user-mutation.js +1 -0
  58. package/dist-server/service/user/user-mutation.js.map +1 -1
  59. package/dist-server/service/user/user.d.ts +1 -0
  60. package/dist-server/service/user/user.js +19 -4
  61. package/dist-server/service/user/user.js.map +1 -1
  62. package/dist-server/templates/invitation-email.d.ts +2 -1
  63. package/dist-server/templates/invitation-email.js +16 -4
  64. package/dist-server/templates/invitation-email.js.map +1 -1
  65. package/dist-server/tsconfig.tsbuildinfo +1 -1
  66. package/dist-server/utils/credential-at-rest.d.ts +28 -0
  67. package/dist-server/utils/credential-at-rest.js +36 -0
  68. package/dist-server/utils/credential-at-rest.js.map +1 -0
  69. package/dist-server/utils/credential-features.d.ts +35 -0
  70. package/dist-server/utils/credential-features.js +45 -0
  71. package/dist-server/utils/credential-features.js.map +1 -0
  72. package/dist-server/utils/credential-serial-rule.d.ts +41 -0
  73. package/dist-server/utils/credential-serial-rule.js +43 -0
  74. package/dist-server/utils/credential-serial-rule.js.map +1 -0
  75. package/dist-server/utils/invitation-state.d.ts +52 -0
  76. package/dist-server/utils/invitation-state.js +76 -0
  77. package/dist-server/utils/invitation-state.js.map +1 -0
  78. package/dist-server/utils/refuse-role-name.d.ts +42 -0
  79. package/dist-server/utils/refuse-role-name.js +76 -0
  80. package/dist-server/utils/refuse-role-name.js.map +1 -0
  81. package/dist-server/utils/role-name-standing.d.ts +58 -0
  82. package/dist-server/utils/role-name-standing.js +72 -0
  83. package/dist-server/utils/role-name-standing.js.map +1 -0
  84. package/package.json +4 -4
  85. package/tests/app-binding-delete-db.test.ts +188 -0
  86. package/tests/appliance-credential-state-db.test.ts +207 -0
  87. package/tests/appliance-delete-db.test.ts +119 -0
  88. package/tests/appliance-revoke-db.test.ts +73 -28
  89. package/tests/appliance-schema.test.ts +129 -0
  90. package/tests/application-delete-db.test.ts +190 -0
  91. package/tests/application-token-payload-db.test.ts +185 -0
  92. package/tests/checkin-privilege-seam-db.test.ts +158 -0
  93. package/tests/credential-at-rest-db.test.ts +246 -0
  94. package/tests/credential-features-off-db.test.ts +189 -0
  95. package/tests/credential-serial-rule.test.ts +84 -0
  96. package/tests/invitation-db.test.ts +416 -0
  97. package/tests/invitation-state.test.ts +92 -0
  98. package/tests/open-doors.test.ts +132 -0
  99. package/tests/role-inheritance-db.test.ts +286 -0
  100. package/tests/role-mutation-db.test.ts +21 -7
  101. package/tests/role-name-lookup-sentinel.test.ts +79 -0
  102. package/tests/role-name-standing.test.ts +83 -0
  103. package/tests/token-issuance.test.ts +7 -3
  104. package/translations/en.json +2 -0
  105. package/translations/ja.json +2 -0
  106. package/translations/ko.json +2 -0
  107. package/translations/ms.json +2 -0
  108. package/translations/zh.json +2 -0
@@ -0,0 +1,286 @@
1
+ /**
2
+ * A role name that exists in this domain **and** in the one it inherits from.
3
+ *
4
+ * ── How the two rows come about ─────────────────────────────────────────────
5
+ * Roles are visible one level up: every read is scoped to `[domain.id, domain.parentId]`. The
6
+ * four duplicate checks in `role-mutation.ts` ask only about this domain, so a domain with a
7
+ * parent can be given a role whose name is already visible through it. Measured 2026-09-20:
8
+ * `createRole` accepts it, and both rows then come back from every list.
9
+ *
10
+ * ── What was settled, and when ──────────────────────────────────────────────
11
+ * ADR-0062 decision 3 (2026-09-20): the platform forbids it, and the operator may say otherwise.
12
+ * The name stays the same when they do — that is the point, because moving a domain off an
13
+ * inherited structure means standing up the same names here first (decision 2).
14
+ *
15
+ * Resolving the name was already deterministic before that, and had to be either way. `In([own, parent])`
16
+ * with no `ORDER BY` answered whichever the driver handed back first — the same call could
17
+ * answer the parent's role on one installation and this domain's on another, and a role is what
18
+ * a privilege check reads. So these cases pin **determinism and direction**, not the semantics.
19
+ *
20
+ * ⚠ sqlite (`tests/db.ts`). This harness cannot reproduce another driver's ordering, which is
21
+ * the point: the fix is to stop depending on ordering at all, and that is visible here as the
22
+ * parent's row never being the answer while this domain has one.
23
+ */
24
+
25
+ import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
26
+ import { loadCompiled } from './compiled'
27
+
28
+ const { Role } = loadCompiled('service/role/role')
29
+ const { RoleQuery } = loadCompiled('service/role/role-query')
30
+ const { RoleMutation } = loadCompiled('service/role/role-mutation')
31
+ const { User, UserStatus } = loadCompiled('service/user/user')
32
+
33
+ const query = new RoleQuery()
34
+ const mutation = new RoleMutation()
35
+
36
+ let parent: any
37
+ let child: any
38
+ let orphan: any
39
+ let actor: any
40
+
41
+ function context(d: any) {
42
+ return {
43
+ state: { domain: d, user: actor, tx: undefined },
44
+ throw: (code: number, message: string) => {
45
+ const error: any = new Error(message)
46
+ error.status = code
47
+ throw error
48
+ }
49
+ } as any
50
+ }
51
+
52
+ /** `context.state.domain` carries `parentId`; the repository does not fill it in for us. */
53
+ async function withParentId(domain: any, parentId: string | null) {
54
+ return { ...(await getRepository(Domain).findOneBy({ id: domain.id })), parentId }
55
+ }
56
+
57
+ beforeAll(async () => {
58
+ await openAuthDatabase()
59
+ })
60
+
61
+ afterAll(async () => {
62
+ await closeAuthDatabase()
63
+ })
64
+
65
+ beforeEach(async () => {
66
+ await resetAuthDatabase()
67
+
68
+ const p = await getRepository(Domain).save({ name: 'system', subdomain: 'system' })
69
+ const c = await getRepository(Domain).save({ name: 'acme', subdomain: 'acme', parent: p })
70
+ const o = await getRepository(Domain).save({ name: 'beta', subdomain: 'beta' })
71
+
72
+ parent = await withParentId(p, null)
73
+ child = await withParentId(c, p.id)
74
+ orphan = await withParentId(o, null)
75
+
76
+ actor = await getRepository(User).save({
77
+ username: 'operator@acme.z',
78
+ email: 'operator@acme.z',
79
+ name: 'operator',
80
+ status: UserStatus.ACTIVATED,
81
+ domains: [c]
82
+ })
83
+ })
84
+
85
+ /** 거절의 이름을 본다 — 영어 문장이 아니라 `code` 가 계약이다(ADR-0054). */
86
+ async function refusalCodeOf(promise: Promise<unknown>): Promise<string> {
87
+ try {
88
+ await promise
89
+ } catch (error: any) {
90
+ return error?.code || error?.extensions?.refusal?.code || `not a refusal: ${error?.message}`
91
+ }
92
+
93
+ return 'nothing was thrown'
94
+ }
95
+
96
+ /** 거절이 싣고 온 값. 화면이 물음을 만드는 재료다. */
97
+ async function refusalParamsOf(promise: Promise<unknown>): Promise<Record<string, any>> {
98
+ try {
99
+ await promise
100
+ } catch (error: any) {
101
+ return error?.params || error?.extensions?.refusal?.params || {}
102
+ }
103
+
104
+ return {}
105
+ }
106
+
107
+ async function roleIn(domain: any, name: string, description: string) {
108
+ return await getRepository(Role).save({ name, description, domain: { id: domain.id } })
109
+ }
110
+
111
+ describe('one role, no ambiguity', () => {
112
+ it('answers this domain own role', async () => {
113
+ await roleIn(child, 'approver', 'ours')
114
+
115
+ expect(await query.role('approver', context(child))).toMatchObject({ description: 'ours' })
116
+ })
117
+
118
+ it('answers a role that only the parent has', async () => {
119
+ /* Inheritance still works — this is the case the whole one-level scope exists for. */
120
+ await roleIn(parent, 'auditor', 'theirs')
121
+
122
+ expect(await query.role('auditor', context(child))).toMatchObject({ description: 'theirs' })
123
+ })
124
+
125
+ it('answers nothing for a name neither has', async () => {
126
+ expect(await query.role('nobody', context(child))).toBeNull()
127
+ })
128
+
129
+ it('does not reach a parent that is not ours', async () => {
130
+ /* A root domain inherits from nobody, and another tenant is not a parent. */
131
+ await roleIn(parent, 'auditor', 'theirs')
132
+
133
+ expect(await query.role('auditor', context(orphan))).toBeNull()
134
+ })
135
+ })
136
+
137
+ describe('the same name in both', () => {
138
+ it('is refused unless the operator says this domain stands its own', async () => {
139
+ /*
140
+ * ⚠ ADR-0062 decision 3, settled 2026-09-20. Until then the duplicate checks looked at this
141
+ * domain only and nothing refused a name already visible through the parent, so the two rows
142
+ * appeared silently.
143
+ *
144
+ * The refusal names the domain the other one stands in, because that is what the operator
145
+ * needs to decide — they may not have known the parent had it.
146
+ */
147
+ await roleIn(parent, 'approver', 'theirs')
148
+
149
+ expect(await refusalCodeOf(mutation.createRole({ name: 'approver' } as any, context(child)))).toEqual(
150
+ 'role-name-stands-inherited'
151
+ )
152
+ })
153
+
154
+ it('names the domain the other one stands in', async () => {
155
+ /*
156
+ * ⚠ What makes the refusal answerable. The operator's next move is to decide whether this
157
+ * domain stands its own of that name, and they cannot decide without knowing where the other
158
+ * one is — they may not have known the parent had it. A refusal that only said "duplicated"
159
+ * would leave them guessing.
160
+ *
161
+ * The value rides in `params`, not in the sentence: the sentence is built in the reader's
162
+ * language and the screen puts its own question together from these (ADR-0054).
163
+ */
164
+ await roleIn(parent, 'approver', 'theirs')
165
+
166
+ expect(await refusalParamsOf(mutation.createRole({ name: 'approver' } as any, context(child)))).toMatchObject({
167
+ name: 'approver',
168
+ domain: 'system'
169
+ })
170
+ })
171
+
172
+ it('stands when the operator does say so, under the same name', async () => {
173
+ /*
174
+ * ⚠ The escape is not "call it something else". Standing up the same names here is the first
175
+ * step of moving a domain off an inherited structure (decision 2) — the child's role replaces
176
+ * what it inherits, and cutting the parent last leaves one name again. Requiring a different
177
+ * name would close that route.
178
+ */
179
+ await roleIn(parent, 'approver', 'theirs')
180
+
181
+ const created = await mutation.createRole(
182
+ { name: 'approver', description: 'ours', separately: true } as any,
183
+ context(child)
184
+ )
185
+
186
+ expect(created).toMatchObject({ name: 'approver', description: 'ours' })
187
+ })
188
+
189
+ it('refuses when the name stands in both, whatever the operator says', async () => {
190
+ /*
191
+ * ⚠ The state the migration of decision 2 passes through for its whole length: this domain's
192
+ * own role is standing beside the inherited one until the parent is cut, last. Trying to
193
+ * stand a second one during that window must be refused as `here` — there is already one
194
+ * here — and the operator's "stand it separately" must not reach it.
195
+ *
196
+ * It depends on the lookup asking this domain first. A single read over both domains with no
197
+ * ORDER BY can answer with the parent's row, and then the refusal is the inherited one,
198
+ * which `separately` is allowed to pass. That was the same defect this file already pins for
199
+ * `role(name)`, reintroduced in the checking path.
200
+ *
201
+ * ⚠ This case could not see that version of it. sqlite answered the combined query with this
202
+ * domain's row, so the outcome was right by accident — measured 2026-09-20 by putting the old
203
+ * shape back: 448 tests, none red. `role-name-lookup-sentinel.test.ts` reads the query before
204
+ * it is sent, which is where that shape is reachable from here. What this case does catch,
205
+ * now that the order is in our code rather than the driver's, is the order being wrong.
206
+ */
207
+ await roleIn(parent, 'approver', 'theirs')
208
+ await roleIn(child, 'approver', 'ours')
209
+
210
+ expect(
211
+ await refusalCodeOf(mutation.createRole({ name: 'approver', separately: true } as any, context(child)))
212
+ ).toEqual('role-name-taken-here')
213
+ })
214
+
215
+ it('refuses the same way whichever row was written first', async () => {
216
+ await roleIn(child, 'approver', 'ours')
217
+ await roleIn(parent, 'approver', 'theirs')
218
+
219
+ expect(
220
+ await refusalCodeOf(mutation.createRole({ name: 'approver', separately: true } as any, context(child)))
221
+ ).toEqual('role-name-taken-here')
222
+ })
223
+
224
+ it('refuses a name this domain already has, whatever the operator says', async () => {
225
+ /*
226
+ * ⚠ Not something to opt into. Two rows in one domain under one name would be
227
+ * indistinguishable in every respect — nothing for a screen to show, nothing to choose
228
+ * between.
229
+ */
230
+ await roleIn(child, 'approver', 'ours')
231
+
232
+ expect(
233
+ await refusalCodeOf(mutation.createRole({ name: 'approver', separately: true } as any, context(child)))
234
+ ).toEqual('role-name-taken-here')
235
+ })
236
+
237
+ it('leaves a name that stands only in some third domain alone', async () => {
238
+ /* Another tenant's naming does not reach us. */
239
+ await roleIn(orphan, 'approver', 'theirs')
240
+
241
+ const created = await mutation.createRole({ name: 'approver', description: 'ours' } as any, context(child))
242
+
243
+ expect(created).toMatchObject({ description: 'ours' })
244
+ })
245
+
246
+ it('answers this domain own, never the parent', async () => {
247
+ /*
248
+ * ⚠ The case the fix is for. Before it, both rows matched one `In([own, parent])` with no
249
+ * ORDER BY, so the answer was whichever row the driver handed back first.
250
+ */
251
+ await roleIn(parent, 'approver', 'theirs')
252
+ await roleIn(child, 'approver', 'ours')
253
+
254
+ expect(await query.role('approver', context(child))).toMatchObject({ description: 'ours' })
255
+ })
256
+
257
+ it('answers the same way whichever row was written first', async () => {
258
+ /* ⚠ Insertion order is the thing that must stop mattering. */
259
+ await roleIn(child, 'approver', 'ours')
260
+ await roleIn(parent, 'approver', 'theirs')
261
+
262
+ expect(await query.role('approver', context(child))).toMatchObject({ description: 'ours' })
263
+ })
264
+
265
+ it('leaves the parent domain reading its own', async () => {
266
+ /* Shadowing runs one way. The parent is not affected by what a child called something. */
267
+ await roleIn(parent, 'approver', 'theirs')
268
+ await roleIn(child, 'approver', 'ours')
269
+
270
+ expect(await query.role('approver', context(parent))).toMatchObject({ description: 'theirs' })
271
+ })
272
+
273
+ it('still shows both to a screen that lists them', async () => {
274
+ /*
275
+ * ⚠ Deliberate, and the reason the screen has to say where each came from. Resolving a name
276
+ * is not the same act as offering a list to choose from: hiding the parent's row here would
277
+ * silently drop a role somebody may still be assigned.
278
+ */
279
+ await roleIn(parent, 'approver', 'theirs')
280
+ await roleIn(child, 'approver', 'ours')
281
+
282
+ const offered = await query.userRoles(actor.id, context(child))
283
+
284
+ expect(offered.filter(role => role.name === 'approver')).toHaveLength(2)
285
+ })
286
+ })
@@ -86,6 +86,20 @@ async function makeRole(name: string, domain: any, privileges: any[] = [], users
86
86
  return await getRepository(Role).save({ name, domain, privileges, users })
87
87
  }
88
88
 
89
+ /**
90
+ * 거절의 이름을 본다. 영어 문장이 아니라 `code` 가 계약이다(ADR-0054) — 문장은 요청의 언어가
91
+ * 알려진 자리에서 만들어지므로, 문장을 시험에 박으면 번역이 붙는 날 같이 깨진다.
92
+ */
93
+ async function refusalCodeOf(promise: Promise<unknown>): Promise<string> {
94
+ try {
95
+ await promise
96
+ } catch (error: any) {
97
+ return error?.code || error?.extensions?.refusal?.code || `not a refusal: ${error?.message}`
98
+ }
99
+
100
+ return 'nothing was thrown'
101
+ }
102
+
89
103
  describe('고치기', () => {
90
104
  it('권한을 갈아 끼운다', async () => {
91
105
  const role = await makeRole('viewer', child, [boardQuery])
@@ -123,8 +137,8 @@ describe('고치기', () => {
123
137
  await makeRole('viewer', child)
124
138
  const role = await makeRole('editor', child)
125
139
 
126
- await expect(mutation.updateRole(role.id, { name: 'viewer' } as any, context(child))).rejects.toThrow(
127
- 'role name is duplicated'
140
+ expect(await refusalCodeOf(mutation.updateRole(role.id, { name: 'viewer' } as any, context(child)))).toEqual(
141
+ 'role-name-taken-here'
128
142
  )
129
143
  })
130
144
  })
@@ -235,9 +249,9 @@ describe('격자에서 여럿 저장', () => {
235
249
  it('같은 이름으로 만들려 하면 거절한다', async () => {
236
250
  await makeRole('operator', child)
237
251
 
238
- await expect(
239
- mutation.updateMultipleRole([{ cuFlag: '+', name: 'operator' }] as any, context(child))
240
- ).rejects.toThrow('role name is duplicated')
252
+ expect(
253
+ await refusalCodeOf(mutation.updateMultipleRole([{ cuFlag: '+', name: 'operator' }] as any, context(child)))
254
+ ).toEqual('role-name-taken-here')
241
255
  })
242
256
  })
243
257
 
@@ -256,8 +270,8 @@ describe('만들기', () => {
256
270
  it('같은 도메인에 같은 이름은 안 만든다', async () => {
257
271
  await makeRole('operator', child)
258
272
 
259
- await expect(mutation.createRole({ name: 'operator' } as any, context(child))).rejects.toThrow(
260
- 'role name is duplicated'
273
+ expect(await refusalCodeOf(mutation.createRole({ name: 'operator' } as any, context(child)))).toEqual(
274
+ 'role-name-taken-here'
261
275
  )
262
276
  })
263
277
  })
@@ -0,0 +1,79 @@
1
+ import { closeAuthDatabase, Domain, getRepository, openAuthDatabase } from './db'
2
+ import { loadCompiled } from './compiled'
3
+
4
+ const { Role } = loadCompiled('service/role/role')
5
+ const { standingRoleQuery } = loadCompiled('utils/refuse-role-name')
6
+
7
+ /**
8
+ * The read behind the inherited-name check asks **one domain at a time**.
9
+ *
10
+ * ── Why this reads what we send ─────────────────────────────────────────────
11
+ * The check has to ask this domain first and the parent only if it has nothing. One read over
12
+ * both with `In([own, parent])` has no `ORDER BY`, so when the name stands in both, the answer
13
+ * is whichever row the driver hands back — and if that is the parent's, the refusal is the
14
+ * inherited one, which the operator's "stand it separately" is allowed to pass. A second row of
15
+ * the name then stands in a domain that already has it: the state the whole rule exists to
16
+ * prevent, reached through the rule.
17
+ *
18
+ * ⚠ **This harness cannot fail on that.** sqlite returned this domain's row for the combined
19
+ * query, so the outcome was right by accident and every case in
20
+ * `role-inheritance-db.test.ts` stayed green with the defect in place — measured 2026-09-20 by
21
+ * putting the old shape back: 448 tests, none red.
22
+ *
23
+ * So this reads the query **before it is sent**, the way `domain-inheritance-sentinel.test.ts`
24
+ * does for the same reason. What we build is the same on every driver, and that is the only
25
+ * place this class of defect is reachable from here.
26
+ */
27
+
28
+ const OWN = '00000000-0000-4000-8000-000000000002'
29
+ const PARENT = '00000000-0000-4000-8000-000000000001'
30
+
31
+ beforeAll(async () => {
32
+ await openAuthDatabase()
33
+ })
34
+
35
+ afterAll(async () => {
36
+ await closeAuthDatabase()
37
+ })
38
+
39
+ function sent(domainId: string): { sql: string; parameters: any[] } {
40
+ const [sql, parameters] = standingRoleQuery(getRepository(Role), 'approver', domainId).getQueryAndParameters()
41
+
42
+ return { sql, parameters }
43
+ }
44
+
45
+ describe('the query the check sends', () => {
46
+ it('binds the one domain it was asked about', () => {
47
+ expect(sent(OWN).parameters).toContain(OWN)
48
+ })
49
+
50
+ it('never binds a second domain', () => {
51
+ /*
52
+ * ⚠ The assertion that holds the fix. `In([own, parent])` would put both ids in here, and
53
+ * from that moment which one answers is the driver's choice.
54
+ */
55
+ const { parameters } = sent(OWN)
56
+
57
+ expect(parameters.filter(parameter => parameter === PARENT)).toHaveLength(0)
58
+ expect(parameters.filter(parameter => typeof parameter === 'string' && parameter.startsWith('0000'))).toEqual([OWN])
59
+ })
60
+
61
+ it('compares the domain with one value, not a set', () => {
62
+ /* `IN (...)` in the text is the shape of asking about more than one domain at once. */
63
+ expect(sent(OWN).sql).not.toMatch(/ROLE_DOMAIN.*\bIN\b/i)
64
+ })
65
+
66
+ it('asks by name as well, bound rather than written in', () => {
67
+ /* A name is a value: written into the text it would be an injection and a cache miss. */
68
+ const { sql, parameters } = sent(OWN)
69
+
70
+ expect(parameters).toContain('approver')
71
+ expect(sql).not.toContain('approver')
72
+ })
73
+
74
+ it('is the same query for the parent, with the parent id', () => {
75
+ /* The fallback is the same read, not a second shape that could drift from this one. */
76
+ expect(sent(PARENT).parameters).toContain(PARENT)
77
+ expect(sent(PARENT).parameters.filter(parameter => parameter === OWN)).toHaveLength(0)
78
+ })
79
+ })
@@ -0,0 +1,83 @@
1
+ import { nameStands, refusesName } from '../server/utils/role-name-standing'
2
+
3
+ /**
4
+ * Whether a role may take a name that is already standing (ADR-0062 decision 3).
5
+ *
6
+ * The two directions cost different things. Letting an inherited name through silently leaves
7
+ * two rows answering to one name, which is what put an approval line in front of a role nobody
8
+ * looks at. Refusing too widely closes the migration path of decision 2 — stand the role up
9
+ * here, move the people to the role **of the same name**, cut the parent last — and then a
10
+ * domain cannot get off its inherited structure at all.
11
+ *
12
+ * So most of these are about the inherited case and about what the operator is allowed to say.
13
+ */
14
+
15
+ const ROOT = { id: 'domain-root' }
16
+ const CHILD = { id: 'domain-child', parentId: 'domain-root' }
17
+
18
+ describe('where the name stands', () => {
19
+ it('nowhere, when nothing was found', () => {
20
+ expect(nameStands(null, CHILD)).toEqual('nowhere')
21
+ expect(nameStands(undefined, CHILD)).toEqual('nowhere')
22
+ expect(nameStands({}, CHILD)).toEqual('nowhere')
23
+ })
24
+
25
+ it('here, when this domain has it', () => {
26
+ expect(nameStands({ domainId: 'domain-child' }, CHILD)).toEqual('here')
27
+ })
28
+
29
+ it('inherited, when the parent has it', () => {
30
+ expect(nameStands({ domainId: 'domain-root' }, CHILD)).toEqual('inherited')
31
+ })
32
+
33
+ it('nowhere, when the row belongs to some third domain', () => {
34
+ /*
35
+ * ⚠ Another tenant's role is not this domain's business, and reading it as a collision would
36
+ * let one tenant's naming block another's.
37
+ */
38
+ expect(nameStands({ domainId: 'domain-other' }, CHILD)).toEqual('nowhere')
39
+ })
40
+
41
+ it('never says inherited for a domain with no parent', () => {
42
+ /* A root domain inherits from nobody, so `here` is the only collision it can have. */
43
+ expect(nameStands({ domainId: 'domain-root' }, ROOT)).toEqual('here')
44
+ expect(nameStands({ domainId: 'domain-child' }, ROOT)).toEqual('nowhere')
45
+ })
46
+
47
+ it('does not match an absent parent against an absent domain', () => {
48
+ /* ⚠ Two missing values are equal to each other, which would make every row inherited. */
49
+ expect(nameStands({ domainId: null }, { id: 'domain-child', parentId: null })).toEqual('nowhere')
50
+ expect(nameStands({ domainId: undefined }, CHILD)).toEqual('nowhere')
51
+ })
52
+ })
53
+
54
+ describe('whether it refuses', () => {
55
+ it('always, for a name this domain already has', () => {
56
+ /*
57
+ * ⚠ Not something to opt into. Two rows in one domain with one name would be
58
+ * indistinguishable in every respect — there is nothing for a screen to show and nothing for
59
+ * a person to choose between.
60
+ */
61
+ expect(refusesName('here')).toBe(true)
62
+ expect(refusesName('here', true)).toBe(true)
63
+ })
64
+
65
+ it('for an inherited name, unless the operator said this domain stands its own', () => {
66
+ expect(refusesName('inherited')).toBe(true)
67
+ expect(refusesName('inherited', false)).toBe(true)
68
+ expect(refusesName('inherited', true)).toBe(false)
69
+ })
70
+
71
+ it('not at all when the name is free', () => {
72
+ expect(refusesName('nowhere')).toBe(false)
73
+ expect(refusesName('nowhere', true)).toBe(false)
74
+ })
75
+
76
+ it('treats a missing answer as not said', () => {
77
+ /*
78
+ * ⚠ The default has to be refuse. Standing a second role of an inherited name is the
79
+ * deliberate act; a caller that says nothing has not made it.
80
+ */
81
+ expect(refusesName('inherited', undefined)).toBe(true)
82
+ })
83
+ })
@@ -113,9 +113,13 @@ describe('어플라이언스 토큰 (Appliance.generateAccessToken)', () => {
113
113
  describe('애플리케이션 토큰 (Application)', () => {
114
114
  const SCOPES = ['reader', 'approver']
115
115
 
116
+ /* 서명이 받는 것은 이름을 가진 객체다. 맨 문자열을 받던 시절에는 같은 자리에 appSecret 을
117
+ 넣어도 아무 표시가 없었다 — `Application.sign` 의 주석. */
118
+ const CLIENT = { appKey: 'APPKEY' }
119
+
116
120
  it('access token 은 30일, refresh token 은 1년이다', () => {
117
- const access = decode(Application.generateAccessToken(DOMAIN, { id: 'appuser-1' }, 'APPKEY', SCOPES))
118
- const refresh = decode(Application.generateRefreshToken(DOMAIN, { id: 'appuser-1' }, 'APPKEY', SCOPES))
121
+ const access = decode(Application.generateAccessToken(DOMAIN, { id: 'appuser-1' }, CLIENT, SCOPES))
122
+ const refresh = decode(Application.generateRefreshToken(DOMAIN, { id: 'appuser-1' }, CLIENT, SCOPES))
119
123
 
120
124
  expect(lifetime(access.payload)).toBe(30 * DAY)
121
125
  expect(lifetime(refresh.payload)).toBe(YEAR)
@@ -127,7 +131,7 @@ describe('애플리케이션 토큰 (Application)', () => {
127
131
  /* `oauth2-server.ts:131` 이 `roles: scopes` 로 저장하고 `:240` 이 `name: In(scopes)` 로
128
132
  역할을 찾는다. 즉 scope 어휘가 따로 없고 역할 이름이 곧 scope 다 — 역할 이름을 바꾸면
129
133
  이미 발급된 토큰의 scope 가 조용히 뜻을 잃는다. */
130
- const { payload } = decode(Application.generateAccessToken(DOMAIN, { id: 'appuser-1' }, 'APPKEY', SCOPES))
134
+ const { payload } = decode(Application.generateAccessToken(DOMAIN, { id: 'appuser-1' }, CLIENT, SCOPES))
131
135
 
132
136
  expect(payload).toMatchObject({
133
137
  userType: 'application',
@@ -11,6 +11,8 @@
11
11
  "error.password should be supported": "initial password or default password should be supported",
12
12
  "error.password should match the rule": "password should match following rule. ${rule}",
13
13
  "error.password used in the past": "password used in the past",
14
+ "error.role-name-stands-inherited": "a role called {name} is already visible here, defined in {domain}. stand this domain's own of that name only if that is what you mean to do.",
15
+ "error.role-name-taken-here": "this domain already has a role called {name}.",
14
16
  "error.subdomain not found": "domain not found",
15
17
  "error.token or password is invalid": "token or password is invalid",
16
18
  "error.unavailable-domain": "unavailable domain",
@@ -11,6 +11,8 @@
11
11
  "error.password should be supported": "初期パスワードまたはデフォルトパスワードがサポートされるべきです",
12
12
  "error.password should match the rule": "パスワードは次の規則を守らなければなりません. {rule}",
13
13
  "error.password used in the past": "過去に使用されたパスワードです.",
14
+ "error.role-name-stands-inherited": "{name} ロールは {domain} に定義されており、ここからも見えています。このドメインに同じ名前で別に立てるつもりの場合のみ作成します。",
15
+ "error.role-name-taken-here": "このドメインには既に {name} ロールがあります。",
14
16
  "error.subdomain not found": "サブドメインが見つかりません.",
15
17
  "error.token or password is invalid": "トークンまたはパスワードが無効です.",
16
18
  "error.unavailable-domain": "使用できないドメインです.",
@@ -11,6 +11,8 @@
11
11
  "error.password should be supported": "초기 비밀번호나 디폴트 비밀번호가 제공되어야 합니다.",
12
12
  "error.password should match the rule": "비밀번호는 다음 규칙을 지켜야 합니다. {rule}",
13
13
  "error.password used in the past": "과거에 사용된 비밀번호입니다.",
14
+ "error.role-name-stands-inherited": "{name} 역할이 {domain} 에 정의되어 여기에서도 보입니다. 이 도메인에 같은 이름으로 따로 세우려는 것이 맞을 때만 세웁니다.",
15
+ "error.role-name-taken-here": "이 도메인에 이미 {name} 역할이 있습니다.",
14
16
  "error.subdomain not found": "서브도메인을 찾을 수 없습니다.",
15
17
  "error.token or password is invalid": "토큰 또는 비밀번호가 유효하지 않습니다.",
16
18
  "error.unavailable-domain": "사용할 수 없는 도메인입니다.",
@@ -11,6 +11,8 @@
11
11
  "error.password should be supported": "kata laluan awal atau kata laluan lalai harus disokong",
12
12
  "error.password should match the rule": "Kata laluan harus mematuhi peraturan berikut. ${rule}",
13
13
  "error.password used in the past": "Kata laluan telah digunakan dalam masa lampau",
14
+ "error.role-name-stands-inherited": "peranan bernama {name} ditakrifkan dalam {domain} dan kelihatan di sini juga. Bina milik domain ini dengan nama yang sama hanya jika itu memang yang dimaksudkan.",
15
+ "error.role-name-taken-here": "domain ini sudah mempunyai peranan bernama {name}.",
14
16
  "error.subdomain not found": "Domain tidak ditemui",
15
17
  "error.token or password is invalid": "Token atau kata laluan tidak sah",
16
18
  "error.unavailable-domain": "Domain tidak tersedia",
@@ -11,6 +11,8 @@
11
11
  "error.password should be supported": "应支持初始密码或默认密码",
12
12
  "error.password should match the rule": "密码应符合以下规则。${rule}",
13
13
  "error.password used in the past": "使用过的密码!",
14
+ "error.role-name-stands-inherited": "名为 {name} 的角色定义在 {domain},在此处也可见。只有确实要在该域中另行建立同名角色时才创建。",
15
+ "error.role-name-taken-here": "该域中已有名为 {name} 的角色。",
14
16
  "error.subdomain not found": "用户域查询失败!",
15
17
  "error.token or password is invalid": "令牌或密码无效!",
16
18
  "error.unavailable-domain": "不可用的域名",