Release v2.15.4
This commit is contained in:
1 parent
64aeb1b47e
commit
05c9341b08
25 files changed
+595
-31
No files matched your search
@@ -37,6 +37,8 @@ const request = {
|
||||
test('audit categories include weather data sources', () => {
|
||||
assert.ok(AUDIT_CATEGORY_KEYS.includes('weather'));
|
||||
assert.equal(AUDIT_CATEGORY_LABELS.weather, 'Weather');
|
||||
assert.ok(AUDIT_CATEGORY_KEYS.includes('media-assets'));
|
||||
assert.equal(AUDIT_CATEGORY_LABELS['media-assets'], 'Media Assets');
|
||||
});
|
||||
|
||||
test('screen control auditing is disabled by default', () => {
|
||||
|
||||
@@ -226,4 +226,52 @@ test('fonts page renders the upload card above the table card', () => {
|
||||
assert.match(html, /data-table-sort-key="file"/);
|
||||
assert.match(html, /data-table-sort-key="format"/);
|
||||
assert.match(html, /data-table-sort-key="status"/);
|
||||
});
|
||||
|
||||
test('font upload, status changes, and deletion are audited', async () => {
|
||||
const originals = {
|
||||
loadFontLibrary: fontLibrary.loadFontLibrary,
|
||||
addFontFile: fontLibrary.addFontFile,
|
||||
setFontEnabled: fontLibrary.setFontEnabled,
|
||||
deleteFontFile: fontLibrary.deleteFontFile
|
||||
};
|
||||
const { app, handlers } = createHandlers();
|
||||
const events = [];
|
||||
const font = { id: 'alpha.woff2', family: 'Alpha Sans', name: 'Alpha Sans', fileName: 'alpha.woff2', format: 'woff2', enabled: true };
|
||||
fontLibrary.loadFontLibrary = function () { return { fonts: [font], stylesheetHref: '' }; };
|
||||
fontLibrary.addFontFile = async function () { return { font: font, fonts: [font] }; };
|
||||
fontLibrary.setFontEnabled = async function (_dir, _id, enabled) { return Object.assign({}, font, { enabled: enabled }); };
|
||||
fontLibrary.deleteFontFile = async function () { return font; };
|
||||
|
||||
try {
|
||||
registerFontRoutes(app, {
|
||||
pool: { async query() { return [[{ match_count: 0 }]]; } },
|
||||
pages: { renderFontsPage() { return ''; } },
|
||||
upload: { single() { return function (_req, _res, next) { next(); }; } },
|
||||
uploadDir: 'unused',
|
||||
backgroundTaskQueue: null,
|
||||
recordRequestAuditEvent(_pool, _req, event) { events.push(event); }
|
||||
});
|
||||
const response = { redirect() { return this; } };
|
||||
const next = function (error) { if (error) throw error; };
|
||||
const currentUser = { id: 12, permissionKeys: ['fonts.create', 'fonts.update', 'fonts.delete'] };
|
||||
|
||||
await handlers['POST /settings/fonts'].at(-1)(
|
||||
{ currentUser: currentUser, body: { font_family: 'Alpha Sans' }, file: { path: 'unused' } }, response, next
|
||||
);
|
||||
await handlers['POST /settings/fonts/:id/toggle'].at(-1)(
|
||||
{ currentUser: currentUser, params: { id: font.id }, body: { enabled: '0' } }, response, next
|
||||
);
|
||||
await handlers['POST /settings/fonts/:id/delete'].at(-1)(
|
||||
{ currentUser: currentUser, params: { id: font.id } }, response, next
|
||||
);
|
||||
|
||||
assert.deepEqual(events.map(function (event) { return event.eventType; }), ['font.uploaded', 'font.disabled', 'font.deleted']);
|
||||
assert.ok(events.every(function (event) { return event.category === 'media-assets' && event.actorUserId === 12; }));
|
||||
} finally {
|
||||
fontLibrary.loadFontLibrary = originals.loadFontLibrary;
|
||||
fontLibrary.addFontFile = originals.addFontFile;
|
||||
fontLibrary.setFontEnabled = originals.setFontEnabled;
|
||||
fontLibrary.deleteFontFile = originals.deleteFontFile;
|
||||
}
|
||||
});
|
||||
@@ -5,6 +5,9 @@ const os = require('os');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const sharp = require('sharp');
|
||||
require('../src/common');
|
||||
const mediaLibrary = require('../src/web/lib/media/library');
|
||||
const registerMediaLibraryRoutes = require('../src/web/routes/settings/media-library');
|
||||
const {
|
||||
normalizeMediaPath,
|
||||
createImageVariants,
|
||||
@@ -140,4 +143,72 @@ test('media library counts slide and template references', async () => {
|
||||
assert.equal(queries[0].params[0], '/media/uploads/shared.png');
|
||||
assert.equal(queries[1].params[0], '/media/uploads/shared.png');
|
||||
assert.match(queries[0].sql, /LOCATE/);
|
||||
assert.match(queries[0].sql, /CAST\(COALESCE\(content_json, ''\) AS CHAR\)/);
|
||||
});
|
||||
|
||||
test('media library upload and delete routes audit successful asset changes', async () => {
|
||||
const originalCreateVariants = mediaLibrary.createImageVariants;
|
||||
const originalRegisterAsset = mediaLibrary.registerMediaAsset;
|
||||
const originalCountReferences = mediaLibrary.countMediaAssetReferences;
|
||||
const originalRemoveVariants = mediaLibrary.removeImageVariants;
|
||||
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-audit-'));
|
||||
const uploadDir = path.join(root, 'media', 'uploads');
|
||||
const filePath = path.join(uploadDir, 'photo.png');
|
||||
await fs.promises.mkdir(uploadDir, { recursive: true });
|
||||
await fs.promises.writeFile(filePath, 'image');
|
||||
const routeHandlers = {};
|
||||
const events = [];
|
||||
const app = {
|
||||
get(route, ...handlers) { routeHandlers['GET ' + route] = handlers; },
|
||||
post(route, ...handlers) { routeHandlers['POST ' + route] = handlers; }
|
||||
};
|
||||
const pool = {
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM o_app_settings')) return [[]];
|
||||
if (sql.includes('FROM c_media_assets')) {
|
||||
return [[{ id: 9, media_path: '/media/uploads/photo.png', original_name: 'Photo.png', media_type: 'image', file_size: 5 }]];
|
||||
}
|
||||
return [[]];
|
||||
}
|
||||
};
|
||||
|
||||
mediaLibrary.createImageVariants = async function () {};
|
||||
mediaLibrary.registerMediaAsset = async function () {};
|
||||
mediaLibrary.countMediaAssetReferences = async function () { return 0; };
|
||||
mediaLibrary.removeImageVariants = async function () {};
|
||||
|
||||
try {
|
||||
registerMediaLibraryRoutes(app, {
|
||||
pool: pool,
|
||||
pages: {},
|
||||
upload: { single() { return function (_req, _res, next) { next(); }; } },
|
||||
uploadDir: uploadDir,
|
||||
getAuditUserId(req) { return req.currentUser.id; },
|
||||
recordRequestAuditEvent(_pool, _req, event) { events.push(event); },
|
||||
requirePermission() { return function (_req, _res, next) { next(); }; }
|
||||
});
|
||||
const response = { redirect(value) { this.location = value; return this; } };
|
||||
const next = function (error) { if (error) throw error; };
|
||||
const request = {
|
||||
currentUser: { id: 4 },
|
||||
body: {},
|
||||
file: { path: filePath, filename: 'photo.png', originalname: 'Photo.png', mimetype: 'image/png', size: 5 }
|
||||
};
|
||||
|
||||
await routeHandlers['POST /media-library'].at(-1)(request, response, next);
|
||||
await routeHandlers['POST /media-library/:id/delete'].at(-1)(
|
||||
{ currentUser: { id: 4 }, params: { id: '9' } }, response, next
|
||||
);
|
||||
|
||||
assert.deepEqual(events.map(function (event) { return event.eventType; }), ['media.uploaded', 'media.deleted']);
|
||||
assert.equal(events[0].targetLabel, 'Photo.png');
|
||||
assert.equal(events[1].targetId, 9);
|
||||
assert.ok(events.every(function (event) { return event.category === 'media-assets' && event.actorUserId === 4; }));
|
||||
} finally {
|
||||
mediaLibrary.createImageVariants = originalCreateVariants;
|
||||
mediaLibrary.registerMediaAsset = originalRegisterAsset;
|
||||
mediaLibrary.countMediaAssetReferences = originalCountReferences;
|
||||
mediaLibrary.removeImageVariants = originalRemoveVariants;
|
||||
await fs.promises.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -83,6 +83,91 @@ function loadScheduleModule(overrides) {
|
||||
return registry.get('timetable');
|
||||
}
|
||||
|
||||
test('timetable date ranges agree in preview and player, before limiting items', () => {
|
||||
const previewModule = loadScheduleModule();
|
||||
const groups = [{ id: 1, entries: [
|
||||
{ id: 1, title: 'Early', start_datetime: '2099-08-20T09:00:00Z' },
|
||||
{ id: 2, title: 'Middle', start_datetime: '2099-08-20T10:00:00Z' },
|
||||
{ id: 3, title: 'Late', start_datetime: '2099-08-20T11:00:00Z' }
|
||||
] }];
|
||||
let playerModule;
|
||||
const sandbox = {
|
||||
window: {
|
||||
pulsePlayerRegionTypes: { register(type, module) { playerModule = module; } },
|
||||
placeholderUtils: {
|
||||
resolvePlaceholderExpression(entry, expression) { return entry[expression]; },
|
||||
formatPlaceholderValue(value) { return value; }
|
||||
}
|
||||
},
|
||||
initialData: { timetableGroups: groups },
|
||||
escapeHtml: String,
|
||||
sanitizeRichText: String
|
||||
};
|
||||
vm.runInNewContext(fs.readFileSync(require.resolve('../src/player/regions/timetable.js'), 'utf8'), sandbox);
|
||||
const cases = [
|
||||
[{}, ['Early', 'Middle', 'Late']],
|
||||
[{ date_filter: 'all', date_from: '2099-08-20T10:00:00Z' }, ['Early', 'Middle', 'Late']],
|
||||
[{ date_filter: 'between', date_from: '2099-08-20T10:00:00Z' }, ['Middle', 'Late']],
|
||||
[{ date_filter: 'between', date_to: '2099-08-20T10:00:00Z' }, ['Early', 'Middle']],
|
||||
[{ date_filter: 'between', date_from: '2099-08-20T12:00:00+02:00', date_to: '2099-08-20T10:00:00Z' }, ['Middle']],
|
||||
[{ date_filter: 'between', date_from: '2099-08-20T10:00:00Z', max_items: 1 }, ['Middle']],
|
||||
[{ date_filter: 'between', date_from: '2099-08-21T00:00:00Z' }, []],
|
||||
[{ date_filter: 'after', date_from: '2099-08-20T10:00:00Z', date_to: '2099-08-20T09:00:00Z' }, ['Middle', 'Late']],
|
||||
[{ date_filter: 'before', date_to: '2099-08-20T10:00:00Z', date_from: '2099-08-20T11:00:00Z' }, ['Early', 'Middle']]
|
||||
];
|
||||
for (const [range, titles] of cases) {
|
||||
const config = { value: '{{title}}', timetable_group_id: 1, display_mode: 'both', max_items: 5, ...range };
|
||||
const region = { id: 47, baseStyle: '' };
|
||||
const preview = previewModule.renderPreview(region, config, { timetableGroups: groups });
|
||||
const player = playerModule.renderRegion(region, config);
|
||||
for (const title of ['Early', 'Middle', 'Late']) {
|
||||
assert.equal(preview.includes(title), titles.includes(title), 'preview ' + JSON.stringify(range));
|
||||
assert.equal(player.includes(title), titles.includes(title), 'player ' + JSON.stringify(range));
|
||||
}
|
||||
}
|
||||
|
||||
groups[0].entries = [{ id: 4, title: 'Live', start_datetime: new Date(Date.now() - 60000).toISOString(), end_datetime: new Date(Date.now() + 60000).toISOString() }];
|
||||
const config = { value: '{{title}}', timetable_group_id: 1, display_mode: 'upcoming', date_filter: 'between', date_from: '2099-08-20T00:00:00Z' };
|
||||
assert.doesNotMatch(previewModule.renderPreview({}, config, { timetableGroups: groups }), /Live/);
|
||||
assert.doesNotMatch(playerModule.renderRegion({}, config), /Live/);
|
||||
});
|
||||
|
||||
test('timetable date controls default to all and pass unsaved date ranges to preview', () => {
|
||||
const module = loadScheduleModule();
|
||||
const region = { id: 47, region_key: 'timetable', label: 'Timetable' };
|
||||
const html = module.renderEditorCard({ region, current: {}, timetableGroups: [] });
|
||||
assert.match(html, /<option value="all" selected>All<\/option>/);
|
||||
assert.match(html, /data-timetable-date-range hidden/);
|
||||
assert.match(html, /data-timetable-date="date_from"[^>]* disabled/);
|
||||
const saved = { date_filter: 'between', date_from: '2099-08-20T10:00:00.000Z', date_to: '' };
|
||||
const enabled = module.renderEditorCard({ region, current: saved, timetableGroups: [] });
|
||||
assert.match(enabled, /<option value="between" selected>Between Dates<\/option>/);
|
||||
assert.doesNotMatch(enabled, /data-timetable-date-range hidden/);
|
||||
assert.doesNotMatch(enabled, /data-timetable-date="date_from"[^>]* disabled/);
|
||||
const config = module.getCurrentConfig(region, { timetable: saved });
|
||||
assert.equal(config.date_from, saved.date_from);
|
||||
const fields = {
|
||||
'[name="region_date_filter_47"]': { value: 'between' },
|
||||
'[name="region_date_from_47"]': { value: '' },
|
||||
'[name="region_date_to_47"]': { value: '2099-08-21T10:00:00.000Z' }
|
||||
};
|
||||
const context = module.buildPreviewRenderContext(region, { querySelector(selector) { return fields[selector] || null; } }, { timetable: saved }, []);
|
||||
assert.equal(context.date_filter, 'between');
|
||||
assert.equal(context.date_from, '');
|
||||
assert.equal(context.date_to, fields['[name="region_date_to_47"]'].value);
|
||||
assert.doesNotMatch(html, /Use the selected group and display mode/);
|
||||
for (const mode of ['before', 'after']) {
|
||||
const current = { ...saved, date_filter: mode };
|
||||
const markup = module.renderEditorCard({ region, current, timetableGroups: [] });
|
||||
assert.match(markup, new RegExp('<option value="' + mode + '" selected>'));
|
||||
assert.equal(module.getCurrentConfig(region, { timetable: current }).date_filter, mode);
|
||||
const activeField = mode === 'after' ? 'date_from' : 'date_to';
|
||||
const inactiveField = mode === 'after' ? 'date_to' : 'date_from';
|
||||
assert.doesNotMatch(markup, new RegExp('data-timetable-date="' + activeField + '"[^>]* disabled'));
|
||||
assert.match(markup, new RegExp('data-timetable-date="' + inactiveField + '"[^>]* disabled'));
|
||||
}
|
||||
});
|
||||
|
||||
test('timetable region editor lists timezone transforms in helper text', () => {
|
||||
const module = loadScheduleModule();
|
||||
const html = module.renderEditorCard({
|
||||
|
||||
@@ -25,7 +25,10 @@ test('buildSlidePayload normalizes timetable region fields', async () => {
|
||||
region_text_47: '<p>{{title}}</p>',
|
||||
region_timetable_group_id_47: '1',
|
||||
region_timetable_display_mode_47: 'current',
|
||||
region_timetable_max_items_47: '7'
|
||||
region_timetable_max_items_47: '7',
|
||||
region_date_filter_47: 'between',
|
||||
region_date_from_47: '2099-08-20T12:00:00+02:00',
|
||||
region_date_to_47: ''
|
||||
},
|
||||
files: []
|
||||
}, null);
|
||||
@@ -37,10 +40,50 @@ test('buildSlidePayload normalizes timetable region fields', async () => {
|
||||
assert.equal(content.timetable.timetable_group_id, '1');
|
||||
assert.equal(content.timetable.display_mode, 'current');
|
||||
assert.equal(content.timetable.max_items, '7');
|
||||
assert.equal(content.timetable.date_filter, 'between');
|
||||
assert.equal(content.timetable.date_from, '2099-08-20T10:00:00.000Z');
|
||||
assert.equal(content.timetable.date_to, '');
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_display_mode'), false);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_max_items'), false);
|
||||
});
|
||||
|
||||
test('timetable date settings persist, clear, default to all, and reject invalid ranges', async () => {
|
||||
const pool = {
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM c_templates st')) {
|
||||
return [[{ id: 9, name: 'Template 9' }]];
|
||||
}
|
||||
if (sql.includes('FROM c_template_regions')) {
|
||||
return [[{ id: 47, region_key: 'timetable', region_type: 'timetable' }]];
|
||||
}
|
||||
return [[]];
|
||||
}
|
||||
};
|
||||
const existing = { content_json: JSON.stringify({ timetable: { type: 'timetable', date_filter: 'between', date_from: '2099-08-20T10:00:00.000Z', date_to: '2099-08-20T11:00:00.000Z' } }) };
|
||||
async function save(fields, slide) {
|
||||
const payload = await buildSlidePayload(pool, { body: { title: 'Timetable', template_id: '9', ...fields }, files: [] }, slide);
|
||||
return JSON.parse(payload.contentJson).timetable;
|
||||
}
|
||||
assert.equal((await save({})).date_filter, 'all');
|
||||
assert.equal((await save({}, existing)).date_from, '2099-08-20T10:00:00.000Z');
|
||||
assert.equal((await save({ region_date_from_47: '' }, existing)).date_from, '');
|
||||
const all = await save({ region_date_filter_47: 'all' }, existing);
|
||||
assert.equal(all.date_filter, 'all');
|
||||
assert.equal(all.date_to, '2099-08-20T11:00:00.000Z');
|
||||
for (const mode of ['before', 'after']) {
|
||||
const saved = await save({ region_date_filter_47: mode }, existing);
|
||||
assert.equal(saved.date_filter, mode);
|
||||
assert.equal(saved.date_from, '2099-08-20T10:00:00.000Z');
|
||||
assert.equal(saved.date_to, '2099-08-20T11:00:00.000Z');
|
||||
}
|
||||
assert.equal((await save({ region_date_filter_47: 'after', region_date_to_47: '2099-08-19T00:00:00Z' }, existing)).date_filter, 'after');
|
||||
assert.equal((await save({ region_date_filter_47: 'before', region_date_from_47: '2099-08-21T00:00:00Z' }, existing)).date_filter, 'before');
|
||||
await assert.rejects(save({ region_date_filter_47: 'before', region_date_to_47: 'invalid' }), { statusCode: 400 });
|
||||
await assert.rejects(save({ region_date_filter_47: 'after', region_date_from_47: 'invalid' }), { statusCode: 400 });
|
||||
await assert.rejects(save({ region_date_filter_47: 'between', region_date_from_47: 'invalid' }), { statusCode: 400 });
|
||||
await assert.rejects(save({ region_date_from_47: '2099-08-21T00:00:00Z' }, existing), { statusCode: 400 });
|
||||
});
|
||||
|
||||
test('buildSlidePayload preserves safe image markup in text regions', async () => {
|
||||
const pool = {
|
||||
async query(sql) {
|
||||
|
||||
@@ -206,4 +206,74 @@ test('weather icon usage recognizes one set reference', function () {
|
||||
};
|
||||
assert.equal(isUsed('set'), true);
|
||||
assert.equal(isUsed('other'), false);
|
||||
});
|
||||
|
||||
test('weather icon set create, update, toggle, and delete are audited', async function () {
|
||||
const originals = {
|
||||
readWeatherIconSets: library.readWeatherIconSets,
|
||||
saveWeatherIconSet: library.saveWeatherIconSet,
|
||||
collectWeatherIconRemovalOperations: library.collectWeatherIconRemovalOperations,
|
||||
setWeatherIconSetEnabled: library.setWeatherIconSetEnabled,
|
||||
deleteWeatherIconSet: library.deleteWeatherIconSet
|
||||
};
|
||||
const handlers = {};
|
||||
const events = [];
|
||||
let sets = [];
|
||||
const app = {
|
||||
get(route, ...callbacks) { handlers['GET ' + route] = callbacks; },
|
||||
post(route, ...callbacks) { handlers['POST ' + route] = callbacks; }
|
||||
};
|
||||
const iconSet = { id: 'clear-set', name: 'Clear skies', icons: { clear: '/media/assets/weather-icons/clear-set/clear.png' }, nightIcons: {} };
|
||||
library.readWeatherIconSets = function () { return sets.slice(); };
|
||||
library.saveWeatherIconSet = async function (_root, name, _files, id) {
|
||||
const saved = Object.assign({}, iconSet, { id: id || iconSet.id, name: name });
|
||||
sets = id ? sets.map(function (set) { return set.id === id ? saved : set; }) : sets.concat(saved);
|
||||
return saved;
|
||||
};
|
||||
library.collectWeatherIconRemovalOperations = function () { return []; };
|
||||
library.setWeatherIconSetEnabled = function (_root, id, enabled) {
|
||||
const updated = Object.assign({}, sets.find(function (set) { return set.id === id; }), { enabled: enabled });
|
||||
sets = sets.map(function (set) { return set.id === id ? updated : set; });
|
||||
return updated;
|
||||
};
|
||||
library.deleteWeatherIconSet = function (_root, id) {
|
||||
const removed = sets.find(function (set) { return set.id === id; });
|
||||
sets = sets.filter(function (set) { return set.id !== id; });
|
||||
return removed;
|
||||
};
|
||||
|
||||
try {
|
||||
require('../src/web/routes/settings/weather-icons') (app, {
|
||||
mediaDir: 'unused',
|
||||
pool: { async query() { return [[]]; } },
|
||||
requirePermission() { return function (_req, _res, next) { next(); }; },
|
||||
recordRequestAuditEvent(_pool, _req, event) { events.push(event); }
|
||||
});
|
||||
const response = { redirect() { return this; }, status() { return this; }, send() { return this; } };
|
||||
const next = function (error) { if (error) throw error; };
|
||||
const currentUser = { id: 21 };
|
||||
const createRequest = { currentUser: currentUser, body: { name: 'Clear skies' }, files: {}, params: {} };
|
||||
|
||||
await handlers['POST /settings/weather-icons'].at(-1)(createRequest, response, next);
|
||||
await handlers['POST /settings/weather-icons/:id'].at(-1)(
|
||||
{ currentUser: currentUser, body: { name: 'Updated skies' }, files: {}, params: { id: iconSet.id } }, response, next
|
||||
);
|
||||
await handlers['POST /settings/weather-icons/:id/toggle'].at(-1)(
|
||||
{ currentUser: currentUser, body: { enabled: '0' }, params: { id: iconSet.id } }, response, next
|
||||
);
|
||||
await handlers['POST /settings/weather-icons/:id/delete'].at(-1)(
|
||||
{ currentUser: currentUser, params: { id: iconSet.id } }, response, next
|
||||
);
|
||||
|
||||
assert.deepEqual(events.map(function (event) { return event.eventType; }), [
|
||||
'weather-icon-set.created', 'weather-icon-set.updated', 'weather-icon-set.disabled', 'weather-icon-set.deleted'
|
||||
]);
|
||||
assert.ok(events.every(function (event) { return event.category === 'media-assets' && event.actorUserId === 21; }));
|
||||
} finally {
|
||||
library.readWeatherIconSets = originals.readWeatherIconSets;
|
||||
library.saveWeatherIconSet = originals.saveWeatherIconSet;
|
||||
library.collectWeatherIconRemovalOperations = originals.collectWeatherIconRemovalOperations;
|
||||
library.setWeatherIconSetEnabled = originals.setWeatherIconSetEnabled;
|
||||
library.deleteWeatherIconSet = originals.deleteWeatherIconSet;
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user