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.
Files changed (35) hide show
  1. package/CHANGELOG.md +43 -2
  2. package/claude-tools/run-mocha-file.sh +29 -0
  3. package/modules/@apostrophecms/area/index.js +14 -5
  4. package/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js +72 -3
  5. package/modules/@apostrophecms/asset/index.js +170 -21
  6. package/modules/@apostrophecms/asset/lib/build/external-module-api.js +7 -16
  7. package/modules/@apostrophecms/asset/lib/build/internals.js +1 -3
  8. package/modules/@apostrophecms/asset/lib/build/task.js +7 -13
  9. package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +21 -1
  10. package/modules/@apostrophecms/doc-type/index.js +73 -15
  11. package/modules/@apostrophecms/http/index.js +175 -139
  12. package/modules/@apostrophecms/image/index.js +16 -7
  13. package/modules/@apostrophecms/image/ui/apos/components/AposMediaManager.vue +9 -1
  14. package/modules/@apostrophecms/job/index.js +3 -1
  15. package/modules/@apostrophecms/oembed/index.js +12 -45
  16. package/modules/@apostrophecms/page/index.js +17 -1
  17. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +10 -1
  18. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapInsertItem.vue +3 -14
  19. package/modules/@apostrophecms/rich-text-widget/ui/apos/lib/remove-slash.js +18 -0
  20. package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +37 -6
  21. package/modules/@apostrophecms/url/index.js +54 -0
  22. package/package.json +11 -12
  23. package/test/asset-external.js +3 -0
  24. package/test/asset-lock-cache.js +243 -0
  25. package/test/assets.js +25 -2
  26. package/test/config-cascade-merge.js +73 -0
  27. package/test/files.js +10 -11
  28. package/test/image-tags.js +103 -0
  29. package/test/modules/collision-piece/index.js +23 -0
  30. package/test/oembed.js +0 -98
  31. package/test/pages-move-permissions.js +317 -0
  32. package/test/pages.js +12 -15
  33. package/test/pieces-public-api-relationship-choices.js +245 -0
  34. package/test/pieces.js +12 -15
  35. 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 (10%) to avoid flaky failures on loaded CI runners where the
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 >= 10, `Expected gain >=10%, got ${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
- // Send an attacker-controlled Host header (e.g. the cloud metadata
146
- // address from the advisory). The upstream fetch must be resolved
147
- // against the server-trusted baseUrl, not this header, so the
148
- // legitimate content is still served and the request is never
149
- // steered at the spoofed host.
150
- const response = await apos.http.get(url, {
151
- headers: {
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
  });