refactor(quick-260921-m34): Aufgabe 3c - Randschicht beurteilt, drei Befunde gemeldet, 15 bleiben mit Urteil
httpntlm (exchange.provider, exchange-inbox.provider): NtlmOptions und
NtlmResponse beschreiben genau das, was uebergeben und gelesen wird. Die
ueberfluessige Zusicherung (httpntlm as any) faellt weg.
Graph-Rueckrufe (exchange.provider :157/:313): AuthProviderCallback aus dem
SDK selbst statt Handannotation - als import type, also ohne den dynamischen
Import zur Laufzeit zurueckzunehmen.
imapflow: streamToBuffer() nimmt Readable statt NodeJS.ReadableStream (alle
drei Aufrufer reichen client.download().content herein, imapflow deklariert
das als Readable) - damit traegt der Typ destroy() und die Zusicherung
faellt. node.parameters?.name war ebenfalls schon getypt.
nodemailer: ResolvedTransport.options wird SMTPTransport.Options; beide
Zweige bauen reine SMTP-Optionen, createTransport() nimmt sie ohne
Zusicherung.
node-forge: die vier let p7: any werden Captured<PkcsEnvelopedData |
PkcsSignedData> - der MITGELIEFERTE Typ. Die Lesestellen grenzen mit
'certificates' in p7 ein statt zuzusichern; verhaltensgleich, weil der
enveloped-Form das Feld fehlt und beide Schreibweisen dann die leere Liste
liefern. cert.siginfo war bereits getypt.
apps/web/src/test/setup.ts: expect.extend(matchers) traegt ohne Zusicherung
- geprueft im echten Typlauf (setup.ts liegt im include von
apps/web/tsconfig.json, mit einem absichtlichen Fehler nachgewiesen).
BEFUND 4 (D-03, gemeldet, NICHT repariert) imap.provider.ts:78 - der
Ausdruck (node as any).disposition?.parameters?.filename liest .parameters
von einer ZEICHENKETTE: imapflow deklariert disposition als string
(imap-flow.d.ts:448), die Parameter liegen in dispositionParameters (:450).
dispositionFilename ist damit zur Laufzeit immer ''. Folge: Outlook-Anhaenge,
die als application/octet-stream kommen, werden ueber den Dateinamen aus
Content-Disposition NICHT erkannt - nur ueber den aus Content-Type. Umbiegen
waere eine Verhaltensaenderung; die Zusicherung bleibt sichtbar stehen.
BEFUND 5 (D-03, gemeldet, NICHT repariert) imap.provider.ts:402 -
requireTLS kommt in imapflow 1.4.3 NIRGENDS vor, weder in ImapFlowOptions
noch im Laufzeitcode (beides durchsucht). Die Option wird still verworfen;
STARTTLS wird durch sie nicht erzwungen. Genau das } as any hat es
verdeckt. Bleibt stehen, damit der Befund in der Zaehlung sichtbar ist.
BEFUND 6 (D-03, gemeldet, Verhalten unveraendert) httpntlm liefert den
Rumpf als Zeichenkette, nicht als Buffer: httpreq setzt ihn nur bei
gesetzter Option binary auf Buffer (httpreq@1.1.1/lib/httpreq.js:391),
keiner der beiden Aufrufer setzt sie. Der Bestand rief unbesehen
.toString('utf-8') auf - das ging nur gut, weil String.toString() sein
Argument ignoriert. Die Testdoppel reichen dagegen wirklich Buffer herein.
NtlmResponse.body nennt jetzt beide Formen, die Fallunterscheidung liefert
fuer jede exakt dasselbe Ergebnis wie zuvor.
Urteil BLEIBT mit Begruendung im Code an allen 15 verbleibenden Stellen:
3x addCronJob (require-Umweg aus 07-04), 5x node-forge (EC-Zweig und
extensions: any[] sind in @types/node-forge nicht beschrieben, 2x null as
any wo die Typen die Bibliothek nachweislich falsch beschreiben), 2x
imap-Befunde oben, 2x tx: any plus 2x Gefolge (Aufgabe 1), 1x
disposition-Befund.
noExplicitAny in apps/api/src: 31 -> 15 (Ausgang 288, Schranke 45), apps/web
1 -> 0. type-check 4/4, lint 5/5 (0 error), apps/api 72/1143, apps/web
73/531, rls-access-inventory 30/30. noNonNullAssertion 56, as unknown as 33,
ts-expect-error/ts-ignore 0/0, Unterdrueckungsmarker 1. biome.json, alle
package.json und pnpm-lock.yaml unveraendert.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
@@ -1,9 +1,34 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import type { AuthProviderCallback } from '@microsoft/microsoft-graph-client';
|
||||||
import { CalendarEvent, CalendarProvider } from '../calendar.service';
|
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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const httpntlm = require('httpntlm') as {
|
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/';
|
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] : '';
|
return attrMatch ? attrMatch[1] : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function ntlmPost(opts: {
|
function ntlmPost(opts: NtlmOptions): Promise<{ statusCode: number; body: string }> {
|
||||||
url: string; username: string; password: string;
|
|
||||||
domain: string; workstation: string; body: string;
|
|
||||||
headers: Record<string, string>;
|
|
||||||
}): Promise<{ statusCode: number; body: string }> {
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
httpntlm.post(opts, (err, res) => {
|
httpntlm.post(opts, (err, res) => {
|
||||||
if (err) return reject(err);
|
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({
|
const client = GraphClient.init({
|
||||||
authProvider: (done: (error: any, token: string) => void) => {
|
authProvider: (done: AuthProviderCallback) => {
|
||||||
// Use the password as the access token (OAuth bearer token)
|
// Use the password as the access token (OAuth bearer token)
|
||||||
// Users configure their OAuth token in the password field for Graph API
|
// Users configure their OAuth token in the password field for Graph API
|
||||||
done(null, source.password || '');
|
done(null, source.password || '');
|
||||||
@@ -310,7 +334,7 @@ export class ExchangeProvider implements CalendarProvider {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const client = GraphClient.init({
|
const client = GraphClient.init({
|
||||||
authProvider: (done: (error: any, token: string) => void) => {
|
authProvider: (done: AuthProviderCallback) => {
|
||||||
done(null, source.password || '');
|
done(null, source.password || '');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
import * as forge from 'node-forge';
|
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';
|
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -231,14 +244,16 @@ export class CertManagerService {
|
|||||||
.slice(0, 27)
|
.slice(0, 27)
|
||||||
.toString('ascii')
|
.toString('ascii')
|
||||||
.includes('-----BEGIN');
|
.includes('-----BEGIN');
|
||||||
let p7: any;
|
// siehe P7Message oben — mitgelieferter Typ, keine Behauptung.
|
||||||
|
let p7: P7Message;
|
||||||
if (isPemP7b) {
|
if (isPemP7b) {
|
||||||
p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8'));
|
p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8'));
|
||||||
} else {
|
} else {
|
||||||
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
|
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
|
||||||
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
|
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) {
|
if (p7Certs.length === 0) {
|
||||||
throw new Error('No certificate found in P7B/PKCS7');
|
throw new Error('No certificate found in P7B/PKCS7');
|
||||||
}
|
}
|
||||||
@@ -270,6 +285,14 @@ export class CertManagerService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Key type and size
|
// 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;
|
const pubKey = cert.publicKey as any;
|
||||||
let keyType = 'RSA';
|
let keyType = 'RSA';
|
||||||
let keyBits = 0;
|
let keyBits = 0;
|
||||||
@@ -283,13 +306,24 @@ export class CertManagerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subject Alternative Names
|
// 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 sanExt = cert.extensions?.find((e: any) => e.name === 'subjectAltName');
|
||||||
const san: string[] = ((sanExt as any)?.altNames ?? []).map((n: any) =>
|
const san: string[] = ((sanExt as any)?.altNames ?? []).map((n: any) =>
|
||||||
n.type === 2 ? (n.value as string) : `IP:${(n.ip ?? n.value) as string}`,
|
n.type === 2 ? (n.value as string) : `IP:${(n.ip ?? n.value) as string}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Signature algorithm — OID → human-readable name
|
// 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;
|
const signatureAlgorithm = REVERSE_OIDS[sigOid] ?? sigOid;
|
||||||
|
|
||||||
// Fingerprints
|
// Fingerprints
|
||||||
@@ -369,7 +403,9 @@ export class CertManagerService {
|
|||||||
.slice(0, 27)
|
.slice(0, 27)
|
||||||
.toString('ascii')
|
.toString('ascii')
|
||||||
.includes('-----BEGIN');
|
.includes('-----BEGIN');
|
||||||
let p7: any;
|
// Der mitgelieferte Typ traegt hier: messageFromPem/messageFromAsn1
|
||||||
|
// liefern beide Captured<PkcsEnvelopedData | PkcsSignedData>.
|
||||||
|
let p7: P7Message;
|
||||||
if (isPemP7b) {
|
if (isPemP7b) {
|
||||||
// PEM-wrapped PKCS7 (e.g. -----BEGIN PKCS7-----)
|
// PEM-wrapped PKCS7 (e.g. -----BEGIN PKCS7-----)
|
||||||
p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8'));
|
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));
|
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
|
||||||
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
|
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
|
||||||
}
|
}
|
||||||
certs = (p7.certificates as forge.pki.Certificate[]) ?? [];
|
certs = 'certificates' in p7 ? p7.certificates : [];
|
||||||
if (certs.length === 0) {
|
if (certs.length === 0) {
|
||||||
throw new Error('No certificates found in P7B/PKCS7 bundle');
|
throw new Error('No certificates found in P7B/PKCS7 bundle');
|
||||||
}
|
}
|
||||||
@@ -505,14 +541,15 @@ export class CertManagerService {
|
|||||||
.slice(0, 27)
|
.slice(0, 27)
|
||||||
.toString('ascii')
|
.toString('ascii')
|
||||||
.includes('-----BEGIN');
|
.includes('-----BEGIN');
|
||||||
let p7: any;
|
// siehe P7Message oben — mitgelieferter Typ, keine Behauptung.
|
||||||
|
let p7: P7Message;
|
||||||
if (isPemP7b) {
|
if (isPemP7b) {
|
||||||
p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8'));
|
p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8'));
|
||||||
} else {
|
} else {
|
||||||
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
|
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
|
||||||
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
|
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
|
||||||
}
|
}
|
||||||
return (p7.certificates as forge.pki.Certificate[]) ?? [];
|
return 'certificates' in p7 ? p7.certificates : [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -543,6 +580,12 @@ export class CertManagerService {
|
|||||||
// Open Question 1 resolution: toPkcs12Asn1(null, certs, password) works in node-forge 1.4.0
|
// 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)
|
// null as the private key produces a cert-only PKCS12 bundle (no key bag — cert bag only)
|
||||||
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
|
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
|
null as any, // cert-only PFX — null key accepted by node-forge 1.4.0
|
||||||
certs,
|
certs,
|
||||||
password!,
|
password!,
|
||||||
@@ -648,14 +691,16 @@ export class CertManagerService {
|
|||||||
.slice(0, 27)
|
.slice(0, 27)
|
||||||
.toString('ascii')
|
.toString('ascii')
|
||||||
.includes('-----BEGIN');
|
.includes('-----BEGIN');
|
||||||
let p7: any;
|
// siehe P7Message oben — mitgelieferter Typ, keine Behauptung.
|
||||||
|
let p7: P7Message;
|
||||||
if (isPemP7b) {
|
if (isPemP7b) {
|
||||||
p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8'));
|
p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8'));
|
||||||
} else {
|
} else {
|
||||||
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
|
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
|
||||||
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
|
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) {
|
if (p7Certs.length === 0) {
|
||||||
throw new Error('No certificate found in P7B/PKCS7');
|
throw new Error('No certificate found in P7B/PKCS7');
|
||||||
}
|
}
|
||||||
@@ -698,6 +743,12 @@ export class CertManagerService {
|
|||||||
} else {
|
} else {
|
||||||
// PFX — cert-only PKCS12 bundle (Open Question 1: null key works in node-forge 1.4.0)
|
// PFX — cert-only PKCS12 bundle (Open Question 1: null key works in node-forge 1.4.0)
|
||||||
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
|
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
|
null as any, // cert-only PFX — null key accepted by node-forge 1.4.0
|
||||||
[cert],
|
[cert],
|
||||||
password!,
|
password!,
|
||||||
|
|||||||
@@ -133,6 +133,14 @@ export class DkvSchedulerService implements OnModuleInit {
|
|||||||
// Cast required: our minimal CronJob type doesn't match cron's full type signature.
|
// 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.
|
// At runtime the object IS a full CronJob — SchedulerRegistry only calls stop() on it.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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);
|
this.schedulerRegistry.addCronJob(jobName, job as any);
|
||||||
job.start();
|
job.start();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// 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 { InboxAttachment, InboxConfig, InboxEmail, InboxMessage } from './inbox-provider.interface';
|
||||||
import type { InboxProvider } from './inbox-provider.interface';
|
import type { InboxProvider } from './inbox-provider.interface';
|
||||||
|
|
||||||
@@ -236,11 +238,34 @@ interface NtlmOptions {
|
|||||||
rejectUnauthorized?: boolean;
|
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 }> {
|
function ntlmPost(opts: NtlmOptions): Promise<{ statusCode: number; body: string }> {
|
||||||
return new Promise((resolve, reject) => {
|
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);
|
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') ?? ''),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import type { Readable } from 'node:stream';
|
||||||
import { ImapFlow, MessageStructureObject } from 'imapflow';
|
import { ImapFlow, MessageStructureObject } from 'imapflow';
|
||||||
import type { InboxAttachment, InboxConfig, InboxEmail, InboxMessage } from './inbox-provider.interface';
|
import type { InboxAttachment, InboxConfig, InboxEmail, InboxMessage } from './inbox-provider.interface';
|
||||||
import type { InboxProvider } 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.
|
* Converts a Node.js Readable stream into a Buffer.
|
||||||
* Accumulates chunks up to MAX_ATTACHMENT_BYTES; throws if limit exceeded.
|
* Accumulates chunks up to MAX_ATTACHMENT_BYTES; throws if limit exceeded.
|
||||||
*/
|
*/
|
||||||
async function streamToBuffer(
|
async function streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||||
stream: NodeJS.ReadableStream,
|
|
||||||
): Promise<Buffer> {
|
|
||||||
return new Promise<Buffer>((resolve, reject) => {
|
return new Promise<Buffer>((resolve, reject) => {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
let total = 0;
|
let total = 0;
|
||||||
@@ -24,8 +23,12 @@ async function streamToBuffer(
|
|||||||
stream.on('data', (chunk: Buffer) => {
|
stream.on('data', (chunk: Buffer) => {
|
||||||
total += chunk.length;
|
total += chunk.length;
|
||||||
if (total > MAX_ATTACHMENT_BYTES) {
|
if (total > MAX_ATTACHMENT_BYTES) {
|
||||||
// Destroy the stream to prevent further data emission
|
// Destroy the stream to prevent further data emission.
|
||||||
(stream as any).destroy?.();
|
// 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(
|
reject(
|
||||||
new Error(
|
new Error(
|
||||||
`Attachment exceeds maximum allowed size of ${MAX_ATTACHMENT_BYTES} bytes (T-07-05)`,
|
`Attachment exceeds maximum allowed size of ${MAX_ATTACHMENT_BYTES} bytes (T-07-05)`,
|
||||||
@@ -59,10 +62,23 @@ function collectPdfParts(
|
|||||||
const type = node.type?.toLowerCase() ?? '';
|
const type = node.type?.toLowerCase() ?? '';
|
||||||
// Some mail clients (e.g. Outlook) send PDFs as application/octet-stream.
|
// 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.
|
// 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 =
|
const dispositionFilename =
|
||||||
((node as any).disposition?.parameters?.filename as string | undefined)?.toLowerCase() ?? '';
|
((node as any).disposition?.parameters?.filename as string | undefined)?.toLowerCase() ?? '';
|
||||||
const typeFilename =
|
// Hier dagegen war die Zusicherung schlicht ueberfluessig: imapflow
|
||||||
((node as any).parameters?.name as string | undefined)?.toLowerCase() ?? '';
|
// deklariert `parameters?: { [key: string]: string }` (imap-flow.d.ts:438).
|
||||||
|
const typeFilename = node.parameters?.name?.toLowerCase() ?? '';
|
||||||
const looksLikePdf =
|
const looksLikePdf =
|
||||||
type === 'application/pdf' ||
|
type === 'application/pdf' ||
|
||||||
(type === 'application/octet-stream' &&
|
(type === 'application/octet-stream' &&
|
||||||
@@ -374,6 +390,15 @@ export class ImapProvider implements InboxProvider {
|
|||||||
: undefined,
|
: undefined,
|
||||||
// T-07-03: suppress imapflow verbose logs — they include auth credentials
|
// T-07-03: suppress imapflow verbose logs — they include auth credentials
|
||||||
logger: false,
|
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);
|
} as any);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as nodemailer from 'nodemailer';
|
import * as nodemailer from 'nodemailer';
|
||||||
|
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||||
import { SettingsService } from '../settings/settings.service';
|
import { SettingsService } from '../settings/settings.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,7 +70,14 @@ export type BugReportMail = Pick<OutgoingMail, 'subject' | 'text' | 'attachments
|
|||||||
|
|
||||||
interface ResolvedTransport {
|
interface ResolvedTransport {
|
||||||
source: 'tenant' | 'env';
|
source: 'tenant' | 'env';
|
||||||
options: nodemailer.TransportOptions & Record<string, unknown>;
|
/**
|
||||||
|
* SMTPTransport.Options statt TransportOptions & Record<string, unknown>:
|
||||||
|
* 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;
|
from: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +170,7 @@ export class MailService {
|
|||||||
let transport: nodemailer.Transporter | null = null;
|
let transport: nodemailer.Transporter | null = null;
|
||||||
try {
|
try {
|
||||||
const resolved = await this.resolveTransport(tenantId);
|
const resolved = await this.resolveTransport(tenantId);
|
||||||
transport = nodemailer.createTransport(resolved.options as any);
|
transport = nodemailer.createTransport(resolved.options);
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
from: resolved.from,
|
from: resolved.from,
|
||||||
to: mail.to,
|
to: mail.to,
|
||||||
|
|||||||
@@ -83,6 +83,14 @@ export class TenderDigestScheduler implements OnModuleInit {
|
|||||||
// type signature. At runtime the object IS a full CronJob —
|
// type signature. At runtime the object IS a full CronJob —
|
||||||
// SchedulerRegistry only calls stop() on it.
|
// SchedulerRegistry only calls stop() on it.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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);
|
this.schedulerRegistry.addCronJob(this.JOB_NAME, job as any);
|
||||||
job.start();
|
job.start();
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,14 @@ export class TenderSchedulerService implements OnApplicationBootstrap {
|
|||||||
// Cast required: our minimal CronJob type doesn't match cron's full type signature.
|
// 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.
|
// At runtime the object IS a full CronJob — SchedulerRegistry only calls stop() on it.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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);
|
this.schedulerRegistry.addCronJob(this.JOB_NAME, job as any);
|
||||||
job.start();
|
job.start();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// 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
|
// compatibility with vitest@4.x (the /vitest barrel doesn't extend the same
|
||||||
// expect instance in vitest 4's module resolution).
|
// 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.)
|
// Type augmentation for the custom matchers (adds toBeInTheDocument, etc.)
|
||||||
// This import only carries type declarations — no runtime effect.
|
// This import only carries type declarations — no runtime effect.
|
||||||
|
|||||||
Reference in New Issue
Block a user