@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.
- package/dist-server/controllers/invitation.d.ts +47 -10
- package/dist-server/controllers/invitation.js +159 -113
- package/dist-server/controllers/invitation.js.map +1 -1
- package/dist-server/controllers/profile.d.ts +1 -0
- package/dist-server/router/auth-public-process-router.js +36 -13
- package/dist-server/router/auth-public-process-router.js.map +1 -1
- package/dist-server/router/oauth2/oauth2-router.js +7 -2
- package/dist-server/router/oauth2/oauth2-router.js.map +1 -1
- package/dist-server/router/oauth2/oauth2-server.js +4 -4
- package/dist-server/router/oauth2/oauth2-server.js.map +1 -1
- package/dist-server/service/app-binding/app-binding-mutation.d.ts +27 -0
- package/dist-server/service/app-binding/app-binding-mutation.js +39 -6
- package/dist-server/service/app-binding/app-binding-mutation.js.map +1 -1
- package/dist-server/service/app-binding/app-binding-query.js +14 -7
- package/dist-server/service/app-binding/app-binding-query.js.map +1 -1
- package/dist-server/service/app-binding/app-binding.d.ts +9 -0
- package/dist-server/service/app-binding/app-binding.js +10 -1
- package/dist-server/service/app-binding/app-binding.js.map +1 -1
- package/dist-server/service/appliance/appliance-mutation.js +34 -1
- package/dist-server/service/appliance/appliance-mutation.js.map +1 -1
- package/dist-server/service/appliance/appliance-query.d.ts +2 -0
- package/dist-server/service/appliance/appliance-query.js +48 -0
- package/dist-server/service/appliance/appliance-query.js.map +1 -1
- package/dist-server/service/appliance/appliance.d.ts +1 -0
- package/dist-server/service/appliance/appliance.js +33 -3
- package/dist-server/service/appliance/appliance.js.map +1 -1
- package/dist-server/service/application/application-mutation.js +51 -6
- package/dist-server/service/application/application-mutation.js.map +1 -1
- package/dist-server/service/application/application.d.ts +9 -3
- package/dist-server/service/application/application.js +24 -8
- package/dist-server/service/application/application.js.map +1 -1
- package/dist-server/service/auth-provider/auth-provider-mutation.js +5 -0
- package/dist-server/service/auth-provider/auth-provider-mutation.js.map +1 -1
- package/dist-server/service/domain-generator/domain-generator-mutation.js +3 -0
- package/dist-server/service/domain-generator/domain-generator-mutation.js.map +1 -1
- package/dist-server/service/invitation/invitation-mutation.d.ts +17 -15
- package/dist-server/service/invitation/invitation-mutation.js +83 -56
- package/dist-server/service/invitation/invitation-mutation.js.map +1 -1
- package/dist-server/service/invitation/invitation-query.d.ts +20 -5
- package/dist-server/service/invitation/invitation-query.js +60 -19
- package/dist-server/service/invitation/invitation-query.js.map +1 -1
- package/dist-server/service/invitation/invitation.d.ts +14 -2
- package/dist-server/service/invitation/invitation.js +77 -8
- package/dist-server/service/invitation/invitation.js.map +1 -1
- package/dist-server/service/login-history/login-history-query.js +3 -0
- package/dist-server/service/login-history/login-history-query.js.map +1 -1
- package/dist-server/service/role/role-mutation.js +14 -15
- package/dist-server/service/role/role-mutation.js.map +1 -1
- package/dist-server/service/role/role-query.js +26 -4
- package/dist-server/service/role/role-query.js.map +1 -1
- package/dist-server/service/role/role-types.d.ts +2 -0
- package/dist-server/service/role/role-types.js +8 -0
- package/dist-server/service/role/role-types.js.map +1 -1
- package/dist-server/service/role-template/role-template-mutation.d.ts +17 -1
- package/dist-server/service/role-template/role-template-mutation.js +14 -3
- package/dist-server/service/role-template/role-template-mutation.js.map +1 -1
- package/dist-server/service/user/user-mutation.js +1 -0
- package/dist-server/service/user/user-mutation.js.map +1 -1
- package/dist-server/service/user/user.d.ts +1 -0
- package/dist-server/service/user/user.js +19 -4
- package/dist-server/service/user/user.js.map +1 -1
- package/dist-server/templates/invitation-email.d.ts +2 -1
- package/dist-server/templates/invitation-email.js +16 -4
- package/dist-server/templates/invitation-email.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/dist-server/utils/credential-at-rest.d.ts +28 -0
- package/dist-server/utils/credential-at-rest.js +36 -0
- package/dist-server/utils/credential-at-rest.js.map +1 -0
- package/dist-server/utils/credential-features.d.ts +35 -0
- package/dist-server/utils/credential-features.js +45 -0
- package/dist-server/utils/credential-features.js.map +1 -0
- package/dist-server/utils/credential-serial-rule.d.ts +41 -0
- package/dist-server/utils/credential-serial-rule.js +43 -0
- package/dist-server/utils/credential-serial-rule.js.map +1 -0
- package/dist-server/utils/invitation-state.d.ts +52 -0
- package/dist-server/utils/invitation-state.js +76 -0
- package/dist-server/utils/invitation-state.js.map +1 -0
- package/dist-server/utils/refuse-role-name.d.ts +42 -0
- package/dist-server/utils/refuse-role-name.js +76 -0
- package/dist-server/utils/refuse-role-name.js.map +1 -0
- package/dist-server/utils/role-name-standing.d.ts +58 -0
- package/dist-server/utils/role-name-standing.js +72 -0
- package/dist-server/utils/role-name-standing.js.map +1 -0
- package/package.json +4 -4
- package/tests/app-binding-delete-db.test.ts +188 -0
- package/tests/appliance-credential-state-db.test.ts +207 -0
- package/tests/appliance-delete-db.test.ts +119 -0
- package/tests/appliance-revoke-db.test.ts +73 -28
- package/tests/appliance-schema.test.ts +129 -0
- package/tests/application-delete-db.test.ts +190 -0
- package/tests/application-token-payload-db.test.ts +185 -0
- package/tests/checkin-privilege-seam-db.test.ts +158 -0
- package/tests/credential-at-rest-db.test.ts +246 -0
- package/tests/credential-features-off-db.test.ts +189 -0
- package/tests/credential-serial-rule.test.ts +84 -0
- package/tests/invitation-db.test.ts +416 -0
- package/tests/invitation-state.test.ts +92 -0
- package/tests/open-doors.test.ts +132 -0
- package/tests/role-inheritance-db.test.ts +286 -0
- package/tests/role-mutation-db.test.ts +21 -7
- package/tests/role-name-lookup-sentinel.test.ts +79 -0
- package/tests/role-name-standing.test.ts +83 -0
- package/tests/token-issuance.test.ts +7 -3
- package/translations/en.json +2 -0
- package/translations/ja.json +2 -0
- package/translations/ko.json +2 -0
- package/translations/ms.json +2 -0
- package/translations/zh.json +2 -0
|
@@ -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
|
+
})
|