@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 +17 -17
- package/src/crons/mollie-refunds.ts +1 -1
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +6 -1
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +56 -27
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.test.ts +388 -0
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +432 -0
- package/src/endpoints/global/members/shouldCheckIfMemberIsDuplicate.ts +0 -1
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +99 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +29 -19
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +3 -6
- package/src/helpers/MemberMerger.ts +16 -0
- package/src/helpers/MemberUserSyncer.test.ts +64 -1
- package/src/helpers/MemberUserSyncer.ts +61 -22
- package/src/helpers/StripeInvoicer.ts +1 -1
- 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.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.
|
|
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.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.
|
|
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": "
|
|
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 *
|
|
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('
|
|
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
|
|
37
|
-
limit:
|
|
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, '
|
|
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
|
-
|
|
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 === '
|
|
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(`%
|
|
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
|
+
});
|