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: '

Hallo

', 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, '

Hallo

') 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 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/) })