apostrophe 4.31.1-beta.1 → 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 +43 -2
- 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/oembed/index.js +12 -45
- 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/oembed.js +0 -98
- 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/assets.js
CHANGED
|
@@ -171,6 +171,29 @@ describe('Assets', function() {
|
|
|
171
171
|
|
|
172
172
|
this.timeout(5 * 60 * 1000);
|
|
173
173
|
|
|
174
|
+
it('should include the site prefix in asset URLs', async function() {
|
|
175
|
+
let prefixApos;
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
prefixApos = await t.create({
|
|
179
|
+
root: module,
|
|
180
|
+
prefix: '/apos'
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
assert.equal(
|
|
184
|
+
prefixApos.asset.getAssetBaseUrl(),
|
|
185
|
+
'/apos/apos-frontend/default'
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
assert.equal(
|
|
189
|
+
prefixApos.asset.url('/modules/foo/bar.js'),
|
|
190
|
+
'/apos/apos-frontend/default/modules/foo/bar.js'
|
|
191
|
+
);
|
|
192
|
+
} finally {
|
|
193
|
+
await t.destroy(prefixApos);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
174
197
|
it('should exist on the apos object', async function() {
|
|
175
198
|
apos = await t.create({
|
|
176
199
|
root: module,
|
|
@@ -409,10 +432,10 @@ describe('Assets', function() {
|
|
|
409
432
|
assert(meta2['default:src']);
|
|
410
433
|
|
|
411
434
|
// Caching should provide a measurable speedup. The threshold is kept
|
|
412
|
-
// low (
|
|
435
|
+
// low (5%) to avoid flaky failures on loaded CI runners where the
|
|
413
436
|
// cold run can be fast due to OS-level caching.
|
|
414
437
|
const gain = (execTime - execTimeCached) / execTime * 100;
|
|
415
|
-
assert(gain >=
|
|
438
|
+
assert(gain >= 5, `Expected gain >=5%, got ${gain}%`);
|
|
416
439
|
|
|
417
440
|
// Modification times
|
|
418
441
|
assert(meta['default:apos'].mdate);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const t = require('../test-lib/test.js');
|
|
2
|
+
const assert = require('assert');
|
|
3
|
+
|
|
4
|
+
// PRO-9564
|
|
5
|
+
// This test pins an established moog-require contract so it is not "fixed" by
|
|
6
|
+
// accident — changing it would silently break existing projects.
|
|
7
|
+
//
|
|
8
|
+
// Scope: the SAME module configured BOTH via the app.js object passed to
|
|
9
|
+
// apostrophe() AND its project-level index.js. (NOT cascade merging across the
|
|
10
|
+
// module chain via extends/improvements, which merges field-by-field as usual.)
|
|
11
|
+
//
|
|
12
|
+
// moog-require collapses the two sources with a shallow `_.defaults` before the
|
|
13
|
+
// chain is built, so app.js wins for any top-level section declared in both.
|
|
14
|
+
// `options` then gets a second pass that gap-fills from index.js, but no other
|
|
15
|
+
// section does — so a cascade like `fields` is NOT merged: an app.js `fields`
|
|
16
|
+
// replaces the index.js `fields` wholesale, dropping even unrelated
|
|
17
|
+
// project-level fields. The options-only gap-fill is by design.
|
|
18
|
+
//
|
|
19
|
+
// Example — this test and its fixture:
|
|
20
|
+
// app.js modules: { 'collision-piece': { fields: { add: { configField } } } }
|
|
21
|
+
// index.js test/modules/collision-piece/index.js -> fields: { add: { projectField } }
|
|
22
|
+
// result schema has `configField`; `projectField` is gone, although the two
|
|
23
|
+
// fields are unrelated.
|
|
24
|
+
// options app.js { alias: ... } still merges with any index.js options
|
|
25
|
+
// (gap-fill), unlike `fields`.
|
|
26
|
+
//
|
|
27
|
+
// Files: collapse in packages/apostrophe/lib/moog-require.js (~`_.defaults(
|
|
28
|
+
// definition, projectLevelDefinition)`); cascade compiler that the dropped
|
|
29
|
+
// section never reaches in packages/apostrophe/lib/moog.js; project-level
|
|
30
|
+
// fixture in test/modules/collision-piece/index.js.
|
|
31
|
+
|
|
32
|
+
describe('config-object vs project-level cascade merge (moog-require contract)', function () {
|
|
33
|
+
this.timeout(t.timeout);
|
|
34
|
+
let apos;
|
|
35
|
+
after(async () => {
|
|
36
|
+
await t.destroy(apos);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('app.js `fields` wins over the project-level file when both declare it', async function () {
|
|
40
|
+
apos = await t.create({
|
|
41
|
+
root: module,
|
|
42
|
+
modules: {
|
|
43
|
+
'collision-piece': {
|
|
44
|
+
extend: '@apostrophecms/piece-type',
|
|
45
|
+
options: { alias: 'collisionPiece' },
|
|
46
|
+
fields: {
|
|
47
|
+
add: {
|
|
48
|
+
configField: {
|
|
49
|
+
type: 'string',
|
|
50
|
+
label: 'Config Field'
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
group: {
|
|
54
|
+
configGroup: {
|
|
55
|
+
label: 'Config Group',
|
|
56
|
+
fields: [ 'configField' ]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const names = apos.collisionPiece.schema.map(f => f.name);
|
|
65
|
+
// The app.js (config object) fields win.
|
|
66
|
+
assert(names.includes('configField'));
|
|
67
|
+
// The project-level file's fields are dropped wholesale — the contract.
|
|
68
|
+
assert(!names.includes('projectField'));
|
|
69
|
+
// Options are the exception: an index.js-only option is gap-filled in,
|
|
70
|
+
// where a project-level field would have been dropped.
|
|
71
|
+
assert.strictEqual(apos.collisionPiece.options.fromProjectFile, true);
|
|
72
|
+
});
|
|
73
|
+
});
|
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
|
+
};
|
package/test/oembed.js
CHANGED
|
@@ -53,102 +53,4 @@ describe('Oembed', function() {
|
|
|
53
53
|
// const data = JSON.parse(response.body);
|
|
54
54
|
// assert(data.type === 'video');
|
|
55
55
|
// });
|
|
56
|
-
|
|
57
|
-
it('should cache successful responses and not re-fetch them', async function() {
|
|
58
|
-
const req = apos.task.getReq();
|
|
59
|
-
const url = 'https://example.com/good-video';
|
|
60
|
-
let calls = 0;
|
|
61
|
-
const original = apos.oembed.oembetter.fetch;
|
|
62
|
-
apos.oembed.oembetter.fetch = (fetchUrl, options, cb) => {
|
|
63
|
-
calls++;
|
|
64
|
-
return cb(null, {
|
|
65
|
-
type: 'video',
|
|
66
|
-
title: 'Success',
|
|
67
|
-
html: '<iframe src="//example.com/embed"></iframe>'
|
|
68
|
-
});
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
try {
|
|
72
|
-
const first = await apos.oembed.query(req, url);
|
|
73
|
-
assert.strictEqual(first.title, 'Success');
|
|
74
|
-
assert.strictEqual(calls, 1);
|
|
75
|
-
|
|
76
|
-
// Second call must be served from the cache, not re-fetched
|
|
77
|
-
const second = await apos.oembed.query(req, url);
|
|
78
|
-
assert.strictEqual(second.title, 'Success');
|
|
79
|
-
assert.strictEqual(calls, 1);
|
|
80
|
-
} finally {
|
|
81
|
-
apos.oembed.oembetter.fetch = original;
|
|
82
|
-
}
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('should cache failures so a bad URL is not requested repeatedly, and log the original error', async function() {
|
|
86
|
-
const req = apos.task.getReq();
|
|
87
|
-
const url = 'https://example.com/bad-video';
|
|
88
|
-
let calls = 0;
|
|
89
|
-
const original = apos.oembed.oembetter.fetch;
|
|
90
|
-
apos.oembed.oembetter.fetch = (fetchUrl, options, cb) => {
|
|
91
|
-
calls++;
|
|
92
|
-
return cb(new Error('simulated provider failure'));
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
// Capture structured log output to confirm the original error is logged
|
|
96
|
-
const originalLogError = apos.oembed.logError;
|
|
97
|
-
const logged = [];
|
|
98
|
-
apos.oembed.logError = (...args) => logged.push(args);
|
|
99
|
-
|
|
100
|
-
try {
|
|
101
|
-
await assert.rejects(
|
|
102
|
-
() => apos.oembed.query(req, url),
|
|
103
|
-
{ name: 'invalid' }
|
|
104
|
-
);
|
|
105
|
-
assert.strictEqual(calls, 1);
|
|
106
|
-
|
|
107
|
-
// The underlying cause is logged before the higher-level error is thrown
|
|
108
|
-
assert.strictEqual(logged.length, 1);
|
|
109
|
-
const [ loggedReq, eventType, message ] = logged[0];
|
|
110
|
-
assert.strictEqual(loggedReq, req);
|
|
111
|
-
assert.strictEqual(eventType, 'query-failed');
|
|
112
|
-
assert.strictEqual(message, 'simulated provider failure');
|
|
113
|
-
|
|
114
|
-
// Second call must be served from the cached failure, not re-fetched
|
|
115
|
-
await assert.rejects(
|
|
116
|
-
() => apos.oembed.query(req, url),
|
|
117
|
-
{ name: 'invalid' }
|
|
118
|
-
);
|
|
119
|
-
assert.strictEqual(calls, 1);
|
|
120
|
-
} finally {
|
|
121
|
-
apos.oembed.oembetter.fetch = original;
|
|
122
|
-
apos.oembed.logError = originalLogError;
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it('should tolerate legacy (unwrapped) cache entries without crashing', async function() {
|
|
127
|
-
const req = apos.task.getReq();
|
|
128
|
-
const url = 'https://example.com/legacy-video';
|
|
129
|
-
const legacyResponse = {
|
|
130
|
-
type: 'video',
|
|
131
|
-
title: 'Legacy',
|
|
132
|
-
html: '<iframe src="//example.com/legacy"></iframe>'
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
// Simulate a raw response cached by an older version of this module
|
|
136
|
-
const originalGet = apos.cache.get;
|
|
137
|
-
apos.cache.get = async () => legacyResponse;
|
|
138
|
-
|
|
139
|
-
// Fetching must not happen when the cache already has data
|
|
140
|
-
const originalFetch = apos.oembed.oembetter.fetch;
|
|
141
|
-
apos.oembed.oembetter.fetch = (fetchUrl, options, cb) => {
|
|
142
|
-
return cb(new Error('should not be called for a cache hit'));
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
const result = await apos.oembed.query(req, url);
|
|
147
|
-
assert.strictEqual(result.title, 'Legacy');
|
|
148
|
-
assert.strictEqual(result.type, 'video');
|
|
149
|
-
} finally {
|
|
150
|
-
apos.cache.get = originalGet;
|
|
151
|
-
apos.oembed.oembetter.fetch = originalFetch;
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
56
|
});
|