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.
- package/README.md +2 -1
- package/docs/API.md +12 -9
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +5 -2
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +3 -3
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -0
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1 -1
- package/docs/GUIDE/GUIDE_X_Autofiltering.md +63 -0
- package/docs/GUIDE/GUIDE_X_File_Uploads.md +17 -12
- package/docs/GUIDE/GUIDE_X_Row_Policies.md +605 -0
- package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
- package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
- package/docs/GUIDE/index.md +4 -1
- package/fixing_plan.md +112 -0
- package/index.js +2 -0
- package/package.json +1 -1
- package/plugins/core/bulk-operations-plugin.js +5 -4
- package/plugins/core/connectors/express-plugin.js +6 -1
- package/plugins/core/connectors/fastify-plugin.js +5 -1
- package/plugins/core/connectors/lib/connector-core.js +17 -11
- package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
- package/plugins/core/file-handling-plugin.js +126 -96
- package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
- package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +36 -26
- package/plugins/core/lib/anyapi/schema-utils.js +1 -0
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +7 -1
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -15
- package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
- package/plugins/core/lib/querying/knex-relationship-includes.js +29 -28
- package/plugins/core/lib/querying/knex-window-queries.js +10 -77
- package/plugins/core/lib/querying/storage-adapter-utils.js +8 -8
- package/plugins/core/lib/querying/url-helpers.js +9 -21
- package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
- package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +6 -1
- package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
- package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
- package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +34 -41
- package/plugins/core/lib/storage/canonical-storage-mapping.js +2 -1
- package/plugins/core/lib/storage/storage-adapter.js +74 -13
- package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -3
- package/plugins/core/lib/writing/relationship-processor.js +10 -1
- package/plugins/core/rest-api-anyapi-knex-plugin.js +210 -97
- package/plugins/core/rest-api-knex-plugin.js +70 -60
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +8 -2
- package/plugins/core/rest-api-plugin-methods/common.js +85 -63
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +23 -13
- package/plugins/core/rest-api-plugin-methods/delete.js +2 -6
- package/plugins/core/rest-api-plugin-methods/get-related.js +15 -13
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +1 -1
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +17 -6
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -6
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +24 -14
- package/plugins/core/rest-api-plugin-methods/post.js +4 -7
- package/plugins/core/rest-api-plugin-methods/put.js +4 -7
- package/plugins/core/rest-api-plugin.js +6 -36
- package/plugins/core/rest-api-row-policy-plugin.js +157 -0
- package/plugins/storage/local-storage.js +44 -19
- package/plugins/storage/s3-storage.js +21 -55
- package/tests/README.md +1 -1
- package/tests/anyapi-cursor-pagination.test.js +7 -1
- package/tests/anyapi-custom-idproperty.test.js +163 -0
- package/tests/fastify-plugin.test.js +28 -0
- package/tests/field-setters.test.js +10 -10
- package/tests/file-handling.test.js +118 -0
- package/tests/fixtures/api-configs.js +383 -4
- package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
- package/tests/http-connectors-parity.test.js +165 -5
- package/tests/local-storage.test.js +96 -0
- package/tests/pagination-cursor-multifield.test.js +121 -0
- package/tests/pagination-enhanced.test.js +66 -15
- package/tests/post-commit-hooks.test.js +120 -0
- package/tests/public-exports.test.js +15 -0
- package/tests/put-create-parity.test.js +216 -0
- package/tests/relationship-endpoints.test.js +259 -0
- package/tests/return-record-settings.test.js +184 -0
- package/tests/row-policy.test.js +489 -0
- package/tests/s3-storage.test.js +38 -0
- package/tests/searchschema-merge.test.js +17 -0
- package/tests/socketio.test.js +62 -0
- package/tests/storage-mapping.test.js +1 -0
- 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
|
+
})
|