fhirsmith 0.14.0 → 0.14.2

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 (73) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +3 -2
  3. package/config-template.json +20 -0
  4. package/library/fhir-openapi-schema.js +441 -0
  5. package/library/html-server.js +9 -3
  6. package/library/languages.js +15 -0
  7. package/library/npm-audit.js +265 -0
  8. package/library/openapi-doc.js +557 -0
  9. package/library/same-origin.js +62 -0
  10. package/library/utilities.js +21 -0
  11. package/nginx.md +17 -9
  12. package/package.json +10 -4
  13. package/packages/openapi.js +13 -0
  14. package/packages/openapi.yaml +603 -0
  15. package/packages/packages-template.html +4 -2
  16. package/packages/packages.js +286 -81
  17. package/packages/readme.md +120 -0
  18. package/publisher/publisher-template.html +1 -0
  19. package/publisher/publisher.js +95 -42
  20. package/registry/api.js +117 -41
  21. package/registry/crawler.js +24 -0
  22. package/registry/fhirsmith-releases.js +170 -0
  23. package/registry/model.js +10 -0
  24. package/registry/openapi.js +13 -0
  25. package/registry/openapi.yaml +311 -0
  26. package/registry/readme.md +107 -208
  27. package/registry/registry-template.html +4 -1
  28. package/registry/registry.js +183 -20
  29. package/server.js +25 -6
  30. package/testing/openapi-schemas.config.js +58 -0
  31. package/testing/openapi-schemas.json +1920 -0
  32. package/testing/openapi.js +15 -0
  33. package/testing/openapi.yaml +273 -0
  34. package/testing/readme.md +75 -1
  35. package/testing/render.js +270 -29
  36. package/testing/search.js +1 -1
  37. package/testing/store.js +152 -8
  38. package/testing/testing-template.html +2 -0
  39. package/testing/testing.js +307 -9
  40. package/translations/Messages.properties +1 -0
  41. package/tx/README.md +58 -0
  42. package/tx/cm/cm-database.js +10 -1
  43. package/tx/cm/cm-package.js +8 -1
  44. package/tx/cs/cs-rxnorm.js +23 -6
  45. package/tx/cs/cs-snomed.js +3 -72
  46. package/tx/html/tx-template.html +2 -0
  47. package/tx/library/canonical-resource.js +50 -1
  48. package/tx/library/codesystem.js +1 -0
  49. package/tx/library/conceptmap.js +1 -0
  50. package/tx/library/valueset.js +16 -0
  51. package/tx/library.js +17 -2
  52. package/tx/openapi-operations.js +363 -0
  53. package/tx/openapi-schemas.config.js +118 -0
  54. package/tx/openapi-schemas.json +7903 -0
  55. package/tx/openapi.js +289 -0
  56. package/tx/openapi.yaml +151 -0
  57. package/tx/operation-context.js +15 -0
  58. package/tx/params.js +3 -12
  59. package/tx/provider.js +10 -1
  60. package/tx/sct/editions.js +70 -0
  61. package/tx/tx-html.js +6 -0
  62. package/tx/tx.js +55 -0
  63. package/tx/vs/vs-database.js +14 -5
  64. package/tx/vs/vs-package.js +10 -0
  65. package/tx/workers/expand.js +37 -18
  66. package/tx/workers/metadata.js +0 -13
  67. package/tx/workers/translate.js +25 -1
  68. package/tx/workers/validate.js +74 -24
  69. package/tx/workers/worker.js +32 -43
  70. package/utilities/extract-schema-json.js +167 -0
  71. package/utilities/generate-openapi-schemas.js +49 -0
  72. package/utilities/submit-test-report.js +1 -1
  73. package/xig/xig.js +2 -4
package/testing/store.js CHANGED
@@ -36,6 +36,8 @@ const SCHEMA = `
36
36
  issued_lo INTEGER NOT NULL,
37
37
  issued_hi INTEGER NOT NULL,
38
38
  size INTEGER NOT NULL,
39
+ run_ms INTEGER, -- how long the run took, when the report says
40
+ test_count INTEGER, -- how many entries in TestReport.test
39
41
  json TEXT NOT NULL
40
42
  );
41
43
 
@@ -48,6 +50,26 @@ const SCHEMA = `
48
50
  display TEXT
49
51
  );
50
52
 
53
+ -- people who can maintain the named links and delete reports. The administrator is not
54
+ -- in here: their password is in the configuration
55
+ CREATE TABLE IF NOT EXISTS users (
56
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
57
+ login TEXT UNIQUE NOT NULL,
58
+ name TEXT NOT NULL,
59
+ password_hash TEXT NOT NULL,
60
+ can_edit_links INTEGER NOT NULL DEFAULT 0,
61
+ can_delete_reports INTEGER NOT NULL DEFAULT 0,
62
+ created TEXT NOT NULL
63
+ );
64
+
65
+ -- names (and links) shown in place of known canonical URLs
66
+ CREATE TABLE IF NOT EXISTS links (
67
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68
+ canonical TEXT UNIQUE NOT NULL, -- without any |version
69
+ name TEXT NOT NULL,
70
+ link TEXT
71
+ );
72
+
51
73
  CREATE INDEX IF NOT EXISTS idx_reports_received ON reports(received_ms);
