@stamhoofd/backend 2.127.1 → 2.129.1

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.127.1",
3
+ "version": "2.129.1",
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.127.1",
61
- "@stamhoofd/backend-i18n": "2.127.1",
62
- "@stamhoofd/backend-middleware": "2.127.1",
63
- "@stamhoofd/crons": "2.127.1",
64
- "@stamhoofd/email": "2.127.1",
65
- "@stamhoofd/excel-writer": "2.127.1",
66
- "@stamhoofd/logging": "2.127.1",
67
- "@stamhoofd/models": "2.127.1",
68
- "@stamhoofd/object-differ": "2.127.1",
69
- "@stamhoofd/queues": "2.127.1",
70
- "@stamhoofd/sql": "2.127.1",
71
- "@stamhoofd/structures": "2.127.1",
72
- "@stamhoofd/types": "2.127.1",
73
- "@stamhoofd/utility": "2.127.1",
60
+ "@stamhoofd/backend-env": "2.129.1",
61
+ "@stamhoofd/backend-i18n": "2.129.1",
62
+ "@stamhoofd/backend-middleware": "2.129.1",
63
+ "@stamhoofd/crons": "2.129.1",
64
+ "@stamhoofd/email": "2.129.1",
65
+ "@stamhoofd/excel-writer": "2.129.1",
66
+ "@stamhoofd/logging": "2.129.1",
67
+ "@stamhoofd/models": "2.129.1",
68
+ "@stamhoofd/object-differ": "2.129.1",
69
+ "@stamhoofd/queues": "2.129.1",
70
+ "@stamhoofd/sql": "2.129.1",
71
+ "@stamhoofd/structures": "2.129.1",
72
+ "@stamhoofd/types": "2.129.1",
73
+ "@stamhoofd/utility": "2.129.1",
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.127.1",
94
+ "@stamhoofd/test-utils": "2.129.1",
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": "0e8db3ce693701ff334894c2e9e60d9a51c7286e"
110
+ "gitHead": "fed4dfbbbb768c9d57c476b93a3a42dfac6dd69b"
111
111
  }
@@ -16,7 +16,7 @@ export const MOLLIE_REFUNDS_MINIMUM_DATE = new Date('2026-06-22T00:00:00.000Z');
16
16
  let lastRun: Date | null = null;
17
17
  export async function checkMollieRefunds() {
18
18
  if (STAMHOOFD.environment !== 'development') {
19
- if (lastRun && new Date().getTime() - lastRun.getTime() < 1000 * 60 * 60 * 24) {
19
+ if (lastRun && new Date().getTime() - lastRun.getTime() < 1000 * 60 * 60 * 12) {
20
20
  return;
21
21
  }
22
22
  lastRun = new Date();
@@ -47,6 +47,7 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
47
47
  lastName,
48
48
  birthDay,
49
49
  generateData: true,
50
+ generateSensitiveData: true,
50
51
  }).create();
51
52
 
52
53
  const token = await Token.createToken(user);
@@ -94,6 +95,7 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
94
95
  lastName,
95
96
  birthDay,
96
97
  generateData: true,
98
+ generateSensitiveData: true,
97
99
  // should be member of the same organization
98
100
  organization,
99
101
  }).create();
@@ -148,6 +150,7 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
148
150
  lastName,
149
151
  birthDay,
150
152
  generateData: true,
153
+ generateSensitiveData: true,
151
154
  // should be member of the same organization
152
155
  organization,
153
156
  }).create();
@@ -157,6 +160,7 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
157
160
  lastName,
158
161
  birthDay,
159
162
  generateData: true,
163
+ generateSensitiveData: true,
160
164
  // should be member of the same organization
161
165
  organization,
162
166
  }).create();
@@ -185,7 +189,7 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
185
189
  request.headers.authorization = 'Bearer ' + token.accessToken;
186
190
  await expect(testServer.test(endpoint, request))
187
191
  .rejects
188
- .toThrow(STExpect.errorWithCode('not_found'));
192
+ .toThrow(STExpect.errorWithCode('known_member_missing_rights'));
189
193
  });
