adapt-authoring-api 2.1.4 → 3.0.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.
@@ -53,6 +53,8 @@ class NotesModule extends AbstractApiModule {
53
53
  export default NotesModule
54
54
  ```
55
55
 
56
+ > **Tip:** For new modules, consider defining routes in a `routes.json` file instead of calling `useDefaultRouteConfig()`. See [Custom routes](#custom-routes) for details.
57
+
56
58
  With a schema at `schema/note.schema.json`:
57
59
 
58
60
  ```json
@@ -2,6 +2,7 @@ import _ from 'lodash'
2
2
  import { AbstractModule, Hook, stringifyValues } from 'adapt-authoring-core'
3
3
  import { argsFromReq, generateApiMetadata, httpMethodToDBFunction } from './utils.js'
4
4
  import DataCache from './DataCache.js'
5
+ import { loadRouteConfig } from 'adapt-authoring-server'
5
6
  /**
6
7
  * Abstract module for creating APIs
7
8
  * @memberof api
@@ -11,11 +12,11 @@ class AbstractApiModule extends AbstractModule {
11
12
  get DEFAULT_ROUTES () {
12
13
  const readPerms = [`read:${this.permissionsScope || this.root}`]
13
14
  const writePerms = [`write:${this.permissionsScope || this.root}`]
14
- const handler = this.requestHandler()
15
+ const handler = this.requestHandler.bind(this)
15
16
  return [
16
17
  {
17
18
  route: '/',
18
- handlers: { post: handler, get: this.queryHandler() },
19
+ handlers: { post: handler, get: this.queryHandler.bind(this) },
19
20
  permissions: { post: writePerms, get: readPerms }
20
21
  },
21
22
  {
@@ -32,7 +33,7 @@ class AbstractApiModule extends AbstractModule {
32
33
  route: '/query',
33
34
  validate: false,
34
35
  modifying: false,
35
- handlers: { post: this.queryHandler() },
36
+ handlers: { post: this.queryHandler.bind(this) },
36
37
  permissions: { post: readPerms }
37
38
  }
38
39
  ]
@@ -149,6 +150,12 @@ class AbstractApiModule extends AbstractModule {
149
150
  * @type {String}
150
151
  */
151
152
  this.schemaName = undefined
153
+
154
+ const config = await loadRouteConfig(this.rootDir, this, {
155
+ schema: 'apiroutes',
156
+ defaults: new URL('./default-routes.json', import.meta.url).pathname
157
+ })
158
+ if (config) this.applyRouteConfig(config)
152
159
  }
153
160
 
154
161
  /**
@@ -189,6 +196,7 @@ class AbstractApiModule extends AbstractModule {
189
196
 
190
197
  /**
191
198
  * Checks required values have been set
199
+ * @deprecated Validation is handled by the routes schema for modules using routes.json
192
200
  */
