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.
- package/CHANGELOG.md +64 -0
- package/README.md +3 -2
- package/config-template.json +20 -0
- package/library/fhir-openapi-schema.js +441 -0
- package/library/html-server.js +9 -3
- package/library/languages.js +15 -0
- package/library/npm-audit.js +265 -0
- package/library/openapi-doc.js +557 -0
- package/library/same-origin.js +62 -0
- package/library/utilities.js +21 -0
- package/nginx.md +17 -9
- package/package.json +10 -4
- package/packages/openapi.js +13 -0
- package/packages/openapi.yaml +603 -0
- package/packages/packages-template.html +4 -2
- package/packages/packages.js +286 -81
- package/packages/readme.md +120 -0
- package/publisher/publisher-template.html +1 -0
- package/publisher/publisher.js +95 -42
- package/registry/api.js +117 -41
- package/registry/crawler.js +24 -0
- package/registry/fhirsmith-releases.js +170 -0
- package/registry/model.js +10 -0
- package/registry/openapi.js +13 -0
- package/registry/openapi.yaml +311 -0
- package/registry/readme.md +107 -208
- package/registry/registry-template.html +4 -1
- package/registry/registry.js +183 -20
- package/server.js +25 -6
- package/testing/openapi-schemas.config.js +58 -0
- package/testing/openapi-schemas.json +1920 -0
- package/testing/openapi.js +15 -0
- package/testing/openapi.yaml +273 -0
- package/testing/readme.md +75 -1
- package/testing/render.js +270 -29
- package/testing/search.js +1 -1
- package/testing/store.js +152 -8
- package/testing/testing-template.html +2 -0
- package/testing/testing.js +307 -9
- package/translations/Messages.properties +1 -0
- package/tx/README.md +58 -0
- package/tx/cm/cm-database.js +10 -1
- package/tx/cm/cm-package.js +8 -1
- package/tx/cs/cs-rxnorm.js +23 -6
- package/tx/cs/cs-snomed.js +3 -72
- package/tx/html/tx-template.html +2 -0
- package/tx/library/canonical-resource.js +50 -1
- package/tx/library/codesystem.js +1 -0
- package/tx/library/conceptmap.js +1 -0
- package/tx/library/valueset.js +16 -0
- package/tx/library.js +17 -2
- package/tx/openapi-operations.js +363 -0
- package/tx/openapi-schemas.config.js +118 -0
- package/tx/openapi-schemas.json +7903 -0
- package/tx/openapi.js +289 -0
- package/tx/openapi.yaml +151 -0
- package/tx/operation-context.js +15 -0
- package/tx/params.js +3 -12
- package/tx/provider.js +10 -1
- package/tx/sct/editions.js +70 -0
- package/tx/tx-html.js +6 -0
- package/tx/tx.js +55 -0
- package/tx/vs/vs-database.js +14 -5
- package/tx/vs/vs-package.js +10 -0
- package/tx/workers/expand.js +37 -18
- package/tx/workers/metadata.js +0 -13
- package/tx/workers/translate.js +25 -1
- package/tx/workers/validate.js +74 -24
- package/tx/workers/worker.js +32 -43
- package/utilities/extract-schema-json.js +167 -0
- package/utilities/generate-openapi-schemas.js +49 -0
- package/utilities/submit-test-report.js +1 -1
- 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
|
-
|
|
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> |
|
|
60
61
|
<a href="/testing" style="color: gold">Test Reports</a> |
|
|
61
62
|
<a href="/testing/summary" style="color: gold">Summary</a> |
|
|
63
|
+
<a href="/testing/openapi" style="color: gold">API</a>
|
|
62
64
|
</div> <!-- /container -->
|
|
63
65
|
</div> <!-- /container -->
|
|
64
66
|
</div>
|
package/testing/testing.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
165
|
-
r.
|
|
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()
|
|
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}''
|