@stamhoofd/backend 2.127.0 → 2.128.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 +17 -17
- package/src/crons/stripe-invoices.ts +1 -1
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +5 -22
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.test.ts +410 -0
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +462 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +2 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +16 -1
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +9 -2
- package/src/helpers/MemberUserSyncer.ts +2 -21
- package/src/helpers/StripeInvoicer.ts +21 -4
- package/src/migrations/1783097842-default-member-security-code-email-template.sql +3 -0
- package/src/{migrations/1761665607-sync-member-users.ts → seeds/1783100286-sync-member-users-security-codes.ts} +2 -7
- package/src/services/SMSService.test.ts +48 -0
- package/src/services/SMSService.ts +101 -0
- package/tests/helpers/SMSMocker.ts +61 -0
- package/tests/init/index.ts +1 -0
- package/tests/init/initSMSApi.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/backend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.128.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -57,20 +57,20 @@
|
|
|
57
57
|
"@simonbackx/simple-endpoints": "1.21.1",
|
|
58
58
|
"@simonbackx/simple-errors": "1.5.0",
|
|
59
59
|
"@simonbackx/simple-logging": "1.0.1",
|
|
60
|
-
"@stamhoofd/backend-env": "2.
|
|
61
|
-
"@stamhoofd/backend-i18n": "2.
|
|
62
|
-
"@stamhoofd/backend-middleware": "2.
|
|
63
|
-
"@stamhoofd/crons": "2.
|
|
64
|
-
"@stamhoofd/email": "2.
|
|
65
|
-
"@stamhoofd/excel-writer": "2.
|
|
66
|
-
"@stamhoofd/logging": "2.
|
|
67
|
-
"@stamhoofd/models": "2.
|
|
68
|
-
"@stamhoofd/object-differ": "2.
|
|
69
|
-
"@stamhoofd/queues": "2.
|
|
70
|
-
"@stamhoofd/sql": "2.
|
|
71
|
-
"@stamhoofd/structures": "2.
|
|
72
|
-
"@stamhoofd/types": "2.
|
|
73
|
-
"@stamhoofd/utility": "2.
|
|
60
|
+
"@stamhoofd/backend-env": "2.128.0",
|
|
61
|
+
"@stamhoofd/backend-i18n": "2.128.0",
|
|
62
|
+
"@stamhoofd/backend-middleware": "2.128.0",
|
|
63
|
+
"@stamhoofd/crons": "2.128.0",
|
|
64
|
+
"@stamhoofd/email": "2.128.0",
|
|
65
|
+
"@stamhoofd/excel-writer": "2.128.0",
|
|
66
|
+
"@stamhoofd/logging": "2.128.0",
|
|
67
|
+
"@stamhoofd/models": "2.128.0",
|
|
68
|
+
"@stamhoofd/object-differ": "2.128.0",
|
|
69
|
+
"@stamhoofd/queues": "2.128.0",
|
|
70
|
+
"@stamhoofd/sql": "2.128.0",
|
|
71
|
+
"@stamhoofd/structures": "2.128.0",
|
|
72
|
+
"@stamhoofd/types": "2.128.0",
|
|
73
|
+
"@stamhoofd/utility": "2.128.0",
|
|
74
74
|
"archiver": "7.0.1",
|
|
75
75
|
"axios": "1.16.0",
|
|
76
76
|
"base-x": "3.0.11",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"stripe": "16.12.0"
|
|
92
92
|
},
|
|
93
93
|
"devDependencies": {
|
|
94
|
-
"@stamhoofd/test-utils": "2.
|
|
94
|
+
"@stamhoofd/test-utils": "2.128.0",
|
|
95
95
|
"@types/cookie": "0.6.0",
|
|
96
96
|
"@types/luxon": "3.7.1",
|
|
97
97
|
"@types/mailparser": "3.4.6",
|
|
@@ -107,5 +107,5 @@
|
|
|
107
107
|
"publishConfig": {
|
|
108
108
|
"access": "public"
|
|
109
109
|
},
|
|
110
|
-
"gitHead": "
|
|
110
|
+
"gitHead": "77c7e13c592a4d51542716d204c3b39dd9d28ec7"
|
|
111
111
|
}
|
|
@@ -44,6 +44,6 @@ async function createStripeInvoices() {
|
|
|
44
44
|
const invoicer = new StripeInvoicer({
|
|
45
45
|
secretKey: STAMHOOFD.STRIPE_SECRET_KEY,
|
|
46
46
|
});
|
|
47
|
-
await invoicer.generateAllInvoices(membershipOrganization);
|
|
47
|
+
await invoicer.generateAllInvoices(membershipOrganization, { forceLast: Formatter.dateIso(today) === '2026-07-03' });
|
|
48
48
|
lastStripeInvoice = new Date();
|
|
49
49
|
}
|
|
@@ -1012,27 +1012,7 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
|
|
|
1012
1012
|
}
|
|
1013
1013
|
|
|
1014
1014
|
static async checkCanAccessMember(member: MemberWithUsersRegistrationsAndGroups, securityCode: string | null | undefined, type: 'put' | 'patch') {
|
|
1015
|
-
|
|
1016
|
-
if (STAMHOOFD.userMode === 'organization') {
|
|
1017
|
-
if ((type === 'put' && await member.isSafeToMergeDuplicateWithoutSecurityCode()) || await Context.auth.canAccessMember(member, PermissionLevel.Write)) {
|
|
1018
|
-
console.log('checkSecurityCode: allowed for ' + member.id);
|
|
1019
|
-
return;
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
if (type === 'patch') {
|
|
1023
|
-
throw Context.auth.memberNotFoundOrNoAccess();
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
throw new SimpleError({
|
|
1027
|
-
code: 'known_member_missing_rights',
|
|
1028
|
-
message: 'Creating known member without sufficient access rights',
|
|
1029
|
-
// different message for userMode organization because security codes are not available in that mode
|
|
1030
|
-
human: $t(`%1Oz`, { member: member.details.firstName }),
|
|
1031
|
-
statusCode: 400,
|
|
1032
|
-
});
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
if ((type === 'put' && await member.isSafeToMergeDuplicateWithoutSecurityCode()) || await Context.auth.canAccessMember(member, PermissionLevel.Write)) {
|
|
1015
|
+
if ((type === 'put' && await member.isSafeToMergeDuplicateWithoutSecurityCode(Context.auth.user.email)) || await Context.auth.canAccessMember(member, PermissionLevel.Write)) {
|
|
1036
1016
|
console.log('checkSecurityCode: without security code: allowed for ' + member.id);
|
|
1037
1017
|
} else if (securityCode) {
|
|
1038
1018
|
await this.checkSecurityCode(member, securityCode);
|
|
@@ -1044,7 +1024,10 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
|
|
|
1044
1024
|
throw new SimpleError({
|
|
1045
1025
|
code: 'known_member_missing_rights',
|
|
1046
1026
|
message: 'Creating known member without sufficient access rights',
|
|
1047
|
-
human: $t(`%
|
|
1027
|
+
human: $t(`%ZbD`, {
|
|
1028
|
+
member: member.details.firstName,
|
|
1029
|
+
email: Context.auth.user.email,
|
|
1030
|
+
}),
|
|
1048
1031
|
statusCode: 400,
|
|
1049
1032
|
});
|
|
1050
1033
|
}
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { EmailMocker } from '@stamhoofd/email';
|
|
3
|
+
import type { RateLimiter } from '@stamhoofd/models';
|
|
4
|
+
import { EmailTemplateFactory, Member, MemberFactory, OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
5
|
+
import { EmailTemplateType, MemberDetails, Parent, ParentType, SecurityCodeSendMethod, SendMemberSecurityCodeRequest } from '@stamhoofd/structures';
|
|
6
|
+
import { STExpect, TestUtils } from '@stamhoofd/test-utils';
|
|
7
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
8
|
+
import type { SMSMocker } from '../../../../tests/helpers/SMSMocker.js';
|
|
9
|
+
import { testServer } from '../../../../tests/helpers/TestServer.js';
|
|
10
|
+
import { initSMSApi } from '../../../../tests/init/index.js';
|
|
11
|
+
import { memberPhoneLookupLimiter, memberSecurityCodeSendLimiter, nameSecurityCodeSendLimiter, SendMemberSecurityCodeEndpoint, smsOrganizationLimiter, userSecurityCodeSendLimiter } from './SendMemberSecurityCodeEndpoint.js';
|
|
12
|
+
|
|
13
|
+
const baseUrl = `/members/security-code`;
|
|
14
|
+
const endpoint = new SendMemberSecurityCodeEndpoint();
|
|
15
|
+
|
|
16
|
+
const securityCode = 'ABCD1234WXYZ5678';
|
|
17
|
+
const formattedCode = 'ABCD-1234-WXYZ-5678';
|
|
18
|
+
const memberPhone = '+32 470 12 34 56'; // -> 32470123456
|
|
19
|
+
const parentPhone = '+32 471 98 76 54'; // -> 32471987654
|
|
20
|
+
|
|
21
|
+
function resetLimiter(limiter: RateLimiter) {
|
|
22
|
+
for (const window of limiter.windows) {
|
|
23
|
+
window.windows.clear();
|
|
24
|
+
window.start = new Date();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('Endpoint.SendMemberSecurityCode', () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
TestUtils.setEnvironment('userMode', 'organization');
|
|
31
|
+
|
|
32
|
+
// Rate limiters are in-memory singletons, reset them between tests
|
|
33
|
+
resetLimiter(memberSecurityCodeSendLimiter);
|
|
34
|
+
resetLimiter(nameSecurityCodeSendLimiter);
|
|
35
|
+
resetLimiter(userSecurityCodeSendLimiter);
|
|
36
|
+
resetLimiter(smsOrganizationLimiter);
|
|
37
|
+
resetLimiter(memberPhoneLookupLimiter);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
async function setup(overrides: { phone?: string | null; parents?: Parent[] } = {}) {
|
|
41
|
+
const organization = await new OrganizationFactory({}).create();
|
|
42
|
+
await new EmailTemplateFactory({ type: EmailTemplateType.MemberSecurityCode }).create();
|
|
43
|
+
|
|
44
|
+
const user = await new UserFactory({ organization }).create();
|
|
45
|
+
const token = await Token.createToken(user);
|
|
46
|
+
|
|
47
|
+
const details = MemberDetails.create({
|
|
48
|
+
firstName: 'Jef',
|
|
49
|
+
lastName: 'Testman',
|
|
50
|
+
birthDay: new Date(Date.UTC(2010, 4, 5)),
|
|
51
|
+
email: 'kid@example.com',
|
|
52
|
+
phone: overrides.phone !== undefined ? overrides.phone : memberPhone,
|
|
53
|
+
securityCode,
|
|
54
|
+
parents: overrides.parents !== undefined
|
|
55
|
+
? overrides.parents
|
|
56
|
+
: [
|
|
57
|
+
Parent.create({
|
|
58
|
+
type: ParentType.Mother,
|
|
59
|
+
firstName: 'Anna',
|
|
60
|
+
lastName: 'Testman',
|
|
61
|
+
email: 'parent@example.com',
|
|
62
|
+
phone: parentPhone,
|
|
63
|
+
}),
|
|
64
|
+
],
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const member = await new MemberFactory({ organization, details }).create();
|
|
68
|
+
return { organization, user, token, member };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildRequest(host: string, token: Token, body: SendMemberSecurityCodeRequest) {
|
|
72
|
+
const request = Request.buildJson('POST', baseUrl, host, body);
|
|
73
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
74
|
+
return request;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test('sends the code via email to all known email addresses', async () => {
|
|
78
|
+
const { organization, token } = await setup();
|
|
79
|
+
|
|
80
|
+
const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
|
|
81
|
+
firstName: 'Jef',
|
|
82
|
+
lastName: 'Testman',
|
|
83
|
+
birthDay: new Date(Date.UTC(2010, 4, 5)),
|
|
84
|
+
method: SecurityCodeSendMethod.Email,
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
const response = await testServer.test(endpoint, request);
|
|
88
|
+
expect(response.body.method).toBe(SecurityCodeSendMethod.Email);
|
|
89
|
+
|
|
90
|
+
const emails = await EmailMocker.transactional.getSucceededEmails();
|
|
91
|
+
expect(emails.length).toBe(2);
|
|
92
|
+
const recipients = emails.map(e => e.to).join(', ');
|
|
93
|
+
expect(recipients).toContain('kid@example.com');
|
|
94
|
+
expect(recipients).toContain('parent@example.com');
|
|
95
|
+
for (const email of emails) {
|
|
96
|
+
expect(email.text).toContain(formattedCode);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('sends the code via SMS to the member phone number', async () => {
|
|
101
|
+
const mocker: SMSMocker = initSMSApi();
|
|
102
|
+
const { organization, token } = await setup();
|
|
103
|
+
|
|
104
|
+
const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
|
|
105
|
+
firstName: 'Jef',
|
|
106
|
+
lastName: 'Testman',
|
|
107
|
+
birthDay: new Date(Date.UTC(2010, 4, 5)),
|
|
108
|
+
method: SecurityCodeSendMethod.SMS,
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
const response = await testServer.test(endpoint, request);
|
|
112
|
+
|
|
113
|
+
expect(response.body.method).toBe(SecurityCodeSendMethod.SMS);
|
|
114
|
+
expect(response.body.maskedRecipient).toBe('•••• 56');
|
|
115
|
+
|
|
116
|
+
expect(mocker.sentMessages.length).toBe(1);
|
|
117
|
+
expect(mocker.lastMessage!.recipient).toEqual(32470123456);
|
|
118
|
+
expect(mocker.lastMessage!.message).toContain(formattedCode);
|
|
119
|
+
|
|
120
|
+
// No emails should have been sent
|
|
121
|
+
expect(await EmailMocker.transactional.getSucceededCount()).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('cycles to the next phone number on retries', async () => {
|
|
125
|
+
const mocker: SMSMocker = initSMSApi();
|
|
126
|
+
const { organization, token } = await setup();
|
|
127
|
+
|
|
128
|
+
const body = SendMemberSecurityCodeRequest.create({
|
|
129
|
+
firstName: 'Jef',
|
|
130
|
+
lastName: 'Testman',
|
|
131
|
+
birthDay: new Date(Date.UTC(2010, 4, 5)),
|
|
132
|
+
method: SecurityCodeSendMethod.SMS,
|
|
133
|
+
tryCount: 0,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await testServer.test(endpoint, buildRequest(organization.getApiHost(), token, body));
|
|
137
|
+
expect(mocker.lastMessage!.recipient).toEqual(32470123456);
|
|
138
|
+
|
|
139
|
+
// Allow a new send for the same member (5 minute limit)
|
|
140
|
+
resetLimiter(memberSecurityCodeSendLimiter);
|
|
141
|
+
|
|
142
|
+
const retry = SendMemberSecurityCodeRequest.create({
|
|
143
|
+
firstName: 'Jef',
|
|
144
|
+
lastName: 'Testman',
|
|
145
|
+
birthDay: new Date(Date.UTC(2010, 4, 5)),
|
|
146
|
+
method: SecurityCodeSendMethod.SMS,
|
|
147
|
+
tryCount: 1,
|
|
148
|
+
});
|
|
149
|
+
const response = await testServer.test(endpoint, buildRequest(organization.getApiHost(), token, retry));
|
|
150
|
+
|
|
151
|
+
expect(mocker.sentMessages.length).toBe(2);
|
|
152
|
+
expect(mocker.lastMessage!.recipient).toEqual(32471987654);
|
|
153
|
+
expect(response.body.maskedRecipient).toBe('•••• 54');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('only sends to the provided phone number when it matches a known number', async () => {
|
|
157
|
+
const mocker: SMSMocker = initSMSApi();
|
|
158
|
+
const { organization, token } = await setup();
|
|
159
|
+
|
|
160
|
+
// Provide the parent number in national format. Even though tryCount 0 would normally select the
|
|
161
|
+
// member number, an exact (normalized) match must win.
|
|
162
|
+
const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
|
|
163
|
+
firstName: 'Jef',
|
|
164
|
+
lastName: 'Testman',
|
|
165
|
+
birthDay: new Date(Date.UTC(2010, 4, 5)),
|
|
166
|
+
method: SecurityCodeSendMethod.SMS,
|
|
167
|
+
tryCount: 0,
|
|
168
|
+
phone: '0471 98 76 54',
|
|
169
|
+
}));
|
|
170
|
+
|
|
171
|
+
const response = await testServer.test(endpoint, request);
|
|
172
|
+
|
|
173
|
+
expect(mocker.sentMessages.length).toBe(1);
|
|
174
|
+
expect(mocker.lastMessage!.recipient).toEqual(32471987654);
|
|
175
|
+
expect(response.body.maskedRecipient).toBe('•••• 54');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('throws when the provided phone number is not known for the member', async () => {
|
|
179
|
+
const mocker: SMSMocker = initSMSApi();
|
|
180
|
+
const { organization, token } = await setup();
|
|
181
|
+
|
|
182
|
+
const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
|
|
183
|
+
firstName: 'Jef',
|
|
184
|
+
lastName: 'Testman',
|
|
185
|
+
birthDay: new Date(Date.UTC(2010, 4, 5)),
|
|
186
|
+
method: SecurityCodeSendMethod.SMS,
|
|
187
|
+
phone: '+32 490 00 00 00',
|
|
188
|
+
}));
|
|
189
|
+
|
|
190
|
+
await expect(testServer.test(endpoint, request))
|
|
191
|
+
.rejects
|
|
192
|
+
.toThrow(STExpect.errorWithCode('phone_not_found'));
|
|
193
|
+
|
|
194
|
+
// No SMS may be sent to an unknown number
|
|
195
|
+
expect(mocker.sentMessages.length).toBe(0);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('rate limits phone number lookups per member to prevent enumeration', async () => {
|
|
199
|
+
const mocker: SMSMocker = initSMSApi();
|
|
200
|
+
const { organization, token, member } = await setup();
|
|
201
|
+
|
|
202
|
+
// Simulate that the hourly phone lookup limit for this member is already reached
|
|
203
|
+
for (let i = 0; i < 10; i++) {
|
|
204
|
+
memberPhoneLookupLimiter.track(member.id, 1);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
|
|
208
|
+
firstName: 'Jef',
|
|
209
|
+
lastName: 'Testman',
|
|
210
|
+
birthDay: new Date(Date.UTC(2010, 4, 5)),
|
|
211
|
+
method: SecurityCodeSendMethod.SMS,
|
|
212
|
+
phone: '+32 490 00 00 00',
|
|
213
|
+
}));
|
|
214
|
+
|
|
215
|
+
await expect(testServer.test(endpoint, request))
|
|
216
|
+
.rejects
|
|
217
|
+
.toThrow(STExpect.errorWithCode('too_many_requests'));
|
|
218
|
+
expect(mocker.sentMessages.length).toBe(0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('can look up the member by id', async () => {
|
|
222
|
+
const { organization, token, member } = await setup();
|
|
223
|
+
|
|
224
|
+
const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
|
|
225
|
+
memberId: member.id,
|
|
226
|
+
method: SecurityCodeSendMethod.Email,
|
|
227
|
+
}));
|
|
228
|
+
|
|
229
|
+
const response = await testServer.test(endpoint, request);
|
|
230
|
+
expect(response.body.method).toBe(SecurityCodeSendMethod.Email);
|
|
231
|
+
expect(await EmailMocker.transactional.getSucceededCount()).toBe(2);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('generates a security code if the member has none', async () => {
|
|
235
|
+
const { organization, token, member } = await setup();
|
|
236
|
+
|
|
237
|
+
// Simulate an organization-mode member without a security code
|
|
238
|
+
member.details.securityCode = null;
|
|
239
|
+
await member.save();
|
|
240
|
+
|
|
241
|
+
const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
|
|
242
|
+
firstName: 'Jef',
|
|
243
|
+
lastName: 'Testman',
|
|
244
|
+
birthDay: new Date(Date.UTC(2010, 4, 5)),
|
|
245
|
+
method: SecurityCodeSendMethod.Email,
|
|
246
|
+
}));
|
|
247
|
+
|
|
248
|
+
await testServer.test(endpoint, request);
|
|
249
|
+
|
|
250
|
+
const updated = await Member.getByID(member.id);
|
|
251
|
+
expect(updated!.details.securityCode).toBeTruthy();
|
|
252
|
+
expect(updated!.details.securityCode!.length).toBe(16);
|
|
253
|
+
|
|
254
|
+
const formatted = Formatter.spaceString(updated!.details.securityCode!, 4, '-');
|
|
255
|
+
const emails = await EmailMocker.transactional.getSucceededEmails();
|
|
256
|
+
expect(emails.length).toBe(2);
|
|
257
|
+
expect(emails[0].text).toContain(formatted);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('throws when no member is found', async () => {
|
|
261
|
+
const { organization, token } = await setup();
|
|
262
|
+
|
|
263
|
+
const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
|
|
264
|
+
firstName: 'Unknown',
|
|
265
|
+
lastName: 'Person',
|
|
266
|
+
birthDay: new Date(Date.UTC(2000, 0, 1)),
|
|
267
|
+
method: SecurityCodeSendMethod.Email,
|
|
268
|
+
}));
|
|
269
|
+
|
|
270
|
+
await expect(testServer.test(endpoint, request))
|
|
271
|
+
.rejects
|
|
272
|
+
.toThrow(STExpect.errorWithCode('member_not_found'));
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('throws when the member has no phone number for SMS', async () => {
|
|
276
|
+
initSMSApi();
|
|
277
|
+
const { organization, token } = await setup({ phone: null, parents: [] });
|
|
278
|
+
|
|
279
|
+
const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
|
|
280
|
+
firstName: 'Jef',
|
|
281
|
+
lastName: 'Testman',
|
|
282
|
+
birthDay: new Date(Date.UTC(2010, 4, 5)),
|
|
283
|
+
method: SecurityCodeSendMethod.SMS,
|
|
284
|
+
}));
|
|
285
|
+
|
|
286
|
+
await expect(testServer.test(endpoint, request))
|
|
287
|
+
.rejects
|
|
288
|
+
.toThrow(STExpect.errorWithCode('no_phone'));
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('rate limits sending per member', async () => {
|
|
292
|
+
const { organization, token } = await setup();
|
|
293
|
+
|
|
294
|
+
const body = () => SendMemberSecurityCodeRequest.create({
|
|
295
|
+
firstName: 'Jef',
|
|
296
|
+
lastName: 'Testman',
|
|
297
|
+
birthDay: new Date(Date.UTC(2010, 4, 5)),
|
|
298
|
+
method: SecurityCodeSendMethod.Email,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
await testServer.test(endpoint, buildRequest(organization.getApiHost(), token, body()));
|
|
302
|
+
|
|
303
|
+
await expect(testServer.test(endpoint, buildRequest(organization.getApiHost(), token, body())))
|
|
304
|
+
.rejects
|
|
305
|
+
.toThrow(STExpect.errorWithCode('too_many_requests'));
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('rate limits sending SMS per member', async () => {
|
|
309
|
+
const mocker: SMSMocker = initSMSApi();
|
|
310
|
+
|
|
311
|
+
const { organization, token } = await setup();
|
|
312
|
+
|
|
313
|
+
const body = () => SendMemberSecurityCodeRequest.create({
|
|
314
|
+
firstName: 'Jef',
|
|
315
|
+
lastName: 'Testman',
|
|
316
|
+
birthDay: new Date(Date.UTC(2010, 4, 5)),
|
|
317
|
+
method: SecurityCodeSendMethod.SMS,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
await testServer.test(endpoint, buildRequest(organization.getApiHost(), token, body()));
|
|
321
|
+
await testServer.test(endpoint, buildRequest(organization.getApiHost(), token, body()));
|
|
322
|
+
await testServer.test(endpoint, buildRequest(organization.getApiHost(), token, body()));
|
|
323
|
+
|
|
324
|
+
await expect(testServer.test(endpoint, buildRequest(organization.getApiHost(), token, body())))
|
|
325
|
+
.rejects
|
|
326
|
+
.toThrow(STExpect.errorWithCode('too_many_requests'));
|
|
327
|
+
expect(mocker.sentMessages.length).toBe(3);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('rate limits by member name and organization to prevent birthday guessing', async () => {
|
|
331
|
+
const { organization, token } = await setup();
|
|
332
|
+
|
|
333
|
+
// Simulate that the hourly limit for this member name in this organization is already reached
|
|
334
|
+
const nameKey = organization.id + ':jef testman';
|
|
335
|
+
for (let i = 0; i < 10; i++) {
|
|
336
|
+
nameSecurityCodeSendLimiter.track(nameKey, 1);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Even with a wrong birth day, the request is blocked before the member lookup happens
|
|
340
|
+
const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
|
|
341
|
+
firstName: 'Jef',
|
|
342
|
+
lastName: 'Testman',
|
|
343
|
+
birthDay: new Date(Date.UTC(1990, 0, 1)),
|
|
344
|
+
method: SecurityCodeSendMethod.Email,
|
|
345
|
+
}));
|
|
346
|
+
|
|
347
|
+
await expect(testServer.test(endpoint, request))
|
|
348
|
+
.rejects
|
|
349
|
+
.toThrow(STExpect.errorWithCode('too_many_requests'));
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('rate limits SMS per organization to 25 per day', async () => {
|
|
353
|
+
const mocker: SMSMocker = initSMSApi();
|
|
354
|
+
const { organization, token, member } = await setup();
|
|
355
|
+
|
|
356
|
+
// Simulate that the organization already sent 25 SMS today
|
|
357
|
+
for (let i = 0; i < 25; i++) {
|
|
358
|
+
smsOrganizationLimiter.track(member.organizationId ?? organization.id, 1);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
|
|
362
|
+
firstName: 'Jef',
|
|
363
|
+
lastName: 'Testman',
|
|
364
|
+
birthDay: new Date(Date.UTC(2010, 4, 5)),
|
|
365
|
+
method: SecurityCodeSendMethod.SMS,
|
|
366
|
+
}));
|
|
367
|
+
|
|
368
|
+
await expect(testServer.test(endpoint, request))
|
|
369
|
+
.rejects
|
|
370
|
+
.toThrow(STExpect.errorWithCode('too_many_sms'));
|
|
371
|
+
expect(mocker.sentMessages.length).toBe(0);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('rate limits per user to prevent enumeration', async () => {
|
|
375
|
+
const { organization, user, token } = await setup();
|
|
376
|
+
|
|
377
|
+
// Simulate that the user already reached the hourly limit
|
|
378
|
+
for (let i = 0; i < 20; i++) {
|
|
379
|
+
userSecurityCodeSendLimiter.track(user.id, 1);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
|
|
383
|
+
firstName: 'Jef',
|
|
384
|
+
lastName: 'Testman',
|
|
385
|
+
birthDay: new Date(Date.UTC(2010, 4, 5)),
|
|
386
|
+
method: SecurityCodeSendMethod.Email,
|
|
387
|
+
}));
|
|
388
|
+
|
|
389
|
+
await expect(testServer.test(endpoint, request))
|
|
390
|
+
.rejects
|
|
391
|
+
.toThrow(STExpect.errorWithCode('too_many_requests'));
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('reports an error when the SMS gateway fails', async () => {
|
|
395
|
+
const mocker: SMSMocker = initSMSApi();
|
|
396
|
+
mocker.forceFailure();
|
|
397
|
+
const { organization, token } = await setup();
|
|
398
|
+
|
|
399
|
+
const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
|
|
400
|
+
firstName: 'Jef',
|
|
401
|
+
lastName: 'Testman',
|
|
402
|
+
birthDay: new Date(Date.UTC(2010, 4, 5)),
|
|
403
|
+
method: SecurityCodeSendMethod.SMS,
|
|
404
|
+
}));
|
|
405
|
+
|
|
406
|
+
await expect(testServer.test(endpoint, request))
|
|
407
|
+
.rejects
|
|
408
|
+
.toThrow(STExpect.errorWithCode('sms_failed'));
|
|
409
|
+
});
|
|
410
|
+
});
|