193
201
  validateValues () {
194
202
  if (!this.root && !this.router) {
@@ -226,6 +234,46 @@ class AbstractApiModule extends AbstractModule {
226
234
  Object.values(uniqueRoutes).forEach(r => this.addRoute(r, auth))
227
235
  }
228
236
 
237
+ /**
238
+ * Applies route configuration loaded from routes.json.
239
+ * Resolves `${scope}`, `${schemaName}`, and `${collectionName}` placeholders
240
+ * throughout the route config (permissions, meta, etc.).
241
+ * @param {Object} config The route config object returned by loadRouteConfig
242
+ */
243
+ applyRouteConfig (config) {
244
+ /** @ignore */ this.root = config.root
245
+ if (config.schemaName !== undefined) this.schemaName = config.schemaName
246
+ if (config.collectionName !== undefined) this.collectionName = config.collectionName
247
+ /* eslint-disable no-template-curly-in-string */
248
+ const replacements = {
249
+ '${scope}': this.permissionsScope || this.root,
250
+ '${schemaName}': this.schemaName,
251
+ '${collectionName}': this.collectionName
252
+ }
253
+ /* eslint-enable no-template-curly-in-string */
254
+ this.routes = config.routes.map(r => this.replacePlaceholders(r, replacements))
255
+ }
256
+
257
+ /**
258
+ * Recursively replaces placeholder strings in an object tree.
259
+ * Non-string values (functions, numbers, booleans, null) pass through unchanged.
260
+ * @param {*} obj The value to process
261
+ * @param {Object<string,string>} replacements Map of placeholder to replacement value
262
+ * @returns {*} The value with all placeholders resolved
263
+ */
264
+ replacePlaceholders (obj, replacements) {
265
+ if (typeof obj === 'string') {
266
+ return Object.entries(replacements).reduce((s, [k, v]) => v != null ? s.replaceAll(k, v) : s, obj)
267
+ }
268
+ if (Array.isArray(obj)) return obj.map(item => this.replacePlaceholders(item, replacements))
269
+ if (obj && typeof obj === 'object' && obj.constructor === Object) {
270
+ return Object.fromEntries(
271
+ Object.entries(obj).map(([k, v]) => [k, this.replacePlaceholders(v, replacements)])
272
+ )
273
+ }
274
+ return obj
275
+ }
276
+
229
277
  /**
230
278
  * Adds a single route definition
231
279
  * @param {Route} config The route config
@@ -353,135 +401,129 @@ class AbstractApiModule extends AbstractModule {
353
401
  * Middleware to handle a generic API request. Supports POST, GET, PUT and DELETE of items in the database.
354
402
  * @return {Function} Express middleware function
355
403
  */
356
- requestHandler () {
357
- const requestHandler = async (req, res, next) => {
358
- const method = req.method.toLowerCase()
359
- const func = this[httpMethodToDBFunction(method)]
360
- if (!func) {
361
- return next(this.app.errors.HTTP_METHOD_NOT_SUPPORTED.setData({ method }))
362
- }
363
- let data
364
- try {
365
- await this.requestHook.invoke(req)
366
- const preCheck = method !== 'get' && method !== 'post'
367
- const postCheck = method === 'get'
368
- if (preCheck) {
369
- await this.checkAccess(req, req.apiData.query)
370
- }
371
- data = await func.apply(this, argsFromReq(req))
372
- if (postCheck) {
373
- data = await this.checkAccess(req, data)
374
- }
375
- data = await this.sanitise(req.apiData.schemaName, data, { isInternal: true, strict: false })
376
- } catch (e) {
377
- return next(e)
404
+ async requestHandler (req, res, next) {
405
+ const method = req.method.toLowerCase()
406
+ const func = this[httpMethodToDBFunction(method)]
407
+ if (!func) {
408
+ return next(this.app.errors.HTTP_METHOD_NOT_SUPPORTED.setData({ method }))
409
+ }
410
+ let data
411
+ try {
412
+ await this.requestHook.invoke(req)
413
+ const preCheck = method !== 'get' && method !== 'post'
414
+ const postCheck = method === 'get'
415
+ if (preCheck) {
416
+ await this.checkAccess(req, req.apiData.query)
378
417
  }
379
- if (Array.isArray(data) && req.params._id) { // special case for when _id param is present
380
- if (!data.length) {
381
- return next(this.app.errors.NOT_FOUND.setData({ id: req.params.id, type: req.apiData.schemaName }))
382
- }
383
- data = data[0]
418
+ data = await func.apply(this, argsFromReq(req))
419
+ if (postCheck) {
420
+ data = await this.checkAccess(req, data)
384
421
  }
385
- if (method !== 'get') {
386
- const resource = Array.isArray(data) ? req.apiData.query : data._id.toString()
387
- this.log('debug', `API_${func.name.toUpperCase()}`, resource, 'by', req.auth.user._id.toString())
422
+ data = await this.sanitise(req.apiData.schemaName, data, { isInternal: true, strict: false })
423
+ } catch (e) {
424
+ return next(e)
425
+ }
426
+ if (Array.isArray(data) && req.params._id) { // special case for when _id param is present
427
+ if (!data.length) {
428
+ return next(this.app.errors.NOT_FOUND.setData({ id: req.params.id, type: req.apiData.schemaName }))
388
429
  }
389
- res.status(this.mapStatusCode(method)).json(data)
430
+ data = data[0]
431
+ }
432
+ if (method !== 'get') {
433
+ const resource = Array.isArray(data) ? req.apiData.query : data._id.toString()
434
+ this.log('debug', `API_${func.name.toUpperCase()}`, resource, 'by', req.auth.user._id.toString())
390
435
  }
391
- return requestHandler
436
+ res.status(this.mapStatusCode(method)).json(data)
392
437
  }
393
438
 
394
439
  /**
395
440
  * Express request handler for advanced API queries. Supports collation/limit/page/skip/sort and pagination. For incoming query data to be correctly parsed, it must be sent as body data using a POST request.
396
441
  * @return {function}
397
442
  */
398
- queryHandler () {
399
- const queryHandler = async (req, res, next) => {
400
- try {
401
- const opts = {
402
- schemaName: req.apiData.schemaName,
403
- collectionName: req.apiData.collectionName
404
- }
405
- const mongoOpts = {}
406
- // find and remove mongo options from the query
407
- Object.entries(req.apiData.query).forEach(([key, val]) => {
408
- if (['collation', 'limit', 'page', 'skip', 'sort'].includes(key)) {
409
- try {
410
- mongoOpts[key] = JSON.parse(req.apiData.query[key])
411
- } catch (e) {
412
- this.log('warn', `failed to parse query ${key} param '${mongoOpts[key]}', ${e}`)
413
- }
414
- delete req.apiData.query[key]
415
- } else {
416
- // otherwise assume we have a query field or option and store for later processing
417
- opts[key] = val
418
- }
419
- })
420
- // handle search parameter
421
- const search = req.apiData.query.search
422
- if (search) {
423
- delete req.apiData.query.search
443
+ async queryHandler (req, res, next) {
444
+ try {
445
+ const opts = {
446
+ schemaName: req.apiData.schemaName,
447
+ collectionName: req.apiData.collectionName
448
+ }
449
+ const mongoOpts = {}
450
+ // find and remove mongo options from the query
451
+ Object.entries(req.apiData.query).forEach(([key, val]) => {
452
+ if (['collation', 'limit', 'page', 'skip', 'sort'].includes(key)) {
424
453
  try {
425
- const schema = await this.getSchema(req.apiData.schemaName)
426
- if (schema && schema.built && schema.built.properties) {
427
- const searchableFields = Object.keys(schema.built.properties).filter(
428
- field => schema.built.properties[field].isSearchable === true
429
- )
430
- if (searchableFields.length) {
431
- // escape special regex characters to prevent ReDoS attacks
432
- const escapedSearch = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
433
- const regex = { $regex: escapedSearch, $options: 'i' }
434
- const searchConditions = searchableFields.map(f => ({ [f]: regex }))
435
- // merge with existing $or if present
436
- if (req.apiData.query.$or) {
437
- req.apiData.query.$and = [
438
- { $or: req.apiData.query.$or },
439
- { $or: searchConditions }
440
- ]
441
- delete req.apiData.query.$or
442
- } else {
443
- req.apiData.query.$or = searchConditions
444
- }
454
+ mongoOpts[key] = JSON.parse(req.apiData.query[key])
455
+ } catch (e) {
456
+ this.log('warn', `failed to parse query ${key} param '${mongoOpts[key]}', ${e}`)
457
+ }
458
+ delete req.apiData.query[key]
459
+ } else {
460
+ // otherwise assume we have a query field or option and store for later processing
461
+ opts[key] = val
462
+ }
463
+ })
464
+ // handle search parameter
465
+ const search = req.apiData.query.search
466
+ if (search) {
467
+ delete req.apiData.query.search
468
+ try {
469
+ const schema = await this.getSchema(req.apiData.schemaName)
470
+ if (schema && schema.built && schema.built.properties) {
471
+ const searchableFields = Object.keys(schema.built.properties).filter(
472
+ field => schema.built.properties[field].isSearchable === true
473
+ )
474
+ if (searchableFields.length) {
475
+ // escape special regex characters to prevent ReDoS attacks
476
+ const escapedSearch = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
477
+ const regex = { $regex: escapedSearch, $options: 'i' }
478
+ const searchConditions = searchableFields.map(f => ({ [f]: regex }))
479
+ // merge with existing $or if present
480
+ if (req.apiData.query.$or) {
481
+ req.apiData.query.$and = [
482
+ { $or: req.apiData.query.$or },
483
+ { $or: searchConditions }
484
+ ]
485
+ delete req.apiData.query.$or
486
+ } else {
487
+ req.apiData.query.$or = searchConditions
445
488
  }
446
489
  }
447
- } catch (e) {
448
- this.log('warn', `failed to process search parameter, ${e.message}`)
449
490
  }
491
+ } catch (e) {
492
+ this.log('warn', `failed to process search parameter, ${e.message}`)
450
493
  }
451
- req.apiData.query = await this.parseQuery(req.apiData.schemaName, req.body, mongoOpts)
452
- // remove any valid query keys from the options
453
- Object.keys(req.apiData.query).forEach(key => delete opts[key])
494
+ }
495
+ req.apiData.query = await this.parseQuery(req.apiData.schemaName, req.body, mongoOpts)
496
+ // remove any valid query keys from the options
497
+ Object.keys(req.apiData.query).forEach(key => delete opts[key])
454
498
 
455
- await this.requestHook.invoke(req)
499
+ await this.requestHook.invoke(req)
456
500
 
457
- await this.setUpPagination(req, res, mongoOpts)
501
+ await this.setUpPagination(req, res, mongoOpts)
458
502
 
459
- let results = await this.find(req.apiData.query, opts, mongoOpts)
503
+ let results = await this.find(req.apiData.query, opts, mongoOpts)
460
504
 
461
- results = await this.checkAccess(req, results)
505
+ results = await this.checkAccess(req, results)
462
506
 
463
- // If checkAccess filtered some results, fetch more to fill the page
464
- const pageSize = mongoOpts.limit
465
- if (pageSize && results.length < pageSize) {
466
- let fetchSkip = mongoOpts.skip + pageSize
467
- while (results.length < pageSize) {
468
- const extra = await this.find(req.apiData.query, opts, { ...mongoOpts, skip: fetchSkip })
469
- if (!extra.length) break
470
- const filtered = await this.checkAccess(req, extra)
471
- results = results.concat(filtered)
472
- fetchSkip += extra.length
473
- }
474
- if (results.length > pageSize) results = results.slice(0, pageSize)
507
+ // If checkAccess filtered some results, fetch more to fill the page
508
+ const pageSize = mongoOpts.limit
509
+ if (pageSize && results.length < pageSize) {
510
+ let fetchSkip = mongoOpts.skip + pageSize
511
+ while (results.length < pageSize) {
512
+ const extra = await this.find(req.apiData.query, opts, { ...mongoOpts, skip: fetchSkip })
513
+ if (!extra.length) break
514
+ const filtered = await this.checkAccess(req, extra)
515
+ results = results.concat(filtered)
516
+ fetchSkip += extra.length
475
517
  }
518
+ if (results.length > pageSize) results = results.slice(0, pageSize)
519
+ }
476
520
 
477
- results = await this.sanitise(req.apiData.schemaName, results, { isInternal: true, strict: false })
521
+ results = await this.sanitise(req.apiData.schemaName, results, { isInternal: true, strict: false })
478
522
 
479
- res.status(this.mapStatusCode('get')).json(results)
480
- } catch (e) {
481
- return next(e)
482
- }
523
+ res.status(this.mapStatusCode('get')).json(results)
524
+ } catch (e) {
525
+ return next(e)
483
526
  }
484
- return queryHandler
485
527
  }
486
528
 
487
529
  /**
@@ -0,0 +1,148 @@
1
+ {
2
+ "routes": [
3
+ {
4
+ "route": "/",
5
+ "handlers": { "post": "requestHandler", "get": "queryHandler" },
6
+ "permissions": { "post": ["write:${scope}"], "get": ["read:${scope}"] },
7
+ "meta": {
8
+ "post": {
9
+ "summary": "Insert a new ${schemaName} document",
10
+ "requestBody": {
11
+ "content": {
12
+ "application/json": {
13
+ "schema": { "$ref": "#/components/schemas/${schemaName}" }
14
+ }
15
+ }
16
+ },
17
+ "responses": {
18
+ "201": {
19
+ "description": "The created ${schemaName} document",
20
+ "content": {
21
+ "application/json": {
22
+ "schema": { "$ref": "#/components/schemas/${schemaName}" }
23
+ }
24
+ }
25
+ }
26
+ }
27
+ },
28
+ "get": {
29
+ "summary": "Retrieve all ${collectionName} documents",
30
+ "parameters": [
31
+ { "name": "limit", "in": "query", "description": "How many results to return" },
32
+ { "name": "page", "in": "query", "description": "The page of results to return" }
33
+ ],
34
+ "responses": {
35
+ "200": {
36
+ "description": "List of ${schemaName} documents",
37
+ "content": {
38
+ "application/json": {
39
+ "schema": { "type": "array", "items": { "$ref": "#/components/schemas/${schemaName}" } }
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ }
46
+ },
47
+ {
48
+ "route": "/schema",
49
+ "handlers": { "get": "serveSchema" },
50
+ "permissions": { "get": ["read:schema"] },
51
+ "meta": {
52
+ "get": { "summary": "Retrieve ${schemaName} schema" }
53
+ }
54
+ },
55
+ {
56
+ "route": "/:_id",
57
+ "handlers": { "put": "requestHandler", "get": "requestHandler", "patch": "requestHandler", "delete": "requestHandler" },
58
+ "permissions": { "put": ["write:${scope}"], "get": ["read:${scope}"], "patch": ["write:${scope}"], "delete": ["write:${scope}"] },
59
+ "meta": {
60
+ "put": {
61
+ "summary": "Replace an existing ${schemaName} document",
62
+ "requestBody": {
63
+ "content": {
64
+ "application/json": {
65
+ "schema": { "$ref": "#/components/schemas/${schemaName}" }
66
+ }
67
+ }
68
+ },
69
+ "responses": {
70
+ "200": {
71
+ "description": "The updated ${schemaName} document",
72
+ "content": {
73
+ "application/json": {
74
+ "schema": { "$ref": "#/components/schemas/${schemaName}" }
75
+ }
76
+ }
77
+ }
78
+ }
79
+ },
80
+ "get": {
81
+ "summary": "Retrieve an existing ${schemaName} document",
82
+ "responses": {
83
+ "200": {
84
+ "description": "The ${schemaName} document",
85
+ "content": {
86
+ "application/json": {
87
+ "schema": { "$ref": "#/components/schemas/${schemaName}" }
88
+ }
89
+ }
90
+ }
91
+ }
92
+ },
93
+ "patch": {
94
+ "summary": "Update an existing ${schemaName} document",
95
+ "requestBody": {
96
+ "content": {
97
+ "application/json": {
98
+ "schema": { "$ref": "#/components/schemas/${schemaName}" }
99
+ }
100
+ }
101
+ },
102
+ "responses": {
103
+ "200": {
104
+ "description": "The updated ${schemaName} document",
105
+ "content": {
106
+ "application/json": {
107
+ "schema": { "$ref": "#/components/schemas/${schemaName}" }
108
+ }
109
+ }
110
+ }
111
+ }
112
+ },
113
+ "delete": {
114
+ "summary": "Delete an existing ${schemaName} document",
115
+ "responses": {
116
+ "204": { "description": "Document deleted successfully" }
117
+ }
118
+ }
119
+ }
120
+ },
121
+ {
122
+ "route": "/query",
123
+ "validate": false,
124
+ "modifying": false,
125
+ "handlers": { "post": "queryHandler" },
126
+ "permissions": { "post": ["read:${scope}"] },
127
+ "meta": {
128
+ "post": {
129
+ "summary": "Query all ${collectionName}",
130
+ "parameters": [
131
+ { "name": "limit", "in": "query", "description": "How many results to return" },
132
+ { "name": "page", "in": "query", "description": "The page of results to return" }
133
+ ],
134
+ "responses": {
135
+ "200": {
136
+ "description": "List of ${schemaName} documents",
137
+ "content": {
138
+ "application/json": {
139
+ "schema": { "type": "array", "items": { "$ref": "#/components/schemas/${schemaName}" } }
140
+ }
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+ }
147
+ ]
148
+ }
@@ -2,6 +2,7 @@
2
2
  * Generates REST API metadata and stores on route config
3
3
  * @param {AbstractApiModule} instance The current AbstractApiModule instance
4
4
  * @memberof api
5
+ * @deprecated For modules with routes.json, define metadata in the meta field of each route entry instead
5
6
  */
6
7
  export function generateApiMetadata (instance) {
7
8
  const getData = isList => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-api",
3
- "version": "2.1.4",
3
+ "version": "3.0.0",
4
4
  "description": "Abstract module for creating APIs",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-api",
6
6
  "license": "GPL-3.0",
@@ -18,10 +18,11 @@
18
18
  "adapt-authoring-auth": "^2.0.0",
19
19
  "adapt-authoring-jsonschema": "^1.2.0",
20
20
  "adapt-authoring-mongodb": "^3.0.0",
21
- "adapt-authoring-server": "^2.0.0"
21
+ "adapt-authoring-server": "^2.1.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@semantic-release/git": "^10.0.1",
25
+ "adapt-authoring-server": "^2.1.0",
25
26
  "conventional-changelog-eslint": "^6.0.0",
26
27
  "semantic-release": "^25.0.2",
27
28
  "standard": "^17.1.0"
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$anchor": "apiroutes",
4
+ "$merge": {
5
+ "source": { "$ref": "routes" },
6
+ "with": {
7
+ "properties": {
8
+ "schemaName": {
9
+ "type": "string",
10
+ "description": "Schema name for the module's data model"
11
+ },
12
+ "collectionName": {
13
+ "type": "string",
14
+ "description": "MongoDB collection name"
15
+ },
16
+ "useDefaultRoutes": {
17
+ "type": "boolean",
18
+ "description": "Whether to generate default CRUD routes",
19
+ "default": true
20
+ },
21
+ "routes": {
22
+ "type": "array",
23
+ "items": { "$ref": "routeitem" }
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }
@@ -2,6 +2,20 @@ import { describe, it } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import AbstractApiModule from '../lib/AbstractApiModule.js'
4
4
 
5
+ function createInstance (overrides = {}) {
6
+ const instance = Object.create(AbstractApiModule.prototype)
7
+ instance.root = 'test'
8
+ instance.permissionsScope = undefined
9
+ instance.schemaName = undefined
10
+ instance.collectionName = undefined
11
+ instance.routes = []
12
+ instance.requestHandler = function defaultRequestHandler () {}
13
+ instance.queryHandler = function queryHandler () {}
14
+ instance.serveSchema = function serveSchema () {}
15
+ Object.assign(instance, overrides)
16
+ return instance
17
+ }
18
+
5
19
  describe('AbstractApiModule', () => {
6
20
  describe('#mapStatusCode()', () => {
7
21
  const instance = Object.create(AbstractApiModule.prototype)
@@ -56,4 +70,112 @@ describe('AbstractApiModule', () => {
56
70
  assert.equal(options, undefined)
57
71
  })
58
72
  })
73
+
74
+ describe('#applyRouteConfig()', () => {
75
+ it('should set root, schemaName, and collectionName from config', async () => {
76
+ const instance = createInstance()
77
+ await instance.applyRouteConfig({
78
+ root: 'content',
79
+ schemaName: 'content',
80
+ collectionName: 'content',
81
+ useDefaultRoutes: false,
82
+ routes: []
83
+ })
84
+ assert.equal(instance.root, 'content')
85
+ assert.equal(instance.schemaName, 'content')
86
+ assert.equal(instance.collectionName, 'content')
87
+ })
88
+
89
+ it('should not override schemaName or collectionName when not in config', async () => {
90
+ const instance = createInstance({ schemaName: 'existing', collectionName: 'existing' })
91
+ await instance.applyRouteConfig({ root: 'content', useDefaultRoutes: false, routes: [] })
92
+ assert.equal(instance.schemaName, 'existing')
93
+ assert.equal(instance.collectionName, 'existing')
94
+ })
95
+
96
+ it('should set routes to custom routes only when useDefaultRoutes is false', async () => {
97
+ const instance = createInstance()
98
+ const customRoute = { route: '/custom', handlers: { get: () => {} } }
99
+ await instance.applyRouteConfig({ root: 'test', useDefaultRoutes: false, routes: [customRoute] })
100
+ assert.equal(instance.routes.length, 1)
101
+ assert.equal(instance.routes[0].route, '/custom')
102
+ })
103
+
104
+ it('should set routes to empty array when config has no routes', async () => {
105
+ const instance = createInstance()
106
+ await instance.applyRouteConfig({ root: 'test', routes: [] })
107
+ assert.deepEqual(instance.routes, [])
108
+ })
109
+
110
+ it('should expand ${scope} placeholders in permissions using root', async () => { // eslint-disable-line no-template-curly-in-string
111
+ const instance = createInstance({ root: 'content' })
112
+ await instance.applyRouteConfig({
113
+ root: 'content',
114
+ routes: [{ route: '/', permissions: { get: ['read:${scope}'], post: ['write:${scope}'] } }] // eslint-disable-line no-template-curly-in-string
115
+ })
116
+ assert.deepEqual(instance.routes[0].permissions.get, ['read:content'])
117
+ assert.deepEqual(instance.routes[0].permissions.post, ['write:content'])
118
+ })
119
+
120
+ it('should prefer permissionsScope over root for ${scope} expansion', async () => { // eslint-disable-line no-template-curly-in-string
121
+ const instance = createInstance({ root: 'content', permissionsScope: 'custom' })
122
+ await instance.applyRouteConfig({
123
+ root: 'content',
124
+ routes: [{ route: '/', permissions: { get: ['read:${scope}'] } }] // eslint-disable-line no-template-curly-in-string
125
+ })
126
+ assert.deepEqual(instance.routes[0].permissions.get, ['read:custom'])
127
+ })
128
+
129
+ it('should pass through null permissions unchanged', async () => {
130
+ const instance = createInstance({ root: 'content' })
131
+ await instance.applyRouteConfig({
132
+ root: 'content',
133
+ routes: [{ route: '/', permissions: { get: null } }]
134
+ })
135
+ assert.equal(instance.routes[0].permissions.get, null)
136
+ })
137
+ })
138
+
139
+ describe('#DEFAULT_ROUTES', () => {
140
+ it('should return an array of route objects', async () => {
141
+ const instance = createInstance()
142
+ const routes = await instance.DEFAULT_ROUTES
143
+ assert.ok(Array.isArray(routes))
144
+ assert.ok(routes.length > 0)
145
+ })
146
+
147
+ it('should include routes for /, /schema, /:_id, and /query', async () => {
148
+ const instance = createInstance()
149
+ const routes = await instance.DEFAULT_ROUTES
150
+ const routePaths = routes.map(r => r.route)
151
+ assert.ok(routePaths.includes('/'))
152
+ assert.ok(routePaths.includes('/schema'))
153
+ assert.ok(routePaths.includes('/:_id'))
154
+ assert.ok(routePaths.includes('/query'))
155
+ })
156
+
157
+ it('should use permissionsScope when set', async () => {
158
+ const instance = createInstance({ root: 'content', permissionsScope: 'custom' })
159
+ const routes = await instance.DEFAULT_ROUTES
160
+ const rootRoute = routes.find(r => r.route === '/')
161
+ assert.ok(rootRoute.permissions.post.includes('write:custom'))
162
+ assert.ok(rootRoute.permissions.get.includes('read:custom'))
163
+ })
164
+
165
+ it('should fall back to root for permissions when permissionsScope is not set', async () => {
166
+ const instance = createInstance({ root: 'content', permissionsScope: undefined })
167
+ const routes = await instance.DEFAULT_ROUTES
168
+ const rootRoute = routes.find(r => r.route === '/')
169
+ assert.ok(rootRoute.permissions.post.includes('write:content'))
170
+ assert.ok(rootRoute.permissions.get.includes('read:content'))
171
+ })
172
+
173
+ it('should set validate: false and modifying: false on /query route', async () => {
174
+ const instance = createInstance()
175
+ const routes = await instance.DEFAULT_ROUTES
176
+ const queryRoute = routes.find(r => r.route === '/query')
177
+ assert.equal(queryRoute.validate, false)
178
+ assert.equal(queryRoute.modifying, false)
179
+ })
180
+ })
59
181
  })