@things-factory/auth-base 10.1.6 → 10.1.7

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 (35) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist-server/index.js +8 -0
  3. package/dist-server/index.js.map +1 -1
  4. package/dist-server/service/index.d.ts +3 -1
  5. package/dist-server/service/index.js +15 -11
  6. package/dist-server/service/index.js.map +1 -1
  7. package/dist-server/service/privilege/privilege-directive.d.ts +10 -41
  8. package/dist-server/service/privilege/privilege-directive.js +14 -74
  9. package/dist-server/service/privilege/privilege-directive.js.map +1 -1
  10. package/dist-server/service/role/role-mutation.js +2 -2
  11. package/dist-server/service/role/role-mutation.js.map +1 -1
  12. package/dist-server/service/role/role-query.d.ts +43 -1
  13. package/dist-server/service/role/role-query.js +76 -33
  14. package/dist-server/service/role/role-query.js.map +1 -1
  15. package/dist-server/service/role-template/index.d.ts +4 -0
  16. package/dist-server/service/role-template/index.js +9 -0
  17. package/dist-server/service/role-template/index.js.map +1 -0
  18. package/dist-server/service/role-template/role-template-mutation.d.ts +39 -0
  19. package/dist-server/service/role-template/role-template-mutation.js +124 -0
  20. package/dist-server/service/role-template/role-template-mutation.js.map +1 -0
  21. package/dist-server/service/role-template/role-template-query.d.ts +11 -0
  22. package/dist-server/service/role-template/role-template-query.js +79 -0
  23. package/dist-server/service/role-template/role-template-query.js.map +1 -0
  24. package/dist-server/service/role-template/role-template-types.d.ts +51 -0
  25. package/dist-server/service/role-template/role-template-types.js +71 -0
  26. package/dist-server/service/role-template/role-template-types.js.map +1 -0
  27. package/dist-server/service/role-template/role-template.d.ts +110 -0
  28. package/dist-server/service/role-template/role-template.js +129 -0
  29. package/dist-server/service/role-template/role-template.js.map +1 -0
  30. package/dist-server/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +4 -4
  32. package/tests/privilege-directive.test.ts +1 -91
  33. package/tests/role-privileges-db.test.ts +98 -0
  34. package/tests/role-template-seed-db.test.ts +179 -0
  35. package/tests/role-template.test.ts +126 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/auth-base",
3
- "version": "10.1.6",
3
+ "version": "10.1.7",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/index.js",
6
6
  "things-factory": true,
@@ -34,9 +34,9 @@
34
34
  "@reduxjs/toolkit": "^2.2.5",
35
35
  "@simplewebauthn/browser": "^13.0.0",
36
36
  "@simplewebauthn/server": "^13.0.0",
37
- "@things-factory/email-base": "^10.1.4",
37
+ "@things-factory/email-base": "^10.1.7",
38
38
  "@things-factory/env": "^10.1.3",
39
- "@things-factory/shell": "^10.1.4",
39
+ "@things-factory/shell": "^10.1.7",
40
40
  "@things-factory/utils": "^10.1.3",
41
41
  "@types/webappsec-credential-management": "^0.6.9",
42
42
  "jsonwebtoken": "^9.0.0",
@@ -48,5 +48,5 @@
48
48
  "passport-jwt": "^4.0.0",
49
49
  "passport-local": "^1.0.0"
50
50
  },
51
- "gitHead": "963aeb9ac470be5aea05bb5842a0a92afcab6036"
51
+ "gitHead": "9a036f83f5751f9e4af9180e9a0018f716a4ba27"
52
52
  }
@@ -13,7 +13,6 @@
13
13
 
14
14
  import { graphql } from 'graphql'
15
15
  import { makeExecutableSchema } from '@graphql-tools/schema'
16
- import { logger } from '@things-factory/env'
17
16
 
18
17
  import { loadCompiled } from './compiled'
19
18
 
@@ -34,7 +33,6 @@ const typeDefs = `
34
33
  guardedBoard: String @privilege(category: "board", privilege: "mutation")
35
34
  ownershipOnly: String @privilege(domainOwnerGranted: true)
36
35
  undeclared: String
37
- moved: String @privilege(category: "ai-assistant", privilege: "mutation", legacy: "board-ai")
38
36
  }
39
37
  `
40
38
 
