jskos-server 2.4.0

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 (112) hide show
  1. package/.dockerignore +20 -0
  2. package/.editorconfig +9 -0
  3. package/.github/workflows/docker.yml +59 -0
  4. package/.github/workflows/gh-pages.yml +23 -0
  5. package/.github/workflows/gh-release.yml +19 -0
  6. package/.github/workflows/test.yml +39 -0
  7. package/.husky/pre-commit +1 -0
  8. package/CHANGELOG.md +18 -0
  9. package/LICENSE +21 -0
  10. package/README.md +2710 -0
  11. package/bin/extra.js +81 -0
  12. package/bin/import.js +438 -0
  13. package/bin/mongodb.js +21 -0
  14. package/bin/reset.js +257 -0
  15. package/bin/upgrade.js +34 -0
  16. package/config/config.default.json +88 -0
  17. package/config/config.schema.json +877 -0
  18. package/config/config.test.json +107 -0
  19. package/config/index.js +77 -0
  20. package/config/setup.js +212 -0
  21. package/depdendencies.png +0 -0
  22. package/docker/.env +1 -0
  23. package/docker/Dockerfile +20 -0
  24. package/docker/README.md +175 -0
  25. package/docker/docker-compose.yml +29 -0
  26. package/docker/docker-entrypoint.sh +8 -0
  27. package/docker/mongo-initdb.d/mongo_setup.sh +22 -0
  28. package/ecosystem.example.json +7 -0
  29. package/errors/index.js +94 -0
  30. package/eslint.config.js +17 -0
  31. package/index.js +10 -0
  32. package/models/annotations.js +13 -0
  33. package/models/concepts.js +12 -0
  34. package/models/concordances.js +12 -0
  35. package/models/index.js +33 -0
  36. package/models/mappings.js +20 -0
  37. package/models/meta.js +21 -0
  38. package/models/registries.js +12 -0
  39. package/models/schemes.js +12 -0
  40. package/package.json +91 -0
  41. package/routes/annotations.js +83 -0
  42. package/routes/common.js +86 -0
  43. package/routes/concepts.js +64 -0
  44. package/routes/concordances.js +86 -0
  45. package/routes/data.js +19 -0
  46. package/routes/mappings.js +108 -0
  47. package/routes/registries.js +24 -0
  48. package/routes/schemes.js +72 -0
  49. package/routes/validate.js +37 -0
  50. package/server.js +190 -0
  51. package/services/abstract.js +328 -0
  52. package/services/annotations.js +237 -0
  53. package/services/concepts.js +459 -0
  54. package/services/concordances.js +264 -0
  55. package/services/data.js +30 -0
  56. package/services/index.js +34 -0
  57. package/services/mappings.js +978 -0
  58. package/services/registries.js +319 -0
  59. package/services/schemes.js +318 -0
  60. package/services/validate.js +39 -0
  61. package/status.schema.json +145 -0
  62. package/test/abstract-service.js +36 -0
  63. package/test/annotations/annotation.json +13 -0
  64. package/test/api.js +2481 -0
  65. package/test/chai.js +14 -0
  66. package/test/changes.js +179 -0
  67. package/test/concepts/conceptNoFileEnding +4 -0
  68. package/test/concepts/concepts-ddc-6-60-61-62.json +123 -0
  69. package/test/concordances/concordances.ndjson +2 -0
  70. package/test/config.js +26 -0
  71. package/test/configs/complex-config.json +90 -0
  72. package/test/configs/empty-object.json +1 -0
  73. package/test/configs/fail-array.json +1 -0
  74. package/test/configs/fail-empty.json +0 -0
  75. package/test/configs/fail-mapping-only-props1.json +5 -0
  76. package/test/configs/fail-mapping-only-props2.json +5 -0
  77. package/test/configs/fail-mapping-only-props3.json +5 -0
  78. package/test/configs/fail-mapping-only-props4.json +5 -0
  79. package/test/configs/fail-nonexisting-prop.json +3 -0
  80. package/test/configs/fail-port-string.json +3 -0
  81. package/test/configs/fail-registry-types.json +16 -0
  82. package/test/configs/registry-types.json +16 -0
  83. package/test/data-write.js +784 -0
  84. package/test/eslint.js +22 -0
  85. package/test/import-reset.js +287 -0
  86. package/test/infer-mappings.js +340 -0
  87. package/test/ipcheck.js +287 -0
  88. package/test/mappings/README.md +1 -0
  89. package/test/mappings/ddc-gnd-1.mapping.json +33 -0
  90. package/test/mappings/ddc-gnd-2.mapping.json +67 -0
  91. package/test/mappings/mapping-ddc-gnd-noScheme.json +145 -0
  92. package/test/mappings/mapping-ddc-gnd.json +175 -0
  93. package/test/mappings/mappings-ddc.json +214 -0
  94. package/test/registries/registries.ndjson +2 -0
  95. package/test/services.js +557 -0
  96. package/test/terminologies/terminologies.json +94 -0
  97. package/test/test-utils.js +182 -0
  98. package/test/utils.js +425 -0
  99. package/test/validate.js +226 -0
  100. package/utils/adjust.js +206 -0
  101. package/utils/auth.js +154 -0
  102. package/utils/changes.js +88 -0
  103. package/utils/db.js +106 -0
  104. package/utils/ipcheck.js +76 -0
  105. package/utils/middleware.js +636 -0
  106. package/utils/searchHelper.js +153 -0
  107. package/utils/status.js +77 -0
  108. package/utils/users.js +7 -0
  109. package/utils/utils.js +114 -0
  110. package/utils/uuid.js +6 -0
  111. package/utils/version.js +324 -0
  112. package/views/base.ejs +172 -0
