From 7691d1fd6df6e40cd9980bdae64be68691eb8575 Mon Sep 17 00:00:00 2001 From: Schalli Date: Mon, 21 Sep 2026 18:00:53 +0200 Subject: [PATCH] test(quick-260921-oxm): rote Tests fuer die beiden IMAP-Befunde - B-06: prueft die an ImapFlow uebergebenen Optionen fuer starttls und ssl-tls - B-05: prueft, dass ein octet-stream-Anhang am Dateinamen aus Content-Disposition erkannt wird - Einhaengen des Testdoppels in einen Helfer gezogen; die Umdeutung des Konstruktors steht damit nur noch an einer Stelle statt an zwoelf Gegen den heutigen Stand rot: 3 von 12 Faellen scheitern (doSTARTTLS undefined, Feld requireTLS vorhanden, Anhang nicht eingesammelt). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J --- apps/api/src/inbox/imap.provider.spec.ts | 154 +++++++++++++++++++++-- 1 file changed, 147 insertions(+), 7 deletions(-) diff --git a/apps/api/src/inbox/imap.provider.spec.ts b/apps/api/src/inbox/imap.provider.spec.ts index e9246d1..750d70c 100644 --- a/apps/api/src/inbox/imap.provider.spec.ts +++ b/apps/api/src/inbox/imap.provider.spec.ts @@ -72,6 +72,17 @@ function makeMockClient(overrides: Partial> = {}) { }; } +/** + * Haengt ein Testdoppel als ImapFlow-Klient ein. + * + * Der Modul-Mock oben ersetzt den Konstruktor durch `vi.fn()`; diese Funktion + * ist die einzige Stelle im Test, die das ausnutzt. Vorher stand dieselbe + * Umdeutung in jedem einzelnen Fall. + */ +function useMockClient(client: ReturnType): void { + (ImapFlow as unknown as ReturnType).mockImplementation(() => client); +} + beforeEach(() => { vi.clearAllMocks(); }); @@ -79,7 +90,7 @@ beforeEach(() => { describe('ImapProvider.fetchMessages', () => { it('returns one InboxMessage with bodyHtml from the html part and bodyText from the plain part', async () => { const client = makeMockClient(); - (ImapFlow as unknown as ReturnType).mockImplementation(() => client); + useMockClient(client); const provider = new ImapProvider(); const messages = await provider.fetchMessages(BASE_CONFIG); @@ -97,7 +108,7 @@ describe('ImapProvider.fetchMessages', () => { it('marks each processed message \\Seen (idempotency for re-polls)', async () => { const client = makeMockClient(); - (ImapFlow as unknown as ReturnType).mockImplementation(() => client); + useMockClient(client); const provider = new ImapProvider(); await provider.fetchMessages(BASE_CONFIG); @@ -107,7 +118,7 @@ describe('ImapProvider.fetchMessages', () => { it('honors the same UNSEEN + optional senderFilter search as fetchPdfAttachments', async () => { const client = makeMockClient(); - (ImapFlow as unknown as ReturnType).mockImplementation(() => client); + useMockClient(client); const provider = new ImapProvider(); await provider.fetchMessages({ ...BASE_CONFIG, senderFilter: 'vergabeportal.de' }); @@ -122,7 +133,7 @@ describe('ImapProvider.fetchMessages', () => { const client = makeMockClient({ connect: vi.fn().mockRejectedValue(new Error('ECONNREFUSED')), }); - (ImapFlow as unknown as ReturnType).mockImplementation(() => client); + useMockClient(client); const provider = new ImapProvider(); await expect(provider.fetchMessages(BASE_CONFIG)).resolves.toEqual([]); @@ -132,7 +143,7 @@ describe('ImapProvider.fetchMessages', () => { const client = makeMockClient({ search: vi.fn().mockRejectedValue(new Error('search boom')), }); - (ImapFlow as unknown as ReturnType).mockImplementation(() => client); + useMockClient(client); const provider = new ImapProvider(); await expect(provider.fetchMessages(BASE_CONFIG)).resolves.toEqual([]); @@ -141,7 +152,7 @@ describe('ImapProvider.fetchMessages', () => { it('returns [] when there are no unread messages', async () => { const client = makeMockClient({ search: vi.fn().mockResolvedValue([]) }); - (ImapFlow as unknown as ReturnType).mockImplementation(() => client); + useMockClient(client); const provider = new ImapProvider(); const messages = await provider.fetchMessages(BASE_CONFIG); @@ -164,7 +175,7 @@ describe('ImapProvider.fetchMessages', () => { }, ]), }); - (ImapFlow as unknown as ReturnType).mockImplementation(() => client); + useMockClient(client); const provider = new ImapProvider(); const messages = await provider.fetchMessages(BASE_CONFIG); @@ -174,3 +185,132 @@ describe('ImapProvider.fetchMessages', () => { expect(messages[0]!.bodyText).toBe(''); }); }); + +/** + * Befund B-06 (gemeldet in 260921-m34): buildClient() uebergab `requireTLS`, + * eine Option, die imapflow 1.4.3 gar nicht kennt — weder in `ImapFlowOptions` + * (lib/imap-flow.d.ts) noch im Laufzeitcode (lib/imap-flow.js). Sie wurde still + * verworfen, ein STARTTLS-Zwang entstand durch sie nie. Die richtige Option + * heisst `doSTARTTLS` (imap-flow.d.ts:81). + * + * Geprueft wird hier ausschliesslich, was an `new ImapFlow(...)` uebergeben + * wird — keine echte Verbindung. + */ +describe('ImapProvider — Transportverschluesselung (B-06)', () => { + /** Optionen des zuletzt erzeugten ImapFlow-Klienten. */ + function lastClientOptions() { + const calls = vi.mocked(ImapFlow).mock.calls; + return calls[calls.length - 1]?.[0]; + } + + it('erzwingt STARTTLS, wenn die Verschluesselung auf starttls steht', async () => { + const client = makeMockClient(); + useMockClient(client); + const provider = new ImapProvider(); + + await provider.testConnection({ ...BASE_CONFIG, port: 143, encryption: 'starttls' }); + + const options = lastClientOptions(); + expect(options?.secure).toBe(false); + expect(options?.doSTARTTLS).toBe(true); + expect(options).not.toHaveProperty('requireTLS'); + }); + + it('setzt doSTARTTLS nicht auf true, wenn die Verschluesselung auf ssl-tls steht', async () => { + const client = makeMockClient(); + useMockClient(client); + const provider = new ImapProvider(); + + await provider.testConnection({ ...BASE_CONFIG, encryption: 'ssl-tls' }); + + const options = lastClientOptions(); + // imapflow wirft bei secure=true zusammen mit doSTARTTLS=true + // ("Misconfiguration", imap-flow.js:1201) — diese Kombination darf nie entstehen. + expect(options?.secure).toBe(true); + expect(options?.doSTARTTLS).not.toBe(true); + expect(options).not.toHaveProperty('requireTLS'); + }); +}); + +/** + * Befund B-05 (gemeldet in 260921-m34): der Dateiname aus Content-Disposition + * wurde als `disposition.parameters.filename` gelesen. imapflow deklariert + * `disposition` aber als Zeichenkette (imap-flow.d.ts:448) und legt die + * Parameter in ein eigenes Feld `dispositionParameters` (:450, gefuellt in + * tools.js:887 mit kleingeschriebenen Schluesseln). Der alte Ausdruck war zur + * Laufzeit immer undefined — Anhaenge, die als application/octet-stream + * ankommen (typisch fuer Outlook), wurden darueber nie erkannt. + */ +describe('ImapProvider.fetchPdfAttachments — Dateiname aus Content-Disposition (B-05)', () => { + function makeAttachmentClient(bodyStructure: unknown) { + return makeMockClient({ + search: vi.fn().mockResolvedValue([7]), + fetchAll: vi.fn().mockResolvedValue([ + { + uid: 7, + envelope: { + messageId: '', + subject: 'Rechnung', + from: [{ address: 'rechnung@dkv.de' }], + date: new Date('2026-07-20T08:00:00Z'), + }, + bodyStructure, + }, + ]), + download: vi.fn(async () => ({ content: makeReadable('%PDF-1.4 inhalt') })), + }); + } + + it('erkennt einen application/octet-stream-Anhang am Dateinamen aus dispositionParameters', async () => { + const client = makeAttachmentClient({ + type: 'multipart/mixed', + childNodes: [ + { type: 'text/plain', part: '1' }, + { + type: 'application/octet-stream', + part: '2', + disposition: 'attachment', + dispositionParameters: { filename: 'Rechnung-4711.PDF' }, + }, + ], + }); + useMockClient(client); + const provider = new ImapProvider(); + + const emails = await provider.fetchPdfAttachments(BASE_CONFIG); + + expect(emails).toHaveLength(1); + expect(emails[0]?.attachments ?? []).toHaveLength(1); + expect(emails[0]?.attachments?.[0]?.contentType).toBe('application/pdf'); + }); + + it('erkennt einen application/octet-stream-Anhang weiterhin am Namen aus Content-Type', async () => { + const client = makeAttachmentClient({ + type: 'application/octet-stream', + part: '1', + parameters: { name: 'Rechnung-4711.pdf' }, + }); + useMockClient(client); + const provider = new ImapProvider(); + + const emails = await provider.fetchPdfAttachments(BASE_CONFIG); + + expect(emails).toHaveLength(1); + expect(emails[0]?.attachments ?? []).toHaveLength(1); + }); + + it('sammelt einen application/octet-stream-Anhang ohne .pdf-Dateinamen nicht ein', async () => { + const client = makeAttachmentClient({ + type: 'application/octet-stream', + part: '1', + disposition: 'attachment', + dispositionParameters: { filename: 'Lieferschein.docx' }, + }); + useMockClient(client); + const provider = new ImapProvider(); + + const emails = await provider.fetchPdfAttachments(BASE_CONFIG); + + expect(emails).toEqual([]); + }); +});