@things-factory/auth-base 10.1.31 → 10.1.34

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 (111) 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
  109. package/dist-server/controllers/checkin.d.ts +0 -4
  110. package/dist-server/controllers/checkin.js +0 -20
  111. package/dist-server/controllers/checkin.js.map +0 -1
@@ -0,0 +1,416 @@
1
+ import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
2
+ import { loadCompiled } from './compiled'
3
+
4
+ const { Invitation } = loadCompiled('service/invitation/invitation')
5
+ const { InvitationMutation } = loadCompiled('service/invitation/invitation-mutation')
6
+ const { InvitationQuery } = loadCompiled('service/invitation/invitation-query')
7
+ const { Privilege } = loadCompiled('service/privilege/privilege')
8
+ const { Role } = loadCompiled('service/role/role')
9
+ const { User, UserStatus } = loadCompiled('service/user/user')
10
+ const { acceptInvitation } = loadCompiled('controllers/invitation')
11
+ const { InvitationStatus } = loadCompiled('utils/invitation-state')
12
+
13
+ const mutation = new InvitationMutation()
14
+ const query = new InvitationQuery()
15
+
16
+ /**
17
+ * Inviting somebody into a domain, and their accepting it.
18
+ *
19
+ * ── What this replaced ──────────────────────────────────────────────────────
20
+ * `invitation-surface-db.test.ts` stood here and pinned the old shape: a row with no domain, no
21
+ * status and no expiry, read by `{ email, reference, type }` from any tenant, deleted the moment
22
+ * anybody accepted. It existed so that nobody could build on `reference` or remove it quietly.
23
+ * It failed the moment this was built, which is what it was for.
24
+ *
25
+ * ── The token ───────────────────────────────────────────────────────────────
26
+ * It travels in the link's fragment, so the server never sees it in a request line. Accepting
27
+ * asks for the invited address too, because a forwarded mail is the one way the token can still
28
+ * reach somebody else and no amount of hiding closes that.
29
+ *
30
+ * ⚠ sqlite (`tests/db.ts`). Mail is not sent here — `@things-factory/email-base` is stubbed by
31
+ * the harness the same way the other mailing paths are, and what is asserted is the row.
32
+ */
33
+
34
+ let ours: any
35
+ let theirs: any
36
+ let admin: any
37
+
38
+ function context(d: any) {
39
+ return {
40
+ state: { domain: d, user: admin, tx: undefined },
41
+ lng: 'en',
42
+ headers: {},
43
+ request: { protocol: 'http', host: 'acme.test' },
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
+ async function refusalCodeOf(promise: Promise<unknown>): Promise<string> {
53
+ try {
54
+ await promise
55
+ } catch (error: any) {
56
+ return error?.code || error?.extensions?.refusal?.code || `not a refusal: ${error?.message}`
57
+ }
58
+
59
+ return 'nothing was thrown'
60
+ }
61
+
62
+ async function roleIn(domain: any, name: string) {
63
+ const privilege = await getRepository(Privilege).save({ name: 'query', category: `board-${name}` })
64
+
65
+ return await getRepository(Role).save({ name, domain: { id: domain.id }, privileges: [privilege] })
66
+ }
67
+
68
+ async function personWithAccount(email: string, domains: any[] = []) {
69
+ return await getRepository(User).save({
70
+ username: email,
71
+ email,
72
+ name: email,
73
+ userType: 'user',
74
+ status: UserStatus.ACTIVATED,
75
+ domains: domains.map(d => ({ id: d.id }))
76
+ })
77
+ }
78
+
79
+ /** The row as it stands, with its token — which no client is ever given. */
80
+ async function stored(id: string) {
81
+ return await getRepository(Invitation).findOne({ where: { id }, relations: ['domain', 'roles'] })
82
+ }
83
+
84
+ beforeAll(async () => {
85
+ await openAuthDatabase()
86
+ })
87
+
88
+ afterAll(async () => {
89
+ await closeAuthDatabase()
90
+ })
91
+
92
+ beforeEach(async () => {
93
+ await resetAuthDatabase()
94
+
95
+ ours = await getRepository(Domain).save({ name: 'acme', subdomain: 'acme' })
96
+ theirs = await getRepository(Domain).save({ name: 'beta', subdomain: 'beta' })
97
+ admin = await personWithAccount('admin@acme.z', [ours])
98
+ })
99
+
100
+ describe('sending one', () => {
101
+ it('stands a pending invitation to this domain, with an expiry and a token', async () => {
102
+ const sent = await mutation.sendInvitation('kim@acme.z', context(ours))
103
+ const row = await stored(sent.id)
104
+
105
+ expect(row.status).toEqual(InvitationStatus.PENDING)
106
+ expect(row.domain.id).toEqual(ours.id)
107
+ expect(row.expiresAt).toBeTruthy()
108
+ expect(row.token).toHaveLength(32)
109
+ })
110
+
111
+ it('takes the domain from the request, not from the caller', async () => {
112
+ /*
113
+ * ⚠ It used to be `reference`, a string the caller passed. That is how a door ends up
114
+ * writing and reading other tenants' rows.
115
+ */
116
+ const sent = await mutation.sendInvitation('kim@acme.z', context(ours))
117
+
118
+ expect((await stored(sent.id)).domain.id).toEqual(ours.id)
119
+ expect((await stored(sent.id)).domain.id).not.toEqual(theirs.id)
120
+ })
121
+
122
+ it('carries the roles the person arrives with', async () => {
123
+ /* Without them they sign in and have no screens at all. */
124
+ const viewer = await roleIn(ours, 'viewer')
125
+
126
+ const sent = await mutation.sendInvitation('kim@acme.z', context(ours), [viewer.id])
127
+
128
+ expect((await stored(sent.id)).roles.map((role: any) => role.id)).toEqual([viewer.id])
129
+ })
130
+
131
+ it('does not take a role from another domain', async () => {
132
+ const foreign = await roleIn(theirs, 'viewer')
133
+
134
+ const sent = await mutation.sendInvitation('kim@acme.z', context(ours), [foreign.id])
135
+
136
+ expect((await stored(sent.id)).roles).toEqual([])
137
+ })
138
+
139
+ it('re-issues the standing invitation rather than making a second one', async () => {
140
+ /*
141
+ * ⚠ One pending invitation per person and domain. The history keeps every row, so this
142
+ * cannot be a unique index without forbidding "invited, left, invited again" — it is held
143
+ * here instead.
144
+ */
145
+ const first = await mutation.sendInvitation('kim@acme.z', context(ours))
146
+ const again = await mutation.sendInvitation('kim@acme.z', context(ours))
147
+
148
+ expect(again.id).toEqual(first.id)
149
+ expect(await getRepository(Invitation).count()).toEqual(1)
150
+ })
151
+
152
+ it('gives the re-issued one a new token', async () => {
153
+ /* Two live links for one invitation is one more than anybody meant to send. */
154
+ const first = await mutation.sendInvitation('kim@acme.z', context(ours))
155
+ const before = (await stored(first.id)).token
156
+
157
+ await mutation.sendInvitation('kim@acme.z', context(ours))
158
+
159
+ expect((await stored(first.id)).token).not.toEqual(before)
160
+ })
161
+
162
+ it('refuses somebody who is already here', async () => {
163
+ await personWithAccount('kim@acme.z', [ours])
164
+
165
+ expect(await refusalCodeOf(mutation.sendInvitation('kim@acme.z', context(ours)))).toEqual(
166
+ 'invitation-already-a-member'
167
+ )
168
+ })
169
+ })
170
+
171
+ describe('accepting', () => {
172
+ async function pending(email = 'kim@acme.z', roleNames: string[] = []) {
173
+ const roles = []
174
+
175
+ for (const name of roleNames) {
176
+ roles.push(await roleIn(ours, name))
177
+ }
178
+
179
+ const sent = await mutation.sendInvitation(
180
+ email,
181
+ context(ours),
182
+ roles.map(role => role.id)
183
+ )
184
+
185
+ return await stored(sent.id)
186
+ }
187
+
188
+ it('lets somebody who already has an account in, with their roles', async () => {
189
+ const invitation = await pending('kim@acme.z', ['viewer'])
190
+ const kim = await personWithAccount('kim@acme.z')
191
+
192
+ const { created } = await acceptInvitation({
193
+ token: invitation.token,
194
+ email: 'kim@acme.z',
195
+ context: context(ours)
196
+ })
197
+
198
+ const joined = await getRepository(User).findOne({ where: { id: kim.id }, relations: ['domains', 'roles'] })
199
+
200
+ expect(created).toBe(false)
201
+ expect(joined.domains.map((d: any) => d.id)).toContain(ours.id)
202
+ expect(joined.roles.map((role: any) => role.name)).toEqual(['viewer'])
203
+ })
204
+
205
+ it('creates the account for somebody who has none', async () => {
206
+ /*
207
+ * ⚠ The case this whole feature is for. The `user-invite` screen records that an installation
208
+ * with no default password has no path at all for a person who does not exist yet — nobody
209
+ * can set their password for them.
210
+ */
211
+ const invitation = await pending('new@acme.z', ['viewer'])
212
+
213
+ const { created } = await acceptInvitation({
214
+ token: invitation.token,
215
+ email: 'new@acme.z',
216
+ name: 'New Person',
217
+ password: 'Passw0rd!x',
218
+ context: context(ours)
219
+ })
220
+
221
+ const person = await getRepository(User).findOne({
222
+ where: { email: 'new@acme.z' },
223
+ relations: ['domains', 'roles']
224
+ })
225
+
226
+ expect(created).toBe(true)
227
+ expect(person.status).toEqual(UserStatus.ACTIVATED)
228
+ expect(person.domains.map((d: any) => d.id)).toEqual([ours.id])
229
+ expect(person.roles.map((role: any) => role.name)).toEqual(['viewer'])
230
+ })
231
+
232
+ it('will not create an account without a password', async () => {
233
+ const invitation = await pending('new@acme.z')
234
+
235
+ expect(
236
+ await refusalCodeOf(
237
+ acceptInvitation({ token: invitation.token, email: 'new@acme.z', context: context(ours) })
238
+ )
239
+ ).toEqual('invitation-needs-a-password')
240
+ })
241
+
242
+ it('marks the row accepted instead of deleting it', async () => {
243
+ /* ⚠ The reason the table exists. Deleting takes the fact that they were invited with it. */
244
+ const invitation = await pending()
245
+ await personWithAccount('kim@acme.z')
246
+
247
+ await acceptInvitation({ token: invitation.token, email: 'kim@acme.z', context: context(ours) })
248
+
249
+ const row = await stored(invitation.id)
250
+
251
+ expect(row).not.toBeNull()
252
+ expect(row.status).toEqual(InvitationStatus.ACCEPTED)
253
+ expect(row.acceptedAt).toBeTruthy()
254
+ })
255
+
256
+ it('cannot be used twice', async () => {
257
+ const invitation = await pending()
258
+ await personWithAccount('kim@acme.z')
259
+
260
+ await acceptInvitation({ token: invitation.token, email: 'kim@acme.z', context: context(ours) })
261
+
262
+ expect(
263
+ await refusalCodeOf(
264
+ acceptInvitation({ token: invitation.token, email: 'kim@acme.z', context: context(ours) })
265
+ )
266
+ ).toEqual('invitation-not-open')
267
+ })
268
+ })
269
+
270
+ describe('⚠ what the token alone is not enough for', () => {
271
+ async function pending(email = 'kim@acme.z') {
272
+ return await stored((await mutation.sendInvitation(email, context(ours))).id)
273
+ }
274
+
275
+ it('refuses when the address given is not the one invited', async () => {
276
+ /*
277
+ * ⚠ The mitigation for the one leak that hiding the token cannot close: the mail being
278
+ * forwarded. Holding the link is not enough on its own.
279
+ */
280
+ const invitation = await pending('kim@acme.z')
281
+ await personWithAccount('someone.else@acme.z')
282
+
283
+ expect(
284
+ await refusalCodeOf(
285
+ acceptInvitation({ token: invitation.token, email: 'someone.else@acme.z', context: context(ours) })
286
+ )
287
+ ).toEqual('invitation-not-open')
288
+ })
289
+
290
+ it('accepts the invited address whatever case it is typed in', async () => {
291
+ const invitation = await pending('Kim@Acme.Z')
292
+ await personWithAccount('kim@acme.z')
293
+
294
+ await expect(
295
+ acceptInvitation({ token: invitation.token, email: ' KIM@acme.z ', context: context(ours) })
296
+ ).resolves.toBeTruthy()
297
+ })
298
+
299
+ it('refuses an expired invitation', async () => {
300
+ const invitation = await pending()
301
+ await personWithAccount('kim@acme.z')
302
+
303
+ await getRepository(Invitation).save({ ...invitation, expiresAt: new Date(Date.now() - 1000) })
304
+
305
+ expect(
306
+ await refusalCodeOf(
307
+ acceptInvitation({ token: invitation.token, email: 'kim@acme.z', context: context(ours) })
308
+ )
309
+ ).toEqual('invitation-not-open')
310
+ })
311
+
312
+ it('refuses a cancelled one whose expiry is still ahead', async () => {
313
+ const invitation = await pending()
314
+ await personWithAccount('kim@acme.z')
315
+
316
+ await mutation.cancelInvitation(invitation.id, context(ours))
317
+
318
+ expect(
319
+ await refusalCodeOf(
320
+ acceptInvitation({ token: invitation.token, email: 'kim@acme.z', context: context(ours) })
321
+ )
322
+ ).toEqual('invitation-not-open')
323
+ })
324
+
325
+ it('refuses a token that is not one of ours, the same way', async () => {
326
+ /*
327
+ * One answer for a wrong token, a wrong address, an expired one and a cancelled one. Telling
328
+ * them apart would let somebody learn which tokens are real.
329
+ */
330
+ expect(
331
+ await refusalCodeOf(acceptInvitation({ token: 'no-such-token', email: 'kim@acme.z', context: context(ours) }))
332
+ ).toEqual('invitation-not-open')
333
+ })
334
+ })
335
+
336
+ describe('withdrawing one', () => {
337
+ it('marks it cancelled and keeps the row', async () => {
338
+ const sent = await mutation.sendInvitation('kim@acme.z', context(ours))
339
+
340
+ expect(await mutation.cancelInvitation(sent.id, context(ours))).toBe(true)
341
+ expect((await stored(sent.id)).status).toEqual(InvitationStatus.CANCELLED)
342
+ })
343
+
344
+ it('does not reach another tenant', async () => {
345
+ /* ⚠ It used to delete by `{ email, reference, type }`, which took an id from anywhere. */
346
+ const foreign = await getRepository(Invitation).save({
347
+ email: 'lee@beta.z',
348
+ domain: { id: theirs.id },
349
+ status: InvitationStatus.PENDING,
350
+ token: 'theirs',
351
+ expiresAt: new Date(Date.now() + 3600_000)
352
+ })
353
+
354
+ expect(await mutation.cancelInvitation(foreign.id, context(ours))).toBe(false)
355
+ expect((await stored(foreign.id)).status).toEqual(InvitationStatus.PENDING)
356
+ })
357
+
358
+ it('says no for one that is already finished', async () => {
359
+ const sent = await mutation.sendInvitation('kim@acme.z', context(ours))
360
+ await mutation.cancelInvitation(sent.id, context(ours))
361
+
362
+ expect(await mutation.cancelInvitation(sent.id, context(ours))).toBe(false)
363
+ })
364
+ })
365
+
366
+ describe('the list', () => {
367
+ it('shows this domain and not another', async () => {
368
+ await mutation.sendInvitation('kim@acme.z', context(ours))
369
+ await getRepository(Invitation).save({
370
+ email: 'lee@beta.z',
371
+ domain: { id: theirs.id },
372
+ status: InvitationStatus.PENDING,
373
+ token: 'theirs',
374
+ expiresAt: new Date(Date.now() + 3600_000)
375
+ })
376
+
377
+ const { items, total } = await query.invitations({ filters: [] } as any, context(ours))
378
+
379
+ expect(total).toEqual(1)
380
+ expect(items[0].email).toEqual('kim@acme.z')
381
+ })
382
+
383
+ it('keeps accepted invitations in it', async () => {
384
+ /* ⚠ The history. This is what there was nothing to put on a screen before. */
385
+ const sent = await mutation.sendInvitation('kim@acme.z', context(ours))
386
+ await personWithAccount('kim@acme.z')
387
+
388
+ await acceptInvitation({ token: (await stored(sent.id)).token, email: 'kim@acme.z', context: context(ours) })
389
+
390
+ const { items, total } = await query.invitations({ filters: [] } as any, context(ours))
391
+
392
+ expect(total).toEqual(1)
393
+ expect(await query.state(items[0])).toEqual(InvitationStatus.ACCEPTED)
394
+ })
395
+
396
+ it('calls a pending row past its expiry expired, before anything writes that', async () => {
397
+ const sent = await mutation.sendInvitation('kim@acme.z', context(ours))
398
+ const row = await stored(sent.id)
399
+
400
+ await getRepository(Invitation).save({ ...row, expiresAt: new Date(Date.now() - 1000) })
401
+
402
+ expect(await query.state(await stored(sent.id))).toEqual(InvitationStatus.EXPIRED)
403
+ })
404
+
405
+ it('does not answer for an id from another tenant', async () => {
406
+ const foreign = await getRepository(Invitation).save({
407
+ email: 'lee@beta.z',
408
+ domain: { id: theirs.id },
409
+ status: InvitationStatus.PENDING,
410
+ token: 'theirs',
411
+ expiresAt: new Date(Date.now() + 3600_000)
412
+ })
413
+
414
+ expect(await query.invitation(foreign.id, context(ours))).toBeNull()
415
+ })
416
+ })
@@ -0,0 +1,92 @@
1
+ import { InvitationStatus, invitationState, mayAccept } from '../server/utils/invitation-state'
2
+
3
+ /**
4
+ * What state an invitation is in, and whether its token still opens anything.
5
+ *
6
+ * The token in an invitation email is a credential: whoever holds it joins a domain. So the
7
+ * cases below lean the way credentials lean — an invitation that cannot say it is live is not
8
+ * live. The opposite mistake is a link that works forever, which is what the table had before
9
+ * (no expiry column at all).
10
+ */
11
+
12
+ const NOW = new Date('2026-09-20T12:00:00Z')
13
+ const LATER = new Date('2026-09-21T12:00:00Z')
14
+ const EARLIER = new Date('2026-09-19T12:00:00Z')
15
+
16
+ describe('a pending invitation', () => {
17
+ it('is pending while its expiry is ahead', () => {
18
+ expect(invitationState({ status: 'pending', expiresAt: LATER }, NOW)).toEqual(InvitationStatus.PENDING)
19
+ expect(mayAccept({ status: 'pending', expiresAt: LATER }, NOW)).toBe(true)
20
+ })
21
+
22
+ it('is expired once the expiry has passed, before anything writes that down', () => {
23
+ /*
24
+ * ⚠ The guard is the clock, not the column. If `expired` were only a written value, the gap
25
+ * between the expiry passing and something running to record it would be a window where the
26
+ * token still works.
27
+ */
28
+ expect(invitationState({ status: 'pending', expiresAt: EARLIER }, NOW)).toEqual(InvitationStatus.EXPIRED)
29
+ expect(mayAccept({ status: 'pending', expiresAt: EARLIER }, NOW)).toBe(false)
30
+ })
31
+
32
+ it('is expired exactly at the moment it runs out', () => {
33
+ /* The boundary belongs to the dead side — a credential does not get its last instant. */
34
+ expect(mayAccept({ status: 'pending', expiresAt: NOW }, NOW)).toBe(false)
35
+ })
36
+
37
+ it('reads a stored timestamp string the same as a Date', () => {
38
+ expect(mayAccept({ status: 'pending', expiresAt: LATER.toISOString() }, NOW)).toBe(true)
39
+ expect(mayAccept({ status: 'pending', expiresAt: EARLIER.toISOString() }, NOW)).toBe(false)
40
+ })
41
+ })
42
+
43
+ describe('what an absent value means', () => {
44
+ it('no status reads as pending', () => {
45
+ /* That is what a row existing meant in the old table, which had no status column. */
46
+ expect(invitationState({ expiresAt: LATER }, NOW)).toEqual(InvitationStatus.PENDING)
47
+ })
48
+
49
+ it('⚠ no expiry reads as expired', () => {
50
+ /*
51
+ * ⚠ Rows written before this existed have no expiry, and they come from a flow that never
52
+ * completed — the link in the email led to no route at all. Reading them as live would keep
53
+ * alive tokens that never worked. A credential that cannot say when it dies does not open
54
+ * anything.
55
+ */
56
+ expect(invitationState({ status: 'pending' }, NOW)).toEqual(InvitationStatus.EXPIRED)
57
+ expect(invitationState({}, NOW)).toEqual(InvitationStatus.EXPIRED)
58
+ expect(mayAccept({ status: 'pending', expiresAt: null }, NOW)).toBe(false)
59
+ })
60
+
61
+ it('an unreadable timestamp reads as expired too', () => {
62
+ expect(mayAccept({ status: 'pending', expiresAt: 'not a date' }, NOW)).toBe(false)
63
+ })
64
+
65
+ it('no invitation at all is not acceptable', () => {
66
+ expect(mayAccept(null, NOW)).toBe(false)
67
+ expect(mayAccept(undefined, NOW)).toBe(false)
68
+ })
69
+ })
70
+
71
+ describe('a row that is already finished', () => {
72
+ it('stays accepted however long ago it was', () => {
73
+ /*
74
+ * ⚠ Time does not rewrite history. If an accepted row expired later, the list would stop
75
+ * being able to say that this person was invited and came in — which is the whole reason the
76
+ * row is kept.
77
+ */
78
+ expect(invitationState({ status: 'accepted', expiresAt: EARLIER }, NOW)).toEqual(InvitationStatus.ACCEPTED)
79
+ expect(invitationState({ status: 'accepted' }, NOW)).toEqual(InvitationStatus.ACCEPTED)
80
+ })
81
+
82
+ it('stays cancelled', () => {
83
+ expect(invitationState({ status: 'cancelled', expiresAt: LATER }, NOW)).toEqual(InvitationStatus.CANCELLED)
84
+ })
85
+
86
+ it('cannot be accepted, whatever its expiry says', () => {
87
+ /* ⚠ A cancelled invitation whose expiry is still ahead must not open anything. */
88
+ expect(mayAccept({ status: 'cancelled', expiresAt: LATER }, NOW)).toBe(false)
89
+ expect(mayAccept({ status: 'accepted', expiresAt: LATER }, NOW)).toBe(false)
90
+ expect(mayAccept({ status: 'expired', expiresAt: LATER }, NOW)).toBe(false)
91
+ })
92
+ })
@@ -0,0 +1,132 @@
1
+ import { buildSchema } from 'type-graphql'
2
+ import { GraphQLSchema } from 'graphql'
3
+
4
+ import { loadCompiled } from './compiled'
5
+
6
+ /**
7
+ * Which of auth-base's doors are open, and why each one is allowed to be.
8
+ *
9
+ * ── The thing this catches ──────────────────────────────────────────────────
10
+ * `@privilege` is opt-in. A root field that declares nothing is recorded as open, and that is
11
+ * indistinguishable from a field somebody decided to open — no error, no warning, nothing to
12
+ * grep for. So a door goes in ungated the way `deleteAppBinding` did, and only a reader who
13
+ * happens to look at that one file ever finds out.
14
+ *
15
+ * The list below turns the absence into a statement. Anything open that is not on it fails, and
16
+ * the reason has to be written before it can be added.
17
+ *
18
+ * ── Why this is measured and not read off the source ────────────────────────
19
+ * The schema is built from `service/index.ts`'s own `resolverClasses` — the list the server
20
+ * boots from — and the directive fills `PRIVILEGE_OF_FIELD` while it walks it. A regular
21
+ * expression over the files would answer about the text; this answers about the schema that
22
+ * actually gets served, including every field the walk reaches that a hand-written pattern
23
+ * would not.
24
+ *
25
+ * ── What it does not cover ──────────────────────────────────────────────────
26
+ * Only auth-base. The other packages have their own resolvers and their own open doors, and
27
+ * `docs/design/auth-rebuild.md` §7 is where the policy for those is still undecided.
28
+ */
29
+
30
+ const { schema: declared } = loadCompiled('service/index')
31
+ const { privilegeDirectiveResolver, privilegeOfField } = loadCompiled('service/privilege/privilege-directive')
32
+
33
+ /**
34
+ * Open on purpose. Each of these answers about the caller, or about the installation rather
35
+ * than about anybody's data.
36
+ */
37
+ const OPEN_BY_DECISION: Record<string, Record<string, string>> = {
38
+ Query: {
39
+ /* The caller's own. */
40
+ myLoginHistories: 'the caller\'s own sign-ins',
41
+ myRoles: 'the caller\'s own roles',
42
+ hasPrivilege: 'whether the caller has one — a gate here would need the answer to itself',
43
+ domainsWithPrivilege: 'the domains where the caller has one',
44
+ checkUserBelongsDomain: 'whether the caller belongs to this domain',
45
+ checkResettablePasswordToDefault: 'about the caller\'s own password',
46
+ checkDefaultPassword: 'whether the caller is still on the default password',
47
+
48
+ /* Neither tenant data nor about a person. */
49
+ passwordRule:
50
+ 'the installation\'s password policy. The change-password screen needs it before the caller has any privilege — and after a forced reset they may have none',
51
+ authProviderTypes: 'which provider types this build supports. A catalogue, not a tenant\'s configuration'
52
+ },
53
+ Mutation: {}
54
+ }
55
+
56
+ let schema: GraphQLSchema
57
+
58
+ beforeAll(async () => {
59
+ schema = await buildSchema({ resolvers: declared.resolverClasses, validate: false })
60
+
61
+ privilegeDirectiveResolver(schema)
62
+ })
63
+
64
+ function openFields(typeName: 'Query' | 'Mutation'): string[] {
65
+ const type = typeName === 'Query' ? schema.getQueryType() : schema.getMutationType()
66
+
67
+ return Object.keys(type?.getFields() || {}).filter(field => {
68
+ const declaredGate = privilegeOfField(typeName, field)
69
+
70
+ return declaredGate.known && declaredGate['gate'] === null
71
+ })
72
+ }
73
+
74
+ describe('every open door is one somebody chose', () => {
75
+ it.each(['Query', 'Mutation'] as const)('%s', typeName => {
76
+ expect(openFields(typeName).sort()).toEqual(Object.keys(OPEN_BY_DECISION[typeName]).sort())
77
+ })
78
+ })
79
+
80
+ describe('the doors that were closed', () => {
81
+ /*
82
+ * ⚠ These were all open on 2026-09-20, every one of them reachable by anyone holding a valid
83
+ * token. They are listed by name so that reopening one is a visible act rather than a
84
+ * deletion nobody reads.
85
+ *
86
+ * the five auth provider writes reading the configuration needed a privilege; replacing it
87
+ * did not. An auth provider is how an external identity
88
+ * signs in, so adding one is not an edit to a settings row
89
+ * domainUserRoleRegister makes a domain, its roles and its users. `domainRegister`
90
+ * beside it makes only a domain and is super-user only
91
+ * loginHistories everybody's sign-ins
92
+ * invitation / invitations and neither of them scopes by tenant at all
93
+ * sendInvitation / cancelInvitation / inviteUser
94
+ */
95
+ const CLOSED = {
96
+ Mutation: [
97
+ 'createAuthProvider',
98
+ 'updateAuthProvider',
99
+ 'updateMultipleAuthProvider',
100
+ 'deleteAuthProvider',
101
+ 'deleteAuthProviders',
102
+ 'domainUserRoleRegister',
103
+ 'sendInvitation',
104
+ 'cancelInvitation',
105
+ 'inviteUser'
106
+ ],
107
+ Query: ['loginHistories', 'invitation', 'invitations']
108
+ }
109
+
110
+ it.each(Object.entries(CLOSED).flatMap(([t, fields]) => fields.map(f => [t, f])))('%s.%s', (typeName, field) => {
111
+ const declaredGate = privilegeOfField(typeName, field)
112
+
113
+ expect(declaredGate.known).toBe(true)
114
+ expect(declaredGate['gate']).not.toBeNull()
115
+ })
116
+ })
117
+
118
+ describe('what the walk itself has to be true for', () => {
119
+ it('reaches the whole schema, not a handful of fields', () => {
120
+ /*
121
+ * If the resolver list shrank to nothing the two assertions above would pass by having
122
+ * nothing to look at. This is the floor under them.
123
+ */
124
+ expect(Object.keys(schema.getQueryType()?.getFields() || {}).length).toBeGreaterThan(30)
125
+ expect(Object.keys(schema.getMutationType()?.getFields() || {}).length).toBeGreaterThan(40)
126
+ })
127
+
128
+ it('tells an undeclared field from one the schema does not have', () => {
129
+ /* `known: false` must never read as open — that is how a typo becomes a gate that is not there. */
130
+ expect(privilegeOfField('Query', 'noSuchField')).toEqual({ known: false })
131
+ })
132
+ })