apostrophe 4.30.0-alpha.1 → 4.30.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 (64) hide show
  1. package/.claude/settings.local.json +15 -0
  2. package/CHANGELOG.md +30 -2
  3. package/eslint.config.js +1 -2
  4. package/lib/mongodb-connect.js +62 -0
  5. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBar.vue +0 -1
  6. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarMenu.vue +25 -8
  7. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBreakpointPreviewMode.vue +9 -0
  8. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue +20 -2
  9. package/modules/@apostrophecms/area/index.js +10 -5
  10. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +2 -0
  11. package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +11 -1
  12. package/modules/@apostrophecms/db/index.js +27 -68
  13. package/modules/@apostrophecms/http/index.js +1 -1
  14. package/modules/@apostrophecms/i18n/i18n/en.json +8 -0
  15. package/modules/@apostrophecms/i18n/index.js +1 -8
  16. package/modules/@apostrophecms/image-widget/index.js +29 -1
  17. package/modules/@apostrophecms/job/index.js +7 -9
  18. package/modules/@apostrophecms/layout-widget/index.js +124 -2
  19. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +89 -6
  20. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +2 -2
  21. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +2 -2
  22. package/modules/@apostrophecms/layout-widget/ui/apos/layout.css +8 -0
  23. package/modules/@apostrophecms/layout-widget/ui/src/layout.css +1 -1
  24. package/modules/@apostrophecms/layout-widget/views/widget.html +3 -3
  25. package/modules/@apostrophecms/login/index.js +13 -15
  26. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +2 -1
  27. package/modules/@apostrophecms/oembed/index.js +18 -13
  28. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedIcon.vue +2 -0
  29. package/modules/@apostrophecms/rich-text-widget/index.js +36 -0
  30. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputCheckboxes.js +1 -1
  31. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRadio.js +1 -1
  32. package/modules/@apostrophecms/styles/index.js +16 -0
  33. package/modules/@apostrophecms/styles/lib/handlers.js +6 -0
  34. package/modules/@apostrophecms/styles/lib/methods.js +93 -0
  35. package/modules/@apostrophecms/styles/lib/presets.js +17 -0
  36. package/modules/@apostrophecms/styles/ui/apos/universal/render.mjs +10 -1
  37. package/modules/@apostrophecms/submitted-draft/ui/apos/components/AposSubmittedDraftIcon.vue +1 -0
  38. package/modules/@apostrophecms/template/views/outerLayoutBase.html +1 -1
  39. package/modules/@apostrophecms/ui/ui/apos/components/AposButton.vue +14 -2
  40. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +29 -5
  41. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuDialog.vue +8 -0
  42. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuItem.vue +5 -2
  43. package/modules/@apostrophecms/ui/ui/apos/components/AposLocalePicker.vue +14 -1
  44. package/modules/@apostrophecms/ui/ui/apos/scss/global/_utilities.scss +13 -1
  45. package/modules/@apostrophecms/util/index.js +4 -0
  46. package/modules/@apostrophecms/widget-type/index.js +6 -0
  47. package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +32 -0
  48. package/package.json +13 -13
  49. package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +131 -0
  50. package/test/add-missing-schema-fields-project/test.js +3 -11
  51. package/test/assets.js +67 -110
  52. package/test/db.js +15 -24
  53. package/test/job.js +1 -1
  54. package/test/layout-widget-gap.js +530 -0
  55. package/test/login.js +122 -1
  56. package/test/rich-text-widget.js +200 -0
  57. package/test/styles.js +50 -0
  58. package/test-lib/util.js +14 -50
  59. package/claude-tools/detect-handles.js +0 -46
  60. package/claude-tools/minimal-hang-test.js +0 -28
  61. package/claude-tools/mongo-close-test.js +0 -11
  62. package/claude-tools/stdin-ref-test.js +0 -14
  63. package/test/db-tools.js +0 -365
  64. package/test/default-adapter.js +0 -256