52
74
  CREATE INDEX IF NOT EXISTS idx_reports_name ON reports(name_lc);
53
75
  CREATE INDEX IF NOT EXISTS idx_reports_tester ON reports(tester_lc);
@@ -84,6 +106,41 @@ function str(v) {
84
106
  return typeof v === 'string' ? v : null;
85
107
  }
86
108
 
109
+ /**
110
+ * How long the run took, in milliseconds, if the report says: from the earliest
111
+ * period.start to the latest period.end across its tests. null if no test has a period
112
+ * with both ends.
113
+ */
114
+ function runLengthOf(report) {
115
+ let lo = null;
116
+ let hi = null;
117
+ for (const t of Array.isArray(report.test) ? report.test : []) {
118
+ const p = t && typeof t === 'object' ? t.period : null;
119
+ if (!p || typeof p !== 'object') {
120
+ continue;
121
+ }
122
+ const s = typeof p.start === 'string' ? Date.parse(p.start) : NaN;
123
+ const e = typeof p.end === 'string' ? Date.parse(p.end) : NaN;
124
+ if (!Number.isNaN(s) && (lo === null || s < lo)) {
125
+ lo = s;
126
+ }
127
+ if (!Number.isNaN(e) && (hi === null || e > hi)) {
128
+ hi = e;
129
+ }
130
+ }
131
+ return lo !== null && hi !== null && hi >= lo ? hi - lo : null;
132
+ }
133
+
134
+ /** How many tests the report has (entries in TestReport.test). */
135
+ function testCountOf(report) {
136
+ return Array.isArray(report.test) ? report.test.length : 0;
137
+ }
138
+
139
+ /** A canonical URL without its |version. */
140
+ function unversioned(url) {
141
+ return typeof url === 'string' && url.includes('|') ? url.substring(0, url.indexOf('|')) : url;
142
+ }
143
+
87
144
  class TestReportStore {
88
145
  /**
89
146
  * @param {string} dbPath - file name, or ':memory:'
@@ -93,16 +150,13 @@ class TestReportStore {
93
150
  this.db.pragma('journal_mode = WAL');
94
151
  this.db.pragma('foreign_keys = ON');
95
152
  this.db.exec(SCHEMA);
96
- // participants.version was added after the first databases were made
97
- if (!this.db.prepare('PRAGMA table_info(participants)').all().some(c => c.name === 'version')) {
98
- this.db.exec('ALTER TABLE participants ADD COLUMN version TEXT');
99
- }
153
+ this.migrate();
100
154
  this.stmts = {
101
155
  insert: this.db.prepare(`
102
156
  INSERT INTO reports (id, received, received_ms, ip, name, name_lc, status, result, score,
103
- tester, tester_lc, test_script, issued, issued_lo, issued_hi, size, json)
157
+ tester, tester_lc, test_script, issued, issued_lo, issued_hi, size, run_ms, test_count, json)
104
158
  VALUES (@id, @received, @received_ms, @ip, @name, @name_lc, @status, @result, @score,
105
- @tester, @tester_lc, @test_script, @issued, @issued_lo, @issued_hi, @size, @json)`),
159
+ @tester, @tester_lc, @test_script, @issued, @issued_lo, @issued_hi, @size, @run_ms, @test_count, @json)`),
106
160
  insertParticipant: this.db.prepare(
107
161
  'INSERT INTO participants (report_id, seq, type, uri, version, display) VALUES (?, ?, ?, ?, ?, ?)'),
108
162
  read: this.db.prepare('SELECT json FROM reports WHERE id = ?'),
@@ -112,6 +166,31 @@ class TestReportStore {
112
166
  };
113
167
  }
114
168
 
169
+ /** Bring a database made by an earlier version of the module up to date. */
170
+ migrate() {
171
+ const has = (table, col) => this.db.prepare(`PRAGMA table_info(${table})`).all().some(c => c.name === col);
172
+ if (!has('participants', 'version')) {
173
+ this.db.exec('ALTER TABLE participants ADD COLUMN version TEXT');
174
+ }
175
+ // columns worked out from the report: add them, and fill them in for the reports
176
+ // already there
177
+ const derived = [['run_ms', runLengthOf], ['test_count', testCountOf]];
178
+ for (const [col, fn] of derived) {
179
+ if (!has('reports', col)) {
180
+ this.db.exec(`ALTER TABLE reports ADD COLUMN ${col} INTEGER`);
181
+ const update = this.db.prepare(`UPDATE reports SET ${col} = ? WHERE id = ?`);
182
+ this.db.transaction(() => {
183
+ for (const row of this.db.prepare('SELECT id, json FROM reports').all()) {
184
+ const v = fn(JSON.parse(row.json));
185
+ if (v !== null) {
186
+ update.run(v, row.id);
187
+ }
188
+ }
189
+ })();
190
+ }
191
+ }
192
+ }
193
+
115
194
  close() {
116
195
  if (this.db) {
117
196
  this.db.close();
@@ -145,6 +224,8 @@ class TestReportStore {
145
224
  issued_lo: issued.lo,
146
225
  issued_hi: issued.hi,
147
226
  size: Buffer.byteLength(json, 'utf8'),
227
+ run_ms: runLengthOf(report),
228
+ test_count: testCountOf(report),
148
229
  json
149
230
  };
150
231
  this.db.transaction(() => {
@@ -191,7 +272,7 @@ class TestReportStore {
191
272
  }
192
273
  const cols = options.withJson
193
274
  ? 'r.id, r.json'
194
- : 'r.id, r.received, r.name, r.status, r.result, r.score, r.tester, r.test_script, r.issued';
275
+ : 'r.id, r.received, r.name, r.status, r.result, r.score, r.tester, r.test_script, r.issued, r.run_ms, r.test_count';
195
276
  const rows = this.db.prepare(
196
277
  `SELECT ${cols} FROM reports r ${where} ORDER BY ${search.orderBy} LIMIT ? OFFSET ?`
197
278
  ).all(...search.args, search.count, search.offset);
@@ -211,6 +292,69 @@ class TestReportStore {
211
292
  return { total, rows };
212
293
  }
213
294
 
295
+ /** Whether any report has a participant of this type. */
296
+ hasParticipantType(type) {
297
+ return !!this.db.prepare('SELECT 1 FROM participants WHERE type = ? LIMIT 1').get(type);
298
+ }
299
+
300
+ // ---- named links ----
301
+
302
+ /** @returns {Array<{id, canonical, name, link}>} sorted by name */
303
+ links() {
304
+ return this.db.prepare('SELECT id, canonical, name, link FROM links ORDER BY name COLLATE NOCASE, canonical').all();
305
+ }
306
+
307
+ /** Add a link, or replace the one for the same canonical. */
308
+ saveLink(canonical, name, link) {
309
+ this.db.prepare(`INSERT INTO links (canonical, name, link) VALUES (?, ?, ?)
310
+ ON CONFLICT(canonical) DO UPDATE SET name = excluded.name, link = excluded.link`)
311
+ .run(unversioned(canonical), name, link || null);
312
+ }
313
+
314
+ updateLink(id, canonical, name, link) {
315
+ return this.db.prepare('UPDATE links SET canonical = ?, name = ?, link = ? WHERE id = ?')
316
+ .run(unversioned(canonical), name, link || null, id).changes > 0;
317
+ }
318
+
319
+ deleteLink(id) {
320
+ return this.db.prepare('DELETE FROM links WHERE id = ?').run(id).changes > 0;
321
+ }
322
+
323
+ // ---- users ----
324
+
325
+ /** @returns {Array} users, without their password hashes */
326
+ users() {
327
+ return this.db.prepare('SELECT id, login, name, can_edit_links, can_delete_reports, created FROM users ORDER BY login').all();
328
+ }
329
+
330
+ /** @returns {Object|null} the user, including the password hash */
331
+ userByLogin(login) {
332
+ return this.db.prepare('SELECT * FROM users WHERE login = ?').get(login) || null;
333
+ }
334
+
335
+ userById(id) {
336
+ return this.db.prepare('SELECT id, login, name, can_edit_links, can_delete_reports FROM users WHERE id = ?').get(id) || null;
337
+ }
338
+
339
+ createUser(login, name, passwordHash, rights) {
340
+ this.db.prepare(`INSERT INTO users (login, name, password_hash, can_edit_links, can_delete_reports, created)
341
+ VALUES (?, ?, ?, ?, ?, ?)`).run(login, name, passwordHash, rights.editLinks ? 1 : 0, rights.deleteReports ? 1 : 0,
342
+ new Date().toISOString());
343
+ }
344
+
345
+ updateUser(id, name, rights, passwordHash) {
346
+ const n = this.db.prepare('UPDATE users SET name = ?, can_edit_links = ?, can_delete_reports = ? WHERE id = ?')
347
+ .run(name, rights.editLinks ? 1 : 0, rights.deleteReports ? 1 : 0, id).changes;
348
+ if (n > 0 && passwordHash) {
349
+ this.db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, id);
350
+ }
351
+ return n > 0;
352
+ }
353
+
354
+ deleteUser(id) {
355
+ return this.db.prepare('DELETE FROM users WHERE id = ?').run(id).changes > 0;
356
+ }
357
+
214
358
  /** The distinct values in a column, for filter drop-downs. */
215
359
  distinct(column) {
216
360
  if (!['status', 'result'].includes(column)) {
@@ -263,4 +407,4 @@ class TestReportStore {
263
407
  }
264
408
  }
265
409
 
266
- module.exports = { TestReportStore, testScriptOf };
410
+ module.exports = { TestReportStore, testScriptOf, runLengthOf, testCountOf, unversioned };
@@ -9,6 +9,7 @@
9
9
  <meta content="http://hl7.org/fhir" name="author"/>
10
10
  <meta charset="utf-8" http-equiv="X-UA-Compatible" content="IE=edge" />
11
11
 
12
+ <link rel="service-desc" type="application/json" href="/testing/openapi.json"/>
12
13
  <link rel="stylesheet" href="/fhir.css"/>
13
14
 
14
15
 
@@ -59,6 +60,7 @@
59
60
  <a href="/" style="color: gold"> Server Home</a> &nbsp;|&nbsp;
60
61
  <a href="/testing" style="color: gold">Test Reports</a> &nbsp;|&nbsp;
61
62
  <a href="/testing/summary" style="color: gold">Summary</a> &nbsp;|&nbsp;
63
+ <a href="/testing/openapi" style="color: gold">API</a>
62
64
  </div> <!-- /container -->
63
65
  </div> <!-- /container -->
64
66
  </div>
@@ -15,6 +15,14 @@
15
15
  * GET {base}/metadata CapabilityStatement
16
16
  * GET {base} the filterable list (HTML)
17
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.
18
26
  *
19
27
  * R4 and R5 TestReports are treated as the same thing. Reports can't be changed once
20
28
  * received: each POST is a new report, even when it's the same report as before.
@@ -27,14 +35,21 @@ const path = require('path');
27
35
  const crypto = require('crypto');
28
36
  const rateLimit = require('express-rate-limit');
29
37
  const escape = require('escape-html');
38
+ const session = require('express-session');
39
+ const lusca = require('lusca');
40
+ const bcrypt = require('bcrypt');
30
41
 
31
42
  const folders = require('../library/folder-setup');
32
43
  const htmlServer = require('../library/html-server');
33
44
  const { tokenMatches, tokenConfigured } = require('../library/request-token');
45
+ const { requireSameOrigin } = require('../library/same-origin');
34
46
  const packageJson = require('../package.json');
35
47
  const { TestReportStore } = require('./store');
36
48
  const { parseSearch, dateRange, capabilitySearchParams } = require('./search');
37
- const { renderList, renderSummary, renderReport, listQueryToSearch } = require('./render');
49
+ const testingOpenApi = require('./openapi');
50
+ const {
51
+ renderList, renderSummary, renderReport, renderLogin, renderLinks, renderUsers, listQueryToSearch
52
+ } = require('./render');
38
53
 
39
54
  const TEMPLATE = 'testing';
40
55
  const DEFAULT_MAX_SIZE = 500 * 1024;
@@ -80,6 +95,11 @@ function validateReport(r) {
80
95
  if (r.meta !== undefined && (r.meta === null || typeof r.meta !== 'object' || Array.isArray(r.meta))) {
81
96
  problems.push('TestReport.meta must be an object');
82
97
  }
98
+ // contained resources aren't accepted: nothing in a report needs them, and allowing them
99
+ // would mean describing (and rendering) any resource at all
100
+ if (r.contained !== undefined) {
101
+ problems.push('TestReport.contained is not allowed: reports may not contain resources');
102
+ }
83
103
  return problems;
84
104
  }
85
105
 
@@ -140,12 +160,60 @@ class TestingModule {
140
160
 
141
161
  htmlServer.loadTemplate(TEMPLATE, path.join(__dirname, 'testing-template.html'));
142
162
 
163
+ // the administrator's password is checked the same way as everyone else's: bcrypt against
164
+ // a hash, made once here, of the configured password
165
+ this.adminHash = tokenConfigured(this.config.adminPassword) ? bcrypt.hashSync(this.config.adminPassword, 10) : null;
166
+
167
+ this.setupSession();
143
168
  this.setupRoutes();
169
+ this.setupAdminRoutes();
144
170
  this.setupRetention();
145
171
  this.log.info(`Testing module: ${this.store.count()} reports in ${dbPath}; ` +
146
172
  `posting ${tokenConfigured(this.config.token) ? 'requires a token' : 'is open'}, max ${this.maxSize} bytes`);
147
173
  }
148
174
 
175
+ /**
176
+ * The session (login) cookie and CSRF protection. Neither is put on the whole router:
177
+ * the FHIR API (POST and DELETE of TestReports) is authenticated by a token in a header,
178
+ * not a cookie, so it needs neither and must not be made to carry a CSRF token. The web
179
+ * pages get this.web; every form post gets sameOrigin + this.session + a form parser +
180
+ * this.csrf (see setupAdminRoutes).
181
+ *
182
+ * CSRF has two layers: lusca's per-session token, which every form carries in a hidden
183
+ * _csrf field, and the Origin check (library/same-origin). The cookie is also SameSite=Lax.
184
+ */
185
+ setupSession() {
186
+ if (!this.config.sessionSecret) {
187
+ // as for the publisher: never a constant secret, which would be a published signing key
188
+ this.log.warn('testing: no sessionSecret configured - using a random one, so logins will not ' +
189
+ 'survive a restart. Set modules.testing.sessionSecret.');
190
+ }
191
+ this.session = session({
192
+ name: 'testing.sid',
193
+ secret: this.config.sessionSecret || crypto.randomBytes(64).toString('hex'),
194
+ resave: false,
195
+ saveUninitialized: false,
196
+ cookie: {
197
+ // HTTPS only unless cookieSecure is false - see the publisher's notes on nginx
198
+ secure: this.config.cookieSecure ?? true,
199
+ // the browser will not send the cookie on a form posted from another site, which,
200
+ // with the Origin check on every form post, is the CSRF protection
201
+ sameSite: 'lax',
202
+ httpOnly: true,
203
+ maxAge: 24 * 60 * 60 * 1000
204
+ }
205
+ });
206
+ // used directly (not wrapped) in every form post's route, so static analysis can see
207
+ // it there. A bad or missing token comes out as an error, which the error handler at the
208
+ // end of setupAdminRoutes turns into a page
209
+ this.csrf = lusca.csrf({ key: '_csrf' });
210
+ // the pages show a logout form (which needs a token) only to someone logged in. Making
211
+ // a token stores a secret in the session, so doing it for every visitor would give
212
+ // every crawler a session
213
+ const csrfIfLoggedIn = (req, res, next) => this.currentUser(req) ? this.csrf(req, res, next) : next();
214
+ this.web = [this.session, csrfIfLoggedIn];
215
+ }
216
+
149
217
  setupRoutes() {
150
218
  const r = this.router;
151
219
  const rl = this.config.rateLimit || {};
@@ -161,13 +229,31 @@ class TestingModule {
161
229
  }));
162
230
  }
163
231
 
164
- r.get('/', (req, res) => this.handle(req, res, 'list', () => this.htmlList(req, res)));
165
- r.get('/summary', (req, res) => this.handle(req, res, 'summary', () => this.htmlSummary(req, res)));
232
+ // RFC 8631: where to find the machine-readable description of this API
233
+ r.use((req, res, next) => {
234
+ res.setHeader('Link', `<${req.baseUrl}/openapi.json>; rel="service-desc", <${req.baseUrl}/openapi>; rel="service-doc"`);
235
+ next();
236
+ });
237
+
238
+ // the OpenAPI description: /openapi.json, /openapi.yaml, and /openapi (an HTML reference
239
+ // for browsers, the JSON otherwise)
240
+ r.get('/openapi.json', (req, res) => this.handle(req, res, 'openapi', () => res.type('application/json').send(testingOpenApi.getJson())));
241
+ r.get('/openapi.yaml', (req, res) => this.handle(req, res, 'openapi', () =>
242
+ res.set('Content-Type', 'application/yaml').send(testingOpenApi.getYaml())));
243
+ r.get('/openapi', (req, res) => this.handle(req, res, 'openapi', () => {
244
+ if (!wantsHtml(req)) {
245
+ return res.type('application/json').send(testingOpenApi.getJson());
246
+ }
247
+ return this.sendHtml(res, 'Test Report API', testingOpenApi.renderHtml(), Date.now());
248
+ }));
249
+
250
+ r.get('/', ...this.web, (req, res) => this.handle(req, res, 'list', () => this.htmlList(req, res)));
251
+ r.get('/summary', ...this.web, (req, res) => this.handle(req, res, 'summary', () => this.htmlSummary(req, res)));
166
252
  r.get('/metadata', (req, res) => this.handle(req, res, 'metadata', () => this.metadata(req, res)));
167
253
  r.post('/TestReport', ...posting, (req, res) => this.handle(req, res, 'create', () => this.create(req, res)));
168
- r.get('/TestReport', (req, res) => this.handle(req, res, 'search', () =>
254
+ r.get('/TestReport', ...this.web, (req, res) => this.handle(req, res, 'search', () =>
169
255
  wantsHtml(req) ? this.htmlList(req, res) : this.search(req, res)));
170
- r.get('/TestReport/:id', (req, res) => this.handle(req, res, 'read', () => this.read(req, res)));
256
+ r.get('/TestReport/:id', ...this.web, (req, res) => this.handle(req, res, 'read', () => this.read(req, res)));
171
257
  r.delete('/TestReport/:id', (req, res) => this.handle(req, res, 'delete', () => this.remove(req, res)));
172
258
  }
173
259
 
@@ -337,7 +423,7 @@ class TestingModule {
337
423
  return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`No report with id '${req.params.id}'`), 404);
338
424
  }
339
425
  return this.sendHtml(res, 'Test Report: ' + (typeof report.name === 'string' ? report.name : report.id),
340
- renderReport(report, req.baseUrl), start);
426
+ renderReport(report, req.baseUrl, { names: this.names(), user: this.currentUser(req), csrf: res.locals._csrf }), start);
341
427
  }
