fhirsmith 0.13.4 → 0.14.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/CHANGELOG.md +61 -1
- package/README.md +16 -3
- package/config-template.json +81 -1
- package/library/cgroup-memory.js +113 -0
- package/library/folder-setup.js +8 -1
- package/library/languages.js +15 -0
- package/library/same-origin.js +62 -0
- package/nginx.md +399 -0
- package/package.json +3 -2
- package/publisher/git-remote.js +141 -0
- package/publisher/github-release.js +195 -0
- package/publisher/publisher-template.html +1 -0
- package/publisher/publisher.js +241 -56
- package/registry/registry.js +3 -1
- package/server.js +57 -15
- package/stats.js +19 -13
- package/testing/readme.md +144 -0
- package/testing/render.js +723 -0
- package/testing/search.js +378 -0
- package/testing/store.js +410 -0
- package/testing/testing-template.html +126 -0
- package/testing/testing.js +760 -0
- package/translations/Messages.properties +32 -0
- package/tx/closure/closure-store.js +396 -0
- package/tx/cs/cs-api.js +6 -1
- package/tx/cs/cs-rxnorm.js +35 -8
- package/tx/cs/cs-snomed.js +19 -69
- package/tx/html/codesystem-operations.liquid +177 -0
- package/tx/html/ecl-panel.liquid +93 -0
- package/tx/html/home-metrics.liquid +39 -76
- package/tx/html/tx-template.html +1 -0
- package/tx/library/conceptmap.js +22 -6
- package/tx/library/designations.js +12 -3
- package/tx/library/operation-outcome.js +3 -1
- package/tx/library/renderer.js +42 -5
- package/tx/operation-context.js +80 -17
- package/tx/provider.js +85 -0
- package/tx/sct/editions.js +70 -0
- package/tx/sct/expressions.js +128 -44
- package/tx/tx-html.js +426 -44
- package/tx/tx.js +117 -9
- package/tx/workers/cache-control.js +2 -2
- package/tx/workers/closure.js +483 -13
- package/tx/workers/expand.js +2 -2
- package/tx/workers/metadata.js +8 -3
- package/tx/workers/read.js +18 -3
- package/tx/workers/subsumes.js +18 -2
- package/tx/workers/translate.js +192 -21
- package/tx/workers/validate.js +70 -187
- package/tx/workers/worker.js +12 -35
- package/tx/xversion/xv-conceptmap.js +17 -8
- package/utilities/submit-test-report.js +150 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
|
|
3
|
+
//
|
|
4
|
+
// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
|
|
5
|
+
//
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The /testing module: a place to send TestReports (from TxTester, and anything else),
|
|
9
|
+
* and to look at them.
|
|
10
|
+
*
|
|
11
|
+
* POST {base}/TestReport create (token required if one is configured)
|
|
12
|
+
* GET {base}/TestReport search - a Bundle, or HTML for a browser
|
|
13
|
+
* GET {base}/TestReport/:id read - JSON, or the rendered report for a browser
|
|
14
|
+
* DELETE {base}/TestReport/:id admin only (adminToken)
|
|
15
|
+
* GET {base}/metadata CapabilityStatement
|
|
16
|
+
* GET {base} the filterable list (HTML)
|
|
17
|
+
* GET {base}/summary latest result per test script x participant (HTML)
|
|
18
|
+
* GET {base}/login login for the administration pages; POST {base}/logout
|
|
19
|
+
* GET {base}/admin/links names for known canonical URLs (users who can edit links)
|
|
20
|
+
* GET {base}/admin/users users and their rights (the administrator only)
|
|
21
|
+
*
|
|
22
|
+
* Logins work as the publisher's do: a session cookie, bcrypt password hashes, a rate
|
|
23
|
+
* limited login. The administrator logs in as 'admin' with the adminPassword from the
|
|
24
|
+
* configuration, and manages the other users; a user can have either or both of the
|
|
25
|
+
* rights to edit the named links and to delete reports.
|
|
26
|
+
*
|
|
27
|
+
* R4 and R5 TestReports are treated as the same thing. Reports can't be changed once
|
|
28
|
+
* received: each POST is a new report, even when it's the same report as before.
|
|
29
|
+
*
|
|
30
|
+
* @module testing/testing
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const express = require('express');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
const crypto = require('crypto');
|
|
36
|
+
const rateLimit = require('express-rate-limit');
|
|
37
|
+
const escape = require('escape-html');
|
|
38
|
+
const session = require('express-session');
|
|
39
|
+
const lusca = require('lusca');
|
|
40
|
+
const bcrypt = require('bcrypt');
|
|
41
|
+
|
|
42
|
+
const folders = require('../library/folder-setup');
|
|
43
|
+
const htmlServer = require('../library/html-server');
|
|
44
|
+
const { tokenMatches, tokenConfigured } = require('../library/request-token');
|
|
45
|
+
const { requireSameOrigin } = require('../library/same-origin');
|
|
46
|
+
const packageJson = require('../package.json');
|
|
47
|
+
const { TestReportStore } = require('./store');
|
|
48
|
+
const { parseSearch, dateRange, capabilitySearchParams } = require('./search');
|
|
49
|
+
const {
|
|
50
|
+
renderList, renderSummary, renderReport, renderLogin, renderLinks, renderUsers, listQueryToSearch
|
|
51
|
+
} = require('./render');
|
|
52
|
+
|
|
53
|
+
const TEMPLATE = 'testing';
|
|
54
|
+
const DEFAULT_MAX_SIZE = 500 * 1024;
|
|
55
|
+
const DEFAULT_TOKEN_HEADER = 'Authorization';
|
|
56
|
+
const FHIR_JSON = 'application/fhir+json; charset=utf-8';
|
|
57
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What's wrong with a submitted resource, if anything.
|
|
61
|
+
*
|
|
62
|
+
* @param {*} r - the parsed JSON
|
|
63
|
+
* @returns {string[]} problems; empty if it's acceptable
|
|
64
|
+
*/
|
|
65
|
+
function validateReport(r) {
|
|
66
|
+
if (r === null || typeof r !== 'object' || Array.isArray(r)) {
|
|
67
|
+
return ['The body must be a JSON object'];
|
|
68
|
+
}
|
|
69
|
+
if (r.resourceType !== 'TestReport') {
|
|
70
|
+
return [`The resource must be a TestReport, not ${typeof r.resourceType === 'string' ? r.resourceType : 'something without a resourceType'}`];
|
|
71
|
+
}
|
|
72
|
+
const problems = [];
|
|
73
|
+
const needString = (name) => {
|
|
74
|
+
if (typeof r[name] !== 'string' || r[name].trim() === '') {
|
|
75
|
+
problems.push(`TestReport.${name} is required`);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
['name', 'status', 'result', 'tester', 'issued'].forEach(needString);
|
|
79
|
+
if (typeof r.issued === 'string' && r.issued.trim() !== '' && !dateRange(r.issued)) {
|
|
80
|
+
problems.push(`TestReport.issued is not a valid dateTime: '${r.issued}'`);
|
|
81
|
+
}
|
|
82
|
+
if (!Array.isArray(r.participant) || r.participant.length === 0) {
|
|
83
|
+
problems.push('TestReport.participant is required (at least one)');
|
|
84
|
+
} else {
|
|
85
|
+
r.participant.forEach((p, i) => {
|
|
86
|
+
if (p === null || typeof p !== 'object' || typeof p.uri !== 'string' || p.uri.trim() === '') {
|
|
87
|
+
problems.push(`TestReport.participant[${i}].uri is required`);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (r.score !== undefined && (typeof r.score !== 'number' || !Number.isFinite(r.score))) {
|
|
92
|
+
problems.push('TestReport.score must be a number');
|
|
93
|
+
}
|
|
94
|
+
if (r.meta !== undefined && (r.meta === null || typeof r.meta !== 'object' || Array.isArray(r.meta))) {
|
|
95
|
+
problems.push('TestReport.meta must be an object');
|
|
96
|
+
}
|
|
97
|
+
return problems;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function operationOutcome(severity, code, messages) {
|
|
101
|
+
return {
|
|
102
|
+
resourceType: 'OperationOutcome',
|
|
103
|
+
issue: (Array.isArray(messages) ? messages : [messages]).map(m => ({
|
|
104
|
+
severity, code, details: { text: m }
|
|
105
|
+
}))
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function preferValue(req, name) {
|
|
110
|
+
const prefer = req.get('Prefer') || '';
|
|
111
|
+
for (const part of prefer.split(/[,;]/)) {
|
|
112
|
+
const [k, v] = part.split('=').map(s => s && s.trim());
|
|
113
|
+
if (k === name) {
|
|
114
|
+
return v ? v.replace(/^"|"$/g, '') : '';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function wantsHtml(req) {
|
|
121
|
+
const f = req.query._format;
|
|
122
|
+
if (typeof f === 'string' && f !== '') {
|
|
123
|
+
return /html/i.test(f);
|
|
124
|
+
}
|
|
125
|
+
return (req.get('Accept') || '').includes('text/html');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
class TestingModule {
|
|
129
|
+
/**
|
|
130
|
+
* @param {Object} stats - a ModuleStats
|
|
131
|
+
* @param {Object} [log] - a logger; defaults to the server's
|
|
132
|
+
*/
|
|
133
|
+
constructor(stats, log) {
|
|
134
|
+
this.stats = stats;
|
|
135
|
+
this.router = express.Router();
|
|
136
|
+
this.store = null;
|
|
137
|
+
this.config = null;
|
|
138
|
+
this.retentionTimer = null;
|
|
139
|
+
if (log) {
|
|
140
|
+
this.log = log;
|
|
141
|
+
} else {
|
|
142
|
+
const Logger = require('../library/logger');
|
|
143
|
+
this.log = Logger.getInstance().child({ module: 'testing' });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async initialize(config) {
|
|
148
|
+
this.config = config || {};
|
|
149
|
+
const db = this.config.database || 'testing.db';
|
|
150
|
+
const dbPath = db === ':memory:' || path.isAbsolute(db) ? db : path.join(folders.databasesDir(), db);
|
|
151
|
+
this.store = new TestReportStore(dbPath);
|
|
152
|
+
this.maxSize = Number.isInteger(this.config.maxSize) && this.config.maxSize > 0 ? this.config.maxSize : DEFAULT_MAX_SIZE;
|
|
153
|
+
this.tokenHeader = this.config.tokenHeader || DEFAULT_TOKEN_HEADER;
|
|
154
|
+
|
|
155
|
+
htmlServer.loadTemplate(TEMPLATE, path.join(__dirname, 'testing-template.html'));
|
|
156
|
+
|
|
157
|
+
// the administrator's password is checked the same way as everyone else's: bcrypt against
|
|
158
|
+
// a hash, made once here, of the configured password
|
|
159
|
+
this.adminHash = tokenConfigured(this.config.adminPassword) ? bcrypt.hashSync(this.config.adminPassword, 10) : null;
|
|
160
|
+
|
|
161
|
+
this.setupSession();
|
|
162
|
+
this.setupRoutes();
|
|
163
|
+
this.setupAdminRoutes();
|
|
164
|
+
this.setupRetention();
|
|
165
|
+
this.log.info(`Testing module: ${this.store.count()} reports in ${dbPath}; ` +
|
|
166
|
+
`posting ${tokenConfigured(this.config.token) ? 'requires a token' : 'is open'}, max ${this.maxSize} bytes`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The session (login) cookie and CSRF protection. Neither is put on the whole router:
|
|
171
|
+
* the FHIR API (POST and DELETE of TestReports) is authenticated by a token in a header,
|
|
172
|
+
* not a cookie, so it needs neither and must not be made to carry a CSRF token. The web
|
|
173
|
+
* pages get this.web; every form post gets sameOrigin + this.session + a form parser +
|
|
174
|
+
* this.csrf (see setupAdminRoutes).
|
|
175
|
+
*
|
|
176
|
+
* CSRF has two layers: lusca's per-session token, which every form carries in a hidden
|
|
177
|
+
* _csrf field, and the Origin check (library/same-origin). The cookie is also SameSite=Lax.
|
|
178
|
+
*/
|
|
179
|
+
setupSession() {
|
|
180
|
+
if (!this.config.sessionSecret) {
|
|
181
|
+
// as for the publisher: never a constant secret, which would be a published signing key
|
|
182
|
+
this.log.warn('testing: no sessionSecret configured - using a random one, so logins will not ' +
|
|
183
|
+
'survive a restart. Set modules.testing.sessionSecret.');
|
|
184
|
+
}
|
|
185
|
+
this.session = session({
|
|
186
|
+
name: 'testing.sid',
|
|
187
|
+
secret: this.config.sessionSecret || crypto.randomBytes(64).toString('hex'),
|
|
188
|
+
resave: false,
|
|
189
|
+
saveUninitialized: false,
|
|
190
|
+
cookie: {
|
|
191
|
+
// HTTPS only unless cookieSecure is false - see the publisher's notes on nginx
|
|
192
|
+
secure: this.config.cookieSecure ?? true,
|
|
193
|
+
// the browser will not send the cookie on a form posted from another site, which,
|
|
194
|
+
// with the Origin check on every form post, is the CSRF protection
|
|
195
|
+
sameSite: 'lax',
|
|
196
|
+
httpOnly: true,
|
|
197
|
+
maxAge: 24 * 60 * 60 * 1000
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
// used directly (not wrapped) in every form post's route, so static analysis can see
|
|
201
|
+
// it there. A bad or missing token comes out as an error, which the error handler at the
|
|
202
|
+
// end of setupAdminRoutes turns into a page
|
|
203
|
+
this.csrf = lusca.csrf({ key: '_csrf' });
|
|
204
|
+
// the pages show a logout form (which needs a token) only to someone logged in. Making
|
|
205
|
+
// a token stores a secret in the session, so doing it for every visitor would give
|
|
206
|
+
// every crawler a session
|
|
207
|
+
const csrfIfLoggedIn = (req, res, next) => this.currentUser(req) ? this.csrf(req, res, next) : next();
|
|
208
|
+
this.web = [this.session, csrfIfLoggedIn];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
setupRoutes() {
|
|
212
|
+
const r = this.router;
|
|
213
|
+
const rl = this.config.rateLimit || {};
|
|
214
|
+
const max = rl.max === undefined ? 60 : rl.max;
|
|
215
|
+
const posting = [];
|
|
216
|
+
if (max > 0) {
|
|
217
|
+
posting.push(rateLimit({
|
|
218
|
+
windowMs: (rl.windowMinutes || 1) * 60 * 1000,
|
|
219
|
+
limit: max,
|
|
220
|
+
standardHeaders: 'draft-7',
|
|
221
|
+
legacyHeaders: false,
|
|
222
|
+
handler: (req, res) => this.sendOutcome(res, 429, 'throttled', 'Too many reports submitted; try again later')
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
r.get('/', ...this.web, (req, res) => this.handle(req, res, 'list', () => this.htmlList(req, res)));
|
|
227
|
+
r.get('/summary', ...this.web, (req, res) => this.handle(req, res, 'summary', () => this.htmlSummary(req, res)));
|
|
228
|
+
r.get('/metadata', (req, res) => this.handle(req, res, 'metadata', () => this.metadata(req, res)));
|
|
229
|
+
r.post('/TestReport', ...posting, (req, res) => this.handle(req, res, 'create', () => this.create(req, res)));
|
|
230
|
+
r.get('/TestReport', ...this.web, (req, res) => this.handle(req, res, 'search', () =>
|
|
231
|
+
wantsHtml(req) ? this.htmlList(req, res) : this.search(req, res)));
|
|
232
|
+
r.get('/TestReport/:id', ...this.web, (req, res) => this.handle(req, res, 'read', () => this.read(req, res)));
|
|
233
|
+
r.delete('/TestReport/:id', (req, res) => this.handle(req, res, 'delete', () => this.remove(req, res)));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
setupRetention() {
|
|
237
|
+
const days = this.config.retentionDays;
|
|
238
|
+
if (!(typeof days === 'number' && days > 0)) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const purge = () => {
|
|
242
|
+
try {
|
|
243
|
+
const n = this.store.purgeBefore(Date.now() - days * DAY_MS);
|
|
244
|
+
if (n > 0) {
|
|
245
|
+
this.log.info(`Testing module: deleted ${n} reports older than ${days} days`);
|
|
246
|
+
}
|
|
247
|
+
if (this.stats) {
|
|
248
|
+
this.stats.taskDone('TestReport retention', `${n} deleted`);
|
|
249
|
+
}
|
|
250
|
+
} catch (e) {
|
|
251
|
+
this.log.error('Testing module: retention purge failed: ' + e.message);
|
|
252
|
+
if (this.stats) {
|
|
253
|
+
this.stats.taskError('TestReport retention', e.message);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
if (this.stats) {
|
|
258
|
+
this.stats.addTask('TestReport retention', 'daily');
|
|
259
|
+
}
|
|
260
|
+
purge();
|
|
261
|
+
this.retentionTimer = setInterval(purge, DAY_MS);
|
|
262
|
+
this.retentionTimer.unref();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async shutdown() {
|
|
266
|
+
if (this.retentionTimer) {
|
|
267
|
+
clearInterval(this.retentionTimer);
|
|
268
|
+
this.retentionTimer = null;
|
|
269
|
+
}
|
|
270
|
+
if (this.store) {
|
|
271
|
+
this.store.close();
|
|
272
|
+
this.store = null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---- plumbing ----
|
|
277
|
+
|
|
278
|
+
async handle(req, res, name, fn) {
|
|
279
|
+
const start = Date.now();
|
|
280
|
+
try {
|
|
281
|
+
await fn();
|
|
282
|
+
} catch (e) {
|
|
283
|
+
this.log.error(`Testing module: ${name} failed: ${e.stack || e.message}`);
|
|
284
|
+
if (!res.headersSent) {
|
|
285
|
+
if (wantsHtml(req)) {
|
|
286
|
+
htmlServer.sendErrorResponse(res, TEMPLATE, e);
|
|
287
|
+
} else {
|
|
288
|
+
this.sendOutcome(res, 500, 'exception', 'Internal error: ' + e.message);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} finally {
|
|
292
|
+
if (this.stats) {
|
|
293
|
+
this.stats.countRequest(name, Date.now() - start);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
baseUrl(req) {
|
|
299
|
+
return `${req.protocol}://${req.get('host')}${req.baseUrl}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
sendJson(res, status, body) {
|
|
303
|
+
res.status(status).set('Content-Type', FHIR_JSON).send(JSON.stringify(body, null, 2));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
sendOutcome(res, status, code, messages) {
|
|
307
|
+
const severity = status < 400 ? 'information' : 'error';
|
|
308
|
+
this.sendJson(res, status, operationOutcome(severity, code, messages));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
sendHtml(res, title, content, start) {
|
|
312
|
+
htmlServer.sendHtmlResponse(res, TEMPLATE, title, content, {
|
|
313
|
+
version: packageJson.version,
|
|
314
|
+
processingTime: Date.now() - start
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The token from the configured header, without any "Bearer " prefix. */
|
|
319
|
+
suppliedToken(req) {
|
|
320
|
+
const v = req.get(this.tokenHeader);
|
|
321
|
+
return typeof v === 'string' ? v.replace(/^Bearer\s+/i, '').trim() : v;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---- FHIR API ----
|
|
325
|
+
|
|
326
|
+
async create(req, res) {
|
|
327
|
+
if (tokenConfigured(this.config.token) && !tokenMatches(this.config.token, this.suppliedToken(req))) {
|
|
328
|
+
res.set('WWW-Authenticate', 'Bearer');
|
|
329
|
+
return this.sendOutcome(res, 401, 'login', `A valid token is required in the ${this.tokenHeader} header`);
|
|
330
|
+
}
|
|
331
|
+
if (!req.is('application/fhir+json') && !req.is('application/json')) {
|
|
332
|
+
return this.sendOutcome(res, 415, 'not-supported', 'Reports must be JSON (application/fhir+json)');
|
|
333
|
+
}
|
|
334
|
+
const declared = parseInt(req.get('Content-Length') || '', 10);
|
|
335
|
+
if (declared > this.maxSize) {
|
|
336
|
+
return this.sendOutcome(res, 413, 'too-costly', `Reports can't be bigger than ${this.maxSize} bytes`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// the server's body parsers have already run: application/fhir+json arrives as a
|
|
340
|
+
// Buffer, application/json already parsed
|
|
341
|
+
let report;
|
|
342
|
+
let size;
|
|
343
|
+
if (Buffer.isBuffer(req.body)) {
|
|
344
|
+
size = req.body.length;
|
|
345
|
+
if (size > this.maxSize) {
|
|
346
|
+
return this.sendOutcome(res, 413, 'too-costly', `Reports can't be bigger than ${this.maxSize} bytes`);
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
report = JSON.parse(req.body.toString('utf8'));
|
|
350
|
+
} catch (e) {
|
|
351
|
+
return this.sendOutcome(res, 400, 'structure', 'The body is not valid JSON: ' + e.message);
|
|
352
|
+
}
|
|
353
|
+
} else if (req.body && typeof req.body === 'object') {
|
|
354
|
+
report = req.body;
|
|
355
|
+
size = Buffer.byteLength(JSON.stringify(report), 'utf8');
|
|
356
|
+
if (size > this.maxSize) {
|
|
357
|
+
return this.sendOutcome(res, 413, 'too-costly', `Reports can't be bigger than ${this.maxSize} bytes`);
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
return this.sendOutcome(res, 400, 'structure', 'No report was found in the body');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const problems = validateReport(report);
|
|
364
|
+
if (problems.length > 0) {
|
|
365
|
+
return this.sendOutcome(res, 400, 'invalid', problems);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// id and lastUpdated are the server's; there are no versions
|
|
369
|
+
const now = Date.now();
|
|
370
|
+
const lastUpdated = new Date(now).toISOString();
|
|
371
|
+
const meta = { ...(report.meta || {}), lastUpdated };
|
|
372
|
+
delete meta.versionId;
|
|
373
|
+
const stored = { resourceType: report.resourceType, id: crypto.randomUUID(), meta };
|
|
374
|
+
for (const [k, v] of Object.entries(report)) {
|
|
375
|
+
if (!(k in stored)) {
|
|
376
|
+
stored[k] = v;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
this.store.insert(stored, { ip: req.ip, receivedMs: now });
|
|
380
|
+
|
|
381
|
+
const location = `${this.baseUrl(req)}/TestReport/${stored.id}`;
|
|
382
|
+
res.set('Location', location);
|
|
383
|
+
res.set('Last-Modified', new Date(now).toUTCString());
|
|
384
|
+
const ret = preferValue(req, 'return');
|
|
385
|
+
if (ret === 'minimal') {
|
|
386
|
+
return res.status(201).end();
|
|
387
|
+
}
|
|
388
|
+
if (ret === 'OperationOutcome') {
|
|
389
|
+
return this.sendOutcome(res, 201, 'informational', `Report stored as ${location}`);
|
|
390
|
+
}
|
|
391
|
+
return this.sendJson(res, 201, stored);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async read(req, res) {
|
|
395
|
+
const start = Date.now();
|
|
396
|
+
const report = this.store.read(req.params.id);
|
|
397
|
+
if (wantsHtml(req)) {
|
|
398
|
+
if (!report) {
|
|
399
|
+
return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`No report with id '${req.params.id}'`), 404);
|
|
400
|
+
}
|
|
401
|
+
return this.sendHtml(res, 'Test Report: ' + (typeof report.name === 'string' ? report.name : report.id),
|
|
402
|
+
renderReport(report, req.baseUrl, { names: this.names(), user: this.currentUser(req), csrf: res.locals._csrf }), start);
|
|
403
|
+
}
|
|
404
|
+
if (!report) {
|
|
405
|
+
return this.sendOutcome(res, 404, 'not-found', `No TestReport with id '${req.params.id}'`);
|
|
406
|
+
}
|
|
407
|
+
res.set('Last-Modified', new Date(report.meta.lastUpdated).toUTCString());
|
|
408
|
+
return this.sendJson(res, 200, report);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async remove(req, res) {
|
|
412
|
+
if (!tokenConfigured(this.config.adminToken) || !tokenMatches(this.config.adminToken, this.suppliedToken(req))) {
|
|
413
|
+
return this.sendOutcome(res, 403, 'forbidden', 'Deleting reports requires the admin token');
|
|
414
|
+
}
|
|
415
|
+
if (!this.store.delete(req.params.id)) {
|
|
416
|
+
return this.sendOutcome(res, 404, 'not-found', `No TestReport with id '${req.params.id}'`);
|
|
417
|
+
}
|
|
418
|
+
this.log.info(`Testing module: report ${req.params.id} deleted by admin`);
|
|
419
|
+
return res.status(204).end();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async search(req, res) {
|
|
423
|
+
const search = parseSearch(req.query);
|
|
424
|
+
if (search.errors.length > 0) {
|
|
425
|
+
return this.sendOutcome(res, 400, 'invalid', search.errors);
|
|
426
|
+
}
|
|
427
|
+
if (search.unknown.length > 0 && preferValue(req, 'handling') === 'strict') {
|
|
428
|
+
return this.sendOutcome(res, 400, 'not-supported', search.unknown.map(u => `Unknown search parameter '${u}'`));
|
|
429
|
+
}
|
|
430
|
+
const results = this.store.search(search, { withJson: true });
|
|
431
|
+
const base = this.baseUrl(req);
|
|
432
|
+
|
|
433
|
+
const link = (offset) => {
|
|
434
|
+
const u = new URLSearchParams();
|
|
435
|
+
for (const [k, v] of search.used) {
|
|
436
|
+
u.append(k, v);
|
|
437
|
+
}
|
|
438
|
+
if (search.sort) {
|
|
439
|
+
u.append('_sort', search.sort);
|
|
440
|
+
}
|
|
441
|
+
if (search.summaryCount) {
|
|
442
|
+
u.append('_summary', 'count');
|
|
443
|
+
} else {
|
|
444
|
+
u.append('_count', String(search.count));
|
|
445
|
+
u.append('_offset', String(offset));
|
|
446
|
+
}
|
|
447
|
+
return `${base}/TestReport?${u.toString()}`;
|
|
448
|
+
};
|
|
449
|
+
const links = [{ relation: 'self', url: link(search.offset) }];
|
|
450
|
+
if (!search.summaryCount && search.count > 0) {
|
|
451
|
+
links.push({ relation: 'first', url: link(0) });
|
|
452
|
+
if (search.offset > 0) {
|
|
453
|
+
links.push({ relation: 'previous', url: link(Math.max(0, search.offset - search.count)) });
|
|
454
|
+
}
|
|
455
|
+
if (search.offset + search.count < results.total) {
|
|
456
|
+
links.push({ relation: 'next', url: link(search.offset + search.count) });
|
|
457
|
+
}
|
|
458
|
+
links.push({ relation: 'last', url: link(results.total === 0 ? 0 : Math.floor((results.total - 1) / search.count) * search.count) });
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const bundle = {
|
|
462
|
+
resourceType: 'Bundle',
|
|
463
|
+
id: crypto.randomUUID(),
|
|
464
|
+
meta: { lastUpdated: new Date().toISOString() },
|
|
465
|
+
type: 'searchset',
|
|
466
|
+
total: results.total,
|
|
467
|
+
link: links,
|
|
468
|
+
entry: results.rows.map(row => ({
|
|
469
|
+
fullUrl: `${base}/TestReport/${row.id}`,
|
|
470
|
+
resource: JSON.parse(row.json),
|
|
471
|
+
search: { mode: 'match' }
|
|
472
|
+
}))
|
|
473
|
+
};
|
|
474
|
+
if (search.unknown.length > 0) {
|
|
475
|
+
bundle.entry.push({
|
|
476
|
+
resource: operationOutcome('warning', 'not-supported', search.unknown.map(u => `Unknown search parameter '${u}' was ignored`)),
|
|
477
|
+
search: { mode: 'outcome' }
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
if (bundle.entry.length === 0) {
|
|
481
|
+
delete bundle.entry;
|
|
482
|
+
}
|
|
483
|
+
return this.sendJson(res, 200, bundle);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
metadata(req, res) {
|
|
487
|
+
const cs = {
|
|
488
|
+
resourceType: 'CapabilityStatement',
|
|
489
|
+
status: 'active',
|
|
490
|
+
date: new Date().toISOString(),
|
|
491
|
+
kind: 'instance',
|
|
492
|
+
software: { name: 'FHIRsmith', version: packageJson.version },
|
|
493
|
+
implementation: {
|
|
494
|
+
description: 'FHIRsmith TestReport repository',
|
|
495
|
+
url: this.baseUrl(req)
|
|
496
|
+
},
|
|
497
|
+
fhirVersion: '5.0.0',
|
|
498
|
+
format: ['json'],
|
|
499
|
+
rest: [{
|
|
500
|
+
mode: 'server',
|
|
501
|
+
documentation: tokenConfigured(this.config.token)
|
|
502
|
+
? `Submitting a report requires a token in the ${this.tokenHeader} header`
|
|
503
|
+
: 'Anyone may submit a report',
|
|
504
|
+
resource: [{
|
|
505
|
+
type: 'TestReport',
|
|
506
|
+
interaction: [{ code: 'create' }, { code: 'read' }, { code: 'search-type' }],
|
|
507
|
+
versioning: 'no-version',
|
|
508
|
+
readHistory: false,
|
|
509
|
+
updateCreate: false,
|
|
510
|
+
searchParam: capabilitySearchParams()
|
|
511
|
+
}]
|
|
512
|
+
}]
|
|
513
|
+
};
|
|
514
|
+
return this.sendJson(res, 200, cs);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ---- HTML ----
|
|
518
|
+
|
|
519
|
+
async htmlList(req, res) {
|
|
520
|
+
const start = Date.now();
|
|
521
|
+
const query = req.query || {};
|
|
522
|
+
const search = parseSearch(listQueryToSearch(query));
|
|
523
|
+
let prefix = '';
|
|
524
|
+
if (search.errors.length > 0) {
|
|
525
|
+
prefix = '<div class="alert alert-warning">' + search.errors.map(e => escape(e)).join('<br/>') + '</div>';
|
|
526
|
+
// show everything rather than nothing
|
|
527
|
+
const fallback = parseSearch({ _count: query._count });
|
|
528
|
+
search.where = fallback.where;
|
|
529
|
+
search.args = fallback.args;
|
|
530
|
+
}
|
|
531
|
+
const results = this.store.search(search);
|
|
532
|
+
const content = prefix + renderList(query, results, search, {
|
|
533
|
+
results: this.store.distinct('result'),
|
|
534
|
+
base: req.baseUrl,
|
|
535
|
+
names: this.names(),
|
|
536
|
+
user: this.currentUser(req),
|
|
537
|
+
csrf: res.locals._csrf,
|
|
538
|
+
showClient: this.store.hasParticipantType('client')
|
|
539
|
+
});
|
|
540
|
+
this.sendHtml(res, 'Test Reports', content, start);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async htmlSummary(req, res) {
|
|
544
|
+
const start = Date.now();
|
|
545
|
+
const by = req.query.by === 'tester' ? 'tester' : 'participant';
|
|
546
|
+
this.sendHtml(res, 'Test Report Summary', renderSummary(this.store.summary(by), by, req.baseUrl, this.store.testerCounts(),
|
|
547
|
+
{ names: this.names(), user: this.currentUser(req), csrf: res.locals._csrf }), start);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ---- users, named links, and the administration pages ----
|
|
551
|
+
|
|
552
|
+
/** The named links, by unversioned canonical. Cached until they change. */
|
|
553
|
+
names() {
|
|
554
|
+
if (!this.namesCache) {
|
|
555
|
+
this.namesCache = new Map(this.store.links().map(l => [l.canonical, l]));
|
|
556
|
+
}
|
|
557
|
+
return this.namesCache;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Who is logged in, with their rights as they are now (not as they were at login, so
|
|
562
|
+
* taking a right away, or deleting the user, takes effect at once).
|
|
563
|
+
*/
|
|
564
|
+
currentUser(req) {
|
|
565
|
+
const s = req.session;
|
|
566
|
+
if (!s) {
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
if (s.testingAdmin) {
|
|
570
|
+
return { name: 'Administrator', isAdmin: true, canEditLinks: true, canDeleteReports: true };
|
|
571
|
+
}
|
|
572
|
+
if (s.testingUserId) {
|
|
573
|
+
const u = this.store.userById(s.testingUserId);
|
|
574
|
+
if (u) {
|
|
575
|
+
return { id: u.id, name: u.name, isAdmin: false, canEditLinks: !!u.can_edit_links, canDeleteReports: !!u.can_delete_reports };
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
setupAdminRoutes() {
|
|
582
|
+
const r = this.router;
|
|
583
|
+
const form = express.urlencoded({ extended: false, limit: '64kb' });
|
|
584
|
+
const loginLimiter = rateLimit({
|
|
585
|
+
windowMs: 15 * 60 * 1000,
|
|
586
|
+
limit: this.config.loginAttemptsPerWindow ?? 20,
|
|
587
|
+
standardHeaders: 'draft-7',
|
|
588
|
+
legacyHeaders: false,
|
|
589
|
+
handler: (req, res) => this.sendLogin(req, res, 429, 'Too many login attempts - try again later')
|
|
590
|
+
});
|
|
591
|
+
// every form post must come from one of our own pages
|
|
592
|
+
const sameOrigin = requireSameOrigin((req, res) =>
|
|
593
|
+
htmlServer.sendErrorResponse(res, TEMPLATE, new Error('Cross-site form posts are not accepted'), 403));
|
|
594
|
+
// a right, or the administrator
|
|
595
|
+
const need = (right) => (req, res, next) => {
|
|
596
|
+
const user = this.currentUser(req);
|
|
597
|
+
if (!user) {
|
|
598
|
+
return res.redirect(req.baseUrl + '/login');
|
|
599
|
+
}
|
|
600
|
+
if (!(user.isAdmin || (right && user[right]))) {
|
|
601
|
+
return htmlServer.sendErrorResponse(res, TEMPLATE, new Error('You do not have permission to do that'), 403);
|
|
602
|
+
}
|
|
603
|
+
req.user = user;
|
|
604
|
+
next();
|
|
605
|
+
};
|
|
606
|
+
const h = (name, fn) => (req, res) => this.handle(req, res, name, () => fn(req, res));
|
|
607
|
+
// Every page with a form has this.session, this.csrf; every form post has sameOrigin,
|
|
608
|
+
// this.session, form, this.csrf. They are written out in each route rather than spread
|
|
609
|
+
// from an array, so that CodeQL can see the CSRF middleware on every cookie-using route.
|
|
610
|
+
|
|
611
|
+
r.get('/login', this.session, this.csrf, h('login', (req, res) => this.sendLogin(req, res, 200)));
|
|
612
|
+
r.post('/login', sameOrigin, loginLimiter, this.session, form, this.csrf, h('login', (req, res) => this.login(req, res)));
|
|
613
|
+
r.post('/logout', sameOrigin, this.session, form, this.csrf, h('logout', (req, res) => {
|
|
614
|
+
req.session.destroy(() => res.redirect(req.baseUrl || '/'));
|
|
615
|
+
}));
|
|
616
|
+
|
|
617
|
+
r.get('/admin/links', this.session, this.csrf, need('canEditLinks'), h('links', (req, res) => this.sendLinks(req, res)));
|
|
618
|
+
r.post('/admin/links', sameOrigin, this.session, form, this.csrf, need('canEditLinks'), h('links', (req, res) => this.saveLink(req, res, null)));
|
|
619
|
+
r.post('/admin/links/:id', sameOrigin, this.session, form, this.csrf, need('canEditLinks'), h('links', (req, res) => this.saveLink(req, res, req.params.id)));
|
|
620
|
+
r.post('/admin/links/:id/delete', sameOrigin, this.session, form, this.csrf, need('canEditLinks'), h('links', (req, res) => {
|
|
621
|
+
this.store.deleteLink(parseInt(req.params.id, 10));
|
|
622
|
+
this.namesCache = null;
|
|
623
|
+
res.redirect(req.baseUrl + '/admin/links');
|
|
624
|
+
}));
|
|
625
|
+
|
|
626
|
+
r.get('/admin/users', this.session, this.csrf, need(null), h('users', (req, res) => this.sendUsers(req, res)));
|
|
627
|
+
r.post('/admin/users', sameOrigin, this.session, form, this.csrf, need(null), h('users', (req, res) => this.saveUser(req, res, null)));
|
|
628
|
+
r.post('/admin/users/:id', sameOrigin, this.session, form, this.csrf, need(null), h('users', (req, res) => this.saveUser(req, res, req.params.id)));
|
|
629
|
+
r.post('/admin/users/:id/delete', sameOrigin, this.session, form, this.csrf, need(null), h('users', (req, res) => {
|
|
630
|
+
this.store.deleteUser(parseInt(req.params.id, 10));
|
|
631
|
+
this.log.info(`Testing module: user ${req.params.id} deleted`);
|
|
632
|
+
res.redirect(req.baseUrl + '/admin/users');
|
|
633
|
+
}));
|
|
634
|
+
|
|
635
|
+
r.post('/TestReport/:id/delete', sameOrigin, this.session, form, this.csrf, need('canDeleteReports'), h('delete', (req, res) => {
|
|
636
|
+
if (!this.store.delete(req.params.id)) {
|
|
637
|
+
return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`No report with id '${req.params.id}'`), 404);
|
|
638
|
+
}
|
|
639
|
+
this.log.info(`Testing module: report ${req.params.id} deleted by ${req.user.name}`);
|
|
640
|
+
res.redirect(req.baseUrl);
|
|
641
|
+
}));
|
|
642
|
+
|
|
643
|
+
// lusca rejects a form post with a missing or wrong token by passing on an error
|
|
644
|
+
// ("CSRF token missing" / "CSRF token mismatch"); anything else goes on as it was
|
|
645
|
+
r.use((err, req, res, next) => {
|
|
646
|
+
if (err && typeof err.message === 'string' && err.message.startsWith('CSRF token')) {
|
|
647
|
+
return htmlServer.sendErrorResponse(res, TEMPLATE,
|
|
648
|
+
new Error('The form has expired or did not come from this site - reload the page and try again'), 403);
|
|
649
|
+
}
|
|
650
|
+
next(err);
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
sendLogin(req, res, status, error) {
|
|
655
|
+
res.status(status);
|
|
656
|
+
this.sendHtml(res, 'Login', renderLogin(req.baseUrl, error, res.locals._csrf), Date.now());
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async login(req, res) {
|
|
660
|
+
const login = typeof req.body.login === 'string' ? req.body.login.trim() : '';
|
|
661
|
+
const password = typeof req.body.password === 'string' ? req.body.password : '';
|
|
662
|
+
let ok = false;
|
|
663
|
+
const regenerate = () => new Promise((resolve, reject) => req.session.regenerate(e => e ? reject(e) : resolve()));
|
|
664
|
+
if (login === 'admin') {
|
|
665
|
+
if (this.adminHash && await bcrypt.compare(password, this.adminHash)) {
|
|
666
|
+
await regenerate();
|
|
667
|
+
req.session.testingAdmin = true;
|
|
668
|
+
ok = true;
|
|
669
|
+
}
|
|
670
|
+
} else if (login) {
|
|
671
|
+
const user = this.store.userByLogin(login);
|
|
672
|
+
if (user && await bcrypt.compare(password, user.password_hash)) {
|
|
673
|
+
await regenerate();
|
|
674
|
+
req.session.testingUserId = user.id;
|
|
675
|
+
ok = true;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (!ok) {
|
|
679
|
+
this.log.info(`Testing module: failed login for '${login}' from ${req.ip}`);
|
|
680
|
+
return this.sendLogin(req, res, 401, 'Unknown username or wrong password');
|
|
681
|
+
}
|
|
682
|
+
this.log.info(`Testing module: '${login}' logged in from ${req.ip}`);
|
|
683
|
+
res.redirect(req.baseUrl || '/');
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
sendLinks(req, res, message, status = 200) {
|
|
687
|
+
res.status(status);
|
|
688
|
+
this.sendHtml(res, 'Named Links', renderLinks(this.store.links(), { base: req.baseUrl, user: req.user, csrf: res.locals._csrf }, message), Date.now());
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
saveLink(req, res, id) {
|
|
692
|
+
const canonical = typeof req.body.canonical === 'string' ? req.body.canonical.trim() : '';
|
|
693
|
+
const name = typeof req.body.name === 'string' ? req.body.name.trim() : '';
|
|
694
|
+
const link = typeof req.body.link === 'string' ? req.body.link.trim() : '';
|
|
695
|
+
let problem = null;
|
|
696
|
+
if (!canonical || /\s/.test(canonical) || canonical.length > 1024) {
|
|
697
|
+
problem = 'The canonical must be a URL with no spaces';
|
|
698
|
+
} else if (!name || name.length > 200) {
|
|
699
|
+
problem = 'A name is required (up to 200 characters)';
|
|
700
|
+
} else if (link && !/^https?:\/\/[^\s"'<>]+$/i.test(link)) {
|
|
701
|
+
problem = 'The link must be an http or https URL';
|
|
702
|
+
}
|
|
703
|
+
if (problem) {
|
|
704
|
+
return this.sendLinks(req, res, problem, 400);
|
|
705
|
+
}
|
|
706
|
+
try {
|
|
707
|
+
if (id === null) {
|
|
708
|
+
this.store.saveLink(canonical, name, link);
|
|
709
|
+
} else if (!this.store.updateLink(parseInt(id, 10), canonical, name, link)) {
|
|
710
|
+
return this.sendLinks(req, res, 'That link no longer exists', 404);
|
|
711
|
+
}
|
|
712
|
+
} catch (e) {
|
|
713
|
+
if (/UNIQUE/.test(e.message)) {
|
|
714
|
+
return this.sendLinks(req, res, `There is already a name for ${canonical}`, 409);
|
|
715
|
+
}
|
|
716
|
+
throw e;
|
|
717
|
+
}
|
|
718
|
+
this.namesCache = null;
|
|
719
|
+
res.redirect(req.baseUrl + '/admin/links');
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
sendUsers(req, res, message, status = 200) {
|
|
723
|
+
res.status(status);
|
|
724
|
+
this.sendHtml(res, 'Users', renderUsers(this.store.users(), { base: req.baseUrl, user: req.user, csrf: res.locals._csrf }, message), Date.now());
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async saveUser(req, res, id) {
|
|
728
|
+
const login = typeof req.body.login === 'string' ? req.body.login.trim() : '';
|
|
729
|
+
const name = typeof req.body.name === 'string' ? req.body.name.trim() : '';
|
|
730
|
+
const password = typeof req.body.password === 'string' ? req.body.password : '';
|
|
731
|
+
const rights = { editLinks: req.body.editLinks === '1', deleteReports: req.body.deleteReports === '1' };
|
|
732
|
+
let problem = null;
|
|
733
|
+
if (id === null && !/^[A-Za-z0-9._@-]{1,64}$/.test(login)) {
|
|
734
|
+
problem = 'The username must be 1-64 letters, digits, or . _ @ -';
|
|
735
|
+
} else if (id === null && login.toLowerCase() === 'admin') {
|
|
736
|
+
problem = "'admin' is the administrator's login";
|
|
737
|
+
} else if (!name || name.length > 100) {
|
|
738
|
+
problem = 'A name is required (up to 100 characters)';
|
|
739
|
+
} else if ((id === null || password) && password.length < 8) {
|
|
740
|
+
problem = 'Passwords must be at least 8 characters';
|
|
741
|
+
}
|
|
742
|
+
if (problem) {
|
|
743
|
+
return this.sendUsers(req, res, problem, 400);
|
|
744
|
+
}
|
|
745
|
+
const hash = password ? await bcrypt.hash(password, 10) : null;
|
|
746
|
+
if (id === null) {
|
|
747
|
+
if (this.store.userByLogin(login)) {
|
|
748
|
+
return this.sendUsers(req, res, `There is already a user '${login}'`, 409);
|
|
749
|
+
}
|
|
750
|
+
this.store.createUser(login, name, hash, rights);
|
|
751
|
+
this.log.info(`Testing module: user '${login}' created`);
|
|
752
|
+
} else if (!this.store.updateUser(parseInt(id, 10), name, rights, hash)) {
|
|
753
|
+
return this.sendUsers(req, res, 'That user no longer exists', 404);
|
|
754
|
+
}
|
|
755
|
+
res.redirect(req.baseUrl + '/admin/users');
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
module.exports = TestingModule;
|
|
760
|
+
module.exports.validateReport = validateReport;
|