@ossy/users 3.2.0 → 3.5.0
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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/users",
|
|
3
3
|
"description": "User domain - aggregate, events, and validators for the Ossy user model",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.5.0",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.js",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@ossy/config": "^3.0.9",
|
|
38
38
|
"@ossy/observability": "^3.0.9",
|
|
39
|
-
"@ossy/schema": "^3.0
|
|
39
|
+
"@ossy/schema": "^3.5.0",
|
|
40
40
|
"nanoid": "^5.1.11"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
@@ -48,5 +48,5 @@
|
|
|
48
48
|
"/src",
|
|
49
49
|
"README.md"
|
|
50
50
|
],
|
|
51
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "23167600f991596f122765a53f2b9df08314e115"
|
|
52
52
|
}
|
|
@@ -6,6 +6,7 @@ import { EmailRenderer } from '@ossy/email'
|
|
|
6
6
|
import { ConfigService } from '@ossy/config'
|
|
7
7
|
import { createTranslatorForBuild } from '@ossy/platform/locale'
|
|
8
8
|
import { createLogger } from '@ossy/observability'
|
|
9
|
+
import { isEmailLike } from '@ossy/schema'
|
|
9
10
|
import { createEmailChangeToken, EMAIL_CHANGE_TOKEN_TYPE } from './email-change-token.js'
|
|
10
11
|
import VerifyEmailChangeEmail, {
|
|
11
12
|
id as verifyEmailChangeEmailId,
|
|
@@ -15,8 +16,6 @@ export const metadata = { id: '@ossy/users/tasks/request-email-change' }
|
|
|
15
16
|
|
|
16
17
|
const log = createLogger('@ossy/users/tasks/request-email-change')
|
|
17
18
|
|
|
18
|
-
const isEmailLike = (maybeEmail) => /.@.+\.../.test(maybeEmail)
|
|
19
|
-
|
|
20
19
|
/**
|
|
21
20
|
* Request an email change for the authenticated user.
|
|
22
21
|
* If the new email is already used by another account, returns `{ ok: true }`
|
|
@@ -1,39 +1,214 @@
|
|
|
1
1
|
import { describe, expect, it } from '@jest/globals'
|
|
2
2
|
import { User } from './user.aggregate.js'
|
|
3
3
|
import { UsersEvents } from './users.events.js'
|
|
4
|
+
import { UserSchema } from './schema-ids.js'
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
const NOW = 1_700_000_000_000
|
|
7
|
+
|
|
8
|
+
function createdEvent (overrides = {}) {
|
|
9
|
+
return {
|
|
10
|
+
...UsersEvents.Created({
|
|
11
|
+
email: 'ada@example.com',
|
|
9
12
|
firstName: 'Ada',
|
|
10
13
|
lastName: 'Lovelace',
|
|
11
14
|
userId: 'user-1',
|
|
15
|
+
}),
|
|
16
|
+
created: NOW,
|
|
17
|
+
...overrides,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function withMeta (event, overrides = {}) {
|
|
22
|
+
return {
|
|
23
|
+
...event,
|
|
24
|
+
created: NOW + 1000,
|
|
25
|
+
...overrides,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('User.View', () => {
|
|
30
|
+
|
|
31
|
+
describe('empty event list', () => {
|
|
32
|
+
it('returns the savedState unchanged when no events are given', () => {
|
|
33
|
+
expect(User.View([], { id: 'user-1' })).toEqual({ id: 'user-1' })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('uses {} as the default savedState', () => {
|
|
37
|
+
expect(User.View([])).toEqual({})
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('Created event', () => {
|
|
42
|
+
it('sets identity and profile fields from the event', () => {
|
|
43
|
+
const result = User.View([createdEvent()])
|
|
44
|
+
expect(result).toMatchObject({
|
|
45
|
+
id: 'user-1',
|
|
46
|
+
type: 'User',
|
|
47
|
+
created: NOW,
|
|
48
|
+
email: 'ada@example.com',
|
|
49
|
+
firstName: 'Ada',
|
|
50
|
+
lastName: 'Lovelace',
|
|
51
|
+
workspaces: [],
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('preserves existing workspaces from savedState', () => {
|
|
56
|
+
const result = User.View([createdEvent()], { workspaces: ['ws-1'] })
|
|
57
|
+
expect(result.workspaces).toEqual(['ws-1'])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('uses the user schema type on Created events', () => {
|
|
61
|
+
expect(createdEvent().type).toBe(UserSchema.user)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('SignInVerified', () => {
|
|
66
|
+
it('sets verifiedAt from the first verification event', () => {
|
|
67
|
+
const result = User.View([
|
|
68
|
+
createdEvent(),
|
|
69
|
+
withMeta(UsersEvents.SignInVerified({
|
|
70
|
+
createdBy: 'user-1',
|
|
71
|
+
token: 'tok',
|
|
72
|
+
}), { created: NOW + 2000 }),
|
|
73
|
+
])
|
|
74
|
+
expect(result.verifiedAt).toBe(NOW + 2000)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('keeps the earliest verifiedAt when verified again', () => {
|
|
78
|
+
const result = User.View([
|
|
79
|
+
createdEvent(),
|
|
80
|
+
withMeta(UsersEvents.SignInVerified({
|
|
81
|
+
createdBy: 'user-1',
|
|
82
|
+
token: 'tok-1',
|
|
83
|
+
}), { created: NOW + 2000 }),
|
|
84
|
+
withMeta(UsersEvents.SignInVerified({
|
|
85
|
+
createdBy: 'user-1',
|
|
86
|
+
token: 'tok-2',
|
|
87
|
+
}), { created: NOW + 3000 }),
|
|
88
|
+
])
|
|
89
|
+
expect(result.verifiedAt).toBe(NOW + 2000)
|
|
12
90
|
})
|
|
13
|
-
|
|
14
|
-
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('NameUpdated', () => {
|
|
94
|
+
it('updates first and last name from the payload', () => {
|
|
95
|
+
const result = User.View([
|
|
96
|
+
createdEvent(),
|
|
97
|
+
withMeta(UsersEvents.NameUpdated({
|
|
98
|
+
firstName: 'Augusta',
|
|
99
|
+
lastName: 'Byron',
|
|
100
|
+
createdBy: 'user-1',
|
|
101
|
+
})),
|
|
102
|
+
])
|
|
103
|
+
expect(result.firstName).toBe('Augusta')
|
|
104
|
+
expect(result.lastName).toBe('Byron')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('keeps previous names when payload fields are empty', () => {
|
|
108
|
+
const result = User.View([
|
|
109
|
+
createdEvent(),
|
|
110
|
+
withMeta(UsersEvents.NameUpdated({
|
|
111
|
+
firstName: '',
|
|
112
|
+
lastName: '',
|
|
113
|
+
createdBy: 'user-1',
|
|
114
|
+
})),
|
|
115
|
+
])
|
|
116
|
+
expect(result.firstName).toBe('Ada')
|
|
117
|
+
expect(result.lastName).toBe('Lovelace')
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('email change', () => {
|
|
122
|
+
it('sets pendingEmail on EmailChangeRequested and updates email on EmailUpdated', () => {
|
|
123
|
+
const requested = withMeta(UsersEvents.EmailChangeRequested({
|
|
15
124
|
email: 'new@example.com',
|
|
16
125
|
createdBy: 'user-1',
|
|
17
|
-
}),
|
|
18
|
-
|
|
19
|
-
created: Date.now(),
|
|
20
|
-
}
|
|
21
|
-
const updated = {
|
|
22
|
-
...UsersEvents.EmailUpdated({
|
|
126
|
+
}), { resourceId: 'user-1', created: NOW + 1 })
|
|
127
|
+
const updated = withMeta(UsersEvents.EmailUpdated({
|
|
23
128
|
email: 'new@example.com',
|
|
24
|
-
previousEmail: '
|
|
129
|
+
previousEmail: 'ada@example.com',
|
|
130
|
+
createdBy: 'user-1',
|
|
131
|
+
}), { resourceId: 'user-1', created: NOW + 2 })
|
|
132
|
+
|
|
133
|
+
const afterRequest = User.View([createdEvent(), requested])
|
|
134
|
+
expect(afterRequest.email).toBe('ada@example.com')
|
|
135
|
+
expect(afterRequest.pendingEmail).toBe('new@example.com')
|
|
136
|
+
|
|
137
|
+
const afterConfirm = User.View([createdEvent(), requested, updated])
|
|
138
|
+
expect(afterConfirm.email).toBe('new@example.com')
|
|
139
|
+
expect(afterConfirm.pendingEmail).toBeUndefined()
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('WorkspaceJoined and WorkspaceLeft', () => {
|
|
144
|
+
it('adds a workspace id without duplicates', () => {
|
|
145
|
+
const joined = withMeta(UsersEvents.WorkspaceJoined({
|
|
146
|
+
workspaceId: 'ws-1',
|
|
147
|
+
createdBy: 'user-1',
|
|
148
|
+
}))
|
|
149
|
+
const result = User.View([createdEvent(), joined, joined])
|
|
150
|
+
expect(result.workspaces).toEqual(['ws-1'])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('removes a workspace id on WorkspaceLeft', () => {
|
|
154
|
+
const result = User.View([
|
|
155
|
+
createdEvent(),
|
|
156
|
+
withMeta(UsersEvents.WorkspaceJoined({
|
|
157
|
+
workspaceId: 'ws-1',
|
|
158
|
+
createdBy: 'user-1',
|
|
159
|
+
})),
|
|
160
|
+
withMeta(UsersEvents.WorkspaceJoined({
|
|
161
|
+
workspaceId: 'ws-2',
|
|
162
|
+
createdBy: 'user-1',
|
|
163
|
+
}), { created: NOW + 2000 }),
|
|
164
|
+
withMeta(UsersEvents.WorkspaceLeft({
|
|
165
|
+
workspaceId: 'ws-1',
|
|
166
|
+
createdBy: 'user-1',
|
|
167
|
+
}), { created: NOW + 3000 }),
|
|
168
|
+
])
|
|
169
|
+
expect(result.workspaces).toEqual(['ws-2'])
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('unknown event type', () => {
|
|
174
|
+
it('ignores unknown events and returns state unchanged', () => {
|
|
175
|
+
const unknown = { event: 'SomethingElse', payload: {} }
|
|
176
|
+
expect(User.View([createdEvent(), unknown]).email).toBe('ada@example.com')
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe('User.UserForWorkspace', () => {
|
|
182
|
+
it('projects the workspace-facing user fields', () => {
|
|
183
|
+
const projection = User.UserForWorkspace([createdEvent()])
|
|
184
|
+
expect(projection).toEqual({
|
|
185
|
+
id: 'user-1',
|
|
186
|
+
firstName: 'Ada',
|
|
187
|
+
lastName: 'Lovelace',
|
|
188
|
+
email: 'ada@example.com',
|
|
189
|
+
type: 'User',
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
describe('User.History', () => {
|
|
195
|
+
it('returns event types newest first', () => {
|
|
196
|
+
const history = User.History([
|
|
197
|
+
{ ...createdEvent(), created: NOW },
|
|
198
|
+
withMeta(UsersEvents.NameUpdated({
|
|
199
|
+
firstName: 'Augusta',
|
|
200
|
+
lastName: 'Byron',
|
|
201
|
+
createdBy: 'user-1',
|
|
202
|
+
}), { created: NOW + 5000 }),
|
|
203
|
+
withMeta(UsersEvents.SignInVerified({
|
|
25
204
|
createdBy: 'user-1',
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const afterConfirm = User.View([created, requested, updated])
|
|
36
|
-
expect(afterConfirm.email).toBe('new@example.com')
|
|
37
|
-
expect(afterConfirm.pendingEmail).toBeUndefined()
|
|
205
|
+
token: 'tok',
|
|
206
|
+
}), { created: NOW + 2000 }),
|
|
207
|
+
])
|
|
208
|
+
expect(history).toEqual([
|
|
209
|
+
{ type: 'NameUpdated', created: NOW + 5000 },
|
|
210
|
+
{ type: 'SignInVerified', created: NOW + 2000 },
|
|
211
|
+
{ type: 'Created', created: NOW },
|
|
212
|
+
])
|
|
38
213
|
})
|
|
39
214
|
})
|