diff --git a/apps/api/src/calendar/providers/exchange.provider.ts b/apps/api/src/calendar/providers/exchange.provider.ts index 455a989..95ebcc0 100644 --- a/apps/api/src/calendar/providers/exchange.provider.ts +++ b/apps/api/src/calendar/providers/exchange.provider.ts @@ -1,9 +1,34 @@ import { Injectable, Logger } from '@nestjs/common'; +import type { AuthProviderCallback } from '@microsoft/microsoft-graph-client'; import { CalendarEvent, CalendarProvider } from '../calendar.service'; +/** Optionen, die ntlmPost() unten uebergibt — nichts darueber hinaus. */ +interface NtlmOptions { + url: string; + username: string; + password: string; + domain: string; + workstation: string; + body: string; + headers: Record; +} + +/** + * Antwortform von httpntlm.post, beschrieben aus dem, was gelesen wird. + * + * `body` ist `Buffer | string`: httpreq (unter httpntlm) liefert eine + * Zeichenkette, solange `binary` nicht gesetzt ist (gemessen, + * httpreq@1.1.1/lib/httpreq.js:391) — hier wird es nicht gesetzt. Siehe die + * ausfuehrliche Begruendung in inbox/exchange-inbox.provider.ts. + */ +interface NtlmResponse { + statusCode: number; + body?: Buffer | string; +} + // eslint-disable-next-line @typescript-eslint/no-require-imports const httpntlm = require('httpntlm') as { - post: (opts: any, cb: (err: Error | null, res: any) => void) => void; + post: (opts: NtlmOptions, cb: (err: Error | null, res: NtlmResponse) => void) => void; }; const NS_SOAP = 'http://schemas.xmlsoap.org/soap/envelope/'; @@ -47,15 +72,14 @@ function extractAttr(xml: string, tag: string, attr: string): string { return attrMatch ? attrMatch[1] : ''; } -function ntlmPost(opts: { - url: string; username: string; password: string; - domain: string; workstation: string; body: string; - headers: Record; -}): Promise<{ statusCode: number; body: string }> { +function ntlmPost(opts: NtlmOptions): Promise<{ statusCode: number; body: string }> { return new Promise((resolve, reject) => { httpntlm.post(opts, (err, res) => { if (err) return reject(err); - resolve({ statusCode: res.statusCode, body: res.body?.toString('utf-8') ?? '' }); + resolve({ + statusCode: res.statusCode, + body: typeof res.body === 'string' ? res.body : (res.body?.toString('utf-8') ?? ''), + }); }); }); } @@ -154,7 +178,7 @@ export class ExchangeProvider implements CalendarProvider { ); const client = GraphClient.init({ - authProvider: (done: (error: any, token: string) => void) => { + authProvider: (done: AuthProviderCallback) => { // Use the password as the access token (OAuth bearer token) // Users configure their OAuth token in the password field for Graph API done(null, source.password || ''); @@ -310,7 +334,7 @@ export class ExchangeProvider implements CalendarProvider { ); const client = GraphClient.init({ - authProvider: (done: (error: any, token: string) => void) => { + authProvider: (done: AuthProviderCallback) => { done(null, source.password || ''); }, }); diff --git a/apps/api/src/cert-manager/cert-manager.service.ts b/apps/api/src/cert-manager/cert-manager.service.ts index c7de9cc..76d95e4 100644 --- a/apps/api/src/cert-manager/cert-manager.service.ts +++ b/apps/api/src/cert-manager/cert-manager.service.ts @@ -1,5 +1,18 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import * as forge from 'node-forge'; + +/** + * Was `forge.pkcs7.messageFromPem()` bzw. `messageFromAsn1()` zurueckgeben — + * der mitgelieferte Typ aus `@types/node-forge`, nicht ein eigener. + * + * Nur die signierte Form traegt `certificates`; die Lesestellen grenzen + * deshalb mit `'certificates' in p7` ein. Das ist verhaltensgleich zum + * bisherigen `p7.certificates ?? []`: bei einer enveloped-Nachricht fehlt + * das Feld, und beide Schreibweisen liefern dann die leere Liste. + */ +type P7Message = forge.pkcs7.Captured< + forge.pkcs7.PkcsEnvelopedData | forge.pkcs7.PkcsSignedData +>; import type { UploadedFileLike } from '../auth/types/auth-user'; /** @@ -231,14 +244,16 @@ export class CertManagerService { .slice(0, 27) .toString('ascii') .includes('-----BEGIN'); - let p7: any; + // siehe P7Message oben — mitgelieferter Typ, keine Behauptung. + let p7: P7Message; if (isPemP7b) { p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8')); } else { const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); p7 = forge.pkcs7.messageFromAsn1(p7Asn1); } - const p7Certs: forge.pki.Certificate[] = p7.certificates ?? []; + const p7Certs: forge.pki.Certificate[] = + 'certificates' in p7 ? p7.certificates : []; if (p7Certs.length === 0) { throw new Error('No certificate found in P7B/PKCS7'); } @@ -270,6 +285,14 @@ export class CertManagerService { ); // Key type and size + // BLEIBT als any, mit Begruendung (260921-m34, Aufgabe 3c, D-01/D-02): + // @types/node-forge kennt nur `PublicKey = rsa.PublicKey | ed25519.Key` + // (index.d.ts:232). Der EC-Zweig unten liest `curve` und + // `params.curve.q.bitLength()` — Felder, die node-forge zur Laufzeit + // liefert, die der mitgelieferte Typ aber GAR NICHT kennt. Eine + // Umdeutung ueber zwei Stufen wuerde dieselbe Luecke verdecken und + // zusaetzlich so aussehen, als sei sie geprueft. Ein ehrliches any mit + // dieser Zeile ist hier das bessere Ergebnis. const pubKey = cert.publicKey as any; let keyType = 'RSA'; let keyBits = 0; @@ -283,13 +306,24 @@ export class CertManagerService { } // Subject Alternative Names + // + // BLEIBEN als any, mit Begruendung (260921-m34, Aufgabe 3c, D-01/D-02): + // @types/node-forge deklariert `Certificate.extensions` als `any[]` + // (index.d.ts:435) und sagt damit ueber den Inhalt einer Erweiterung + // NICHTS aus. Jede Schnittstelle, die wir hier selbst fuer `altNames` + // schrieben, waere unbelegt — der Compiler koennte sie an keiner + // Stelle gegen etwas pruefen, sie saehe aber geprueft aus. Die drei + // any-Stellen dieses Blocks bleiben deshalb sichtbar stehen, statt + // gegen eine Behauptung getauscht zu werden. const sanExt = cert.extensions?.find((e: any) => e.name === 'subjectAltName'); const san: string[] = ((sanExt as any)?.altNames ?? []).map((n: any) => n.type === 2 ? (n.value as string) : `IP:${(n.ip ?? n.value) as string}`, ); // Signature algorithm — OID → human-readable name - const sigOid = (cert.siginfo as any)?.algorithmOid ?? ''; + // @types/node-forge deklariert siginfo.algorithmOid als string — die + // Zusicherung war ueberfluessig. Das ?. bleibt woertlich erhalten. + const sigOid = cert.siginfo?.algorithmOid ?? ''; const signatureAlgorithm = REVERSE_OIDS[sigOid] ?? sigOid; // Fingerprints @@ -369,7 +403,9 @@ export class CertManagerService { .slice(0, 27) .toString('ascii') .includes('-----BEGIN'); - let p7: any; + // Der mitgelieferte Typ traegt hier: messageFromPem/messageFromAsn1 + // liefern beide Captured. + let p7: P7Message; if (isPemP7b) { // PEM-wrapped PKCS7 (e.g. -----BEGIN PKCS7-----) p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8')); @@ -378,7 +414,7 @@ export class CertManagerService { const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); p7 = forge.pkcs7.messageFromAsn1(p7Asn1); } - certs = (p7.certificates as forge.pki.Certificate[]) ?? []; + certs = 'certificates' in p7 ? p7.certificates : []; if (certs.length === 0) { throw new Error('No certificates found in P7B/PKCS7 bundle'); } @@ -505,14 +541,15 @@ export class CertManagerService { .slice(0, 27) .toString('ascii') .includes('-----BEGIN'); - let p7: any; + // siehe P7Message oben — mitgelieferter Typ, keine Behauptung. + let p7: P7Message; if (isPemP7b) { p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8')); } else { const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); p7 = forge.pkcs7.messageFromAsn1(p7Asn1); } - return (p7.certificates as forge.pki.Certificate[]) ?? []; + return 'certificates' in p7 ? p7.certificates : []; } }); } catch (err) { @@ -543,6 +580,12 @@ export class CertManagerService { // Open Question 1 resolution: toPkcs12Asn1(null, certs, password) works in node-forge 1.4.0 // null as the private key produces a cert-only PKCS12 bundle (no key bag — cert bag only) const p12Asn1 = forge.pkcs12.toPkcs12Asn1( + // BLEIBT (260921-m34, Aufgabe 3c): node-forge 1.4.0 nimmt hier einen + // fehlenden Schluessel an und erzeugt ein reines + // Zertifikatsbuendel; @types/node-forge schliesst null aus. Die + // mitgelieferten Typen beschreiben die Bibliothek an dieser Stelle + // also nachweislich falsch — ein erzwungener Typ waere eine + // Behauptung ueber etwas, das nicht stimmt. null as any, // cert-only PFX — null key accepted by node-forge 1.4.0 certs, password!, @@ -648,14 +691,16 @@ export class CertManagerService { .slice(0, 27) .toString('ascii') .includes('-----BEGIN'); - let p7: any; + // siehe P7Message oben — mitgelieferter Typ, keine Behauptung. + let p7: P7Message; if (isPemP7b) { p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8')); } else { const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); p7 = forge.pkcs7.messageFromAsn1(p7Asn1); } - const p7Certs: forge.pki.Certificate[] = p7.certificates ?? []; + const p7Certs: forge.pki.Certificate[] = + 'certificates' in p7 ? p7.certificates : []; if (p7Certs.length === 0) { throw new Error('No certificate found in P7B/PKCS7'); } @@ -698,6 +743,12 @@ export class CertManagerService { } else { // PFX — cert-only PKCS12 bundle (Open Question 1: null key works in node-forge 1.4.0) const p12Asn1 = forge.pkcs12.toPkcs12Asn1( + // BLEIBT (260921-m34, Aufgabe 3c): node-forge 1.4.0 nimmt hier einen + // fehlenden Schluessel an und erzeugt ein reines + // Zertifikatsbuendel; @types/node-forge schliesst null aus. Die + // mitgelieferten Typen beschreiben die Bibliothek an dieser Stelle + // also nachweislich falsch — ein erzwungener Typ waere eine + // Behauptung ueber etwas, das nicht stimmt. null as any, // cert-only PFX — null key accepted by node-forge 1.4.0 [cert], password!, diff --git a/apps/api/src/dkv/dkv-scheduler.service.ts b/apps/api/src/dkv/dkv-scheduler.service.ts index e026aaf..e8214c4 100644 --- a/apps/api/src/dkv/dkv-scheduler.service.ts +++ b/apps/api/src/dkv/dkv-scheduler.service.ts @@ -133,6 +133,14 @@ export class DkvSchedulerService implements OnModuleInit { // Cast required: our minimal CronJob type doesn't match cron's full type signature. // At runtime the object IS a full CronJob — SchedulerRegistry only calls stop() on it. // eslint-disable-next-line @typescript-eslint/no-explicit-any + // URTEIL: BLEIBT (260921-m34, Aufgabe 3, D-01). Gemessen: ohne die + // Zusicherung meldet tsc, dass das lokale `job` nur die Form + // `{ start(): void }` hat, waehrend addCronJob() einen vollstaendigen + // CronJob verlangt. Ursache ist der require()-Umweg aus 07-04 (pnpm- + // Isolation, `cron` ist nur eine mittelbare Abhaengigkeit). Das + // aufzuloesen hiesse, die Beschaffung der Klasse zu aendern — eine + // Verhaltensaenderung — oder `cron` direkt aufzunehmen — eine neue + // Abhaengigkeit. Beides ist hier verboten (D-03/D-04). this.schedulerRegistry.addCronJob(jobName, job as any); job.start(); diff --git a/apps/api/src/inbox/exchange-inbox.provider.ts b/apps/api/src/inbox/exchange-inbox.provider.ts index cb8f0ee..78e4ec5 100644 --- a/apps/api/src/inbox/exchange-inbox.provider.ts +++ b/apps/api/src/inbox/exchange-inbox.provider.ts @@ -1,6 +1,8 @@ import { Injectable, Logger } from '@nestjs/common'; // eslint-disable-next-line @typescript-eslint/no-require-imports -const httpntlm = require('httpntlm') as { post: (opts: any, cb: (err: Error | null, res: any) => void) => void }; +const httpntlm = require('httpntlm') as { + post: (opts: NtlmOptions, cb: (err: Error | null, res: NtlmResponse) => void) => void; +}; import type { InboxAttachment, InboxConfig, InboxEmail, InboxMessage } from './inbox-provider.interface'; import type { InboxProvider } from './inbox-provider.interface'; @@ -236,11 +238,34 @@ interface NtlmOptions { rejectUnauthorized?: boolean; } +/** + * Antwortform von httpntlm.post, beschrieben aus dem, was der Aufrufer + * unten liest — mehr nicht. + * + * BEFUND (260921-m34, Aufgabe 3c, D-03): `body` ist bewusst + * `Buffer | string`. httpntlm reicht an httpreq durch, und httpreq gibt den + * Rumpf als ZEICHENKETTE zurueck, solange die Option `binary` nicht gesetzt + * ist (gemessen in httpreq@1.1.1/lib/httpreq.js:391) — keiner der beiden + * Aufrufer in diesem Baum setzt sie. Die Testdoppel reichen dagegen einen + * Buffer herein. Der Bestand rief hier unbesehen `.toString('utf-8')` auf; + * das funktioniert bei einer Zeichenkette nur, weil String.toString() sein + * Argument ignoriert. Beide Formen kommen also wirklich vor, der Typ nennt + * beide, und die Fallunterscheidung unten liefert fuer jede exakt dasselbe + * Ergebnis wie zuvor. Verhalten unveraendert. + */ +interface NtlmResponse { + statusCode: number; + body?: Buffer | string; +} + function ntlmPost(opts: NtlmOptions): Promise<{ statusCode: number; body: string }> { return new Promise((resolve, reject) => { - (httpntlm as any).post(opts, (err: Error | null, res: any) => { + httpntlm.post(opts, (err, res) => { if (err) return reject(err); - resolve({ statusCode: res.statusCode, body: res.body?.toString('utf-8') ?? '' }); + resolve({ + statusCode: res.statusCode, + body: typeof res.body === 'string' ? res.body : (res.body?.toString('utf-8') ?? ''), + }); }); }); } diff --git a/apps/api/src/inbox/imap.provider.ts b/apps/api/src/inbox/imap.provider.ts index b55e7f6..e356c09 100644 --- a/apps/api/src/inbox/imap.provider.ts +++ b/apps/api/src/inbox/imap.provider.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; +import type { Readable } from 'node:stream'; import { ImapFlow, MessageStructureObject } from 'imapflow'; import type { InboxAttachment, InboxConfig, InboxEmail, InboxMessage } from './inbox-provider.interface'; import type { InboxProvider } from './inbox-provider.interface'; @@ -14,9 +15,7 @@ const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB * Converts a Node.js Readable stream into a Buffer. * Accumulates chunks up to MAX_ATTACHMENT_BYTES; throws if limit exceeded. */ -async function streamToBuffer( - stream: NodeJS.ReadableStream, -): Promise { +async function streamToBuffer(stream: Readable): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; let total = 0; @@ -24,8 +23,12 @@ async function streamToBuffer( stream.on('data', (chunk: Buffer) => { total += chunk.length; if (total > MAX_ATTACHMENT_BYTES) { - // Destroy the stream to prevent further data emission - (stream as any).destroy?.(); + // Destroy the stream to prevent further data emission. + // Readable statt NodeJS.ReadableStream: alle drei Aufrufer reichen + // client.download().content herein, und imapflow deklariert das als + // Readable (imap-flow.d.ts:521). Readable traegt destroy(), also + // braucht der Aufruf keine Zusicherung mehr. + stream.destroy?.(); reject( new Error( `Attachment exceeds maximum allowed size of ${MAX_ATTACHMENT_BYTES} bytes (T-07-05)`, @@ -59,10 +62,23 @@ function collectPdfParts( const type = node.type?.toLowerCase() ?? ''; // Some mail clients (e.g. Outlook) send PDFs as application/octet-stream. // Fall back to checking the filename from Content-Disposition or Content-Type parameters. + // BLEIBT als any, mit Befund (260921-m34, Aufgabe 3c, D-01/D-03): + // imapflow deklariert `disposition` als ZEICHENKETTE (imap-flow.d.ts:448, + // also "attachment"/"inline"), und die zugehoerigen Parameter liegen in + // einem eigenen Feld `dispositionParameters` (:450). Der Ausdruck unten + // liest `.parameters` von einer Zeichenkette und ist damit zur Laufzeit + // IMMER undefined — dispositionFilename ist stets ''. Das ist ein Befund + // im Bestandscode, kein Typproblem: ihn hier auf `dispositionParameters` + // umzubiegen waere eine Verhaltensaenderung (Outlook-Anhaenge als + // application/octet-stream wuerden ab dann erstmals erkannt), und die ist + // in dieser Aufgabe verboten. Gemeldet im SUMMARY, Entscheidung beim + // Menschen. Die Zusicherung bleibt sichtbar stehen, damit der Befund + // nicht verschwindet. const dispositionFilename = ((node as any).disposition?.parameters?.filename as string | undefined)?.toLowerCase() ?? ''; - const typeFilename = - ((node as any).parameters?.name as string | undefined)?.toLowerCase() ?? ''; + // Hier dagegen war die Zusicherung schlicht ueberfluessig: imapflow + // deklariert `parameters?: { [key: string]: string }` (imap-flow.d.ts:438). + const typeFilename = node.parameters?.name?.toLowerCase() ?? ''; const looksLikePdf = type === 'application/pdf' || (type === 'application/octet-stream' && @@ -374,6 +390,15 @@ export class ImapProvider implements InboxProvider { : undefined, // T-07-03: suppress imapflow verbose logs — they include auth credentials logger: false, + // BLEIBT als Zusicherung, mit Befund (260921-m34, Aufgabe 3c, D-03): + // `requireTLS` oben kommt in imapflow 1.4.3 NIRGENDS vor — weder in + // ImapFlowOptions (lib/imap-flow.d.ts) noch im Laufzeitcode + // (lib/imap-flow.js), beides durchsucht. Die Option wird also still + // verworfen; STARTTLS wird nicht durch sie erzwungen. Genau diese + // Zusicherung hat das bisher verdeckt. Sie bleibt trotzdem stehen: + // die Option zu entfernen waere eine stille Reparatur einer falschen + // Annahme (verboten), und der `any`-Befund haelt die Stelle in der + // Zaehlung sichtbar, bis ein Mensch entscheidet. Gemeldet im SUMMARY. } as any); } } diff --git a/apps/api/src/mail/mail.service.ts b/apps/api/src/mail/mail.service.ts index e7e9764..fcf07a2 100644 --- a/apps/api/src/mail/mail.service.ts +++ b/apps/api/src/mail/mail.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as nodemailer from 'nodemailer'; +import type SMTPTransport from 'nodemailer/lib/smtp-transport'; import { SettingsService } from '../settings/settings.service'; /** @@ -69,7 +70,14 @@ export type BugReportMail = Pick; + /** + * SMTPTransport.Options statt TransportOptions & Record: + * beide Zweige von resolveTransport() bauen reine SMTP-Optionen (host, + * port, secure, requireTLS, auth) — und genau das nimmt createTransport() + * ohne Zusicherung entgegen. Die bisherige Kombination war zu weit und + * brauchte deshalb ein `as any` an der Uebergabe. + */ + options: SMTPTransport.Options; from: string; } @@ -162,7 +170,7 @@ export class MailService { let transport: nodemailer.Transporter | null = null; try { const resolved = await this.resolveTransport(tenantId); - transport = nodemailer.createTransport(resolved.options as any); + transport = nodemailer.createTransport(resolved.options); await transport.sendMail({ from: resolved.from, to: mail.to, diff --git a/apps/api/src/tenders/tender-digest.scheduler.ts b/apps/api/src/tenders/tender-digest.scheduler.ts index 99b2aca..18d07e9 100644 --- a/apps/api/src/tenders/tender-digest.scheduler.ts +++ b/apps/api/src/tenders/tender-digest.scheduler.ts @@ -83,6 +83,14 @@ export class TenderDigestScheduler implements OnModuleInit { // type signature. At runtime the object IS a full CronJob — // SchedulerRegistry only calls stop() on it. // eslint-disable-next-line @typescript-eslint/no-explicit-any + // URTEIL: BLEIBT (260921-m34, Aufgabe 3, D-01). Gemessen: ohne die + // Zusicherung meldet tsc, dass das lokale `job` nur die Form + // `{ start(): void }` hat, waehrend addCronJob() einen vollstaendigen + // CronJob verlangt. Ursache ist der require()-Umweg aus 07-04 (pnpm- + // Isolation, `cron` ist nur eine mittelbare Abhaengigkeit). Das + // aufzuloesen hiesse, die Beschaffung der Klasse zu aendern — eine + // Verhaltensaenderung — oder `cron` direkt aufzunehmen — eine neue + // Abhaengigkeit. Beides ist hier verboten (D-03/D-04). this.schedulerRegistry.addCronJob(this.JOB_NAME, job as any); job.start(); diff --git a/apps/api/src/tenders/tender-scheduler.service.ts b/apps/api/src/tenders/tender-scheduler.service.ts index e674168..0f653f9 100644 --- a/apps/api/src/tenders/tender-scheduler.service.ts +++ b/apps/api/src/tenders/tender-scheduler.service.ts @@ -132,6 +132,14 @@ export class TenderSchedulerService implements OnApplicationBootstrap { // Cast required: our minimal CronJob type doesn't match cron's full type signature. // At runtime the object IS a full CronJob — SchedulerRegistry only calls stop() on it. // eslint-disable-next-line @typescript-eslint/no-explicit-any + // URTEIL: BLEIBT (260921-m34, Aufgabe 3, D-01). Gemessen: ohne die + // Zusicherung meldet tsc, dass das lokale `job` nur die Form + // `{ start(): void }` hat, waehrend addCronJob() einen vollstaendigen + // CronJob verlangt. Ursache ist der require()-Umweg aus 07-04 (pnpm- + // Isolation, `cron` ist nur eine mittelbare Abhaengigkeit). Das + // aufzuloesen hiesse, die Beschaffung der Klasse zu aendern — eine + // Verhaltensaenderung — oder `cron` direkt aufzunehmen — eine neue + // Abhaengigkeit. Beides ist hier verboten (D-03/D-04). this.schedulerRegistry.addCronJob(this.JOB_NAME, job as any); job.start(); diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts index 31c91e1..ee153bb 100644 --- a/apps/web/src/test/setup.ts +++ b/apps/web/src/test/setup.ts @@ -5,7 +5,7 @@ import * as matchers from '@testing-library/jest-dom/matchers'; // Using the explicit extend approach (not @testing-library/jest-dom/vitest) for // compatibility with vitest@4.x (the /vitest barrel doesn't extend the same // expect instance in vitest 4's module resolution). -expect.extend(matchers as any); +expect.extend(matchers); // Type augmentation for the custom matchers (adds toBeInTheDocument, etc.) // This import only carries type declarations — no runtime effect.