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.
- package/README.md +24 -2
- package/docs/API.md +157 -33
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +88 -6
- 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_Knex_Schema_And_Migrations.md +122 -2
- package/docs/GUIDE/GUIDE_X_Row_Policies.md +603 -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/lib/enhanced-logger.js +1 -1
- package/package.json +6 -2
- package/plugins/core/bulk-operations-plugin.js +31 -38
- package/plugins/core/connectors/express-plugin.js +6 -7
- package/plugins/core/connectors/fastify-plugin.js +5 -1
- package/plugins/core/connectors/lib/busboy-detector.js +4 -4
- 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/schema-utils.js +44 -4
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +11 -2
- package/plugins/core/lib/dbIntrospection.js +1 -1
- package/plugins/core/lib/dbTablesOperations.js +10 -3
- package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -16
- package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
- package/plugins/core/lib/querying/knex-query-helpers.js +5 -6
- package/plugins/core/lib/querying/knex-relationship-includes.js +40 -46
- package/plugins/core/lib/querying/knex-window-queries.js +10 -77
- package/plugins/core/lib/querying/url-helpers.js +9 -21
- package/plugins/core/lib/querying-writing/compile-schemas.js +10 -6
- 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 +7 -3
- 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/resource-id-normalization.js +131 -0
- package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +34 -43
- package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
- package/plugins/core/lib/storage/canonical-storage-mapping.js +24 -3
- package/plugins/core/lib/storage/storage-adapter.js +76 -14
- package/plugins/core/lib/storage/storage-mapping.js +86 -16
- package/plugins/core/lib/writing/many-to-many-manipulations.js +8 -5
- package/plugins/core/lib/writing/relationship-processor.js +10 -1
- package/plugins/core/rest-api-anyapi-knex-plugin.js +402 -197
- package/plugins/core/rest-api-knex-plugin.js +93 -76
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +9 -2
- package/plugins/core/rest-api-plugin-methods/common.js +112 -72
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +34 -15
- package/plugins/core/rest-api-plugin-methods/delete.js +8 -7
- package/plugins/core/rest-api-plugin-methods/get-related.js +22 -15
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +8 -3
- package/plugins/core/rest-api-plugin-methods/get.js +7 -1
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +26 -8
- package/plugins/core/rest-api-plugin-methods/patch.js +34 -9
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +35 -16
- package/plugins/core/rest-api-plugin-methods/post.js +12 -7
- package/plugins/core/rest-api-plugin-methods/put.js +35 -10
- package/plugins/core/rest-api-plugin-methods/release.js +0 -2
- package/plugins/core/rest-api-plugin.js +11 -37
- package/plugins/core/rest-api-positioning-plugin.js +1 -4
- package/plugins/core/rest-api-row-policy-plugin.js +157 -0
- package/plugins/core/socketio-plugin.js +0 -1
- package/plugins/storage/local-storage.js +45 -20
- package/plugins/storage/s3-storage.js +23 -54
- package/quickTest.js +1 -0
- package/scripts/run-docs.js +80 -84
- package/test-debug.js +1 -1
- package/tests/README.md +8 -1
- package/tests/anyapi-basic.test.js +3 -0
- package/tests/anyapi-cursor-pagination.test.js +7 -1
- package/tests/anyapi-custom-idproperty.test.js +163 -0
- package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
- package/tests/db-introspection.test.js +4 -1
- package/tests/db-table-operations.test.js +25 -20
- package/tests/fastify-plugin.test.js +49 -6
- package/tests/field-setters.test.js +10 -10
- package/tests/file-handling.test.js +118 -0
- package/tests/fixtures/api-configs.js +495 -4
- package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
- package/tests/http-connectors-parity.test.js +165 -5
- package/tests/id-normalization.test.js +444 -0
- package/tests/local-storage.test.js +96 -0
- package/tests/logical-id-write-validation.test.js +151 -0
- package/tests/pagination-cursor-multifield.test.js +132 -11
- 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 +433 -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 +56 -6
- 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
|
})
|