342
428
  if (!report) {
343
429
  return this.sendOutcome(res, 404, 'not-found', `No TestReport with id '${req.params.id}'`);
@@ -468,9 +554,12 @@ class TestingModule {
468
554
  }
469
555
  const results = this.store.search(search);
470
556
  const content = prefix + renderList(query, results, search, {
471
- statuses: this.store.distinct('status'),
472
557
  results: this.store.distinct('result'),
473
- base: req.baseUrl
558
+ base: req.baseUrl,
559
+ names: this.names(),
560
+ user: this.currentUser(req),
561
+ csrf: res.locals._csrf,
562
+ showClient: this.store.hasParticipantType('client')
474
563
  });
475
564
  this.sendHtml(res, 'Test Reports', content, start);
476
565
  }
@@ -478,7 +567,216 @@ class TestingModule {
478
567
  async htmlSummary(req, res) {
479
568
  const start = Date.now();
480
569
  const by = req.query.by === 'tester' ? 'tester' : 'participant';
481
- this.sendHtml(res, 'Test Report Summary', renderSummary(this.store.summary(by), by, req.baseUrl, this.store.testerCounts()), start);
570
+ this.sendHtml(res, 'Test Report Summary', renderSummary(this.store.summary(by), by, req.baseUrl, this.store.testerCounts(),
571
+ { names: this.names(), user: this.currentUser(req), csrf: res.locals._csrf }), start);
572
+ }
573
+
574
+ // ---- users, named links, and the administration pages ----
575
+
576
+ /** The named links, by unversioned canonical. Cached until they change. */
577
+ names() {
578
+ if (!this.namesCache) {
579
+ this.namesCache = new Map(this.store.links().map(l => [l.canonical, l]));
580
+ }
581
+ return this.namesCache;
582
+ }
583
+
584
+ /**
585
+ * Who is logged in, with their rights as they are now (not as they were at login, so
586
+ * taking a right away, or deleting the user, takes effect at once).
587
+ */
588
+ currentUser(req) {
589
+ const s = req.session;
590
+ if (!s) {
591
+ return null;
592
+ }
593
+ if (s.testingAdmin) {
594
+ return { name: 'Administrator', isAdmin: true, canEditLinks: true, canDeleteReports: true };
595
+ }
596
+ if (s.testingUserId) {
597
+ const u = this.store.userById(s.testingUserId);
598
+ if (u) {
599
+ return { id: u.id, name: u.name, isAdmin: false, canEditLinks: !!u.can_edit_links, canDeleteReports: !!u.can_delete_reports };
600
+ }
601
+ }
602
+ return null;
603
+ }
604
+
605
+ setupAdminRoutes() {
606
+ const r = this.router;
607
+ const form = express.urlencoded({ extended: false, limit: '64kb' });
608
+ const loginLimiter = rateLimit({
609
+ windowMs: 15 * 60 * 1000,
610
+ limit: this.config.loginAttemptsPerWindow ?? 20,
611
+ standardHeaders: 'draft-7',
612
+ legacyHeaders: false,
613
+ handler: (req, res) => this.sendLogin(req, res, 429, 'Too many login attempts - try again later')
614
+ });
615
+ // every form post must come from one of our own pages
616
+ const sameOrigin = requireSameOrigin((req, res) =>
617
+ htmlServer.sendErrorResponse(res, TEMPLATE, new Error('Cross-site form posts are not accepted'), 403));
618
+ // a right, or the administrator
619
+ const need = (right) => (req, res, next) => {
620
+ const user = this.currentUser(req);
621
+ if (!user) {
622
+ return res.redirect(req.baseUrl + '/login');
623
+ }
624
+ if (!(user.isAdmin || (right && user[right]))) {
625
+ return htmlServer.sendErrorResponse(res, TEMPLATE, new Error('You do not have permission to do that'), 403);
626
+ }
627
+ req.user = user;
628
+ next();
629
+ };
630
+ const h = (name, fn) => (req, res) => this.handle(req, res, name, () => fn(req, res));
631
+ // Every page with a form has this.session, this.csrf; every form post has sameOrigin,
632
+ // this.session, form, this.csrf. They are written out in each route rather than spread
633
+ // from an array, so that CodeQL can see the CSRF middleware on every cookie-using route.
634
+
635
+ r.get('/login', this.session, this.csrf, h('login', (req, res) => this.sendLogin(req, res, 200)));
636
+ r.post('/login', sameOrigin, loginLimiter, this.session, form, this.csrf, h('login', (req, res) => this.login(req, res)));
637
+ r.post('/logout', sameOrigin, this.session, form, this.csrf, h('logout', (req, res) => {
638
+ req.session.destroy(() => res.redirect(req.baseUrl || '/'));
639
+ }));
640
+
641
+ r.get('/admin/links', this.session, this.csrf, need('canEditLinks'), h('links', (req, res) => this.sendLinks(req, res)));
642
+ r.post('/admin/links', sameOrigin, this.session, form, this.csrf, need('canEditLinks'), h('links', (req, res) => this.saveLink(req, res, null)));
643
+ r.post('/admin/links/:id', sameOrigin, this.session, form, this.csrf, need('canEditLinks'), h('links', (req, res) => this.saveLink(req, res, req.params.id)));
644
+ r.post('/admin/links/:id/delete', sameOrigin, this.session, form, this.csrf, need('canEditLinks'), h('links', (req, res) => {
645
+ this.store.deleteLink(parseInt(req.params.id, 10));
646
+ this.namesCache = null;
647
+ res.redirect(req.baseUrl + '/admin/links');
648
+ }));
649
+
650
+ r.get('/admin/users', this.session, this.csrf, need(null), h('users', (req, res) => this.sendUsers(req, res)));
651
+ r.post('/admin/users', sameOrigin, this.session, form, this.csrf, need(null), h('users', (req, res) => this.saveUser(req, res, null)));
652
+ r.post('/admin/users/:id', sameOrigin, this.session, form, this.csrf, need(null), h('users', (req, res) => this.saveUser(req, res, req.params.id)));
653
+ r.post('/admin/users/:id/delete', sameOrigin, this.session, form, this.csrf, need(null), h('users', (req, res) => {
654
+ this.store.deleteUser(parseInt(req.params.id, 10));
655
+ this.log.info(`Testing module: user ${req.params.id} deleted`);
656
+ res.redirect(req.baseUrl + '/admin/users');
657
+ }));
658
+
659
+ r.post('/TestReport/:id/delete', sameOrigin, this.session, form, this.csrf, need('canDeleteReports'), h('delete', (req, res) => {
660
+ if (!this.store.delete(req.params.id)) {
661
+ return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`No report with id '${req.params.id}'`), 404);
662
+ }
663
+ this.log.info(`Testing module: report ${req.params.id} deleted by ${req.user.name}`);
664
+ res.redirect(req.baseUrl);
665
+ }));
666
+
667
+ // lusca rejects a form post with a missing or wrong token by passing on an error
668
+ // ("CSRF token missing" / "CSRF token mismatch"); anything else goes on as it was
669
+ r.use((err, req, res, next) => {
670
+ if (err && typeof err.message === 'string' && err.message.startsWith('CSRF token')) {
671
+ return htmlServer.sendErrorResponse(res, TEMPLATE,
672
+ new Error('The form has expired or did not come from this site - reload the page and try again'), 403);
673
+ }
674
+ next(err);
675
+ });
676
+ }
677
+
678
+ sendLogin(req, res, status, error) {
679
+ res.status(status);
680
+ this.sendHtml(res, 'Login', renderLogin(req.baseUrl, error, res.locals._csrf), Date.now());
681
+ }
682
+
683
+ async login(req, res) {
684
+ const login = typeof req.body.login === 'string' ? req.body.login.trim() : '';
685
+ const password = typeof req.body.password === 'string' ? req.body.password : '';
686
+ let ok = false;
687
+ const regenerate = () => new Promise((resolve, reject) => req.session.regenerate(e => e ? reject(e) : resolve()));
688
+ if (login === 'admin') {
689
+ if (this.adminHash && await bcrypt.compare(password, this.adminHash)) {
690
+ await regenerate();
691
+ req.session.testingAdmin = true;
692
+ ok = true;
693
+ }
694
+ } else if (login) {
695
+ const user = this.store.userByLogin(login);
696
+ if (user && await bcrypt.compare(password, user.password_hash)) {
697
+ await regenerate();
698
+ req.session.testingUserId = user.id;
699
+ ok = true;
700
+ }
701
+ }
702
+ if (!ok) {
703
+ this.log.info(`Testing module: failed login for '${login}' from ${req.ip}`);
704
+ return this.sendLogin(req, res, 401, 'Unknown username or wrong password');
705
+ }
706
+ this.log.info(`Testing module: '${login}' logged in from ${req.ip}`);
707
+ res.redirect(req.baseUrl || '/');
708
+ }
709
+
710
+ sendLinks(req, res, message, status = 200) {
711
+ res.status(status);
712
+ this.sendHtml(res, 'Named Links', renderLinks(this.store.links(), { base: req.baseUrl, user: req.user, csrf: res.locals._csrf }, message), Date.now());
713
+ }
714
+
715
+ saveLink(req, res, id) {
716
+ const canonical = typeof req.body.canonical === 'string' ? req.body.canonical.trim() : '';
717
+ const name = typeof req.body.name === 'string' ? req.body.name.trim() : '';
718
+ const link = typeof req.body.link === 'string' ? req.body.link.trim() : '';
719
+ let problem = null;
720
+ if (!canonical || /\s/.test(canonical) || canonical.length > 1024) {
721
+ problem = 'The canonical must be a URL with no spaces';
722
+ } else if (!name || name.length > 200) {
723
+ problem = 'A name is required (up to 200 characters)';
724
+ } else if (link && !/^https?:\/\/[^\s"'<>]+$/i.test(link)) {
725
+ problem = 'The link must be an http or https URL';
726
+ }
727
+ if (problem) {
728
+ return this.sendLinks(req, res, problem, 400);
729
+ }
730
+ try {
731
+ if (id === null) {
732
+ this.store.saveLink(canonical, name, link);
733
+ } else if (!this.store.updateLink(parseInt(id, 10), canonical, name, link)) {
734
+ return this.sendLinks(req, res, 'That link no longer exists', 404);
735
+ }
736
+ } catch (e) {
737
+ if (/UNIQUE/.test(e.message)) {
738
+ return this.sendLinks(req, res, `There is already a name for ${canonical}`, 409);
739
+ }
740
+ throw e;
741
+ }
742
+ this.namesCache = null;
743
+ res.redirect(req.baseUrl + '/admin/links');
744
+ }
745
+
746
+ sendUsers(req, res, message, status = 200) {
747
+ res.status(status);
748
+ this.sendHtml(res, 'Users', renderUsers(this.store.users(), { base: req.baseUrl, user: req.user, csrf: res.locals._csrf }, message), Date.now());
749
+ }
750
+
751
+ async saveUser(req, res, id) {
752
+ const login = typeof req.body.login === 'string' ? req.body.login.trim() : '';
753
+ const name = typeof req.body.name === 'string' ? req.body.name.trim() : '';
754
+ const password = typeof req.body.password === 'string' ? req.body.password : '';
755
+ const rights = { editLinks: req.body.editLinks === '1', deleteReports: req.body.deleteReports === '1' };
756
+ let problem = null;
757
+ if (id === null && !/^[A-Za-z0-9._@-]{1,64}$/.test(login)) {
758
+ problem = 'The username must be 1-64 letters, digits, or . _ @ -';
759
+ } else if (id === null && login.toLowerCase() === 'admin') {
760
+ problem = "'admin' is the administrator's login";
761
+ } else if (!name || name.length > 100) {
762
+ problem = 'A name is required (up to 100 characters)';
763
+ } else if ((id === null || password) && password.length < 8) {
764
+ problem = 'Passwords must be at least 8 characters';
765
+ }
766
+ if (problem) {
767
+ return this.sendUsers(req, res, problem, 400);
768
+ }
769
+ const hash = password ? await bcrypt.hash(password, 10) : null;
770
+ if (id === null) {
771
+ if (this.store.userByLogin(login)) {
772
+ return this.sendUsers(req, res, `There is already a user '${login}'`, 409);
773
+ }
774
+ this.store.createUser(login, name, hash, rights);
775
+ this.log.info(`Testing module: user '${login}' created`);
776
+ } else if (!this.store.updateUser(parseInt(id, 10), name, rights, hash)) {
777
+ return this.sendUsers(req, res, 'That user no longer exists', 404);
778
+ }
779
+ res.redirect(req.baseUrl + '/admin/users');
482
780
  }
483
781
  }
