@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.127.0",
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.127.0",
61
- "@stamhoofd/backend-i18n": "2.127.0",
62
- "@stamhoofd/backend-middleware": "2.127.0",
63
- "@stamhoofd/crons": "2.127.0",
64
- "@stamhoofd/email": "2.127.0",
65
- "@stamhoofd/excel-writer": "2.127.0",
66
- "@stamhoofd/logging": "2.127.0",
67
- "@stamhoofd/models": "2.127.0",
68
- "@stamhoofd/object-differ": "2.127.0",
69
- "@stamhoofd/queues": "2.127.0",
70
- "@stamhoofd/sql": "2.127.0",
71
- "@stamhoofd/structures": "2.127.0",
72
- "@stamhoofd/types": "2.127.0",
73
- "@stamhoofd/utility": "2.127.0",
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.127.0",
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": "c3c61a7728293f5fe7b4b639f95b3cc79166265f"
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
- // 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)) {
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(`%1BA`, { member: member.details.firstName }),
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
+ });