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>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user