484
782
 
@@ -1288,6 +1288,7 @@ VALUESET_INCLUDE_CSVER_NOT_FOUND = No matching contained code system found for s
1288
1288
  VALUESET_INCLUDE_CSVER_SUPPLEMENT = The value set references CodeSystem ''{0}'' version ''{2}'' which is a supplement. It must reference the underlying CodeSystem ''{1}'' and use the http://hl7.org/fhir/StructureDefinition/valueset-supplement extension for the supplement
1289
1289
  VALUESET_INCLUDE_INVALID_CONCEPT_CODE = The code ''{1}'' is not valid in the system {0} ({2})
1290
1290
  VALUESET_INCLUDE_INVALID_CONCEPT_CODE_VER = The code ''{2}'' is not valid in the system {0} version {1} ({2})
1291
+ VALUESET_NO_COMPOSE = The value set {0} has no compose and no expansion, so it cannot be expanded
1291
1292
  VALUESET_INCLUDE_SYSTEM_ABSOLUTE = URI values in ValueSet.compose.include.system must be absolute
1292
1293
  VALUESET_INCLUDE_SYSTEM_ABSOLUTE_FRAG = URI values in ValueSet.compose.include.system must be absolute. To reference a contained code system, use the full CodeSystem URL and reference it using the http://hl7.org/fhir/StructureDefinition/valueset-system extension
1293
1294
  VALUESET_INCLUDE_WRONG_CS_OID = It is not valid to refer to a CodeSystem by an identifier like this ''{0}'' - use ''{1}''