@stamhoofd/backend 2.137.5 → 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 +20 -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/index.ts +1 -0
- package/src/crons/invoices.ts +5 -3
- package/src/email-recipient-loaders/orders.ts +2 -1
- package/src/email-recipient-loaders/organizations.ts +3 -2
- package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
- package/src/endpoints/auth/CreateAdminEndpoint.ts +2 -1
- package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
- package/src/endpoints/auth/CreateTokenEndpoint.ts +133 -6
- 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 +2 -1
- 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/RegenerateRecoveryCodesEndpoint.ts +51 -0
- package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
- package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
- package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
- package/src/endpoints/auth/SignupEndpoint.ts +13 -2
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +3 -2
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +3 -2
- 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 +2 -1
- package/src/endpoints/global/files/UploadFile.ts +1 -1
- package/src/endpoints/global/files/UploadImage.ts +1 -1
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.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/organization/PatchOrganizationEndpoint.ts +1 -0
- 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/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
- package/src/helpers/AuthenticatedStructures.ts +9 -1
- 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/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/EmailPreviewService.ts +1 -1
- 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/InvoicePdfService.ts +3 -3
- package/src/services/InvoiceService.ts +4 -2
- package/src/services/InvoiceXMLService.ts +2 -1
- package/src/services/OrderService.ts +2 -1
- package/src/services/OrganizationAdminService.test.ts +47 -0
- package/src/services/OrganizationAdminService.ts +139 -0
- package/src/services/OrganizationEmailService.ts +3 -2
- package/src/services/PaymentService.ts +5 -3
- package/src/services/ReferralService.ts +3 -2
- package/src/services/RegistrationService.ts +2 -1
- package/src/services/SSOService.ts +106 -14
- package/src/services/STPackageService.ts +4 -2
- package/src/services/TwoFactorAuditLogService.ts +64 -0
- package/src/services/VerificationCodeService.ts +2 -1
- package/tests/e2e/api-rate-limits.test.ts +1 -1
- 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
package/src/helpers/Context.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
2
2
|
import { isSimpleError, SimpleError } from '@simonbackx/simple-errors';
|
|
3
3
|
import { I18n } from '@stamhoofd/backend-i18n';
|
|
4
|
-
import
|
|
5
|
-
import { Organization, Platform, RateLimiter, Token } from '@stamhoofd/models';
|
|
4
|
+
import { MFAToken, Organization, Platform, RateLimiter, Token, User } from '@stamhoofd/models';
|
|
6
5
|
import { AsyncLocalStorage } from 'async_hooks';
|
|
7
6
|
|
|
8
7
|
import type { Decoder } from '@simonbackx/simple-encoding';
|
|
9
8
|
import { AutoEncoder, field, StringDecoder } from '@simonbackx/simple-encoding';
|
|
10
9
|
import { ApiUserRateLimits } from '@stamhoofd/structures';
|
|
11
10
|
import { AdminPermissionChecker } from './AdminPermissionChecker.js';
|
|
11
|
+
import { TwoFactorHelper } from './TwoFactorHelper.js';
|
|
12
12
|
|
|
13
13
|
export const apiUserRateLimiter = new RateLimiter({
|
|
14
14
|
limits: [
|
|
@@ -180,7 +180,7 @@ export class ContextInstance {
|
|
|
180
180
|
if (STAMHOOFD.userMode === 'platform') {
|
|
181
181
|
return null;
|
|
182
182
|
}
|
|
183
|
-
return await this.
|
|
183
|
+
return await this.setOptionalOrganizationScope(options);
|
|
184
184
|
}
|
|
185
185
|
|
|
186
186
|
async setOrganizationScope(options?: { willAuthenticate?: boolean }) {
|
|
@@ -223,7 +223,24 @@ export class ContextInstance {
|
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Identify the caller from the Authorization header without ever throwing: returns
|
|
228
|
+
* null when the request is unauthenticated or carries an invalid/expired token.
|
|
229
|
+
* Use this to give an already signed in user a smoother experience, never to grant
|
|
230
|
+
* access (there is no error to react to).
|
|
231
|
+
*/
|
|
232
|
+
async optionalAuthenticatedUser(): Promise<User | null> {
|
|
233
|
+
try {
|
|
234
|
+
const { user } = await this.authenticate({ allowWithoutAccount: true });
|
|
235
|
+
return user;
|
|
236
|
+
} catch (e) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async authenticate(options: { allowMFASetupToken: true; allowWithoutAccount?: boolean; allowUnscoped?: boolean }): Promise<{ user: User; token: Token } | { user: User; setupToken: MFAToken }>;
|
|
242
|
+
async authenticate(options?: { allowMFASetupToken?: false | undefined; allowWithoutAccount?: boolean; allowUnscoped?: boolean }): Promise<{ user: User; token: Token }>;
|
|
243
|
+
async authenticate({ allowWithoutAccount = false, allowUnscoped = false, allowMFASetupToken = false }: { allowMFASetupToken?: boolean; allowWithoutAccount?: boolean; allowUnscoped?: boolean } = {}): Promise<{ user: User; token: Token } | { user: User; setupToken: MFAToken }> {
|
|
227
244
|
let header = this.request.headers.authorization;
|
|
228
245
|
|
|
229
246
|
if (!header && this.request.method === 'POST') {
|
|
@@ -244,6 +261,12 @@ export class ContextInstance {
|
|
|
244
261
|
}
|
|
245
262
|
|
|
246
263
|
if (!header.startsWith('Bearer ')) {
|
|
264
|
+
if (allowMFASetupToken) {
|
|
265
|
+
const result = await this.authenticateMFASetup();
|
|
266
|
+
if (result) {
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
247
270
|
throw new SimpleError({
|
|
248
271
|
code: 'not_supported_authentication',
|
|
249
272
|
message: 'Authentication method not supported. Please authenticate with OAuth2',
|
|
@@ -320,6 +343,93 @@ export class ContextInstance {
|
|
|
320
343
|
return { user, token };
|
|
321
344
|
}
|
|
322
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Like authenticate(), but additionally requires the access token to be "fresh"
|
|
348
|
+
* (minted by a real authentication within the FRESH_WINDOW, not via a refresh).
|
|
349
|
+
* Used to gate sensitive actions such as managing 2FA methods.
|
|
350
|
+
*/
|
|
351
|
+
async authenticateFresh(options: { allowWithoutAccount?: boolean; allowUnscoped?: boolean } = {}): Promise<{ user: User; token: Token }> {
|
|
352
|
+
const result = await this.authenticate(options);
|
|
353
|
+
if (!result.token.isFresh()) {
|
|
354
|
+
throw new SimpleError({
|
|
355
|
+
code: 'require_fresh_auth',
|
|
356
|
+
message: 'A recent authentication is required for this action',
|
|
357
|
+
human: $t('%Zgs'),
|
|
358
|
+
statusCode: 403,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
return result;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Authorize a 2FA enrollment/management action. Accepts either a fresh full session
|
|
366
|
+
* (Bearer access token) or a setup token (Authorization: MFASetup <token>) issued
|
|
367
|
+
* during forced enrollment, before a session exists.
|
|
368
|
+
*/
|
|
369
|
+
async authenticateMFASetup(): Promise<{ user: User; setupToken: MFAToken } | null> {
|
|
370
|
+
const header = this.request.headers.authorization;
|
|
371
|
+
if (header && header.startsWith('MFASetup ')) {
|
|
372
|
+
const raw = header.substring('MFASetup '.length);
|
|
373
|
+
const setupToken = await MFAToken.getValid(raw, 'setup');
|
|
374
|
+
if (!setupToken) {
|
|
375
|
+
throw new SimpleError({
|
|
376
|
+
code: 'mfa_setup_expired',
|
|
377
|
+
message: 'The MFA setup session is invalid or expired',
|
|
378
|
+
human: $t('%ZhJ'),
|
|
379
|
+
statusCode: 401,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
const user = await User.getByID(setupToken.userId);
|
|
383
|
+
if (!user) {
|
|
384
|
+
throw new SimpleError({
|
|
385
|
+
code: 'mfa_setup_expired',
|
|
386
|
+
message: 'The MFA setup session is invalid or expired',
|
|
387
|
+
human: $t('%ZhJ'),
|
|
388
|
+
statusCode: 401,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// A setup token is issued on the strength of a password alone, to bootstrap a
|
|
393
|
+
// *first* factor. If the user enrolled one in the meantime (e.g. from another
|
|
394
|
+
// device), it must stop being worth a session: otherwise someone who only
|
|
395
|
+
// knows the password keeps a 15 minute window in which they can enroll a
|
|
396
|
+
// factor of their own and sign in, even though the account is now protected.
|
|
397
|
+
if (await TwoFactorHelper.userHasFactors(user.id)) {
|
|
398
|
+
await setupToken.consume();
|
|
399
|
+
throw new SimpleError({
|
|
400
|
+
code: 'mfa_setup_expired',
|
|
401
|
+
message: 'The MFA setup session is no longer valid: the user already has a second factor',
|
|
402
|
+
human: $t('%ZhJ'),
|
|
403
|
+
statusCode: 401,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
this.user = user;
|
|
408
|
+
await this.insecurelyAuthenticateAs(user);
|
|
409
|
+
return { user, setupToken };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Authorize a 2FA enrollment/management action. Accepts either a fresh full session
|
|
417
|
+
* (Bearer access token) or a setup token (Authorization: MFASetup <token>) issued
|
|
418
|
+
* during forced enrollment, before a session exists.
|
|
419
|
+
*
|
|
420
|
+
* Exactly one of `setupToken` / `token` is set: `token` is the session the request was
|
|
421
|
+
* made with, which enrollment keeps alive while signing out the user's other sessions.
|
|
422
|
+
*/
|
|
423
|
+
async authenticateMFAEnrollment(): Promise<{ user: User; setupToken: MFAToken | null; token: Token | null }> {
|
|
424
|
+
const mfa = await this.authenticateMFASetup();
|
|
425
|
+
if (mfa) {
|
|
426
|
+
return { ...mfa, token: null };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const { user, token } = await this.authenticateFresh();
|
|
430
|
+
return { user, setupToken: null, token };
|
|
431
|
+
}
|
|
432
|
+
|
|
323
433
|
async insecurelyAuthenticateAs(user: User) {
|
|
324
434
|
this.#auth = new AdminPermissionChecker(user, await Platform.getSharedPrivateStruct(), this.organization);
|
|
325
435
|
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { EmailMocker } from '@stamhoofd/email';
|
|
2
|
+
import { EmailContent, EmailTemplateType, Recipient, Replacement } from '@stamhoofd/structures';
|
|
3
|
+
import { Country } from '@stamhoofd/types/Country';
|
|
4
|
+
import { Language } from '@stamhoofd/types/Language';
|
|
5
|
+
import { TestUtils } from '@stamhoofd/test-utils';
|
|
6
|
+
import type { Organization, RegistrationPeriod } from '@stamhoofd/models';
|
|
7
|
+
import { Email, EmailTemplateFactory, OrganizationFactory, RegistrationPeriodFactory } from '@stamhoofd/models';
|
|
8
|
+
import { removeUnusedReplacements, sendEmailTemplate } from './EmailBuilder.js';
|
|
9
|
+
|
|
10
|
+
describe('sendEmailTemplate with translations', () => {
|
|
11
|
+
let period: RegistrationPeriod;
|
|
12
|
+
let organization: Organization;
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
period = await new RegistrationPeriodFactory({
|
|
16
|
+
startDate: new Date(2023, 0, 1),
|
|
17
|
+
endDate: new Date(2023, 11, 31),
|
|
18
|
+
}).create();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
organization = await new OrganizationFactory({ period }).create();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const type = EmailTemplateType.ForgotPassword;
|
|
26
|
+
|
|
27
|
+
test('each recipient receives the content in its own language, with the default as fallback', async () => {
|
|
28
|
+
await new EmailTemplateFactory({
|
|
29
|
+
organization,
|
|
30
|
+
type,
|
|
31
|
+
subject: 'Default subject',
|
|
32
|
+
html: '<p>Default html</p>',
|
|
33
|
+
text: 'Default text',
|
|
34
|
+
language: Language.Dutch,
|
|
35
|
+
translations: new Map([
|
|
36
|
+
[Language.French, EmailContent.create({ subject: 'Sujet français', html: '<p>Français</p>', text: 'Français' })],
|
|
37
|
+
]),
|
|
38
|
+
}).create();
|
|
39
|
+
|
|
40
|
+
await sendEmailTemplate(organization, {
|
|
41
|
+
recipients: [
|
|
42
|
+
Recipient.create({ email: 'french@example.com', language: Language.French }),
|
|
43
|
+
Recipient.create({ email: 'dutch@example.com', language: Language.Dutch }),
|
|
44
|
+
Recipient.create({ email: 'english@example.com', language: Language.English }),
|
|
45
|
+
Recipient.create({ email: 'unknown@example.com' }),
|
|
46
|
+
],
|
|
47
|
+
template: { type },
|
|
48
|
+
type: 'transactional',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const emails = await EmailMocker.transactional.getSucceededEmails();
|
|
52
|
+
expect(emails).toHaveLength(4);
|
|
53
|
+
|
|
54
|
+
const french = emails.find(e => e.to.includes('french@example.com'))!;
|
|
55
|
+
expect(french.subject).toBe('Sujet français');
|
|
56
|
+
expect(french.html).toContain('Français');
|
|
57
|
+
|
|
58
|
+
// Dutch is the default language: its content lives in the default content, not in the translations
|
|
59
|
+
const dutch = emails.find(e => e.to.includes('dutch@example.com'))!;
|
|
60
|
+
expect(dutch.subject).toBe('Default subject');
|
|
61
|
+
expect(dutch.html).toContain('Default html');
|
|
62
|
+
|
|
63
|
+
// English has no translation: falls back to the default content
|
|
64
|
+
const english = emails.find(e => e.to.includes('english@example.com'))!;
|
|
65
|
+
expect(english.subject).toBe('Default subject');
|
|
66
|
+
expect(english.html).toContain('Default html');
|
|
67
|
+
|
|
68
|
+
const unknown = emails.find(e => e.to.includes('unknown@example.com'))!;
|
|
69
|
+
expect(unknown.subject).toBe('Default subject');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('generates recipient replacements in the recipient language', async () => {
|
|
73
|
+
// French must be a valid locale, otherwise it gets corrected to the default language
|
|
74
|
+
TestUtils.setEnvironment('locales', { [Country.Belgium]: [Language.Dutch, Language.French] });
|
|
75
|
+
|
|
76
|
+
// The unsubscribe URL is localized per recipient (it is not part of the translatable content)
|
|
77
|
+
await new EmailTemplateFactory({
|
|
78
|
+
organization,
|
|
79
|
+
type,
|
|
80
|
+
subject: 'Subject',
|
|
81
|
+
html: '<p>{{greeting}} {{unsubscribeUrl}}</p>',
|
|
82
|
+
text: '{{greeting}} {{unsubscribeUrl}}',
|
|
83
|
+
}).create();
|
|
84
|
+
|
|
85
|
+
await sendEmailTemplate(organization, {
|
|
86
|
+
recipients: [
|
|
87
|
+
Recipient.create({ email: 'french@example.com', language: Language.French }),
|
|
88
|
+
Recipient.create({ email: 'dutch@example.com', language: Language.Dutch }),
|
|
89
|
+
Recipient.create({ email: 'unknown@example.com' }),
|
|
90
|
+
],
|
|
91
|
+
template: { type },
|
|
92
|
+
type: 'transactional',
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const emails = await EmailMocker.transactional.getSucceededEmails();
|
|
96
|
+
const french = emails.find(e => e.to.includes('french@example.com'))!;
|
|
97
|
+
const dutch = emails.find(e => e.to.includes('dutch@example.com'))!;
|
|
98
|
+
const unknown = emails.find(e => e.to.includes('unknown@example.com'))!;
|
|
99
|
+
|
|
100
|
+
// The unsubscribe page URL points to the recipient's localized page
|
|
101
|
+
expect(french.html).toContain('/fr-BE/unsubscribe');
|
|
102
|
+
expect(dutch.html).toContain('/nl-BE/unsubscribe');
|
|
103
|
+
// No language set: falls back to the ambient (default) locale
|
|
104
|
+
expect(unknown.html).toContain('/nl-BE/unsubscribe');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a missing language never falls back to the translation of a different template', async () => {
|
|
108
|
+
// Platform level template with a French translation
|
|
109
|
+
await new EmailTemplateFactory({
|
|
110
|
+
type,
|
|
111
|
+
subject: 'Platform subject',
|
|
112
|
+
html: '<p>Platform html</p>',
|
|
113
|
+
text: 'Platform text',
|
|
114
|
+
language: Language.Dutch,
|
|
115
|
+
translations: new Map([
|
|
116
|
+
[Language.French, EmailContent.create({ subject: 'Sujet plateforme', html: '<p>Plateforme</p>', text: 'Plateforme' })],
|
|
117
|
+
]),
|
|
118
|
+
}).create();
|
|
119
|
+
|
|
120
|
+
// Organization level template without any translations
|
|
121
|
+
await new EmailTemplateFactory({
|
|
122
|
+
organization,
|
|
123
|
+
type,
|
|
124
|
+
subject: 'Organization subject',
|
|
125
|
+
html: '<p>Organization html</p>',
|
|
126
|
+
text: 'Organization text',
|
|
127
|
+
}).create();
|
|
128
|
+
|
|
129
|
+
await sendEmailTemplate(organization, {
|
|
130
|
+
recipients: [
|
|
131
|
+
Recipient.create({ email: 'french@example.com', language: Language.French }),
|
|
132
|
+
],
|
|
133
|
+
template: { type },
|
|
134
|
+
type: 'transactional',
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const emails = await EmailMocker.transactional.getSucceededEmails();
|
|
138
|
+
expect(emails).toHaveLength(1);
|
|
139
|
+
|
|
140
|
+
// The organization template wins, and its default content is used for French:
|
|
141
|
+
// never the French translation of the platform template
|
|
142
|
+
expect(emails[0].subject).toBe('Organization subject');
|
|
143
|
+
expect(emails[0].html).toContain('Organization html');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('setFromTemplate copies only the default language of the template onto the email if no language chosen for email', async () => {
|
|
147
|
+
await new EmailTemplateFactory({
|
|
148
|
+
organization,
|
|
149
|
+
type: EmailTemplateType.SavedMembersEmail,
|
|
150
|
+
subject: 'Default subject',
|
|
151
|
+
html: '<p>Default html</p>',
|
|
152
|
+
text: 'Default text',
|
|
153
|
+
language: Language.Dutch,
|
|
154
|
+
translations: new Map([
|
|
155
|
+
[Language.French, EmailContent.create({ subject: 'Sujet français', html: '<p>Français</p>', text: 'Français' })],
|
|
156
|
+
]),
|
|
157
|
+
}).create();
|
|
158
|
+
|
|
159
|
+
const email = new Email();
|
|
160
|
+
email.organizationId = organization.id;
|
|
161
|
+
expect(await email.setFromTemplate(EmailTemplateType.SavedMembersEmail)).toBe(true);
|
|
162
|
+
|
|
163
|
+
expect(email.subject).toBe('Default subject');
|
|
164
|
+
expect(email.language).toBe(null);
|
|
165
|
+
expect(email.translations.size).toBe(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('setFromTemplate copies only the correct language of the template onto the email', async () => {
|
|
169
|
+
await new EmailTemplateFactory({
|
|
170
|
+
organization,
|
|
171
|
+
type: EmailTemplateType.SavedMembersEmail,
|
|
172
|
+
subject: 'Default subject',
|
|
173
|
+
html: '<p>Default html</p>',
|
|
174
|
+
text: 'Default text',
|
|
175
|
+
language: Language.Dutch,
|
|
176
|
+
translations: new Map([
|
|
177
|
+
[Language.French, EmailContent.create({ subject: 'Sujet français', html: '<p>Français</p>', text: 'Français' })],
|
|
178
|
+
]),
|
|
179
|
+
}).create();
|
|
180
|
+
|
|
181
|
+
const email = new Email();
|
|
182
|
+
email.language = Language.French;
|
|
183
|
+
email.organizationId = organization.id;
|
|
184
|
+
expect(await email.setFromTemplate(EmailTemplateType.SavedMembersEmail)).toBe(true);
|
|
185
|
+
|
|
186
|
+
expect(email.subject).toBe('Sujet français');
|
|
187
|
+
expect(email.language).toBe(Language.French);
|
|
188
|
+
expect(email.translations.size).toBe(0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('setFromTemplate copies only the default language of the template onto the email if languages match', async () => {
|
|
192
|
+
await new EmailTemplateFactory({
|
|
193
|
+
organization,
|
|
194
|
+
type: EmailTemplateType.SavedMembersEmail,
|
|
195
|
+
subject: 'Default subject',
|
|
196
|
+
html: '<p>Default html</p>',
|
|
197
|
+
text: 'Default text',
|
|
198
|
+
language: Language.Dutch,
|
|
199
|
+
translations: new Map([
|
|
200
|
+
[Language.French, EmailContent.create({ subject: 'Sujet français', html: '<p>Français</p>', text: 'Français' })],
|
|
201
|
+
]),
|
|
202
|
+
}).create();
|
|
203
|
+
|
|
204
|
+
const email = new Email();
|
|
205
|
+
email.language = Language.Dutch;
|
|
206
|
+
email.organizationId = organization.id;
|
|
207
|
+
expect(await email.setFromTemplate(EmailTemplateType.SavedMembersEmail)).toBe(true);
|
|
208
|
+
|
|
209
|
+
expect(email.subject).toBe('Default subject');
|
|
210
|
+
expect(email.language).toBe(Language.Dutch);
|
|
211
|
+
expect(email.translations.size).toBe(0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('replaceAll is applied to the html of every language, not only the default', async () => {
|
|
215
|
+
await new EmailTemplateFactory({
|
|
216
|
+
organization,
|
|
217
|
+
type,
|
|
218
|
+
subject: 'Subject',
|
|
219
|
+
// The same placeholder appears in both the default and the translated html
|
|
220
|
+
html: '<p>Default __PLACEHOLDER__</p>',
|
|
221
|
+
text: 'Default __PLACEHOLDER__',
|
|
222
|
+
language: Language.Dutch,
|
|
223
|
+
translations: new Map([
|
|
224
|
+
[Language.French, EmailContent.create({ subject: 'Sujet', html: '<p>Français __PLACEHOLDER__</p>', text: 'Français __PLACEHOLDER__' })],
|
|
225
|
+
]),
|
|
226
|
+
}).create();
|
|
227
|
+
|
|
228
|
+
await sendEmailTemplate(organization, {
|
|
229
|
+
recipients: [
|
|
230
|
+
Recipient.create({ email: 'french@example.com', language: Language.French }),
|
|
231
|
+
Recipient.create({ email: 'default@example.com' }),
|
|
232
|
+
],
|
|
233
|
+
template: { type },
|
|
234
|
+
type: 'transactional',
|
|
235
|
+
replaceAll: [{ from: '__PLACEHOLDER__', to: 'REPLACED' }],
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const emails = await EmailMocker.transactional.getSucceededEmails();
|
|
239
|
+
const french = emails.find(e => e.to.includes('french@example.com'))!;
|
|
240
|
+
const fallback = emails.find(e => e.to.includes('default@example.com'))!;
|
|
241
|
+
|
|
242
|
+
// The replaceAll must reach the translated html too, otherwise the placeholder leaks
|
|
243
|
+
expect(french.html).toContain('Français REPLACED');
|
|
244
|
+
expect(french.html).not.toContain('__PLACEHOLDER__');
|
|
245
|
+
expect(fallback.html).toContain('Default REPLACED');
|
|
246
|
+
expect(fallback.html).not.toContain('__PLACEHOLDER__');
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('Email.getCombinedHtml', () => {
|
|
251
|
+
test('combines the default html with the html of every translation', () => {
|
|
252
|
+
const email = new Email();
|
|
253
|
+
email.html = '<p>Default {{signInUrl}}</p>';
|
|
254
|
+
email.translations = new Map([
|
|
255
|
+
[Language.French, EmailContent.create({ html: '<p>Français {{balanceTable}}</p>' })],
|
|
256
|
+
]);
|
|
257
|
+
|
|
258
|
+
const combined = email.getCombinedHtml();
|
|
259
|
+
expect(combined).toContain('{{signInUrl}}');
|
|
260
|
+
expect(combined).toContain('{{balanceTable}}');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('keeps a replacement that is only used inside a translation', () => {
|
|
264
|
+
const email = new Email();
|
|
265
|
+
// The default html uses signInUrl, only the French translation uses balanceTable
|
|
266
|
+
email.html = '<p>Default {{signInUrl}}</p>';
|
|
267
|
+
email.translations = new Map([
|
|
268
|
+
[Language.French, EmailContent.create({ html: '<p>Français {{balanceTable}}</p>' })],
|
|
269
|
+
]);
|
|
270
|
+
|
|
271
|
+
const replacements = [
|
|
272
|
+
Replacement.create({ token: 'signInUrl', value: 'https://example.com' }),
|
|
273
|
+
Replacement.create({ token: 'balanceTable', value: '', html: '<table></table>' }),
|
|
274
|
+
Replacement.create({ token: 'outstandingBalance', value: '€ 10' }),
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
// Using only the default html would wrongly strip balanceTable (used only by the translation)
|
|
278
|
+
const usingDefaultHtml = removeUnusedReplacements(email.html ?? '', replacements).map(r => r.token);
|
|
279
|
+
expect(usingDefaultHtml).not.toContain('balanceTable');
|
|
280
|
+
|
|
281
|
+
// Using the combined html keeps every replacement that any language needs, and still drops the truly unused one
|
|
282
|
+
const usingCombinedHtml = removeUnusedReplacements(email.getCombinedHtml(), replacements).map(r => r.token);
|
|
283
|
+
expect(usingCombinedHtml).toContain('signInUrl');
|
|
284
|
+
expect(usingCombinedHtml).toContain('balanceTable');
|
|
285
|
+
expect(usingCombinedHtml).not.toContain('outstandingBalance');
|
|
286
|
+
});
|
|
287
|
+
});
|