@@ -0,0 +1,530 @@
1
+ const t = require('../test-lib/test.js');
2
+ const assert = require('node:assert/strict');
3
+
4
+ describe('Layout Widget — gap via styles', function () {
5
+ this.timeout(t.timeout);
6
+
7
+ describe('Styles helpers (server)', function () {
8
+ let apos;
9
+
10
+ before(async function () {
11
+ apos = await t.create({
12
+ root: module,
13
+ modules: {
14
+ '@apostrophecms/styles': {}
15
+ }
16
+ });
17
+ });
18
+
19
+ after(async function () {
20
+ return t.destroy(apos);
21
+ });
22
+
23
+ it('exposes the layoutGap preset with the layoutGapDefault marker', function () {
24
+ const preset = apos.styles.getPreset('layoutGap');
25
+ assert.ok(preset, 'layoutGap preset should be registered');
26
+ assert.equal(preset.type, 'range');
27
+ assert.equal(preset.min, 0);
28
+ assert.equal(preset.max, 64);
29
+ assert.equal(preset.def, 24);
30
+ assert.equal(preset.unit, 'px');
31
+ assert.equal(preset.property, '--apos-layout-gap');
32
+ assert.equal(preset.selector, ':root');
33
+ assert.equal(preset.layoutGapDefault, true);
34
+ });
35
+
36
+ it('fieldsWithProperty enumerates fields by CSS property (incl. nested)', function () {
37
+ const schema = [
38
+ {
39
+ name: 'gap',
40
+ type: 'range',
41
+ property: 'gap'
42
+ },
43
+ {
44
+ name: 'wrap',
45
+ type: 'object',
46
+ schema: [
47
+ {
48
+ name: 'innerGap',
49
+ type: 'range',
50
+ property: 'gap'
51
+ },
52
+ {
53
+ name: 'color',
54
+ type: 'color',
55
+ property: 'background-color'
56
+ }
57
+ ]
58
+ }
59
+ ];
60
+ assert.deepEqual(
61
+ apos.styles.fieldsWithProperty(schema, 'gap'),
62
+ [ 'gap', 'wrap.innerGap' ]
63
+ );
64
+ assert.deepEqual(
65
+ apos.styles.fieldsWithProperty(schema, 'background-color'),
66
+ [ 'wrap.color' ]
67
+ );
68
+ assert.deepEqual(apos.styles.fieldsWithProperty([], 'gap'), []);
69
+ assert.deepEqual(apos.styles.fieldsWithProperty(null, 'gap'), []);
70
+ });
71
+
72
+ it('getFieldByPath resolves top-level and nested (dotted) paths', function () {
73
+ const innerGap = {
74
+ name: 'innerGap',
75
+ type: 'range',
76
+ property: 'gap',
77
+ unit: 'px'
78
+ };
79
+ const wrap = {
80
+ name: 'wrap',
81
+ type: 'object',
82
+ schema: [ innerGap ]
83
+ };
84
+ const gap = {
85
+ name: 'gap',
86
+ type: 'range',
87
+ property: 'gap'
88
+ };
89
+ const schema = [ gap, wrap ];
90
+
91
+ assert.equal(apos.styles.getFieldByPath(schema, 'gap'), gap);
92
+ assert.equal(apos.styles.getFieldByPath(schema, 'wrap'), wrap);
93
+ assert.equal(apos.styles.getFieldByPath(schema, 'wrap.innerGap'), innerGap);
94
+ // Missing segments
95
+ assert.equal(apos.styles.getFieldByPath(schema, 'nope'), null);
96
+ assert.equal(apos.styles.getFieldByPath(schema, 'wrap.nope'), null);
97
+ // Walking into a leaf (no `.schema`) must not throw
98
+ assert.equal(apos.styles.getFieldByPath(schema, 'gap.foo'), null);
99
+ // Invalid inputs
100
+ assert.equal(apos.styles.getFieldByPath(null, 'gap'), null);
101
+ assert.equal(apos.styles.getFieldByPath(schema, ''), null);
102
+ });
103
+
104
+ it('fieldsWithMarker returns names of fields carrying a true marker', function () {
105
+ const schema = [
106
+ {
107
+ name: 'a',
108
+ layoutGapDefault: true
109
+ },
110
+ { name: 'b' },
111
+ {
112
+ name: 'c',
113
+ layoutGapDefault: false
114
+ },
115
+ {
116
+ name: 'wrap',
117
+ type: 'object',
118
+ schema: [
119
+ {
120
+ name: 'inner',
121
+ layoutGapDefault: true
122
+ },
123
+ {
124
+ name: 'other',
125
+ layoutGapDefault: false
126
+ }
127
+ ]
128
+ }
129
+ ];
130
+ assert.deepEqual(
131
+ apos.styles.fieldsWithMarker(schema, 'layoutGapDefault'),
132
+ [ 'a', 'wrap.inner' ]
133
+ );
134
+ assert.deepEqual(apos.styles.fieldsWithMarker([], 'x'), []);
135
+ });
136
+
137
+ it('rejectLayoutGapPresetOnSchema throws on offending fields', function () {
138
+ assert.throws(
139
+ () => apos.styles.rejectLayoutGapPresetOnSchema(
140
+ [ {
141
+ name: 'siteGap',
142
+ layoutGapDefault: true
143
+ } ],
144
+ 'test-widget'
145
+ ),
146
+ /layoutGap/
147
+ );
148
+ // Throws when the marker lives on a nested object subfield
149
+ assert.throws(
150
+ () => apos.styles.rejectLayoutGapPresetOnSchema(
151
+ [ {
152
+ name: 'wrap',
153
+ type: 'object',
154
+ schema: [ {
155
+ name: 'siteGap',
156
+ layoutGapDefault: true
157
+ } ]
158
+ } ],
159
+ 'test-widget'
160
+ ),
161
+ /layoutGap/
162
+ );
163
+ // Must not throw on a clean schema
164
+ apos.styles.rejectLayoutGapPresetOnSchema(
165
+ [ {
166
+ name: 'gap',
167
+ property: 'gap'
168
+ } ],
169
+ 'test-widget'
170
+ );
171
+ });
172
+ });
173
+
174
+ describe('Widget schema validation', function () {
175
+ let apos;
176
+
177
+ before(async function () {
178
+ apos = await t.create({
179
+ root: module,
180
+ modules: {
181
+ '@apostrophecms/styles': {}
182
+ }
183
+ });
184
+ });
185
+
186
+ after(async function () {
187
+ return t.destroy(apos);
188
+ });
189
+
190
+ it('expandStyles + rejectLayoutGapPresetOnSchema blocks the layoutGap preset on widget configs', function () {
191
+ const expanded = apos.styles.expandStyles({ siteGap: 'layoutGap' });
192
+ const schema = Object.entries(expanded).map(([ name, field ]) => ({
193
+ name,
194
+ ...field
195
+ }));
196
+ assert.throws(
197
+ () => apos.styles.rejectLayoutGapPresetOnSchema(schema, 'Widget "x"'),
198
+ /layoutGap/
199
+ );
200
+ });
201
+ });
202
+
203
+ describe('Layout-widget detection & resolution', function () {
204
+ let apos;
205
+
206
+ before(async function () {
207
+ apos = await t.create({
208
+ root: module,
209
+ modules: {
210
+ '@apostrophecms/styles': {
211
+ styles: {
212
+ add: {
213
+ siteGap: 'layoutGap'
214
+ }
215
+ }
216
+ },
217
+ '@apostrophecms/layout-widget': {
218
+ styles: {
219
+ add: {
220
+ gap: {
221
+ label: 'Gap',
222
+ type: 'range',
223
+ min: 0,
224
+ max: 64,
225
+ unit: 'px',
226
+ property: 'gap'
227
+ }
228
+ }
229
+ }
230
+ }
231
+ }
232
+ });
233
+ });
234
+
235
+ after(async function () {
236
+ return t.destroy(apos);
237
+ });
238
+
239
+ it('detects the styles layoutGap field name', function () {
240
+ assert.equal(apos.styles.layoutGapFieldName, 'siteGap');
241
+ });
242
+
243
+ it('detects the widget gap field name and global enabled flag', function () {
244
+ const lw = apos.modules['@apostrophecms/layout-widget'];
245
+ assert.equal(lw.widgetGapFieldName, 'gap');
246
+ assert.equal(lw.globalGapEnabled, true);
247
+ });
248
+
249
+ it('resolveWidgetGap returns null when widget value is absent', function () {
250
+ const lw = apos.modules['@apostrophecms/layout-widget'];
251
+ assert.equal(lw.resolveWidgetGap({}), null);
252
+ assert.equal(lw.resolveWidgetGap({ gap: null }), null);
253
+ assert.equal(lw.resolveWidgetGap({ gap: '' }), null);
254
+ });
255
+
256
+ it('resolveWidgetGap appends the field unit to numeric values', function () {
257
+ const lw = apos.modules['@apostrophecms/layout-widget'];
258
+ assert.equal(lw.resolveWidgetGap({ gap: 16 }), '16px');
259
+ });
260
+
261
+ it('shouldOmitInlineGap decision matrix', function () {
262
+ const lw = apos.modules['@apostrophecms/layout-widget'];
263
+ // Widget has its own gap → never omit
264
+ assert.equal(
265
+ lw.shouldOmitInlineGap({ gap: 8 }, { aposLayoutGap: 24 }),
266
+ false
267
+ );
268
+ // No widget gap, global enabled → omit (cascade resolves via
269
+ // :root --apos-layout-gap, falling through to the static
270
+ // options.gap default when no saved value).
271
+ assert.equal(
272
+ lw.shouldOmitInlineGap({}, { aposLayoutGap: 24 }),
273
+ true
274
+ );
275
+ assert.equal(
276
+ lw.shouldOmitInlineGap({}, { aposLayoutGap: null }),
277
+ true
278
+ );
279
+ assert.equal(lw.shouldOmitInlineGap({}, {}), true);
280
+ assert.equal(lw.shouldOmitInlineGap({}, null), true);
281
+ });
282
+
283
+ it('gapInlineCss prefers widget value, then omits when global enabled', function () {
284
+ const lw = apos.modules['@apostrophecms/layout-widget'];
285
+ const gapInlineCss = lw.__helpers.gapInlineCss;
286
+ // Widget value wins
287
+ assert.equal(
288
+ gapInlineCss({ gap: 12 }, { gap: '1rem' }, { aposLayoutGap: 24 }),
289
+ ' --grid-gap: 12px;'
290
+ );
291
+ // No widget value, global enabled → empty (cascade resolves
292
+ // through :root --apos-layout-gap or static options.gap default)
293
+ assert.equal(
294
+ gapInlineCss({}, { gap: '1rem' }, { aposLayoutGap: 24 }),
295
+ ''
296
+ );
297
+ assert.equal(
298
+ gapInlineCss({}, { gap: '1rem' }, { aposLayoutGap: null }),
299
+ ''
300
+ );
301
+ assert.equal(
302
+ gapInlineCss({}, {}, { aposLayoutGap: null }),
303
+ ''
304
+ );
305
+ });
306
+
307
+ it('annotateWidgetForExternalFront adds _gap and _gapHasGlobal', function () {
308
+ const lw = apos.modules['@apostrophecms/layout-widget'];
309
+ const out = lw.annotateWidgetForExternalFront(
310
+ {
311
+ _id: 'w1',
312
+ type: '@apostrophecms/layout',
313
+ gap: 18
314
+ },
315
+ {}
316
+ );
317
+ assert.equal(out._gap, '18px');
318
+ assert.equal(out._gapHasGlobal, true);
319
+ });
320
+
321
+ it('parentOptionsForArea passes resolved widget gap to the editor', function () {
322
+ const lw = apos.modules['@apostrophecms/layout-widget'];
323
+ const parentOptionsForArea = lw.__helpers.parentOptionsForArea;
324
+ // Widget value present → carries through as a string with unit
325
+ assert.deepEqual(
326
+ parentOptionsForArea(
327
+ {
328
+ _id: 'w1',
329
+ gap: 18
330
+ },
331
+ { columns: 12 },
332
+ { aposLayoutGap: 24 }
333
+ ),
334
+ {
335
+ columns: 12,
336
+ widgetId: 'w1',
337
+ gap: '18px'
338
+ }
339
+ );
340
+ // Widget value absent + global has value → gap: null (signal omit)
341
+ assert.deepEqual(
342
+ parentOptionsForArea(
343
+ { _id: 'w2' },
344
+ { columns: 12 },
345
+ { aposLayoutGap: 24 }
346
+ ),
347
+ {
348
+ columns: 12,
349
+ widgetId: 'w2',
350
+ gap: null
351
+ }
352
+ );
353
+ // Widget value absent + global enabled → gap: null (signal omit)
354
+ // regardless of whether the global has a saved value.
355
+ assert.deepEqual(
356
+ parentOptionsForArea(
357
+ { _id: 'w3' },
358
+ { columns: 12 },
359
+ { aposLayoutGap: null }
360
+ ),
361
+ {
362
+ columns: 12,
363
+ widgetId: 'w3',
364
+ gap: null
365
+ }
366
+ );
367
+ });
368
+ });
369
+
370
+ describe('Layout-widget without global gap configured', function () {
371
+ let apos;
372
+
373
+ before(async function () {
374
+ apos = await t.create({
375
+ root: module,
376
+ modules: {
377
+ '@apostrophecms/styles': {},
378
+ '@apostrophecms/layout-widget': {}
379
+ }
380
+ });
381
+ });
382
+
383
+ after(async function () {
384
+ return t.destroy(apos);
385
+ });
386
+
387
+ it('globalGapEnabled is false when no layoutGap field configured', function () {
388
+ const lw = apos.modules['@apostrophecms/layout-widget'];
389
+ assert.equal(lw.globalGapEnabled, false);
390
+ assert.equal(lw.widgetGapFieldName, null);
391
+ });
392
+
393
+ it('gapInlineCss falls back to BC value', function () {
394
+ const lw = apos.modules['@apostrophecms/layout-widget'];
395
+ assert.equal(
396
+ lw.__helpers.gapInlineCss({}, { gap: '1.5rem' }, {}),
397
+ ' --grid-gap: 1.5rem;'
398
+ );
399
+ });
400
+ });
401
+
402
+ describe('Layout-widget with nested (dotted) gap field', function () {
403
+ let apos;
404
+
405
+ before(async function () {
406
+ apos = await t.create({
407
+ root: module,
408
+ modules: {
409
+ '@apostrophecms/styles': {},
410
+ '@apostrophecms/layout-widget': {
411
+ styles: {
412
+ add: {
413
+ gap: {
414
+ label: 'Gap',
415
+ type: 'range',
416
+ min: 0,
417
+ max: 64,
418
+ unit: 'px',
419
+ property: 'gap'
420
+ }
421
+ }
422
+ }
423
+ }
424
+ }
425
+ });
426
+ });
427
+
428
+ after(async function () {
429
+ return t.destroy(apos);
430
+ });
431
+
432
+ it('resolveWidgetGap reads from a dotted widgetGapFieldName via apos.util.get', function () {
433
+ const lw = apos.modules['@apostrophecms/layout-widget'];
434
+ const original = lw.widgetGapFieldName;
435
+ const originalSchema = lw.schema;
436
+ // Simulate detection of a nested gap field at `spacing.inner`.
437
+ lw.widgetGapFieldName = 'spacing.inner';
438
+ lw.schema = [ {
439
+ name: 'spacing',
440
+ type: 'object',
441
+ schema: [ {
442
+ name: 'inner',
443
+ type: 'range',
444
+ property: 'gap',
445
+ unit: 'rem'
446
+ } ]
447
+ } ];
448
+ try {
449
+ assert.equal(
450
+ lw.resolveWidgetGap({ spacing: { inner: 2 } }),
451
+ '2rem'
452
+ );
453
+ assert.equal(
454
+ lw.resolveWidgetGap({ spacing: { inner: '3rem' } }),
455
+ '3rem'
456
+ );
457
+ assert.equal(lw.resolveWidgetGap({ spacing: {} }), null);
458
+ assert.equal(lw.resolveWidgetGap({}), null);
459
+ } finally {
460
+ lw.widgetGapFieldName = original;
461
+ lw.schema = originalSchema;
462
+ }
463
+ });
464
+ });
465
+
466
+ describe('Layout-widget with widget gap field declaring `def`', function () {
467
+ let apos;
468
+
469
+ before(async function () {
470
+ apos = await t.create({
471
+ root: module,
472
+ modules: {
473
+ '@apostrophecms/styles': {},
474
+ '@apostrophecms/layout-widget': {
475
+ styles: {
476
+ add: {
477
+ gap: {
478
+ label: 'Gap',
479
+ type: 'range',
480
+ min: 0,
481
+ max: 64,
482
+ def: 24,
483
+ unit: 'px',
484
+ property: 'gap'
485
+ }
486
+ }
487
+ }
488
+ }
489
+ }
490
+ });
491
+ });
492
+
493
+ after(async function () {
494
+ return t.destroy(apos);
495
+ });
496
+
497
+ it('resolveWidgetGap falls back to field.def when widget value is absent', function () {
498
+ const lw = apos.modules['@apostrophecms/layout-widget'];
499
+ assert.equal(lw.resolveWidgetGap({}), '24px');
500
+ assert.equal(lw.resolveWidgetGap({ gap: null }), '24px');
501
+ assert.equal(lw.resolveWidgetGap({ gap: '' }), '24px');
502
+ // Explicit value still wins.
503
+ assert.equal(lw.resolveWidgetGap({ gap: 8 }), '8px');
504
+ });
505
+
506
+ it('gapInlineCss emits the def value (no global cascade in play)', function () {
507
+ const lw = apos.modules['@apostrophecms/layout-widget'];
508
+ assert.equal(
509
+ lw.__helpers.gapInlineCss({}, { gap: '1.5rem' }, {}),
510
+ ' --grid-gap: 24px;'
511
+ );
512
+ });
513
+
514
+ it('parentOptionsForArea passes the def value to the editor', function () {
515
+ const lw = apos.modules['@apostrophecms/layout-widget'];
516
+ assert.deepEqual(
517
+ lw.__helpers.parentOptionsForArea(
518
+ { _id: 'w1' },
519
+ { columns: 12 },
520
+ {}
521
+ ),
522
+ {
523
+ columns: 12,
524
+ widgetId: 'w1',
525
+ gap: '24px'
526
+ }
527
+ );
528
+ });
529
+ });
530
+ });
package/test/login.js CHANGED
@@ -11,6 +11,7 @@ describe('Login', function () {
11
11
  before(async function () {
12
12
  apos = await t.create({
13
13
  root: module,
14
+ baseUrl: 'http://localhost:3000',
14
15
  modules: {
15
16
  '@apostrophecms/express': {
16
17
  options: {
@@ -602,8 +603,14 @@ describe('Login', function () {
602
603
  assert.equal(opts.to, user.email);
603
604
  assert(opts.subject);
604
605
 
605
- // Safe the token for the reset tests
606
+ // The reset URL must be derived from apos.baseUrl, not from the
607
+ // request's Host header, to prevent host header injection attacks
608
+ // that could leak the reset token to an attacker-controlled domain.
606
609
  const url = new URL(data.url);
610
+ assert.equal(`${url.protocol}//${url.host}`, apos.baseUrl);
611
+ assert.equal(data.site, new URL(apos.baseUrl).hostname);
612
+
613
+ // Safe the token for the reset tests
607
614
  resetToken = url.searchParams.get('reset');
608
615
  }
609
616
 
@@ -798,6 +805,56 @@ describe('Login', function () {
798
805
  assert(page.match(/logged in/));
799
806
  });
800
807
 
808
+ it('should ignore a spoofed Host header on POST /login/reset-request', async function () {
809
+ let args;
810
+ const orig = apos.login.email;
811
+ apos.login.email = (req, ...a) => {
812
+ args = a;
813
+ };
814
+
815
+ let user = apos.user.newInstance();
816
+ user.title = 'spoofUser';
817
+ user.email = 'spoofUser@example.com';
818
+ user.username = 'spoofUser';
819
+ user.password = 'secret';
820
+ user.role = 'guest';
821
+ user = await apos.user.insert(apos.task.getReq(), user);
822
+
823
+ const jar = apos.http.jar();
824
+ await apos.http.get(
825
+ '/',
826
+ {
827
+ jar
828
+ }
829
+ );
830
+
831
+ await apos.http.post(
832
+ '/api/v1/@apostrophecms/login/reset-request',
833
+ {
834
+ body: {
835
+ email: 'spoofUser',
836
+ session: true
837
+ },
838
+ headers: {
839
+ Host: 'evil.attacker.example'
840
+ },
841
+ jar
842
+ }
843
+ );
844
+
845
+ assert(Array.isArray(args));
846
+ const [ , data ] = args;
847
+ const url = new URL(data.url);
848
+ // The reset URL must reflect the configured apos.baseUrl, not the
849
+ // attacker-supplied Host header.
850
+ assert.equal(`${url.protocol}//${url.host}`, apos.baseUrl);
851
+ assert.notEqual(url.hostname, 'evil.attacker.example');
852
+ assert.equal(data.site, new URL(apos.baseUrl).hostname);
853
+ assert.notEqual(data.site, 'evil.attacker.example');
854
+
855
+ apos.login.email = orig;
856
+ });
857
+
801
858
  it('should find user by reset data', async function () {
802
859
  let user = apos.user.newInstance();
803
860
  user.title = 'getResetUser';
@@ -1053,6 +1110,70 @@ describe('Login', function () {
1053
1110
  assert.deepEqual(actual, expected);
1054
1111
  });
1055
1112
  });
1113
+
1114
+ describe('passwordReset without apos.baseUrl', function () {
1115
+ let apos3;
1116
+
1117
+ this.timeout(20000);
1118
+
1119
+ before(async function () {
1120
+ apos3 = await t.create({
1121
+ root: module,
1122
+ modules: {
1123
+ '@apostrophecms/login': {
1124
+ options: {
1125
+ passwordReset: true,
1126
+ environmentLabel: 'test'
1127
+ }
1128
+ }
1129
+ }
1130
+ });
1131
+ });
1132
+
1133
+ after(function () {
1134
+ return t.destroy(apos3);
1135
+ });
1136
+
1137
+ it('should reject reset-request when apos.baseUrl is not configured', async function () {
1138
+ assert(!apos3.baseUrl);
1139
+
1140
+ const user = apos3.user.newInstance();
1141
+ user.title = 'noBaseUrlUser';
1142
+ user.username = 'noBaseUrlUser';
1143
+ user.email = 'noBaseUrlUser@example.com';
1144
+ user.password = 'secret';
1145
+ user.role = 'guest';
1146
+ await apos3.user.insert(apos3.task.getReq(), user);
1147
+
1148
+ const jar = apos3.http.jar();
1149
+ await apos3.http.get('/', { jar });
1150
+
1151
+ let emailed = false;
1152
+ const orig = apos3.login.email;
1153
+ apos3.login.email = () => {
1154
+ emailed = true;
1155
+ };
1156
+
1157
+ await assert.rejects(() => apos3.http.post(
1158
+ '/api/v1/@apostrophecms/login/reset-request',
1159
+ {
1160
+ body: {
1161
+ email: 'noBaseUrlUser',
1162
+ session: true
1163
+ },
1164
+ jar
1165
+ }
1166
+ ), {
1167
+ status: 400
1168
+ });
1169
+
1170
+ // Confirm no reset email was sent — the request must be refused
1171
+ // before any token is generated or message dispatched.
1172
+ assert.equal(emailed, false);
1173
+
1174
+ apos3.login.email = orig;
1175
+ });
1176
+ });
1056
1177
  });
1057
1178
 
1058
1179
  describe('Case Sensitivity', function() {