json-rest-api 1.0.23 → 1.0.25

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 (106) hide show
  1. package/README.md +24 -2
  2. package/docs/API.md +157 -33
  3. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +88 -6
  4. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +3 -3
  5. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -0
  6. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1 -1
  7. package/docs/GUIDE/GUIDE_X_Autofiltering.md +63 -0
  8. package/docs/GUIDE/GUIDE_X_File_Uploads.md +17 -12
  9. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +122 -2
  10. package/docs/GUIDE/GUIDE_X_Row_Policies.md +603 -0
  11. package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
  12. package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
  13. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
  14. package/docs/GUIDE/index.md +4 -1
  15. package/fixing_plan.md +112 -0
  16. package/index.js +2 -0
  17. package/lib/enhanced-logger.js +1 -1
  18. package/package.json +6 -2
  19. package/plugins/core/bulk-operations-plugin.js +31 -38
  20. package/plugins/core/connectors/express-plugin.js +6 -7
  21. package/plugins/core/connectors/fastify-plugin.js +5 -1
  22. package/plugins/core/connectors/lib/busboy-detector.js +4 -4
  23. package/plugins/core/connectors/lib/connector-core.js +17 -11
  24. package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
  25. package/plugins/core/file-handling-plugin.js +126 -96
  26. package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
  27. package/plugins/core/lib/anyapi/schema-utils.js +44 -4
  28. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +11 -2
  29. package/plugins/core/lib/dbIntrospection.js +1 -1
  30. package/plugins/core/lib/dbTablesOperations.js +10 -3
  31. package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
  32. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -16
  33. package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
  34. package/plugins/core/lib/querying/knex-query-helpers.js +5 -6
  35. package/plugins/core/lib/querying/knex-relationship-includes.js +40 -46
  36. package/plugins/core/lib/querying/knex-window-queries.js +10 -77
  37. package/plugins/core/lib/querying/url-helpers.js +9 -21
  38. package/plugins/core/lib/querying-writing/compile-schemas.js +10 -6
  39. package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
  40. package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
  41. package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
  42. package/plugins/core/lib/querying-writing/knex-field-helpers.js +7 -3
  43. package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
  44. package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
  45. package/plugins/core/lib/querying-writing/resource-id-normalization.js +131 -0
  46. package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
  47. package/plugins/core/lib/querying-writing/schema-helpers.js +34 -43
  48. package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
  49. package/plugins/core/lib/storage/canonical-storage-mapping.js +24 -3
  50. package/plugins/core/lib/storage/storage-adapter.js +76 -14
  51. package/plugins/core/lib/storage/storage-mapping.js +86 -16
  52. package/plugins/core/lib/writing/many-to-many-manipulations.js +8 -5
  53. package/plugins/core/lib/writing/relationship-processor.js +10 -1
  54. package/plugins/core/rest-api-anyapi-knex-plugin.js +402 -197
  55. package/plugins/core/rest-api-knex-plugin.js +93 -76
  56. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +9 -2
  57. package/plugins/core/rest-api-plugin-methods/common.js +112 -72
  58. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +34 -15
  59. package/plugins/core/rest-api-plugin-methods/delete.js +8 -7
  60. package/plugins/core/rest-api-plugin-methods/get-related.js +22 -15
  61. package/plugins/core/rest-api-plugin-methods/get-relationship.js +8 -3
  62. package/plugins/core/rest-api-plugin-methods/get.js +7 -1
  63. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +26 -8
  64. package/plugins/core/rest-api-plugin-methods/patch.js +34 -9
  65. package/plugins/core/rest-api-plugin-methods/post-relationship.js +35 -16
  66. package/plugins/core/rest-api-plugin-methods/post.js +12 -7
  67. package/plugins/core/rest-api-plugin-methods/put.js +35 -10
  68. package/plugins/core/rest-api-plugin-methods/release.js +0 -2
  69. package/plugins/core/rest-api-plugin.js +11 -37
  70. package/plugins/core/rest-api-positioning-plugin.js +1 -4
  71. package/plugins/core/rest-api-row-policy-plugin.js +157 -0
  72. package/plugins/core/socketio-plugin.js +0 -1
  73. package/plugins/storage/local-storage.js +45 -20
  74. package/plugins/storage/s3-storage.js +23 -54
  75. package/quickTest.js +1 -0
  76. package/scripts/run-docs.js +80 -84
  77. package/test-debug.js +1 -1
  78. package/tests/README.md +8 -1
  79. package/tests/anyapi-basic.test.js +3 -0
  80. package/tests/anyapi-cursor-pagination.test.js +7 -1
  81. package/tests/anyapi-custom-idproperty.test.js +163 -0
  82. package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
  83. package/tests/db-introspection.test.js +4 -1
  84. package/tests/db-table-operations.test.js +25 -20
  85. package/tests/fastify-plugin.test.js +49 -6
  86. package/tests/field-setters.test.js +10 -10
  87. package/tests/file-handling.test.js +118 -0
  88. package/tests/fixtures/api-configs.js +495 -4
  89. package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
  90. package/tests/http-connectors-parity.test.js +165 -5
  91. package/tests/id-normalization.test.js +444 -0
  92. package/tests/local-storage.test.js +96 -0
  93. package/tests/logical-id-write-validation.test.js +151 -0
  94. package/tests/pagination-cursor-multifield.test.js +132 -11
  95. package/tests/pagination-enhanced.test.js +66 -15
  96. package/tests/post-commit-hooks.test.js +120 -0
  97. package/tests/public-exports.test.js +15 -0
  98. package/tests/put-create-parity.test.js +216 -0
  99. package/tests/relationship-endpoints.test.js +259 -0
  100. package/tests/return-record-settings.test.js +184 -0
  101. package/tests/row-policy.test.js +433 -0
  102. package/tests/s3-storage.test.js +38 -0
  103. package/tests/searchschema-merge.test.js +17 -0
  104. package/tests/socketio.test.js +62 -0
  105. package/tests/storage-mapping.test.js +56 -6
  106. package/tests/test-development-guide.md +5 -5
