json-rest-api 1.0.10 → 1.0.11
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/.claude/settings.local.json +9 -1
- package/AGENTS.md +34 -0
- package/CLAUDE.md +26 -1
- package/CRUSH.md +55 -0
- package/README.md +3 -3
- package/TODO.todo +8 -0
- package/docs/API.md +17 -18
- package/docs/COMPARISON.md +18 -13
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +17 -21
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +33 -2
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +61 -7
- package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +58 -61
- package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +37 -37
- package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +103 -87
- package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +37 -34
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +45 -42
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +3 -3
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +1 -1
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +5 -5
- package/docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md +498 -755
- package/docs/GUIDE/GUIDE_6_Positioning.md +1 -3
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +4 -5
- package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +15 -6
- package/docs/GUIDE/GUIDE_X_Cors.md +2 -5
- package/docs/GUIDE/GUIDE_X_File_Uploads.md +2 -3
- package/docs/GUIDE/GUIDE_X_Multihome.md +1 -4
- package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -2
- package/docs/GUIDE/GUIDE_X_URL_Management.md +317 -0
- package/docs/GUIDE/index.md +3 -0
- package/docs/QUICKSTART.md +12 -12
- package/docs/index.md +2 -2
- package/docs/mini-guides/file-uploads-complete.md +0 -1
- package/examples/url-override-example.js +137 -0
- package/index.js +7 -1
- package/package.json +5 -9
- package/plugins/core/bulk-operations-plugin.js +2 -0
- package/plugins/core/connectors/express-plugin.js +43 -24
- package/plugins/core/connectors/lib/request-helpers.js +2 -2
- package/plugins/core/lib/anyapi/anyapi-registry.js +821 -0
- package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +647 -0
- package/plugins/core/lib/anyapi/schema-utils.js +186 -0
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +113 -0
- package/plugins/core/lib/dbTablesOperations.js +305 -0
- package/plugins/core/lib/querying/knex-cross-table-search.js +64 -108
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +21 -27
- package/plugins/core/lib/querying/knex-pagination-helpers.js +36 -20
- package/plugins/core/lib/querying/knex-query-helpers-base.js +49 -14
- package/plugins/core/lib/querying/knex-query-helpers.js +378 -222
- package/plugins/core/lib/querying/knex-relationship-includes.js +262 -87
- package/plugins/core/lib/querying/url-helpers.js +119 -0
- package/plugins/core/lib/querying-writing/compile-schemas.js +36 -28
- package/plugins/core/lib/querying-writing/database-capabilities.js +10 -9
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +9 -3
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +2 -2
- package/plugins/core/lib/querying-writing/payload-validators.js +10 -16
- package/plugins/core/lib/querying-writing/schema-helpers.js +52 -60
- package/plugins/core/lib/querying-writing/scope-validations.js +78 -22
- package/plugins/core/lib/querying-writing/simplified-helpers.js +208 -61
- package/plugins/core/lib/storage/storage-adapter.js +160 -0
- package/plugins/core/lib/writing/relationship-processor.js +4 -11
- package/plugins/core/rest-api-access.js +456 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +2285 -0
- package/plugins/core/rest-api-knex-plugin.js +226 -87
- package/plugins/core/rest-api-multihome-plugin.js +89 -27
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +0 -1
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +1 -5
- package/plugins/core/rest-api-plugin-methods/common.js +46 -10
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +31 -27
- package/plugins/core/rest-api-plugin-methods/delete.js +1 -4
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +13 -1
- package/plugins/core/rest-api-plugin-methods/get-related.js +114 -56
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +4 -3
- package/plugins/core/rest-api-plugin-methods/get.js +2 -5
- package/plugins/core/rest-api-plugin-methods/patch.js +15 -16
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +21 -14
- package/plugins/core/rest-api-plugin-methods/post.js +23 -11
- package/plugins/core/rest-api-plugin-methods/put.js +18 -21
- package/plugins/core/rest-api-plugin-methods/query.js +3 -4
- package/plugins/core/rest-api-plugin-methods/release.js +13 -0
- package/plugins/core/rest-api-plugin.js +3 -4
- package/plugins/core/rest-api-positioning-plugin.js +253 -76
- package/plugins/core/socketio-plugin.js +436 -502
- package/quickTest.js +2 -2
- package/scripts/debug-anyapi-review-filter.mjs +63 -0
- package/scripts/inspect-anyapi-descriptor-country.mjs +19 -0
- package/scripts/inspect-anyapi-descriptor.mjs +19 -0
- package/test-debug.js +59 -0
- package/tests/README.md +7 -1
- package/tests/anyapi-basic.test.js +102 -0
- package/tests/anyapi-cursor-pagination.test.js +144 -0
- package/tests/computed-fields.test.js +27 -12
- package/tests/cors.test.js +5 -6
- package/tests/curl.test.js +1 -1
- package/tests/custom-idproperty-relationships.test.js +37 -29
- package/tests/field-getters.test.js +25 -7
- package/tests/fixtures/api-configs.js +576 -150
- package/tests/helpers/storage-mode.js +65 -0
- package/tests/helpers/test-utils.js +85 -3
- package/tests/include-depth-validation.test.js +35 -1
- package/tests/nested-includes.test.js +130 -103
- package/tests/pagination-cursor-multifield.test.js +17 -13
- package/tests/pagination-enhanced.test.js +19 -24
- package/tests/polymorphic-simple.test.js +9 -9
- package/tests/positioning-advanced.test.js +19 -19
- package/tests/positioning-extended.test.js +25 -25
- package/tests/positioning-multifilter.test.js +62 -62
- package/tests/positioning.test.js +7 -7
- package/tests/queries.test.js +5 -5
- package/tests/query-limits.test.js +77 -92
- package/tests/rest-api-access.test.js +206 -0
- package/tests/searchschema-merge.test.js +182 -0
- package/tests/socketio.test.js +33 -14
- package/tests/virtual-fields.test.js +0 -1
- package/docs/DOCS_ALL.md +0 -21894
- package/plugins/core/jwt-auth-plugin.js +0 -897
- package/plugins/core/lib/jwt-auth-helpers.js +0 -130
- package/tests/jwt-auth.test.js +0 -692
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
cleanTables,
|
|
9
9
|
countRecords,
|
|
10
10
|
createJsonApiDocument,
|
|
11
|
+
createRelationship,
|
|
11
12
|
validateJsonApiStructure
|
|
12
13
|
} from './helpers/test-utils.js';
|
|
13
14
|
|
|
@@ -61,7 +62,8 @@ describe('Query Limits and Include Limits', () => {
|
|
|
61
62
|
},
|
|
62
63
|
relationships: {
|
|
63
64
|
books: {
|
|
64
|
-
|
|
65
|
+
type: 'hasMany',
|
|
66
|
+
target: 'books',
|
|
65
67
|
foreignKey: 'publisher_id',
|
|
66
68
|
// Relationships are always includable via ?include=
|
|
67
69
|
include: {
|
|
@@ -81,7 +83,7 @@ describe('Query Limits and Include Limits', () => {
|
|
|
81
83
|
},
|
|
82
84
|
relationships: {
|
|
83
85
|
books: {
|
|
84
|
-
|
|
86
|
+
type: 'manyToMany',
|
|
85
87
|
through: 'book_authors',
|
|
86
88
|
foreignKey: 'author_id',
|
|
87
89
|
otherKey: 'book_id',
|
|
@@ -104,13 +106,13 @@ describe('Query Limits and Include Limits', () => {
|
|
|
104
106
|
},
|
|
105
107
|
relationships: {
|
|
106
108
|
authors: {
|
|
107
|
-
|
|
109
|
+
type: 'manyToMany',
|
|
108
110
|
through: 'book_authors',
|
|
109
111
|
foreignKey: 'book_id',
|
|
110
|
-
otherKey: 'author_id'
|
|
112
|
+
otherKey: 'author_id'
|
|
111
113
|
// Relationships are always includable via ?include=
|
|
112
114
|
},
|
|
113
|
-
reviews: { hasMany: 'reviews', via: 'reviewable' }
|
|
115
|
+
reviews: { type: 'hasMany', target: 'reviews', via: 'reviewable' }
|
|
114
116
|
}
|
|
115
117
|
});
|
|
116
118
|
await api.resources.books.createKnexTable();
|
|
@@ -215,29 +217,21 @@ describe('Query Limits and Include Limits', () => {
|
|
|
215
217
|
});
|
|
216
218
|
|
|
217
219
|
const publisher = await api.resources.publishers.post({
|
|
218
|
-
inputRecord: {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
country_id: parseInt(country.id)
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
220
|
+
inputRecord: createJsonApiDocument('publishers', {
|
|
221
|
+
name: 'Test Publisher'
|
|
222
|
+
}, {
|
|
223
|
+
country: createRelationship({ type: 'countries', id: String(country.id) })
|
|
224
|
+
})
|
|
227
225
|
});
|
|
228
226
|
|
|
229
227
|
// Create 25 books for the publisher
|
|
230
228
|
for (let i = 1; i <= 25; i++) {
|
|
231
229
|
await api.resources.books.post({
|
|
232
|
-
inputRecord: {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
publisher_id: parseInt(publisher.id)
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
230
|
+
inputRecord: createJsonApiDocument('books', {
|
|
231
|
+
title: `Book ${i}`
|
|
232
|
+
}, {
|
|
233
|
+
publisher: createRelationship({ type: 'publishers', id: String(publisher.id) })
|
|
234
|
+
})
|
|
241
235
|
});
|
|
242
236
|
}
|
|
243
237
|
});
|
|
@@ -262,46 +256,47 @@ describe('Query Limits and Include Limits', () => {
|
|
|
262
256
|
});
|
|
263
257
|
|
|
264
258
|
it('should respect explicit limit in relationship config', async () => {
|
|
265
|
-
// Create a
|
|
259
|
+
// Create a country first
|
|
260
|
+
const country = await api.resources.countries.post({
|
|
261
|
+
inputRecord: createJsonApiDocument('countries', {
|
|
262
|
+
name: 'Test Country',
|
|
263
|
+
code: 'TC'
|
|
264
|
+
})
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Create a publisher
|
|
266
268
|
const publisher = await api.resources.publishers.post({
|
|
267
269
|
inputRecord: createJsonApiDocument('publishers', {
|
|
268
|
-
name: 'Test Publisher'
|
|
269
|
-
|
|
270
|
+
name: 'Test Publisher'
|
|
271
|
+
}, {
|
|
272
|
+
country: createRelationship({ type: 'countries', id: String(country.id) })
|
|
270
273
|
})
|
|
271
274
|
});
|
|
272
275
|
|
|
273
276
|
// Create authors with many books
|
|
274
277
|
const author = await api.resources.authors.post({
|
|
275
|
-
inputRecord: {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
country_id: 1
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
278
|
+
inputRecord: createJsonApiDocument('authors', {
|
|
279
|
+
name: 'Test Author'
|
|
280
|
+
}, {
|
|
281
|
+
country: createRelationship({ type: 'countries', id: String(country.id) })
|
|
282
|
+
})
|
|
284
283
|
});
|
|
285
284
|
|
|
286
285
|
// Create 10 books and associate with author
|
|
287
286
|
for (let i = 1; i <= 10; i++) {
|
|
288
287
|
const book = await api.resources.books.post({
|
|
289
|
-
inputRecord: {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
publisher_id: parseInt(publisher.id)
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
288
|
+
inputRecord: createJsonApiDocument('books', {
|
|
289
|
+
title: `Book ${i}`
|
|
290
|
+
}, {
|
|
291
|
+
publisher: createRelationship({ type: 'publishers', id: String(publisher.id) })
|
|
292
|
+
})
|
|
298
293
|
});
|
|
299
294
|
|
|
300
295
|
await api.resources.book_authors.post({
|
|
301
|
-
inputRecord: createJsonApiDocument('book_authors', {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
296
|
+
inputRecord: createJsonApiDocument('book_authors', {}, {
|
|
297
|
+
book: createRelationship({ type: 'books', id: String(book.id) }),
|
|
298
|
+
author: createRelationship({ type: 'authors', id: String(author.id) })
|
|
299
|
+
})
|
|
305
300
|
});
|
|
306
301
|
}
|
|
307
302
|
|
|
@@ -331,9 +326,10 @@ describe('Query Limits and Include Limits', () => {
|
|
|
331
326
|
// Create a publisher with country
|
|
332
327
|
const publisher = await api.resources.publishers.post({
|
|
333
328
|
inputRecord: createJsonApiDocument('publishers', {
|
|
334
|
-
name: 'Test Publisher'
|
|
335
|
-
|
|
336
|
-
|
|
329
|
+
name: 'Test Publisher'
|
|
330
|
+
}, {
|
|
331
|
+
country: createRelationship({ type: 'countries', id: String(country.id) })
|
|
332
|
+
})
|
|
337
333
|
});
|
|
338
334
|
|
|
339
335
|
// Create books with reviews
|
|
@@ -419,7 +415,8 @@ describe('Query Limits and Include Limits', () => {
|
|
|
419
415
|
},
|
|
420
416
|
relationships: {
|
|
421
417
|
subitems: {
|
|
422
|
-
|
|
418
|
+
type: 'hasMany',
|
|
419
|
+
target: 'subitems',
|
|
423
420
|
foreignKey: 'test_item_id',
|
|
424
421
|
include: {
|
|
425
422
|
limit: 25 // Exceeds queryMaxLimit of 20
|
|
@@ -428,7 +425,9 @@ describe('Query Limits and Include Limits', () => {
|
|
|
428
425
|
}
|
|
429
426
|
});
|
|
430
427
|
},
|
|
431
|
-
|
|
428
|
+
(err) => {
|
|
429
|
+
return err.message && err.message.includes('limit') && err.message.includes('exceeds queryMaxLimit');
|
|
430
|
+
},
|
|
432
431
|
'Should throw error for excessive include limit'
|
|
433
432
|
);
|
|
434
433
|
});
|
|
@@ -437,7 +436,7 @@ describe('Query Limits and Include Limits', () => {
|
|
|
437
436
|
describe('Window Strategy with Defaults', () => {
|
|
438
437
|
beforeEach(async () => {
|
|
439
438
|
// Create a country first
|
|
440
|
-
await api.resources.countries.post({
|
|
439
|
+
const country = await api.resources.countries.post({
|
|
441
440
|
inputRecord: createJsonApiDocument('countries', {
|
|
442
441
|
name: 'USA',
|
|
443
442
|
code: 'US'
|
|
@@ -448,15 +447,13 @@ describe('Query Limits and Include Limits', () => {
|
|
|
448
447
|
for (let pubNum = 1; pubNum <= 3; pubNum++) {
|
|
449
448
|
const publisher = await api.resources.publishers.post({
|
|
450
449
|
inputRecord: {
|
|
451
|
-
data: {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
}
|
|
450
|
+
data: createJsonApiDocument('publishers', {
|
|
451
|
+
name: `Publisher ${pubNum}`
|
|
452
|
+
}, {
|
|
453
|
+
country: createRelationship({ type: 'countries', id: String(country.id) })
|
|
454
|
+
}).data
|
|
458
455
|
}
|
|
459
|
-
})
|
|
456
|
+
})
|
|
460
457
|
|
|
461
458
|
// Create different number of books for each publisher
|
|
462
459
|
const bookCount = pubNum * 8; // 8, 16, 24 books
|
|
@@ -518,30 +515,22 @@ describe('Query Limits and Include Limits', () => {
|
|
|
518
515
|
});
|
|
519
516
|
|
|
520
517
|
const publisher = await api.resources.publishers.post({
|
|
521
|
-
inputRecord: {
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
country_id: parseInt(country.id)
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
}
|
|
518
|
+
inputRecord: createJsonApiDocument('publishers', {
|
|
519
|
+
name: 'Test Publisher'
|
|
520
|
+
}, {
|
|
521
|
+
country: createRelationship({ type: 'countries', id: String(country.id) })
|
|
522
|
+
})
|
|
530
523
|
});
|
|
531
524
|
|
|
532
525
|
// Create 2 authors
|
|
533
526
|
const authors = [];
|
|
534
527
|
for (let i = 1; i <= 2; i++) {
|
|
535
528
|
const author = await api.resources.authors.post({
|
|
536
|
-
inputRecord: {
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
country_id: parseInt(country.id)
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
}
|
|
529
|
+
inputRecord: createJsonApiDocument('authors', {
|
|
530
|
+
name: `Author ${i}`
|
|
531
|
+
}, {
|
|
532
|
+
country: createRelationship({ type: 'countries', id: String(country.id) })
|
|
533
|
+
})
|
|
545
534
|
});
|
|
546
535
|
authors.push(author);
|
|
547
536
|
}
|
|
@@ -549,23 +538,19 @@ describe('Query Limits and Include Limits', () => {
|
|
|
549
538
|
// Create 10 books and associate ALL with BOTH authors
|
|
550
539
|
for (let i = 1; i <= 10; i++) {
|
|
551
540
|
const book = await api.resources.books.post({
|
|
552
|
-
inputRecord: {
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
publisher_id: parseInt(publisher.id)
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
}
|
|
541
|
+
inputRecord: createJsonApiDocument('books', {
|
|
542
|
+
title: `Book ${i}`
|
|
543
|
+
}, {
|
|
544
|
+
publisher: createRelationship({ type: 'publishers', id: String(publisher.id) })
|
|
545
|
+
})
|
|
561
546
|
});
|
|
562
547
|
|
|
563
548
|
// Associate with both authors
|
|
564
549
|
for (const author of authors) {
|
|
565
550
|
await api.resources.book_authors.post({
|
|
566
|
-
inputRecord: createJsonApiDocument('book_authors', {
|
|
567
|
-
|
|
568
|
-
|
|
551
|
+
inputRecord: createJsonApiDocument('book_authors', {}, {
|
|
552
|
+
book: createRelationship({ type: 'books', id: String(book.id) }),
|
|
553
|
+
author: createRelationship({ type: 'authors', id: String(author.id) })
|
|
569
554
|
})
|
|
570
555
|
});
|
|
571
556
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, it, before, after, beforeEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import knexLib from 'knex';
|
|
4
|
+
import {
|
|
5
|
+
validateJsonApiStructure,
|
|
6
|
+
cleanTables,
|
|
7
|
+
createJsonApiDocument,
|
|
8
|
+
assertResourceAttributes,
|
|
9
|
+
assertResourceRelationship
|
|
10
|
+
} from './helpers/test-utils.js';
|
|
11
|
+
import { createAccessControlApi } from './fixtures/api-configs.js';
|
|
12
|
+
|
|
13
|
+
const knex = knexLib({
|
|
14
|
+
client: 'better-sqlite3',
|
|
15
|
+
connection: {
|
|
16
|
+
filename: ':memory:'
|
|
17
|
+
},
|
|
18
|
+
useNullAsDefault: true
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
let api;
|
|
22
|
+
|
|
23
|
+
async function createUser(email, displayName = 'Test User') {
|
|
24
|
+
const userDoc = createJsonApiDocument('users', {
|
|
25
|
+
email,
|
|
26
|
+
display_name: displayName
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const result = await api.resources.users.post({
|
|
30
|
+
inputRecord: userDoc,
|
|
31
|
+
simplified: false
|
|
32
|
+
}, { auth: { system: true } });
|
|
33
|
+
|
|
34
|
+
validateJsonApiStructure(result);
|
|
35
|
+
assertResourceAttributes(result.data, {
|
|
36
|
+
email,
|
|
37
|
+
display_name: displayName
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return Number(result.data.id);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function createProjectForUser(userId, name, status = null) {
|
|
44
|
+
const projectDoc = createJsonApiDocument('projects', {
|
|
45
|
+
name,
|
|
46
|
+
status
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const result = await api.resources.projects.post({
|
|
50
|
+
inputRecord: projectDoc,
|
|
51
|
+
simplified: false
|
|
52
|
+
}, { auth: { userId } });
|
|
53
|
+
|
|
54
|
+
validateJsonApiStructure(result);
|
|
55
|
+
return result.data;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('Access plugin', () => {
|
|
59
|
+
before(async () => {
|
|
60
|
+
api = await createAccessControlApi(knex);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
after(async () => {
|
|
64
|
+
await knex.destroy();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
beforeEach(async () => {
|
|
68
|
+
await cleanTables(knex, [
|
|
69
|
+
'access_notes',
|
|
70
|
+
'access_projects',
|
|
71
|
+
'access_users'
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('rejects unauthenticated requests for protected operations', async () => {
|
|
76
|
+
const projectDoc = createJsonApiDocument('projects', {
|
|
77
|
+
name: 'Unauthenticated project'
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await assert.rejects(async () => {
|
|
81
|
+
await api.resources.projects.post({
|
|
82
|
+
inputRecord: projectDoc,
|
|
83
|
+
simplified: false
|
|
84
|
+
});
|
|
85
|
+
}, (error) => {
|
|
86
|
+
assert.equal(error.statusCode, 403);
|
|
87
|
+
assert.match(error.message, /Access denied/);
|
|
88
|
+
return true;
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('auto assigns ownership when creating a project', async () => {
|
|
93
|
+
const ownerId = await createUser('owner@example.com', 'Owner One');
|
|
94
|
+
|
|
95
|
+
const projectDoc = createJsonApiDocument('projects', {
|
|
96
|
+
name: 'Owned Project',
|
|
97
|
+
status: 'active'
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const createResult = await api.resources.projects.post({
|
|
101
|
+
inputRecord: projectDoc,
|
|
102
|
+
simplified: false
|
|
103
|
+
}, { auth: { userId: ownerId } });
|
|
104
|
+
|
|
105
|
+
validateJsonApiStructure(createResult);
|
|
106
|
+
assertResourceAttributes(createResult.data, {
|
|
107
|
+
name: 'Owned Project',
|
|
108
|
+
status: 'active'
|
|
109
|
+
});
|
|
110
|
+
assertResourceRelationship(createResult.data, 'user', {
|
|
111
|
+
type: 'users',
|
|
112
|
+
id: String(ownerId)
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const getResult = await api.resources.projects.get({
|
|
116
|
+
id: createResult.data.id,
|
|
117
|
+
simplified: false
|
|
118
|
+
}, { auth: { userId: ownerId } });
|
|
119
|
+
|
|
120
|
+
validateJsonApiStructure(getResult);
|
|
121
|
+
assertResourceRelationship(getResult.data, 'user', {
|
|
122
|
+
type: 'users',
|
|
123
|
+
id: String(ownerId)
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('filters query results by ownership for non-admin users', async () => {
|
|
128
|
+
const aliceId = await createUser('alice@example.com', 'Alice');
|
|
129
|
+
const bobId = await createUser('bob@example.com', 'Bob');
|
|
130
|
+
|
|
131
|
+
const aliceProject = await createProjectForUser(aliceId, 'Alice Project', 'active');
|
|
132
|
+
await createProjectForUser(bobId, 'Bob Project', 'planning');
|
|
133
|
+
|
|
134
|
+
const queryResult = await api.resources.projects.query({
|
|
135
|
+
simplified: false
|
|
136
|
+
}, { auth: { userId: aliceId } });
|
|
137
|
+
|
|
138
|
+
validateJsonApiStructure(queryResult, true);
|
|
139
|
+
assert.equal(queryResult.data.length, 1);
|
|
140
|
+
assert.equal(queryResult.data[0].id, aliceProject.id);
|
|
141
|
+
assertResourceRelationship(queryResult.data[0], 'user', {
|
|
142
|
+
type: 'users',
|
|
143
|
+
id: String(aliceId)
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('allows admin roles to bypass ownership filtering', async () => {
|
|
148
|
+
const userA = await createUser('admin_a@example.com', 'Admin A');
|
|
149
|
+
const userB = await createUser('admin_b@example.com', 'Admin B');
|
|
150
|
+
|
|
151
|
+
await createProjectForUser(userA, 'Admin Project A');
|
|
152
|
+
await createProjectForUser(userB, 'Admin Project B');
|
|
153
|
+
|
|
154
|
+
const adminQuery = await api.resources.projects.query({
|
|
155
|
+
simplified: false
|
|
156
|
+
}, { auth: { userId: 999, roles: ['admin'] } });
|
|
157
|
+
|
|
158
|
+
validateJsonApiStructure(adminQuery, true);
|
|
159
|
+
assert.equal(adminQuery.data.length, 2);
|
|
160
|
+
|
|
161
|
+
const relatedUserIds = adminQuery.data.map((resource) => resource.relationships.user.data.id).sort();
|
|
162
|
+
assert.deepEqual(relatedUserIds, [String(userA), String(userB)]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('prevents users from accessing records they do not own', async () => {
|
|
166
|
+
const ownerId = await createUser('primary@example.com', 'Primary');
|
|
167
|
+
const otherId = await createUser('secondary@example.com', 'Secondary');
|
|
168
|
+
|
|
169
|
+
const project = await createProjectForUser(ownerId, 'Primary Project');
|
|
170
|
+
|
|
171
|
+
await assert.rejects(async () => {
|
|
172
|
+
await api.resources.projects.get({
|
|
173
|
+
id: project.id,
|
|
174
|
+
simplified: false
|
|
175
|
+
}, { auth: { userId: otherId } });
|
|
176
|
+
}, (error) => {
|
|
177
|
+
assert.equal(error.statusCode, 403);
|
|
178
|
+
assert.match(error.message, /Access denied/);
|
|
179
|
+
return true;
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('enforces helper ownership checks', () => {
|
|
184
|
+
const context = { auth: { userId: 123 } };
|
|
185
|
+
const resource = { user_id: 123 };
|
|
186
|
+
|
|
187
|
+
const auth = api.helpers.auth.requireOwnership(context, resource);
|
|
188
|
+
assert.equal(auth.userId, 123);
|
|
189
|
+
|
|
190
|
+
assert.throws(() => {
|
|
191
|
+
api.helpers.auth.requireOwnership({ auth: { userId: 123 } }, { user_id: 456 });
|
|
192
|
+
}, (error) => {
|
|
193
|
+
assert.equal(error.statusCode, 403);
|
|
194
|
+
assert.match(error.message, /Access denied/);
|
|
195
|
+
return true;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
assert.throws(() => {
|
|
199
|
+
api.helpers.auth.requireAuth({});
|
|
200
|
+
}, (error) => {
|
|
201
|
+
assert.equal(error.statusCode, 401);
|
|
202
|
+
assert.match(error.message, /Authentication required/);
|
|
203
|
+
return true;
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, it, before, beforeEach, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import knexLib from 'knex';
|
|
4
|
+
import {
|
|
5
|
+
cleanTables,
|
|
6
|
+
createJsonApiDocument,
|
|
7
|
+
createRelationship
|
|
8
|
+
} from './helpers/test-utils.js';
|
|
9
|
+
import { createSearchSchemaMergeApi } from './fixtures/api-configs.js';
|
|
10
|
+
|
|
11
|
+
// Create Knex instance for tests - always use SQLite in-memory
|
|
12
|
+
const knex = knexLib({
|
|
13
|
+
client: 'better-sqlite3',
|
|
14
|
+
connection: {
|
|
15
|
+
filename: ':memory:'
|
|
16
|
+
},
|
|
17
|
+
useNullAsDefault: true
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('SearchSchema Merge Behavior', () => {
|
|
21
|
+
let api;
|
|
22
|
+
|
|
23
|
+
before(async () => {
|
|
24
|
+
// Create API instance once - NEVER in beforeEach
|
|
25
|
+
api = await createSearchSchemaMergeApi(knex);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
after(async () => {
|
|
29
|
+
// Clean up - destroy connection
|
|
30
|
+
await knex.destroy();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
// Clean tables between tests
|
|
35
|
+
await cleanTables(knex, ['searchmerge_products', 'searchmerge_users', 'searchmerge_orders']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should merge search:true fields with explicit searchSchema', async () => {
|
|
39
|
+
// First do a query to trigger schema compilation
|
|
40
|
+
await api.resources.products.query({
|
|
41
|
+
queryParams: {}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Get the compiled searchSchema for products (has both search:true and searchSchema)
|
|
45
|
+
const scope = api.resources.products;
|
|
46
|
+
const searchSchema = scope.vars.schemaInfo.searchSchemaInstance;
|
|
47
|
+
|
|
48
|
+
// Verify searchSchema exists
|
|
49
|
+
assert.ok(searchSchema, 'searchSchema should exist');
|
|
50
|
+
assert.ok(searchSchema.structure, 'searchSchema should have structure');
|
|
51
|
+
|
|
52
|
+
// Check that search:true fields were added
|
|
53
|
+
assert.ok(searchSchema.structure.name, 'name field should be searchable');
|
|
54
|
+
assert.equal(searchSchema.structure.name.filterOperator || '=', '=', 'name should use default = operator');
|
|
55
|
+
|
|
56
|
+
assert.ok(searchSchema.structure.description, 'description field should be searchable');
|
|
57
|
+
assert.equal(searchSchema.structure.description.filterOperator || '=', '=', 'description should use default = operator');
|
|
58
|
+
|
|
59
|
+
assert.ok(searchSchema.structure.sku, 'sku field should be searchable');
|
|
60
|
+
assert.equal(searchSchema.structure.sku.filterOperator || '=', '=', 'sku should use default = operator');
|
|
61
|
+
|
|
62
|
+
// Check that explicit searchSchema fields override search:true
|
|
63
|
+
assert.ok(searchSchema.structure.price, 'price field should be searchable');
|
|
64
|
+
assert.equal(searchSchema.structure.price.filterOperator, 'between', 'price should use between operator from searchSchema');
|
|
65
|
+
|
|
66
|
+
// Check that virtual fields from searchSchema are present
|
|
67
|
+
assert.ok(searchSchema.structure.category_name, 'category_name virtual field should be searchable');
|
|
68
|
+
assert.equal(searchSchema.structure.category_name.filterOperator, 'like', 'category_name should use like operator');
|
|
69
|
+
assert.equal(searchSchema.structure.category_name.actualField, 'category.name', 'category_name should map to category.name');
|
|
70
|
+
|
|
71
|
+
// Check explicit searchSchema field not marked with search:true
|
|
72
|
+
assert.ok(searchSchema.structure.status, 'status field should be searchable from searchSchema');
|
|
73
|
+
assert.equal(searchSchema.structure.status.filterOperator, 'in', 'status should use in operator');
|
|
74
|
+
|
|
75
|
+
// Check that non-searchable fields are not included
|
|
76
|
+
assert.ok(!searchSchema.structure.category_id, 'category_id should not be searchable');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should work with only search:true fields (no explicit searchSchema)', async () => {
|
|
80
|
+
// First do a query to trigger schema compilation
|
|
81
|
+
await api.resources.users.query({
|
|
82
|
+
queryParams: {}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Get the compiled searchSchema for users (only search:true fields)
|
|
86
|
+
const scope = api.resources.users;
|
|
87
|
+
const searchSchema = scope.vars.schemaInfo.searchSchemaInstance;
|
|
88
|
+
|
|
89
|
+
assert.ok(searchSchema, 'searchSchema should exist');
|
|
90
|
+
assert.ok(searchSchema.structure.username, 'username should be searchable');
|
|
91
|
+
assert.ok(searchSchema.structure.email, 'email should be searchable');
|
|
92
|
+
assert.ok(searchSchema.structure.bio, 'bio should be searchable');
|
|
93
|
+
assert.equal(searchSchema.structure.bio.filterOperator, 'like', 'bio should use like operator');
|
|
94
|
+
assert.ok(!searchSchema.structure.age, 'age should not be searchable');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should work with only explicit searchSchema (no search:true)', async () => {
|
|
98
|
+
// First do a query to trigger schema compilation
|
|
99
|
+
await api.resources.orders.query({
|
|
100
|
+
queryParams: {}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Get the compiled searchSchema for orders (only explicit searchSchema)
|
|
104
|
+
const scope = api.resources.orders;
|
|
105
|
+
const searchSchema = scope.vars.schemaInfo.searchSchemaInstance;
|
|
106
|
+
|
|
107
|
+
assert.ok(searchSchema, 'searchSchema should exist');
|
|
108
|
+
assert.ok(searchSchema.structure.order_number, 'order_number should be searchable');
|
|
109
|
+
assert.ok(searchSchema.structure.status, 'status should be searchable');
|
|
110
|
+
assert.equal(searchSchema.structure.status.filterOperator, 'in', 'status should use in operator');
|
|
111
|
+
assert.ok(!searchSchema.structure.total, 'total should not be searchable');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should allow filtering with merged searchSchema fields', async () => {
|
|
115
|
+
const context = {};
|
|
116
|
+
|
|
117
|
+
// Create some test data using API methods
|
|
118
|
+
const product1 = await api.resources.products.post({
|
|
119
|
+
inputRecord: createJsonApiDocument('products', {
|
|
120
|
+
name: 'Widget A',
|
|
121
|
+
description: 'A great widget',
|
|
122
|
+
price: 50,
|
|
123
|
+
sku: 'WGT-001',
|
|
124
|
+
status: 'active'
|
|
125
|
+
})
|
|
126
|
+
}, context);
|
|
127
|
+
|
|
128
|
+
const product2 = await api.resources.products.post({
|
|
129
|
+
inputRecord: createJsonApiDocument('products', {
|
|
130
|
+
name: 'Widget B',
|
|
131
|
+
description: 'Another widget',
|
|
132
|
+
price: 150,
|
|
133
|
+
sku: 'WGT-002',
|
|
134
|
+
status: 'inactive'
|
|
135
|
+
})
|
|
136
|
+
}, context);
|
|
137
|
+
|
|
138
|
+
// Test filtering with field from search:true
|
|
139
|
+
const nameResults = await api.resources.products.query({
|
|
140
|
+
queryParams: {
|
|
141
|
+
filters: {
|
|
142
|
+
name: 'Widget A'
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}, context);
|
|
146
|
+
assert.equal(nameResults.data.length, 1, 'Should find one product by name');
|
|
147
|
+
assert.equal(nameResults.data[0].attributes.name, 'Widget A');
|
|
148
|
+
|
|
149
|
+
// Test filtering with field from explicit searchSchema (between operator)
|
|
150
|
+
const priceResults = await api.resources.products.query({
|
|
151
|
+
queryParams: {
|
|
152
|
+
filters: {
|
|
153
|
+
price: [40, 60] // Between 40 and 60
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}, context);
|
|
157
|
+
assert.equal(priceResults.data.length, 1, 'Should find one product in price range');
|
|
158
|
+
assert.equal(priceResults.data[0].attributes.price, 50);
|
|
159
|
+
|
|
160
|
+
// Test filtering with explicit searchSchema field (in operator)
|
|
161
|
+
const statusResults = await api.resources.products.query({
|
|
162
|
+
queryParams: {
|
|
163
|
+
filters: {
|
|
164
|
+
status: ['active']
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}, context);
|
|
168
|
+
assert.equal(statusResults.data.length, 1, 'Should find one active product');
|
|
169
|
+
assert.equal(statusResults.data[0].attributes.status, 'active');
|
|
170
|
+
|
|
171
|
+
// Test filtering with search:true field that wasn't overridden
|
|
172
|
+
const skuResults = await api.resources.products.query({
|
|
173
|
+
queryParams: {
|
|
174
|
+
filters: {
|
|
175
|
+
sku: 'WGT-002'
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}, context);
|
|
179
|
+
assert.equal(skuResults.data.length, 1, 'Should find one product by SKU');
|
|
180
|
+
assert.equal(skuResults.data[0].attributes.sku, 'WGT-002');
|
|
181
|
+
});
|
|
182
|
+
});
|