@@ -45,8 +43,7 @@ function buildSchema() {
45
43
  Query: {
46
44
  guardedBoard: () => 'board',
47
45
  ownershipOnly: () => 'ownership',
48
- undeclared: () => 'undeclared',
49
- moved: () => 'moved'
46
+ undeclared: () => 'undeclared'
50
47
  }
51
48
  }
52
49
  })
@@ -54,13 +51,6 @@ function buildSchema() {
54
51
  return privilegeDirectiveResolver(schema)
55
52
  }
56
53
 
57
- /** 이 category 들만 가진 사람. `hasPrivilege(privilege, category, ...)` 순서를 그대로 받는다. */
58
- function granted(...categories: string[]) {
59
- User.hasPrivilege = jest.fn().mockImplementation(async (_privilege: string, category: string) =>
60
- categories.includes(category)
61
- )
62
- }
63
-
64
54
  function run(schema: any, field: string, state: any = {}) {
65
55
  return graphql({
66
56
  schema,
@@ -173,74 +163,6 @@ describe('안전하지 않은 IP 가 이음매를 지나 게이트까지 간다'
173
163
  })
174
164
  })
175
165
 
176
- /**
177
- * 권한 이름을 옮기는 중.
178
- *
179
- * 옛 이름을 받아 주지 않으면 옮긴 그날 그 기능을 쓰던 사람들이 전부 막힌다. 그런데 옛 부여를
180
- * 역할 행에 베껴 넣으면 조용히 권한이 번지고 되돌릴 근거가 없어진다. 그래서 **행은 그대로 두고
181
- * 문에서 한동안 받아 준다.**
182
- */
183
- describe('옮기는 중인 권한', () => {
184
- it('새 이름을 가졌으면 열린다', async () => {
185
- granted('ai-assistant')
186
-
187
- const result = await run(buildSchema(), 'moved')
188
-
189
- expect(result.errors).toBeUndefined()
190
- expect(result.data).toEqual({ moved: 'moved' })
191
- })
192
-
193
- it('새 이름이 없고 옛 이름만 있어도 열린다 — 옮긴 날 아무도 막히지 않는다', async () => {
194
- granted('board-ai')
195
-
196
- const result = await run(buildSchema(), 'moved')
197
-
198
- expect(result.errors).toBeUndefined()
199
- expect(result.data).toEqual({ moved: 'moved' })
200
- })
201
-
202
- it('둘 다 없으면 거절하고, 필요한 것은 새 이름으로 말한다', async () => {
203
- granted()
204
-
205
- const result = await run(buildSchema(), 'moved')
206
-
207
- expect(result.data).toEqual({ moved: null })
208
- expect(result.errors).toHaveLength(1)
209
- /* 옛 이름을 말하면 관리자가 곧 사라질 권한을 부여한다. */
210
- expect((result.errors![0].extensions as any).requires).toEqual({
211
- category: 'ai-assistant',
212
- privilege: 'mutation'
213
- })
214
- })
215
-
216
- it('금지 목록이 새 쌍을 막고 있으면 옛 이름으로도 열리지 않는다', async () => {
217
- /*
218
- * 금지 목록은 옛 이름을 모른다. 별칭이 금지를 되돌리면 「이 IP 에서는 이것을 못 한다」는
219
- * 설정이 조용히 무력해진다.
220
- */
221
- granted('board-ai')
222
-
223
- const result = await run(buildSchema(), 'moved', {
224
- unsafeIP: true,
225
- prohibitedPrivileges: [{ category: 'ai-assistant', privilege: 'mutation' }]
226
- })
227
-
228
- expect(result.errors).toHaveLength(1)
229
- })
230
-
231
- it('옛 이름을 부른 것을 로그에 남긴다 — 누가 기대고 있는지가 부여의 근거다', async () => {
232
- granted('board-ai')
233
-
234
- const warn = jest.spyOn(logger, 'warn')
235
-
236
- await run(buildSchema(), 'moved')
237
-
238
- expect(warn).toHaveBeenCalled()
239
- expect(warn.mock.calls[0][0]).toContain('board-ai:mutation')
240
- warn.mockRestore()
241
- })
242
- })
243
-
244
166
  describe('스키마를 지으면서 남기는 것', () => {
245
167
  it('선언한 (category, privilege) 를 전역 레지스트리에 등록한다', () => {
246
168
  /* 부팅 때 없는 권한 행을 만드는 근거다 — 마이그레이션도 수동 생성도 필요 없다. */
@@ -249,18 +171,6 @@ describe('스키마를 지으면서 남기는 것', () => {
249
171
  expect((process as any).PRIVILEGES['board mutation']).toEqual(['board', 'mutation'])
250
172
  })
251
173
 
252
- it('옛 이름은 등록하지 않는다 — 그래서 옛 권한 행이 유령으로 보인다', () => {
253
- /*
254
- * `isDeprecatedPrivilege` 는 「레지스트리에 없으면 유령」으로 판정한다
255
- * (`privilege-deprecation.ts`). 옛 쌍을 등록하면 관리자 목록에서 옛 행이 정상으로 보여
256
- * **별칭을 지울 시점을 아무도 못 본다.** 이 시험이 그 신호를 지킨다.
257
- */
258
- buildSchema()
259
-
260
- expect((process as any).PRIVILEGES['ai-assistant mutation']).toEqual(['ai-assistant', 'mutation'])
261
- expect((process as any).PRIVILEGES['board-ai mutation']).toBeUndefined()
262
- })
263
-
264
174
  it('필드 설명에 필요한 권한을 적어 둔다', async () => {
265
175
  const schema = buildSchema()
266
176
  const description = (schema.getQueryType() as any).getFields().guardedBoard.description
@@ -18,6 +18,7 @@ import { loadCompiled } from './compiled'
18
18
 
19
19
  const { Role } = loadCompiled('service/role/role')
20
20
  const { Privilege } = loadCompiled('service/privilege/privilege')
21
+ const { User } = loadCompiled('service/user/user')
21
22
  const { RoleQuery } = loadCompiled('service/role/role-query')
22
23
 
23
24
  const query = new RoleQuery()
@@ -38,6 +39,10 @@ function ask(roleId: string, domain: any) {
38
39
  return query.rolePrivileges(roleId, { state: { domain } } as any)
39
40
  }
40
41
 
42
+ function askUserRoles(userId: string, domain: any) {
43
+ return query.userRoles(userId, { state: { domain } } as any)
44
+ }
45
+
41
46
  beforeAll(async () => {
42
47
  await openAuthDatabase()
43
48
  })
@@ -126,3 +131,96 @@ describe('부러지지 않는다', () => {
126
131
  expect(await ask("' OR 1=1 --", child)).toEqual([])
127
132
  })
128
133
  })
134
+
135
+ /**
136
+ * 목록이 쓰는 **수**.
137
+ *
138
+ * 목록이 「이 역할이 무엇을 여나」를 말하려고 권한 **행**을 받고 있었다 — 역할 20개에
139
+ * 5,731 bytes · 69ms 대 518 bytes · 7ms(UX 레인 실측). 행을 안 받고 수를 받는다.
140
+ */
141
+ describe('권한 수', () => {
142
+ it('가진 것만 센다', async () => {
143
+ const role = await getRepository(Role).save({ name: 'viewer', domain: child, privileges: [boardQuery] })
144
+
145
+ expect(await query.privilegeCount(role)).toBe(1)
146
+ })
147
+
148
+ it('하나도 없으면 0 이다 — 「모른다」가 아니라 0 이다', async () => {
149
+ const role = await getRepository(Role).save({ name: 'blank', domain: child, privileges: [] })
150
+
151
+ expect(await query.privilegeCount(role)).toBe(0)
152
+ })
153
+
154
+ it('다른 역할의 권한을 세지 않는다', async () => {
155
+ await getRepository(Role).save({ name: 'other', domain: child, privileges: [boardQuery, boardMutation] })
156
+ const mine = await getRepository(Role).save({ name: 'mine', domain: child, privileges: [boardQuery] })
157
+
158
+ expect(await query.privilegeCount(mine)).toBe(1)
159
+ })
160
+ })
161
+
162
+ /**
163
+ * 한 사람의 역할 — `rolePrivileges` 와 **같은 결함을 갖고 있던** 자리.
164
+ *
165
+ * 원시 SQL 로 `userId` 를 이어 붙이고, MSSQL 에 없는 `THEN true` 를 쓰고, `FROM roles` 로
166
+ * **도메인을 아예 안 봤다.** 한 자리를 고쳤으면 두 번째 자리도 재야 한다.
167
+ */
168
+ describe('사용자의 역할', () => {
169
+ async function makeUser(roles: any[]) {
170
+ const salt = User.generateSalt()
171
+ return await getRepository(User).save({
172
+ username: `u-${Math.random().toString(36).slice(2, 8)}`,
173
+ email: `u-${Math.random().toString(36).slice(2, 8)}@example.com`,
174
+ name: 'Kim',
175
+ salt,
176
+ status: 'activated',
177
+ domains: [child],
178
+ roles
179
+ })
180
+ }
181
+
182
+ it('부여할 수 있는 전체를 주고, 가진 것에만 표시한다', async () => {
183
+ const held = await getRepository(Role).save({ name: 'held', domain: child })
184
+ await getRepository(Role).save({ name: 'free', domain: child })
185
+ const user = await makeUser([held])
186
+
187
+ const rows = await askUserRoles(user.id, child)
188
+
189
+ expect(rows).toHaveLength(2)
190
+ expect(rows.find(row => row.id === held.id).assigned).toBe(true)
191
+ expect(rows.find(row => row.name === 'free').assigned).toBe(false)
192
+ })
193
+
194
+ it('부모 도메인의 역할도 준다 — 상속이 부여 범위다', async () => {
195
+ await getRepository(Role).save({ name: 'shared', domain: parent })
196
+ const user = await makeUser([])
197
+
198
+ expect((await askUserRoles(user.id, child)).map((row: any) => row.name)).toEqual(['shared'])
199
+ })
200
+
201
+ it('남의 도메인 역할은 주지 않는다 — 예전에는 전부 줬다', async () => {
202
+ await getRepository(Role).save({ name: 'theirs', domain: stranger })
203
+ await getRepository(Role).save({ name: 'mine', domain: child })
204
+ const user = await makeUser([])
205
+
206
+ expect((await askUserRoles(user.id, child)).map((row: any) => row.name)).toEqual(['mine'])
207
+ })
208
+
209
+ it('없는 사용자에게도 목록을 준다 — 가진 것이 0 인 것과 같다', async () => {
210
+ await getRepository(Role).save({ name: 'mine', domain: child })
211
+
212
+ const rows = await askUserRoles('00000000-0000-0000-0000-000000000000', child)
213
+
214
+ expect(rows).toHaveLength(1)
215
+ expect(rows[0].assigned).toBe(false)
216
+ })
217
+
218
+ it('id 자리에 SQL 을 넣어도 던지지 않는다', async () => {
219
+ await getRepository(Role).save({ name: 'mine', domain: child })
220
+
221
+ const rows = await askUserRoles("' OR 1=1 --", child)
222
+
223
+ expect(rows).toHaveLength(1)
224
+ expect(rows[0].assigned).toBe(false)
225
+ })
226
+ })
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Seeding a role from a template — against a real database.
3
+ *
4
+ * `role-template.test.ts` covers what the registry accepts. What it cannot cover is the
5
+ * part that touches rows, and that is where the rules with teeth are:
6
+ *
7
+ * - the plant names the seat, not the template;
8
+ * - an existing name is reused rather than duplicated, because approval lines hold a
9
+ * role's id and a second row would leave them pointing at the one nobody edits;
10
+ * - two templates on one name make the union, and privileges are never taken away;
11
+ * - a privilege this installation does not declare is skipped, never invented.
12
+ */
13
+
14
+ import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
15
+ import { authDataSource } from './db'
16
+ import { loadCompiled } from './compiled'
17
+
18
+ const { Role } = loadCompiled('service/role/role')
19
+ const { Privilege } = loadCompiled('service/privilege/privilege')
20
+ const { User } = loadCompiled('service/user/user')
21
+ const { RoleTemplateMutation } = loadCompiled('service/role-template/role-template-mutation')
22
+ const { registerRoleTemplate } = loadCompiled('service/role-template/role-template')
23
+
24
+ const mutation = new RoleTemplateMutation()
25
+
26
+ let domain: any
27
+ let actor: any
28
+ let boardQuery: any
29
+ let boardMutation: any
30
+
31
+ function context() {
32
+ return {
33
+ state: { domain, user: actor, tx: authDataSource().manager }
34
+ } as any
35
+ }
36
+
37
+ async function privilegesOf(id: string): Promise<string[]> {
38
+ const role = await getRepository(Role).findOne({ where: { id }, relations: ['privileges'] })
39
+
40
+ return (role!.privileges || []).map((one: any) => `${one.category}:${one.name}`).sort()
41
+ }
42
+
43
+ beforeAll(async () => {
44
+ await openAuthDatabase()
45
+ })
46
+
47
+ afterAll(async () => {
48
+ await closeAuthDatabase()
49
+ })
50
+
51
+ beforeEach(async () => {
52
+ await resetAuthDatabase()
53
+ ;(process as any)['ROLE_TEMPLATES'] = {}
54
+
55
+ const saved = await getRepository(Domain).save({ name: 'Plant A', subdomain: 'plant-a' })
56
+ domain = await getRepository(Domain).findOneBy({ id: saved.id })
57
+
58
+ const privilegeRepository = getRepository(Privilege)
59
+ boardQuery = await privilegeRepository.save({ name: 'query', category: 'board' })
60
+ boardMutation = await privilegeRepository.save({ name: 'mutation', category: 'board' })
61
+
62
+ /* The gate reads this; seeding reads the rows above. Both have to agree. */
63
+ ;(process as any).PRIVILEGES = {
64
+ 'board query': ['board', 'query'],
65
+ 'board mutation': ['board', 'mutation']
66
+ }
67
+
68
+ actor = await getRepository(User).save({
69
+ username: 'admin',
70
+ email: 'admin@example.com',
71
+ name: 'Admin',
72
+ salt: User.generateSalt(),
73
+ status: 'activated',
74
+ domains: [domain]
75
+ })
76
+ })
77
+
78
+ describe('seeding', () => {
79
+ it('creates the role with the privileges the template names', async () => {
80
+ registerRoleTemplate({
81
+ id: 'plant.qi',
82
+ name: 'QUALITY-INSPECTOR',
83
+ grants: [{ category: 'board', axes: ['query'] }]
84
+ })
85
+
86
+ const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
87
+
88
+ expect(role.name).toBe('QUALITY-INSPECTOR')
89
+ expect(await privilegesOf(role.id)).toEqual(['board:query'])
90
+ })
91
+
92
+ it('lets the plant name the seat itself', async () => {
93
+ registerRoleTemplate({ id: 'plant.qi', name: 'QUALITY-INSPECTOR', grants: [{ category: 'board', axes: ['query'] }] })
94
+
95
+ const role = await mutation.createRoleFromTemplate('plant.qi', '품질검사원', context())
96
+
97
+ expect(role.name).toBe('품질검사원')
98
+ expect(await privilegesOf(role.id)).toEqual(['board:query'])
99
+ })
100
+
101
+ it('makes a second seat from one template when the name differs — day shift and night shift', async () => {
102
+ registerRoleTemplate({ id: 'plant.qi', name: 'QUALITY-INSPECTOR', grants: [{ category: 'board', axes: ['query'] }] })
103
+
104
+ const day = await mutation.createRoleFromTemplate('plant.qi', 'QI-DAY', context())
105
+ const night = await mutation.createRoleFromTemplate('plant.qi', 'QI-NIGHT', context())
106
+
107
+ expect(day.id).not.toBe(night.id)
108
+ expect(await privilegesOf(day.id)).toEqual(['board:query'])
109
+ expect(await privilegesOf(night.id)).toEqual(['board:query'])
110
+ })
111
+
112
+ it('reuses an existing role of that name instead of creating a second one', async () => {
113
+ registerRoleTemplate({ id: 'plant.qi', name: 'QUALITY-INSPECTOR', grants: [{ category: 'board', axes: ['query'] }] })
114
+
115
+ const existing = await getRepository(Role).save({ name: 'QUALITY-INSPECTOR', domain, privileges: [] })
116
+ const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
117
+
118
+ expect(role.id).toBe(existing.id)
119
+ expect(await getRepository(Role).count()).toBe(1)
120
+ })
121
+
122
+ it('adds the union when two applications declare the same seat', async () => {
123
+ registerRoleTemplate({ id: 'plant.qi', name: 'QUALITY-INSPECTOR', grants: [{ category: 'board', axes: ['query'] }] })
124
+ registerRoleTemplate({
125
+ id: 'twin.qi',
126
+ name: 'QUALITY-INSPECTOR',
127
+ grants: [{ category: 'board', axes: ['mutation'] }]
128
+ })
129
+
130
+ await mutation.createRoleFromTemplate('plant.qi', undefined, context())
131
+ const role = await mutation.createRoleFromTemplate('twin.qi', undefined, context())
132
+
133
+ expect(await privilegesOf(role.id)).toEqual(['board:mutation', 'board:query'])
134
+ expect(await getRepository(Role).count()).toBe(1)
135
+ })
136
+
137
+ it('leaves privileges the administrator added, and takes nothing away', async () => {
138
+ registerRoleTemplate({ id: 'plant.qi', name: 'QUALITY-INSPECTOR', grants: [{ category: 'board', axes: ['query'] }] })
139
+
140
+ const existing = await getRepository(Role).save({
141
+ name: 'QUALITY-INSPECTOR',
142
+ domain,
143
+ privileges: [boardMutation]
144
+ })
145
+
146
+ await mutation.createRoleFromTemplate('plant.qi', undefined, context())
147
+
148
+ expect(await privilegesOf(existing.id)).toEqual(['board:mutation', 'board:query'])
149
+ })
150
+
151
+ it('skips a privilege this installation does not declare rather than inventing the row', async () => {
152
+ registerRoleTemplate({
153
+ id: 'plant.x',
154
+ name: 'X',
155
+ grants: [
156
+ { category: 'board', axes: ['query'] },
157
+ { category: 'board-ai', axes: ['query'] }
158
+ ]
159
+ })
160
+
161
+ const role = await mutation.createRoleFromTemplate('plant.x', undefined, context())
162
+
163
+ expect(await privilegesOf(role.id)).toEqual(['board:query'])
164
+ expect(await getRepository(Privilege).count()).toBe(2)
165
+ })
166
+
167
+ it('refuses a template this installation does not have', async () => {
168
+ await expect(mutation.createRoleFromTemplate('plant.missing', undefined, context())).rejects.toThrow(
169
+ /no role template/
170
+ )
171
+ })
172
+
173
+ it('seeds the framework-computed administrator without any application declaring it', async () => {
174
+ const role = await mutation.createRoleFromTemplate('framework.administrator', undefined, context())
175
+
176
+ expect(role.name).toBe('ADMINISTRATOR')
177
+ expect(await privilegesOf(role.id)).toEqual(['board:mutation', 'board:query'])
178
+ })
179
+ })
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Role templates — the registry and what it refuses.
3
+ *
4
+ * The refusals are the interesting part. A template that registers happily and then grants
5
+ * nothing is the quiet failure this design exists to avoid, so the checks that make noise
6
+ * are the ones pinned here.
7
+ */
8
+
9
+ import { loadCompiled } from './compiled'
10
+
11
+ const {
12
+ registerRoleTemplate,
13
+ roleTemplates,
14
+ roleTemplateById,
15
+ computedRoleTemplates,
16
+ allRoleTemplates,
17
+ unknownTemplateGrants
18
+ } = loadCompiled('service/role-template/role-template')
19
+
20
+ beforeEach(() => {
21
+ ;(process as any)['ROLE_TEMPLATES'] = {}
22
+ ;(process as any).PRIVILEGES = {
23
+ 'board query': ['board', 'query'],
24
+ 'board mutation': ['board', 'mutation'],
25
+ 'order query': ['order', 'query'],
26
+ 'worklist query': ['worklist', 'query'],
27
+ 'worklist mutation': ['worklist', 'mutation']
28
+ }
29
+ })
30
+
31
+ describe('declaring a template', () => {
32
+ it('can be found again by id', () => {
33
+ registerRoleTemplate({
34
+ id: 'plant.production-lead',
35
+ name: 'PRODUCTION-LEAD',
36
+ grants: [{ category: 'worklist', axes: ['query', 'mutation'] }]
37
+ })
38
+
39
+ expect(roleTemplateById('plant.production-lead')!.name).toBe('PRODUCTION-LEAD')
40
+ expect(roleTemplates()).toHaveLength(1)
41
+ })
42
+ })
43
+
44
+ describe('what registration refuses', () => {
45
+ it('refuses a template with no grants written at all', () => {
46
+ expect(() => registerRoleTemplate({ id: 'x', name: 'X' } as any)).toThrow(/no grants/)
47
+ })
48
+
49
+ it('accepts an empty list — a seat that opens nothing on purpose, for approval routing', () => {
50
+ registerRoleTemplate({ id: 'twin.quality-inspector', name: 'QUALITY-INSPECTOR', grants: [] })
51
+
52
+ expect(roleTemplateById('twin.quality-inspector')!.grants).toEqual([])
53
+ })
54
+
55
+ it('refuses a grant with no axes', () => {
56
+ expect(() => registerRoleTemplate({ id: 'x', name: 'X', grants: [{ category: 'board', axes: [] }] })).toThrow(
57
+ /no axes/
58
+ )
59
+ })
60
+
61
+ it('refuses the same id twice', () => {
62
+ registerRoleTemplate({ id: 'x', name: 'X', grants: [] })
63
+
64
+ expect(() => registerRoleTemplate({ id: 'x', name: 'Y', grants: [] })).toThrow(/declared twice/)
65
+ })
66
+
67
+ it('accepts the same role name from two applications — one seat, declared twice', () => {
68
+ registerRoleTemplate({ id: 'plant.qi', name: 'QUALITY-INSPECTOR', grants: [{ category: 'board', axes: ['query'] }] })
69
+ registerRoleTemplate({ id: 'twin.qi', name: 'QUALITY-INSPECTOR', grants: [] })
70
+
71
+ expect(roleTemplates().filter(one => one.name === 'QUALITY-INSPECTOR')).toHaveLength(2)
72
+ })
73
+ })
74
+
75
+ describe('names this installation does not declare', () => {
76
+ it('points at grants that would silently open nothing after a category rename', () => {
77
+ registerRoleTemplate({
78
+ id: 'plant.x',
79
+ name: 'X',
80
+ grants: [
81
+ { category: 'board', axes: ['query'] },
82
+ { category: 'board-ai', axes: ['query', 'mutation'] }
83
+ ]
84
+ })
85
+
86
+ expect(unknownTemplateGrants(roleTemplateById('plant.x')!)).toEqual(['board-ai:query', 'board-ai:mutation'])
87
+ })
88
+
89
+ it('reports nothing when every named privilege is installed', () => {
90
+ registerRoleTemplate({ id: 'plant.y', name: 'Y', grants: [{ category: 'order', axes: ['query'] }] })
91
+
92
+ expect(unknownTemplateGrants(roleTemplateById('plant.y')!)).toEqual([])
93
+ })
94
+ })
95
+
96
+ describe('the two templates the framework computes', () => {
97
+ it('gives the viewer every category that declares a query axis', () => {
98
+ const viewer = computedRoleTemplates().find(one => one.id === 'framework.viewer')!
99
+
100
+ expect(viewer.grants.map(grant => grant.category).sort()).toEqual(['board', 'order', 'worklist'])
101
+ expect(viewer.grants.every(grant => grant.axes.join() === 'query')).toBe(true)
102
+ })
103
+
104
+ it('gives the administrator everything declared', () => {
105
+ const administrator = computedRoleTemplates().find(one => one.id === 'framework.administrator')!
106
+ const pairs = administrator.grants.flatMap(grant => grant.axes.map(axis => `${grant.category}:${axis}`)).sort()
107
+
108
+ expect(pairs).toEqual(['board:mutation', 'board:query', 'order:query', 'worklist:mutation', 'worklist:query'])
109
+ })
110
+
111
+ it('widens only the administrator when a new axis is declared', () => {
112
+ ;(process as any).PRIVILEGES['board execute'] = ['board', 'execute']
113
+
114
+ const administrator = computedRoleTemplates().find(one => one.id === 'framework.administrator')!
115
+ const viewer = computedRoleTemplates().find(one => one.id === 'framework.viewer')!
116
+
117
+ expect(administrator.grants.find(grant => grant.category === 'board')!.axes).toContain('execute')
118
+ expect(viewer.grants.find(grant => grant.category === 'board')!.axes).toEqual(['query'])
119
+ })
120
+
121
+ it('offers the computed two alongside whatever applications declared', () => {
122
+ registerRoleTemplate({ id: 'plant.z', name: 'Z', grants: [] })
123
+
124
+ expect(allRoleTemplates().map(one => one.id)).toEqual(['framework.viewer', 'framework.administrator', 'plant.z'])
125
+ })
126
+ })