@stamhoofd/backend 2.96.3 → 2.97.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 +10 -10
- package/src/audit-logs/EmailLogger.ts +27 -22
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +8 -0
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +174 -0
- package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -2
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +623 -0
- package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +672 -3
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +28 -33
- package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.test.ts +167 -0
- package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +1 -1
- package/src/endpoints/global/email-recipients/helpers/validateEmailRecipientFilter.ts +21 -4
- package/src/helpers/AdminPermissionChecker.ts +8 -3
- package/src/helpers/AuthenticatedStructures.ts +5 -3
- package/src/helpers/Context.ts +3 -3
- package/src/seeds/1755790070-fill-email-recipient-errors.ts +3 -3
- package/src/sql-filters/members.ts +2 -2
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { Email, EmailRecipient, MemberFactory, Organization, OrganizationFactory, RegistrationPeriod, RegistrationPeriodFactory, Token, User, UserFactory } from '@stamhoofd/models';
|
|
3
|
+
import { EmailStatus, LimitedFilteredRequest, Replacement } from '@stamhoofd/structures';
|
|
4
|
+
import { TestUtils } from '@stamhoofd/test-utils';
|
|
5
|
+
import { testServer } from '../../../../tests/helpers/TestServer';
|
|
6
|
+
import { GetUserEmailsEndpoint } from './GetUserEmailsEndpoint';
|
|
7
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
8
|
+
|
|
9
|
+
const baseUrl = `/user/email`;
|
|
10
|
+
|
|
11
|
+
describe('Endpoint.GetUserEmails', () => {
|
|
12
|
+
const endpoint = new GetUserEmailsEndpoint();
|
|
13
|
+
let period: RegistrationPeriod;
|
|
14
|
+
let organization: Organization;
|
|
15
|
+
let userToken: Token;
|
|
16
|
+
let user: User;
|
|
17
|
+
let member: any; // MemberWithRegistrations type
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
TestUtils.setPermanentEnvironment('userMode', 'platform');
|
|
21
|
+
|
|
22
|
+
period = await new RegistrationPeriodFactory({
|
|
23
|
+
startDate: new Date(2023, 0, 1),
|
|
24
|
+
endDate: new Date(2023, 11, 31),
|
|
25
|
+
}).create();
|
|
26
|
+
|
|
27
|
+
organization = await new OrganizationFactory({ period })
|
|
28
|
+
.create();
|
|
29
|
+
|
|
30
|
+
user = await new UserFactory({
|
|
31
|
+
organization,
|
|
32
|
+
}).create();
|
|
33
|
+
|
|
34
|
+
// Create a member associated with the user
|
|
35
|
+
member = await new MemberFactory({
|
|
36
|
+
organization,
|
|
37
|
+
user,
|
|
38
|
+
}).create();
|
|
39
|
+
|
|
40
|
+
userToken = await Token.createToken(user);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const getUserEmails = async (query: LimitedFilteredRequest = new LimitedFilteredRequest({ limit: 10 }), token: Token = userToken, testOrganization: Organization = organization) => {
|
|
44
|
+
const request = Request.get({
|
|
45
|
+
path: baseUrl,
|
|
46
|
+
host: testOrganization.getApiHost(),
|
|
47
|
+
query,
|
|
48
|
+
headers: {
|
|
49
|
+
authorization: 'Bearer ' + token.accessToken,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
return await testServer.test(endpoint, request);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
test('Should return empty list when no emails are sent to user', async () => {
|
|
56
|
+
const response = await getUserEmails();
|
|
57
|
+
expect(response.body.results).toHaveLength(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('Should return sent email when it is sent to user', async () => {
|
|
61
|
+
// Create an email
|
|
62
|
+
const email = new Email();
|
|
63
|
+
email.subject = 'Test Email Subject';
|
|
64
|
+
email.status = EmailStatus.Sent;
|
|
65
|
+
email.text = 'This is a test email content';
|
|
66
|
+
email.html = '<p>This is a test email content</p>';
|
|
67
|
+
email.json = {};
|
|
68
|
+
email.organizationId = organization.id;
|
|
69
|
+
email.showInMemberPortal = true;
|
|
70
|
+
email.sentAt = new Date();
|
|
71
|
+
await email.save();
|
|
72
|
+
|
|
73
|
+
// Create an email recipient linked to the member
|
|
74
|
+
const emailRecipient = new EmailRecipient();
|
|
75
|
+
emailRecipient.emailId = email.id;
|
|
76
|
+
emailRecipient.memberId = member.id;
|
|
77
|
+
emailRecipient.userId = user.id;
|
|
78
|
+
emailRecipient.email = user.email;
|
|
79
|
+
emailRecipient.firstName = member.details.firstName;
|
|
80
|
+
emailRecipient.lastName = member.details.lastName;
|
|
81
|
+
emailRecipient.sentAt = new Date();
|
|
82
|
+
await emailRecipient.save();
|
|
83
|
+
|
|
84
|
+
const response = await getUserEmails();
|
|
85
|
+
|
|
86
|
+
expect(response.body.results).toHaveLength(1);
|
|
87
|
+
expect(response.body.results[0].subject).toBe('Test Email Subject');
|
|
88
|
+
expect(response.body.results[0].id).toBe(email.id);
|
|
89
|
+
expect(response.body.results[0].status).toBe(EmailStatus.Sent);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('Should not return draft emails', async () => {
|
|
93
|
+
// Create a draft email
|
|
94
|
+
const email = new Email();
|
|
95
|
+
email.subject = 'Draft Email';
|
|
96
|
+
email.status = EmailStatus.Draft;
|
|
97
|
+
email.text = 'This is a draft email';
|
|
98
|
+
email.html = '<p>This is a draft email</p>';
|
|
99
|
+
email.json = {};
|
|
100
|
+
email.organizationId = organization.id;
|
|
101
|
+
email.showInMemberPortal = true;
|
|
102
|
+
await email.save();
|
|
103
|
+
|
|
104
|
+
// Create an email recipient linked to the member
|
|
105
|
+
const emailRecipient = new EmailRecipient();
|
|
106
|
+
emailRecipient.emailId = email.id;
|
|
107
|
+
emailRecipient.memberId = member.id;
|
|
108
|
+
emailRecipient.userId = user.id;
|
|
109
|
+
emailRecipient.email = user.email;
|
|
110
|
+
emailRecipient.firstName = member.details.firstName;
|
|
111
|
+
emailRecipient.lastName = member.details.lastName;
|
|
112
|
+
await emailRecipient.save();
|
|
113
|
+
|
|
114
|
+
const response = await getUserEmails();
|
|
115
|
+
|
|
116
|
+
// Should not include the draft email
|
|
117
|
+
expect(response.body.results.find(e => e.id === email.id)).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('Should not return emails with showInMemberPortal = false', async () => {
|
|
121
|
+
// Create an email with showInMemberPortal = false
|
|
122
|
+
const email = new Email();
|
|
123
|
+
email.subject = 'Hidden Email';
|
|
124
|
+
email.status = EmailStatus.Sent;
|
|
125
|
+
email.text = 'This email should be hidden';
|
|
126
|
+
email.html = '<p>This email should be hidden</p>';
|
|
127
|
+
email.json = {};
|
|
128
|
+
email.organizationId = organization.id;
|
|
129
|
+
email.showInMemberPortal = false; // This should hide the email
|
|
130
|
+
email.sentAt = new Date();
|
|
131
|
+
await email.save();
|
|
132
|
+
|
|
133
|
+
// Create an email recipient linked to the member
|
|
134
|
+
const emailRecipient = new EmailRecipient();
|
|
135
|
+
emailRecipient.emailId = email.id;
|
|
136
|
+
emailRecipient.memberId = member.id;
|
|
137
|
+
emailRecipient.userId = user.id;
|
|
138
|
+
emailRecipient.email = user.email;
|
|
139
|
+
emailRecipient.firstName = member.details.firstName;
|
|
140
|
+
emailRecipient.lastName = member.details.lastName;
|
|
141
|
+
emailRecipient.sentAt = new Date();
|
|
142
|
+
await emailRecipient.save();
|
|
143
|
+
|
|
144
|
+
const response = await getUserEmails();
|
|
145
|
+
|
|
146
|
+
// Should not include the hidden email
|
|
147
|
+
expect(response.body.results.find(e => e.id === email.id)).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('Should not return emails sent to other users', async () => {
|
|
151
|
+
// Create another user and member
|
|
152
|
+
const otherUser = await new UserFactory({
|
|
153
|
+
organization,
|
|
154
|
+
}).create();
|
|
155
|
+
|
|
156
|
+
const otherMember = await new MemberFactory({
|
|
157
|
+
organization,
|
|
158
|
+
user: otherUser,
|
|
159
|
+
}).create();
|
|
160
|
+
|
|
161
|
+
// Create an email
|
|
162
|
+
const email = new Email();
|
|
163
|
+
email.subject = 'Email for Other User';
|
|
164
|
+
email.status = EmailStatus.Sent;
|
|
165
|
+
email.text = 'This email is for another user';
|
|
166
|
+
email.html = '<p>This email is for another user</p>';
|
|
167
|
+
email.json = {};
|
|
168
|
+
email.organizationId = organization.id;
|
|
169
|
+
email.showInMemberPortal = true;
|
|
170
|
+
email.sentAt = new Date();
|
|
171
|
+
await email.save();
|
|
172
|
+
|
|
173
|
+
// Create an email recipient linked to the OTHER member
|
|
174
|
+
const emailRecipient = new EmailRecipient();
|
|
175
|
+
emailRecipient.emailId = email.id;
|
|
176
|
+
emailRecipient.memberId = otherMember.id;
|
|
177
|
+
emailRecipient.userId = otherUser.id;
|
|
178
|
+
emailRecipient.email = otherUser.email;
|
|
179
|
+
emailRecipient.firstName = otherMember.details.firstName;
|
|
180
|
+
emailRecipient.lastName = otherMember.details.lastName;
|
|
181
|
+
emailRecipient.sentAt = new Date();
|
|
182
|
+
await emailRecipient.save();
|
|
183
|
+
|
|
184
|
+
const response = await getUserEmails();
|
|
185
|
+
|
|
186
|
+
// Should not include emails sent to other users
|
|
187
|
+
expect(response.body.results.find(e => e.id === email.id)).toBeUndefined();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('Should not return deleted emails', async () => {
|
|
191
|
+
// Create a deleted email
|
|
192
|
+
const email = new Email();
|
|
193
|
+
email.subject = 'Deleted Email';
|
|
194
|
+
email.status = EmailStatus.Sent;
|
|
195
|
+
email.text = 'This email has been deleted';
|
|
196
|
+
email.html = '<p>This email has been deleted</p>';
|
|
197
|
+
email.json = {};
|
|
198
|
+
email.organizationId = organization.id;
|
|
199
|
+
email.showInMemberPortal = true;
|
|
200
|
+
email.sentAt = new Date();
|
|
201
|
+
email.deletedAt = new Date(); // Mark as deleted
|
|
202
|
+
await email.save();
|
|
203
|
+
|
|
204
|
+
// Create an email recipient linked to the member
|
|
205
|
+
const emailRecipient = new EmailRecipient();
|
|
206
|
+
emailRecipient.emailId = email.id;
|
|
207
|
+
emailRecipient.memberId = member.id;
|
|
208
|
+
emailRecipient.userId = user.id;
|
|
209
|
+
emailRecipient.email = user.email;
|
|
210
|
+
emailRecipient.firstName = member.details.firstName;
|
|
211
|
+
emailRecipient.lastName = member.details.lastName;
|
|
212
|
+
emailRecipient.sentAt = new Date();
|
|
213
|
+
await emailRecipient.save();
|
|
214
|
+
|
|
215
|
+
const response = await getUserEmails();
|
|
216
|
+
|
|
217
|
+
// Should not include the deleted email
|
|
218
|
+
expect(response.body.results.find(e => e.id === email.id)).toBeUndefined();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('Should filter emails by subject when search is provided', async () => {
|
|
222
|
+
// Create first email
|
|
223
|
+
const email1 = new Email();
|
|
224
|
+
email1.subject = 'Important Meeting Reminder';
|
|
225
|
+
email1.status = EmailStatus.Sent;
|
|
226
|
+
email1.text = 'Meeting content';
|
|
227
|
+
email1.html = '<p>Meeting content</p>';
|
|
228
|
+
email1.json = {};
|
|
229
|
+
email1.organizationId = organization.id;
|
|
230
|
+
email1.showInMemberPortal = true;
|
|
231
|
+
email1.sentAt = new Date();
|
|
232
|
+
await email1.save();
|
|
233
|
+
|
|
234
|
+
// Create second email
|
|
235
|
+
const email2 = new Email();
|
|
236
|
+
email2.subject = 'Newsletter Update';
|
|
237
|
+
email2.status = EmailStatus.Sent;
|
|
238
|
+
email2.text = 'Newsletter content';
|
|
239
|
+
email2.html = '<p>Newsletter content</p>';
|
|
240
|
+
email2.json = {};
|
|
241
|
+
email2.organizationId = organization.id;
|
|
242
|
+
email2.showInMemberPortal = true;
|
|
243
|
+
email2.sentAt = new Date();
|
|
244
|
+
await email2.save();
|
|
245
|
+
|
|
246
|
+
// Create recipients for both emails
|
|
247
|
+
const recipient1 = new EmailRecipient();
|
|
248
|
+
recipient1.emailId = email1.id;
|
|
249
|
+
recipient1.memberId = member.id;
|
|
250
|
+
recipient1.userId = user.id;
|
|
251
|
+
recipient1.email = user.email;
|
|
252
|
+
recipient1.firstName = member.details.firstName;
|
|
253
|
+
recipient1.lastName = member.details.lastName;
|
|
254
|
+
recipient1.sentAt = new Date();
|
|
255
|
+
await recipient1.save();
|
|
256
|
+
|
|
257
|
+
const recipient2 = new EmailRecipient();
|
|
258
|
+
recipient2.emailId = email2.id;
|
|
259
|
+
recipient2.memberId = member.id;
|
|
260
|
+
recipient2.userId = user.id;
|
|
261
|
+
recipient2.email = user.email;
|
|
262
|
+
recipient2.firstName = member.details.firstName;
|
|
263
|
+
recipient2.lastName = member.details.lastName;
|
|
264
|
+
recipient2.sentAt = new Date();
|
|
265
|
+
await recipient2.save();
|
|
266
|
+
|
|
267
|
+
// Search for "Meeting"
|
|
268
|
+
const searchQuery = new LimitedFilteredRequest({
|
|
269
|
+
limit: 10,
|
|
270
|
+
search: 'Meeting',
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const response = await getUserEmails(searchQuery);
|
|
274
|
+
|
|
275
|
+
expect(response.body.results).toHaveLength(1);
|
|
276
|
+
expect(response.body.results[0].subject).toBe('Important Meeting Reminder');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('Should respect pagination limit', async () => {
|
|
280
|
+
// Create multiple emails
|
|
281
|
+
const emails: Email[] = [];
|
|
282
|
+
for (let i = 0; i < 5; i++) {
|
|
283
|
+
const email = new Email();
|
|
284
|
+
email.subject = `Test Email ${i}`;
|
|
285
|
+
email.status = EmailStatus.Sent;
|
|
286
|
+
email.text = `Content ${i}`;
|
|
287
|
+
email.html = `<p>Content ${i}</p>`;
|
|
288
|
+
email.json = {};
|
|
289
|
+
email.organizationId = organization.id;
|
|
290
|
+
email.showInMemberPortal = true;
|
|
291
|
+
email.sentAt = new Date();
|
|
292
|
+
await email.save();
|
|
293
|
+
emails.push(email);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Create recipients for all emails
|
|
297
|
+
const recipients: EmailRecipient[] = [];
|
|
298
|
+
for (const email of emails) {
|
|
299
|
+
const recipient = new EmailRecipient();
|
|
300
|
+
recipient.emailId = email.id;
|
|
301
|
+
recipient.memberId = member.id;
|
|
302
|
+
recipient.userId = user.id;
|
|
303
|
+
recipient.email = user.email;
|
|
304
|
+
recipient.firstName = member.details.firstName;
|
|
305
|
+
recipient.lastName = member.details.lastName;
|
|
306
|
+
recipient.sentAt = new Date();
|
|
307
|
+
await recipient.save();
|
|
308
|
+
recipients.push(recipient);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Test with limit of 3
|
|
312
|
+
const limitedQuery = new LimitedFilteredRequest({
|
|
313
|
+
limit: 3,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const response = await getUserEmails(limitedQuery);
|
|
317
|
+
|
|
318
|
+
expect(response.body.results).toHaveLength(3);
|
|
319
|
+
expect(response.body.next).toBeDefined(); // Should have next page
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('Should return correct recipient when email has multiple recipients matching same user', async () => {
|
|
323
|
+
// Create an email
|
|
324
|
+
const email = new Email();
|
|
325
|
+
email.subject = 'Multiple Recipients Email';
|
|
326
|
+
email.status = EmailStatus.Sent;
|
|
327
|
+
email.text = 'Email with multiple recipients';
|
|
328
|
+
email.html = '<p>Email with multiple recipients</p>';
|
|
329
|
+
email.json = {};
|
|
330
|
+
email.organizationId = organization.id;
|
|
331
|
+
email.showInMemberPortal = true;
|
|
332
|
+
email.sentAt = new Date();
|
|
333
|
+
await email.save();
|
|
334
|
+
|
|
335
|
+
// Create multiple recipients for the same user/member combination
|
|
336
|
+
// First recipient: exact match (userId and email match)
|
|
337
|
+
const exactMatchRecipient = new EmailRecipient();
|
|
338
|
+
exactMatchRecipient.emailId = email.id;
|
|
339
|
+
exactMatchRecipient.memberId = member.id;
|
|
340
|
+
exactMatchRecipient.userId = user.id;
|
|
341
|
+
exactMatchRecipient.email = user.email;
|
|
342
|
+
exactMatchRecipient.firstName = 'Exact';
|
|
343
|
+
exactMatchRecipient.lastName = 'Match';
|
|
344
|
+
exactMatchRecipient.sentAt = new Date();
|
|
345
|
+
await exactMatchRecipient.save();
|
|
346
|
+
|
|
347
|
+
// Second recipient: member match only (no userId or email)
|
|
348
|
+
const memberOnlyRecipient = new EmailRecipient();
|
|
349
|
+
memberOnlyRecipient.emailId = email.id;
|
|
350
|
+
memberOnlyRecipient.memberId = member.id;
|
|
351
|
+
memberOnlyRecipient.userId = null;
|
|
352
|
+
memberOnlyRecipient.email = null;
|
|
353
|
+
memberOnlyRecipient.firstName = 'Member';
|
|
354
|
+
memberOnlyRecipient.lastName = 'Only';
|
|
355
|
+
memberOnlyRecipient.sentAt = new Date();
|
|
356
|
+
await memberOnlyRecipient.save();
|
|
357
|
+
|
|
358
|
+
// Third recipient: any data but same member
|
|
359
|
+
const anyDataRecipient = new EmailRecipient();
|
|
360
|
+
anyDataRecipient.emailId = email.id;
|
|
361
|
+
anyDataRecipient.memberId = member.id;
|
|
362
|
+
anyDataRecipient.userId = null; // No specific user
|
|
363
|
+
anyDataRecipient.email = 'other@example.com'; // Different email
|
|
364
|
+
anyDataRecipient.firstName = 'Any';
|
|
365
|
+
anyDataRecipient.lastName = 'Data';
|
|
366
|
+
anyDataRecipient.sentAt = new Date();
|
|
367
|
+
await anyDataRecipient.save();
|
|
368
|
+
|
|
369
|
+
// Search specifically for this email to avoid interference from other tests
|
|
370
|
+
const searchQuery = new LimitedFilteredRequest({
|
|
371
|
+
limit: 10,
|
|
372
|
+
search: 'Multiple Recipients Email',
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const response = await getUserEmails(searchQuery);
|
|
376
|
+
|
|
377
|
+
expect(response.body.results).toHaveLength(1);
|
|
378
|
+
const emailResult = response.body.results[0];
|
|
379
|
+
|
|
380
|
+
// Should prefer the exact match recipient
|
|
381
|
+
expect(emailResult.recipients).toHaveLength(1);
|
|
382
|
+
expect(emailResult.recipients[0].firstName).toBe('Exact');
|
|
383
|
+
expect(emailResult.recipients[0].lastName).toBe('Match');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('Should return generic data when recipient has no matching user id or email', async () => {
|
|
387
|
+
// Create an email
|
|
388
|
+
const email = new Email();
|
|
389
|
+
email.subject = 'Generic Recipient Email';
|
|
390
|
+
email.status = EmailStatus.Sent;
|
|
391
|
+
email.text = 'Email with generic recipient';
|
|
392
|
+
email.html = '<p>Email with generic recipient</p>';
|
|
393
|
+
email.json = {};
|
|
394
|
+
email.organizationId = organization.id;
|
|
395
|
+
email.showInMemberPortal = true;
|
|
396
|
+
email.sentAt = new Date();
|
|
397
|
+
await email.save();
|
|
398
|
+
|
|
399
|
+
// Create a recipient with no userId or email (generic member data)
|
|
400
|
+
const genericRecipient = new EmailRecipient();
|
|
401
|
+
genericRecipient.emailId = email.id;
|
|
402
|
+
genericRecipient.memberId = member.id;
|
|
403
|
+
genericRecipient.userId = null; // No specific user
|
|
404
|
+
genericRecipient.email = null; // No specific email
|
|
405
|
+
genericRecipient.firstName = 'Generic';
|
|
406
|
+
genericRecipient.lastName = 'Member';
|
|
407
|
+
genericRecipient.sentAt = new Date();
|
|
408
|
+
await genericRecipient.save();
|
|
409
|
+
|
|
410
|
+
// Search specifically for this email to avoid interference from other tests
|
|
411
|
+
const searchQuery = new LimitedFilteredRequest({
|
|
412
|
+
limit: 10,
|
|
413
|
+
search: 'Generic Recipient Email',
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const response = await getUserEmails(searchQuery);
|
|
417
|
+
|
|
418
|
+
expect(response.body.results).toHaveLength(1);
|
|
419
|
+
const emailResult = response.body.results[0];
|
|
420
|
+
|
|
421
|
+
// Should return the generic recipient data
|
|
422
|
+
expect(emailResult.recipients).toHaveLength(1);
|
|
423
|
+
expect(emailResult.recipients[0].firstName).toBe('Generic');
|
|
424
|
+
expect(emailResult.recipients[0].lastName).toBe('Member');
|
|
425
|
+
// The original recipient struct keeps its original userId and email
|
|
426
|
+
expect(emailResult.recipients[0].userId).toBe(null); // Original was null
|
|
427
|
+
expect(emailResult.recipients[0].email).toBe(null); // Original was null
|
|
428
|
+
// But replacements should be generated for the current user (tested below)
|
|
429
|
+
expect(emailResult.recipients[0].replacements).toBeDefined();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test('Should strip sensitive information from email replacements for non-matching recipients', async () => {
|
|
433
|
+
// Create another user that will have sensitive data
|
|
434
|
+
const sensitiveUser = await new UserFactory({
|
|
435
|
+
organization,
|
|
436
|
+
}).create();
|
|
437
|
+
|
|
438
|
+
// Create an email
|
|
439
|
+
const email = new Email();
|
|
440
|
+
email.subject = 'Sensitive Data Email';
|
|
441
|
+
email.status = EmailStatus.Sent;
|
|
442
|
+
email.text = 'Email with sensitive replacements {{outstandingBalance}} {{loginDetails}} {{unsubscribeUrl}}';
|
|
443
|
+
email.html = '<p>Email with sensitive replacements {{outstandingBalance}} {{loginDetails}} {{unsubscribeUrl}}</p>';
|
|
444
|
+
email.json = {};
|
|
445
|
+
email.organizationId = organization.id;
|
|
446
|
+
email.showInMemberPortal = true;
|
|
447
|
+
email.sentAt = new Date();
|
|
448
|
+
await email.save();
|
|
449
|
+
|
|
450
|
+
// Create a recipient for the sensitive user (with different user data than our test user)
|
|
451
|
+
const sensitiveRecipient = new EmailRecipient();
|
|
452
|
+
sensitiveRecipient.emailId = email.id;
|
|
453
|
+
sensitiveRecipient.memberId = member.id; // Same member as our test user
|
|
454
|
+
sensitiveRecipient.userId = sensitiveUser.id; // Different user ID
|
|
455
|
+
sensitiveRecipient.email = sensitiveUser.email; // Different email
|
|
456
|
+
sensitiveRecipient.firstName = member.details.firstName;
|
|
457
|
+
sensitiveRecipient.lastName = member.details.lastName;
|
|
458
|
+
sensitiveRecipient.sentAt = new Date();
|
|
459
|
+
|
|
460
|
+
// Add sensitive replacements that should be stripped by the API
|
|
461
|
+
sensitiveRecipient.replacements = [
|
|
462
|
+
Replacement.create({
|
|
463
|
+
token: 'loginDetails',
|
|
464
|
+
value: '',
|
|
465
|
+
html: `<p class="description"><em>Login with <strong>${sensitiveUser.email}</strong> Alice Security Code: <span class="style-inline-code">ABCD-EFGH-IJKL-MNOP</span></em></p>`,
|
|
466
|
+
}),
|
|
467
|
+
Replacement.create({
|
|
468
|
+
token: 'unsubscribeUrl',
|
|
469
|
+
value: 'https://example.com/unsubscribe?token=secret-token-12345',
|
|
470
|
+
}),
|
|
471
|
+
Replacement.create({
|
|
472
|
+
token: 'signInUrl',
|
|
473
|
+
value: 'https://example.com/login?token=private-signin-token-67890',
|
|
474
|
+
}),
|
|
475
|
+
Replacement.create({
|
|
476
|
+
token: 'outstandingBalance',
|
|
477
|
+
value: '€ 150.00',
|
|
478
|
+
}),
|
|
479
|
+
Replacement.create({
|
|
480
|
+
token: 'balanceTable',
|
|
481
|
+
html: '<table><tr><td>Private balance information</td><td>€ 150.00</td></tr></table>',
|
|
482
|
+
}),
|
|
483
|
+
];
|
|
484
|
+
|
|
485
|
+
await sensitiveRecipient.save();
|
|
486
|
+
|
|
487
|
+
// Search specifically for this email to avoid interference from other tests
|
|
488
|
+
const searchQuery = new LimitedFilteredRequest({
|
|
489
|
+
limit: 10,
|
|
490
|
+
search: 'Sensitive Data Email',
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const response = await getUserEmails(searchQuery);
|
|
494
|
+
|
|
495
|
+
expect(response.body.results).toHaveLength(1);
|
|
496
|
+
const emailResult = response.body.results[0];
|
|
497
|
+
|
|
498
|
+
expect(emailResult.recipients).toHaveLength(1);
|
|
499
|
+
const recipient = emailResult.recipients[0];
|
|
500
|
+
|
|
501
|
+
// The original recipient struct keeps its original userId and email from the sensitive user
|
|
502
|
+
expect(recipient.userId).toBe(sensitiveUser.id); // Original userId
|
|
503
|
+
expect(recipient.email).toBe(sensitiveUser.email); // Original email
|
|
504
|
+
|
|
505
|
+
// Verify that sensitive data has been properly processed
|
|
506
|
+
expect(recipient.replacements).toBeDefined();
|
|
507
|
+
expect(Array.isArray(recipient.replacements)).toBe(true);
|
|
508
|
+
|
|
509
|
+
// The system should:
|
|
510
|
+
// 1. Strip sensitive replacements from the original recipient (done with willFill: true)
|
|
511
|
+
// 2. Create new appropriate replacements for the current viewing user (done with fillRecipientReplacements)
|
|
512
|
+
// 3. Apply web display filtering (done with stripRecipientReplacementsForWebDisplay)
|
|
513
|
+
|
|
514
|
+
// The original sensitive data (ABCD-EFGH-IJKL-MNOP, secret-token-12345, private-signin-token-67890)
|
|
515
|
+
// should be completely gone because:
|
|
516
|
+
// - stripSensitiveRecipientReplacements removes the original replacements
|
|
517
|
+
// - fillRecipientReplacements creates new ones for the current user
|
|
518
|
+
// - stripRecipientReplacementsForWebDisplay makes them safe for web display
|
|
519
|
+
|
|
520
|
+
const allReplacementsString = JSON.stringify(recipient.replacements);
|
|
521
|
+
expect(allReplacementsString).not.toContain('ABCD-EFGH-IJKL-MNOP'); // Original security code should be gone
|
|
522
|
+
expect(allReplacementsString).not.toContain('secret-token-12345'); // Original sensitive unsubscribe token should be gone
|
|
523
|
+
expect(allReplacementsString).not.toContain('private-signin-token-67890'); // Original sensitive signin token should be gone
|
|
524
|
+
|
|
525
|
+
// Verify that safe, current-user-appropriate replacements are created
|
|
526
|
+
const loginDetailsReplacement = recipient.replacements.find(r => r.token === 'loginDetails');
|
|
527
|
+
const unsubscribeUrlReplacement = recipient.replacements.find(r => r.token === 'unsubscribeUrl');
|
|
528
|
+
|
|
529
|
+
// loginDetails should exist and be empty/generic for web display
|
|
530
|
+
expect(loginDetailsReplacement).toBeDefined();
|
|
531
|
+
expect(loginDetailsReplacement!.html).toBe(undefined); // Should be empty for web display
|
|
532
|
+
expect(loginDetailsReplacement!.value).toBe('');
|
|
533
|
+
|
|
534
|
+
// unsubscribeUrl should exist and be safe for web display
|
|
535
|
+
expect(unsubscribeUrlReplacement).toBeDefined();
|
|
536
|
+
expect(unsubscribeUrlReplacement!.value).toMatch(/^https:\/\//); // Should still be a valid URL
|
|
537
|
+
expect(unsubscribeUrlReplacement!.value).not.toContain('secret-token-12345'); // Original sensitive token should be gone
|
|
538
|
+
|
|
539
|
+
// This tests that Email.getStructureForUser properly handles sensitive data by:
|
|
540
|
+
// 1. Removing original sensitive replacements from other users' data
|
|
541
|
+
// 2. Creating fresh, appropriate replacements for the current viewer
|
|
542
|
+
// 3. Ensuring web safety of all replacement values
|
|
543
|
+
|
|
544
|
+
// Check outstandingBalance replacement
|
|
545
|
+
const balanceReplacement = recipient.replacements.find(r => r.token === 'outstandingBalance');
|
|
546
|
+
expect(balanceReplacement).toBeDefined();
|
|
547
|
+
expect(balanceReplacement!.value).toBe(Formatter.price(0)); // Should be corrected to the new user
|
|
548
|
+
|
|
549
|
+
// Check balanceTable replacement
|
|
550
|
+
const balanceTableReplacement = recipient.replacements.find(r => r.token === 'balanceTable');
|
|
551
|
+
expect(balanceTableReplacement).toBeDefined();
|
|
552
|
+
expect(balanceTableReplacement!.html).toBe('<p class="description">' + $t('4c4f6571-f7b5-469d-a16f-b1547b43a610') + '</p>');
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test('Should not return emails from other members the user does not have access to', async () => {
|
|
556
|
+
// Create another user and member
|
|
557
|
+
const otherUser = await new UserFactory({
|
|
558
|
+
organization,
|
|
559
|
+
}).create();
|
|
560
|
+
|
|
561
|
+
const otherMember = await new MemberFactory({
|
|
562
|
+
organization,
|
|
563
|
+
user: otherUser,
|
|
564
|
+
}).create();
|
|
565
|
+
|
|
566
|
+
// Create an email
|
|
567
|
+
const email = new Email();
|
|
568
|
+
email.subject = 'Email for Other Member';
|
|
569
|
+
email.status = EmailStatus.Sent;
|
|
570
|
+
email.text = 'This email is for another member';
|
|
571
|
+
email.html = '<p>This email is for another member</p>';
|
|
572
|
+
email.json = {};
|
|
573
|
+
email.organizationId = organization.id;
|
|
574
|
+
email.showInMemberPortal = true;
|
|
575
|
+
email.sentAt = new Date();
|
|
576
|
+
await email.save();
|
|
577
|
+
|
|
578
|
+
// Create an email recipient linked to the OTHER member
|
|
579
|
+
const emailRecipient = new EmailRecipient();
|
|
580
|
+
emailRecipient.emailId = email.id;
|
|
581
|
+
emailRecipient.memberId = otherMember.id; // Different member
|
|
582
|
+
emailRecipient.userId = otherUser.id;
|
|
583
|
+
emailRecipient.email = otherUser.email;
|
|
584
|
+
emailRecipient.firstName = otherMember.details.firstName;
|
|
585
|
+
emailRecipient.lastName = otherMember.details.lastName;
|
|
586
|
+
emailRecipient.sentAt = new Date();
|
|
587
|
+
await emailRecipient.save();
|
|
588
|
+
|
|
589
|
+
const response = await getUserEmails();
|
|
590
|
+
|
|
591
|
+
// Should not include emails sent to other members
|
|
592
|
+
expect(response.body.results.find(e => e.id === email.id)).toBeUndefined();
|
|
593
|
+
|
|
594
|
+
// Test that is IS included if we request the same data via the 'otherUser' token
|
|
595
|
+
const otherUserToken = await Token.createToken(otherUser);
|
|
596
|
+
const responseForOtherUser = await getUserEmails(new LimitedFilteredRequest({ limit: 10 }), otherUserToken);
|
|
597
|
+
expect(responseForOtherUser.body.results.find(e => e.id === email.id)).toBeDefined();
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test('Should throw error when not authenticated', async () => {
|
|
601
|
+
const request = Request.get({
|
|
602
|
+
path: baseUrl,
|
|
603
|
+
host: organization.getApiHost(),
|
|
604
|
+
query: new LimitedFilteredRequest({ limit: 10 }),
|
|
605
|
+
});
|
|
606
|
+
// No authorization header
|
|
607
|
+
|
|
608
|
+
await expect(testServer.test(endpoint, request)).rejects.toThrow();
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test('Should throw error with invalid token', async () => {
|
|
612
|
+
const request = Request.get({
|
|
613
|
+
path: baseUrl,
|
|
614
|
+
host: organization.getApiHost(),
|
|
615
|
+
query: new LimitedFilteredRequest({ limit: 10 }),
|
|
616
|
+
headers: {
|
|
617
|
+
authorization: 'Bearer invalid_token',
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
await expect(testServer.test(endpoint, request)).rejects.toThrow();
|
|
622
|
+
});
|
|
623
|
+
});
|
|
@@ -60,7 +60,8 @@ export class GetUserEmailsEndpoint extends Endpoint<Params, Query, Body, Respons
|
|
|
60
60
|
|
|
61
61
|
const query = Email.select()
|
|
62
62
|
.where('deletedAt', null)
|
|
63
|
-
.where('status', EmailStatus.Sent)
|
|
63
|
+
.where('status', EmailStatus.Sent)
|
|
64
|
+
.where('showInMemberPortal', true);
|
|
64
65
|
|
|
65
66
|
if (scopeFilter) {
|
|
66
67
|
query.where(await compileToSQLFilter(scopeFilter, filterCompilers));
|