@ossy/resources 1.7.0 → 1.8.0
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/package.json +11 -2
- package/src/access-filter.spec.js +134 -0
- package/src/resources.queries.js +1 -1
- package/src/resources.spec.js +147 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/resources",
|
|
3
3
|
"description": "Resource domain — aggregate and events for the Ossy resource model",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.8.0",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.js",
|
|
@@ -9,11 +9,20 @@
|
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./src/index.js"
|
|
11
11
|
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "NODE_OPTIONS=--experimental-vm-modules jest --verbose"
|
|
14
|
+
},
|
|
12
15
|
"author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
|
|
13
16
|
"license": "MIT",
|
|
14
17
|
"ossy": {
|
|
15
18
|
"src": "./src"
|
|
16
19
|
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@jest/globals": "^30.2.0",
|
|
22
|
+
"@ossy/platform": "^1.35.0",
|
|
23
|
+
"casual": "^1.6.2",
|
|
24
|
+
"jest": "^30.2.0"
|
|
25
|
+
},
|
|
17
26
|
"publishConfig": {
|
|
18
27
|
"access": "public",
|
|
19
28
|
"registry": "https://registry.npmjs.org"
|
|
@@ -22,5 +31,5 @@
|
|
|
22
31
|
"/src",
|
|
23
32
|
"README.md"
|
|
24
33
|
],
|
|
25
|
-
"gitHead": "
|
|
34
|
+
"gitHead": "24df2bde0d5d8794c5a82b9e802a2594ca73a5d9"
|
|
26
35
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { accessFilter } from './resources.queries.js'
|
|
2
|
+
|
|
3
|
+
// Helper to build a minimal Policy aggregate document that policyToQueryClause expects.
|
|
4
|
+
function makePolicy({ effect = 'allow', actions = ['resource:read'], where = {} } = {}) {
|
|
5
|
+
return {
|
|
6
|
+
state: {
|
|
7
|
+
effect,
|
|
8
|
+
actions,
|
|
9
|
+
where: {
|
|
10
|
+
workspace: '*',
|
|
11
|
+
location: '*',
|
|
12
|
+
type: '*',
|
|
13
|
+
...where,
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// accessFilter
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
describe('accessFilter', () => {
|
|
24
|
+
|
|
25
|
+
describe('anonymous user', () => {
|
|
26
|
+
it('returns only the public condition', () => {
|
|
27
|
+
const filter = accessFilter({ anonymous: true })
|
|
28
|
+
expect(filter).toEqual({
|
|
29
|
+
$or: [
|
|
30
|
+
{ 'state.access': 'public' },
|
|
31
|
+
],
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('authenticated user with no workspaces and no policies', () => {
|
|
37
|
+
it('returns only the public condition', () => {
|
|
38
|
+
const user = { id: 'u-1', workspaces: [], policies: [] }
|
|
39
|
+
const filter = accessFilter(user)
|
|
40
|
+
expect(filter).toEqual({
|
|
41
|
+
$or: [
|
|
42
|
+
{ 'state.access': 'public' },
|
|
43
|
+
],
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('authenticated user with workspaces', () => {
|
|
49
|
+
it('includes workspace condition with all workspace ids in $in', () => {
|
|
50
|
+
const user = { id: 'u-1', workspaces: ['ws-1', 'ws-2'], policies: [] }
|
|
51
|
+
const filter = accessFilter(user)
|
|
52
|
+
expect(filter.$or).toContainEqual({
|
|
53
|
+
'state.access': { $in: ['public', 'workspace'] },
|
|
54
|
+
'state.belongsTo': { $in: ['ws-1', 'ws-2'] },
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('still includes the public condition alongside the workspace condition', () => {
|
|
59
|
+
const user = { id: 'u-1', workspaces: ['ws-1'], policies: [] }
|
|
60
|
+
const filter = accessFilter(user)
|
|
61
|
+
expect(filter.$or).toContainEqual({ 'state.access': 'public' })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('includes all workspace ids in the $in clause', () => {
|
|
65
|
+
const workspaces = ['ws-a', 'ws-b', 'ws-c']
|
|
66
|
+
const filter = accessFilter({ id: 'u-1', workspaces, policies: [] })
|
|
67
|
+
const workspaceCondition = filter.$or.find(c => c['state.belongsTo'])
|
|
68
|
+
expect(workspaceCondition['state.belongsTo'].$in).toEqual(workspaces)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('authenticated user with allow policies', () => {
|
|
73
|
+
it('includes a restricted condition for each matching allow policy', () => {
|
|
74
|
+
const policy = makePolicy({ where: { workspace: 'ws-1' } })
|
|
75
|
+
const user = { id: 'u-1', workspaces: [], policies: [policy] }
|
|
76
|
+
const filter = accessFilter(user)
|
|
77
|
+
expect(filter.$or).toContainEqual(
|
|
78
|
+
expect.objectContaining({ 'state.access': 'restricted', 'state.belongsTo': 'ws-1' })
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('policy with effect deny is not included', () => {
|
|
83
|
+
const denyPolicy = makePolicy({ effect: 'deny' })
|
|
84
|
+
const user = { id: 'u-1', workspaces: [], policies: [denyPolicy] }
|
|
85
|
+
const filter = accessFilter(user)
|
|
86
|
+
const restrictedConditions = filter.$or.filter(c => c['state.access'] === 'restricted')
|
|
87
|
+
expect(restrictedConditions).toHaveLength(0)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('policy without resource:read action is not included', () => {
|
|
91
|
+
const writeOnlyPolicy = makePolicy({ actions: ['resource:write'] })
|
|
92
|
+
const user = { id: 'u-1', workspaces: [], policies: [writeOnlyPolicy] }
|
|
93
|
+
const filter = accessFilter(user)
|
|
94
|
+
const restrictedConditions = filter.$or.filter(c => c['state.access'] === 'restricted')
|
|
95
|
+
expect(restrictedConditions).toHaveLength(0)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('wildcard workspace policy does not add state.belongsTo to the clause', () => {
|
|
99
|
+
const policy = makePolicy({ where: { workspace: '*' } })
|
|
100
|
+
const user = { id: 'u-1', workspaces: [], policies: [policy] }
|
|
101
|
+
const filter = accessFilter(user)
|
|
102
|
+
const restrictedCondition = filter.$or.find(c => c['state.access'] === 'restricted')
|
|
103
|
+
expect(restrictedCondition['state.belongsTo']).toBeUndefined()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('includes one restricted clause per valid allow policy', () => {
|
|
107
|
+
const policies = [
|
|
108
|
+
makePolicy({ where: { workspace: 'ws-1' } }),
|
|
109
|
+
makePolicy({ where: { workspace: 'ws-2' } }),
|
|
110
|
+
]
|
|
111
|
+
const user = { id: 'u-1', workspaces: [], policies }
|
|
112
|
+
const filter = accessFilter(user)
|
|
113
|
+
const restrictedConditions = filter.$or.filter(c => c['state.access'] === 'restricted')
|
|
114
|
+
expect(restrictedConditions).toHaveLength(2)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('null / undefined user', () => {
|
|
119
|
+
it('returns only the public condition for null user', () => {
|
|
120
|
+
const filter = accessFilter(null)
|
|
121
|
+
expect(filter.$or).toContainEqual({ 'state.access': 'public' })
|
|
122
|
+
const nonPublic = filter.$or.filter(c => c['state.access'] !== 'public')
|
|
123
|
+
expect(nonPublic).toHaveLength(0)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('returns only the public condition for undefined user', () => {
|
|
127
|
+
const filter = accessFilter(undefined)
|
|
128
|
+
expect(filter.$or).toContainEqual({ 'state.access': 'public' })
|
|
129
|
+
const nonPublic = filter.$or.filter(c => c['state.access'] !== 'public')
|
|
130
|
+
expect(nonPublic).toHaveLength(0)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
})
|
package/src/resources.queries.js
CHANGED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import casual from 'casual'
|
|
2
|
+
import { TestUtil } from '@ossy/platform/test'
|
|
3
|
+
|
|
4
|
+
function workspaceHeaders(userToken, workspaceId) {
|
|
5
|
+
return {
|
|
6
|
+
'Content-Type': 'application/json',
|
|
7
|
+
Authorization: userToken,
|
|
8
|
+
workspaceId,
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('[/resources][POST]', () => {
|
|
13
|
+
|
|
14
|
+
TestUtil.AssertAuthenticationNeeded({
|
|
15
|
+
endpoint: '/resources',
|
|
16
|
+
method: 'POST',
|
|
17
|
+
headers: { 'Content-Type': 'application/json', 'workspaceId': 'ws-test' },
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('[/resources][GET]', () => {
|
|
23
|
+
TestUtil.AssertAuthenticationNeeded({
|
|
24
|
+
endpoint: '/resources',
|
|
25
|
+
method: 'GET',
|
|
26
|
+
headers: { 'Content-Type': 'application/json', 'workspaceId': 'ws-test' },
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('[/resources/:resourceId][GET]', () => {
|
|
31
|
+
TestUtil.AssertAuthenticationNeeded({
|
|
32
|
+
endpoint: '/resources/r1',
|
|
33
|
+
method: 'GET',
|
|
34
|
+
headers: { 'Content-Type': 'application/json', 'workspaceId': 'ws-test' },
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('[/resources/:resourceId/name][PUT]', () => {
|
|
39
|
+
TestUtil.AssertAuthenticationNeeded({
|
|
40
|
+
endpoint: '/resources/r1/name',
|
|
41
|
+
method: 'PUT',
|
|
42
|
+
headers: { 'Content-Type': 'application/json', 'workspaceId': 'ws-test' },
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('[/resources/:resourceId/location][PUT]', () => {
|
|
47
|
+
TestUtil.AssertAuthenticationNeeded({
|
|
48
|
+
endpoint: '/resources/r1/location',
|
|
49
|
+
method: 'PUT',
|
|
50
|
+
headers: { 'Content-Type': 'application/json', 'workspaceId': 'ws-test' },
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('[/resources/:resourceId/content][PUT]', () => {
|
|
55
|
+
TestUtil.AssertAuthenticationNeeded({
|
|
56
|
+
endpoint: '/resources/r1/content',
|
|
57
|
+
method: 'PUT',
|
|
58
|
+
headers: { 'Content-Type': 'application/json', 'workspaceId': 'ws-test' },
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('[/resources/:resourceId][DELETE]', () => {
|
|
63
|
+
TestUtil.AssertAuthenticationNeeded({
|
|
64
|
+
endpoint: '/resources/r1',
|
|
65
|
+
method: 'DELETE',
|
|
66
|
+
headers: { 'Content-Type': 'application/json', 'workspaceId': 'ws-test' },
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('E2E: template-backed resource (workspaceId header)', () => {
|
|
71
|
+
it('create → update content → delete', async () => {
|
|
72
|
+
const user = await TestUtil.GetAuthenticatedTestUser()
|
|
73
|
+
const workspace = await TestUtil.MakeRequest({
|
|
74
|
+
endpoint: '/workspaces',
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'Content-Type': 'application/json', Authorization: user.token },
|
|
77
|
+
body: JSON.stringify({ name: casual.word }),
|
|
78
|
+
}).then(r => r.json())
|
|
79
|
+
|
|
80
|
+
const templateType = '@ossy/web/page'
|
|
81
|
+
const createBody = {
|
|
82
|
+
location: '/',
|
|
83
|
+
type: templateType,
|
|
84
|
+
name: `page-${casual.word}.json`,
|
|
85
|
+
content: {
|
|
86
|
+
title: 'Hello',
|
|
87
|
+
description: 'D',
|
|
88
|
+
body: '<p>initial</p>',
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const createRes = await TestUtil.MakeRequest({
|
|
93
|
+
endpoint: '/resources',
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: workspaceHeaders(user.token, workspace.id),
|
|
96
|
+
body: JSON.stringify(createBody),
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
expect(createRes.status).toEqual(200)
|
|
100
|
+
const created = await createRes.json()
|
|
101
|
+
expect(created.type).toEqual(templateType)
|
|
102
|
+
expect(created.content).toEqual({
|
|
103
|
+
title: 'Hello',
|
|
104
|
+
description: 'D',
|
|
105
|
+
body: '<p>initial</p>',
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const updateRes = await TestUtil.MakeRequest({
|
|
109
|
+
endpoint: `/resources/${created.id}/content`,
|
|
110
|
+
method: 'PUT',
|
|
111
|
+
headers: workspaceHeaders(user.token, workspace.id),
|
|
112
|
+
body: JSON.stringify({
|
|
113
|
+
content: {
|
|
114
|
+
title: 'Updated',
|
|
115
|
+
description: 'D2',
|
|
116
|
+
body: '<p>revised</p>',
|
|
117
|
+
},
|
|
118
|
+
}),
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
expect(updateRes.status).toEqual(200)
|
|
122
|
+
const updated = await updateRes.json()
|
|
123
|
+
expect(updated.content).toEqual({
|
|
124
|
+
title: 'Updated',
|
|
125
|
+
description: 'D2',
|
|
126
|
+
body: '<p>revised</p>',
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const deleteRes = await TestUtil.MakeRequest({
|
|
130
|
+
endpoint: `/resources/${created.id}`,
|
|
131
|
+
method: 'DELETE',
|
|
132
|
+
headers: workspaceHeaders(user.token, workspace.id),
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
expect(deleteRes.status).toEqual(204)
|
|
136
|
+
|
|
137
|
+
const listRes = await TestUtil.MakeRequest({
|
|
138
|
+
endpoint: '/resources?location=/',
|
|
139
|
+
method: 'GET',
|
|
140
|
+
headers: workspaceHeaders(user.token, workspace.id),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
expect(listRes.status).toEqual(200)
|
|
144
|
+
const list = await listRes.json()
|
|
145
|
+
expect(list.some(r => r.id === created.id)).toBe(false)
|
|
146
|
+
})
|
|
147
|
+
})
|