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.
Files changed (52) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +16 -3
  3. package/config-template.json +81 -1
  4. package/library/cgroup-memory.js +113 -0
  5. package/library/folder-setup.js +8 -1
  6. package/library/languages.js +15 -0
  7. package/library/same-origin.js +62 -0
  8. package/nginx.md +399 -0
  9. package/package.json +3 -2
  10. package/publisher/git-remote.js +141 -0
  11. package/publisher/github-release.js +195 -0
  12. package/publisher/publisher-template.html +1 -0
  13. package/publisher/publisher.js +241 -56
  14. package/registry/registry.js +3 -1
  15. package/server.js +57 -15
  16. package/stats.js +19 -13
  17. package/testing/readme.md +144 -0
  18. package/testing/render.js +723 -0
  19. package/testing/search.js +378 -0
  20. package/testing/store.js +410 -0
  21. package/testing/testing-template.html +126 -0
  22. package/testing/testing.js +760 -0
  23. package/translations/Messages.properties +32 -0
  24. package/tx/closure/closure-store.js +396 -0
  25. package/tx/cs/cs-api.js +6 -1
  26. package/tx/cs/cs-rxnorm.js +35 -8
  27. package/tx/cs/cs-snomed.js +19 -69
  28. package/tx/html/codesystem-operations.liquid +177 -0
  29. package/tx/html/ecl-panel.liquid +93 -0
  30. package/tx/html/home-metrics.liquid +39 -76
  31. package/tx/html/tx-template.html +1 -0
  32. package/tx/library/conceptmap.js +22 -6
  33. package/tx/library/designations.js +12 -3
  34. package/tx/library/operation-outcome.js +3 -1
  35. package/tx/library/renderer.js +42 -5
  36. package/tx/operation-context.js +80 -17
  37. package/tx/provider.js +85 -0
  38. package/tx/sct/editions.js +70 -0
  39. package/tx/sct/expressions.js +128 -44
  40. package/tx/tx-html.js +426 -44
  41. package/tx/tx.js +117 -9
  42. package/tx/workers/cache-control.js +2 -2
  43. package/tx/workers/closure.js +483 -13
  44. package/tx/workers/expand.js +2 -2
  45. package/tx/workers/metadata.js +8 -3
  46. package/tx/workers/read.js +18 -3
  47. package/tx/workers/subsumes.js +18 -2
  48. package/tx/workers/translate.js +192 -21
  49. package/tx/workers/validate.js +70 -187
  50. package/tx/workers/worker.js +12 -35
  51. package/tx/xversion/xv-conceptmap.js +17 -8
  52. 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;