json-rest-api 1.0.24 → 1.0.26

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 (84) hide show
  1. package/README.md +2 -1
  2. package/docs/API.md +12 -9
  3. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +5 -2
  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_Row_Policies.md +605 -0
  10. package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
  11. package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
  12. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
  13. package/docs/GUIDE/index.md +4 -1
  14. package/fixing_plan.md +112 -0
  15. package/index.js +2 -0
  16. package/package.json +1 -1
  17. package/plugins/core/bulk-operations-plugin.js +5 -4
  18. package/plugins/core/connectors/express-plugin.js +6 -1
  19. package/plugins/core/connectors/fastify-plugin.js +5 -1
  20. package/plugins/core/connectors/lib/connector-core.js +17 -11
  21. package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
  22. package/plugins/core/file-handling-plugin.js +126 -96
  23. package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
  24. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +36 -26
  25. package/plugins/core/lib/anyapi/schema-utils.js +1 -0
  26. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +7 -1
  27. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -15
  28. package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
  29. package/plugins/core/lib/querying/knex-relationship-includes.js +29 -28
  30. package/plugins/core/lib/querying/knex-window-queries.js +10 -77
  31. package/plugins/core/lib/querying/storage-adapter-utils.js +8 -8
  32. package/plugins/core/lib/querying/url-helpers.js +9 -21
  33. package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
  34. package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
  35. package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
  36. package/plugins/core/lib/querying-writing/knex-field-helpers.js +6 -1
  37. package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
  38. package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
  39. package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
  40. package/plugins/core/lib/querying-writing/schema-helpers.js +34 -41
  41. package/plugins/core/lib/storage/canonical-storage-mapping.js +2 -1
  42. package/plugins/core/lib/storage/storage-adapter.js +74 -13
  43. package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -3
  44. package/plugins/core/lib/writing/relationship-processor.js +10 -1
  45. package/plugins/core/rest-api-anyapi-knex-plugin.js +210 -97
  46. package/plugins/core/rest-api-knex-plugin.js +70 -60
  47. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +8 -2
  48. package/plugins/core/rest-api-plugin-methods/common.js +85 -63
  49. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +23 -13
  50. package/plugins/core/rest-api-plugin-methods/delete.js +2 -6
  51. package/plugins/core/rest-api-plugin-methods/get-related.js +15 -13
  52. package/plugins/core/rest-api-plugin-methods/get-relationship.js +1 -1
  53. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +17 -6
  54. package/plugins/core/rest-api-plugin-methods/patch.js +3 -6
  55. package/plugins/core/rest-api-plugin-methods/post-relationship.js +24 -14
  56. package/plugins/core/rest-api-plugin-methods/post.js +4 -7
  57. package/plugins/core/rest-api-plugin-methods/put.js +4 -7
  58. package/plugins/core/rest-api-plugin.js +6 -36
  59. package/plugins/core/rest-api-row-policy-plugin.js +157 -0
  60. package/plugins/storage/local-storage.js +44 -19
  61. package/plugins/storage/s3-storage.js +21 -55
  62. package/tests/README.md +1 -1
  63. package/tests/anyapi-cursor-pagination.test.js +7 -1
  64. package/tests/anyapi-custom-idproperty.test.js +163 -0
  65. package/tests/fastify-plugin.test.js +28 -0
  66. package/tests/field-setters.test.js +10 -10
  67. package/tests/file-handling.test.js +118 -0
  68. package/tests/fixtures/api-configs.js +383 -4
  69. package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
  70. package/tests/http-connectors-parity.test.js +165 -5
  71. package/tests/local-storage.test.js +96 -0
  72. package/tests/pagination-cursor-multifield.test.js +121 -0
  73. package/tests/pagination-enhanced.test.js +66 -15
  74. package/tests/post-commit-hooks.test.js +120 -0
  75. package/tests/public-exports.test.js +15 -0
  76. package/tests/put-create-parity.test.js +216 -0
  77. package/tests/relationship-endpoints.test.js +259 -0
  78. package/tests/return-record-settings.test.js +184 -0
  79. package/tests/row-policy.test.js +489 -0
  80. package/tests/s3-storage.test.js +38 -0
  81. package/tests/searchschema-merge.test.js +17 -0
  82. package/tests/socketio.test.js +62 -0
  83. package/tests/storage-mapping.test.js +1 -0
  84. 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,489 @@
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
+ import { createStorageAdapterUtilities } from '../plugins/core/lib/querying/storage-adapter-utils.js'
15
+
16
+ const knex = knexLib({
17
+ client: 'better-sqlite3',
18
+ connection: {
19
+ filename: ':memory:'
20
+ },
21
+ useNullAsDefault: true
22
+ })
23
+
24
+ let api
25
+ const policyEvents = []
26
+
27
+ const adminContext = (workspaceId = 'workspace-a') => ({
28
+ visibility: { all: true },
29
+ scopeValues: { workspaceId }
30
+ })
31
+ const groupContext = (...groups) => ({
32
+ visibility: { groups },
33
+ scopeValues: { workspaceId: 'workspace-a' }
34
+ })
35
+
36
+ const postProject = async (name, accessGroup, context = adminContext()) => {
37
+ return api.resources.policy_projects.post({
38
+ inputRecord: createJsonApiDocument('policy_projects', {
39
+ name,
40
+ access_group: accessGroup
41
+ }),
42
+ simplified: false
43
+ }, context)
44
+ }
45
+
46
+ const postTask = async ({ title, accessGroup, projectId, context = adminContext() }) => {
47
+ const relationships = projectId === undefined
48
+ ? {}
49
+ : {
50
+ project: createRelationship(resourceIdentifier('policy_projects', projectId))
51
+ }
52
+
53
+ return api.resources.policy_tasks.post({
54
+ inputRecord: createJsonApiDocument('policy_tasks', {
55
+ title,
56
+ access_group: accessGroup
57
+ }, relationships),
58
+ simplified: false
59
+ }, context)
60
+ }
61
+
62
+ describe('RowPolicy Plugin', () => {
63
+ it('keeps active query translation stable after the hook context is restored', () => {
64
+ const queryStorageAdapter = {
65
+ translateColumn: (field) => field === 'id' ? 'record_id' : field
66
+ }
67
+ const ambientStorageAdapter = {
68
+ translateColumn: (field) => field === 'id' ? 'wrong_id' : field
69
+ }
70
+ const context = {
71
+ knexQuery: {
72
+ scopeName: 'organisationUnits',
73
+ tableName: 'organisation_units',
74
+ storageAdapter: queryStorageAdapter
75
+ },
76
+ storageAdapter: ambientStorageAdapter
77
+ }
78
+ const utilities = createStorageAdapterUtilities({ context })
79
+
80
+ delete context.knexQuery
81
+ delete context.storageAdapter
82
+
83
+ assert.equal(
84
+ utilities.translateColumn('organisationUnits', 'id'),
85
+ 'organisation_units.record_id'
86
+ )
87
+ assert.equal(
88
+ utilities.translateColumn('organisationUnits', 'id', 'visible_units'),
89
+ 'visible_units.record_id'
90
+ )
91
+ assert.equal(utilities.defaultAliasForScope('users'), 'users')
92
+ assert.equal(Object.hasOwn(context, 'knexQuery'), false)
93
+ assert.equal(Object.hasOwn(context, 'storageAdapter'), false)
94
+ })
95
+
96
+ before(async () => {
97
+ api = await createRowPolicyApi(knex, {
98
+ onPolicy: (event) => policyEvents.push(event)
99
+ })
100
+ })
101
+
102
+ after(async () => {
103
+ await knex.destroy()
104
+ })
105
+
106
+ beforeEach(async () => {
107
+ policyEvents.length = 0
108
+ await cleanTables(knex, [
109
+ 'row_policy_project_tasks',
110
+ 'row_policy_projects',
111
+ 'row_policy_tasks',
112
+ 'row_policy_broken'
113
+ ])
114
+ })
115
+
116
+ it('applies deferred grouped visibility before offset pagination and counts the same dataset', async () => {
117
+ await postProject('Allowed 1', 'group-a')
118
+ await postProject('Hidden 1', 'group-b')
119
+ await postProject('Allowed 2', 'group-a')
120
+ await postProject('Hidden 2', 'group-b')
121
+ await postProject('Allowed 3', 'group-a')
122
+
123
+ policyEvents.length = 0
124
+ const firstPage = await api.resources.policy_projects.query({
125
+ queryParams: {
126
+ sort: ['id'],
127
+ page: { number: 1, size: 2 }
128
+ },
129
+ simplified: false
130
+ }, groupContext('group-a'))
131
+
132
+ validateJsonApiStructure(firstPage, true)
133
+ assert.deepEqual(
134
+ firstPage.data.map((record) => record.attributes.name),
135
+ ['Allowed 1', 'Allowed 2']
136
+ )
137
+ assert.equal(firstPage.meta.pagination.total, 3)
138
+ assert.equal(firstPage.meta.pagination.pageCount, 2)
139
+ assert(policyEvents.some((event) => event.queryPurpose === 'collection'))
140
+
141
+ const secondPage = await api.resources.policy_projects.query({
142
+ queryParams: {
143
+ sort: ['id'],
144
+ page: { number: 2, size: 2 }
145
+ },
146
+ simplified: false
147
+ }, groupContext('group-a'))
148
+
149
+ assert.deepEqual(
150
+ secondPage.data.map((record) => record.attributes.name),
151
+ ['Allowed 3']
152
+ )
153
+ })
154
+
155
+ it('combines row policy visibility with the resource autofilter', async () => {
156
+ await postProject('Visible', 'group-a')
157
+ await postProject('Wrong group', 'group-b')
158
+ await postProject('Wrong workspace', 'group-a', adminContext('workspace-b'))
159
+
160
+ const result = await api.resources.policy_projects.query({
161
+ queryParams: { sort: ['id'] },
162
+ simplified: false
163
+ }, groupContext('group-a'))
164
+
165
+ assert.deepEqual(
166
+ result.data.map((record) => record.attributes.name),
167
+ ['Visible']
168
+ )
169
+ })
170
+
171
+ it('keeps cursor pagination inside the visible dataset', async () => {
172
+ await postProject('Allowed 1', 'group-a')
173
+ await postProject('Hidden 1', 'group-b')
174
+ await postProject('Allowed 2', 'group-a')
175
+ await postProject('Hidden 2', 'group-b')
176
+ await postProject('Allowed 3', 'group-a')
177
+
178
+ const firstPage = await api.resources.policy_projects.query({
179
+ queryParams: {
180
+ sort: ['name'],
181
+ page: { size: 2 }
182
+ },
183
+ simplified: false
184
+ }, groupContext('group-a'))
185
+
186
+ assert.deepEqual(
187
+ firstPage.data.map((record) => record.attributes.name),
188
+ ['Allowed 1', 'Allowed 2']
189
+ )
190
+ assert.equal(firstPage.meta.pagination.hasMore, true)
191
+ assert(firstPage.meta.pagination.cursor.next)
192
+
193
+ const secondPage = await api.resources.policy_projects.query({
194
+ queryParams: {
195
+ sort: ['name'],
196
+ page: {
197
+ size: 2,
198
+ after: firstPage.meta.pagination.cursor.next
199
+ }
200
+ },
201
+ simplified: false
202
+ }, groupContext('group-a'))
203
+
204
+ assert.deepEqual(
205
+ secondPage.data.map((record) => record.attributes.name),
206
+ ['Allowed 3']
207
+ )
208
+ assert.equal(secondPage.meta.pagination.hasMore, false)
209
+ })
210
+
211
+ if (storageMode.isAnyApi()) {
212
+ it('applies the policy to the standalone AnyAPI count helper', async () => {
213
+ await postProject('Allowed 1', 'group-a')
214
+ await postProject('Hidden 1', 'group-b')
215
+ await postProject('Allowed 2', 'group-a')
216
+
217
+ policyEvents.length = 0
218
+ const total = await api.helpers.dataQueryCount({
219
+ scopeName: 'policy_projects',
220
+ context: {
221
+ db: knex,
222
+ queryParams: {},
223
+ ...groupContext('group-a')
224
+ }
225
+ })
226
+
227
+ assert.equal(total, 2)
228
+ assert(policyEvents.some((event) => event.queryPurpose === 'count'))
229
+ })
230
+ }
231
+
232
+ it('uses the policy for single-record and write preflight lookups', async () => {
233
+ const allowed = await postProject('Allowed', 'group-a')
234
+ const hidden = await postProject('Hidden', 'group-b')
235
+
236
+ const visibleRecord = await api.resources.policy_projects.get({
237
+ id: allowed.data.id,
238
+ simplified: false
239
+ }, groupContext('group-a'))
240
+ assert.equal(visibleRecord.data.attributes.name, 'Allowed')
241
+
242
+ await assert.rejects(
243
+ api.resources.policy_projects.get({
244
+ id: hidden.data.id,
245
+ simplified: false
246
+ }, groupContext('group-a')),
247
+ (error) => error.code === 'REST_API_RESOURCE'
248
+ )
249
+
250
+ await assert.rejects(
251
+ api.resources.policy_projects.patch({
252
+ id: hidden.data.id,
253
+ inputRecord: {
254
+ data: {
255
+ type: 'policy_projects',
256
+ id: hidden.data.id,
257
+ attributes: { name: 'Changed' }
258
+ }
259
+ },
260
+ simplified: false
261
+ }, groupContext('group-a')),
262
+ (error) => error.code === 'REST_API_RESOURCE'
263
+ )
264
+
265
+ await assert.rejects(
266
+ api.resources.policy_projects.delete({
267
+ id: hidden.data.id,
268
+ simplified: false
269
+ }, groupContext('group-a')),
270
+ (error) => error.code === 'REST_API_RESOURCE'
271
+ )
272
+
273
+ const stillPresent = await api.resources.policy_projects.get({
274
+ id: hidden.data.id,
275
+ simplified: false
276
+ }, adminContext())
277
+ assert.equal(stillPresent.data.attributes.name, 'Hidden')
278
+ })
279
+
280
+ it('filters included children and relationship identifiers', async () => {
281
+ const project = await postProject('Visible project', 'group-a')
282
+ const visibleTask = await postTask({
283
+ title: 'Visible task',
284
+ accessGroup: 'group-a',
285
+ projectId: project.data.id
286
+ })
287
+ await postTask({
288
+ title: 'Hidden task',
289
+ accessGroup: 'group-b',
290
+ projectId: project.data.id
291
+ })
292
+
293
+ policyEvents.length = 0
294
+ const result = await api.resources.policy_projects.get({
295
+ id: project.data.id,
296
+ queryParams: { include: ['tasks'] },
297
+ simplified: false
298
+ }, groupContext('group-a'))
299
+
300
+ assert.deepEqual(result.data.relationships.tasks.data, [
301
+ resourceIdentifier('policy_tasks', visibleTask.data.id)
302
+ ])
303
+ assert.deepEqual(
304
+ result.included.map((record) => record.attributes.title),
305
+ ['Visible task']
306
+ )
307
+ assert(policyEvents.some((event) => event.queryPurpose === 'relationship-identifiers'))
308
+ assert(policyEvents.some((event) => event.queryPurpose === 'include'))
309
+
310
+ policyEvents.length = 0
311
+ const relationship = await api.resources.policy_projects.getRelationship({
312
+ id: project.data.id,
313
+ relationshipName: 'tasks',
314
+ simplified: false
315
+ }, groupContext('group-a'))
316
+
317
+ assert.deepEqual(relationship.data, [
318
+ resourceIdentifier('policy_tasks', visibleTask.data.id)
319
+ ])
320
+ assert(policyEvents.some((event) => event.queryPurpose === 'single'))
321
+ })
322
+
323
+ it('filters a belongs-to include through the target resource policy', async () => {
324
+ const hiddenProject = await postProject('Hidden project', 'group-b')
325
+ await postTask({
326
+ title: 'Visible task',
327
+ accessGroup: 'group-a',
328
+ projectId: hiddenProject.data.id
329
+ })
330
+
331
+ const result = await api.resources.policy_tasks.query({
332
+ queryParams: { include: ['project'], sort: ['id'] },
333
+ simplified: false
334
+ }, groupContext('group-a'))
335
+
336
+ assert.equal(result.data.length, 1)
337
+ assert.equal(result.included?.length || 0, 0)
338
+ })
339
+
340
+ it('uses target row policies during relationship validation', async () => {
341
+ const hiddenProject = await postProject('Hidden project', 'group-b')
342
+
343
+ await assert.rejects(
344
+ postTask({
345
+ title: 'Cross-policy task',
346
+ accessGroup: 'group-a',
347
+ projectId: hiddenProject.data.id,
348
+ context: groupContext('group-a')
349
+ }),
350
+ (error) => error.code === 'REST_API_RESOURCE' || error.code === 'REST_API_VALIDATION'
351
+ )
352
+
353
+ assert(policyEvents.some((event) => event.queryPurpose === 'relationship-validation'))
354
+ })
355
+
356
+ it('uses target row policies when adding many-to-many relationships', async () => {
357
+ const project = await postProject('Visible project', 'group-a')
358
+ const visibleTask = await postTask({ title: 'Visible task', accessGroup: 'group-a' })
359
+ const hiddenTask = await postTask({ title: 'Hidden task', accessGroup: 'group-b' })
360
+
361
+ await api.resources.policy_projects.postRelationship({
362
+ id: project.data.id,
363
+ relationshipName: 'shared_tasks',
364
+ relationshipData: [resourceIdentifier('policy_tasks', visibleTask.data.id)],
365
+ simplified: false
366
+ }, groupContext('group-a'))
367
+
368
+ await assert.rejects(
369
+ api.resources.policy_projects.postRelationship({
370
+ id: project.data.id,
371
+ relationshipName: 'shared_tasks',
372
+ relationshipData: [resourceIdentifier('policy_tasks', hiddenTask.data.id)],
373
+ simplified: false
374
+ }, groupContext('group-a')),
375
+ (error) => error.code === 'REST_API_RESOURCE'
376
+ )
377
+
378
+ assert.equal(await countRecords(knex, 'row_policy_project_tasks'), 1)
379
+ })
380
+
381
+ it('does not expose relationship routes for a hidden parent', async () => {
382
+ const hiddenProject = await postProject('Hidden project', 'group-b')
383
+
384
+ await assert.rejects(
385
+ api.resources.policy_projects.getRelated({
386
+ id: hiddenProject.data.id,
387
+ relationshipName: 'tasks',
388
+ queryParams: {},
389
+ simplified: false
390
+ }, groupContext('group-a')),
391
+ (error) => error.code === 'REST_API_RESOURCE'
392
+ )
393
+
394
+ await assert.rejects(
395
+ api.resources.policy_projects.getRelationship({
396
+ id: hiddenProject.data.id,
397
+ relationshipName: 'tasks',
398
+ simplified: false
399
+ }, groupContext('group-a')),
400
+ (error) => error.code === 'REST_API_RESOURCE'
401
+ )
402
+
403
+ await assert.rejects(
404
+ api.resources.policy_projects.patchRelationship({
405
+ id: hiddenProject.data.id,
406
+ relationshipName: 'tasks',
407
+ relationshipData: [],
408
+ simplified: false
409
+ }, groupContext('group-a')),
410
+ (error) => error.code === 'REST_API_RESOURCE'
411
+ )
412
+
413
+ assert(policyEvents.some((event) => event.queryPurpose === 'relationship-parent'))
414
+ })
415
+
416
+ it('filters related children before pagination and keeps target filters off the parent lookup', async () => {
417
+ const project = await postProject('Visible project', 'group-a')
418
+ await postTask({ title: 'Allowed 1', accessGroup: 'group-a', projectId: project.data.id })
419
+ await postTask({ title: 'Hidden 1', accessGroup: 'group-b', projectId: project.data.id })
420
+ await postTask({ title: 'Allowed 2', accessGroup: 'group-a', projectId: project.data.id })
421
+ await postTask({ title: 'Hidden 2', accessGroup: 'group-b', projectId: project.data.id })
422
+ await postTask({ title: 'Allowed 3', accessGroup: 'group-a', projectId: project.data.id })
423
+
424
+ const firstPage = await api.resources.policy_projects.getRelated({
425
+ id: project.data.id,
426
+ relationshipName: 'tasks',
427
+ queryParams: {
428
+ sort: ['id'],
429
+ page: { number: 1, size: 2 }
430
+ },
431
+ simplified: false
432
+ }, groupContext('group-a'))
433
+
434
+ assert.deepEqual(
435
+ firstPage.data.map((record) => record.attributes.title),
436
+ ['Allowed 1', 'Allowed 2']
437
+ )
438
+ assert.equal(firstPage.meta.pagination.total, 3)
439
+
440
+ const filtered = await api.resources.policy_projects.getRelated({
441
+ id: project.data.id,
442
+ relationshipName: 'tasks',
443
+ queryParams: {
444
+ filters: { title: 'Allowed 3' },
445
+ page: { number: 1, size: 2 }
446
+ },
447
+ simplified: false
448
+ }, groupContext('group-a'))
449
+
450
+ assert.deepEqual(
451
+ filtered.data.map((record) => record.attributes.title),
452
+ ['Allowed 3']
453
+ )
454
+ assert.equal(filtered.meta.pagination.total, 1)
455
+ })
456
+
457
+ it('denies all rows when a policy explicitly returns false', async () => {
458
+ await postProject('Hidden without context', 'group-a')
459
+
460
+ const result = await api.resources.policy_projects.query({
461
+ queryParams: { page: { number: 1, size: 10 } },
462
+ simplified: false
463
+ }, { scopeValues: { workspaceId: 'workspace-a' } })
464
+
465
+ assert.deepEqual(result.data, [])
466
+ assert.equal(result.meta.pagination.total, 0)
467
+ })
468
+
469
+ it('fails closed when a policy does not return an explicit decision', async () => {
470
+ await assert.rejects(
471
+ api.resources.policy_broken.query({ simplified: false }),
472
+ (error) => error.code === 'REST_API_ROW_POLICY_CONTRACT'
473
+ )
474
+ })
475
+
476
+ it('exposes compiled policy configuration for inspection', () => {
477
+ assert.deepEqual(api.rowPolicies.getConfig(), {
478
+ policies: ['groupVisibility']
479
+ })
480
+ assert.deepEqual(api.rowPolicies.getScopeConfig('policy_projects'), {
481
+ policy: 'groupVisibility',
482
+ source: 'registry'
483
+ })
484
+ assert.deepEqual(api.rowPolicies.getScopeConfig('policy_broken'), {
485
+ policy: '<inline>',
486
+ source: 'inline'
487
+ })
488
+ })
489
+ })
@@ -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
+ })