@things-factory/auth-base 10.1.215 → 10.1.218
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/config/config.development.js +2 -4
- package/config/config.production.js +1 -2
- package/dist-client/actions/auth.d.ts +1 -0
- package/dist-client/actions/auth.js +6 -0
- package/dist-client/actions/auth.js.map +1 -1
- package/dist-client/reducers/auth.d.ts +8 -0
- package/dist-client/reducers/auth.js +8 -2
- package/dist-client/reducers/auth.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/constants/error-code.d.ts +1 -0
- package/dist-server/constants/error-code.js +2 -1
- package/dist-server/constants/error-code.js.map +1 -1
- package/dist-server/controllers/auth.d.ts +0 -1
- package/dist-server/controllers/auth.js +0 -1
- package/dist-server/controllers/auth.js.map +1 -1
- package/dist-server/controllers/password-setup.d.ts +33 -0
- package/dist-server/controllers/password-setup.js +77 -0
- package/dist-server/controllers/password-setup.js.map +1 -0
- package/dist-server/controllers/reset-password.d.ts +8 -1
- package/dist-server/controllers/reset-password.js +21 -11
- package/dist-server/controllers/reset-password.js.map +1 -1
- package/dist-server/controllers/signin.js +10 -0
- package/dist-server/controllers/signin.js.map +1 -1
- package/dist-server/index.d.ts +1 -0
- package/dist-server/index.js +2 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/middlewares/jwt-authenticate-middleware.js +8 -25
- package/dist-server/middlewares/jwt-authenticate-middleware.js.map +1 -1
- package/dist-server/router/auth-public-process-router.js +3 -41
- package/dist-server/router/auth-public-process-router.js.map +1 -1
- package/dist-server/service/access-request/access-request.js +2 -2
- package/dist-server/service/access-request/access-request.js.map +1 -1
- package/dist-server/service/audit/audit-kinds.js +1 -0
- package/dist-server/service/audit/audit-kinds.js.map +1 -1
- package/dist-server/service/audit/audit-subscriber.d.ts +1 -7
- package/dist-server/service/audit/audit-subscriber.js +2 -33
- package/dist-server/service/audit/audit-subscriber.js.map +1 -1
- package/dist-server/service/audit/index.d.ts +1 -1
- package/dist-server/service/index.d.ts +2 -4
- package/dist-server/service/index.js +22 -27
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/open-fields.js +0 -2
- package/dist-server/service/open-fields.js.map +1 -1
- package/dist-server/service/user/user-mutation.d.ts +27 -6
- package/dist-server/service/user/user-mutation.js +26 -114
- package/dist-server/service/user/user-mutation.js.map +1 -1
- package/dist-server/service/user/user-query.d.ts +3 -4
- package/dist-server/service/user/user-query.js +7 -32
- package/dist-server/service/user/user-query.js.map +1 -1
- package/dist-server/service/user/user-types.d.ts +6 -2
- package/dist-server/service/user/user-types.js +20 -9
- package/dist-server/service/user/user-types.js.map +1 -1
- package/dist-server/service/user/user.js +17 -8
- package/dist-server/service/user/user.js.map +1 -1
- package/dist-server/service/verification-token/verification-token.d.ts +1 -0
- package/dist-server/service/verification-token/verification-token.js +1 -0
- package/dist-server/service/verification-token/verification-token.js.map +1 -1
- package/dist-server/templates/reset-password-email.d.ts +10 -3
- package/dist-server/templates/reset-password-email.js +8 -4
- package/dist-server/templates/reset-password-email.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/dist-server/utils/verification-token-rule.js +3 -1
- package/dist-server/utils/verification-token-rule.js.map +1 -1
- package/package.json +2 -2
- package/tests/add-user-gates.test.ts +34 -0
- package/tests/list-inheritance-db.test.ts +0 -15
- package/tests/lock-recovery.test.ts +2 -2
- package/tests/open-doors.test.ts +2 -5
- package/tests/password-reset-rule-db.test.ts +17 -16
- package/tests/password-setup-db.test.ts +213 -0
- package/tests/picture-reducer.test.ts +25 -0
- package/tests/verification-token-db.test.ts +12 -7
- package/translations/en.json +2 -2
- package/translations/es.json +2 -2
- package/translations/ja.json +2 -2
- package/translations/ko.json +2 -2
- package/translations/ms.json +2 -2
- package/translations/zh.json +2 -2
- package/dist-server/controllers/invitation.d.ts +0 -75
- package/dist-server/controllers/invitation.js +0 -195
- package/dist-server/controllers/invitation.js.map +0 -1
- package/dist-server/controllers/utils/make-invitation-token.d.ts +0 -1
- package/dist-server/controllers/utils/make-invitation-token.js +0 -9
- package/dist-server/controllers/utils/make-invitation-token.js.map +0 -1
- package/dist-server/controllers/utils/save-invitation-token.d.ts +0 -5
- package/dist-server/controllers/utils/save-invitation-token.js +0 -12
- package/dist-server/controllers/utils/save-invitation-token.js.map +0 -1
- package/dist-server/service/invitation/index.d.ts +0 -5
- package/dist-server/service/invitation/index.js +0 -9
- package/dist-server/service/invitation/index.js.map +0 -1
- package/dist-server/service/invitation/invitation-mutation.d.ts +0 -20
- package/dist-server/service/invitation/invitation-mutation.js +0 -109
- package/dist-server/service/invitation/invitation-mutation.js.map +0 -1
- package/dist-server/service/invitation/invitation-query.d.ts +0 -23
- package/dist-server/service/invitation/invitation-query.js +0 -89
- package/dist-server/service/invitation/invitation-query.js.map +0 -1
- package/dist-server/service/invitation/invitation-types.d.ts +0 -5
- package/dist-server/service/invitation/invitation-types.js +0 -21
- package/dist-server/service/invitation/invitation-types.js.map +0 -1
- package/dist-server/service/invitation/invitation.d.ts +0 -26
- package/dist-server/service/invitation/invitation.js +0 -142
- package/dist-server/service/invitation/invitation.js.map +0 -1
- package/dist-server/templates/invitation-email.d.ts +0 -6
- package/dist-server/templates/invitation-email.js +0 -82
- package/dist-server/templates/invitation-email.js.map +0 -1
- package/dist-server/utils/invitation-state.d.ts +0 -52
- package/dist-server/utils/invitation-state.js +0 -76
- package/dist-server/utils/invitation-state.js.map +0 -1
- package/tests/invitation-db.test.ts +0 -416
- package/tests/invitation-route.test.ts +0 -116
- package/tests/invitation-state.test.ts +0 -92
|
@@ -1,416 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import { readFileSync, readdirSync } from 'fs'
|
|
2
|
-
import { join } from 'path'
|
|
3
|
-
|
|
4
|
-
import { loadCompiled } from './compiled'
|
|
5
|
-
|
|
6
|
-
const { authPublicProcessRouter } = loadCompiled('router/auth-public-process-router')
|
|
7
|
-
const { ACCEPT_INVITATION_PATH } = loadCompiled('controllers/invitation')
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* The link in each mail lands on a route that exists.
|
|
11
|
-
*
|
|
12
|
-
* ── Why this file exists ────────────────────────────────────────────────────
|
|
13
|
-
* The invitation mail pointed at `/auth/accept/<token>` — a path with no route anywhere — so the
|
|
14
|
-
* invitee fell through to the private history fallback and the domain gate turned them away for
|
|
15
|
-
* not being a member yet. That was found and fixed on 2026-09-20, and the fix made the same
|
|
16
|
-
* mistake again: the new route was declared as `/accept-invitation` on a router whose prefix is
|
|
17
|
-
* `/auth`, and the mail and the page both used `/accept-invitation`. It shipped that way.
|
|
18
|
-
*
|
|
19
|
-
* Every test of the invitation called the controller directly, so none of them ever went through
|
|
20
|
-
* a route. This one reads the router's own table — the paths koa-router resolved, prefix included
|
|
21
|
-
* — and holds each mail's link against it.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
type Layer = { path: string; methods: string[] }
|
|
25
|
-
|
|
26
|
-
const layers: Layer[] = authPublicProcessRouter.stack.map((layer: any) => ({
|
|
27
|
-
path: layer.path,
|
|
28
|
-
methods: layer.methods
|
|
29
|
-
}))
|
|
30
|
-
|
|
31
|
-
function answers(method: string, path: string): boolean {
|
|
32
|
-
return layers.some(layer => layer.methods.includes(method) && matches(layer.path, path))
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** A declared path against a concrete one: `:param` matches one segment. */
|
|
36
|
-
function matches(declared: string, actual: string): boolean {
|
|
37
|
-
const a = declared.split('/')
|
|
38
|
-
const b = actual.split('/')
|
|
39
|
-
|
|
40
|
-
return a.length === b.length && a.every((part, i) => part.startsWith(':') || part === b[i])
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
describe('the invitation link', () => {
|
|
44
|
-
it('the page answers where the mail points', () => {
|
|
45
|
-
/* ⚠ The case that shipped broken. */
|
|
46
|
-
expect(answers('GET', ACCEPT_INVITATION_PATH)).toBe(true)
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
it('the page posts where the router listens', () => {
|
|
50
|
-
expect(answers('POST', ACCEPT_INVITATION_PATH)).toBe(true)
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it('carries the prefix the router adds', () => {
|
|
54
|
-
/*
|
|
55
|
-
* Declaring `/accept-invitation` on this router makes it `/auth/accept-invitation`. The spelling
|
|
56
|
-
* without the prefix is exactly the one that went out, so it is named here as the wrong one.
|
|
57
|
-
*/
|
|
58
|
-
expect(ACCEPT_INVITATION_PATH.startsWith('/auth/')).toBe(true)
|
|
59
|
-
expect(answers('GET', '/accept-invitation')).toBe(false)
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
it('is the path the client page uses too', () => {
|
|
63
|
-
const page = readFileSync(
|
|
64
|
-
join(__dirname, '..', '..', 'auth-ui', 'client', 'components', 'accept-invitation.ts'),
|
|
65
|
-
'utf-8'
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
expect(page).toContain(`return '${ACCEPT_INVITATION_PATH}'`)
|
|
69
|
-
})
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
describe('the other links sent by mail', () => {
|
|
73
|
-
/*
|
|
74
|
-
* Reset, unlock and sign-up confirmation moved to the invitation's shape on 2026-09-24 (Q4): one
|
|
75
|
-
* exported path, the token after `#`. Each page answers GET where the mail points and POST where
|
|
76
|
-
* the page sends the token.
|
|
77
|
-
*/
|
|
78
|
-
const { RESET_PASSWORD_PATH } = loadCompiled('controllers/reset-password')
|
|
79
|
-
const { UNLOCK_USER_PATH } = loadCompiled('controllers/unlock-user')
|
|
80
|
-
const { VERIFY_EMAIL_PATH } = loadCompiled('controllers/verification')
|
|
81
|
-
|
|
82
|
-
const paths = [RESET_PASSWORD_PATH, UNLOCK_USER_PATH, VERIFY_EMAIL_PATH]
|
|
83
|
-
|
|
84
|
-
it('names the three it means to check', () => {
|
|
85
|
-
expect(paths).toEqual(['/auth/reset-password', '/auth/unlock-user', '/auth/verify'])
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it.each(paths)('%s — the page answers where the mail points', path => {
|
|
89
|
-
expect(answers('GET', path)).toBe(true)
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
it.each(paths)('%s — and listens where the page posts', path => {
|
|
93
|
-
expect(answers('POST', path)).toBe(true)
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
it('no longer answers the old link that confirmed on opening', () => {
|
|
97
|
-
/* `GET /auth/verify/<token>` confirmed the address for whoever fetched it — a mail scanner too. */
|
|
98
|
-
expect(answers('GET', '/auth/verify/0123456789abcdef0123456789abcdef')).toBe(false)
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
it('no controller puts a token in a query string or a path', () => {
|
|
102
|
-
const CONTROLLERS = join(__dirname, '..', 'server', 'controllers')
|
|
103
|
-
const offending = readdirSync(CONTROLLERS)
|
|
104
|
-
.filter(file => file.endsWith('.ts'))
|
|
105
|
-
.filter(file => /\?token=\$\{|\/verify\/\$\{/.test(readFileSync(join(CONTROLLERS, file), 'utf-8')))
|
|
106
|
-
|
|
107
|
-
expect(offending).toEqual([])
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
it('nor does the redirect for a password that must be reset', () => {
|
|
111
|
-
const middleware = readFileSync(join(__dirname, '..', 'server', 'middlewares', 'jwt-authenticate-middleware.ts'), 'utf-8')
|
|
112
|
-
|
|
113
|
-
expect(middleware).toContain('context.redirect(`${RESET_PASSWORD_PATH}#${token}`)')
|
|
114
|
-
expect(middleware).not.toMatch(/\?token=/)
|
|
115
|
-
})
|
|
116
|
-
})
|
|
@@ -1,92 +0,0 @@
|
|
|
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
|
-
})
|