@@ -0,0 +1,184 @@
1
+ import { describe, it, before, after, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import knexLib from 'knex'
4
+ import { createReturnRecordApi } from './fixtures/api-configs.js'
5
+ import { cleanTables } from './helpers/test-utils.js'
6
+
7
+ const knex = knexLib({
8
+ client: 'better-sqlite3',
9
+ connection: {
10
+ filename: ':memory:'
11
+ },
12
+ useNullAsDefault: true
13
+ })
14
+
15
+ let api
16
+
17
+ function itemDocument (type, name, id) {
18
+ const doc = {
19
+ data: {
20
+ type,
21
+ attributes: { name }
22
+ }
23
+ }
24
+
25
+ if (id !== undefined) {
26
+ doc.data.id = String(id)
27
+ }
28
+
29
+ return doc
30
+ }
31
+
32
+ describe('Return record settings', () => {
33
+ before(async () => {
34
+ api = await createReturnRecordApi(knex)
35
+ })
36
+
37
+ after(async () => {
38
+ await knex.destroy()
39
+ })
40
+
41
+ beforeEach(async () => {
42
+ await cleanTables(knex, [
43
+ 'return_global_items',
44
+ 'return_scope_items'
45
+ ])
46
+ })
47
+
48
+ it('normalizes plugin-level boolean return settings', async () => {
49
+ const created = await api.resources.global_items.post({
50
+ inputRecord: itemDocument('global_items', 'Global Created'),
51
+ simplified: false
52
+ })
53
+
54
+ assert.equal(created.data.type, 'global_items')
55
+ assert.equal(created.data.attributes.name, 'Global Created')
56
+
57
+ const replaced = await api.resources.global_items.put({
58
+ id: created.data.id,
59
+ inputRecord: itemDocument('global_items', 'Global Replaced', created.data.id),
60
+ simplified: false
61
+ })
62
+ assert.equal(replaced, undefined)
63
+
64
+ const patched = await api.resources.global_items.patch({
65
+ id: created.data.id,
66
+ inputRecord: itemDocument('global_items', 'Global Patched', created.data.id),
67
+ simplified: false
68
+ })
69
+ assert.equal(patched, undefined)
70
+
71
+ const fetched = await api.resources.global_items.get({
72
+ id: created.data.id,
73
+ simplified: false
74
+ })
75
+ assert.equal(fetched.data.attributes.name, 'Global Patched')
76
+ })
77
+
78
+ it('normalizes per-call boolean return settings', async () => {
79
+ const created = await api.resources.global_items.post({
80
+ inputRecord: itemDocument('global_items', 'Per-call Created'),
81
+ simplified: false,
82
+ returnFullRecord: false
83
+ })
84
+ assert.equal(created, undefined)
85
+
86
+ const queryResult = await api.resources.global_items.query({
87
+ simplified: false
88
+ })
89
+ assert.equal(queryResult.data.length, 1)
90
+ const fetched = queryResult.data[0]
91
+ assert.equal(fetched.attributes.name, 'Per-call Created')
92
+
93
+ const patched = await api.resources.global_items.patch({
94
+ id: fetched.id,
95
+ inputRecord: itemDocument('global_items', 'Per-call Patched', fetched.id),
96
+ simplified: false,
97
+ returnFullRecord: true
98
+ })
99
+ assert.equal(patched.data.type, 'global_items')
100
+ assert.equal(patched.data.id, fetched.id)
101
+ assert.equal(patched.data.attributes.name, 'Per-call Patched')
102
+ })
103
+
104
+ it('normalizes resource-level boolean return settings', async () => {
105
+ const created = await api.resources.scope_items.post({
106
+ inputRecord: itemDocument('scope_items', 'Scoped Created'),
107
+ simplified: false
108
+ })
109
+ assert.equal(created, undefined)
110
+
111
+ const queryResult = await api.resources.scope_items.query({
112
+ simplified: false
113
+ })
114
+ assert.equal(queryResult.data.length, 1)
115
+ const fetched = queryResult.data[0]
116
+
117
+ const replaced = await api.resources.scope_items.put({
118
+ id: fetched.id,
119
+ inputRecord: itemDocument('scope_items', 'Scoped Replaced', fetched.id),
120
+ simplified: false
121
+ })
122
+ assert.equal(replaced.data.type, 'scope_items')
123
+ assert.equal(replaced.data.id, fetched.id)
124
+ assert.equal(replaced.data.attributes.name, 'Scoped Replaced')
125
+
126
+ const patched = await api.resources.scope_items.patch({
127
+ id: fetched.id,
128
+ inputRecord: itemDocument('scope_items', 'Scoped Patched', fetched.id),
129
+ simplified: false
130
+ })
131
+ assert.deepEqual(patched, {
132
+ data: {
133
+ type: 'scope_items',
134
+ id: fetched.id
135
+ }
136
+ })
137
+ })
138
+
139
+ it('normalizes transport boolean return settings', async () => {
140
+ const created = await api.resources.global_items.post({
141
+ inputRecord: itemDocument('global_items', 'Transport Created'),
142
+ simplified: false,
143
+ isTransport: true
144
+ })
145
+ assert.equal(created, undefined)
146
+
147
+ const queryResult = await api.resources.global_items.query({
148
+ simplified: false
149
+ })
150
+ assert.equal(queryResult.data.length, 1)
151
+ const fetched = queryResult.data[0]
152
+
153
+ const replaced = await api.resources.global_items.put({
154
+ id: fetched.id,
155
+ inputRecord: itemDocument('global_items', 'Transport Replaced', fetched.id),
156
+ simplified: false,
157
+ isTransport: true
158
+ })
159
+ assert.equal(replaced.data.type, 'global_items')
160
+ assert.equal(replaced.data.id, fetched.id)
161
+ assert.equal(replaced.data.attributes.name, 'Transport Replaced')
162
+
163
+ const patched = await api.resources.global_items.patch({
164
+ id: fetched.id,
165
+ inputRecord: itemDocument('global_items', 'Transport Patched', fetched.id),
166
+ simplified: false,
167
+ isTransport: true
168
+ })
169
+ assert.deepEqual(patched, {
170
+ data: {
171
+ type: 'global_items',
172
+ id: fetched.id
173
+ }
174
+ })
175
+
176
+ const scoped = await api.resources.scope_items.post({
177
+ inputRecord: itemDocument('scope_items', 'Scoped Transport Created'),
178
+ simplified: false,
179
+ isTransport: true
180
+ })
181
+ assert.equal(scoped.data.type, 'scope_items')
182
+ assert.equal(scoped.data.attributes.name, 'Scoped Transport Created')
183
+ })
184
+ })
@@ -0,0 +1,433 @@
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
+ cleanTables,
6
+ countRecords,
7
+ createJsonApiDocument,
8
+ createRelationship,
9
+ resourceIdentifier,
10
+ validateJsonApiStructure
11
+ } from './helpers/test-utils.js'
12
+ import { storageMode } from './helpers/storage-mode.js'
13
+ import { createRowPolicyApi } from './fixtures/api-configs.js'
14
+
15
+ const knex = knexLib({
16
+ client: 'better-sqlite3',
17
+ connection: {
18
+ filename: ':memory:'
19
+ },
20
+ useNullAsDefault: true
21
+ })
22
+
23
+ let api
24
+ const policyEvents = []
25
+
26
+ const adminContext = () => ({ visibility: { all: true } })
27
+ const groupContext = (...groups) => ({ visibility: { groups } })
28
+
29
+ const postProject = async (name, accessGroup) => {
30
+ return api.resources.policy_projects.post({
31
+ inputRecord: createJsonApiDocument('policy_projects', {
32
+ name,
33
+ access_group: accessGroup
34
+ }),
35
+ simplified: false
36
+ }, adminContext())
37
+ }
38
+
39
+ const postTask = async ({ title, accessGroup, projectId, context = adminContext() }) => {
40
+ const relationships = projectId === undefined
41
+ ? {}
42
+ : {
43
+ project: createRelationship(resourceIdentifier('policy_projects', projectId))
44
+ }
45
+
46
+ return api.resources.policy_tasks.post({
47
+ inputRecord: createJsonApiDocument('policy_tasks', {
48
+ title,
49
+ access_group: accessGroup
50
+ }, relationships),
51
+ simplified: false
52
+ }, context)
53
+ }
54
+
55
+ describe('RowPolicy Plugin', () => {
56
+ before(async () => {
57
+ api = await createRowPolicyApi(knex, {
58
+ onPolicy: (event) => policyEvents.push(event)
59
+ })
60
+ })
61
+
62
+ after(async () => {
63
+ await knex.destroy()
64
+ })
65
+
66
+ beforeEach(async () => {
67
+ policyEvents.length = 0
68
+ await cleanTables(knex, [
69
+ 'row_policy_project_tasks',
70
+ 'row_policy_projects',
71
+ 'row_policy_tasks',
72
+ 'row_policy_broken'
73
+ ])
74
+ })
75
+
76
+ it('applies visibility before offset pagination and counts the same dataset', async () => {
77
+ await postProject('Allowed 1', 'group-a')
78
+ await postProject('Hidden 1', 'group-b')
79
+ await postProject('Allowed 2', 'group-a')
80
+ await postProject('Hidden 2', 'group-b')
81
+ await postProject('Allowed 3', 'group-a')
82
+
83
+ policyEvents.length = 0
84
+ const firstPage = await api.resources.policy_projects.query({
85
+ queryParams: {
86
+ sort: ['id'],
87
+ page: { number: 1, size: 2 }
88
+ },
89
+ simplified: false
90
+ }, groupContext('group-a'))
91
+
92
+ validateJsonApiStructure(firstPage, true)
93
+ assert.deepEqual(
94
+ firstPage.data.map((record) => record.attributes.name),
95
+ ['Allowed 1', 'Allowed 2']
96
+ )
97
+ assert.equal(firstPage.meta.pagination.total, 3)
98
+ assert.equal(firstPage.meta.pagination.pageCount, 2)
99
+ assert(policyEvents.some((event) => event.queryPurpose === 'collection'))
100
+
101
+ const secondPage = await api.resources.policy_projects.query({
102
+ queryParams: {
103
+ sort: ['id'],
104
+ page: { number: 2, size: 2 }
105
+ },
106
+ simplified: false
107
+ }, groupContext('group-a'))
108
+
109
+ assert.deepEqual(
110
+ secondPage.data.map((record) => record.attributes.name),
111
+ ['Allowed 3']
112
+ )
113
+ })
114
+
115
+ it('keeps cursor pagination inside the visible dataset', async () => {
116
+ await postProject('Allowed 1', 'group-a')
117
+ await postProject('Hidden 1', 'group-b')
118
+ await postProject('Allowed 2', 'group-a')
119
+ await postProject('Hidden 2', 'group-b')
120
+ await postProject('Allowed 3', 'group-a')
121
+
122
+ const firstPage = await api.resources.policy_projects.query({
123
+ queryParams: {
124
+ sort: ['name'],
125
+ page: { size: 2 }
126
+ },
127
+ simplified: false
128
+ }, groupContext('group-a'))
129
+
130
+ assert.deepEqual(
131
+ firstPage.data.map((record) => record.attributes.name),
132
+ ['Allowed 1', 'Allowed 2']
133
+ )
134
+ assert.equal(firstPage.meta.pagination.hasMore, true)
135
+ assert(firstPage.meta.pagination.cursor.next)
136
+
137
+ const secondPage = await api.resources.policy_projects.query({
138
+ queryParams: {
139
+ sort: ['name'],
140
+ page: {
141
+ size: 2,
142
+ after: firstPage.meta.pagination.cursor.next
143
+ }
144
+ },
145
+ simplified: false
146
+ }, groupContext('group-a'))
147
+
148
+ assert.deepEqual(
149
+ secondPage.data.map((record) => record.attributes.name),
150
+ ['Allowed 3']
151
+ )
152
+ assert.equal(secondPage.meta.pagination.hasMore, false)
153
+ })
154
+
155
+ if (storageMode.isAnyApi()) {
156
+ it('applies the policy to the standalone AnyAPI count helper', async () => {
157
+ await postProject('Allowed 1', 'group-a')
158
+ await postProject('Hidden 1', 'group-b')
159
+ await postProject('Allowed 2', 'group-a')
160
+
161
+ policyEvents.length = 0
162
+ const total = await api.helpers.dataQueryCount({
163
+ scopeName: 'policy_projects',
164
+ context: {
165
+ db: knex,
166
+ queryParams: {},
167
+ ...groupContext('group-a')
168
+ }
169
+ })
170
+
171
+ assert.equal(total, 2)
172
+ assert(policyEvents.some((event) => event.queryPurpose === 'count'))
173
+ })
174
+ }
175
+
176
+ it('uses the policy for single-record and write preflight lookups', async () => {
177
+ const allowed = await postProject('Allowed', 'group-a')
178
+ const hidden = await postProject('Hidden', 'group-b')
179
+
180
+ const visibleRecord = await api.resources.policy_projects.get({
181
+ id: allowed.data.id,
182
+ simplified: false
183
+ }, groupContext('group-a'))
184
+ assert.equal(visibleRecord.data.attributes.name, 'Allowed')
185
+
186
+ await assert.rejects(
187
+ api.resources.policy_projects.get({
188
+ id: hidden.data.id,
189
+ simplified: false
190
+ }, groupContext('group-a')),
191
+ (error) => error.code === 'REST_API_RESOURCE'
192
+ )
193
+
194
+ await assert.rejects(
195
+ api.resources.policy_projects.patch({
196
+ id: hidden.data.id,
197
+ inputRecord: {
198
+ data: {
199
+ type: 'policy_projects',
200
+ id: hidden.data.id,
201
+ attributes: { name: 'Changed' }
202
+ }
203
+ },
204
+ simplified: false
205
+ }, groupContext('group-a')),
206
+ (error) => error.code === 'REST_API_RESOURCE'
207
+ )
208
+
209
+ await assert.rejects(
210
+ api.resources.policy_projects.delete({
211
+ id: hidden.data.id,
212
+ simplified: false
213
+ }, groupContext('group-a')),
214
+ (error) => error.code === 'REST_API_RESOURCE'
215
+ )
216
+
217
+ const stillPresent = await api.resources.policy_projects.get({
218
+ id: hidden.data.id,
219
+ simplified: false
220
+ }, adminContext())
221
+ assert.equal(stillPresent.data.attributes.name, 'Hidden')
222
+ })
223
+
224
+ it('filters included children and relationship identifiers', async () => {
225
+ const project = await postProject('Visible project', 'group-a')
226
+ const visibleTask = await postTask({
227
+ title: 'Visible task',
228
+ accessGroup: 'group-a',
229
+ projectId: project.data.id
230
+ })
231
+ await postTask({
232
+ title: 'Hidden task',
233
+ accessGroup: 'group-b',
234
+ projectId: project.data.id
235
+ })
236
+
237
+ policyEvents.length = 0
238
+ const result = await api.resources.policy_projects.get({
239
+ id: project.data.id,
240
+ queryParams: { include: ['tasks'] },
241
+ simplified: false
242
+ }, groupContext('group-a'))
243
+
244
+ assert.deepEqual(result.data.relationships.tasks.data, [
245
+ resourceIdentifier('policy_tasks', visibleTask.data.id)
246
+ ])
247
+ assert.deepEqual(
248
+ result.included.map((record) => record.attributes.title),
249
+ ['Visible task']
250
+ )
251
+ assert(policyEvents.some((event) => event.queryPurpose === 'relationship-identifiers'))
252
+ assert(policyEvents.some((event) => event.queryPurpose === 'include'))
253
+
254
+ policyEvents.length = 0
255
+ const relationship = await api.resources.policy_projects.getRelationship({
256
+ id: project.data.id,
257
+ relationshipName: 'tasks',
258
+ simplified: false
259
+ }, groupContext('group-a'))
260
+
261
+ assert.deepEqual(relationship.data, [
262
+ resourceIdentifier('policy_tasks', visibleTask.data.id)
263
+ ])
264
+ assert(policyEvents.some((event) => event.queryPurpose === 'single'))
265
+ })
266
+
267
+ it('filters a belongs-to include through the target resource policy', async () => {
268
+ const hiddenProject = await postProject('Hidden project', 'group-b')
269
+ await postTask({
270
+ title: 'Visible task',
271
+ accessGroup: 'group-a',
272
+ projectId: hiddenProject.data.id
273
+ })
274
+
275
+ const result = await api.resources.policy_tasks.query({
276
+ queryParams: { include: ['project'], sort: ['id'] },
277
+ simplified: false
278
+ }, groupContext('group-a'))
279
+
280
+ assert.equal(result.data.length, 1)
281
+ assert.equal(result.included?.length || 0, 0)
282
+ })
283
+
284
+ it('uses target row policies during relationship validation', async () => {
285
+ const hiddenProject = await postProject('Hidden project', 'group-b')
286
+
287
+ await assert.rejects(
288
+ postTask({
289
+ title: 'Cross-policy task',
290
+ accessGroup: 'group-a',
291
+ projectId: hiddenProject.data.id,
292
+ context: groupContext('group-a')
293
+ }),
294
+ (error) => error.code === 'REST_API_RESOURCE' || error.code === 'REST_API_VALIDATION'
295
+ )
296
+
297
+ assert(policyEvents.some((event) => event.queryPurpose === 'relationship-validation'))
298
+ })
299
+
300
+ it('uses target row policies when adding many-to-many relationships', async () => {
301
+ const project = await postProject('Visible project', 'group-a')
302
+ const visibleTask = await postTask({ title: 'Visible task', accessGroup: 'group-a' })
303
+ const hiddenTask = await postTask({ title: 'Hidden task', accessGroup: 'group-b' })
304
+
305
+ await api.resources.policy_projects.postRelationship({
306
+ id: project.data.id,
307
+ relationshipName: 'shared_tasks',
308
+ relationshipData: [resourceIdentifier('policy_tasks', visibleTask.data.id)],
309
+ simplified: false
310
+ }, groupContext('group-a'))
311
+
312
+ await assert.rejects(
313
+ api.resources.policy_projects.postRelationship({
314
+ id: project.data.id,
315
+ relationshipName: 'shared_tasks',
316
+ relationshipData: [resourceIdentifier('policy_tasks', hiddenTask.data.id)],
317
+ simplified: false
318
+ }, groupContext('group-a')),
319
+ (error) => error.code === 'REST_API_RESOURCE'
320
+ )
321
+
322
+ assert.equal(await countRecords(knex, 'row_policy_project_tasks'), 1)
323
+ })
324
+
325
+ it('does not expose relationship routes for a hidden parent', async () => {
326
+ const hiddenProject = await postProject('Hidden project', 'group-b')
327
+
328
+ await assert.rejects(
329
+ api.resources.policy_projects.getRelated({
330
+ id: hiddenProject.data.id,
331
+ relationshipName: 'tasks',
332
+ queryParams: {},
333
+ simplified: false
334
+ }, groupContext('group-a')),
335
+ (error) => error.code === 'REST_API_RESOURCE'
336
+ )
337
+
338
+ await assert.rejects(
339
+ api.resources.policy_projects.getRelationship({
340
+ id: hiddenProject.data.id,
341
+ relationshipName: 'tasks',
342
+ simplified: false
343
+ }, groupContext('group-a')),
344
+ (error) => error.code === 'REST_API_RESOURCE'
345
+ )
346
+
347
+ await assert.rejects(
348
+ api.resources.policy_projects.patchRelationship({
349
+ id: hiddenProject.data.id,
350
+ relationshipName: 'tasks',
351
+ relationshipData: [],
352
+ simplified: false
353
+ }, groupContext('group-a')),
354
+ (error) => error.code === 'REST_API_RESOURCE'
355
+ )
356
+
357
+ assert(policyEvents.some((event) => event.queryPurpose === 'relationship-parent'))
358
+ })
359
+
360
+ it('filters related children before pagination and keeps target filters off the parent lookup', async () => {
361
+ const project = await postProject('Visible project', 'group-a')
362
+ await postTask({ title: 'Allowed 1', accessGroup: 'group-a', projectId: project.data.id })
363
+ await postTask({ title: 'Hidden 1', accessGroup: 'group-b', projectId: project.data.id })
364
+ await postTask({ title: 'Allowed 2', accessGroup: 'group-a', projectId: project.data.id })
365
+ await postTask({ title: 'Hidden 2', accessGroup: 'group-b', projectId: project.data.id })
366
+ await postTask({ title: 'Allowed 3', accessGroup: 'group-a', projectId: project.data.id })
367
+
368
+ const firstPage = await api.resources.policy_projects.getRelated({
369
+ id: project.data.id,
370
+ relationshipName: 'tasks',
371
+ queryParams: {
372
+ sort: ['id'],
373
+ page: { number: 1, size: 2 }
374
+ },
375
+ simplified: false
376
+ }, groupContext('group-a'))
377
+
378
+ assert.deepEqual(
379
+ firstPage.data.map((record) => record.attributes.title),
380
+ ['Allowed 1', 'Allowed 2']
381
+ )
382
+ assert.equal(firstPage.meta.pagination.total, 3)
383
+
384
+ const filtered = await api.resources.policy_projects.getRelated({
385
+ id: project.data.id,
386
+ relationshipName: 'tasks',
387
+ queryParams: {
388
+ filters: { title: 'Allowed 3' },
389
+ page: { number: 1, size: 2 }
390
+ },
391
+ simplified: false
392
+ }, groupContext('group-a'))
393
+
394
+ assert.deepEqual(
395
+ filtered.data.map((record) => record.attributes.title),
396
+ ['Allowed 3']
397
+ )
398
+ assert.equal(filtered.meta.pagination.total, 1)
399
+ })
400
+
401
+ it('denies all rows when a policy explicitly returns false', async () => {
402
+ await postProject('Hidden without context', 'group-a')
403
+
404
+ const result = await api.resources.policy_projects.query({
405
+ queryParams: { page: { number: 1, size: 10 } },
406
+ simplified: false
407
+ })
408
+
409
+ assert.deepEqual(result.data, [])
410
+ assert.equal(result.meta.pagination.total, 0)
411
+ })
412
+
413
+ it('fails closed when a policy does not return an explicit decision', async () => {
414
+ await assert.rejects(
415
+ api.resources.policy_broken.query({ simplified: false }),
416
+ (error) => error.code === 'REST_API_ROW_POLICY_CONTRACT'
417
+ )
418
+ })
419
+
420
+ it('exposes compiled policy configuration for inspection', () => {
421
+ assert.deepEqual(api.rowPolicies.getConfig(), {
422
+ policies: ['groupVisibility']
423
+ })
424
+ assert.deepEqual(api.rowPolicies.getScopeConfig('policy_projects'), {
425
+ policy: 'groupVisibility',
426
+ source: 'registry'
427
+ })
428
+ assert.deepEqual(api.rowPolicies.getScopeConfig('policy_broken'), {
429
+ policy: '<inline>',
430
+ source: 'inline'
431
+ })
432
+ })
433
+ })
@@ -0,0 +1,38 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { S3Storage } from '../index.js'
5
+
6
+ function createFile (filename = 'photo.png') {
7
+ return {
8
+ filename,
9
+ mimetype: 'image/png',
10
+ data: Buffer.from('file contents')
11
+ }
12
+ }
13
+
14
+ describe('S3Storage', () => {
15
+ it('requires a bucket name', () => {
16
+ assert.throws(() => new S3Storage(), /S3Storage requires a bucket name/)
17
+ })
18
+
19
+ it('generates S3-style mock URLs and accepts deleting returned URLs', async () => {
20
+ const storage = new S3Storage({
21
+ bucket: 'my-uploads',
22
+ region: 'ap-southeast-2',
23
+ prefix: 'uploads/'
24
+ })
25
+
26
+ const url = await storage.upload(createFile())
27
+
28
+ assert.match(url, /^https:\/\/my-uploads\.s3\.ap-southeast-2\.amazonaws\.com\/uploads\/[a-f0-9]{32}\.png$/)
29
+ await assert.doesNotReject(storage.delete(url))
30
+ })
31
+
32
+ it('fails clearly when real S3 mode is requested', () => {
33
+ assert.throws(
34
+ () => new S3Storage({ bucket: 'my-uploads', mockMode: false }),
35
+ /S3Storage real S3 mode is not implemented/
36
+ )
37
+ })
38
+ })
@@ -5,6 +5,7 @@ import {
5
5
  cleanTables,
6
6
  } from './helpers/test-utils.js'
7
7
  import { createSearchSchemaMergeApi } from './fixtures/api-configs.js'
8
+ import { generateSearchSchemaFromSchema } from '../plugins/core/lib/querying-writing/schema-helpers.js'
8
9
 
9
10
  // Create Knex instance for tests - always use SQLite in-memory
10
11
  const knex = knexLib({
@@ -131,4 +132,20 @@ describe('SearchSchema Merge Behavior', () => {
131
132
  'Should reject fields that are not defined in searchSchema'
132
133
  )
133
134
  })
135
+
136
+ it('should keep canonical belongsTo field keys searchable alongside relationship aliases', () => {
137
+ const searchSchema = generateSearchSchemaFromSchema({
138
+ petId: {
139
+ type: 'id',
140
+ belongsTo: 'pets',
141
+ as: 'pet',
142
+ search: true
143
+ }
144
+ }, null)
145
+
146
+ assert.ok(searchSchema.pet, 'relationship alias should stay searchable')
147
+ assert.equal(searchSchema.pet.actualField, 'petId')
148
+ assert.ok(searchSchema.petId, 'canonical field key should stay searchable for parent filters')
149
+ assert.equal(searchSchema.petId.actualField, 'petId')
150
+ })
134
151
  })