Files
tessera-ctl/apps/api/src/nextcloud-status/nextcloud-alert-mail.spec.ts
T
schalli faed0d760f feat(nextcloud-status): Benachrichtigung abonnieren und Mail bei Störung
- Glocke je Kachel (persönlich, Benutzen-Ebene), Tabelle NextcloudAlertSubscription mit RLS
- Zwei-Fehlschläge-Regel und gemeldeter Zustand auf der Zeile, Anspruch vor dem Mailversand
- Mail (nur Deutsch) über MailService, bis zu drei Versuche, Zugriff beim Senden erneut geprüft
- Fehlercodes in der Mail als lesbarer Hinweis

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 15:23:34 +02:00

174 lines
6.1 KiB
TypeScript

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> = {},
): 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<Parameters<typeof buildNextcloudAlertMail>[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);
}
});
});