diff --git a/MMM-CalendarExtMiniMonth.js b/MMM-CalendarExtMiniMonth.js index 1c19873..923a7db 100644 --- a/MMM-CalendarExtMiniMonth.js +++ b/MMM-CalendarExtMiniMonth.js @@ -179,6 +179,8 @@ Module.register('MMM-CalendarExtMiniMonth', { } return { name: calendarName, + // Stored raw: never rendered via innerHTML today (see test/event-title-no-dom-sink.test.js). + // If a feature ever displays this, escape at the render sink, not here. title: event.title, startDate: startDate, endDate: endDate, diff --git a/package.json b/package.json index f22003f..3804f75 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "lint": "eslint && prettier . --check", "lint:fix": "eslint --fix && prettier . --write", "release": "commit-and-tag-version --config ./changelog.config.js", - "test": "node --run lint" + "test": "node --run lint && node --test" }, "repository": { "type": "git", diff --git a/test/event-title-no-dom-sink.test.js b/test/event-title-no-dom-sink.test.js new file mode 100644 index 0000000..b792991 --- /dev/null +++ b/test/event-title-no-dom-sink.test.js @@ -0,0 +1,141 @@ +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const path = require('node:path') + +function createFakeElement() { + return { + _id: '', + className: '', + style: {}, + innerHTML: '', + children: [], + classList: { + add() {}, + contains() { + return false + }, + }, + appendChild(child) { + this.children.push(child) + return child + }, + get id() { + return this._id + }, + set id(value) { + this._id = value + elementsById.set(value, this) + }, + } +} + +const elementsById = new Map() + +function serialize(el) { + let out = el.innerHTML || '' + for (const child of el.children) { + out += serialize(child) + } + return out +} + +function loadModule() { + let captured = null + global.Module = { + register(name, definition) { + captured = definition + }, + } + global.config = {} + global.document = { + createElement: createFakeElement, + getElementById(id) { + return elementsById.get(id) || null + }, + body: createFakeElement(), + } + + delete require.cache[path.join(__dirname, '..', 'MMM-CalendarExtMiniMonth.js')] + require('../MMM-CalendarExtMiniMonth.js') + + const instance = Object.assign(Object.create(captured), captured) + instance.config = Object.assign({}, captured.defaults, { dynamicEventColor: null }) + instance.start() + instance.updateDom = function () {} + instance.refreshScreen = function () { + this.updateDom() + } + return instance +} + +function makeEvent(title) { + return { + calendarName: 'Test Calendar', + title: title, + startDate: Date.now(), + endDate: Date.now() + 60 * 60 * 1000, + } +} + +test('normal event title is stored and rendered without error', () => { + const instance = loadModule() + instance.updateContentFromCalendarEvents([makeEvent('Team Meeting')]) + + assert.equal(instance.events.length, 1) + assert.equal(instance.events[0].title, 'Team Meeting') + + const dom = instance.getDom() + assert.ok(dom.children.length > 0, 'expected rendered DOM to contain header/slot nodes') +}) + +test('' + instance.updateContentFromCalendarEvents([makeEvent(payload)]) + + assert.equal(instance.events[0].title, payload, 'title must be stored raw, not escaped/mangled') + + const dom = instance.getDom() + const rendered = serialize(dom) + assert.ok(!rendered.includes(' { + const instance = loadModule() + const payload = '' + instance.updateContentFromCalendarEvents([makeEvent(payload)]) + + assert.equal(instance.events[0].title, payload) + + const dom = instance.getDom() + const rendered = serialize(dom) + assert.ok(!rendered.includes('onerror'), 'event title must never be inserted into the DOM') +}) + +test('ampersand in title is preserved exactly, not HTML-entity-encoded', () => { + const instance = loadModule() + instance.updateContentFromCalendarEvents([makeEvent('Research & Development')]) + + assert.equal(instance.events[0].title, 'Research & Development') +}) + +test('quotes and apostrophes in title are preserved exactly', () => { + const instance = loadModule() + instance.updateContentFromCalendarEvents([makeEvent('Tom\'s "Meeting"')]) + + assert.equal(instance.events[0].title, 'Tom\'s "Meeting"') +}) + +test('missing or empty title drops the event without throwing', () => { + const instance = loadModule() + assert.doesNotThrow(() => { + instance.updateContentFromCalendarEvents([makeEvent(''), makeEvent(undefined)]) + }) + assert.equal(instance.events.length, 0) + + assert.doesNotThrow(() => { + instance.getDom() + }) +})