apostrophe 4.31.0 → 4.32.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/CHANGELOG.md +48 -1
- package/claude-tools/run-mocha-file.sh +29 -0
- package/modules/@apostrophecms/area/index.js +14 -5
- package/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js +72 -3
- package/modules/@apostrophecms/asset/index.js +170 -21
- package/modules/@apostrophecms/asset/lib/build/external-module-api.js +7 -16
- package/modules/@apostrophecms/asset/lib/build/internals.js +1 -3
- package/modules/@apostrophecms/asset/lib/build/task.js +7 -13
- package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +21 -1
- package/modules/@apostrophecms/doc-type/index.js +73 -15
- package/modules/@apostrophecms/http/index.js +175 -139
- package/modules/@apostrophecms/image/index.js +16 -7
- package/modules/@apostrophecms/image/ui/apos/components/AposMediaManager.vue +9 -1
- package/modules/@apostrophecms/job/index.js +3 -1
- package/modules/@apostrophecms/page/index.js +17 -1
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +10 -1
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapInsertItem.vue +3 -14
- package/modules/@apostrophecms/rich-text-widget/ui/apos/lib/remove-slash.js +18 -0
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +37 -6
- package/modules/@apostrophecms/url/index.js +54 -0
- package/package.json +11 -12
- package/test/asset-external.js +3 -0
- package/test/asset-lock-cache.js +243 -0
- package/test/assets.js +25 -2
- package/test/config-cascade-merge.js +73 -0
- package/test/files.js +10 -11
- package/test/image-tags.js +103 -0
- package/test/modules/collision-piece/index.js +23 -0
- package/test/pages-move-permissions.js +317 -0
- package/test/pages.js +12 -15
- package/test/pieces-public-api-relationship-choices.js +245 -0
- package/test/pieces.js +12 -15
- package/test-lib/test.js +32 -0
package/test/files.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
const t = require('../test-lib/test.js');
|
|
2
2
|
const assert = require('assert/strict');
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
// rawGet (raw node:http) lets this suite send a spoofed `Host` header, which
|
|
5
|
+
// the built-in fetch used by apos.http would drop as a forbidden header.
|
|
6
|
+
const { rawGet } = t;
|
|
4
7
|
|
|
5
8
|
describe('Files', function() {
|
|
6
9
|
|
|
@@ -142,17 +145,13 @@ describe('Files', function() {
|
|
|
142
145
|
const attachment = apos.attachment.first(file);
|
|
143
146
|
const url = apos.attachment.url(attachment);
|
|
144
147
|
assert(url);
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
Host: '169.254.169.254'
|
|
153
|
-
},
|
|
154
|
-
fullResponse: true
|
|
155
|
-
});
|
|
148
|
+
// Spoof the Host header (the cloud-metadata address from the advisory)
|
|
149
|
+
// over a raw request: apos.http uses the built-in fetch, which drops a
|
|
150
|
+
// forbidden `Host` header and so cannot deliver the spoof. The server
|
|
151
|
+
// must resolve the upstream fetch against its trusted baseUrl, not this
|
|
152
|
+
// header, so the legitimate content is still served and the request is
|
|
153
|
+
// never steered at the spoofed host.
|
|
154
|
+
const response = await rawGet(apos, url, { Host: '169.254.169.254' });
|
|
156
155
|
assert.strictEqual(response.status, 200);
|
|
157
156
|
assert.strictEqual(response.body, attachment.data);
|
|
158
157
|
} finally {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const t = require('../test-lib/test.js');
|
|
2
|
+
const assert = require('assert/strict');
|
|
3
|
+
|
|
4
|
+
describe('Image tags', function() {
|
|
5
|
+
|
|
6
|
+
let apos;
|
|
7
|
+
let jar;
|
|
8
|
+
|
|
9
|
+
this.timeout(t.timeout);
|
|
10
|
+
|
|
11
|
+
// Initialization, not a test: boot apostrophe and log in an admin, so any
|
|
12
|
+
// single test below can run in isolation (e.g. with `.only`).
|
|
13
|
+
before(async function() {
|
|
14
|
+
apos = await t.create({
|
|
15
|
+
root: module
|
|
16
|
+
});
|
|
17
|
+
assert(apos.image);
|
|
18
|
+
assert(apos.modules['@apostrophecms/image-tag']);
|
|
19
|
+
|
|
20
|
+
const user = apos.user.newInstance();
|
|
21
|
+
Object.assign(user, {
|
|
22
|
+
title: 'admin',
|
|
23
|
+
username: 'admin',
|
|
24
|
+
password: 'admin',
|
|
25
|
+
email: 'ad@min.com',
|
|
26
|
+
role: 'admin'
|
|
27
|
+
});
|
|
28
|
+
await apos.user.insert(apos.task.getReq(), user);
|
|
29
|
+
jar = await login('admin');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
after(function() {
|
|
33
|
+
return t.destroy(apos);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should reuse an existing tag instead of creating a duplicate', async function() {
|
|
37
|
+
const manager = apos.modules['@apostrophecms/image-tag'];
|
|
38
|
+
// A tag the editor's popover would not necessarily have loaded.
|
|
39
|
+
const existing = await manager.insert(apos.task.getReq(), {
|
|
40
|
+
...manager.newInstance(),
|
|
41
|
+
title: 'Reused Tag'
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const response = await tagCreate('Reused Tag');
|
|
45
|
+
assert(response.jobId);
|
|
46
|
+
|
|
47
|
+
const ids = await distinctTagIds('Reused Tag');
|
|
48
|
+
assert.equal(ids.length, 1, 'exactly one image-tag for the title');
|
|
49
|
+
assert.equal(ids[0], existing.aposDocId, 'the existing tag was reused');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should not create duplicates under concurrent create requests', async function() {
|
|
53
|
+
const title = 'Concurrent Tag';
|
|
54
|
+
|
|
55
|
+
await Promise.all([
|
|
56
|
+
tagCreate(title),
|
|
57
|
+
tagCreate(title)
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const ids = await distinctTagIds(title);
|
|
61
|
+
assert.equal(ids.length, 1, 'exactly one image-tag despite concurrent creates');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// The `tag` route runs the find-or-create before the (here empty) batch
|
|
65
|
+
// job, so no images are needed to exercise it.
|
|
66
|
+
async function tagCreate(title) {
|
|
67
|
+
return apos.http.post('/api/v1/@apostrophecms/image/tag', {
|
|
68
|
+
body: {
|
|
69
|
+
_ids: [],
|
|
70
|
+
operation: 'create',
|
|
71
|
+
title
|
|
72
|
+
},
|
|
73
|
+
jar
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function distinctTagIds(title) {
|
|
78
|
+
const docs = await apos.doc.db
|
|
79
|
+
.find({
|
|
80
|
+
type: '@apostrophecms/image-tag',
|
|
81
|
+
title
|
|
82
|
+
})
|
|
83
|
+
.toArray();
|
|
84
|
+
return [ ...new Set(docs.map(doc => doc.aposDocId)) ];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function login(username, password = username) {
|
|
88
|
+
const loginJar = apos.http.jar();
|
|
89
|
+
let page = await apos.http.get('/', { jar: loginJar });
|
|
90
|
+
assert(page.match(/logged out/));
|
|
91
|
+
await apos.http.post('/api/v1/@apostrophecms/login/login', {
|
|
92
|
+
body: {
|
|
93
|
+
username,
|
|
94
|
+
password,
|
|
95
|
+
session: true
|
|
96
|
+
},
|
|
97
|
+
jar: loginJar
|
|
98
|
+
});
|
|
99
|
+
page = await apos.http.get('/', { jar: loginJar });
|
|
100
|
+
assert(page.match(/logged in/));
|
|
101
|
+
return loginJar;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Project-level file for a module that is ALSO configured (with fields)
|
|
2
|
+
// via the apostrophe() config object, to reproduce PRO-9564.
|
|
3
|
+
module.exports = {
|
|
4
|
+
// An index.js-only option: it should survive via the gap-fill pass, unlike
|
|
5
|
+
// the project-level fields, which get dropped wholesale.
|
|
6
|
+
options: {
|
|
7
|
+
fromProjectFile: true
|
|
8
|
+
},
|
|
9
|
+
fields: {
|
|
10
|
+
add: {
|
|
11
|
+
projectField: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
label: 'Project Field'
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
group: {
|
|
17
|
+
projectGroup: {
|
|
18
|
+
label: 'Project Group',
|
|
19
|
+
fields: [ 'projectField' ]
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
};
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// Regression tests for GHSA-wr5r-wqp2-x4fh:
|
|
2
|
+
// "Missing destination-parent authorization in page move() allows a
|
|
3
|
+
// low-privileged editor to move and re-rank pages inside a restricted subtree"
|
|
4
|
+
//
|
|
5
|
+
// A regression in the move() authorization guard silently disabled the
|
|
6
|
+
// destination "create" permission check for every normal (non-archive) move.
|
|
7
|
+
// The only surviving gate was moved._edit ("can the actor edit the page being
|
|
8
|
+
// moved"), which an editor legitimately holds for their own ordinary pages.
|
|
9
|
+
// As a result a non-admin editor could relocate a page they control under a
|
|
10
|
+
// parent of a restricted page type (e.g. one declaring editRole: 'admin')
|
|
11
|
+
// that they have no create/edit rights over, and in doing so trigger an
|
|
12
|
+
// unchecked updateMany that re-ranks the restricted parent's existing
|
|
13
|
+
// children (documents the actor cannot edit).
|
|
14
|
+
//
|
|
15
|
+
// These tests exercise the real apos.page.move() code path (and the REST
|
|
16
|
+
// route it backs) with a non-admin request and assert that the destination
|
|
17
|
+
// authorization boundary is enforced, while confirming legitimate moves and
|
|
18
|
+
// the archive restore flow are still permitted.
|
|
19
|
+
|
|
20
|
+
const t = require('../test-lib/test.js');
|
|
21
|
+
const assert = require('assert/strict');
|
|
22
|
+
|
|
23
|
+
describe('pages - move destination permission (GHSA-wr5r-wqp2-x4fh)', function () {
|
|
24
|
+
this.timeout(t.timeout);
|
|
25
|
+
|
|
26
|
+
let apos;
|
|
27
|
+
let homeId;
|
|
28
|
+
|
|
29
|
+
after(async function () {
|
|
30
|
+
await t.destroy(apos);
|
|
31
|
+
apos = null;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
before(async function () {
|
|
35
|
+
apos = await t.create({
|
|
36
|
+
root: module,
|
|
37
|
+
modules: {
|
|
38
|
+
// A restricted page type: only admins may create/edit pages of this
|
|
39
|
+
// type. This is the same shape as the core @apostrophecms/archive-page
|
|
40
|
+
// (editRole/publishRole: 'admin') and of restricted "section" page
|
|
41
|
+
// types projects commonly configure.
|
|
42
|
+
'secret-page': {
|
|
43
|
+
extend: '@apostrophecms/page-type',
|
|
44
|
+
options: {
|
|
45
|
+
editRole: 'admin',
|
|
46
|
+
publishRole: 'admin'
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
// An ordinary page type any editor may create/edit.
|
|
50
|
+
'public-page': {
|
|
51
|
+
extend: '@apostrophecms/page-type'
|
|
52
|
+
},
|
|
53
|
+
'@apostrophecms/page': {
|
|
54
|
+
options: {
|
|
55
|
+
park: [],
|
|
56
|
+
types: [
|
|
57
|
+
{
|
|
58
|
+
name: '@apostrophecms/home-page',
|
|
59
|
+
label: 'Home'
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'secret-page',
|
|
63
|
+
label: 'Secret'
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'public-page',
|
|
67
|
+
label: 'Public'
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const home = await apos.page
|
|
76
|
+
.find(apos.task.getReq({ role: 'admin' }), { level: 0 })
|
|
77
|
+
.toObject();
|
|
78
|
+
assert.ok(home, 'home page should exist');
|
|
79
|
+
homeId = home._id;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// A non-admin editor request. Editors may create/edit ordinary pages but
|
|
83
|
+
// NOT pages of a type gated by editRole: 'admin'.
|
|
84
|
+
function editorReq() {
|
|
85
|
+
return apos.task.getReq({
|
|
86
|
+
role: 'editor',
|
|
87
|
+
user: {
|
|
88
|
+
_id: 'editor-user',
|
|
89
|
+
title: 'Editor',
|
|
90
|
+
role: 'editor'
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function adminReq() {
|
|
96
|
+
return apos.task.getReq({ role: 'admin' });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Create an admin-only "secret" section with one pre-existing admin-only
|
|
100
|
+
// child, plus an ordinary editor-editable page under home. `slugPrefix`
|
|
101
|
+
// keeps each test's fixtures independent within the shared database.
|
|
102
|
+
async function fixtures(slugPrefix) {
|
|
103
|
+
const admin = adminReq();
|
|
104
|
+
|
|
105
|
+
const secret = await apos.page.insert(admin, homeId, 'lastChild', {
|
|
106
|
+
title: 'Secret Section',
|
|
107
|
+
type: 'secret-page',
|
|
108
|
+
slug: `/${slugPrefix}-secret`
|
|
109
|
+
});
|
|
110
|
+
const secretChild = await apos.page.insert(admin, secret._id, 'lastChild', {
|
|
111
|
+
title: 'Secret Child',
|
|
112
|
+
type: 'secret-page',
|
|
113
|
+
slug: `/${slugPrefix}-secret/child`
|
|
114
|
+
});
|
|
115
|
+
const mine = await apos.page.insert(admin, homeId, 'lastChild', {
|
|
116
|
+
title: 'My Page',
|
|
117
|
+
type: 'public-page',
|
|
118
|
+
slug: `/${slugPrefix}-mine`
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
secret,
|
|
123
|
+
secretChild,
|
|
124
|
+
mine
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
it('precondition: an editor has no create/edit rights on an admin-only section', async function () {
|
|
129
|
+
const { secret } = await fixtures('precondition');
|
|
130
|
+
|
|
131
|
+
const secretForEditor = await apos.page
|
|
132
|
+
.find(editorReq(), { _id: secret._id })
|
|
133
|
+
.permission(false)
|
|
134
|
+
.toObject();
|
|
135
|
+
|
|
136
|
+
assert.ok(secretForEditor, 'the section is locatable regardless of rights');
|
|
137
|
+
assert.notEqual(
|
|
138
|
+
secretForEditor._create,
|
|
139
|
+
true,
|
|
140
|
+
'editor must NOT have create rights on the admin-only section'
|
|
141
|
+
);
|
|
142
|
+
assert.notEqual(
|
|
143
|
+
secretForEditor._edit,
|
|
144
|
+
true,
|
|
145
|
+
'editor must NOT have edit rights on the admin-only section'
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('forbids an editor from moving their page under an admin-only section', async function () {
|
|
150
|
+
const { secret, mine } = await fixtures('forbid-move');
|
|
151
|
+
|
|
152
|
+
await assert.rejects(
|
|
153
|
+
apos.page.move(editorReq(), mine._id, secret._id, 'firstChild'),
|
|
154
|
+
{ name: 'forbidden' },
|
|
155
|
+
'moving a page under a parent the actor cannot create within must be forbidden'
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// The page must NOT have been relocated under the restricted branch.
|
|
159
|
+
const moved = await apos.page
|
|
160
|
+
.find(adminReq(), { _id: mine._id })
|
|
161
|
+
.toObject();
|
|
162
|
+
assert.ok(
|
|
163
|
+
!moved.path.includes(secret.aposDocId),
|
|
164
|
+
'the editor must not have relocated their page under the admin-only section'
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('does not re-rank an admin-only section\'s protected children on a forbidden move', async function () {
|
|
169
|
+
const {
|
|
170
|
+
secret, secretChild, mine
|
|
171
|
+
} = await fixtures('no-rerank');
|
|
172
|
+
|
|
173
|
+
const before = await apos.page
|
|
174
|
+
.find(adminReq(), { _id: secretChild._id })
|
|
175
|
+
.toObject();
|
|
176
|
+
|
|
177
|
+
// 'firstChild' would place the moved page at rank 0 and (if the move were
|
|
178
|
+
// wrongly allowed) nudge the pre-existing admin-only child from rank 0 to
|
|
179
|
+
// rank 1 via an unchecked updateMany.
|
|
180
|
+
await assert.rejects(
|
|
181
|
+
apos.page.move(editorReq(), mine._id, secret._id, 'firstChild'),
|
|
182
|
+
{ name: 'forbidden' }
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const after = await apos.page
|
|
186
|
+
.find(adminReq(), { _id: secretChild._id })
|
|
187
|
+
.toObject();
|
|
188
|
+
|
|
189
|
+
assert.equal(
|
|
190
|
+
after.rank,
|
|
191
|
+
before.rank,
|
|
192
|
+
'the protected sibling\'s rank must be untouched by a forbidden move'
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('still allows an editor to move their page under a parent they may create within', async function () {
|
|
197
|
+
const { mine } = await fixtures('allow-move');
|
|
198
|
+
|
|
199
|
+
// Another ordinary parent the editor legitimately controls.
|
|
200
|
+
const otherParent = await apos.page.insert(adminReq(), homeId, 'lastChild', {
|
|
201
|
+
title: 'Other Parent',
|
|
202
|
+
type: 'public-page',
|
|
203
|
+
slug: '/allow-move-other'
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Sanity: the editor genuinely has create rights on the destination.
|
|
207
|
+
const otherForEditor = await apos.page
|
|
208
|
+
.find(editorReq(), { _id: otherParent._id })
|
|
209
|
+
.permission(false)
|
|
210
|
+
.toObject();
|
|
211
|
+
assert.equal(
|
|
212
|
+
otherForEditor._create,
|
|
213
|
+
true,
|
|
214
|
+
'precondition: editor has create rights on the ordinary destination'
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
await assert.doesNotReject(
|
|
218
|
+
apos.page.move(editorReq(), mine._id, otherParent._id, 'firstChild'),
|
|
219
|
+
'a move into a destination the editor may create within must be allowed'
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const moved = await apos.page.find(adminReq(), { _id: mine._id }).toObject();
|
|
223
|
+
assert.ok(
|
|
224
|
+
moved.path.includes(otherParent.aposDocId),
|
|
225
|
+
'the page should now live under the permitted destination'
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('still allows an admin to move a page under the admin-only section', async function () {
|
|
230
|
+
const { secret, mine } = await fixtures('admin-move');
|
|
231
|
+
|
|
232
|
+
await assert.doesNotReject(
|
|
233
|
+
apos.page.move(adminReq(), mine._id, secret._id, 'firstChild'),
|
|
234
|
+
'an admin has create rights on the section and may move pages into it'
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const moved = await apos.page.find(adminReq(), { _id: mine._id }).toObject();
|
|
238
|
+
assert.ok(
|
|
239
|
+
moved.path.includes(secret.aposDocId),
|
|
240
|
+
'the admin-moved page should live under the section'
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('still allows archiving a page and restoring it out of the archive', async function () {
|
|
245
|
+
// Exercises the guard\'s archive-restore branch: a move whose old parent
|
|
246
|
+
// is the archive page must remain permitted for a destination the actor
|
|
247
|
+
// may edit. Using an editor on an ordinary page they fully control.
|
|
248
|
+
const { mine } = await fixtures('restore');
|
|
249
|
+
|
|
250
|
+
// Archive: moves the page under the archive (old parent becomes archive).
|
|
251
|
+
await assert.doesNotReject(
|
|
252
|
+
apos.page.archive(editorReq(), mine._id),
|
|
253
|
+
'an editor may archive their own ordinary page'
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const archived = await apos.page
|
|
257
|
+
.find(adminReq(), { _id: mine._id })
|
|
258
|
+
.archived(null)
|
|
259
|
+
.ancestors({ archived: null })
|
|
260
|
+
.toObject();
|
|
261
|
+
assert.equal(archived.archived, true, 'the page should be archived');
|
|
262
|
+
assert.equal(
|
|
263
|
+
archived._ancestors[archived._ancestors.length - 1].type,
|
|
264
|
+
'@apostrophecms/archive-page',
|
|
265
|
+
'the archived page\'s parent should be the archive'
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
// Restore: move it back out of the archive, under home.
|
|
269
|
+
await assert.doesNotReject(
|
|
270
|
+
apos.page.move(editorReq(), mine._id, homeId, 'lastChild'),
|
|
271
|
+
'restoring a page out of the archive to a destination the actor may edit must be allowed'
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const restored = await apos.page
|
|
275
|
+
.find(adminReq(), { _id: mine._id })
|
|
276
|
+
.toObject();
|
|
277
|
+
assert.ok(restored, 'the restored page should be live again');
|
|
278
|
+
assert.notEqual(restored.archived, true, 'the restored page should not be archived');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('forbids the same move over the REST PATCH route for a logged-in editor', async function () {
|
|
282
|
+
const { secret, mine } = await fixtures('rest');
|
|
283
|
+
|
|
284
|
+
// A real, logged-in editor session.
|
|
285
|
+
await t.createUser(apos, 'editor', { username: 'rest-editor' });
|
|
286
|
+
const jar = await t.loginAs(apos, 'rest-editor');
|
|
287
|
+
// A safe request first, so the jar carries the CSRF cookie the write
|
|
288
|
+
// routes require. Otherwise the PATCH is rejected for CSRF before it can
|
|
289
|
+
// ever reach move(), which would mask the authorization check under test.
|
|
290
|
+
await apos.http.get('/', { jar });
|
|
291
|
+
|
|
292
|
+
const draftId = mine._id.replace(':published', ':draft');
|
|
293
|
+
const targetDraftId = secret._id.replace(':published', ':draft');
|
|
294
|
+
|
|
295
|
+
let status = null;
|
|
296
|
+
try {
|
|
297
|
+
await apos.http.patch(`/api/v1/@apostrophecms/page/${draftId}`, {
|
|
298
|
+
body: {
|
|
299
|
+
_targetId: targetDraftId,
|
|
300
|
+
_position: 'firstChild'
|
|
301
|
+
},
|
|
302
|
+
jar
|
|
303
|
+
});
|
|
304
|
+
} catch (e) {
|
|
305
|
+
status = e.status;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
assert.equal(status, 403, 'the REST move must be rejected with 403 Forbidden');
|
|
309
|
+
|
|
310
|
+
// Confirm no relocation happened.
|
|
311
|
+
const moved = await apos.page.find(adminReq(), { _id: mine._id }).toObject();
|
|
312
|
+
assert.ok(
|
|
313
|
+
!moved.path.includes(secret.aposDocId),
|
|
314
|
+
'the editor must not have relocated their page under the admin-only section via REST'
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
});
|
package/test/pages.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
const t = require('../test-lib/test.js');
|
|
2
2
|
const assert = require('assert');
|
|
3
3
|
const _ = require('lodash');
|
|
4
|
+
// The REST API etag tests below issue their conditional request via rawGet
|
|
5
|
+
// (raw node:http): the built-in fetch used by apos.http adds Cache-Control:
|
|
6
|
+
// no-cache to any request carrying a conditional header (Fetch standard), which
|
|
7
|
+
// would suppress the asserted 304s. The page-serving etag tests stay on
|
|
8
|
+
// apos.http (those routes set 304 explicitly).
|
|
9
|
+
const { rawGet } = t;
|
|
4
10
|
|
|
5
11
|
describe('Pages', function() {
|
|
6
12
|
let apos;
|
|
@@ -1087,11 +1093,8 @@ describe('Pages', function() {
|
|
|
1087
1093
|
};
|
|
1088
1094
|
|
|
1089
1095
|
const response1 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, { fullResponse: true });
|
|
1090
|
-
const response2 = await apos
|
|
1091
|
-
|
|
1092
|
-
headers: {
|
|
1093
|
-
'if-none-match': response1.headers.etag
|
|
1094
|
-
}
|
|
1096
|
+
const response2 = await rawGet(apos, `/api/v1/@apostrophecms/page/${homeId}`, {
|
|
1097
|
+
'if-none-match': response1.headers.etag
|
|
1095
1098
|
});
|
|
1096
1099
|
|
|
1097
1100
|
assert(response1.status === 200);
|
|
@@ -1125,11 +1128,8 @@ describe('Pages', function() {
|
|
|
1125
1128
|
// so requesting it again should not return a 304 status code
|
|
1126
1129
|
const pageUpdateResponse = await apos.doc.update(apos.task.getReq(), pageDoc);
|
|
1127
1130
|
|
|
1128
|
-
const response2 = await apos
|
|
1129
|
-
|
|
1130
|
-
headers: {
|
|
1131
|
-
'if-none-match': response1.headers.etag
|
|
1132
|
-
}
|
|
1131
|
+
const response2 = await rawGet(apos, `/api/v1/@apostrophecms/page/${homeId}`, {
|
|
1132
|
+
'if-none-match': response1.headers.etag
|
|
1133
1133
|
});
|
|
1134
1134
|
|
|
1135
1135
|
const eTag1Parts = response1.headers.etag.split(':');
|
|
@@ -1165,11 +1165,8 @@ describe('Pages', function() {
|
|
|
1165
1165
|
outOfDateETagParts[2] = Number(outOfDateETagParts[2]) -
|
|
1166
1166
|
(4444 + 1) * 1000; // 1s outdated
|
|
1167
1167
|
|
|
1168
|
-
const response2 = await apos
|
|
1169
|
-
|
|
1170
|
-
headers: {
|
|
1171
|
-
'if-none-match': outOfDateETagParts.join(':')
|
|
1172
|
-
}
|
|
1168
|
+
const response2 = await rawGet(apos, `/api/v1/@apostrophecms/page/${homeId}`, {
|
|
1169
|
+
'if-none-match': outOfDateETagParts.join(':')
|
|
1173
1170
|
});
|
|
1174
1171
|
|
|
1175
1172
|
const eTag1Parts = response1.headers.etag.split(':');
|