fix(quick-261005-d5d): Kalender-Test bricht bei unerreichbarem Exchange nach 15 s ab

EWS-Aufrufe ueber httpntlm bekommen eine eigene 15-s-Zeitgrenze;
httpreqs timeout greift waehrend des Verbindungsaufbaus nicht
(gemessen 134 s). Netzfehler liefern im Test den Schluessel
'unreachable', das Formular meldet 'nicht erreichbar' statt
'Zugangsdaten pruefen'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-05 09:29:25 +02:00
parent 94bd2a4a04
commit e49d4c7c9d
13 changed files with 305 additions and 13 deletions
+40 -1
View File
@@ -27,7 +27,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
}));
import { forTenant } from '../prisma/prisma-tenant.extension';
import { CalendarService } from './calendar.service';
import { CalendarService, CalendarSourceUnreachableError } from './calendar.service';
function _applySelect(row: any, select: Record<string, boolean> | undefined) {
if (!select) return { ...row };
@@ -533,6 +533,45 @@ describe('CalendarService — Bindung an forTenant() (260911-cwh)', () => {
expect(vi.mocked(prisma.calendarSource.create)).not.toHaveBeenCalled();
});
// ─── quick-261005: Kalender-Server nicht erreichbar ──────────────────────
it('testConnectionFromConfig: nicht erreichbarer Server liefert den Schluessel "unreachable"', async () => {
const prisma = makeFakePrisma();
const exchange = {
fetchEvents: vi.fn(async () => []),
testConnection: vi.fn(async () => {
throw new CalendarSourceUnreachableError();
}),
};
const { service } = makeCalendarService(prisma, { exchangeProvider: exchange });
const result = await service.testConnectionFromConfig({
type: 'exchange',
url: 'https://owa.example.invalid/EWS/Exchange.asmx',
exchangeMode: 'ews',
} as any);
expect(result).toEqual({ success: false, error: 'unreachable' });
});
it('testConnection (gespeicherte Quelle): nicht erreichbar -> "unreachable", lastSyncError ohne Details', async () => {
const prisma = makeFakePrisma();
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', type: 'exchange' });
const exchange = {
fetchEvents: vi.fn(async () => []),
testConnection: vi.fn(async () => {
throw new CalendarSourceUnreachableError();
}),
};
const { service } = makeCalendarService(prisma, { exchangeProvider: exchange });
const result = await service.testConnection('src-a1', 'user-a1', 't1');
expect(result).toEqual({ success: false, error: 'unreachable' });
const updateCall = vi.mocked(prisma.calendarSource.update).mock.calls.at(-1)?.[0] as any;
expect(updateCall.data).toEqual({ lastSyncError: 'Server not reachable' });
});
// ─── Wachhund ────────────────────────────────────────────────────────
it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf', async () => {
+46 -4
View File
@@ -30,6 +30,43 @@ export interface CalendarEvent {
color?: string;
}
/**
* quick-261005: Ein Provider wirft diesen Fehler aus `testConnection`, wenn
* der Kalender-Server gar nicht erreichbar ist (Zeitueberschreitung,
* abgewiesene Verbindung, Name unbekannt) — im Unterschied zu „erreichbar,
* aber Anmeldung abgelehnt“ (`false`). Traegt bewusst keine Details (T-05-13).
*/
export class CalendarSourceUnreachableError extends Error {
constructor() {
super('Calendar server not reachable');
this.name = 'CalendarSourceUnreachableError';
}
}
/** Fehlerkennungen von Node/httpreq, die „Server nicht erreichbar“ bedeuten. */
const NETWORK_UNREACHABLE_CODES = new Set([
'TIMEOUT', // httpreq bei Ablauf von `timeout`
'ETIMEDOUT',
'ECONNREFUSED',
'ECONNRESET',
'EHOSTUNREACH',
'ENETUNREACH',
'ENOTFOUND',
'EAI_AGAIN',
]);
export function isNetworkUnreachableError(error: unknown): boolean {
const code = (error as { code?: unknown; cause?: { code?: unknown } } | null)?.code;
const causeCode = (error as { cause?: { code?: unknown } } | null)?.cause?.code;
return (
(typeof code === 'string' && NETWORK_UNREACHABLE_CODES.has(code)) ||
(typeof causeCode === 'string' && NETWORK_UNREACHABLE_CODES.has(causeCode))
);
}
/** Stabiler Fehlerschluessel fuer die Oberflaeche (dort uebersetzt). */
export const CALENDAR_TEST_UNREACHABLE = 'unreachable';
/**
* Provider interface for calendar source integrations.
* Each provider (ICS, CalDAV, Exchange) implements this contract.
@@ -319,13 +356,15 @@ export class CalendarService {
});
return { success };
} catch {
const errorMsg = 'Connection failed'; // T-05-13: generic error, no credentials
} catch (error) {
const unreachable = error instanceof CalendarSourceUnreachableError;
// T-05-13: generic error, no credentials
const errorMsg = unreachable ? 'Server not reachable' : 'Connection failed';
await tenantPrisma.calendarSource.update({
where: { id },
data: { lastSyncError: errorMsg },
});
return { success: false, error: errorMsg };
return { success: false, error: unreachable ? CALENDAR_TEST_UNREACHABLE : errorMsg };
}
}
@@ -364,7 +403,10 @@ export class CalendarService {
try {
const success = await provider.testConnection(tempSource);
return { success };
} catch {
} catch (error) {
if (error instanceof CalendarSourceUnreachableError) {
return { success: false, error: CALENDAR_TEST_UNREACHABLE };
}
return { success: false, error: 'Connection failed' };
}
}
@@ -0,0 +1,110 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { CalendarSourceUnreachableError, isNetworkUnreachableError } from '../calendar.service';
import type { ExchangeProvider as ExchangeProviderType } from './exchange.provider';
/**
* ExchangeProvider.testConnection (quick-261005): Zeitgrenze fuer EWS und
* getrennte Meldung „nicht erreichbar“.
*
* httpntlm wird per CommonJS-`require` geladen — `vi.mock` greift dort nicht.
* Wie in `inbox/exchange-inbox.provider.spec.ts` wird deshalb Nodes
* `require.cache` vor dem ersten Laden des Providers mit einem Stub belegt.
*/
const httpntlmPath = require.resolve('httpntlm');
const httpntlmPost = vi.fn((_opts: any, cb: (err: Error | null, res: any) => void) => {
cb(new Error('httpntlmPost not configured for this test'), null);
});
require.cache[httpntlmPath] = {
id: httpntlmPath,
filename: httpntlmPath,
loaded: true,
exports: { post: httpntlmPost },
} as any;
let ExchangeProvider: typeof ExchangeProviderType;
beforeAll(async () => {
({ ExchangeProvider } = await import('./exchange.provider'));
});
beforeEach(() => {
httpntlmPost.mockReset();
});
const SOURCE = {
id: 'src-1',
url: 'https://owa.example.invalid/EWS/Exchange.asmx',
username: 'kalender',
password: 'geheim',
domain: 'CONTOSO',
exchangeMode: 'ews',
};
function failWith(code: string) {
httpntlmPost.mockImplementation((_opts, cb) => {
const err = Object.assign(new Error(`fail ${code}`), { code });
cb(err, null);
});
}
describe('ExchangeProvider.testConnection (EWS)', () => {
it('uebergibt httpntlm eine Zeitgrenze von 15 Sekunden', async () => {
httpntlmPost.mockImplementation((_opts, cb) => cb(null, { statusCode: 200, body: '' }));
await new ExchangeProvider().testConnection(SOURCE);
expect(httpntlmPost).toHaveBeenCalled();
expect(httpntlmPost.mock.calls[0][0].timeout).toBe(15_000);
});
it.each([
'TIMEOUT',
'ETIMEDOUT',
'ECONNREFUSED',
'ENOTFOUND',
'EHOSTUNREACH',
])('Netzfehler %s -> CalendarSourceUnreachableError', async (code) => {
failWith(code);
await expect(new ExchangeProvider().testConnection(SOURCE)).rejects.toBeInstanceOf(
CalendarSourceUnreachableError,
);
});
it('keine Rueckmeldung von httpntlm (Verbindungsaufbau haengt) -> nach 15 s nicht erreichbar', async () => {
vi.useFakeTimers();
try {
httpntlmPost.mockImplementation(() => {
/* ruft nie zurueck — wie ein unbeantworteter Verbindungsaufbau */
});
const pending = new ExchangeProvider().testConnection(SOURCE);
const assertion = expect(pending).rejects.toBeInstanceOf(CalendarSourceUnreachableError);
await vi.advanceTimersByTimeAsync(14_999);
await vi.advanceTimersByTimeAsync(1);
await assertion;
} finally {
vi.useRealTimers();
}
});
it('erreichbar, aber Anmeldung abgelehnt (401) -> false, kein Wurf', async () => {
httpntlmPost.mockImplementation((_opts, cb) => cb(null, { statusCode: 401, body: '' }));
await expect(new ExchangeProvider().testConnection(SOURCE)).resolves.toBe(false);
});
it('sonstiger Fehler ohne Netzkennung -> false', async () => {
httpntlmPost.mockImplementation((_opts, cb) => cb(new Error('kaputt'), null));
await expect(new ExchangeProvider().testConnection(SOURCE)).resolves.toBe(false);
});
});
describe('isNetworkUnreachableError', () => {
it('erkennt Kennung direkt und in cause, sonst nicht', () => {
expect(isNetworkUnreachableError({ code: 'ETIMEDOUT' })).toBe(true);
expect(isNetworkUnreachableError({ cause: { code: 'ECONNREFUSED' } })).toBe(true);
expect(isNetworkUnreachableError(new Error('x'))).toBe(false);
expect(isNetworkUnreachableError(null)).toBe(false);
});
});
@@ -1,6 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import type { AuthProviderCallback } from '@microsoft/microsoft-graph-client';
import { CalendarEvent, CalendarProvider } from '../calendar.service';
import {
CalendarEvent,
CalendarProvider,
CalendarSourceUnreachableError,
isNetworkUnreachableError,
} from '../calendar.service';
/** Optionen, die ntlmPost() unten uebergibt — nichts darueber hinaus. */
interface NtlmOptions {
@@ -11,6 +16,8 @@ interface NtlmOptions {
workstation: string;
body: string;
headers: Record<string, string>;
/** Millisekunden bis zum Abbruch mit `code: 'TIMEOUT'` (siehe ntlmPost). */
timeout: number;
}
/**
@@ -31,6 +38,13 @@ const httpntlm = require('httpntlm') as {
post: (opts: NtlmOptions, cb: (err: Error | null, res: NtlmResponse) => void) => void;
};
/**
* quick-261005: Ohne Grenze wartete ein EWS-Aufruf auf eine nicht
* erreichbare Adresse rund zwei Minuten (TCP-Verbindungsaufbau des
* Betriebssystems), der Test-Knopf hing so lange auf „wird geprueft“.
*/
const EWS_TIMEOUT_MS = 15_000;
const NS_SOAP = 'http://schemas.xmlsoap.org/soap/envelope/';
const NS_TYPES = 'http://schemas.microsoft.com/exchange/services/2006/types';
const NS_MESSAGES = 'http://schemas.microsoft.com/exchange/services/2006/messages';
@@ -74,7 +88,15 @@ function extractAttr(xml: string, tag: string, attr: string): string {
function ntlmPost(opts: NtlmOptions): Promise<{ statusCode: number; body: string }> {
return new Promise((resolve, reject) => {
// Eigene Zeitgrenze zusaetzlich zu `opts.timeout`: httpreq setzt seine
// nur als Leerlaufgrenze am Socket, die waehrend des Verbindungsaufbaus
// ueber den Keep-alive-Agenten von httpntlm NICHT greift — gemessen
// 05.10.: 134 s bis zum Fehler trotz `timeout: 15000`.
const timer = setTimeout(() => {
reject(Object.assign(new Error('EWS request timed out'), { code: 'TIMEOUT' }));
}, opts.timeout);
httpntlm.post(opts, (err, res) => {
clearTimeout(timer);
if (err) return reject(err);
resolve({
statusCode: res.statusCode,
@@ -152,7 +174,10 @@ export class ExchangeProvider implements CalendarProvider {
} else {
return await this.testEwsConnection(source);
}
} catch {
} catch (error) {
// quick-261005: „nicht erreichbar“ getrennt melden, damit die
// Oberflaeche nicht „Zugangsdaten pruefen“ sagt, wenn das Netz fehlt.
if (isNetworkUnreachableError(error)) throw new CalendarSourceUnreachableError();
return false;
}
}
@@ -316,6 +341,7 @@ export class ExchangeProvider implements CalendarProvider {
domain: source.domain ?? '',
workstation: '',
body: soap,
timeout: EWS_TIMEOUT_MS,
headers: {
'Content-Type': 'text/xml; charset=utf-8',
'SOAPAction': `"http://schemas.microsoft.com/exchange/services/2006/messages/${action}"`,