@@ -0,0 +1,182 @@
1
+ import assert from "node:assert"
2
+ import mongoose from "mongoose"
3
+ import { MongoMemoryServer, MongoMemoryReplSet } from "mongodb-memory-server"
4
+ import { createServices } from "../index.js"
5
+
6
+ import config from "../config/config.test.json" with { type: "json" }
7
+ const services = createServices(config)
8
+
9
+ let mongod
10
+
11
+ /**
12
+ * Starts an in-memory MongoDB server (standalone or replica set).
13
+ */
14
+ export async function setupInMemoryMongo(opts = { replSet: false }) {
15
+ if (opts.replSet) {
16
+ mongod = new MongoMemoryReplSet({ replSet: { count: 1 } })
17
+ } else {
18
+ mongod = new MongoMemoryServer()
19
+ }
20
+ await mongod.start()
21
+ const uri = mongod.getUri()
22
+
23
+ await mongoose.disconnect().catch(() => {})
24
+ await mongoose.connect(uri, {
25
+ serverSelectionTimeoutMS: 30000,
26
+ })
27
+
28
+ return uri
29
+ }
30
+
31
+
32
+ /**
33
+ * Stops Mongoose and in-memory server.
34
+ */
35
+ export async function teardownInMemoryMongo() {
36
+ if (mongoose.connection.readyState !== 0) {
37
+ await mongoose.disconnect()
38
+ }
39
+ if (mongod) {
40
+ await mongod.stop()
41
+ }
42
+ }
43
+
44
+
45
+ /**
46
+ * Drop the database.
47
+ */
48
+ export async function dropDatabase() {
49
+ const conn = mongoose.connection
50
+ // If not connected at all, skip drop
51
+ if (conn.readyState === 0) {
52
+ console.log(" ⚠ No connection, skipping drop")
53
+ return
54
+ }
55
+ // If connecting, wait until connected or timeout
56
+ if (conn.readyState !== 1) {
57
+ await new Promise((resolve, reject) => {
58
+ const timeout = setTimeout(() => reject(new Error("Timeout waiting for DB connection")), 5000)
59
+ conn.once("connected", () => {
60
+ clearTimeout(timeout); resolve()
61
+ })
62
+ conn.once("error", (err) => {
63
+ clearTimeout(timeout); reject(err)
64
+ })
65
+ })
66
+ }
67
+ try {
68
+ await conn.db.dropDatabase()
69
+ console.log(" ✓ Dropped in-memory database")
70
+ } catch (err) {
71
+ console.error(" x Error: Dropping database failed.", err)
72
+ throw err
73
+ }
74
+ }
75
+
76
+
77
+ /* process.on("SIGINT", () => {
78
+ dropDatabase(() => process.exit(1))
79
+ }) */
80
+
81
+ /**
82
+ * Mocha hooks: drop DB before and after each suite.
83
+ */
84
+ export function dropDatabaseBeforeAndAfter() {
85
+ before(async () => {
86
+ await dropDatabase()
87
+ })
88
+ after(async () => {
89
+ await dropDatabase()
90
+ })
91
+ }
92
+
93
+ /**
94
+ * Asserts that Mongoose is connected.
95
+ */
96
+ export function assertMongoDB() {
97
+ describe("MongoDB Connection", () => {
98
+ it("should connect successfully", (done) => {
99
+ const conn = mongoose.connection
100
+ if (conn.readyState === 1) {
101
+ return done()
102
+ }
103
+ conn.once("connected", () => done())
104
+ conn.once("error", (err) => done(err))
105
+ })
106
+ })
107
+ }
108
+
109
+
110
+ /**
111
+ * Ensure that all JSKOS collections exist and have their indexes.
112
+ * This replaces running `import.js --indexes`.
113
+ */
114
+ export async function createCollectionsAndIndexes() {
115
+ const collNames = ["terminologies","concepts","concordances","mappings","annotations","registries"]
116
+ // 1. Create an empty collection if it doesn't exist
117
+ for (const name of collNames) {
118
+ const exists = await mongoose.connection.db
119
+ .listCollections({ name })
120
+ .hasNext()
121
+ if (!exists) {
122
+ await mongoose.connection.db.createCollection(name)
123
+ }
124
+ }
125
+ // 2. Call each service's createIndexes() method
126
+ for (const type of Object.keys(services)) {
127
+ // services[type].createIndexes() should invoke e.g. Model.createIndexes()
128
+ await services[type].createIndexes()
129
+ }
130
+ }
131
+
132
+
133
+ /**
134
+ * Verifies that indexes exist on all relevant collections.
135
+ */
136
+ export async function assertIndexes() {
137
+ it("should have at least default indexes", async () => {
138
+ const collections = ["terminologies", "concepts", "concordances", "mappings", "annotations", "registries"]
139
+ for (const name of collections) {
140
+ // ensure collection exists
141
+ const exists = await mongoose.connection.db.listCollections({ name }).hasNext()
142
+ if (!exists) {
143
+ await mongoose.connection.db.createCollection(name)
144
+ }
145
+ const info = await mongoose.connection.db.collection(name).indexInformation()
146
+ // Expect at least the _id index
147
+ assert.ok(info._id_, `Missing _id index on ${name}`)
148
+ // Optionally check other indexes here
149
+ }
150
+ })
151
+ }
152
+
153
+
154
+ import { exec as cpexec } from "node:child_process"
155
+ /**
156
+ * A wrapper around child_process' exec function for async/await.
157
+ *
158
+ * @param {*} command
159
+ * @param {*} options
160
+ */
161
+ export async function exec(command, options) {
162
+ return new Promise((resolve, reject) => {
163
+ cpexec(command, options || {}, (error, stdout, stderr) => {
164
+ if (error) {
165
+ error.stdout = stdout
166
+ error.stderr = stderr
167
+ return reject(error)
168
+ }
169
+ resolve(stdout)
170
+ })
171
+ })
172
+ }
173
+
174
+ import Stream from "node:stream"
175
+ import * as anystream from "json-anystream"
176
+
177
+ export async function arrayToStream(array) {
178
+ const readable = new Stream.Readable({ objectMode: true })
179
+ array.forEach(item => readable.push(JSON.stringify(item) + "\n"))
180
+ readable.push(null)
181
+ return anystream.make(readable, "ndjson")
182
+ }
package/test/utils.js ADDED
@@ -0,0 +1,425 @@
1
+ // Tests for utilities
2
+
3
+ import assert from "node:assert"
4
+ import { getCreator, handleCreatorForObject } from "../utils/middleware.js"
5
+ import { cleanJSON } from "../utils/utils.js"
6
+ import config from "../config/index.js"
7
+
8
+ describe("utils", () => {
9
+
10
+ describe("getCreator", () => {
11
+ const tests = [
12
+ {
13
+ req: {},
14
+ creator: null,
15
+ },
16
+ {
17
+ req: {
18
+ user: {
19
+ uri: "test",
20
+ },
21
+ },
22
+ creator: {
23
+ uri: "test",
24
+ },
25
+ },
26
+ {
27
+ req: {
28
+ user: {
29
+ uri: "test",
30
+ },
31
+ query: {
32
+ identity: "test2",
33
+ },
34
+ },
35
+ creator: {
36
+ uri: "test",
37
+ },
38
+ },
39
+ {
40
+ req: {
41
+ user: {
42
+ uri: "test",
43
+ identities: {
44
+ test2: { uri: "test2" },
45
+ },
46
+ },
47
+ query: {
48
+ identity: "test2",
49
+ },
50
+ },
51
+ creator: {
52
+ uri: "test2",
53
+ },
54
+ },
55
+ {
56
+ req: {
57
+ user: {
58
+ uri: "test",
59
+ identities: {
60
+ test2: {
61
+ uri: "test2",
62
+ name: "name2",
63
+ },
64
+ },
65
+ },
66
+ query: {
67
+ identity: "test2",
68
+ },
69
+ },
70
+ creator: {
71
+ uri: "test2",
72
+ prefLabel: { en: "name2" },
73
+ },
74
+ },
75
+ {
76
+ req: {
77
+ type: "annotations",
78
+ user: {
79
+ uri: "test",
80
+ identities: {
81
+ test2: {
82
+ uri: "test2",
83
+ name: "name2",
84
+ },
85
+ },
86
+ },
87
+ query: {
88
+ identity: "test2",
89
+ },
90
+ },
91
+ creator: {
92
+ id: "test2",
93
+ name: "name2",
94
+ },
95
+ },
96
+ {
97
+ req: {
98
+ user: {
99
+ uri: "test",
100
+ identities: {
101
+ test2: {
102
+ uri: "test2",
103
+ name: "name2",
104
+ },
105
+ },
106
+ },
107
+ query: {
108
+ identity: "test2",
109
+ identityName: "",
110
+ },
111
+ },
112
+ creator: {
113
+ uri: "test2",
114
+ },
115
+ },
116
+ {
117
+ req: {
118
+ query: {
119
+ identityName: "name",
120
+ },
121
+ },
122
+ creator: {
123
+ prefLabel: { en: "name" },
124
+ },
125
+ },
126
+ {
127
+ req: {
128
+ user: {
129
+ uri: "",
130
+ },
131
+ },
132
+ creator: null,
133
+ },
134
+ ]
135
+ let index = 0
136
+ for (let { req, creator: expected } of tests) {
137
+ it(`should pass test[${index}]`, async () => {
138
+ const actual = getCreator(Object.assign({ query: {} }, req))
139
+ // For non-annotations, creator should be an array if defined
140
+ assert.deepStrictEqual(actual, expected)
141
+ })
142
+ index += 1
143
+ }
144
+
145
+ it("should fail if req is undefined", async () => {
146
+ assert.throws(() => {
147
+ getCreator()
148
+ })
149
+ })
150
+
151
+ it("should fail if req.query is undefined", async () => {
152
+ assert.throws(() => {
153
+ getCreator({})
154
+ })
155
+ })
156
+
157
+ })
158
+
159
+ describe("handleCreatorForObject", () => {
160
+ const req = {
161
+ anonymous: false,
162
+ auth: true,
163
+ }
164
+ const reqWithMethod = (method) => Object.assign({ method }, req)
165
+ const tests = [
166
+ // Everything undefined
167
+ {
168
+ req,
169
+ },
170
+ // For null-ish values, keep the valye
171
+ {
172
+ object: null,
173
+ expected: null,
174
+ req,
175
+ },
176
+ // Everything empty
177
+ {
178
+ object: {},
179
+ expected: {},
180
+ req,
181
+ },
182
+ // No modifications without method/type
183
+ {
184
+ object: {
185
+ creator: "value doesn't matter",
186
+ },
187
+ expected: {
188
+ creator: "value doesn't matter",
189
+ },
190
+ req,
191
+ },
192
+ // Always remove contributor for annotations
193
+ {
194
+ object: {
195
+ contributor: "value doesn't matter",
196
+ },
197
+ expected: {},
198
+ req: Object.assign({
199
+ type: "annotations",
200
+ }, req),
201
+ },
202
+ // Set creator for POST
203
+ {
204
+ object: {},
205
+ creator: { uri: "test" },
206
+ req: reqWithMethod("POST"),
207
+ expected: {
208
+ creator: [{ uri: "test" }],
209
+ },
210
+ },
211
+ // Set creator for annotation
212
+ {
213
+ object: { creator: {} },
214
+ creator: { uri: "test" },
215
+ req: Object.assign({
216
+ type: "annotations",
217
+ }, reqWithMethod("PUT")),
218
+ expected: {
219
+ creator: { uri: "test" },
220
+ },
221
+ },
222
+ // Keep existing creator for PUT
223
+ {
224
+ object: {
225
+ creator: "some creator",
226
+ },
227
+ req: reqWithMethod("PUT"),
228
+ existing: {
229
+ creator: "creator",
230
+ contributor: "contributor",
231
+ },
232
+ expected: {
233
+ creator: "creator",
234
+ },
235
+ },
236
+ // Remove creator for PATCH
237
+ {
238
+ object: {
239
+ creator: "some creator",
240
+ },
241
+ req: reqWithMethod("PATCH"),
242
+ existing: {
243
+ creator: "creator",
244
+ },
245
+ expected: {},
246
+ },
247
+ // Add to contributor for PUT/PATCH
248
+ {
249
+ object: {},
250
+ req: reqWithMethod("PATCH"),
251
+ creator: {
252
+ uri: "test",
253
+ },
254
+ existing: {},
255
+ expected: {
256
+ contributor: [{ uri: "test" }],
257
+ },
258
+ },
259
+ {
260
+ object: {},
261
+ req: reqWithMethod("PUT"),
262
+ creator: {
263
+ uri: "test",
264
+ },
265
+ existing: {
266
+ creator: [{ uri: "other" }],
267
+ },
268
+ expected: {
269
+ creator: [{ uri: "other" }],
270
+ contributor: [{ uri: "test" }],
271
+ },
272
+ },
273
+ // Adjust existing creator entry
274
+ {
275
+ object: {},
276
+ req: Object.assign({
277
+ user: {
278
+ uri: "test",
279
+ identities: {
280
+ test: {
281
+ uri: "testAlternative",
282
+ },
283
+ },
284
+ },
285
+ }, reqWithMethod("PUT")),
286
+ creator: {
287
+ uri: "test",
288
+ prefLabel: { en: "name" },
289
+ },
290
+ existing: {
291
+ creator: [{ uri: "testAlternative" }],
292
+ },
293
+ expected: {
294
+ creator: [{
295
+ uri: "test",
296
+ prefLabel: { en: "name" },
297
+ }],
298
+ },
299
+ },
300
+ // Adjust existing contributor entry, push to end
301
+ {
302
+ object: {},
303
+ req: Object.assign({
304
+ user: {
305
+ uri: "test",
306
+ identities: {
307
+ test: {
308
+ uri: "testAlternative",
309
+ },
310
+ },
311
+ },
312
+ }, reqWithMethod("PUT")),
313
+ creator: { uri: "test" },
314
+ existing: {
315
+ creator: [{}],
316
+ contributor: [{ uri: "testAlternative" }, {}],
317
+ },
318
+ expected: {
319
+ creator: [{}],
320
+ contributor: [{}, { uri: "test" }],
321
+ },
322
+ },
323
+ // Allow any kind of values if auth is false
324
+ {
325
+ object: { creator: [{ uri: "abc " }], contributor: [] },
326
+ expected: { creator: [{ uri: "abc " }], contributor: [] },
327
+ req: Object.assign(reqWithMethod("POST"), { auth: false }),
328
+ // Should be ignored
329
+ creator: { uri: "test" },
330
+ },
331
+ // Always remove creator/contributor from payload when anonymous is true, but don't change existing values
332
+ {
333
+ object: { creator: [{ uri: "abc " }], contributor: [] },
334
+ existing: { creator: [{ uri: "def" }], contributor: [{ uri: "ghj" }] },
335
+ expected: { creator: [{ uri: "def" }], contributor: [{ uri: "ghj" }] },
336
+ req: Object.assign(reqWithMethod("PUT"), { anonymous: true }),
337
+ },
338
+ ]
339
+ let index = 0
340
+ for (let { expected, ...options } of tests) {
341
+ it(`should pass test[${index}]`, async () => {
342
+ const actual = handleCreatorForObject(Object.assign({ req: {} }, options))
343
+ // Should return object reference
344
+ assert.strictEqual(actual, options.object)
345
+ // Check if content is correct as well
346
+ assert.deepStrictEqual(actual, expected)
347
+ })
348
+ index += 1
349
+ }
350
+ })
351
+
352
+ describe("cleanJSON", () => {
353
+ const prevClosedWorldAssumption = config.closedWorldAssumption
354
+
355
+ const tests = [
356
+ {
357
+ closedWorldAssumption: false,
358
+ input: {
359
+ _a: 1,
360
+ b: {},
361
+ c: [],
362
+ d: 2,
363
+ },
364
+ output: {
365
+ d: 2,
366
+ },
367
+ },
368
+ {
369
+ closedWorldAssumption: true,
370
+ input: {
371
+ b: {},
372
+ c: [],
373
+ },
374
+ output: {
375
+ b: {},
376
+ c: [],
377
+ },
378
+ },
379
+ {
380
+ closedWorldAssumption: false,
381
+ input: [
382
+ {
383
+ _a: 1,
384
+ b: {},
385
+ c: [],
386
+ d: null,
387
+ },
388
+ ],
389
+ output: [
390
+ {
391
+ d: null,
392
+ },
393
+ ],
394
+ },
395
+ // Currently, only top-level properties are affected by closedWorldAssumption = false.
396
+ // See: https://github.com/gbv/jskos-server/commit/123dc9da09f1e41f2263ee8a0f7faeefe67fa9ed#r67204606
397
+ {
398
+ closedWorldAssumption: false,
399
+ input: {
400
+ a: {
401
+ b: {},
402
+ _b: 1,
403
+ },
404
+ },
405
+ output: {
406
+ a: {
407
+ b: {},
408
+ },
409
+ },
410
+ },
411
+ ]
412
+
413
+ let index = 0
414
+ for (let { closedWorldAssumption, input, output } of tests) {
415
+ it(`should pass test[${index}]`, async () => {
416
+ cleanJSON(input, 0, closedWorldAssumption)
417
+ assert.deepEqual(input, output)
418
+ })
419
+ index += 1
420
+ }
421
+
422
+ config.closedWorldAssumption = prevClosedWorldAssumption
423
+ })
424
+
425
+ })