@terreno/api 0.7.2 → 0.8.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/dist/__tests__/{versionCheck.test.js → versionCheckPlugin.test.js} +2 -2
- package/dist/api.d.ts +4 -2
- package/dist/api.js +7 -2
- package/dist/consentApp.d.ts +33 -0
- package/dist/consentApp.js +484 -0
- package/dist/consentApp.test.d.ts +1 -0
- package/dist/consentApp.test.js +1132 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/models/consentForm.d.ts +2 -0
- package/dist/models/consentForm.js +115 -0
- package/dist/models/consentResponse.d.ts +2 -0
- package/dist/models/consentResponse.js +73 -0
- package/dist/models/versionConfig.d.ts +1 -1
- package/dist/openApiValidator.js +2 -0
- package/dist/populate.d.ts +1 -0
- package/dist/populate.js +53 -13
- package/dist/syncConsents.d.ts +67 -0
- package/dist/syncConsents.js +334 -0
- package/dist/syncConsents.test.d.ts +1 -0
- package/dist/syncConsents.test.js +249 -0
- package/dist/terrenoApp.js +6 -5
- package/dist/terrenoPlugin.d.ts +1 -1
- package/dist/types/consentForm.d.ts +32 -0
- package/dist/types/consentForm.js +2 -0
- package/dist/types/consentResponse.d.ts +23 -0
- package/dist/types/consentResponse.js +2 -0
- package/dist/vendor/wesleytodd-openapi/lib/generate-doc.js +9 -2
- package/dist/versionCheckPlugin.d.ts +2 -0
- package/dist/versionCheckPlugin.js +3 -6
- package/package.json +1 -1
- package/src/__tests__/{versionCheck.test.ts → versionCheckPlugin.test.ts} +2 -2
- package/src/api.ts +11 -4
- package/src/consentApp.test.ts +749 -0
- package/src/consentApp.ts +463 -0
- package/src/index.ts +6 -0
- package/src/models/consentForm.ts +123 -0
- package/src/models/consentResponse.ts +78 -0
- package/src/models/versionConfig.ts +1 -1
- package/src/openApiValidator.ts +2 -0
- package/src/populate.ts +33 -0
- package/src/syncConsents.test.ts +124 -0
- package/src/syncConsents.ts +263 -0
- package/src/terrenoApp.ts +6 -6
- package/src/terrenoPlugin.ts +1 -1
- package/src/types/consentForm.ts +41 -0
- package/src/types/consentResponse.ts +34 -0
- package/src/vendor/wesleytodd-openapi/lib/generate-doc.js +8 -2
- package/src/versionCheckPlugin.ts +5 -6
- /package/dist/__tests__/{versionCheck.test.d.ts → versionCheckPlugin.test.d.ts} +0 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it} from "bun:test";
|
|
2
|
+
import type express from "express";
|
|
3
|
+
import supertest from "supertest";
|
|
4
|
+
import type TestAgent from "supertest/lib/agent";
|
|
5
|
+
import {ConsentApp} from "./consentApp";
|
|
6
|
+
import {ConsentForm} from "./models/consentForm";
|
|
7
|
+
import {ConsentResponse} from "./models/consentResponse";
|
|
8
|
+
import {TerrenoApp} from "./terrenoApp";
|
|
9
|
+
import {authAsUser, setupDb, UserModel} from "./tests";
|
|
10
|
+
|
|
11
|
+
const buildApp = (consentAppOptions = {}): express.Application =>
|
|
12
|
+
new TerrenoApp({
|
|
13
|
+
skipListen: true,
|
|
14
|
+
userModel: UserModel as any,
|
|
15
|
+
})
|
|
16
|
+
.register(new ConsentApp(consentAppOptions))
|
|
17
|
+
.build();
|
|
18
|
+
|
|
19
|
+
describe("ConsentApp", () => {
|
|
20
|
+
let admin: any;
|
|
21
|
+
let notAdmin: any;
|
|
22
|
+
let adminAgent: TestAgent;
|
|
23
|
+
let userAgent: TestAgent;
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
[admin, notAdmin] = await setupDb();
|
|
27
|
+
await Promise.all([ConsentForm.deleteMany({}), ConsentResponse.deleteMany({})]);
|
|
28
|
+
const app = buildApp({auditTrail: true});
|
|
29
|
+
adminAgent = await authAsUser(app, "admin");
|
|
30
|
+
userAgent = await authAsUser(app, "notAdmin");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
await Promise.all([ConsentForm.deleteMany({}), ConsentResponse.deleteMany({})]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("GET /consent-forms (admin CRUD)", () => {
|
|
38
|
+
it("returns empty list when no forms exist", async () => {
|
|
39
|
+
const res = await adminAgent.get("/consent-forms").expect(200);
|
|
40
|
+
expect(res.body.data).toHaveLength(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("lists consent forms for admins", async () => {
|
|
44
|
+
await ConsentForm.create({
|
|
45
|
+
active: true,
|
|
46
|
+
content: new Map([["en", "# Terms\nPlease agree."]]),
|
|
47
|
+
order: 1,
|
|
48
|
+
slug: "terms",
|
|
49
|
+
title: "Terms of Service",
|
|
50
|
+
type: "terms",
|
|
51
|
+
version: 1,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const res = await adminAgent.get("/consent-forms").expect(200);
|
|
55
|
+
expect(res.body.data).toHaveLength(1);
|
|
56
|
+
expect(res.body.data[0].title).toBe("Terms of Service");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("blocks non-admins from listing forms", async () => {
|
|
60
|
+
await userAgent.get("/consent-forms").expect(405);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("creates a consent form as admin", async () => {
|
|
64
|
+
const res = await adminAgent
|
|
65
|
+
.post("/consent-forms")
|
|
66
|
+
.send({
|
|
67
|
+
active: false,
|
|
68
|
+
content: {en: "# Privacy\nWe protect your data."},
|
|
69
|
+
order: 2,
|
|
70
|
+
slug: "privacy",
|
|
71
|
+
title: "Privacy Policy",
|
|
72
|
+
type: "privacy",
|
|
73
|
+
version: 1,
|
|
74
|
+
})
|
|
75
|
+
.expect(201);
|
|
76
|
+
|
|
77
|
+
expect(res.body.data.title).toBe("Privacy Policy");
|
|
78
|
+
expect(res.body.data.slug).toBe("privacy");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("updates a consent form as admin", async () => {
|
|
82
|
+
const form = await ConsentForm.create({
|
|
83
|
+
active: false,
|
|
84
|
+
content: new Map([["en", "# HIPAA"]]),
|
|
85
|
+
order: 3,
|
|
86
|
+
slug: "hipaa",
|
|
87
|
+
title: "HIPAA Notice",
|
|
88
|
+
type: "hipaa",
|
|
89
|
+
version: 1,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const res = await adminAgent
|
|
93
|
+
.patch(`/consent-forms/${form._id}`)
|
|
94
|
+
.send({title: "HIPAA Authorization"})
|
|
95
|
+
.expect(200);
|
|
96
|
+
|
|
97
|
+
expect(res.body.data.title).toBe("HIPAA Authorization");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("POST /consent-forms/:id/publish", () => {
|
|
102
|
+
it("creates a new version and activates it", async () => {
|
|
103
|
+
const form = await ConsentForm.create({
|
|
104
|
+
active: true,
|
|
105
|
+
content: new Map([["en", "# Terms v1"]]),
|
|
106
|
+
order: 1,
|
|
107
|
+
slug: "terms-publish",
|
|
108
|
+
title: "Terms v1",
|
|
109
|
+
type: "terms",
|
|
110
|
+
version: 1,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const res = await adminAgent.post(`/consent-forms/${form._id}/publish`).expect(200);
|
|
114
|
+
|
|
115
|
+
expect(res.body.data.version).toBe(2);
|
|
116
|
+
expect(res.body.data.active).toBe(true);
|
|
117
|
+
expect(res.body.data.slug).toBe("terms-publish");
|
|
118
|
+
|
|
119
|
+
// Old form should be deactivated
|
|
120
|
+
const oldForm = await ConsentForm.findById(form._id);
|
|
121
|
+
expect(oldForm?.active).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("increments version number correctly", async () => {
|
|
125
|
+
const form = await ConsentForm.create({
|
|
126
|
+
active: true,
|
|
127
|
+
content: new Map([["en", "# Content"]]),
|
|
128
|
+
order: 1,
|
|
129
|
+
slug: "versioned",
|
|
130
|
+
title: "Versioned Form",
|
|
131
|
+
type: "agreement",
|
|
132
|
+
version: 3,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const res = await adminAgent.post(`/consent-forms/${form._id}/publish`).expect(200);
|
|
136
|
+
|
|
137
|
+
expect(res.body.data.version).toBe(4);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("requires admin to publish", async () => {
|
|
141
|
+
const form = await ConsentForm.create({
|
|
142
|
+
active: true,
|
|
143
|
+
content: new Map([["en", "# Terms"]]),
|
|
144
|
+
order: 1,
|
|
145
|
+
slug: "terms-nonadmin",
|
|
146
|
+
title: "Terms",
|
|
147
|
+
type: "terms",
|
|
148
|
+
version: 1,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await userAgent.post(`/consent-forms/${form._id}/publish`).expect(403);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("GET /consent-responses (admin read-only)", () => {
|
|
156
|
+
it("returns empty list when no responses", async () => {
|
|
157
|
+
const res = await adminAgent.get("/consent-responses").expect(200);
|
|
158
|
+
expect(res.body.data).toHaveLength(0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("lists responses for admins", async () => {
|
|
162
|
+
const form = await ConsentForm.create({
|
|
163
|
+
active: true,
|
|
164
|
+
content: new Map([["en", "# Terms"]]),
|
|
165
|
+
order: 1,
|
|
166
|
+
slug: "terms-resp",
|
|
167
|
+
title: "Terms",
|
|
168
|
+
type: "terms",
|
|
169
|
+
version: 1,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await ConsentResponse.create({
|
|
173
|
+
agreed: true,
|
|
174
|
+
agreedAt: new Date(),
|
|
175
|
+
consentFormId: form._id,
|
|
176
|
+
formVersionSnapshot: 1,
|
|
177
|
+
locale: "en",
|
|
178
|
+
userId: notAdmin._id,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const res = await adminAgent.get("/consent-responses").expect(200);
|
|
182
|
+
expect(res.body.data).toHaveLength(1);
|
|
183
|
+
expect(res.body.data[0].agreed).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("blocks non-admins from listing responses", async () => {
|
|
187
|
+
await userAgent.get("/consent-responses").expect(405);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("blocks create on consent-responses", async () => {
|
|
191
|
+
await adminAgent.post("/consent-responses").send({}).expect(405);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("GET /consents/pending", () => {
|
|
196
|
+
it("returns empty array when no active forms", async () => {
|
|
197
|
+
const res = await userAgent.get("/consents/pending").expect(200);
|
|
198
|
+
expect(res.body.data).toHaveLength(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("returns active forms the user hasn't responded to", async () => {
|
|
202
|
+
await ConsentForm.create({
|
|
203
|
+
active: true,
|
|
204
|
+
content: new Map([["en", "# Terms"]]),
|
|
205
|
+
order: 1,
|
|
206
|
+
slug: "pending-terms",
|
|
207
|
+
title: "Terms",
|
|
208
|
+
type: "terms",
|
|
209
|
+
version: 1,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const res = await userAgent.get("/consents/pending").expect(200);
|
|
213
|
+
expect(res.body.data).toHaveLength(1);
|
|
214
|
+
expect(res.body.data[0].title).toBe("Terms");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("excludes forms the user has already responded to at the current version", async () => {
|
|
218
|
+
const form = await ConsentForm.create({
|
|
219
|
+
active: true,
|
|
220
|
+
content: new Map([["en", "# Terms"]]),
|
|
221
|
+
order: 1,
|
|
222
|
+
slug: "responded-terms",
|
|
223
|
+
title: "Terms",
|
|
224
|
+
type: "terms",
|
|
225
|
+
version: 1,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await ConsentResponse.create({
|
|
229
|
+
agreed: true,
|
|
230
|
+
agreedAt: new Date(),
|
|
231
|
+
consentFormId: form._id,
|
|
232
|
+
formVersionSnapshot: 1,
|
|
233
|
+
locale: "en",
|
|
234
|
+
userId: notAdmin._id,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const res = await userAgent.get("/consents/pending").expect(200);
|
|
238
|
+
expect(res.body.data).toHaveLength(0);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("includes forms when the user responded to an older version", async () => {
|
|
242
|
+
const form = await ConsentForm.create({
|
|
243
|
+
active: true,
|
|
244
|
+
content: new Map([["en", "# Terms v2"]]),
|
|
245
|
+
order: 1,
|
|
246
|
+
slug: "updated-terms",
|
|
247
|
+
title: "Terms",
|
|
248
|
+
type: "terms",
|
|
249
|
+
version: 2,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// User responded to version 1
|
|
253
|
+
await ConsentResponse.create({
|
|
254
|
+
agreed: true,
|
|
255
|
+
agreedAt: new Date(),
|
|
256
|
+
consentFormId: form._id,
|
|
257
|
+
formVersionSnapshot: 1,
|
|
258
|
+
locale: "en",
|
|
259
|
+
userId: notAdmin._id,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const res = await userAgent.get("/consents/pending").expect(200);
|
|
263
|
+
expect(res.body.data).toHaveLength(1);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("returns forms sorted by order field", async () => {
|
|
267
|
+
await Promise.all([
|
|
268
|
+
ConsentForm.create({
|
|
269
|
+
active: true,
|
|
270
|
+
content: new Map([["en", "# Second"]]),
|
|
271
|
+
order: 2,
|
|
272
|
+
slug: "second-form",
|
|
273
|
+
title: "Second Form",
|
|
274
|
+
type: "privacy",
|
|
275
|
+
version: 1,
|
|
276
|
+
}),
|
|
277
|
+
ConsentForm.create({
|
|
278
|
+
active: true,
|
|
279
|
+
content: new Map([["en", "# First"]]),
|
|
280
|
+
order: 1,
|
|
281
|
+
slug: "first-form",
|
|
282
|
+
title: "First Form",
|
|
283
|
+
type: "terms",
|
|
284
|
+
version: 1,
|
|
285
|
+
}),
|
|
286
|
+
]);
|
|
287
|
+
|
|
288
|
+
const res = await userAgent.get("/consents/pending").expect(200);
|
|
289
|
+
expect(res.body.data).toHaveLength(2);
|
|
290
|
+
expect(res.body.data[0].title).toBe("First Form");
|
|
291
|
+
expect(res.body.data[1].title).toBe("Second Form");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("applies resolveConsentForms callback to filter forms", async () => {
|
|
295
|
+
await Promise.all([
|
|
296
|
+
ConsentForm.create({
|
|
297
|
+
active: true,
|
|
298
|
+
content: new Map([["en", "# Privacy"]]),
|
|
299
|
+
order: 1,
|
|
300
|
+
slug: "resolver-privacy",
|
|
301
|
+
title: "Privacy",
|
|
302
|
+
type: "privacy",
|
|
303
|
+
version: 1,
|
|
304
|
+
}),
|
|
305
|
+
ConsentForm.create({
|
|
306
|
+
active: true,
|
|
307
|
+
content: new Map([["en", "# Terms"]]),
|
|
308
|
+
order: 2,
|
|
309
|
+
slug: "resolver-terms",
|
|
310
|
+
title: "Terms",
|
|
311
|
+
type: "terms",
|
|
312
|
+
version: 1,
|
|
313
|
+
}),
|
|
314
|
+
]);
|
|
315
|
+
|
|
316
|
+
// Build app with resolver that only returns privacy forms
|
|
317
|
+
const filteredApp = buildApp({
|
|
318
|
+
resolveConsentForms: (_user: any, forms: any[]) =>
|
|
319
|
+
forms.filter((f) => f.type === "privacy"),
|
|
320
|
+
});
|
|
321
|
+
const filteredAgent = await authAsUser(filteredApp, "notAdmin");
|
|
322
|
+
|
|
323
|
+
const res = await filteredAgent.get("/consents/pending").expect(200);
|
|
324
|
+
expect(res.body.data).toHaveLength(1);
|
|
325
|
+
expect(res.body.data[0].type).toBe("privacy");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("resolveConsentForms can filter by user admin status", async () => {
|
|
329
|
+
await ConsentForm.create({
|
|
330
|
+
active: true,
|
|
331
|
+
content: new Map([["en", "# Terms"]]),
|
|
332
|
+
order: 1,
|
|
333
|
+
slug: "admin-filter-terms",
|
|
334
|
+
title: "Terms",
|
|
335
|
+
type: "terms",
|
|
336
|
+
version: 1,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Build app with resolver that skips admin users
|
|
340
|
+
const filteredApp = buildApp({
|
|
341
|
+
resolveConsentForms: (user: any, forms: any[]) => (user.admin ? [] : forms),
|
|
342
|
+
});
|
|
343
|
+
const filteredAdmin = await authAsUser(filteredApp, "admin");
|
|
344
|
+
const filteredUser = await authAsUser(filteredApp, "notAdmin");
|
|
345
|
+
|
|
346
|
+
// Admin sees no forms
|
|
347
|
+
const adminRes = await filteredAdmin.get("/consents/pending").expect(200);
|
|
348
|
+
expect(adminRes.body.data).toHaveLength(0);
|
|
349
|
+
|
|
350
|
+
// Regular user sees forms
|
|
351
|
+
const userRes = await filteredUser.get("/consents/pending").expect(200);
|
|
352
|
+
expect(userRes.body.data).toHaveLength(1);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("requires authentication", async () => {
|
|
356
|
+
const app = buildApp();
|
|
357
|
+
await supertest(app).get("/consents/pending").expect(401);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe("POST /consents/respond", () => {
|
|
362
|
+
let form: any;
|
|
363
|
+
|
|
364
|
+
beforeEach(async () => {
|
|
365
|
+
form = await ConsentForm.create({
|
|
366
|
+
active: true,
|
|
367
|
+
content: new Map([["en", "# Terms"]]),
|
|
368
|
+
order: 1,
|
|
369
|
+
slug: "respond-terms",
|
|
370
|
+
title: "Terms",
|
|
371
|
+
type: "terms",
|
|
372
|
+
version: 1,
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("creates a consent response", async () => {
|
|
377
|
+
const res = await userAgent
|
|
378
|
+
.post("/consents/respond")
|
|
379
|
+
.send({
|
|
380
|
+
agreed: true,
|
|
381
|
+
consentFormId: form._id,
|
|
382
|
+
locale: "en",
|
|
383
|
+
})
|
|
384
|
+
.expect(200);
|
|
385
|
+
|
|
386
|
+
expect(res.body.data.agreed).toBe(true);
|
|
387
|
+
expect(res.body.data.locale).toBe("en");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("returns 400 when consentFormId is missing", async () => {
|
|
391
|
+
await userAgent.post("/consents/respond").send({agreed: true, locale: "en"}).expect(400);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("returns 400 when agreed field is missing", async () => {
|
|
395
|
+
await userAgent
|
|
396
|
+
.post("/consents/respond")
|
|
397
|
+
.send({consentFormId: form._id, locale: "en"})
|
|
398
|
+
.expect(400);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("returns 400 when locale is missing", async () => {
|
|
402
|
+
await userAgent
|
|
403
|
+
.post("/consents/respond")
|
|
404
|
+
.send({agreed: true, consentFormId: form._id})
|
|
405
|
+
.expect(400);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("returns 400 when form is not active", async () => {
|
|
409
|
+
const inactiveForm = await ConsentForm.create({
|
|
410
|
+
active: false,
|
|
411
|
+
content: new Map([["en", "# Inactive"]]),
|
|
412
|
+
order: 5,
|
|
413
|
+
slug: "inactive-form",
|
|
414
|
+
title: "Inactive Form",
|
|
415
|
+
type: "custom",
|
|
416
|
+
version: 1,
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
await userAgent
|
|
420
|
+
.post("/consents/respond")
|
|
421
|
+
.send({agreed: true, consentFormId: inactiveForm._id, locale: "en"})
|
|
422
|
+
.expect(400);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("validates required checkboxes when agreed is true", async () => {
|
|
426
|
+
const formWithRequired = await ConsentForm.create({
|
|
427
|
+
active: true,
|
|
428
|
+
checkboxes: [{label: "I confirm", required: true}],
|
|
429
|
+
content: new Map([["en", "# Required Checkbox Form"]]),
|
|
430
|
+
order: 6,
|
|
431
|
+
slug: "required-checkbox-form",
|
|
432
|
+
title: "Required Checkbox Form",
|
|
433
|
+
type: "agreement",
|
|
434
|
+
version: 1,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// Agree without checking the required checkbox
|
|
438
|
+
await userAgent
|
|
439
|
+
.post("/consents/respond")
|
|
440
|
+
.send({
|
|
441
|
+
agreed: true,
|
|
442
|
+
checkboxValues: {"0": false},
|
|
443
|
+
consentFormId: formWithRequired._id,
|
|
444
|
+
locale: "en",
|
|
445
|
+
})
|
|
446
|
+
.expect(400);
|
|
447
|
+
|
|
448
|
+
// Agree with the required checkbox checked
|
|
449
|
+
const res = await userAgent
|
|
450
|
+
.post("/consents/respond")
|
|
451
|
+
.send({
|
|
452
|
+
agreed: true,
|
|
453
|
+
checkboxValues: {"0": true},
|
|
454
|
+
consentFormId: formWithRequired._id,
|
|
455
|
+
locale: "en",
|
|
456
|
+
})
|
|
457
|
+
.expect(200);
|
|
458
|
+
|
|
459
|
+
expect(res.body.data.agreed).toBe(true);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("requires signature when captureSignature is true and agreed is true", async () => {
|
|
463
|
+
const sigForm = await ConsentForm.create({
|
|
464
|
+
active: true,
|
|
465
|
+
captureSignature: true,
|
|
466
|
+
content: new Map([["en", "# Signature Required"]]),
|
|
467
|
+
order: 7,
|
|
468
|
+
slug: "sig-form",
|
|
469
|
+
title: "Signature Form",
|
|
470
|
+
type: "agreement",
|
|
471
|
+
version: 1,
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
await userAgent
|
|
475
|
+
.post("/consents/respond")
|
|
476
|
+
.send({agreed: true, consentFormId: sigForm._id, locale: "en"})
|
|
477
|
+
.expect(400);
|
|
478
|
+
|
|
479
|
+
const res = await userAgent
|
|
480
|
+
.post("/consents/respond")
|
|
481
|
+
.send({
|
|
482
|
+
agreed: true,
|
|
483
|
+
consentFormId: sigForm._id,
|
|
484
|
+
locale: "en",
|
|
485
|
+
signature: "data:image/png;base64,abc123",
|
|
486
|
+
})
|
|
487
|
+
.expect(200);
|
|
488
|
+
|
|
489
|
+
expect(res.body.data.signature).toBe("data:image/png;base64,abc123");
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("stores formVersionSnapshot always", async () => {
|
|
493
|
+
const res = await userAgent
|
|
494
|
+
.post("/consents/respond")
|
|
495
|
+
.send({agreed: true, consentFormId: form._id, locale: "en"})
|
|
496
|
+
.expect(200);
|
|
497
|
+
|
|
498
|
+
expect(res.body.data.formVersionSnapshot).toBe(1);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("stores audit trail fields when auditTrail is enabled", async () => {
|
|
502
|
+
const res = await userAgent
|
|
503
|
+
.post("/consents/respond")
|
|
504
|
+
.send({agreed: true, consentFormId: form._id, locale: "en"})
|
|
505
|
+
.expect(200);
|
|
506
|
+
|
|
507
|
+
expect(res.body.data.contentSnapshot).toBeDefined();
|
|
508
|
+
expect(res.body.data.contentSnapshot).toContain("Terms");
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("requires authentication", async () => {
|
|
512
|
+
const app = buildApp();
|
|
513
|
+
await supertest(app)
|
|
514
|
+
.post("/consents/respond")
|
|
515
|
+
.send({agreed: true, consentFormId: form._id, locale: "en"})
|
|
516
|
+
.expect(401);
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
describe("GET /consents/my", () => {
|
|
521
|
+
it("returns empty array when user has no consents", async () => {
|
|
522
|
+
const res = await userAgent.get("/consents/my").expect(200);
|
|
523
|
+
expect(res.body.data).toHaveLength(0);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("returns user consent history with populated form data", async () => {
|
|
527
|
+
const form = await ConsentForm.create({
|
|
528
|
+
active: true,
|
|
529
|
+
checkboxes: [{label: "I agree to the terms", required: true}],
|
|
530
|
+
content: new Map([["en", "# Terms"]]),
|
|
531
|
+
order: 1,
|
|
532
|
+
slug: "my-terms",
|
|
533
|
+
title: "Terms of Service",
|
|
534
|
+
type: "terms",
|
|
535
|
+
version: 2,
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
await ConsentResponse.create({
|
|
539
|
+
agreed: true,
|
|
540
|
+
agreedAt: new Date(),
|
|
541
|
+
checkboxValues: new Map([["0", true]]),
|
|
542
|
+
consentFormId: form._id,
|
|
543
|
+
formVersionSnapshot: 2,
|
|
544
|
+
locale: "en",
|
|
545
|
+
userId: notAdmin._id,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
const res = await userAgent.get("/consents/my").expect(200);
|
|
549
|
+
expect(res.body.data).toHaveLength(1);
|
|
550
|
+
|
|
551
|
+
const item = res.body.data[0];
|
|
552
|
+
expect(item.agreed).toBe(true);
|
|
553
|
+
expect(item.locale).toBe("en");
|
|
554
|
+
expect(item.formVersionSnapshot).toBe(2);
|
|
555
|
+
expect(item.form).toBeDefined();
|
|
556
|
+
expect(item.form.title).toBe("Terms of Service");
|
|
557
|
+
expect(item.form.slug).toBe("my-terms");
|
|
558
|
+
expect(item.form.type).toBe("terms");
|
|
559
|
+
expect(item.form.version).toBe(2);
|
|
560
|
+
expect(item.form.checkboxes).toHaveLength(1);
|
|
561
|
+
expect(item.form.checkboxes[0].label).toBe("I agree to the terms");
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("returns responses sorted by agreedAt descending", async () => {
|
|
565
|
+
const form = await ConsentForm.create({
|
|
566
|
+
active: true,
|
|
567
|
+
content: new Map([["en", "# Terms"]]),
|
|
568
|
+
order: 1,
|
|
569
|
+
slug: "sorted-terms",
|
|
570
|
+
title: "Terms",
|
|
571
|
+
type: "terms",
|
|
572
|
+
version: 1,
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const olderDate = new Date("2025-01-01T00:00:00Z");
|
|
576
|
+
const newerDate = new Date("2025-06-01T00:00:00Z");
|
|
577
|
+
|
|
578
|
+
await ConsentResponse.create({
|
|
579
|
+
agreed: true,
|
|
580
|
+
agreedAt: olderDate,
|
|
581
|
+
consentFormId: form._id,
|
|
582
|
+
formVersionSnapshot: 1,
|
|
583
|
+
locale: "en",
|
|
584
|
+
userId: notAdmin._id,
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
await ConsentResponse.create({
|
|
588
|
+
agreed: false,
|
|
589
|
+
agreedAt: newerDate,
|
|
590
|
+
consentFormId: form._id,
|
|
591
|
+
formVersionSnapshot: 1,
|
|
592
|
+
locale: "en",
|
|
593
|
+
userId: notAdmin._id,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
const res = await userAgent.get("/consents/my").expect(200);
|
|
597
|
+
expect(res.body.data).toHaveLength(2);
|
|
598
|
+
expect(res.body.data[0].agreed).toBe(false); // newer first
|
|
599
|
+
expect(res.body.data[1].agreed).toBe(true); // older second
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("includes audit trail fields when present", async () => {
|
|
603
|
+
const form = await ConsentForm.create({
|
|
604
|
+
active: true,
|
|
605
|
+
content: new Map([["en", "# Audit Terms"]]),
|
|
606
|
+
order: 1,
|
|
607
|
+
slug: "audit-my-terms",
|
|
608
|
+
title: "Audit Terms",
|
|
609
|
+
type: "terms",
|
|
610
|
+
version: 1,
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
await ConsentResponse.create({
|
|
614
|
+
agreed: true,
|
|
615
|
+
agreedAt: new Date(),
|
|
616
|
+
consentFormId: form._id,
|
|
617
|
+
contentSnapshot: "# Audit Terms",
|
|
618
|
+
formVersionSnapshot: 1,
|
|
619
|
+
ipAddress: "192.168.1.1",
|
|
620
|
+
locale: "en",
|
|
621
|
+
userAgent: "Mozilla/5.0 Test Agent",
|
|
622
|
+
userId: notAdmin._id,
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
const res = await userAgent.get("/consents/my").expect(200);
|
|
626
|
+
expect(res.body.data).toHaveLength(1);
|
|
627
|
+
|
|
628
|
+
const item = res.body.data[0];
|
|
629
|
+
expect(item.ipAddress).toBe("192.168.1.1");
|
|
630
|
+
expect(item.userAgent).toBe("Mozilla/5.0 Test Agent");
|
|
631
|
+
expect(item.contentSnapshot).toBe("# Audit Terms");
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it("includes signature when present", async () => {
|
|
635
|
+
const form = await ConsentForm.create({
|
|
636
|
+
active: true,
|
|
637
|
+
captureSignature: true,
|
|
638
|
+
content: new Map([["en", "# Signature Terms"]]),
|
|
639
|
+
order: 1,
|
|
640
|
+
slug: "sig-my-terms",
|
|
641
|
+
title: "Signature Terms",
|
|
642
|
+
type: "agreement",
|
|
643
|
+
version: 1,
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
const signedAt = new Date();
|
|
647
|
+
await ConsentResponse.create({
|
|
648
|
+
agreed: true,
|
|
649
|
+
agreedAt: new Date(),
|
|
650
|
+
consentFormId: form._id,
|
|
651
|
+
formVersionSnapshot: 1,
|
|
652
|
+
locale: "en",
|
|
653
|
+
signature: "data:image/png;base64,sig123",
|
|
654
|
+
signedAt,
|
|
655
|
+
userId: notAdmin._id,
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
const res = await userAgent.get("/consents/my").expect(200);
|
|
659
|
+
expect(res.body.data).toHaveLength(1);
|
|
660
|
+
|
|
661
|
+
const item = res.body.data[0];
|
|
662
|
+
expect(item.signature).toBe("data:image/png;base64,sig123");
|
|
663
|
+
expect(item.signedAt).toBeDefined();
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it("does not return other users' consents", async () => {
|
|
667
|
+
const form = await ConsentForm.create({
|
|
668
|
+
active: true,
|
|
669
|
+
content: new Map([["en", "# Isolation Terms"]]),
|
|
670
|
+
order: 1,
|
|
671
|
+
slug: "isolation-terms",
|
|
672
|
+
title: "Isolation Terms",
|
|
673
|
+
type: "terms",
|
|
674
|
+
version: 1,
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
await ConsentResponse.create({
|
|
678
|
+
agreed: true,
|
|
679
|
+
agreedAt: new Date(),
|
|
680
|
+
consentFormId: form._id,
|
|
681
|
+
formVersionSnapshot: 1,
|
|
682
|
+
locale: "en",
|
|
683
|
+
userId: admin._id,
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
await ConsentResponse.create({
|
|
687
|
+
agreed: true,
|
|
688
|
+
agreedAt: new Date(),
|
|
689
|
+
consentFormId: form._id,
|
|
690
|
+
formVersionSnapshot: 1,
|
|
691
|
+
locale: "en",
|
|
692
|
+
userId: notAdmin._id,
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
const adminRes = await adminAgent.get("/consents/my").expect(200);
|
|
696
|
+
expect(adminRes.body.data).toHaveLength(1);
|
|
697
|
+
expect(adminRes.body.data[0].form.title).toBe("Isolation Terms");
|
|
698
|
+
|
|
699
|
+
const userRes = await userAgent.get("/consents/my").expect(200);
|
|
700
|
+
expect(userRes.body.data).toHaveLength(1);
|
|
701
|
+
expect(userRes.body.data[0].form.title).toBe("Isolation Terms");
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("requires authentication", async () => {
|
|
705
|
+
const app = buildApp();
|
|
706
|
+
await supertest(app).get("/consents/my").expect(401);
|
|
707
|
+
});
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
describe("GET /consents/audit/:userId", () => {
|
|
711
|
+
it("returns audit history for a user when auditTrail is enabled", async () => {
|
|
712
|
+
const form = await ConsentForm.create({
|
|
713
|
+
active: true,
|
|
714
|
+
content: new Map([["en", "# Audit Terms"]]),
|
|
715
|
+
order: 1,
|
|
716
|
+
slug: "audit-terms",
|
|
717
|
+
title: "Audit Terms",
|
|
718
|
+
type: "terms",
|
|
719
|
+
version: 1,
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
await ConsentResponse.create({
|
|
723
|
+
agreed: true,
|
|
724
|
+
agreedAt: new Date(),
|
|
725
|
+
consentFormId: form._id,
|
|
726
|
+
contentSnapshot: "# Audit Terms",
|
|
727
|
+
formVersionSnapshot: 1,
|
|
728
|
+
locale: "en",
|
|
729
|
+
userId: notAdmin._id,
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
const res = await adminAgent.get(`/consents/audit/${notAdmin._id}`).expect(200);
|
|
733
|
+
|
|
734
|
+
expect(res.body.data).toHaveLength(1);
|
|
735
|
+
expect(res.body.data[0].agreed).toBe(true);
|
|
736
|
+
expect(res.body.data[0].form.title).toBe("Audit Terms");
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it("returns 403 for non-admin users", async () => {
|
|
740
|
+
await userAgent.get(`/consents/audit/${notAdmin._id}`).expect(403);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it("returns 404 when auditTrail is disabled", async () => {
|
|
744
|
+
const noAuditApp = buildApp({auditTrail: false});
|
|
745
|
+
const noAuditAdmin = await authAsUser(noAuditApp, "admin");
|
|
746
|
+
await noAuditAdmin.get(`/consents/audit/${notAdmin._id}`).expect(404);
|
|
747
|
+
});
|
|
748
|
+
});
|
|
749
|
+
});
|