@stamhoofd/backend 2.78.3 → 2.78.4

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/.env.ci.json CHANGED
@@ -1,17 +1,28 @@
1
1
  {
2
2
  "environment": "test",
3
3
  "domains": {
4
- "dashboard": "dashboard.stamhoofd.dev",
5
- "registration": {
6
- "": "stamhoofd.dev",
7
- "BE": "dev.stamhoofd.be",
8
- "NL": "dev.stamhoofd.nl"
4
+ "dashboard": "dashboard.stamhoofd",
5
+ "marketing": {
6
+ "": "www.be.stamhoofd",
7
+ "BE": "www.be.stamhoofd",
8
+ "NL": "www.nl.stamhoofd"
9
9
  },
10
- "webshop": "shop.stamhoofd.dev",
11
- "api": "api.stamhoofd.dev",
10
+ "webshop": {
11
+ "": "shop.be.stamhoofd",
12
+ "BE": "shop.be.stamhoofd",
13
+ "NL": "shop.nl.stamhoofd"
14
+ },
15
+ "api": "api.stamhoofd",
16
+ "rendererApi": "renderer.stamhoofd",
17
+
18
+ "defaultTransactionalEmail": {
19
+ "": "stamhoofd.be"
20
+ },
21
+
12
22
  "defaultBroadcastEmail": {
13
23
  "": "stamhoofd.email"
14
- }
24
+ },
25
+ "webshopCname": "shop.stamhoofd"
15
26
  },
16
27
 
17
28
  "PORT": 9091,
package/index.ts CHANGED
@@ -60,10 +60,13 @@ const start = async () => {
60
60
  await UniqueUserService.check();
61
61
 
62
62
  // Init platform shared struct: otherwise permissions won't work with missing responsibilities
63
+ console.log('Loading platform...');
63
64
  await Platform.getSharedStruct();
64
65
 
65
66
  const router = new Router();
66
67
 
68
+ console.log('Loading endpoints...');
69
+
67
70
  // Note: we should load endpoints one by once to have a reliable order of url matching
68
71
  await router.loadAllEndpoints(__dirname + '/src/endpoints/global/*');
69
72
  await router.loadAllEndpoints(__dirname + '/src/endpoints/admin/*');
@@ -76,6 +79,7 @@ const start = async () => {
76
79
 
77
80
  router.endpoints.push(new CORSPreflightEndpoint());
78
81
 
82
+ console.log('Creating router...');
79
83
  const routerServer = new RouterServer(router);
80
84
  routerServer.verbose = false;
81
85
 
@@ -104,6 +108,8 @@ const start = async () => {
104
108
  // Add CORS headers
105
109
  routerServer.addResponseMiddleware(CORSMiddleware);
106
110
 
111
+ console.log('Loading loaders...');
112
+
107
113
  // Register Excel loaders
108
114
  await import('./src/excel-loaders/members');
109
115
  await import('./src/excel-loaders/payments');
@@ -115,6 +121,7 @@ const start = async () => {
115
121
  await import('./src/email-recipient-loaders/orders');
116
122
  await import('./src/email-recipient-loaders/receivable-balances');
117
123
 
124
+ console.log('Opening port...');
118
125
  routerServer.listen(STAMHOOFD.PORT ?? 9090);
119
126
 
120
127
  const hrend = process.hrtime(bootTime);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.78.3",
3
+ "version": "2.78.4",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -37,14 +37,14 @@
37
37
  "@simonbackx/simple-encoding": "2.20.0",
38
38
  "@simonbackx/simple-endpoints": "1.19.1",
39
39
  "@simonbackx/simple-logging": "^1.0.1",
40
- "@stamhoofd/backend-i18n": "2.78.3",
41
- "@stamhoofd/backend-middleware": "2.78.3",
42
- "@stamhoofd/email": "2.78.3",
43
- "@stamhoofd/models": "2.78.3",
44
- "@stamhoofd/queues": "2.78.3",
45
- "@stamhoofd/sql": "2.78.3",
46
- "@stamhoofd/structures": "2.78.3",
47
- "@stamhoofd/utility": "2.78.3",
40
+ "@stamhoofd/backend-i18n": "2.78.4",
41
+ "@stamhoofd/backend-middleware": "2.78.4",
42
+ "@stamhoofd/email": "2.78.4",
43
+ "@stamhoofd/models": "2.78.4",
44
+ "@stamhoofd/queues": "2.78.4",
45
+ "@stamhoofd/sql": "2.78.4",
46
+ "@stamhoofd/structures": "2.78.4",
47
+ "@stamhoofd/utility": "2.78.4",
48
48
  "archiver": "^7.0.1",
49
49
  "aws-sdk": "^2.885.0",
50
50
  "axios": "1.6.8",
@@ -64,5 +64,5 @@
64
64
  "publishConfig": {
65
65
  "access": "public"
66
66
  },
67
- "gitHead": "272bfbb259109530326ec2fd8c83af5f69d6268b"
67
+ "gitHead": "e8c207be8fd75320024f670d5c663eaa8306ba29"
68
68
  }
@@ -0,0 +1,726 @@
1
+ import { Database } from '@simonbackx/simple-database';
2
+ import { PatchableArray, PatchMap } from '@simonbackx/simple-encoding';
3
+ import { Endpoint, Request } from '@simonbackx/simple-endpoints';
4
+ import { GroupFactory, MemberFactory, OrganizationFactory, OrganizationTagFactory, Platform, RegistrationFactory, Token, UserFactory } from '@stamhoofd/models';
5
+ import { MemberDetails, MemberWithRegistrationsBlob, OrganizationMetaData, OrganizationRecordsConfiguration, Parent, PatchAnswers, PermissionLevel, Permissions, PermissionsResourceType, RecordCategory, RecordSettings, RecordTextAnswer, ResourcePermissions, UserPermissions } from '@stamhoofd/structures';
6
+ import { TestUtils } from '@stamhoofd/test-utils';
7
+ import { testServer } from '../../../../tests/helpers/TestServer';
8
+ import { PatchOrganizationMembersEndpoint } from './PatchOrganizationMembersEndpoint';
9
+
10
+ const baseUrl = `/organization/members`;
11
+ const endpoint = new PatchOrganizationMembersEndpoint();
12
+ type EndpointType = typeof endpoint;
13
+ type Body = EndpointType extends Endpoint<any, any, infer B, any> ? B : never;
14
+
15
+ const firstName = 'John';
16
+ const lastName = 'Doe';
17
+ const birthDay = { year: 1993, month: 4, day: 5 };
18
+
19
+ const errorWithCode = (code: string) => expect.objectContaining({ code }) as jest.Constructable;
20
+
21
+ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
22
+ beforeEach(async () => {
23
+ TestUtils.setEnvironment('userMode', 'platform');
24
+ });
25
+
26
+ afterEach(async () => {
27
+ // Delete all members (so the duplicate checks work as expected)
28
+ await Database.delete('DELETE FROM `members`');
29
+ });
30
+
31
+ describe('Duplicate members', () => {
32
+ test('The security code should be a requirement', async () => {
33
+ const organization = await new OrganizationFactory({ }).create();
34
+ const user = await new UserFactory({
35
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
36
+ organization, // since we are in platform mode, this will only set the permissions for this organization
37
+ }).create();
38
+
39
+ const existingMember = await new MemberFactory({
40
+ firstName,
41
+ lastName,
42
+ birthDay,
43
+ generateData: true,
44
+ }).create();
45
+
46
+ const token = await Token.createToken(user);
47
+
48
+ const arr: Body = new PatchableArray();
49
+ const put = MemberWithRegistrationsBlob.create({
50
+ details: MemberDetails.create({
51
+ firstName,
52
+ lastName,
53
+ birthDay: new Date(existingMember.details.birthDay!.getTime() + 1),
54
+ }),
55
+ });
56
+ arr.addPut(put);
57
+
58
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
59
+ request.headers.authorization = 'Bearer ' + token.accessToken;
60
+ await expect(testServer.test(endpoint, request))
61
+ .rejects
62
+ .toThrow(errorWithCode('known_member_missing_rights'));
63
+ });
64
+
65
+ test('The security code is not a requirement for members without additional data', async () => {
66
+ const organization = await new OrganizationFactory({ }).create();
67
+ const user = await new UserFactory({
68
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
69
+ organization, // since we are in platform mode, this will only set the permissions for this organization
70
+ }).create();
71
+
72
+ const existingMember = await new MemberFactory({
73
+ firstName,
74
+ lastName,
75
+ birthDay,
76
+ generateData: false,
77
+ }).create();
78
+
79
+ const token = await Token.createToken(user);
80
+
81
+ const arr: Body = new PatchableArray();
82
+ const put = MemberWithRegistrationsBlob.create({
83
+ details: MemberDetails.create({
84
+ firstName,
85
+ lastName,
86
+ birthDay: new Date(existingMember.details.birthDay!.getTime() + 1),
87
+ email: 'anewemail@example.com',
88
+ }),
89
+ });
90
+ arr.addPut(put);
91
+
92
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
93
+ request.headers.authorization = 'Bearer ' + token.accessToken;
94
+ const response = await testServer.test(endpoint, request);
95
+ expect(response.status).toBe(200);
96
+
97
+ // Check id of the returned memebr matches the existing member
98
+ expect(response.body.members.length).toBe(1);
99
+ expect(response.body.members[0].id).toBe(existingMember.id);
100
+
101
+ // Check data matches the original data + changes from the put
102
+ const member = response.body.members[0];
103
+ expect(member.details.firstName).toBe(firstName);
104
+ expect(member.details.lastName).toBe(lastName);
105
+ expect(member.details.birthDay).toEqual(existingMember.details.birthDay);
106
+ expect(member.details.email).toBe('anewemail@example.com'); // this has been merged
107
+ expect(member.details.alternativeEmails).toHaveLength(0);
108
+ });
109
+
110
+ test('A duplicate member with existing registrations returns those registrations after a merge', async () => {
111
+ const organization = await new OrganizationFactory({ }).create();
112
+ const user = await new UserFactory({
113
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
114
+ organization, // since we are in platform mode, this will only set the permissions for this organization
115
+ }).create();
116
+
117
+ const details = MemberDetails.create({
118
+ firstName,
119
+ lastName,
120
+ securityCode: 'ABC-123',
121
+ email: 'original@example.com',
122
+ parents: [
123
+ Parent.create({
124
+ firstName: 'Jane',
125
+ lastName: 'Doe',
126
+ email: 'jane.doe@example.com',
127
+ }),
128
+ ],
129
+ });
130
+
131
+ const existingMember = await new MemberFactory({
132
+ birthDay,
133
+ details,
134
+ }).create();
135
+
136
+ // Create a registration for this member
137
+ const group = await new GroupFactory({ organization }).create();
138
+ const registration = await new RegistrationFactory({
139
+ member: existingMember,
140
+ group,
141
+ }).create();
142
+
143
+ const token = await Token.createToken(user);
144
+
145
+ const arr: Body = new PatchableArray();
146
+ const put = MemberWithRegistrationsBlob.create({
147
+ details: MemberDetails.create({
148
+ firstName,
149
+ lastName,
150
+ birthDay: new Date(existingMember.details.birthDay!.getTime() + 1),
151
+ securityCode: existingMember.details.securityCode,
152
+ email: 'anewemail@example.com',
153
+ }),
154
+ });
155
+ arr.addPut(put);
156
+
157
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
158
+ request.headers.authorization = 'Bearer ' + token.accessToken;
159
+ const response = await testServer.test(endpoint, request);
160
+ expect(response.status).toBe(200);
161
+
162
+ // Check id of the returned memebr matches the existing member
163
+ expect(response.body.members.length).toBe(1);
164
+ expect(response.body.members[0].id).toBe(existingMember.id);
165
+
166
+ // Check data matches the original data + changes from the put
167
+ const member = response.body.members[0];
168
+ expect(member.details.firstName).toBe(firstName);
169
+ expect(member.details.lastName).toBe(lastName);
170
+ expect(member.details.birthDay).toEqual(existingMember.details.birthDay);
171
+ expect(member.details.email).toBe('original@example.com'); // this has been merged
172
+ expect(member.details.alternativeEmails).toEqual(['anewemail@example.com']); // this has been merged
173
+
174
+ // Check the registration is still there
175
+ expect(member.registrations.length).toBe(1);
176
+ expect(member.registrations[0].id).toBe(registration.id);
177
+
178
+ // Check parent is still there
179
+ expect(member.details.parents.length).toBe(1);
180
+ expect(member.details.parents[0]).toEqual(existingMember.details.parents[0]);
181
+ });
182
+ });
183
+
184
+ describe('Permission checking', () => {
185
+ test('An admin cannot edit members of a different organization', async () => {
186
+ const organization = await new OrganizationFactory({}).create();
187
+ const otherOrganization = await new OrganizationFactory({}).create();
188
+
189
+ const user = await new UserFactory({
190
+ permissions: Permissions.create({
191
+ level: PermissionLevel.Full,
192
+ }),
193
+ organization, // since we are in platform mode, this will only set the permissions for this organization
194
+ }).create();
195
+
196
+ const member = await new MemberFactory({
197
+ firstName,
198
+ lastName,
199
+ birthDay,
200
+ generateData: false,
201
+ }).create();
202
+
203
+ // Register this member
204
+ await new RegistrationFactory({
205
+ member,
206
+ organization: otherOrganization,
207
+ }).create();
208
+
209
+ const token = await Token.createToken(user);
210
+
211
+ const arr: Body = new PatchableArray();
212
+ const patch = MemberWithRegistrationsBlob.patch({
213
+ id: member.id,
214
+ details: MemberDetails.patch({
215
+ firstName: 'Changed',
216
+ }),
217
+ });
218
+ arr.addPatch(patch);
219
+
220
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
221
+ request.headers.authorization = 'Bearer ' + token.accessToken;
222
+ await expect(testServer.test(endpoint, request)).rejects.toThrow(errorWithCode('not_found'));
223
+ });
224
+
225
+ test('An admin can edit members registered in its own organization', async () => {
226
+ const organization = await new OrganizationFactory({}).create();
227
+
228
+ const user = await new UserFactory({
229
+ permissions: Permissions.create({
230
+ level: PermissionLevel.Full,
231
+ }),
232
+ organization, // since we are in platform mode, this will only set the permissions for this organization
233
+ }).create();
234
+
235
+ const member = await new MemberFactory({
236
+ firstName,
237
+ lastName,
238
+ birthDay,
239
+ generateData: false,
240
+ }).create();
241
+
242
+ // Register this member
243
+ await new RegistrationFactory({
244
+ member,
245
+ organization,
246
+ }).create();
247
+
248
+ const token = await Token.createToken(user);
249
+
250
+ const arr: Body = new PatchableArray();
251
+ const patch = MemberWithRegistrationsBlob.patch({
252
+ id: member.id,
253
+ details: MemberDetails.patch({
254
+ firstName: 'Changed',
255
+ }),
256
+ });
257
+ arr.addPatch(patch);
258
+
259
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
260
+ request.headers.authorization = 'Bearer ' + token.accessToken;
261
+ const response = await testServer.test(endpoint, request);
262
+
263
+ // Check returned
264
+ expect(response.status).toBe(200);
265
+ expect(response.body.members.length).toBe(1);
266
+ const memberStruct = response.body.members[0];
267
+ expect(memberStruct.details).toMatchObject({
268
+ firstName: 'Changed',
269
+ });
270
+ });
271
+
272
+ test('A full platform admin can edit members without registrations', async () => {
273
+ const organization = await new OrganizationFactory({}).create();
274
+
275
+ const user = await new UserFactory({
276
+ globalPermissions: Permissions.create({
277
+ level: PermissionLevel.Full,
278
+ }),
279
+ }).create();
280
+
281
+ const member = await new MemberFactory({
282
+ firstName,
283
+ lastName,
284
+ birthDay,
285
+ generateData: false,
286
+ }).create();
287
+
288
+ const token = await Token.createToken(user);
289
+
290
+ const arr: Body = new PatchableArray();
291
+ const patch = MemberWithRegistrationsBlob.patch({
292
+ id: member.id,
293
+ details: MemberDetails.patch({
294
+ firstName: 'Changed',
295
+ }),
296
+ });
297
+ arr.addPatch(patch);
298
+
299
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
300
+ request.headers.authorization = 'Bearer ' + token.accessToken;
301
+ const response = await testServer.test(endpoint, request);
302
+
303
+ // Check returned
304
+ expect(response.status).toBe(200);
305
+ expect(response.body.members.length).toBe(1);
306
+ const memberStruct = response.body.members[0];
307
+ expect(memberStruct.details).toMatchObject({
308
+ firstName: 'Changed',
309
+ });
310
+ });
311
+
312
+ test('[Regression] A platform admin with all tag access can edit members without registrations', async () => {
313
+ const organization = await new OrganizationFactory({}).create();
314
+
315
+ const user = await new UserFactory({
316
+ globalPermissions: Permissions.create({
317
+ level: PermissionLevel.None,
318
+ resources: new Map([
319
+ // All Tags
320
+ [PermissionsResourceType.OrganizationTags, new Map(
321
+ [['', ResourcePermissions.create({ level: PermissionLevel.Full })]],
322
+ )],
323
+ ]),
324
+ }),
325
+ }).create();
326
+
327
+ const member = await new MemberFactory({
328
+ firstName,
329
+ lastName,
330
+ birthDay,
331
+ generateData: false,
332
+ }).create();
333
+
334
+ const token = await Token.createToken(user);
335
+
336
+ const arr: Body = new PatchableArray();
337
+ const patch = MemberWithRegistrationsBlob.patch({
338
+ id: member.id,
339
+ details: MemberDetails.patch({
340
+ firstName: 'Changed',
341
+ }),
342
+ });
343
+ arr.addPatch(patch);
344
+
345
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
346
+ request.headers.authorization = 'Bearer ' + token.accessToken;
347
+ const response = await testServer.test(endpoint, request);
348
+
349
+ // Check returned
350
+ expect(response.status).toBe(200);
351
+ expect(response.body.members.length).toBe(1);
352
+ const memberStruct = response.body.members[0];
353
+ expect(memberStruct.details).toMatchObject({
354
+ firstName: 'Changed',
355
+ });
356
+ });
357
+ });
358
+
359
+ describe('Record answers', () => {
360
+ test('An admin can set records of its own organization', async () => {
361
+ const commentsRecord = RecordSettings.create({
362
+ name: 'Opmerkingen',
363
+ externalPermissionLevel: PermissionLevel.Read, // this should be ignored since we are an admin
364
+ });
365
+
366
+ const recordCategory = RecordCategory.create({
367
+ name: 'Medische fiche',
368
+ records: [
369
+ commentsRecord,
370
+ ],
371
+ });
372
+ const organization = await new OrganizationFactory({
373
+ meta: OrganizationMetaData.create({
374
+ recordsConfiguration: OrganizationRecordsConfiguration.create({
375
+ recordCategories: [recordCategory],
376
+ }),
377
+ }),
378
+ }).create();
379
+
380
+ const user = await new UserFactory({
381
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
382
+ organization, // since we are in platform mode, this will only set the permissions for this organization
383
+ }).create();
384
+
385
+ const member = await new MemberFactory({
386
+ firstName,
387
+ lastName,
388
+ birthDay,
389
+ generateData: false,
390
+ }).create();
391
+
392
+ // Register this member
393
+ await new RegistrationFactory({
394
+ member,
395
+ organization,
396
+ }).create();
397
+
398
+ const token = await Token.createToken(user);
399
+
400
+ const recordAnswers = new PatchMap() as PatchAnswers;
401
+
402
+ recordAnswers.set(commentsRecord.id, RecordTextAnswer.create({
403
+ settings: commentsRecord,
404
+ value: 'Some comments',
405
+ }));
406
+
407
+ const arr: Body = new PatchableArray();
408
+ const patch = MemberWithRegistrationsBlob.patch({
409
+ id: member.id,
410
+ details: MemberDetails.patch({
411
+ recordAnswers,
412
+ }),
413
+ });
414
+ arr.addPatch(patch);
415
+
416
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
417
+ request.headers.authorization = 'Bearer ' + token.accessToken;
418
+ const response = await testServer.test(endpoint, request);
419
+
420
+ // Check returned
421
+ expect(response.status).toBe(200);
422
+ expect(response.body.members.length).toBe(1);
423
+ const struct = response.body.members[0];
424
+ expect(struct.details.recordAnswers.get(commentsRecord.id)).toMatchObject({
425
+ value: 'Some comments',
426
+ });
427
+ });
428
+
429
+ test('An admin with read only record category permission cannot set the records in that category', async () => {
430
+ const commentsRecord = RecordSettings.create({
431
+ name: 'Opmerkingen',
432
+ });
433
+
434
+ const recordCategory = RecordCategory.create({
435
+ name: 'Medische fiche',
436
+ records: [
437
+ commentsRecord,
438
+ ],
439
+ });
440
+ const organization = await new OrganizationFactory({
441
+ meta: OrganizationMetaData.create({
442
+ recordsConfiguration: OrganizationRecordsConfiguration.create({
443
+ recordCategories: [recordCategory],
444
+ }),
445
+ }),
446
+ }).create();
447
+
448
+ const group = await new GroupFactory({ organization }).create();
449
+
450
+ const user = await new UserFactory({
451
+ permissions: Permissions.create({
452
+ level: PermissionLevel.None,
453
+ resources: new Map([
454
+ [PermissionsResourceType.RecordCategories, new Map([
455
+ [recordCategory.id, ResourcePermissions.create({ level: PermissionLevel.Read })],
456
+ ])],
457
+ [PermissionsResourceType.Groups, new Map([
458
+ [group.id, ResourcePermissions.create({ level: PermissionLevel.Full })],
459
+ ])],
460
+ ]),
461
+ }),
462
+ organization, // since we are in platform mode, this will only set the permissions for this organization
463
+ }).create();
464
+
465
+ const member = await new MemberFactory({
466
+ firstName,
467
+ lastName,
468
+ birthDay,
469
+ generateData: false,
470
+ }).create();
471
+
472
+ // Register this member
473
+ await new RegistrationFactory({
474
+ member,
475
+ group,
476
+ }).create();
477
+
478
+ const token = await Token.createToken(user);
479
+
480
+ const recordAnswers = new PatchMap() as PatchAnswers;
481
+
482
+ recordAnswers.set(commentsRecord.id, RecordTextAnswer.create({
483
+ settings: commentsRecord,
484
+ value: 'Some comments',
485
+ }));
486
+
487
+ const arr: Body = new PatchableArray();
488
+ const patch = MemberWithRegistrationsBlob.patch({
489
+ id: member.id,
490
+ details: MemberDetails.patch({
491
+ recordAnswers,
492
+ }),
493
+ });
494
+ arr.addPatch(patch);
495
+
496
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
497
+ request.headers.authorization = 'Bearer ' + token.accessToken;
498
+ await expect(testServer.test(endpoint, request)).rejects.toThrow(errorWithCode('permission_denied'));
499
+ });
500
+
501
+ test('An admin without record category permission cannot set the records in that category', async () => {
502
+ const commentsRecord = RecordSettings.create({
503
+ name: 'Opmerkingen',
504
+ });
505
+
506
+ const recordCategory = RecordCategory.create({
507
+ name: 'Medische fiche',
508
+ records: [
509
+ commentsRecord,
510
+ ],
511
+ });
512
+ const organization = await new OrganizationFactory({
513
+ meta: OrganizationMetaData.create({
514
+ recordsConfiguration: OrganizationRecordsConfiguration.create({
515
+ recordCategories: [recordCategory],
516
+ }),
517
+ }),
518
+ }).create();
519
+
520
+ const group = await new GroupFactory({ organization }).create();
521
+
522
+ const user = await new UserFactory({
523
+ permissions: Permissions.create({
524
+ level: PermissionLevel.None,
525
+ resources: new Map([
526
+ [PermissionsResourceType.Groups, new Map([
527
+ [group.id, ResourcePermissions.create({ level: PermissionLevel.Full })],
528
+ ])],
529
+ ]),
530
+ }),
531
+ organization, // since we are in platform mode, this will only set the permissions for this organization
532
+ }).create();
533
+
534
+ const member = await new MemberFactory({
535
+ firstName,
536
+ lastName,
537
+ birthDay,
538
+ generateData: false,
539
+ }).create();
540
+
541
+ // Register this member
542
+ await new RegistrationFactory({
543
+ member,
544
+ group,
545
+ }).create();
546
+
547
+ const token = await Token.createToken(user);
548
+
549
+ const recordAnswers = new PatchMap() as PatchAnswers;
550
+
551
+ recordAnswers.set(commentsRecord.id, RecordTextAnswer.create({
552
+ settings: commentsRecord,
553
+ value: 'Some comments',
554
+ }));
555
+
556
+ const arr: Body = new PatchableArray();
557
+ const patch = MemberWithRegistrationsBlob.patch({
558
+ id: member.id,
559
+ details: MemberDetails.patch({
560
+ recordAnswers,
561
+ }),
562
+ });
563
+ arr.addPatch(patch);
564
+
565
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
566
+ request.headers.authorization = 'Bearer ' + token.accessToken;
567
+ await expect(testServer.test(endpoint, request)).rejects.toThrow(errorWithCode('permission_denied'));
568
+ });
569
+
570
+ test('An admin can set records of the platform', async () => {
571
+ const commentsRecord = RecordSettings.create({
572
+ name: 'Opmerkingen',
573
+ });
574
+
575
+ const recordCategory = RecordCategory.create({
576
+ name: 'Medische fiche',
577
+ records: [
578
+ commentsRecord,
579
+ ],
580
+ });
581
+
582
+ const platform = await Platform.getShared();
583
+ platform.config.recordsConfiguration.recordCategories.push(recordCategory);
584
+ await platform.save();
585
+
586
+ const organization = await new OrganizationFactory({}).create();
587
+ const group = await new GroupFactory({ organization }).create();
588
+
589
+ const user = await new UserFactory({
590
+ permissions: Permissions.create({
591
+ level: PermissionLevel.None,
592
+ resources: new Map([
593
+ [PermissionsResourceType.RecordCategories, new Map([
594
+ [recordCategory.id, ResourcePermissions.create({ level: PermissionLevel.Write })],
595
+ ])],
596
+ [PermissionsResourceType.Groups, new Map([
597
+ [group.id, ResourcePermissions.create({ level: PermissionLevel.Full })],
598
+ ])],
599
+ ]),
600
+ }),
601
+ organization, // since we are in platform mode, this will only set the permissions for this organization
602
+ }).create();
603
+
604
+ const member = await new MemberFactory({
605
+ firstName,
606
+ lastName,
607
+ birthDay,
608
+ generateData: false,
609
+ }).create();
610
+
611
+ // Register this member
612
+ await new RegistrationFactory({
613
+ member,
614
+ group,
615
+ }).create();
616
+
617
+ const token = await Token.createToken(user);
618
+
619
+ const recordAnswers = new PatchMap() as PatchAnswers;
620
+
621
+ recordAnswers.set(commentsRecord.id, RecordTextAnswer.create({
622
+ settings: commentsRecord,
623
+ value: 'Some comments',
624
+ }));
625
+
626
+ const arr: Body = new PatchableArray();
627
+ const patch = MemberWithRegistrationsBlob.patch({
628
+ id: member.id,
629
+ details: MemberDetails.patch({
630
+ recordAnswers,
631
+ }),
632
+ });
633
+ arr.addPatch(patch);
634
+
635
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
636
+ request.headers.authorization = 'Bearer ' + token.accessToken;
637
+ const response = await testServer.test(endpoint, request);
638
+
639
+ // Check returned
640
+ expect(response.status).toBe(200);
641
+ expect(response.body.members.length).toBe(1);
642
+ const struct = response.body.members[0];
643
+ expect(struct.details.recordAnswers.get(commentsRecord.id)).toMatchObject({
644
+ value: 'Some comments',
645
+ });
646
+ });
647
+
648
+ test('[Regression] A platform admin with tag-access to an organization can change platform records', async () => {
649
+ const commentsRecord = RecordSettings.create({
650
+ name: 'Opmerkingen',
651
+ });
652
+
653
+ const recordCategory = RecordCategory.create({
654
+ name: 'Medische fiche',
655
+ records: [
656
+ commentsRecord,
657
+ ],
658
+ });
659
+
660
+ const platform = await Platform.getShared();
661
+ platform.config.recordsConfiguration.recordCategories.push(recordCategory);
662
+ await platform.save();
663
+
664
+ const tag = await new OrganizationTagFactory({}).create();
665
+ const organization = await new OrganizationFactory({
666
+ tags: [tag.id],
667
+ }).create();
668
+ const group = await new GroupFactory({ organization }).create();
669
+
670
+ const user = await new UserFactory({
671
+ globalPermissions: Permissions.create({
672
+ level: PermissionLevel.None,
673
+ resources: new Map([
674
+ [PermissionsResourceType.OrganizationTags, new Map([
675
+ [tag.id, ResourcePermissions.create({ level: PermissionLevel.Full })],
676
+ ])],
677
+ ]),
678
+ }),
679
+ organization, // since we are in platform mode, this will only set the permissions for this organization
680
+ }).create();
681
+
682
+ const member = await new MemberFactory({
683
+ firstName,
684
+ lastName,
685
+ birthDay,
686
+ generateData: false,
687
+ }).create();
688
+
689
+ // Register this member
690
+ await new RegistrationFactory({
691
+ member,
692
+ group,
693
+ }).create();
694
+
695
+ const token = await Token.createToken(user);
696
+
697
+ const recordAnswers = new PatchMap() as PatchAnswers;
698
+
699
+ recordAnswers.set(commentsRecord.id, RecordTextAnswer.create({
700
+ settings: commentsRecord,
701
+ value: 'Some comments',
702
+ }));
703
+
704
+ const arr: Body = new PatchableArray();
705
+ const patch = MemberWithRegistrationsBlob.patch({
706
+ id: member.id,
707
+ details: MemberDetails.patch({
708
+ recordAnswers,
709
+ }),
710
+ });
711
+ arr.addPatch(patch);
712
+
713
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
714
+ request.headers.authorization = 'Bearer ' + token.accessToken;
715
+ const response = await testServer.test(endpoint, request);
716
+
717
+ // Check returned
718
+ expect(response.status).toBe(200);
719
+ expect(response.body.members.length).toBe(1);
720
+ const struct = response.body.members[0];
721
+ expect(struct.details.recordAnswers.get(commentsRecord.id)).toMatchObject({
722
+ value: 'Some comments',
723
+ });
724
+ });
725
+ });
726
+ });
@@ -16,8 +16,7 @@ import { MemberUserSyncer } from '../../../helpers/MemberUserSyncer';
16
16
  import { SetupStepUpdater } from '../../../helpers/SetupStepUpdater';
17
17
  import { PlatformMembershipService } from '../../../services/PlatformMembershipService';
18
18
  import { RegistrationService } from '../../../services/RegistrationService';
19
- import { shouldCheckIfMemberIsDuplicateForPatch, shouldCheckIfMemberIsDuplicateForPut } from './shouldCheckIfMemberIsDuplicate';
20
- import { AuditLogService } from '../../../services/AuditLogService';
19
+ import { shouldCheckIfMemberIsDuplicateForPatch } from './shouldCheckIfMemberIsDuplicate';
21
20
 
22
21
  type Params = Record<string, never>;
23
22
  type Query = undefined;
@@ -109,12 +108,10 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
109
108
  struct.details.cleanData();
110
109
  member.details = struct.details;
111
110
 
112
- if (shouldCheckIfMemberIsDuplicateForPut(struct)) {
113
- const duplicate = await PatchOrganizationMembersEndpoint.checkDuplicate(member, struct.details.securityCode);
114
- if (duplicate) {
111
+ const duplicate = await PatchOrganizationMembersEndpoint.checkDuplicate(member, struct.details.securityCode, 'put');
112
+ if (duplicate) {
115
113
  // Merge data
116
- member = duplicate;
117
- }
114
+ member = duplicate;
118
115
  }
119
116
 
120
117
  // We risk creating a new member without being able to access it manually afterwards
@@ -159,12 +156,11 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
159
156
  const securityCode = patch.details?.securityCode; // will get cleared after the filter
160
157
 
161
158
  if (!member) {
162
- throw Context.auth.notFoundOrNoAccess('Je hebt geen toegang tot dit lid of het bestaat niet');
159
+ throw Context.auth.memberNotFoundOrNoAccess();
163
160
  }
164
161
 
165
- if (!await Context.auth.canAccessMember(member, PermissionLevel.Write)) {
166
- // Still allowed if you provide a security code
167
- await PatchOrganizationMembersEndpoint.checkSecurityCode(member, securityCode);
162
+ if (!(await Context.auth.canAccessMember(member, PermissionLevel.Write))) {
163
+ await PatchOrganizationMembersEndpoint.checkSecurityCode(member, securityCode, 'patch');
168
164
  }
169
165
 
170
166
  patch = await Context.auth.filterMemberPatch(member, patch);
@@ -194,7 +190,7 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
194
190
  }
195
191
 
196
192
  if (shouldCheckDuplicate) {
197
- const duplicate = await PatchOrganizationMembersEndpoint.checkDuplicate(member, securityCode);
193
+ const duplicate = await PatchOrganizationMembersEndpoint.checkDuplicate(member, securityCode, 'patch');
198
194
 
199
195
  if (duplicate) {
200
196
  // Remove the member from the list
@@ -759,8 +755,8 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
759
755
  }
760
756
  }
761
757
 
762
- static async checkSecurityCode(member: MemberWithRegistrations, securityCode: string | null | undefined) {
763
- if (await member.isSafeToMergeDuplicateWithoutSecurityCode() || await Context.auth.canAccessMember(member, PermissionLevel.Write)) {
758
+ static async checkSecurityCode(member: MemberWithRegistrations, securityCode: string | null | undefined, type: 'put' | 'patch') {
759
+ if ((type === 'put' && await member.isSafeToMergeDuplicateWithoutSecurityCode()) || await Context.auth.canAccessMember(member, PermissionLevel.Write)) {
764
760
  console.log('checkSecurityCode: without security code: allowed for ' + member.id);
765
761
  }
766
762
  else if (securityCode) {
@@ -802,8 +798,8 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
802
798
  const log = new AuditLog();
803
799
 
804
800
  // a member has multiple organizations, so this is difficult to determine - for now it is only visible in the admin panel
805
- log.organizationId = member.organizationId;
806
-
801
+ log.organizationId = member.organizationId;
802
+
807
803
  log.type = AuditLogType.MemberSecurityCodeUsed;
808
804
  log.source = AuditLogSource.Anonymous;
809
805
 
@@ -823,6 +819,9 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
823
819
  await log.save();
824
820
  }
825
821
  else {
822
+ if (type === 'patch') {
823
+ throw Context.auth.memberNotFoundOrNoAccess();
824
+ }
826
825
  throw new SimpleError({
827
826
  code: 'known_member_missing_rights',
828
827
  message: 'Creating known member without sufficient access rights',
@@ -832,11 +831,25 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
832
831
  }
833
832
  }
834
833
 
835
- static async checkDuplicate(member: Member, securityCode: string | null | undefined) {
834
+ static shouldCheckIfMemberIsDuplicate(put: Member): boolean {
835
+ if (put.details.firstName.length <= 3 && put.details.lastName.length <= 3) {
836
+ return false;
837
+ }
838
+
839
+ const age = put.details.age;
840
+ // do not check if member is duplicate for historical members
841
+ return age !== null && age < 81;
842
+ }
843
+
844
+ static async checkDuplicate(member: Member, securityCode: string | null | undefined, type: 'put' | 'patch') {
845
+ if (!this.shouldCheckIfMemberIsDuplicate(member)) {
846
+ return;
847
+ }
848
+
836
849
  // Check for duplicates and prevent creating a duplicate member by a user
837
850
  const duplicate = await this.findExistingMember(member);
838
851
  if (duplicate) {
839
- await this.checkSecurityCode(duplicate, securityCode);
852
+ await this.checkSecurityCode(duplicate, securityCode, type);
840
853
 
841
854
  // Merge data
842
855
  // NOTE: We use mergeTwoMembers instead of mergeMultipleMembers, because we should never safe 'member' , because that one does not exist in the database
@@ -1,34 +1,22 @@
1
1
  import { AutoEncoderPatchType } from '@simonbackx/simple-encoding';
2
2
  import { MemberDetails, MemberWithRegistrationsBlob } from '@stamhoofd/structures';
3
3
 
4
+ /**
5
+ * Returns true when either the firstname, lastname or birthday has changed
6
+ */
4
7
  export function shouldCheckIfMemberIsDuplicateForPatch(patch: { details: MemberDetails | AutoEncoderPatchType<MemberDetails> | undefined }, originalDetails: MemberDetails): boolean {
5
8
  if (patch.details === undefined) {
6
9
  return false;
7
10
  }
8
11
 
9
- return (
10
- // has long first name
11
- ((patch.details.firstName !== undefined && patch.details.firstName.length > 3) || (patch.details.firstName === undefined && originalDetails.firstName.length > 3))
12
- // or has long last name
13
- || ((patch.details.lastName !== undefined && patch.details.lastName.length > 3) || (patch.details.lastName === undefined && originalDetails.lastName.length > 3))
14
- )
15
- // has name change or birthday change
16
- && (
17
- // has first name change
12
+ // name or birthday has changed
13
+ if (
18
14
  (patch.details.firstName !== undefined && patch.details.firstName !== originalDetails.firstName)
19
- // has last name change
20
15
  || (patch.details.lastName !== undefined && patch.details.lastName !== originalDetails.lastName)
21
- // has birth day change
22
- || (patch.details.birthDay !== undefined && patch.details.birthDay?.getTime() !== originalDetails.birthDay?.getTime())
23
- );
24
- }
25
-
26
- export function shouldCheckIfMemberIsDuplicateForPut(put: MemberWithRegistrationsBlob): boolean {
27
- if (put.details.firstName.length <= 3 && put.details.lastName.length <= 3) {
28
- return false;
16
+ || (patch.details.birthDay !== undefined && patch.details.birthDay !== originalDetails.birthDay)
17
+ ) {
18
+ return true;
29
19
  }
30
20
 
31
- const age = put.details.age;
32
- // do not check if member is duplicate for historical members
33
- return age !== null && age < 81;
21
+ return false;
34
22
  }
@@ -8,7 +8,7 @@ import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructure
8
8
  import { Context } from '../../../helpers/Context';
9
9
  import { MemberUserSyncer } from '../../../helpers/MemberUserSyncer';
10
10
  import { PatchOrganizationMembersEndpoint } from '../../global/members/PatchOrganizationMembersEndpoint';
11
- import { shouldCheckIfMemberIsDuplicateForPatch, shouldCheckIfMemberIsDuplicateForPut } from '../members/shouldCheckIfMemberIsDuplicate';
11
+ import { shouldCheckIfMemberIsDuplicateForPatch } from '../members/shouldCheckIfMemberIsDuplicate';
12
12
  type Params = Record<string, never>;
13
13
  type Query = undefined;
14
14
  type Body = PatchableArrayAutoEncoder<MemberWithRegistrationsBlob>;
@@ -61,12 +61,10 @@ export class PatchUserMembersEndpoint extends Endpoint<Params, Query, Body, Resp
61
61
 
62
62
  this.throwIfInvalidDetails(member.details);
63
63
 
64
- if (shouldCheckIfMemberIsDuplicateForPut(struct)) {
65
- const duplicate = await PatchOrganizationMembersEndpoint.checkDuplicate(member, struct.details.securityCode);
66
- if (duplicate) {
67
- addedMembers.push(duplicate);
68
- continue;
69
- }
64
+ const duplicate = await PatchOrganizationMembersEndpoint.checkDuplicate(member, struct.details.securityCode, 'put');
65
+ if (duplicate) {
66
+ addedMembers.push(duplicate);
67
+ continue;
70
68
  }
71
69
 
72
70
  await member.save();
@@ -79,12 +77,9 @@ export class PatchUserMembersEndpoint extends Endpoint<Params, Query, Body, Resp
79
77
  for (let struct of request.body.getPatches()) {
80
78
  const member = members.find(m => m.id === struct.id);
81
79
  if (!member) {
82
- throw new SimpleError({
83
- code: 'invalid_member',
84
- message: "This member does not exist or you don't have permissions to modify this member",
85
- human: 'Je probeert een lid aan te passen die niet (meer) bestaat. Er ging ergens iets mis.',
86
- });
80
+ throw Context.auth.memberNotFoundOrNoAccess();
87
81
  }
82
+
88
83
  const securityCode = struct.details?.securityCode; // will get cleared after the filter
89
84
  struct = await Context.auth.filterMemberPatch(member, struct);
90
85
 
@@ -117,7 +112,7 @@ export class PatchUserMembersEndpoint extends Endpoint<Params, Query, Body, Resp
117
112
  }
118
113
 
119
114
  if (shouldCheckDuplicate) {
120
- const duplicate = await PatchOrganizationMembersEndpoint.checkDuplicate(member, securityCode);
115
+ const duplicate = await PatchOrganizationMembersEndpoint.checkDuplicate(member, securityCode, 'patch');
121
116
  if (duplicate) {
122
117
  // Remove the member from the list
123
118
  members.splice(members.findIndex(m => m.id === member.id), 1);
@@ -250,7 +250,7 @@ export class PatchBalanceItemsEndpoint extends Endpoint<Params, Query, Body, Res
250
250
  if (!member || !(await Context.auth.canLinkBalanceItemToMember(member))) {
251
251
  throw new SimpleError({
252
252
  code: 'permission_denied',
253
- message: 'No permission to link balanace items to this member',
253
+ message: 'No permission to link balance items to this member',
254
254
  human: 'Je hebt geen toegang om aanrekeningen te maken verbonden met dit lid',
255
255
  field: 'memberId',
256
256
  });
@@ -264,7 +264,7 @@ export class PatchBalanceItemsEndpoint extends Endpoint<Params, Query, Body, Res
264
264
  if (!user || !await Context.auth.canLinkBalanceItemToUser(balanceItem, user)) {
265
265
  throw new SimpleError({
266
266
  code: 'permission_denied',
267
- message: 'No permission to link balanace items to this user',
267
+ message: 'No permission to link balance items to this user',
268
268
  human: 'Je hebt geen toegang om aanrekeningen te maken verbonden met deze gebruiker',
269
269
  field: 'userId',
270
270
  });
@@ -91,6 +91,10 @@ export class AdminPermissionChecker {
91
91
  });
92
92
  }
93
93
 
94
+ memberNotFoundOrNoAccess(): SimpleError {
95
+ return this.notFoundOrNoAccess($t('Je hebt geen toegang tot dit lid of het bestaat niet'));
96
+ }
97
+
94
98
  notFoundOrNoAccess(message?: string): SimpleError {
95
99
  return new SimpleError({
96
100
  code: 'not_found',
@@ -1043,6 +1047,20 @@ export class AdminPermissionChecker {
1043
1047
  };
1044
1048
  }
1045
1049
 
1050
+ // It is possible that this is a platform admin, and inherits automatic permissions for tags. So'll need to loop all the organizations where this member has an active registration for
1051
+ if (!record.organizationId && this.platformPermissions) {
1052
+ const organizations = Formatter.uniqueArray(member.registrations.map(r => r.organizationId));
1053
+ for (const organizationId of organizations) {
1054
+ const organizationPermissions = await this.getOrganizationPermissions(organizationId);
1055
+ if (organizationPermissions && organizationPermissions.hasResourceAccess(PermissionsResourceType.RecordCategories, record.rootCategoryId, level)) {
1056
+ return {
1057
+ canAccess: true,
1058
+ record: record.record,
1059
+ };
1060
+ }
1061
+ }
1062
+ }
1063
+
1046
1064
  return {
1047
1065
  canAccess: false,
1048
1066
  record: record.record,
@@ -1088,8 +1106,8 @@ export class AdminPermissionChecker {
1088
1106
  if (isUserManager) {
1089
1107
  // For a user manager without an organization, we don't delete data, because when registering a new member, it doesn't have any organizations yet...
1090
1108
  if (!(await this.canAccessMember(member, PermissionLevel.Full))) {
1091
- cloned.details.securityCode = null;
1092
1109
  cloned.details.notes = null;
1110
+ // a user manager can see the security codes
1093
1111
  }
1094
1112
 
1095
1113
  return cloned;
@@ -22,7 +22,7 @@ export class FileSignService {
22
22
  const alg = STAMHOOFD.FILE_SIGNING_ALG || 'ES256';
23
23
 
24
24
  if (!STAMHOOFD.FILE_SIGNING_PUBLIC_KEY || !STAMHOOFD.FILE_SIGNING_PRIVATE_KEY) {
25
- if (STAMHOOFD.environment !== 'development') {
25
+ if (STAMHOOFD.environment !== 'development' && STAMHOOFD.environment !== 'test') {
26
26
  throw new Error('Missing environment variables for file signing. Please make sure FILE_SIGNING_PUBLIC_KEY, FILE_SIGNING_PRIVATE_KEY and FILE_SIGNING_ALG are set.');
27
27
  }
28
28