@things-factory/auth-base 10.1.40 → 10.1.46
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/reset-password.d.ts +6 -0
- package/dist-server/controllers/reset-password.js +12 -13
- package/dist-server/controllers/reset-password.js.map +1 -1
- package/dist-server/controllers/unlock-user.d.ts +6 -0
- package/dist-server/controllers/unlock-user.js +12 -13
- package/dist-server/controllers/unlock-user.js.map +1 -1
- package/dist-server/controllers/utils/find-verification-token.d.ts +16 -0
- package/dist-server/controllers/utils/find-verification-token.js +40 -0
- package/dist-server/controllers/utils/find-verification-token.js.map +1 -0
- package/dist-server/controllers/utils/save-verification-token.d.ts +8 -1
- package/dist-server/controllers/utils/save-verification-token.js +10 -2
- package/dist-server/controllers/utils/save-verification-token.js.map +1 -1
- package/dist-server/controllers/verification.d.ts +6 -0
- package/dist-server/controllers/verification.js +13 -8
- package/dist-server/controllers/verification.js.map +1 -1
- package/dist-server/middlewares/jwt-authenticate-middleware.js +3 -1
- package/dist-server/middlewares/jwt-authenticate-middleware.js.map +1 -1
- package/dist-server/router/auth-checkin-router.js +2 -1
- package/dist-server/router/auth-checkin-router.js.map +1 -1
- package/dist-server/router/auth-public-process-router.js +31 -8
- package/dist-server/router/auth-public-process-router.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/service/role-template/index.d.ts +2 -1
- package/dist-server/service/role-template/index.js +2 -1
- package/dist-server/service/role-template/index.js.map +1 -1
- package/dist-server/service/role-template/role-template-gap-resolver.d.ts +10 -0
- package/dist-server/service/role-template/role-template-gap-resolver.js +41 -0
- package/dist-server/service/role-template/role-template-gap-resolver.js.map +1 -0
- package/dist-server/service/role-template/role-template-types.d.ts +10 -0
- package/dist-server/service/role-template/role-template-types.js +41 -1
- package/dist-server/service/role-template/role-template-types.js.map +1 -1
- package/dist-server/service/role-template/role-template.d.ts +25 -0
- package/dist-server/service/role-template/role-template.js +32 -0
- package/dist-server/service/role-template/role-template.js.map +1 -1
- package/dist-server/service/verification-token/verification-token.d.ts +5 -0
- package/dist-server/service/verification-token/verification-token.js +6 -1
- package/dist-server/service/verification-token/verification-token.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/dist-server/utils/same-origin-redirect.d.ts +4 -0
- package/dist-server/utils/same-origin-redirect.js +39 -0
- package/dist-server/utils/same-origin-redirect.js.map +1 -0
- package/dist-server/utils/verification-token-rule.d.ts +16 -0
- package/dist-server/utils/verification-token-rule.js +73 -0
- package/dist-server/utils/verification-token-rule.js.map +1 -0
- package/package.json +4 -4
- package/tests/invitation-route.test.ts +36 -17
- package/tests/lock-recovery.test.ts +3 -1
- package/tests/open-fields-framework.test.ts +212 -0
- package/tests/open-redirect-db.test.ts +143 -0
- package/tests/role-template-gap-db.test.ts +125 -0
- package/tests/verification-token-db.test.ts +277 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a role lacks against the template of its name - against a real database (ADR-0075 decision 1).
|
|
3
|
+
*
|
|
4
|
+
* A template is a seed and nothing pushes its later grants into roles already standing. When
|
|
5
|
+
* operato-plant's templates gained ai-assistant, the roles in miratek stayed without it and only
|
|
6
|
+
* plant's security runner noticed. The role screen now shows the gap and the administrator adds
|
|
7
|
+
* it; these are the facts that screen stands on.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase, authDataSource } from './db'
|
|
11
|
+
import { loadCompiled } from './compiled'
|
|
12
|
+
|
|
13
|
+
const { Role } = loadCompiled('service/role/role')
|
|
14
|
+
const { Privilege } = loadCompiled('service/privilege/privilege')
|
|
15
|
+
const { User } = loadCompiled('service/user/user')
|
|
16
|
+
const { RoleTemplateMutation } = loadCompiled('service/role-template/role-template-mutation')
|
|
17
|
+
const { RoleTemplateGapResolver } = loadCompiled('service/role-template/role-template-gap-resolver')
|
|
18
|
+
const { registerRoleTemplate } = loadCompiled('service/role-template/role-template')
|
|
19
|
+
|
|
20
|
+
const mutation = new RoleTemplateMutation()
|
|
21
|
+
const gapOf = (role: any) => new RoleTemplateGapResolver().templateGap(role)
|
|
22
|
+
|
|
23
|
+
let domain: any
|
|
24
|
+
let actor: any
|
|
25
|
+
|
|
26
|
+
const context = () => ({ state: { domain, user: actor, tx: authDataSource().manager } }) as any
|
|
27
|
+
|
|
28
|
+
/** The template as it stood when the role was made, then as it stands after it gained a door. */
|
|
29
|
+
function declareTemplate(axes: string[], extra: { category: string; axes: string[] }[] = []) {
|
|
30
|
+
;(process as any)['ROLE_TEMPLATES'] = {}
|
|
31
|
+
registerRoleTemplate({ id: 'plant.qi', name: 'QUALITY-INSPECTOR', grants: [{ category: 'board', axes }, ...extra] })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
await openAuthDatabase()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
afterAll(async () => {
|
|
39
|
+
await closeAuthDatabase()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
beforeEach(async () => {
|
|
43
|
+
await resetAuthDatabase()
|
|
44
|
+
|
|
45
|
+
const saved = await getRepository(Domain).save({ name: 'Plant A', subdomain: 'plant-a' })
|
|
46
|
+
domain = await getRepository(Domain).findOneBy({ id: saved.id })
|
|
47
|
+
|
|
48
|
+
await getRepository(Privilege).save({ name: 'query', category: 'board' })
|
|
49
|
+
await getRepository(Privilege).save({ name: 'mutation', category: 'board' })
|
|
50
|
+
;(process as any).PRIVILEGES = {
|
|
51
|
+
'board query': ['board', 'query'],
|
|
52
|
+
'board mutation': ['board', 'mutation']
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
actor = await getRepository(User).save({
|
|
56
|
+
username: 'admin',
|
|
57
|
+
email: 'admin@example.com',
|
|
58
|
+
name: 'Admin',
|
|
59
|
+
salt: User.generateSalt(),
|
|
60
|
+
status: 'activated',
|
|
61
|
+
domains: [domain]
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('the gap between a role and its template', () => {
|
|
66
|
+
it('is zero for a role made from the template as it stands', async () => {
|
|
67
|
+
declareTemplate(['query'])
|
|
68
|
+
const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
|
|
69
|
+
|
|
70
|
+
expect(await gapOf(role)).toEqual({
|
|
71
|
+
templateId: 'plant.qi',
|
|
72
|
+
templateName: 'QUALITY-INSPECTOR',
|
|
73
|
+
missing: [],
|
|
74
|
+
missingCount: 0
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('★ names what the template gained after the role was made', async () => {
|
|
79
|
+
declareTemplate(['query'])
|
|
80
|
+
const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
|
|
81
|
+
|
|
82
|
+
declareTemplate(['query', 'mutation'])
|
|
83
|
+
|
|
84
|
+
const gap = await gapOf(role)
|
|
85
|
+
expect(gap.missingCount).toBe(1)
|
|
86
|
+
expect(gap.missing).toEqual([{ category: 'board', axes: ['mutation'] }])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('★ closes when the administrator adds it - the same top-up the template button runs', async () => {
|
|
90
|
+
declareTemplate(['query'])
|
|
91
|
+
const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
|
|
92
|
+
declareTemplate(['query', 'mutation'])
|
|
93
|
+
|
|
94
|
+
await mutation.createRoleFromTemplate('plant.qi', 'QUALITY-INSPECTOR', context())
|
|
95
|
+
|
|
96
|
+
expect((await gapOf(role)).missingCount).toBe(0)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('★ does not count what this installation does not declare', async () => {
|
|
100
|
+
declareTemplate(['query'], [{ category: 'order', axes: ['query'] }])
|
|
101
|
+
const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
|
|
102
|
+
|
|
103
|
+
expect((await gapOf(role)).missingCount).toBe(0)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('★ is null for a role no template names - a renamed role belongs to its administrator', async () => {
|
|
107
|
+
declareTemplate(['query'])
|
|
108
|
+
const role = await mutation.createRoleFromTemplate('plant.qi', 'NIGHT-INSPECTOR', context())
|
|
109
|
+
|
|
110
|
+
expect(await gapOf(role)).toBeNull()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('does not take away what the administrator added beyond the template', async () => {
|
|
114
|
+
declareTemplate(['query'])
|
|
115
|
+
const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
|
|
116
|
+
const stored = await getRepository(Role).findOne({ where: { id: role.id }, relations: ['privileges'] })
|
|
117
|
+
stored.privileges = [
|
|
118
|
+
...stored.privileges,
|
|
119
|
+
await getRepository(Privilege).findOneBy({ category: 'board', name: 'mutation' })
|
|
120
|
+
]
|
|
121
|
+
await getRepository(Role).save(stored)
|
|
122
|
+
|
|
123
|
+
expect((await gapOf(role)).missingCount).toBe(0)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
const sentMail: any[] = []
|
|
2
|
+
|
|
3
|
+
jest.mock('@things-factory/email-base', () => ({
|
|
4
|
+
sendEmail: async (mail: any) => {
|
|
5
|
+
sentMail.push(mail)
|
|
6
|
+
}
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
10
|
+
import { loadCompiled } from './compiled'
|
|
11
|
+
import {
|
|
12
|
+
hashVerificationToken,
|
|
13
|
+
isVerificationTokenLive,
|
|
14
|
+
verificationTokenExpiresAt,
|
|
15
|
+
VERIFICATION_TOKEN_LIFETIME_MS
|
|
16
|
+
} from '../server/utils/verification-token-rule'
|
|
17
|
+
|
|
18
|
+
const { User } = loadCompiled('service/user/user')
|
|
19
|
+
const { VerificationToken, VerificationTokenType } = loadCompiled('service/verification-token/verification-token')
|
|
20
|
+
const { resetPassword, sendPasswordResetEmail, RESET_PASSWORD_PATH } = loadCompiled('controllers/reset-password')
|
|
21
|
+
const { unlockUser, sendUnlockUserEmail, UNLOCK_USER_PATH } = loadCompiled('controllers/unlock-user')
|
|
22
|
+
const { verify, sendVerificationEmail, VERIFY_EMAIL_PATH } = loadCompiled('controllers/verification')
|
|
23
|
+
const { saveVerificationToken } = loadCompiled('controllers/utils/save-verification-token')
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The links in the reset, unlock and sign-up confirmation mails.
|
|
27
|
+
*
|
|
28
|
+
* Before 2026-09-24 each token lived forever, sat in the table as written and was found by that
|
|
29
|
+
* value, and rode in the link's query string or path. So a reset mail from last year still changed a
|
|
30
|
+
* password, any copy of the table was a set of live reset links, and every access log on the way
|
|
31
|
+
* held one too. Q4 (architect, 2026-09-24) made them work like the invitation link:
|
|
32
|
+
*
|
|
33
|
+
* stored sha256 of the token, with an `expiresAt` written once
|
|
34
|
+
* lifetime reset 1h · unlock 1h · activation 48h
|
|
35
|
+
* no expiresAt refused — rows from before this, and how links already sent stop working
|
|
36
|
+
* the link `<path>#<token>` built with `buildAuthURL`, so the browser never sends the token
|
|
37
|
+
*
|
|
38
|
+
* The controllers are driven with the token the mail carries, and the row is read back to see what
|
|
39
|
+
* the table holds. ⚠ sqlite (`tests/db.ts`).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const OLD = 'Passw0rd!'
|
|
43
|
+
const NEW = 'N3wPassw0rd!'
|
|
44
|
+
const HOUR = 60 * 60 * 1000
|
|
45
|
+
|
|
46
|
+
const context: any = {
|
|
47
|
+
t: (key: string) => key,
|
|
48
|
+
lng: 'en-US',
|
|
49
|
+
headers: {},
|
|
50
|
+
request: { protocol: 'https', host: 'acme.example.com' }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let domain: any
|
|
54
|
+
|
|
55
|
+
async function person(status = 'activated') {
|
|
56
|
+
const salt = User.generateSalt()
|
|
57
|
+
|
|
58
|
+
return await getRepository(User).save({
|
|
59
|
+
username: 'kim',
|
|
60
|
+
email: 'kim@example.com',
|
|
61
|
+
name: 'Kim',
|
|
62
|
+
salt,
|
|
63
|
+
password: User.encode(OLD, salt),
|
|
64
|
+
status,
|
|
65
|
+
failCount: status === 'locked' ? 5 : 0,
|
|
66
|
+
domains: [domain]
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function row(userId: string) {
|
|
71
|
+
return await getRepository(VerificationToken).findOneBy({ userId })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Moves a stored token's expiry — the clock is the row's, not the test's. */
|
|
75
|
+
async function expireAt(userId: string, at: Date | null) {
|
|
76
|
+
await getRepository(VerificationToken).update({ userId }, { expiresAt: at })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The token as the mail carries it: after `#` in the one link the mail holds. */
|
|
80
|
+
function tokenInLastMail(path: string): string {
|
|
81
|
+
const mail = sentMail[sentMail.length - 1]
|
|
82
|
+
const hrefs = [...String(mail.content).matchAll(/href="([^"]+)"/g)].map(m => m[1]).filter(h => h.includes(path))
|
|
83
|
+
|
|
84
|
+
expect(hrefs).toHaveLength(1)
|
|
85
|
+
const link = new URL(hrefs[0])
|
|
86
|
+
|
|
87
|
+
expect(link.host).toBe('acme.example.com')
|
|
88
|
+
expect(link.pathname).toBe(path)
|
|
89
|
+
expect(link.search).toBe('')
|
|
90
|
+
|
|
91
|
+
return link.hash.slice(1)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
beforeAll(async () => {
|
|
95
|
+
await openAuthDatabase()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
beforeEach(async () => {
|
|
99
|
+
await resetAuthDatabase()
|
|
100
|
+
sentMail.length = 0
|
|
101
|
+
domain = await getRepository(Domain).save({ name: 'plant-a', subdomain: 'plant-a' })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
afterAll(async () => {
|
|
105
|
+
await closeAuthDatabase()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('the rule', () => {
|
|
109
|
+
const now = new Date('2026-09-24T12:00:00Z')
|
|
110
|
+
|
|
111
|
+
it('gives each mailed link its lifetime', () => {
|
|
112
|
+
expect(VERIFICATION_TOKEN_LIFETIME_MS).toEqual({ 'password-reset': HOUR, unlock: HOUR, activation: 48 * HOUR })
|
|
113
|
+
expect(verificationTokenExpiresAt('password-reset', now).getTime()).toBe(now.getTime() + HOUR)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('refuses to make up a lifetime for a type it does not know', () => {
|
|
117
|
+
expect(() => verificationTokenExpiresAt('access-token', now)).toThrow(/no lifetime/)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('works until the last millisecond and stops at the expiry', () => {
|
|
121
|
+
const expiresAt = new Date(now.getTime() + HOUR)
|
|
122
|
+
|
|
123
|
+
expect(isVerificationTokenLive({ expiresAt }, new Date(expiresAt.getTime() - 1))).toBe(true)
|
|
124
|
+
expect(isVerificationTokenLive({ expiresAt }, expiresAt)).toBe(false)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('reads a row without an expiry as expired', () => {
|
|
128
|
+
expect(isVerificationTokenLive({ expiresAt: null }, now)).toBe(false)
|
|
129
|
+
expect(isVerificationTokenLive({}, now)).toBe(false)
|
|
130
|
+
expect(isVerificationTokenLive(null, now)).toBe(false)
|
|
131
|
+
expect(isVerificationTokenLive({ expiresAt: 'not a date' }, now)).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('what the table keeps', () => {
|
|
136
|
+
it('the hash of the token, and when it stops working — not the token', async () => {
|
|
137
|
+
const user = await person()
|
|
138
|
+
const before = Date.now()
|
|
139
|
+
|
|
140
|
+
await saveVerificationToken(user.id, 'a'.repeat(32), VerificationTokenType.PASSWORD_RESET)
|
|
141
|
+
|
|
142
|
+
const saved = await row(user.id)
|
|
143
|
+
expect(saved.token).toBe(hashVerificationToken('a'.repeat(32)))
|
|
144
|
+
expect(saved.token).not.toContain('a'.repeat(32))
|
|
145
|
+
expect(new Date(saved.expiresAt).getTime()).toBeGreaterThanOrEqual(before + HOUR)
|
|
146
|
+
expect(new Date(saved.expiresAt).getTime()).toBeLessThanOrEqual(Date.now() + HOUR)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('password reset', () => {
|
|
151
|
+
it('the mailed link resets the password, once', async () => {
|
|
152
|
+
const user = await person()
|
|
153
|
+
await sendPasswordResetEmail({ user, context })
|
|
154
|
+
const token = tokenInLastMail(RESET_PASSWORD_PATH)
|
|
155
|
+
|
|
156
|
+
await resetPassword(token, NEW, context)
|
|
157
|
+
|
|
158
|
+
const after = await getRepository(User).findOneBy({ id: user.id })
|
|
159
|
+
expect(User.verify(after.password, NEW, after.salt)).toBe(true)
|
|
160
|
+
await expect(resetPassword(token, OLD, context)).rejects.toThrow('text.invalid verification token')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('refuses the link after it expires', async () => {
|
|
164
|
+
const user = await person()
|
|
165
|
+
await sendPasswordResetEmail({ user, context })
|
|
166
|
+
const token = tokenInLastMail(RESET_PASSWORD_PATH)
|
|
167
|
+
|
|
168
|
+
await expireAt(user.id, new Date(Date.now() - 1))
|
|
169
|
+
|
|
170
|
+
await expect(resetPassword(token, NEW, context)).rejects.toThrow('text.invalid verification token')
|
|
171
|
+
const after = await getRepository(User).findOneBy({ id: user.id })
|
|
172
|
+
expect(User.verify(after.password, OLD, after.salt)).toBe(true)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('refuses a row from before — the token as written, and no expiry', async () => {
|
|
176
|
+
const user = await person()
|
|
177
|
+
await getRepository(VerificationToken).save({
|
|
178
|
+
userId: user.id,
|
|
179
|
+
token: 'b'.repeat(32),
|
|
180
|
+
type: VerificationTokenType.PASSWORD_RESET
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
await expect(resetPassword('b'.repeat(32), NEW, context)).rejects.toThrow('text.invalid verification token')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('refuses a row whose expiry is missing, even when the hash matches', async () => {
|
|
187
|
+
/*
|
|
188
|
+
* The case above is refused twice over — the raw value does not hash to itself — so it cannot
|
|
189
|
+
* tell whether "no expiry" refuses. This one leaves only the expiry to decide.
|
|
190
|
+
*/
|
|
191
|
+
const user = await person()
|
|
192
|
+
await sendPasswordResetEmail({ user, context })
|
|
193
|
+
const token = tokenInLastMail(RESET_PASSWORD_PATH)
|
|
194
|
+
|
|
195
|
+
await expireAt(user.id, null)
|
|
196
|
+
|
|
197
|
+
await expect(resetPassword(token, NEW, context)).rejects.toThrow('text.invalid verification token')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('does not find a token by the value stored in the table', async () => {
|
|
201
|
+
/* ⚠ A copy of the table is not a set of reset links: what it holds is not what the link carries. */
|
|
202
|
+
const user = await person()
|
|
203
|
+
await sendPasswordResetEmail({ user, context })
|
|
204
|
+
const stored = (await row(user.id)).token
|
|
205
|
+
|
|
206
|
+
await expect(resetPassword(stored, NEW, context)).rejects.toThrow('text.invalid verification token')
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
describe('unlock', () => {
|
|
211
|
+
it('the mailed link unlocks the account', async () => {
|
|
212
|
+
const user = await person('locked')
|
|
213
|
+
await sendUnlockUserEmail({ user, context })
|
|
214
|
+
|
|
215
|
+
expect(await unlockUser(tokenInLastMail(UNLOCK_USER_PATH), NEW)).toBe(true)
|
|
216
|
+
expect((await getRepository(User).findOneBy({ id: user.id })).status).toBe('activated')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('refuses the link after it expires', async () => {
|
|
220
|
+
const user = await person('locked')
|
|
221
|
+
await sendUnlockUserEmail({ user, context })
|
|
222
|
+
const token = tokenInLastMail(UNLOCK_USER_PATH)
|
|
223
|
+
|
|
224
|
+
await expireAt(user.id, new Date(Date.now() - 1))
|
|
225
|
+
|
|
226
|
+
expect(await unlockUser(token, NEW)).toBe(false)
|
|
227
|
+
expect((await getRepository(User).findOneBy({ id: user.id })).status).toBe('locked')
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
describe('sign-up confirmation', () => {
|
|
232
|
+
it('the mailed link activates the account', async () => {
|
|
233
|
+
const user = await person('inactive')
|
|
234
|
+
await sendVerificationEmail({ user, context })
|
|
235
|
+
|
|
236
|
+
await verify(tokenInLastMail(VERIFY_EMAIL_PATH))
|
|
237
|
+
|
|
238
|
+
expect((await getRepository(User).findOneBy({ id: user.id })).status).toBe('activated')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('refuses the link after it expires', async () => {
|
|
242
|
+
const user = await person('inactive')
|
|
243
|
+
await sendVerificationEmail({ user, context })
|
|
244
|
+
const token = tokenInLastMail(VERIFY_EMAIL_PATH)
|
|
245
|
+
|
|
246
|
+
await expireAt(user.id, new Date(Date.now() - 1))
|
|
247
|
+
|
|
248
|
+
await expect(verify(token)).rejects.toThrow()
|
|
249
|
+
expect((await getRepository(User).findOneBy({ id: user.id })).status).toBe('inactive')
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('does not take a reset link for a confirmation', async () => {
|
|
253
|
+
/* It read the token alone, so any live token of any type activated the account. */
|
|
254
|
+
const user = await person('inactive')
|
|
255
|
+
await sendPasswordResetEmail({ user, context })
|
|
256
|
+
|
|
257
|
+
await expect(verify(tokenInLastMail(RESET_PASSWORD_PATH))).rejects.toThrow()
|
|
258
|
+
expect((await getRepository(User).findOneBy({ id: user.id })).status).toBe('inactive')
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
describe('⚠ one token per person, of any type', () => {
|
|
263
|
+
it('a reset request replaces a pending sign-up confirmation', async () => {
|
|
264
|
+
/*
|
|
265
|
+
* The primary key is `userId`. Pinned as it is today — the architect asked for it to be written
|
|
266
|
+
* down, and whether to change it is ranked in `docs/design/auth-rebuild.md` §8-7. When the key
|
|
267
|
+
* grows a type, this flips.
|
|
268
|
+
*/
|
|
269
|
+
const user = await person('inactive')
|
|
270
|
+
await sendVerificationEmail({ user, context })
|
|
271
|
+
const confirmation = tokenInLastMail(VERIFY_EMAIL_PATH)
|
|
272
|
+
|
|
273
|
+
await sendPasswordResetEmail({ user, context })
|
|
274
|
+
|
|
275
|
+
await expect(verify(confirmation)).rejects.toThrow()
|
|
276
|
+
})
|
|
277
|
+
})
|