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.
- package/.claude/settings.local.json +15 -0
- package/CHANGELOG.md +30 -2
- package/eslint.config.js +1 -2
- package/lib/mongodb-connect.js +62 -0
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBar.vue +0 -1
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarMenu.vue +25 -8
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBreakpointPreviewMode.vue +9 -0
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue +20 -2
- package/modules/@apostrophecms/area/index.js +10 -5
- package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +2 -0
- package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +11 -1
- package/modules/@apostrophecms/db/index.js +27 -68
- package/modules/@apostrophecms/http/index.js +1 -1
- package/modules/@apostrophecms/i18n/i18n/en.json +8 -0
- package/modules/@apostrophecms/i18n/index.js +1 -8
- package/modules/@apostrophecms/image-widget/index.js +29 -1
- package/modules/@apostrophecms/job/index.js +7 -9
- package/modules/@apostrophecms/layout-widget/index.js +124 -2
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +89 -6
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +2 -2
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +2 -2
- package/modules/@apostrophecms/layout-widget/ui/apos/layout.css +8 -0
- package/modules/@apostrophecms/layout-widget/ui/src/layout.css +1 -1
- package/modules/@apostrophecms/layout-widget/views/widget.html +3 -3
- package/modules/@apostrophecms/login/index.js +13 -15
- package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +2 -1
- package/modules/@apostrophecms/oembed/index.js +18 -13
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedIcon.vue +2 -0
- package/modules/@apostrophecms/rich-text-widget/index.js +36 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputCheckboxes.js +1 -1
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRadio.js +1 -1
- package/modules/@apostrophecms/styles/index.js +16 -0
- package/modules/@apostrophecms/styles/lib/handlers.js +6 -0
- package/modules/@apostrophecms/styles/lib/methods.js +93 -0
- package/modules/@apostrophecms/styles/lib/presets.js +17 -0
- package/modules/@apostrophecms/styles/ui/apos/universal/render.mjs +10 -1
- package/modules/@apostrophecms/submitted-draft/ui/apos/components/AposSubmittedDraftIcon.vue +1 -0
- package/modules/@apostrophecms/template/views/outerLayoutBase.html +1 -1
- package/modules/@apostrophecms/ui/ui/apos/components/AposButton.vue +14 -2
- package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +29 -5
- package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuDialog.vue +8 -0
- package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuItem.vue +5 -2
- package/modules/@apostrophecms/ui/ui/apos/components/AposLocalePicker.vue +14 -1
- package/modules/@apostrophecms/ui/ui/apos/scss/global/_utilities.scss +13 -1
- package/modules/@apostrophecms/util/index.js +4 -0
- package/modules/@apostrophecms/widget-type/index.js +6 -0
- package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +32 -0
- package/package.json +13 -13
- package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +131 -0
- package/test/add-missing-schema-fields-project/test.js +3 -11
- package/test/assets.js +67 -110
- package/test/db.js +15 -24
- package/test/job.js +1 -1
- package/test/layout-widget-gap.js +530 -0
- package/test/login.js +122 -1
- package/test/rich-text-widget.js +200 -0
- package/test/styles.js +50 -0
- package/test-lib/util.js +14 -50
- package/claude-tools/detect-handles.js +0 -46
- package/claude-tools/minimal-hang-test.js +0 -28
- package/claude-tools/mongo-close-test.js +0 -11
- package/claude-tools/stdin-ref-test.js +0 -14
- package/test/db-tools.js +0 -365
- 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
|
-
//
|
|
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() {
|