Files
Webklar-Kundenbereich/test/termineIcs.test.js
T

364 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
// Kalenderdatei (RFC 5545/5546) und Gaesteversand. Kein Netz: der Mailversand laeuft ueber die Senke, config.js braucht
// nur eine gueltige Umgebung.
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { icsTermin, icsKalender } = await import('../server/services/termine/ics.js')
const { sendGuestMail, setMailTransportForTests } = await import('../server/services/mailer.js')
after(() => setMailTransportForTests(null))
const JETZT = new Date('2026-10-02T09:00:00.000Z')
const termin = (extra = {}) => ({
uid: 'b_9f3a1c@termine.webklar.com',
sequenz: 2,
methode: 'REQUEST',
start: '2026-10-05T10:00:00.000+02:00', // 08:00 UTC
ende: new Date('2026-10-05T08:45:00.000Z'),
titel: 'Haarschnitt – Salon Muster',
ort: 'Hauptstraße 1, 67655 Kaiserslautern',
beschreibung: 'Buchungsnummer AB12CD34',
organisator: { name: 'Salon Muster', email: 'info@salon-muster.de' },
teilnehmer: { name: 'Anna Müller', email: 'anna@example.com' },
jetzt: JETZT,
...extra,
})
const enc = new TextEncoder()
const octets = (s) => enc.encode(s).length
/**
* Jede physische Zeile hoechstens 75 Oktette und wohlgeformtes UTF-16 (kein halbes Surrogatpaar). Ein Decode-Test
* (TextDecoder) taugt dafuer nicht: JS-Strings kodieren immer, einzelne Surrogate werden still zu U+FFFD.
*/
const pruefeZeilen = (text) => {
for (const zeile of text.split('\r\n')) {
assert.ok(octets(zeile) <= 75, `Zeile zu lang (${octets(zeile)}): ${zeile}`)
assert.ok(zeile.isWellFormed(), `Zeile mit halbem Surrogatpaar: ${JSON.stringify(zeile)}`)
}
}
/** Zeilenfaltung rueckgaengig machen: CRLF + Leerzeichen faellt weg. */
const entfalte = (text) => text.replace(/\r\n /g, '')
/** Logische Zeilen (entfaltet, ohne das abschliessende CRLF). */
const zeilen = (text) => entfalte(text).split('\r\n').slice(0, -1)
const entescape = (s) => s.replace(/\\([\\;,nN])/g, (_, c) => (c === 'n' || c === 'N' ? '\n' : c))
const wert = (text, name) => zeilen(text).find((l) => l.startsWith(`${name}:`) || l.startsWith(`${name};`))
test('Aufbau: CRLF, Kopf, genau ein Termin, UTC-Zeiten, UID, SEQUENCE, DTSTAMP', () => {
const ics = icsTermin(termin())
assert.ok(ics.endsWith('END:VCALENDAR\r\n'), 'endet mit CRLF')
assert.ok(!/[^\r]\n|\r[^\n]/.test(ics), 'nur CRLF, kein einzelnes CR oder LF')
assert.deepEqual(zeilen(ics), [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//WEBklar//Terminplaner//DE',
'CALSCALE:GREGORIAN',
'METHOD:REQUEST',
'BEGIN:VEVENT',
'UID:b_9f3a1c@termine.webklar.com',
'DTSTAMP:20261002T090000Z',
'SEQUENCE:2',
'DTSTART:20261005T080000Z',
'DTEND:20261005T084500Z',
'SUMMARY:Haarschnitt – Salon Muster',
'DESCRIPTION:Buchungsnummer AB12CD34',
'LOCATION:Hauptstraße 1\\, 67655 Kaiserslautern',
'ORGANIZER;CN="Salon Muster":mailto:info@salon-muster.de',
'ATTENDEE;CN="Anna Müller";ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:anna@example.com',
'STATUS:CONFIRMED',
'TRANSP:OPAQUE',
'END:VEVENT',
'END:VCALENDAR',
])
})
test('Zeiten: Date, ISO mit Zone und Millisekunden werden gleich zu UTC; Sekundenbruchteile fallen weg', () => {
const a = icsTermin(termin({ start: new Date('2026-10-05T08:00:00.999Z'), ende: Date.UTC(2026, 9, 5, 8, 45) }))
assert.equal(wert(a, 'DTSTART'), 'DTSTART:20261005T080000Z')
assert.equal(wert(a, 'DTEND'), 'DTEND:20261005T084500Z')
// Sommerzeitende (Berlin 2026-10-25): 02:30 +01:00 ist 01:30 UTC
const b = icsTermin(termin({ start: '2026-10-25T02:30:00+01:00', ende: '2026-10-25T03:00:00+01:00' }))
assert.equal(wert(b, 'DTSTART'), 'DTSTART:20261025T013000Z')
assert.equal(wert(b, 'DTEND'), 'DTEND:20261025T020000Z')
})
test('Zeilenlaenge: jede Zeile hoechstens 75 Oktette, nie mitten in einem UTF-8-Zeichen gefaltet', () => {
for (const baustein of ['a', 'ä', '€', '🙂', 'Ä߀🙂']) {
for (let n = 40; n < 130; n += 1) {
const titel = baustein.repeat(n)
const ics = icsTermin(termin({ titel, ort: titel, beschreibung: titel, organisator: { name: titel, email: 'info@salon-muster.de' }, teilnehmer: { name: titel, email: 'anna@example.com' } }))
pruefeZeilen(ics)
for (const folge of ics.split('\r\n').filter((l) => l.startsWith(' '))) assert.ok(folge.length > 1, 'Folgezeile nicht leer')
assert.equal(entescape(wert(ics, 'SUMMARY').slice('SUMMARY:'.length)), titel, 'Entfalten ergibt den Titel wieder')
}
}
})
test('Zeilenlaenge: Emoji (Surrogatpaare) werden nie zwischen ihren Haelften gefaltet, in Termin und Abo', () => {
let gefaltet = 0
for (const vorsatz of ['', 'a', 'ab', 'abc']) {
for (let n = 1; n < 60; n += 1) {
const titel = vorsatz + '😀'.repeat(n)
const felder = { titel, ort: titel, beschreibung: titel, organisator: { name: titel, email: 'info@salon-muster.de' }, teilnehmer: { name: titel, email: 'anna@example.com' } }
const ics = icsTermin(termin(felder))
pruefeZeilen(ics)
gefaltet += ics.split('\r\n').filter((l) => l.startsWith(' ')).length
assert.equal(entescape(wert(ics, 'SUMMARY').slice('SUMMARY:'.length)), titel)
assert.equal(entescape(wert(ics, 'DESCRIPTION').slice('DESCRIPTION:'.length)), titel)
const feed = icsKalender([termin(felder)], { name: titel, jetzt: JETZT })
pruefeZeilen(feed)
assert.equal(entescape(wert(feed, 'X-WR-CALNAME').slice('X-WR-CALNAME:'.length)), titel)
}
}
assert.ok(gefaltet > 500, `der Test faltet wirklich (${gefaltet} Folgezeilen)`)
// Beispiel: DESCRIPTION: ist 12 Oktette lang, danach passen 15 Emoji (60 Oktette) in die erste Zeile, das 16. beginnt die Folgezeile
const beschreibung = '😀'.repeat(16)
const zeilen = icsTermin(termin({ beschreibung })).split('\r\n')
const i = zeilen.findIndex((l) => l.startsWith('DESCRIPTION:'))
assert.equal(zeilen[i], `DESCRIPTION:${'😀'.repeat(15)}`)
assert.equal(zeilen[i + 1], ' 😀')
})
test('Zeilenlaenge: eine Zeile von genau 75 Oktetten bleibt ungefaltet, 76 wird gefaltet', () => {
const zeileMit = (n) => icsTermin(termin({ titel: 'x'.repeat(n - 'SUMMARY:'.length) })).split('\r\n').find((l) => l.startsWith('SUMMARY:'))
assert.equal(octets(zeileMit(75)), 75)
const ics76 = icsTermin(termin({ titel: 'x'.repeat(76 - 'SUMMARY:'.length) }))
assert.match(ics76, /SUMMARY:x{67}\r\n x\r\n/)
})
test('Escaping: Backslash, Semikolon, Komma und Zeilenumbrueche in TEXT-Werten', () => {
const roh = 'a\\b;c,d\ne\r\nf\rg'
const ics = icsTermin(termin({ titel: roh, ort: roh, beschreibung: roh }))
const erwartet = 'a\\\\b\\;c\\,d\\ne\\nf\\ng'
assert.equal(wert(ics, 'SUMMARY'), `SUMMARY:${erwartet}`)
assert.equal(wert(ics, 'LOCATION'), `LOCATION:${erwartet}`)
assert.equal(wert(ics, 'DESCRIPTION'), `DESCRIPTION:${erwartet}`)
assert.equal(entescape(erwartet), 'a\\b;c,d\ne\nf\ng')
})
test('Einschleusen: CR/LF und Steuerzeichen in Eingaben erzeugen keine zusaetzlichen Zeilen', () => {
const boese = 'x\r\nATTENDEE:mailto:boese@example.com\r\nEND:VEVENT'
const ics = icsTermin(
termin({
uid: `uid${boese}`,
titel: boese,
ort: boese,
beschreibung: boese,
organisator: { name: `Org${boese}`, email: `org@example.com${boese}` },
teilnehmer: { name: `Gast${boese}\u0000\u0007`, email: `gast@example.com${boese}` },
})
)
const logisch = zeilen(ics)
assert.equal(logisch.filter((l) => l.startsWith('ATTENDEE')).length, 1, 'genau ein ATTENDEE')
assert.equal(logisch.filter((l) => l === 'END:VEVENT').length, 1, 'genau ein END:VEVENT')
assert.equal(logisch.filter((l) => l.startsWith('UID:')).length, 1)
assert.ok(!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(ics), 'keine Steuerzeichen')
assert.ok(!/[^\r]\n|\r[^\n]/.test(ics))
// jede Zeile ist eine bekannte Eigenschaft (kein eingeschleuster Name)
for (const l of logisch) assert.match(l, /^(BEGIN|END|VERSION|PRODID|CALSCALE|METHOD|UID|DTSTAMP|SEQUENCE|DTSTART|DTEND|SUMMARY|DESCRIPTION|LOCATION|ORGANIZER|ATTENDEE|STATUS|TRANSP)[:;]/)
})
test('CN="…": ohne Anfuehrungszeichen und Steuerzeichen, leerer Name laesst CN weg', () => {
const ics = icsTermin(termin({ organisator: { name: 'Salon "Chez; Muster"\n', email: 'info@salon-muster.de' }, teilnehmer: { name: '"\u0001"', email: 'anna@example.com' } }))
assert.equal(wert(ics, 'ORGANIZER'), 'ORGANIZER;CN="Salon Chez; Muster":mailto:info@salon-muster.de')
assert.equal(wert(ics, 'ATTENDEE'), 'ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:anna@example.com')
})
test('E-Mail in mailto: ohne Leerraum und Trennzeichen', () => {
const ics = icsTermin(termin({ teilnehmer: { name: 'A', email: ' anna@example.com,boese@example.com ' } }))
assert.match(wert(ics, 'ATTENDEE'), /:mailto:anna@example\.comboese@example\.com$/)
})
test('ohne Teilnehmer, Ort und Beschreibung entfallen die Zeilen', () => {
const ics = icsTermin(termin({ teilnehmer: null, ort: '', beschreibung: undefined }))
assert.equal(wert(ics, 'ATTENDEE'), undefined)
assert.equal(wert(ics, 'LOCATION'), undefined)
assert.equal(wert(ics, 'DESCRIPTION'), undefined)
assert.ok(wert(ics, 'ORGANIZER'))
})
test('CANCEL: gleiche UID, hoehere Sequenz, STATUS:CANCELLED', () => {
const ics = icsTermin(termin({ methode: 'CANCEL', sequenz: 3 }))
assert.equal(wert(ics, 'METHOD'), 'METHOD:CANCEL')
assert.equal(wert(ics, 'UID'), 'UID:b_9f3a1c@termine.webklar.com')
assert.equal(wert(ics, 'SEQUENCE'), 'SEQUENCE:3')
assert.equal(wert(ics, 'STATUS'), 'STATUS:CANCELLED')
assert.match(wert(ics, 'ATTENDEE'), /;RSVP=FALSE:mailto:anna@example\.com$/)
assert.equal(zeilen(icsTermin(termin())).includes('STATUS:CANCELLED'), false, 'REQUEST ist nicht abgesagt')
})
test('PUBLISH (Betriebsmail, Abo): kein ATTENDEE, Organisator bleibt', () => {
const ics = icsTermin(termin({ methode: 'PUBLISH' }))
assert.equal(wert(ics, 'METHOD'), 'METHOD:PUBLISH')
assert.equal(wert(ics, 'ATTENDEE'), undefined)
assert.equal(wert(ics, 'STATUS'), 'STATUS:CONFIRMED')
assert.ok(wert(ics, 'ORGANIZER'))
})
test('Pflichtangaben: unbekannte Methode, ungueltige Zeiten, fehlende Felder werfen', () => {
assert.throws(() => icsTermin(termin({ methode: 'REPLY' })), TypeError)
assert.throws(() => icsTermin(termin({ methode: undefined })), TypeError)
assert.throws(() => icsTermin(termin({ start: 'morgen' })), TypeError)
assert.throws(() => icsTermin(termin({ ende: undefined })), TypeError)
assert.throws(() => icsTermin(termin({ ende: '2026-10-05T08:00:00Z' })), TypeError, 'Ende nicht nach Beginn')
assert.throws(() => icsTermin(termin({ jetzt: undefined })), TypeError)
assert.throws(() => icsTermin(termin({ jetzt: '2026-10-02' })), TypeError)
assert.throws(() => icsTermin(termin({ uid: '' })), TypeError)
assert.throws(() => icsTermin(termin({ uid: '\r\n' })), TypeError)
assert.throws(() => icsTermin(termin({ titel: '' })), TypeError)
assert.throws(() => icsTermin(termin({ sequenz: -1 })), TypeError)
assert.throws(() => icsTermin(termin({ sequenz: 1.5 })), TypeError)
assert.throws(() => icsTermin(termin({ organisator: null })), TypeError, 'REQUEST braucht den Organisator')
assert.throws(() => icsTermin(termin({ methode: 'CANCEL', organisator: undefined })), TypeError)
})
test('icsKalender: PUBLISH-Feed mit Name, Aktualisierungshinweis, mehreren Terminen und ohne Gaeste', () => {
const feed = icsKalender(
[
termin({ uid: 'b_1@termine.webklar.com', teilnehmer: { name: 'Geheim', email: 'geheim@example.com' } }),
{ uid: 'x_sperre@termine.webklar.com', sequenz: 0, start: '2026-10-06T12:00:00Z', ende: '2026-10-06T13:00:00Z', titel: 'Gesperrt', organisator: { name: 'Salon Muster', email: 'info@salon-muster.de' } },
],
{ name: 'Salon Muster, Stuhl 1; Süd', jetzt: JETZT }
)
assert.ok(feed.endsWith('END:VCALENDAR\r\n'))
assert.ok(!/[^\r]\n|\r[^\n]/.test(feed))
for (const zeile of feed.split('\r\n')) assert.ok(octets(zeile) <= 75)
const logisch = zeilen(feed)
assert.deepEqual(logisch.slice(0, 7), [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//WEBklar//Terminplaner//DE',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:Salon Muster\\, Stuhl 1\\; Süd',
'X-PUBLISHED-TTL:PT15M',
])
assert.equal(logisch.filter((l) => l === 'BEGIN:VEVENT').length, 2)
assert.equal(logisch.filter((l) => l === 'END:VEVENT').length, 2)
assert.equal(logisch.filter((l) => l === 'DTSTAMP:20261002T090000Z').length, 2, 'DTSTAMP = jetzt')
assert.equal(logisch.filter((l) => l.startsWith('ATTENDEE')).length, 0, 'keine Gaeste im Abo')
assert.ok(!/geheim@example\.com/.test(feed))
assert.deepEqual(logisch.filter((l) => l.startsWith('UID:')), ['UID:b_1@termine.webklar.com', 'UID:x_sperre@termine.webklar.com'])
assert.deepEqual(logisch.filter((l) => l.startsWith('STATUS:')), ['STATUS:CONFIRMED', 'STATUS:CONFIRMED'])
})
test('icsKalender: leerer Feed ist ein gueltiger Kalender; ohne jetzt oder Name-Typ wirft', () => {
const leer = icsKalender([], { name: 'Leer', jetzt: JETZT })
assert.deepEqual(zeilen(leer).filter((l) => l.startsWith('BEGIN:') || l.startsWith('END:')), ['BEGIN:VCALENDAR', 'END:VCALENDAR'])
assert.throws(() => icsKalender([], { name: 'Leer' }), TypeError)
assert.throws(() => icsKalender([termin({ start: 'x' })], { name: 'Leer', jetzt: JETZT }), TypeError)
})
// ---------------------------------------------------------------------------------------------------------------------
// sendGuestMail
// ---------------------------------------------------------------------------------------------------------------------
const gastMail = (extra = {}) => ({
to: 'anna@example.com',
fromName: 'Salon Muster',
subject: 'Ihr Termin bei Salon Muster am 05.10.2026',
html: '<p>Hallo</p>',
text: 'Hallo',
...extra,
})
async function mitSenke(fn, from = 'noreply@webklar.com') {
const vorher = config.smtp.from
config.smtp.from = from
const senke = mailSink()
setMailTransportForTests(senke)
try {
return await fn(senke)
} finally {
setMailTransportForTests(null)
config.smtp.from = vorher
}
}
test('sendGuestMail: Absendername = Betrieb, Adresse = SMTP_FROM, replyTo = Betrieb, nie BCC, ohne ics kein icalEvent', async () => {
await mitSenke(async (senke) => {
await sendGuestMail(gastMail({ replyTo: 'info@salon-muster.de' }))
assert.equal(senke.sent.length, 1)
const [m] = senke.sent
assert.deepEqual(m.from, { address: 'noreply@webklar.com', name: 'Salon Muster' })
assert.deepEqual(m.to, [{ address: 'anna@example.com', name: '' }])
assert.deepEqual(m.replyTo, [{ address: 'info@salon-muster.de', name: '' }])
assert.equal(m.subject, 'Ihr Termin bei Salon Muster am 05.10.2026')
assert.equal(m.html, '<p>Hallo</p>')
assert.equal(m.text, 'Hallo')
assert.equal('bcc' in m, false, 'nie BCC')
assert.equal('cc' in m, false)
assert.equal('icalEvent' in m, false, 'ohne ics keine Kalenderdatei')
assert.deepEqual(m.attachments ?? [], [])
})
})
test('sendGuestMail: ohne replyTo keine Antwortadresse, auch nicht die des Portals', async () => {
await mitSenke(async (senke) => {
const vorher = config.smtp.replyTo
config.smtp.replyTo = 'support@webklar.com'
try {
await sendGuestMail(gastMail())
} finally {
config.smtp.replyTo = vorher
}
assert.equal(senke.sent[0].replyTo ?? null, null, 'nodemailer schreibt eine fehlende Antwortadresse als null')
assert.equal('bcc' in senke.sent[0], false)
})
})
test('sendGuestMail: mit ics geht die Kalenderdatei als icalEvent (Standard REQUEST, termin.ics)', async () => {
await mitSenke(async (senke) => {
const ics = icsTermin(termin())
await sendGuestMail(gastMail({ ics }))
const m = senke.sent[0]
assert.equal(m.icalEvent.method, 'REQUEST')
assert.equal(m.icalEvent.filename, 'termin.ics')
assert.equal(m.icalEvent.content, ics)
assert.match(m.icalEvent.content, /BEGIN:VCALENDAR/)
assert.equal('bcc' in m, false)
})
})
test('sendGuestMail: icsMethod CANCEL und PUBLISH werden uebernommen, andere Methoden abgelehnt', async () => {
await mitSenke(async (senke) => {
await sendGuestMail(gastMail({ ics: icsTermin(termin({ methode: 'CANCEL' })), icsMethod: 'CANCEL' }))
assert.equal(senke.sent[0].icalEvent.method, 'CANCEL')
await sendGuestMail(gastMail({ ics: icsTermin(termin({ methode: 'PUBLISH' })), icsMethod: 'PUBLISH' }))
assert.equal(senke.sent[1].icalEvent.method, 'PUBLISH')
await assert.rejects(sendGuestMail(gastMail({ ics: 'x', icsMethod: 'REPLY' })), TypeError)
assert.equal(senke.sent.length, 2)
})
})
test('sendGuestMail: Betreff und Absendername ohne Zeilenumbrueche; Anfuehrungszeichen im Namen bleiben heil', async () => {
await mitSenke(async (senke) => {
await sendGuestMail(gastMail({ fromName: 'Salon "Chez, Muster"\r\nBcc: boese@example.com', subject: 'Termin\r\nBcc: boese@example.com' }))
const m = senke.sent[0]
assert.ok(!/[\r\n]/.test(m.subject))
assert.ok(!/[\r\n]/.test(m.from.name))
assert.equal(m.from.address, 'noreply@webklar.com')
assert.equal('bcc' in m, false)
})
})
test('sendGuestMail: genau ein Postfach als Empfaenger und Antwortadresse', async () => {
await mitSenke(async (senke) => {
for (const to of ['a@example.com, b@example.com', 'a@example.com;b@example.com', 'Max <a@example.com>', '"a b"@example.com', 'a@example.com\r\nBcc: b@example.com', '', undefined, 'ohne-at', `${'a'.repeat(250)}@example.com`]) {
await assert.rejects(sendGuestMail(gastMail({ to })), TypeError, String(to))
}
await assert.rejects(sendGuestMail(gastMail({ replyTo: 'a@example.com, b@example.com' })), TypeError)
await assert.rejects(sendGuestMail(gastMail({ subject: '' })), TypeError)
await assert.rejects(sendGuestMail(gastMail({ fromName: '' })), TypeError)
assert.equal(senke.sent.length, 0)
})
})
test('sendGuestMail: ohne SMTP-Zugang wirft sie (wie sendCustomerMail)', async () => {
setMailTransportForTests(null)
assert.equal(config.smtp.host, '', 'Testumgebung ohne SMTP')
await assert.rejects(sendGuestMail(gastMail()), /SMTP nicht konfiguriert/)
})