190
194
 
191
195
  test('Should be able to create duplicate from another organization in userMode organization', async () => {
@@ -215,6 +219,7 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
215
219
  lastName,
216
220
  birthDay,
217
221
  generateData: true,
222
+ generateSensitiveData: true,
218
223
  // member of other organization
219
224
  organization: otherOrganization,
220
225
  details: MemberDetails.create({
@@ -33,13 +33,38 @@ type ResponseBody = MembersBlob;
33
33
  export const securityCodeLimiter = new RateLimiter({
34
34
  limits: [
35
35
  {
36
- // Max 10 a day
37
- limit: 10,
36
+ // Max 5 per 15 minutes (this makes sure accidental enters block maximum 15 minutes and not the whole day)
37
+ limit: 5,
38
+ duration: 15 * 60 * 1000 * 60,
39
+ },
40
+ {
41
+ // Max 12 a day
42
+ limit: 12,
38
43
  duration: 24 * 60 * 1000 * 60,
39
44
  },
40
45
  ],
41
46
  });
42
47
 
48
+ export const duplicateCheckLimiter = new RateLimiter({
49
+ limits: [
50
+ {
51
+ // Max 5 requests per 5 minutes
52
+ limit: 5,
53
+ duration: 15 * 60 * 1000,
54
+ },
55
+ {
56
+ // Max 6 requests per 10 minutes
57
+ limit: 6,
58
+ duration: 15 * 60 * 1000,
59
+ },
60
+ {
61
+ // Max 7 requests per day
62
+ limit: 7,
63
+ duration: 24 * 60 * 60 * 1000,
64
+ },
65
+ ],
66
+ });
67
+
43
68
  /**
44
69
  * One endpoint to create, patch and delete members and their registrations and payments
45
70
  */
@@ -168,7 +193,7 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
168
193
  throw Context.auth.memberNotFoundOrNoAccess();
169
194
  }
170
195
 
171
- await PatchOrganizationMembersEndpoint.checkCanAccessMember(member, securityCode, 'patch');
196
+ await PatchOrganizationMembersEndpoint.checkCanAccessMember(member, securityCode, 'direct');
172
197
 
173
198
  patch = await Context.auth.filterMemberPatch(member, patch);
174
199
  const originalDetails = member.details.clone();
@@ -1011,41 +1036,27 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
1011
1036
  await log.save();
1012
1037
  }
1013
1038
 
1014
- static async checkCanAccessMember(member: MemberWithUsersRegistrationsAndGroups, securityCode: string | null | undefined, type: 'put' | 'patch') {
1015
- // do not check security code for user mode organization (throw error if not allowed)
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)) {
1039
+ static async checkCanAccessMember(member: MemberWithUsersRegistrationsAndGroups, securityCode: string | null | undefined, type: 'put' | 'patch' | 'direct') {
1040
+ if ((type !== 'direct' && await member.isSafeToMergeDuplicateWithoutSecurityCode(Context.auth.user.email)) || await Context.auth.canAccessMember(member, PermissionLevel.Write)) {
1036
1041
  console.log('checkSecurityCode: without security code: allowed for ' + member.id);
1037
1042
  } else if (securityCode) {
1038
1043
  await this.checkSecurityCode(member, securityCode);
1039
1044
  } else {
1040
- if (type === 'patch') {
1045
+ if (type === 'direct') {
1041
1046
  throw Context.auth.memberNotFoundOrNoAccess();
1042
1047
  }
1043
1048
 
1044
1049
  throw new SimpleError({
1045
1050
  code: 'known_member_missing_rights',
1046
1051
  message: 'Creating known member without sufficient access rights',
1047
- human: $t(`%1BA`, { member: member.details.firstName }),
1052
+ human: $t(`%ZbD`, {
1053
+ member: member.details.firstName,
1054
+ email: Context.auth.user.email,
1055
+ }),
1048
1056
  statusCode: 400,
1057
+ meta: {
1058
+ id: member.id,
1059
+ },
1049
1060
  });
1050
1061
  }
1051
1062
  }
@@ -1069,6 +1080,24 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
1069
1080
  return;
1070
1081
  }
1071
1082
 
1083
+ if ((!member.organizationId && !Context.auth.hasSomePlatformAccess()) || (member.organizationId && !await Context.auth.hasSomeAccess(member.organizationId))) {
1084
+ try {
1085
+ duplicateCheckLimiter.track(member.organizationId + '-' + Formatter.slug(member.details.name), 1);
1086
+ } catch (e) {
1087
+ Email.sendWebmaster({
1088
+ subject: '[Limiet] Limiet bereikt voor aantal duplicate checks per member name',
1089
+ text: $t(`%EA`) + ' ' + member.details.name + ' ' + '(Org: ' + ' ' + member.organizationId + ')' + ' by ' + Context.user?.id + '\n\n' + e.message + '\n\nStamhoofd',
1090
+ });
1091
+
1092
+ throw new SimpleError({
1093
+ code: 'too_many_tries',
1094
+ message: 'Too many names',
1095
+ human: $t(`%ZbQ`),
1096
+ field: 'details.name',
1097
+ });
1098
+ }
1099
+ }
1100
+
1072
1101
  // Check for duplicates and prevent creating a duplicate member by a user
1073
1102
  const duplicate = await this.findExistingMember(member);
1074
1103
  if (duplicate) {
@@ -0,0 +1,388 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import { EmailMocker } from '@stamhoofd/email';
3
+ import type { RateLimiter } from '@stamhoofd/models';
4
+ import { AuditLog, EmailTemplateFactory, Member, MemberFactory, OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
5
+ import { AuditLogReplacementType, AuditLogType, 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, 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(userSecurityCodeSendLimiter);
35
+ resetLimiter(smsOrganizationLimiter);
36
+ resetLimiter(memberPhoneLookupLimiter);
37
+ });
38
+
39
+ async function setup(overrides: { phone?: string | null; parents?: Parent[] } = {}) {
40
+ const organization = await new OrganizationFactory({}).create();
41
+ await new EmailTemplateFactory({ type: EmailTemplateType.MemberSecurityCode }).create();
42
+
43
+ const user = await new UserFactory({ organization }).create();
44
+ const token = await Token.createToken(user);
45
+
46
+ const details = MemberDetails.create({
47
+ firstName: 'Jef',
48
+ lastName: 'Testman',
49
+ birthDay: new Date(Date.UTC(2010, 4, 5)),
50
+ email: 'kid@example.com',
51
+ phone: overrides.phone !== undefined ? overrides.phone : memberPhone,
52
+ securityCode,
53
+ parents: overrides.parents !== undefined
54
+ ? overrides.parents
55
+ : [
56
+ Parent.create({
57
+ type: ParentType.Mother,
58
+ firstName: 'Anna',
59
+ lastName: 'Testman',
60
+ email: 'parent@example.com',
61
+ phone: parentPhone,
62
+ }),
63
+ ],
64
+ });
65
+
66
+ const member = await new MemberFactory({ organization, details }).create();
67
+ return { organization, user, token, member };
68
+ }
69
+
70
+ function buildRequest(host: string, token: Token, body: SendMemberSecurityCodeRequest) {
71
+ const request = Request.buildJson('POST', baseUrl, host, body);
72
+ request.headers.authorization = 'Bearer ' + token.accessToken;
73
+ return request;
74
+ }
75
+
76
+ test('sends the code via email to all known email addresses', async () => {
77
+ const { organization, token, member } = await setup();
78
+
79
+ const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
80
+ memberId: member.id,
81
+ method: SecurityCodeSendMethod.Email,
82
+ }));
83
+
84
+ const response = await testServer.test(endpoint, request);
85
+ expect(response.body.method).toBe(SecurityCodeSendMethod.Email);
86
+
87
+ const emails = await EmailMocker.transactional.getSucceededEmails();
88
+ expect(emails.length).toBe(2);
89
+ const recipients = emails.map(e => e.to).join(', ');
90
+ expect(recipients).toContain('kid@example.com');
91
+ expect(recipients).toContain('parent@example.com');
92
+ for (const email of emails) {
93
+ expect(email.text).toContain(formattedCode);
94
+ }
95
+ });
96
+
97
+ test('sends the code via SMS to the member phone number', async () => {
98
+ const mocker: SMSMocker = initSMSApi();
99
+ const { organization, token, member } = await setup();
100
+
101
+ const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
102
+ memberId: member.id,
103
+ method: SecurityCodeSendMethod.SMS,
104
+ }));
105
+
106
+ const response = await testServer.test(endpoint, request);
107
+
108
+ expect(response.body.method).toBe(SecurityCodeSendMethod.SMS);
109
+ expect(response.body.maskedRecipient).toBe('•• •• 56');
110
+
111
+ expect(mocker.sentMessages.length).toBe(1);
112
+ expect(mocker.lastMessage!.recipient).toEqual(32470123456);
113
+ expect(mocker.lastMessage!.message).toContain(formattedCode);
114
+
115
+ // No emails should have been sent
116
+ expect(await EmailMocker.transactional.getSucceededCount()).toBe(0);
117
+ });
118
+
119
+ test('cycles to the next phone number on retries', async () => {
120
+ const mocker: SMSMocker = initSMSApi();
121
+ const { organization, token, member } = await setup();
122
+
123
+ const body = SendMemberSecurityCodeRequest.create({
124
+ memberId: member.id,
125
+ method: SecurityCodeSendMethod.SMS,
126
+ tryCount: 0,
127
+ });
128
+
129
+ await testServer.test(endpoint, buildRequest(organization.getApiHost(), token, body));
130
+ expect(mocker.lastMessage!.recipient).toEqual(32470123456);
131
+
132
+ // Allow a new send for the same member (5 minute limit)
133
+ resetLimiter(memberSecurityCodeSendLimiter);
134
+
135
+ const retry = SendMemberSecurityCodeRequest.create({
136
+ memberId: member.id,
137
+ method: SecurityCodeSendMethod.SMS,
138
+ tryCount: 1,
139
+ });
140
+ const response = await testServer.test(endpoint, buildRequest(organization.getApiHost(), token, retry));
141
+
142
+ expect(mocker.sentMessages.length).toBe(2);
143
+ expect(mocker.lastMessage!.recipient).toEqual(32471987654);
144
+ expect(response.body.maskedRecipient).toBe('•• •• 54');
145
+ });
146
+
147
+ test('only sends to the provided phone number when it matches a known number', async () => {
148
+ const mocker: SMSMocker = initSMSApi();
149
+ const { organization, token, member } = await setup();
150
+
151
+ // Provide the parent number in national format. Even though tryCount 0 would normally select the
152
+ // member number, an exact (normalized) match must win.
153
+ const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
154
+ memberId: member.id,
155
+ method: SecurityCodeSendMethod.SMS,
156
+ tryCount: 0,
157
+ phone: '0471 98 76 54',
158
+ }));
159
+
160
+ const response = await testServer.test(endpoint, request);
161
+
162
+ expect(mocker.sentMessages.length).toBe(1);
163
+ expect(mocker.lastMessage!.recipient).toEqual(32471987654);
164
+ expect(response.body.maskedRecipient).toBe('•• •• 54');
165
+ });
166
+
167
+ test('throws when the provided phone number is not known for the member', async () => {
168
+ const mocker: SMSMocker = initSMSApi();
169
+ const { organization, token, member } = await setup();
170
+
171
+ const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
172
+ memberId: member.id,
173
+ method: SecurityCodeSendMethod.SMS,
174
+ phone: '+32 490 00 00 00',
175
+ }));
176
+
177
+ await expect(testServer.test(endpoint, request))
178
+ .rejects
179
+ .toThrow(STExpect.errorWithCode('phone_not_found'));
180
+
181
+ // No SMS may be sent to an unknown number
182
+ expect(mocker.sentMessages.length).toBe(0);
183
+ });
184
+
185
+ test('rate limits phone number lookups per member to prevent enumeration', async () => {
186
+ const mocker: SMSMocker = initSMSApi();
187
+ const { organization, token, member } = await setup();
188
+
189
+ // Simulate that the hourly phone lookup limit for this member is already reached
190
+ for (let i = 0; i < 10; i++) {
191
+ memberPhoneLookupLimiter.track(member.id, 1);
192
+ }
193
+
194
+ const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
195
+ memberId: member.id,
196
+ method: SecurityCodeSendMethod.SMS,
197
+ phone: '+32 490 00 00 00',
198
+ }));
199
+
200
+ await expect(testServer.test(endpoint, request))
201
+ .rejects
202
+ .toThrow(STExpect.errorWithCode('too_many_requests'));
203
+ expect(mocker.sentMessages.length).toBe(0);
204
+ });
205
+
206
+ test('creates an audit log when a security code is requested via email', async () => {
207
+ const { organization, user, token, member } = await setup();
208
+
209
+ await testServer.test(endpoint, buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
210
+ memberId: member.id,
211
+ method: SecurityCodeSendMethod.Email,
212
+ })));
213
+
214
+ const log = await AuditLog.select()
215
+ .where('type', AuditLogType.MemberSecurityCodeRequested)
216
+ .where('objectId', member.id)
217
+ .first(true);
218
+
219
+ expect(log.userId).toBe(user.id);
220
+ expect(log.organizationId).toBe(member.organizationId);
221
+ expect(log.replacements.get('m')).toMatchObject({
222
+ id: member.id,
223
+ value: member.details.name,
224
+ type: AuditLogReplacementType.Member,
225
+ });
226
+ expect(log.replacements.get('method')?.value).toBe('e-mail');
227
+ expect(log.replacements.get('recipient')?.value).toContain('kid@example.com');
228
+ expect(log.replacements.get('recipient')?.value).toContain('parent@example.com');
229
+ });
230
+
231
+ test('creates an audit log with the masked recipient when a security code is requested via SMS', async () => {
232
+ initSMSApi();
233
+ const { user, organization, token, member } = await setup();
234
+
235
+ await testServer.test(endpoint, buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
236
+ memberId: member.id,
237
+ method: SecurityCodeSendMethod.SMS,
238
+ })));
239
+
240
+ const log = await AuditLog.select()
241
+ .where('type', AuditLogType.MemberSecurityCodeRequested)
242
+ .where('objectId', member.id)
243
+ .first(true);
244
+
245
+ expect(log.userId).toBe(user.id);
246
+ expect(log.replacements.get('method')?.value).toBe('SMS');
247
+ expect(log.replacements.get('recipient')?.value).toBe('•• •• 56');
248
+ });
249
+
250
+ test('generates a security code if the member has none', async () => {
251
+ const { organization, token, member } = await setup();
252
+
253
+ // Simulate an organization-mode member without a security code
254
+ member.details.securityCode = null;
255
+ await member.save();
256
+
257
+ const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
258
+ memberId: member.id,
259
+ method: SecurityCodeSendMethod.Email,
260
+ }));
261
+
262
+ await testServer.test(endpoint, request);
263
+
264
+ const updated = await Member.getByID(member.id);
265
+ expect(updated!.details.securityCode).toBeTruthy();
266
+ expect(updated!.details.securityCode!.length).toBe(16);
267
+
268
+ const formatted = Formatter.spaceString(updated!.details.securityCode!, 4, '-');
269
+ const emails = await EmailMocker.transactional.getSucceededEmails();
270
+ expect(emails.length).toBe(2);
271
+ expect(emails[0].text).toContain(formatted);
272
+ });
273
+
274
+ test('throws when no member is found', async () => {
275
+ const { organization, token } = await setup();
276
+
277
+ const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
278
+ memberId: 'not-found',
279
+ method: SecurityCodeSendMethod.Email,
280
+ }));
281
+
282
+ await expect(testServer.test(endpoint, request))
283
+ .rejects
284
+ .toThrow(STExpect.errorWithCode('member_not_found'));
285
+ });
286
+
287
+ test('throws when the member has no phone number for SMS', async () => {
288
+ initSMSApi();
289
+ const { organization, token, member } = await setup({ phone: null, parents: [] });
290
+
291
+ const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
292
+ memberId: member.id,
293
+ method: SecurityCodeSendMethod.SMS,
294
+ }));
295
+
296
+ await expect(testServer.test(endpoint, request))
297
+ .rejects
298
+ .toThrow(STExpect.errorWithCode('no_phone'));
299
+ });
300
+
301
+ test('rate limits sending email per member', async () => {
302
+ const { organization, token, member } = await setup();
303
+
304
+ const body = () => SendMemberSecurityCodeRequest.create({
305
+ memberId: member.id,
306
+ method: SecurityCodeSendMethod.Email,
307
+ });
308
+
309
+ await testServer.test(endpoint, buildRequest(organization.getApiHost(), token, body()));
310
+
311
+ await expect(testServer.test(endpoint, buildRequest(organization.getApiHost(), token, body())))
312
+ .rejects
313
+ .toThrow(STExpect.errorWithCode('too_many_requests'));
314
+ });
315
+
316
+ test('rate limits sending SMS per member', async () => {
317
+ const mocker: SMSMocker = initSMSApi();
318
+
319
+ const { organization, token, member } = await setup();
320
+
321
+ const body = () => SendMemberSecurityCodeRequest.create({
322
+ memberId: member.id,
323
+ method: SecurityCodeSendMethod.SMS,
324
+ });
325
+
326
+ await testServer.test(endpoint, buildRequest(organization.getApiHost(), token, body()));
327
+ await testServer.test(endpoint, buildRequest(organization.getApiHost(), token, body()));
328
+ await testServer.test(endpoint, buildRequest(organization.getApiHost(), token, body()));
329
+
330
+ await expect(testServer.test(endpoint, buildRequest(organization.getApiHost(), token, body())))
331
+ .rejects
332
+ .toThrow(STExpect.errorWithCode('too_many_requests'));
333
+ expect(mocker.sentMessages.length).toBe(3);
334
+ });
335
+
336
+ test('rate limits SMS per organization to 25 per day', async () => {
337
+ const mocker: SMSMocker = initSMSApi();
338
+ const { organization, token, member } = await setup();
339
+
340
+ // Simulate that the organization already sent 25 SMS today
341
+ for (let i = 0; i < 25; i++) {
342
+ smsOrganizationLimiter.track(member.organizationId ?? organization.id, 1);
343
+ }
344
+
345
+ const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
346
+ memberId: member.id,
347
+ method: SecurityCodeSendMethod.SMS,
348
+ }));
349
+
350
+ await expect(testServer.test(endpoint, request))
351
+ .rejects
352
+ .toThrow(STExpect.errorWithCode('too_many_sms'));
353
+ expect(mocker.sentMessages.length).toBe(0);
354
+ });
355
+
356
+ test('rate limits per user to prevent enumeration', async () => {
357
+ const { organization, user, token, member } = await setup();
358
+
359
+ // Simulate that the user already reached the hourly limit
360
+ for (let i = 0; i < 20; i++) {
361
+ userSecurityCodeSendLimiter.track(user.id, 1);
362
+ }
363
+
364
+ const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
365
+ memberId: member.id,
366
+ method: SecurityCodeSendMethod.Email,
367
+ }));
368
+
369
+ await expect(testServer.test(endpoint, request))
370
+ .rejects
371
+ .toThrow(STExpect.errorWithCode('too_many_requests'));
372
+ });
373
+
374
+ test('reports an error when the SMS gateway fails', async () => {
375
+ const mocker: SMSMocker = initSMSApi();
376
+ mocker.forceFailure();
377
+ const { organization, token, member } = await setup();
378
+
379
+ const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
380
+ memberId: member.id,
381
+ method: SecurityCodeSendMethod.SMS,
382
+ }));
383
+
384
+ await expect(testServer.test(endpoint, request))
385
+ .rejects
386
+ .toThrow(STExpect.errorWithCode('sms_failed'));
387
+ });
388
+ });