import { describe, expect, it } from 'vitest'; import { buildNextcloudAlertMail, describeCheckError } from './nextcloud-alert-mail'; import type { NextcloudRating } from './nextcloud-rating'; const APP = 'https://tessera.example.invalid'; const AT = new Date('2026-10-02T12:30:00.000Z'); // 14:30 Uhr Berliner Zeit function rating( reason: NextcloudRating['reason'], extra: Partial = {}, ): NextcloudRating { const level = ['current', 'update-available', 'eol-soon'].includes(reason) ? reason === 'current' ? 'green' : 'yellow' : 'red'; return { level, reason, updateTo: null, eolDate: null, cycle: null, ...extra }; } function down(over: Partial[0]> = {}) { return buildNextcloudAlertMail( { kind: 'down', customerName: 'Kunde A', baseUrl: 'https://cloud.a.de', rating: rating('unreachable'), errorKind: 'network', errorDetail: 'ECONNREFUSED', at: AT, ...over, }, APP, ); } describe('describeCheckError', () => { it.each([ ['tls', 'ERR_TLS_CERT_ALTNAME_INVALID', 'Zertifikat passt nicht zur Adresse'], ['tls', 'HOSTNAME_MISMATCH', 'Zertifikat passt nicht zur Adresse'], ['tls', 'CERT_HAS_EXPIRED', 'Zertifikat abgelaufen'], ['tls', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'Zertifikat nicht vertrauenswürdig'], ['tls', 'SELF_SIGNED_CERT_IN_CHAIN', 'Zertifikat nicht vertrauenswürdig'], ['tls', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'Zertifikat nicht vertrauenswürdig'], ['network', 'ENOTFOUND', 'Adresse nicht gefunden'], ['network', 'EAI_AGAIN', 'Adresse nicht gefunden'], ['network', 'ECONNREFUSED', 'Verbindung abgelehnt'], ['timeout', null, 'Zeitüberschreitung'], ['network', 'ETIMEDOUT', 'Zeitüberschreitung'], ['http-status', 'HTTP 502', 'Server antwortet mit Fehler 502'], ['network', null, 'Verbindungsfehler'], ['network', 'ECONNRESET', 'Verbindungsfehler'], ['tls', 'WAS_AUCH_IMMER', 'Verbindungsfehler'], [null, null, 'Verbindungsfehler'], ] as const)('%s / %s -> %s', (kind, detail, expected) => { expect(describeCheckError(kind, detail)).toBe(expected); }); }); describe('buildNextcloudAlertMail', () => { it('Betreff nicht erreichbar wortgleich', () => { expect(down().subject).toBe('Nextcloud Kunde A: nicht erreichbar'); }); it('Betreff wieder in Ordnung wortgleich', () => { const mail = buildNextcloudAlertMail( { kind: 'up', customerName: 'Kunde A', baseUrl: 'https://cloud.a.de', rating: rating('current'), errorKind: null, errorDetail: null, at: AT, }, APP, ); expect(mail.subject).toBe('Nextcloud Kunde A: wieder in Ordnung'); }); it.each([ ['invalid-response', 'keine gültige Antwort'], ['maintenance', 'im Wartungsmodus'], ['needs-db-upgrade', 'Datenbank-Aktualisierung ausstehend'], ['eol-passed', 'Support abgelaufen'], ] as const)('Betreff je Grund: %s', (reason, wording) => { expect(down({ rating: rating(reason) }).subject).toBe(`Nextcloud Kunde A: ${wording}`); }); it('Text enthaelt Grund, Adresse, Zeit in Berliner Zeit und Link zum Modul', () => { const { text } = down(); expect(text.startsWith('Guten Tag,')).toBe(true); expect(text).toContain('Grund: Nicht erreichbar (Verbindung abgelehnt)'); expect(text).toContain('Adresse: https://cloud.a.de'); expect(text).toContain('14:30 Uhr'); expect(text).toContain(`Zum Modul: ${APP}/modules/nextcloud-status`); expect(text).toContain('Benachrichtigen'); expect(text).not.toContain('ECONNREFUSED'); }); it('Gründe im Text: Wartung, Datenbank, Support mit Datum', () => { expect(down({ rating: rating('maintenance') }).text).toContain( 'Grund: Wartungsmodus eingeschaltet', ); expect(down({ rating: rating('needs-db-upgrade') }).text).toContain( 'Grund: Datenbank-Aktualisierung ausstehend', ); expect(down({ rating: rating('invalid-response') }).text).toContain( 'Grund: Keine gültige Nextcloud-Antwort', ); expect(down({ rating: rating('eol-passed', { eolDate: '2026-09-30' }) }).text).toContain( 'Grund: Support abgelaufen seit 30.09.2026', ); expect(down({ rating: rating('eol-passed') }).text).toContain('Grund: Support abgelaufen'); }); it('wieder in Ordnung nennt den aktuellen Stand', () => { const base = { kind: 'up' as const, customerName: 'Kunde A', baseUrl: 'https://cloud.a.de', errorKind: null, errorDetail: null, at: AT, }; expect(buildNextcloudAlertMail({ ...base, rating: rating('current') }, APP).text).toContain( 'Aktueller Stand: Aktuell', ); expect( buildNextcloudAlertMail( { ...base, rating: rating('update-available', { updateTo: '35.0.2' }) }, APP, ).text, ).toContain('Aktueller Stand: Update auf 35.0.2 verfügbar'); expect( buildNextcloudAlertMail( { ...base, rating: rating('eol-soon', { eolDate: '2026-12-31' }) }, APP, ).text, ).toContain('Aktueller Stand: Support endet am 31.12.2026'); }); it('entfernt CR/LF aus dem Betreff und kuerzt auf 150 Zeichen (Header-Einschleusung)', () => { const { subject } = down({ customerName: `Kunde\r\nBcc: boese@example.invalid ${'x'.repeat(300)}`, }); expect(subject).not.toMatch(/[\r\n]/); expect(subject.length).toBe(150); expect(subject.startsWith('Nextcloud Kunde Bcc:')).toBe(true); }); it('kein Wort fuer Mandant/Tenant in Betreff oder Text', () => { const mails = [ down(), down({ rating: rating('maintenance') }), down({ rating: rating('eol-passed', { eolDate: '2026-09-30' }) }), buildNextcloudAlertMail( { kind: 'up', customerName: 'Kunde A', baseUrl: 'https://cloud.a.de', rating: rating('current'), errorKind: null, errorDetail: null, at: AT, }, APP, ), ]; for (const mail of mails) { expect(`${mail.subject}\n${mail.text}`).not.toMatch(/mandant|tenant/i); } }); });