@stamhoofd/backend 2.137.4 → 2.138.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 +26 -17
- package/src/boot.ts +5 -0
- package/src/crons/balance-emails.ts +2 -1
- package/src/crons/delete-expired-mfa-tokens.ts +35 -0
- package/src/crons/drip-emails.ts +2 -1
- package/src/crons/index.ts +1 -0
- package/src/crons/invoices.ts +5 -3
- package/src/crons.ts +4 -2
- package/src/email-recipient-loaders/orders.ts +2 -1
- package/src/email-recipient-loaders/organizations.ts +3 -2
- package/src/email-recipient-loaders/payments.ts +3 -2
- package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
- package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -2
- package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
- package/src/endpoints/auth/CreateTokenEndpoint.ts +135 -7
- package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
- package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
- package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
- package/src/endpoints/auth/ForgotPasswordEndpoint.ts +4 -2
- package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
- package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
- package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
- package/src/endpoints/auth/MFA.security.test.ts +688 -0
- package/src/endpoints/auth/MFA.test.ts +1398 -0
- package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
- package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
- package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
- package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
- package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
- package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
- package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
- package/src/endpoints/auth/SignupEndpoint.ts +17 -4
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -3
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +5 -3
- package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
- package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
- package/src/endpoints/global/files/ExportToExcelEndpoint.ts +11 -6
- package/src/endpoints/global/files/UploadFile.test.ts +206 -0
- package/src/endpoints/global/files/UploadFile.ts +31 -6
- package/src/endpoints/global/files/UploadImage.test.ts +177 -0
- package/src/endpoints/global/files/UploadImage.ts +23 -4
- package/src/endpoints/global/files/upload-security.test.ts +837 -0
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
- package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
- package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
- package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
- package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
- package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
- package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
- package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
- package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
- package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
- package/src/excel-loaders/balance-items.ts +1 -1
- package/src/excel-loaders/event-notifications.ts +6 -7
- package/src/excel-loaders/members.test.ts +2 -2
- package/src/excel-loaders/members.ts +9 -10
- package/src/excel-loaders/organizations.ts +14 -20
- package/src/excel-loaders/payments.ts +1 -1
- package/src/excel-loaders/platform-memberships.ts +7 -7
- package/src/excel-loaders/platform-sheets.test.ts +113 -0
- package/src/excel-loaders/receivable-balances.ts +1 -1
- package/src/excel-loaders/registrations.ts +9 -9
- package/src/helpers/AdminPermissionChecker.ts +1 -1
- package/src/helpers/AuthenticatedStructures.ts +21 -10
- package/src/helpers/Context.ts +114 -4
- package/src/helpers/EmailBuilder.test.ts +287 -0
- package/src/helpers/EmailBuilder.ts +811 -0
- package/src/helpers/EmailResumer.ts +2 -1
- package/src/helpers/ForwardHandler.ts +2 -1
- package/src/helpers/MFAEncryption.ts +34 -0
- package/src/helpers/MembershipCharger.ts +3 -2
- package/src/helpers/RecoveryCodeHelper.ts +81 -0
- package/src/helpers/TOTPHelper.ts +79 -0
- package/src/helpers/TenantContext.test.ts +96 -0
- package/src/helpers/TenantContext.ts +67 -0
- package/src/helpers/TwoFactorHelper.ts +356 -0
- package/src/helpers/WebauthnHelper.ts +211 -0
- package/src/helpers/data/aaguids.json +1006 -0
- package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
- package/src/middleware/TenantScopeMiddleware.ts +24 -0
- package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
- package/src/services/AdminSessionService.ts +41 -0
- package/src/services/BalanceItemService.ts +2 -1
- package/src/services/DocumentRenderService.test.ts +229 -0
- package/src/services/DocumentRenderService.ts +180 -0
- package/src/services/EmailPreviewService.test.ts +300 -0
- package/src/services/EmailPreviewService.ts +239 -0
- package/src/services/EmailSendService.test.ts +1218 -0
- package/src/services/EmailSendService.ts +705 -0
- package/src/services/EventNotificationService.ts +2 -1
- package/src/services/FileSignService.test.ts +85 -0
- package/src/services/FileSignService.ts +23 -1
- package/src/services/InvoicePdfService.ts +3 -3
- package/src/services/InvoiceService.ts +4 -2
- package/src/services/InvoiceXMLService.ts +2 -1
- package/src/services/OrderService.test.ts +308 -0
- package/src/services/OrderService.ts +215 -0
- package/src/services/OrganizationAdminService.test.ts +47 -0
- package/src/services/OrganizationAdminService.ts +139 -0
- package/src/services/OrganizationDNSService.test.ts +177 -0
- package/src/services/OrganizationDNSService.ts +282 -0
- package/src/services/OrganizationEmailService.test.ts +57 -0
- package/src/services/OrganizationEmailService.ts +212 -0
- package/src/services/PasswordForgotService.test.ts +99 -0
- package/src/services/PasswordForgotService.ts +38 -0
- package/src/services/PaymentService.ts +5 -3
- package/src/services/PlatformMembershipService.test.ts +180 -0
- package/src/services/PlatformMembershipService.ts +281 -4
- package/src/services/ReferralService.ts +3 -2
- package/src/services/RegistrationService.ts +4 -2
- package/src/services/SSOService.ts +106 -14
- package/src/services/STPackageService.test.ts +191 -0
- package/src/services/STPackageService.ts +115 -1
- package/src/services/TwoFactorAuditLogService.ts +64 -0
- package/src/services/VerificationCodeService.test.ts +71 -0
- package/src/services/VerificationCodeService.ts +101 -0
- package/tests/e2e/api-rate-limits.test.ts +1 -1
- package/tests/e2e/documents.test.ts +2 -1
- package/tests/e2e/private-files.test.ts +77 -14
- package/tests/helpers/MFATestHelper.ts +42 -0
- package/tests/helpers/TestServer.ts +4 -0
- package/tests/helpers/index.ts +1 -0
- package/tsconfig.build.json +2 -1
|
@@ -10,6 +10,7 @@ import { Context } from '../helpers/Context.js';
|
|
|
10
10
|
|
|
11
11
|
import type { ObjectWithHeaders } from '../helpers/CookieHelper.js';
|
|
12
12
|
import { CookieHelper } from '../helpers/CookieHelper.js';
|
|
13
|
+
import { TwoFactorHelper } from '../helpers/TwoFactorHelper.js';
|
|
13
14
|
|
|
14
15
|
async function randomBytes(size: number): Promise<Buffer> {
|
|
15
16
|
return new Promise((resolve, reject) => {
|
|
@@ -37,12 +38,24 @@ type SSOSessionContext = {
|
|
|
37
38
|
* Link this method to this existing user and don't create a new token
|
|
38
39
|
*/
|
|
39
40
|
userId?: string | null;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The session (access token) that has to count as freshly authenticated again once the
|
|
44
|
+
* provider confirms the identity of `userId`. Only set for a reauthentication flow.
|
|
45
|
+
*/
|
|
46
|
+
reauthenticateAccessToken?: string | null;
|
|
40
47
|
};
|
|
41
48
|
|
|
42
49
|
export class SSOAuthToken {
|
|
43
50
|
validUntil: Date;
|
|
44
51
|
token: string;
|
|
45
52
|
userId: string;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The session this token was requested with, so a reauthentication can mark it fresh
|
|
56
|
+
* again. Null for API users and other sessionless callers.
|
|
57
|
+
*/
|
|
58
|
+
accessToken: string | null = null;
|
|
46
59
|
}
|
|
47
60
|
|
|
48
61
|
export class SSOService {
|
|
@@ -76,7 +89,7 @@ export class SSOService {
|
|
|
76
89
|
}
|
|
77
90
|
}
|
|
78
91
|
|
|
79
|
-
static async createToken() {
|
|
92
|
+
static async createToken(sessionToken: Token | null = null) {
|
|
80
93
|
if (!Context.user) {
|
|
81
94
|
throw new SimpleError({
|
|
82
95
|
code: 'invalid_user',
|
|
@@ -89,6 +102,7 @@ export class SSOService {
|
|
|
89
102
|
token.validUntil = new Date(Date.now() + 1000 * 60 * 5);
|
|
90
103
|
token.token = (await randomBytes(192)).toString('base64').toUpperCase();
|
|
91
104
|
token.userId = Context.user.id;
|
|
105
|
+
token.accessToken = sessionToken?.accessToken ?? null;
|
|
92
106
|
|
|
93
107
|
await this.clearExpiredTokensOrFromUser(token.userId);
|
|
94
108
|
this.authTokens.set(token.token, token);
|
|
@@ -369,6 +383,7 @@ export class SSOService {
|
|
|
369
383
|
}
|
|
370
384
|
|
|
371
385
|
let user: User | undefined = undefined;
|
|
386
|
+
let reauthenticateAccessToken: string | null = null;
|
|
372
387
|
|
|
373
388
|
if (data.authToken) {
|
|
374
389
|
const token = await SSOService.validateToken(data.authToken);
|
|
@@ -384,10 +399,27 @@ export class SSOService {
|
|
|
384
399
|
}
|
|
385
400
|
|
|
386
401
|
this.validateEmail(user.email);
|
|
402
|
+
|
|
403
|
+
// Force reentry of login credentials (no automatic login)
|
|
404
|
+
// Sadly select_account is not reliable and sometimes works automatically
|
|
405
|
+
// since this would automatically link a possibly wrong user, we don't allow that
|
|
406
|
+
// we check this with the auth_time, so a user cannot really get around this
|
|
407
|
+
data.prompt = 'login';
|
|
408
|
+
|
|
409
|
+
if (data.reauthenticate) {
|
|
410
|
+
if (!token.accessToken) {
|
|
411
|
+
throw new SimpleError({
|
|
412
|
+
code: 'invalid_token',
|
|
413
|
+
message: 'Cannot reauthenticate without a session',
|
|
414
|
+
statusCode: 400,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
reauthenticateAccessToken = token.accessToken;
|
|
418
|
+
}
|
|
387
419
|
}
|
|
388
420
|
}
|
|
389
421
|
|
|
390
|
-
return await this.startAuthCodeFlow(redirectUri, data.spaState, data.prompt, user);
|
|
422
|
+
return await this.startAuthCodeFlow(redirectUri, data.spaState, data.prompt, user, reauthenticateAccessToken);
|
|
391
423
|
}
|
|
392
424
|
|
|
393
425
|
validateRedirectUri(uri: string) {
|
|
@@ -422,7 +454,7 @@ export class SSOService {
|
|
|
422
454
|
}
|
|
423
455
|
}
|
|
424
456
|
|
|
425
|
-
async startAuthCodeFlow(redirectUri: string, spaState: string, prompt: string | null = null, user?: User): Promise<Response<undefined>> {
|
|
457
|
+
async startAuthCodeFlow(redirectUri: string, spaState: string, prompt: string | null = null, user?: User, reauthenticateAccessToken: string | null = null): Promise<Response<undefined>> {
|
|
426
458
|
const code_verifier = generators.codeVerifier();
|
|
427
459
|
const state = generators.state(); // this is the internal state backend <-> SSO provider
|
|
428
460
|
const nonce = generators.nonce();
|
|
@@ -439,6 +471,7 @@ export class SSOService {
|
|
|
439
471
|
providerType: this.provider,
|
|
440
472
|
organizationId: this.organization?.id ?? null,
|
|
441
473
|
userId: user?.id ?? null,
|
|
474
|
+
reauthenticateAccessToken,
|
|
442
475
|
};
|
|
443
476
|
|
|
444
477
|
try {
|
|
@@ -465,7 +498,8 @@ export class SSOService {
|
|
|
465
498
|
prompt: prompt ?? undefined,
|
|
466
499
|
login_hint: user?.email ?? undefined,
|
|
467
500
|
redirect_uri: this.externalRedirectUri,
|
|
468
|
-
|
|
501
|
+
max_age: prompt === 'login' ? 0 : undefined, // required to get auth_time in the response
|
|
502
|
+
claims: prompt === 'login' ? { id_token: { auth_time: { essential: true } } } : undefined,
|
|
469
503
|
// Google has this instead of the offline_access scope
|
|
470
504
|
access_type: this.provider === LoginProviderType.Google ? 'offline' : undefined,
|
|
471
505
|
});
|
|
@@ -585,6 +619,21 @@ export class SSOServiceWithSession {
|
|
|
585
619
|
|
|
586
620
|
this.service.validateEmail(claims.email);
|
|
587
621
|
|
|
622
|
+
if (this.session.userId) {
|
|
623
|
+
if (!claims.auth_time || claims.auth_time * 1000 < Date.now() - 1000 * 60 * 5) {
|
|
624
|
+
if (STAMHOOFD.environment === 'development' || STAMHOOFD.environment === 'test') {
|
|
625
|
+
// Dex does not support this (yet)
|
|
626
|
+
} else {
|
|
627
|
+
throw new SimpleError({
|
|
628
|
+
code: 'invalid_user',
|
|
629
|
+
message: 'No recent authentication',
|
|
630
|
+
human: $t('%ZhH'),
|
|
631
|
+
statusCode: 400,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
588
637
|
// Get user from database
|
|
589
638
|
let user = await User.getForRegister(this.service.organization?.id ?? null, claims.email);
|
|
590
639
|
if (!user) {
|
|
@@ -639,23 +688,66 @@ export class SSOServiceWithSession {
|
|
|
639
688
|
const redirectUri = new URL(session.redirectUri);
|
|
640
689
|
|
|
641
690
|
if (!this.session.userId) {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
691
|
+
// The identity provider authenticated the user, but that is only the first
|
|
692
|
+
// factor. A user who enrolled a second factor still has to pass it, and an
|
|
693
|
+
// account that also has a password must still enroll one when it is
|
|
694
|
+
// required — the password would otherwise stay a way in that skips
|
|
695
|
+
// whatever the provider enforces.
|
|
696
|
+
//
|
|
697
|
+
// This is a redirect, not a request/response pair, so the client gets the
|
|
698
|
+
// token of the pending step in the URL and picks the flow up from there.
|
|
699
|
+
const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, this.service.organization, { loginMethod: 'sso' });
|
|
700
|
+
|
|
701
|
+
if (requirement.type === 'challenge') {
|
|
702
|
+
redirectUri.searchParams.set('oid_mfa', requirement.challenge.token);
|
|
703
|
+
redirectUri.searchParams.set('s', session.spaState);
|
|
704
|
+
} else if (requirement.type === 'setup') {
|
|
705
|
+
redirectUri.searchParams.set('oid_mfa_setup', requirement.setupToken.token);
|
|
706
|
+
// Which methods to offer. Only a hint for the enrollment screen: the
|
|
707
|
+
// enrollment endpoints check this themselves.
|
|
708
|
+
redirectUri.searchParams.set('oid_mfa_passkeys', user.canUsePasskeys() ? '1' : '0');
|
|
709
|
+
redirectUri.searchParams.set('s', session.spaState);
|
|
710
|
+
} else {
|
|
711
|
+
const token = await Token.createExpiredToken(user);
|
|
712
|
+
|
|
713
|
+
if (!token) {
|
|
714
|
+
throw new SimpleError({
|
|
715
|
+
code: 'error',
|
|
716
|
+
message: 'Could not generate token',
|
|
717
|
+
human: $t(`%D6`),
|
|
718
|
+
statusCode: 500,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const st = new TokenStruct(token);
|
|
723
|
+
redirectUri.searchParams.set('oid_rt', st.refreshToken);
|
|
724
|
+
redirectUri.searchParams.set('s', session.spaState);
|
|
725
|
+
}
|
|
726
|
+
} else if (this.session.reauthenticateAccessToken) {
|
|
727
|
+
// The provider confirmed the identity of the user that is already signed in,
|
|
728
|
+
// so their session counts as freshly authenticated again and the sensitive
|
|
729
|
+
// actions behind Context.authenticateFresh() unlock. This is the only way an
|
|
730
|
+
// SSO-only account can reach them: it has no password to confirm with.
|
|
731
|
+
const token = await Token.getByAccessToken(this.session.reauthenticateAccessToken);
|
|
732
|
+
|
|
733
|
+
if (!token || token.userId !== user.id) {
|
|
645
734
|
throw new SimpleError({
|
|
646
|
-
code: '
|
|
647
|
-
message: '
|
|
648
|
-
human: $t(
|
|
649
|
-
statusCode:
|
|
735
|
+
code: 'invalid_token',
|
|
736
|
+
message: 'The session to reauthenticate no longer exists',
|
|
737
|
+
human: $t('%ZhJ'),
|
|
738
|
+
statusCode: 401,
|
|
650
739
|
});
|
|
651
740
|
}
|
|
652
741
|
|
|
653
|
-
|
|
654
|
-
|
|
742
|
+
token.authenticatedAt = new Date();
|
|
743
|
+
await token.save();
|
|
744
|
+
await user.markActive();
|
|
745
|
+
|
|
655
746
|
redirectUri.searchParams.set('s', session.spaState);
|
|
747
|
+
redirectUri.searchParams.set('msg', $t('%Zhh'));
|
|
656
748
|
} else {
|
|
657
749
|
redirectUri.searchParams.set('s', session.spaState);
|
|
658
|
-
redirectUri.searchParams.set('msg', '
|
|
750
|
+
redirectUri.searchParams.set('msg', $t('%Zh3'));
|
|
659
751
|
}
|
|
660
752
|
|
|
661
753
|
response.headers['location'] = redirectUri.toString();
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { EmailMocker } from '@stamhoofd/email';
|
|
2
|
+
import { EmailTemplateFactory, OrganizationFactory, STPackage, UserFactory } from '@stamhoofd/models';
|
|
3
|
+
import { EmailTemplateType, PermissionLevel, Permissions, STPackageMeta, STPackageType } from '@stamhoofd/structures';
|
|
4
|
+
import { TestUtils } from '@stamhoofd/test-utils';
|
|
5
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
6
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
7
|
+
import { vi } from 'vitest';
|
|
8
|
+
import { STPackageService } from './STPackageService.js';
|
|
9
|
+
|
|
10
|
+
const DAY = 1000 * 60 * 60 * 24;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A global (not organization scoped) template that only contains the replacements the service fills
|
|
14
|
+
* in, so the rendered email can be asserted without depending on the real template contents.
|
|
15
|
+
*/
|
|
16
|
+
async function createExpirationTemplate(type: EmailTemplateType) {
|
|
17
|
+
await new EmailTemplateFactory({
|
|
18
|
+
type,
|
|
19
|
+
html: '<p>organizationName: {{organizationName}}</p>'
|
|
20
|
+
+ '<p>packageName: {{packageName}}</p>'
|
|
21
|
+
+ '<p>validUntil: {{validUntil}}</p>'
|
|
22
|
+
+ '<p>validUntilDate: {{validUntilDate}}</p>'
|
|
23
|
+
+ '<p>renewUrl: {{renewUrl}}</p>',
|
|
24
|
+
}).create();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function createPackage(options: {
|
|
28
|
+
organizationId: string;
|
|
29
|
+
type: STPackageType;
|
|
30
|
+
/**
|
|
31
|
+
* Defaults to 10 days from now: inside the reminder window of every package type.
|
|
32
|
+
*/
|
|
33
|
+
validUntil?: Date | null;
|
|
34
|
+
removeAt?: Date | null;
|
|
35
|
+
validAt?: Date | null;
|
|
36
|
+
}) {
|
|
37
|
+
const pack = new STPackage();
|
|
38
|
+
pack.organizationId = options.organizationId;
|
|
39
|
+
pack.meta = STPackageMeta.create({
|
|
40
|
+
type: options.type,
|
|
41
|
+
startDate: new Date(Date.now() - 365 * DAY),
|
|
42
|
+
});
|
|
43
|
+
pack.validAt = options.validAt !== undefined ? options.validAt : new Date(Date.now() - 365 * DAY);
|
|
44
|
+
pack.validUntil = options.validUntil !== undefined ? options.validUntil : new Date(Date.now() + 10 * DAY);
|
|
45
|
+
pack.removeAt = options.removeAt ?? null;
|
|
46
|
+
await pack.save();
|
|
47
|
+
return pack;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function createOrganizationWithAdmin() {
|
|
51
|
+
const organization = await new OrganizationFactory({}).create();
|
|
52
|
+
const admin = await new UserFactory({
|
|
53
|
+
organization,
|
|
54
|
+
firstName: 'Full',
|
|
55
|
+
lastName: 'Admin',
|
|
56
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
57
|
+
}).create();
|
|
58
|
+
return { organization, admin };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('STPackageService', () => {
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
vi.restoreAllMocks();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('sendExpiryEmail', () => {
|
|
67
|
+
test('Sends the reminder to the full admins of the organization and records it on the package', async () => {
|
|
68
|
+
TestUtils.setEnvironment('environment', 'production');
|
|
69
|
+
await createExpirationTemplate(EmailTemplateType.MembersExpirationReminder);
|
|
70
|
+
|
|
71
|
+
const { organization, admin } = await createOrganizationWithAdmin();
|
|
72
|
+
|
|
73
|
+
// A user without permissions should not receive the reminder
|
|
74
|
+
const member = await new UserFactory({ organization }).create();
|
|
75
|
+
|
|
76
|
+
const validUntil = new Date(Date.now() + 10 * DAY);
|
|
77
|
+
const pack = await createPackage({ organizationId: organization.id, type: STPackageType.Members, validUntil });
|
|
78
|
+
|
|
79
|
+
await STPackageService.sendExpiryEmail(pack);
|
|
80
|
+
|
|
81
|
+
const emails = await EmailMocker.getSucceededEmails();
|
|
82
|
+
expect(emails.length).toBe(1);
|
|
83
|
+
expect(emails[0].to).toContain(admin.email);
|
|
84
|
+
expect(emails[0].to).not.toContain(member.email);
|
|
85
|
+
|
|
86
|
+
expect(emails[0].html).toContain('organizationName: ' + organization.name);
|
|
87
|
+
expect(emails[0].html).toContain('packageName: ' + pack.meta.name);
|
|
88
|
+
expect(emails[0].html).toContain('validUntil: ' + Formatter.dateTime(validUntil));
|
|
89
|
+
expect(emails[0].html).toContain('validUntilDate: ' + Formatter.date(validUntil));
|
|
90
|
+
expect(emails[0].html).toContain(`renewUrl: https://${STAMHOOFD.domains.dashboard ?? 'stamhoofd.app'}/${organization.i18n.locale}/beheerders/${organization.uri}/instellingen/functionaliteiten`);
|
|
91
|
+
|
|
92
|
+
expect(pack.lastEmailAt).not.toBeNull();
|
|
93
|
+
expect(pack.emailCount).toBe(1);
|
|
94
|
+
|
|
95
|
+
const fromDatabase = await STPackage.getByID(pack.id);
|
|
96
|
+
expect(fromDatabase!.lastEmailAt).not.toBeNull();
|
|
97
|
+
expect(fromDatabase!.emailCount).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('Counts the email, but does not set lastEmailAt, for a package type without a reminder', async () => {
|
|
101
|
+
TestUtils.setEnvironment('environment', 'production');
|
|
102
|
+
const { organization } = await createOrganizationWithAdmin();
|
|
103
|
+
|
|
104
|
+
// LegacyMembers has no expiration reminder template type
|
|
105
|
+
const pack = await createPackage({ organizationId: organization.id, type: STPackageType.LegacyMembers });
|
|
106
|
+
|
|
107
|
+
await STPackageService.sendExpiryEmail(pack);
|
|
108
|
+
|
|
109
|
+
expect(await EmailMocker.getSucceededCount()).toBe(0);
|
|
110
|
+
expect(pack.lastEmailAt).toBeNull();
|
|
111
|
+
expect(pack.emailCount).toBe(1);
|
|
112
|
+
|
|
113
|
+
const fromDatabase = await STPackage.getByID(pack.id);
|
|
114
|
+
expect(fromDatabase!.lastEmailAt).toBeNull();
|
|
115
|
+
expect(fromDatabase!.emailCount).toBe(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('Does not send outside production, but still records the email on the package', async () => {
|
|
119
|
+
// The production guard only covers the send itself: the bookkeeping runs in every environment
|
|
120
|
+
expect(STAMHOOFD.environment).not.toBe('production');
|
|
121
|
+
await createExpirationTemplate(EmailTemplateType.MembersExpirationReminder);
|
|
122
|
+
|
|
123
|
+
const { organization } = await createOrganizationWithAdmin();
|
|
124
|
+
const pack = await createPackage({ organizationId: organization.id, type: STPackageType.Members });
|
|
125
|
+
|
|
126
|
+
await STPackageService.sendExpiryEmail(pack);
|
|
127
|
+
|
|
128
|
+
expect(await EmailMocker.getSucceededCount()).toBe(0);
|
|
129
|
+
expect(pack.lastEmailAt).not.toBeNull();
|
|
130
|
+
expect(pack.emailCount).toBe(1);
|
|
131
|
+
|
|
132
|
+
const fromDatabase = await STPackage.getByID(pack.id);
|
|
133
|
+
expect(fromDatabase!.lastEmailAt).not.toBeNull();
|
|
134
|
+
expect(fromDatabase!.emailCount).toBe(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('Logs and skips the email when the organization of the package no longer exists', async () => {
|
|
138
|
+
TestUtils.setEnvironment('environment', 'production');
|
|
139
|
+
await createExpirationTemplate(EmailTemplateType.MembersExpirationReminder);
|
|
140
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
141
|
+
|
|
142
|
+
const pack = await createPackage({ organizationId: uuidv4(), type: STPackageType.Members });
|
|
143
|
+
|
|
144
|
+
await STPackageService.sendExpiryEmail(pack);
|
|
145
|
+
|
|
146
|
+
expect(consoleError).toHaveBeenCalledWith('Could not find package organization ' + pack.id);
|
|
147
|
+
expect(await EmailMocker.getSucceededCount()).toBe(0);
|
|
148
|
+
|
|
149
|
+
// A missing organization does not stop the bookkeeping
|
|
150
|
+
expect(pack.emailCount).toBe(1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('Does not send or count a package that is not due', async () => {
|
|
154
|
+
TestUtils.setEnvironment('environment', 'production');
|
|
155
|
+
await createExpirationTemplate(EmailTemplateType.MembersExpirationReminder);
|
|
156
|
+
|
|
157
|
+
const { organization } = await createOrganizationWithAdmin();
|
|
158
|
+
|
|
159
|
+
const neverActivated = await createPackage({ organizationId: organization.id, type: STPackageType.Members, validAt: null });
|
|
160
|
+
|
|
161
|
+
// Expires in 100 days, which is outside the 32 day window of a members package
|
|
162
|
+
const notExpiringSoon = await createPackage({ organizationId: organization.id, type: STPackageType.Members, validUntil: new Date(Date.now() + 100 * DAY) });
|
|
163
|
+
|
|
164
|
+
await STPackageService.sendExpiryEmail(neverActivated);
|
|
165
|
+
await STPackageService.sendExpiryEmail(notExpiringSoon);
|
|
166
|
+
|
|
167
|
+
expect(await EmailMocker.getSucceededCount()).toBe(0);
|
|
168
|
+
expect(neverActivated.emailCount).toBe(0);
|
|
169
|
+
expect(neverActivated.lastEmailAt).toBeNull();
|
|
170
|
+
expect(notExpiringSoon.emailCount).toBe(0);
|
|
171
|
+
expect(notExpiringSoon.lastEmailAt).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('Counts, but does not send, a package that is already removed', async () => {
|
|
175
|
+
TestUtils.setEnvironment('environment', 'production');
|
|
176
|
+
await createExpirationTemplate(EmailTemplateType.MembersExpirationReminder);
|
|
177
|
+
|
|
178
|
+
const { organization } = await createOrganizationWithAdmin();
|
|
179
|
+
const pack = await createPackage({ organizationId: organization.id, type: STPackageType.Members, removeAt: new Date(Date.now() - DAY) });
|
|
180
|
+
|
|
181
|
+
await STPackageService.sendExpiryEmail(pack);
|
|
182
|
+
|
|
183
|
+
expect(await EmailMocker.getSucceededCount()).toBe(0);
|
|
184
|
+
expect(pack.lastEmailAt).toBeNull();
|
|
185
|
+
expect(pack.emailCount).toBe(1);
|
|
186
|
+
|
|
187
|
+
const fromDatabase = await STPackage.getByID(pack.id);
|
|
188
|
+
expect(fromDatabase!.emailCount).toBe(1);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { BalanceItem, Organization, Platform, STPackage } from '@stamhoofd/models';
|
|
2
|
+
import { sendEmailTemplate } from '../helpers/EmailBuilder.js';
|
|
2
3
|
import { SQL } from '@stamhoofd/sql';
|
|
3
4
|
import type { Company } from '@stamhoofd/structures';
|
|
4
|
-
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, getPricingTypeName, STPackageStatus, STPackageType, STPackageTypeHelper, STPricingType, TranslatedString } from '@stamhoofd/structures';
|
|
5
|
+
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, EmailTemplateType, getPricingTypeName, Recipient, Replacement, STPackageStatus, STPackageType, STPackageTypeHelper, STPricingType, TranslatedString } from '@stamhoofd/structures';
|
|
5
6
|
import { Formatter, STMath } from '@stamhoofd/utility';
|
|
6
7
|
import { VATService } from './VATService.js';
|
|
8
|
+
import { OrganizationAdminService } from './OrganizationAdminService.js';
|
|
7
9
|
|
|
8
10
|
export class STPackageService {
|
|
9
11
|
static async getActivePackages(organizationId: string) {
|
|
@@ -263,6 +265,118 @@ export class STPackageService {
|
|
|
263
265
|
await this.updateOrganizationPackages(organizationId);
|
|
264
266
|
}
|
|
265
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Send the expiration reminder of a package if one is due, and record on the package that it was
|
|
270
|
+
* sent (also when nothing was sent, so a package is only ever considered once).
|
|
271
|
+
*/
|
|
272
|
+
static async sendExpiryEmail(pack: STPackage) {
|
|
273
|
+
if (pack.validAt === null) {
|
|
274
|
+
// never activated
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (pack.removeAt && pack.removeAt <= new Date()) {
|
|
279
|
+
pack.emailCount += 1;
|
|
280
|
+
await pack.save();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let allowDays = 0;
|
|
285
|
+
let type: EmailTemplateType | null = null;
|
|
286
|
+
|
|
287
|
+
if (pack.meta.type === STPackageType.Members) {
|
|
288
|
+
type = EmailTemplateType.MembersExpirationReminder;
|
|
289
|
+
allowDays = 32;
|
|
290
|
+
} else if (pack.meta.type === STPackageType.Webshops) {
|
|
291
|
+
type = EmailTemplateType.WebshopsExpirationReminder;
|
|
292
|
+
allowDays = 32;
|
|
293
|
+
} else if (pack.meta.type === STPackageType.SingleWebshop) {
|
|
294
|
+
type = EmailTemplateType.SingleWebshopExpirationReminder;
|
|
295
|
+
allowDays = 7;
|
|
296
|
+
} else if (pack.meta.type === STPackageType.TrialMembers) {
|
|
297
|
+
type = EmailTemplateType.TrialMembersExpirationReminder;
|
|
298
|
+
allowDays = 3;
|
|
299
|
+
} else if (pack.meta.type === STPackageType.TrialWebshops) {
|
|
300
|
+
type = EmailTemplateType.TrialWebshopsExpirationReminder;
|
|
301
|
+
allowDays = 3;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const allowFrom = new Date(Date.now() + 1000 * 60 * 60 * 24 * allowDays);
|
|
305
|
+
if (type && (pack.validUntil === null || pack.validUntil < new Date() || pack.validUntil > allowFrom)) {
|
|
306
|
+
console.log('Skip sending expiration email for ' + pack.id);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (type) {
|
|
311
|
+
console.log('Sending expiration email for ' + pack.id, type);
|
|
312
|
+
if (STAMHOOFD.environment === 'production') {
|
|
313
|
+
await this.sendEmailTemplate(pack, {
|
|
314
|
+
type,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
pack.lastEmailAt = new Date();
|
|
318
|
+
} else {
|
|
319
|
+
console.log('Skip sending expiration email for ' + pack.id + ' (no type)');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
pack.emailCount += 1;
|
|
323
|
+
await pack.save();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Send an email template about a package to the full administrators of its organization.
|
|
328
|
+
*/
|
|
329
|
+
static async sendEmailTemplate(pack: STPackage, data: {
|
|
330
|
+
type: EmailTemplateType;
|
|
331
|
+
}) {
|
|
332
|
+
const organization = await Organization.getByID(pack.organizationId);
|
|
333
|
+
|
|
334
|
+
if (!organization) {
|
|
335
|
+
console.error('Could not find package organization ' + pack.id);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const admins = await OrganizationAdminService.getFullAdmins(organization);
|
|
340
|
+
|
|
341
|
+
const recipients = admins.map(admin =>
|
|
342
|
+
Recipient.create({
|
|
343
|
+
firstName: admin.firstName,
|
|
344
|
+
lastName: admin.lastName,
|
|
345
|
+
email: admin.email,
|
|
346
|
+
replacements: [
|
|
347
|
+
Replacement.create({
|
|
348
|
+
token: 'organizationName',
|
|
349
|
+
value: organization.name,
|
|
350
|
+
}),
|
|
351
|
+
Replacement.create({
|
|
352
|
+
token: 'packageName',
|
|
353
|
+
value: pack.meta.name ?? '',
|
|
354
|
+
}),
|
|
355
|
+
Replacement.create({
|
|
356
|
+
token: 'validUntil',
|
|
357
|
+
value: pack.validUntil ? Formatter.dateTime(pack.validUntil) : 'nooit',
|
|
358
|
+
}),
|
|
359
|
+
Replacement.create({
|
|
360
|
+
token: 'validUntilDate',
|
|
361
|
+
value: pack.validUntil ? Formatter.date(pack.validUntil) : 'nooit',
|
|
362
|
+
}),
|
|
363
|
+
Replacement.create({
|
|
364
|
+
token: 'renewUrl',
|
|
365
|
+
value: `https://${(STAMHOOFD.domains.dashboard ?? 'stamhoofd.app')}/${organization.i18n.locale}/beheerders/${organization.uri}/instellingen/functionaliteiten`,
|
|
366
|
+
}),
|
|
367
|
+
],
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
// Create e-mail builder
|
|
372
|
+
await sendEmailTemplate(null, {
|
|
373
|
+
template: {
|
|
374
|
+
type: data.type,
|
|
375
|
+
},
|
|
376
|
+
recipients,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
266
380
|
static async markBalanceRestored(organizationId: string) {
|
|
267
381
|
console.log('Marking packages as restored for ' + organizationId);
|
|
268
382
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { User } from '@stamhoofd/models';
|
|
2
|
+
import { AuditLog } from '@stamhoofd/models';
|
|
3
|
+
import type { MFAMethodType } from '@stamhoofd/structures';
|
|
4
|
+
import { AuditLogReplacement, AuditLogReplacementType, AuditLogSource, AuditLogType } from '@stamhoofd/structures';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Records the changes a user makes to their own two-factor authentication.
|
|
8
|
+
*
|
|
9
|
+
* These are security-relevant events that are not visible anywhere else: the credentials
|
|
10
|
+
* themselves are not audit-logged models, and an attacker who took over a session would
|
|
11
|
+
* use exactly these endpoints to lock the owner out. So they are logged explicitly, at the
|
|
12
|
+
* moment the change is effective.
|
|
13
|
+
*/
|
|
14
|
+
export class TwoFactorAuditLogService {
|
|
15
|
+
static async logMethodAdded(user: User, method: MFAMethodType, name: string) {
|
|
16
|
+
await this.save(AuditLogType.UserTwoFactorMethodAdded, user, method, name);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static async logMethodDeleted(user: User, method: MFAMethodType, name: string) {
|
|
20
|
+
await this.save(AuditLogType.UserTwoFactorMethodDeleted, user, method, name);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Only for an explicit regeneration by the user. The batch that is generated together
|
|
25
|
+
* with a first factor is already covered by the method-added log.
|
|
26
|
+
*/
|
|
27
|
+
static async logRecoveryCodesRegenerated(user: User) {
|
|
28
|
+
await this.save(AuditLogType.UserRecoveryCodesRegenerated, user, null, '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private static async save(type: AuditLogType, user: User, method: MFAMethodType | null, name: string) {
|
|
32
|
+
const log = new AuditLog();
|
|
33
|
+
log.type = type;
|
|
34
|
+
log.source = AuditLogSource.User;
|
|
35
|
+
|
|
36
|
+
// A user changes their own factors, so the account is both the actor and the subject.
|
|
37
|
+
log.userId = user.id;
|
|
38
|
+
log.objectId = user.id;
|
|
39
|
+
|
|
40
|
+
// Deliberately the organization of the account itself, not the one the request was
|
|
41
|
+
// scoped to: a platform administrator enrolling a factor while working in an
|
|
42
|
+
// organization is not that organization's business.
|
|
43
|
+
log.organizationId = user.organizationId;
|
|
44
|
+
|
|
45
|
+
log.replacements = new Map([
|
|
46
|
+
['u', AuditLogReplacement.create({
|
|
47
|
+
id: user.id,
|
|
48
|
+
value: user.email,
|
|
49
|
+
type: AuditLogReplacementType.User,
|
|
50
|
+
})],
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const methodReplacement = AuditLogReplacement.enum('MFAMethodType', method);
|
|
54
|
+
if (methodReplacement) {
|
|
55
|
+
log.replacements.set('method', methodReplacement);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (name) {
|
|
59
|
+
log.replacements.set('name', AuditLogReplacement.string(name));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await log.save();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { I18n } from '@stamhoofd/backend-i18n';
|
|
2
|
+
import { EmailMocker } from '@stamhoofd/email';
|
|
3
|
+
import { EmailTemplateFactory, EmailVerificationCode, OrganizationFactory, Platform, UserFactory } from '@stamhoofd/models';
|
|
4
|
+
import { EmailTemplateType } from '@stamhoofd/structures';
|
|
5
|
+
import { TestUtils } from '@stamhoofd/test-utils';
|
|
6
|
+
import { VerificationCodeService } from './VerificationCodeService.js';
|
|
7
|
+
|
|
8
|
+
const i18n = new I18n(I18n.defaultLanguage, I18n.defaultCountry);
|
|
9
|
+
|
|
10
|
+
const PLATFORM_NAME = 'The test platform';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A template that only contains the organizationName replacement, so the name that ends up in the
|
|
14
|
+
* email can be asserted without depending on the real (translated) template contents.
|
|
15
|
+
*/
|
|
16
|
+
async function createVerifyEmailTemplate() {
|
|
17
|
+
await new EmailTemplateFactory({
|
|
18
|
+
type: EmailTemplateType.VerifyEmail,
|
|
19
|
+
html: '<p>{{organizationName}}</p>',
|
|
20
|
+
text: '{{organizationName}}',
|
|
21
|
+
}).create();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('VerificationCodeService', () => {
|
|
25
|
+
let originalPlatformName: string;
|
|
26
|
+
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
const platform = await Platform.getForEditing();
|
|
29
|
+
originalPlatformName = platform.config.name;
|
|
30
|
+
platform.config.name = PLATFORM_NAME;
|
|
31
|
+
await platform.save();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterAll(async () => {
|
|
35
|
+
const platform = await Platform.getForEditing();
|
|
36
|
+
platform.config.name = originalPlatformName;
|
|
37
|
+
await platform.save();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('send', () => {
|
|
41
|
+
test('Uses the name of the organization when the code is scoped to an organization', async () => {
|
|
42
|
+
TestUtils.setEnvironment('userMode', 'organization');
|
|
43
|
+
await createVerifyEmailTemplate();
|
|
44
|
+
|
|
45
|
+
const organization = await new OrganizationFactory({ name: 'Organization with a name' }).create();
|
|
46
|
+
const user = await new UserFactory({ organization }).create();
|
|
47
|
+
const code = await EmailVerificationCode.createFor(user, user.email);
|
|
48
|
+
|
|
49
|
+
await VerificationCodeService.send(code, user, organization, i18n);
|
|
50
|
+
|
|
51
|
+
const emails = await EmailMocker.transactional.getSucceededEmails();
|
|
52
|
+
expect(emails.length).toBe(1);
|
|
53
|
+
expect(emails[0].text).toContain('Organization with a name');
|
|
54
|
+
expect(emails[0].text).not.toContain(PLATFORM_NAME);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('Falls back to the name of the platform when there is no organization', async () => {
|
|
58
|
+
TestUtils.setEnvironment('userMode', 'platform');
|
|
59
|
+
await createVerifyEmailTemplate();
|
|
60
|
+
|
|
61
|
+
const user = await new UserFactory({}).create();
|
|
62
|
+
const code = await EmailVerificationCode.createFor(user, user.email);
|
|
63
|
+
|
|
64
|
+
await VerificationCodeService.send(code, user, null, i18n);
|
|
65
|
+
|
|
66
|
+
const emails = await EmailMocker.transactional.getSucceededEmails();
|
|
67
|
+
expect(emails.length).toBe(1);
|
|
68
|
+
expect(emails[0].text).toContain(PLATFORM_NAME);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|