Add detailed debug logging for the login flow

The login flow was near-silent: three info lines with no way to see why a
session was considered expired or what happened to the cookies. Debugging
a failing login meant guessing.

Every login step now logs on debug level with a `phase` field: the login
page fetch, the extracted nonce, the POST to /login/api/login.php, the
session check, and any request retried after a re-login. Each of those
requests is bracketed by a cookie snapshot taken before it is sent and one
taken after, plus a delta of added/changed/removed cookies. The "before"
snapshot is logged prior to sending, so it survives a hanging request.

isLoginRequiredResponse() is split so the criterion that matched
(status-401, login-markup, ...) can be reported instead of just a boolean.

Secrets stay out of the log: the password is replaced by ***, and cookie
values - including those in the raw Set-Cookie header - are truncated to
their first four characters plus length. That still shows whether a
session id changed without putting the session itself in the log.

Detail logging is strictly debug-level via logLoginDetail(); the existing
logDebug() falls back to info, which would flood a plain info logger. When
no debug logger is attached, no snapshot is taken and nothing is
serialized.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 20:05:02 +02:00
co-authored by Claude Opus 5
parent b844b772b8
commit 4cd0da4a8a
7 changed files with 674 additions and 22 deletions
+22
View File
@@ -52,6 +52,28 @@ const response = await agrarmonitor.http.get('/kunden/detail/123');
console.log(response.status); console.log(response.status);
``` ```
## Login-Logging
Der Login-Flow protokolliert jeden Schritt auf `debug`-Level, sofern der uebergebene Logger eine `debug`-Methode hat. Ohne `debug`-Methode bleiben diese Details still - ein reiner `info`-Logger sieht davon nichts.
Protokolliert werden der Aufruf der Loginseite, die gelesene Nonce, der `POST /login/api/login.php`, die Session-Pruefung und jeder Request, der wegen abgelaufener Session wiederholt wird. Zu jedem dieser Requests steht ein Cookie-Snapshot **vor** dem Absenden und einer **danach** im Log, dazu ein Delta mit neuen, geaenderten und entfernten Cookies:
```
[debug] Login-POST: POST /login/api/login.php
{"phase":"request","method":"POST","url":"/login/api/login.php","cookies":[],
"body":{"username":"demo","passwort":"***","nonce":"9f3a1c7b2e...","ssoAction":"","ssoReturn":""}}
[debug] Login-POST: POST /login/api/login.php -> 200
{"phase":"response","status":200,"istLoginSeite":false,
"setCookie":["PHPSESSID=f81d…(len=32); Path=/; HttpOnly"],
"cookies":[{"name":"PHPSESSID","value":"f81d…(len=32)","domain":"127.0.0.1","path":"/",
"expires":"Session","httpOnly":true,"secure":false}],
"cookieChanges":{"added":["PHPSESSID"],"changed":[],"removed":[]}}
```
Passwort und Cookie-Werte stehen nie im Klartext im Log: das Passwort wird durch `***` ersetzt, Cookie-Werte und der rohe `Set-Cookie`-Header werden auf die ersten vier Zeichen plus Laenge gekuerzt. Damit bleibt erkennbar, ob sich eine Session-ID geaendert hat, ohne dass das Log selbst die Session preisgibt.
Das `phase`-Feld ordnet jeden Eintrag zu: `login-start`, `request`, `response`, `nonce`, `session-check`, `session-expired`, `retry`, `error`, `login-done`.
## Cookie Persistence ## Cookie Persistence
`FileCookieStore` keeps one shared `CookieJar` per file path inside the Node process. Multiple connector instances that use the same cookie file therefore reuse the same session and every successful request saves the latest cookies back to disk. `FileCookieStore` keeps one shared `CookieJar` per file path inside the Node process. Multiple connector instances that use the same cookie file therefore reuse the same session and every successful request saves the latest cookies back to disk.
+31
View File
@@ -50,6 +50,11 @@ export declare class AgrarmonitorConnector implements AgrarmonitorConnectorResul
private isSessionValid; private isSessionValid;
private loginRequestConfig; private loginRequestConfig;
private isLoginRequiredResponse; private isLoginRequiredResponse;
/**
* Liefert das Kriterium, an dem eine abgelaufene Session erkannt wurde,
* oder null fuer eine gueltige Session.
*/
private loginRequiredReason;
private retryAfterLogin; private retryAfterLogin;
private createDateienLivesearchParams; private createDateienLivesearchParams;
private getEingangsrechnungEditMeta; private getEingangsrechnungEditMeta;
@@ -80,5 +85,31 @@ export declare class AgrarmonitorConnector implements AgrarmonitorConnectorResul
private isLoginPageText; private isLoginPageText;
private extractNonce; private extractNonce;
private maskNonce; private maskNonce;
private get debugLogging();
/**
* Detail-Logging des Login-Flows. Anders als logDebug faellt das bewusst
* NICHT auf info zurueck - ohne debug-Logger bleiben die Details still.
*/
private logLoginDetail;
private maskValue;
/**
* Liest die Cookies des Jars fuer eine URL aus. Liefert eine leere Liste,
* solange kein debug-Logger haengt - dann wird gar nicht erst serialisiert.
*/
private snapshotCookies;
/**
* Maskiert die Werte im rohen Set-Cookie-Header - sonst stuende die neue
* Session-ID im Klartext im Log, obwohl der Jar-Snapshot sie maskiert.
*/
private formatSetCookieHeader;
/** Wie snapshotCookies, aber mit maskierten Werten - nur das geht ins Log. */
private formatCookies;
private diffCookies;
/**
* Klammert einen Request mit je einem Cookie-Snapshot davor und danach.
* Der Snapshot davor wird vor dem Absenden geloggt, damit er auch dann
* im Log steht, wenn der Request haengt oder wirft.
*/
private loggedRequest;
} }
//# sourceMappingURL=AgrarmonitorConnector.d.ts.map //# sourceMappingURL=AgrarmonitorConnector.d.ts.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"AgrarmonitorConnector.d.ts","sourceRoot":"","sources":["../src/AgrarmonitorConnector.ts"],"names":[],"mappings":"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"} {"version":3,"file":"AgrarmonitorConnector.d.ts","sourceRoot":"","sources":["../src/AgrarmonitorConnector.ts"],"names":[],"mappings":"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"}
+185 -9
View File
@@ -486,6 +486,14 @@ class AgrarmonitorConnector {
throw new Error('Agrarmonitor-Credentials nicht konfiguriert'); throw new Error('Agrarmonitor-Credentials nicht konfiguriert');
} }
this.logger?.info?.('Fuehre Agrarmonitor-Login durch'); this.logger?.info?.('Fuehre Agrarmonitor-Login durch');
this.logLoginDetail('Login gestartet', {
phase: 'login-start',
baseUrl: this.baseUrl,
username: this.options.username,
loginStrategy: this.loginStrategy,
autoRetry: this.autoRetry,
cookies: this.formatCookies(this.snapshotCookies()),
});
if (this.loginStrategy === 'auto') { if (this.loginStrategy === 'auto') {
this.logger?.warn?.('loginStrategy "auto" ist veraltet; verwende Redirect-Login'); this.logger?.warn?.('loginStrategy "auto" ist veraltet; verwende Redirect-Login');
} }
@@ -495,14 +503,27 @@ class AgrarmonitorConnector {
} }
await this.options.cookieStore.save(this.cookieJar); await this.options.cookieStore.save(this.cookieJar);
this.logger?.info?.('Agrarmonitor-Login erfolgreich'); this.logger?.info?.('Agrarmonitor-Login erfolgreich');
this.logLoginDetail('Login abgeschlossen', {
phase: 'login-done',
cookies: this.formatCookies(this.snapshotCookies()),
});
} }
async performRedirectLogin() { async performRedirectLogin() {
const loginPageResponse = await this.http.get('/', this.loginRequestConfig()); const loginPageResponse = await this.loggedRequest('Loginseite', 'GET', '/', () => this.http.get('/', this.loginRequestConfig()));
const loginPageText = typeof loginPageResponse.data === 'string' ? loginPageResponse.data : ''; const loginPageText = typeof loginPageResponse.data === 'string' ? loginPageResponse.data : '';
if (!this.isLoginPageText(loginPageText)) { if (!this.isLoginPageText(loginPageText)) {
this.logLoginDetail('Keine Loginseite erhalten, Session ist bereits gueltig', {
phase: 'login-skip',
url: '/',
});
return; return;
} }
const nonce = this.extractNonce(loginPageText, 'input[name="nonce"]'); const nonce = this.extractNonce(loginPageText, 'input[name="nonce"]');
this.logLoginDetail('Nonce aus Loginseite gelesen', {
phase: 'nonce',
url: '/',
nonce: this.maskNonce(nonce),
});
const loginData = { const loginData = {
username: this.options.username, username: this.options.username,
passwort: this.options.password, passwort: this.options.password,
@@ -516,7 +537,10 @@ class AgrarmonitorConnector {
}, },
_agrarmonitorLoginRequest: true, _agrarmonitorLoginRequest: true,
}; };
const response = await this.http.post('/login/api/login.php', loginData, loginPostConfig); const response = await this.loggedRequest('Login-POST', 'POST', '/login/api/login.php', () => this.http.post('/login/api/login.php', loginData, loginPostConfig), {
contentType: 'application/json',
body: { ...loginData, passwort: '***', nonce: this.maskNonce(nonce) },
});
const responseText = typeof response.data === 'string' ? response.data : ''; const responseText = typeof response.data === 'string' ? response.data : '';
if (this.isLoginPageText(responseText)) { if (this.isLoginPageText(responseText)) {
throw new Error('Agrarmonitor-Redirect-Login fehlgeschlagen'); throw new Error('Agrarmonitor-Redirect-Login fehlgeschlagen');
@@ -525,9 +549,25 @@ class AgrarmonitorConnector {
async isSessionValid(options = {}) { async isSessionValid(options = {}) {
try { try {
const response = await this.http.get('/', options.skipAutoRetry ? this.loginRequestConfig() : undefined); const response = await this.http.get('/', options.skipAutoRetry ? this.loginRequestConfig() : undefined);
return !this.isLoginRequiredResponse(response); const grund = this.loginRequiredReason(response);
this.logLoginDetail(`Session-Pruefung: ${grund ? 'ungueltig' : 'gueltig'}`, {
phase: 'session-check',
url: '/',
status: response.status,
gueltig: grund === null,
grund,
cookies: this.formatCookies(this.snapshotCookies()),
});
return grund === null;
} }
catch { catch (error) {
this.logLoginDetail('Session-Pruefung fehlgeschlagen', {
phase: 'session-check',
url: '/',
gueltig: false,
grund: 'request-fehler',
fehler: error instanceof Error ? error.message : String(error),
});
return false; return false;
} }
} }
@@ -537,20 +577,38 @@ class AgrarmonitorConnector {
}; };
} }
isLoginRequiredResponse(response) { isLoginRequiredResponse(response) {
const responseUrl = this.getResponseUrl(response); return this.loginRequiredReason(response) !== null;
}
/**
* Liefert das Kriterium, an dem eine abgelaufene Session erkannt wurde,
* oder null fuer eine gueltige Session.
*/
loginRequiredReason(response) {
const responseText = typeof response.data === 'string' ? response.data : ''; const responseText = typeof response.data === 'string' ? response.data : '';
return (response.status === 401 || if (response.status === 401 || response.status === 403) {
response.status === 403 || return `status-${response.status}`;
this.isLoginPageText(responseText)); }
if (this.isLoginPageText(responseText)) {
return 'login-markup';
}
return null;
} }
async retryAfterLogin(config) { async retryAfterLogin(config) {
if (config._agrarmonitorRetry) { if (config._agrarmonitorRetry) {
throw new Error('Agrarmonitor-Request nach erneutem Login weiterhin nicht autorisiert'); throw new Error('Agrarmonitor-Request nach erneutem Login weiterhin nicht autorisiert');
} }
config._agrarmonitorRetry = true; config._agrarmonitorRetry = true;
const method = (config.method ?? 'get').toUpperCase();
const url = config.url ?? '/';
this.logger?.info?.('Agrarmonitor-Session abgelaufen, erneuter Login wird ausgefuehrt'); this.logger?.info?.('Agrarmonitor-Session abgelaufen, erneuter Login wird ausgefuehrt');
this.logLoginDetail(`Session abgelaufen, wiederhole ${method} ${url} nach erneutem Login`, {
phase: 'session-expired',
method,
url,
cookies: this.formatCookies(this.snapshotCookies()),
});
await this.login(); await this.login();
return this.http.request(config); return this.loggedRequest('Wiederholung nach Login', method, url, () => this.http.request(config), {}, 'retry');
} }
createDateienLivesearchParams(suchstring) { createDateienLivesearchParams(suchstring) {
return { return {
@@ -731,6 +789,124 @@ class AgrarmonitorConnector {
maskNonce(nonce) { maskNonce(nonce) {
return nonce.length <= 10 ? nonce : `${nonce.slice(0, 10)}...`; return nonce.length <= 10 ? nonce : `${nonce.slice(0, 10)}...`;
} }
get debugLogging() {
return typeof this.logger?.debug === 'function';
}
/**
* Detail-Logging des Login-Flows. Anders als logDebug faellt das bewusst
* NICHT auf info zurueck - ohne debug-Logger bleiben die Details still.
*/
logLoginDetail(message, meta) {
this.logger?.debug?.(message, meta);
}
maskValue(value) {
if (!value) {
return '(leer)';
}
return value.length <= 8 ? `\u2026(len=${value.length})` : `${value.slice(0, 4)}\u2026(len=${value.length})`;
}
/**
* Liest die Cookies des Jars fuer eine URL aus. Liefert eine leere Liste,
* solange kein debug-Logger haengt - dann wird gar nicht erst serialisiert.
*/
snapshotCookies(url = this.baseUrl) {
if (!this.debugLogging) {
return [];
}
try {
return this.cookieJar.getCookiesSync(url).map(cookie => ({
name: cookie.key,
value: cookie.value,
domain: cookie.domain ?? null,
path: cookie.path ?? null,
expires: cookie.expires instanceof Date ? cookie.expires.toISOString() : 'Session',
httpOnly: Boolean(cookie.httpOnly),
secure: Boolean(cookie.secure),
}));
}
catch {
return [];
}
}
/**
* Maskiert die Werte im rohen Set-Cookie-Header - sonst stuende die neue
* Session-ID im Klartext im Log, obwohl der Jar-Snapshot sie maskiert.
*/
formatSetCookieHeader(header) {
if (!Array.isArray(header)) {
return null;
}
return header.map(entry => {
const [pair, ...attributes] = String(entry).split(';');
const separator = pair.indexOf('=');
if (separator === -1) {
return [pair, ...attributes].join(';');
}
const name = pair.slice(0, separator);
const value = pair.slice(separator + 1);
return [`${name}=${this.maskValue(value)}`, ...attributes].join(';');
});
}
/** Wie snapshotCookies, aber mit maskierten Werten - nur das geht ins Log. */
formatCookies(cookies) {
return cookies.map(cookie => ({ ...cookie, value: this.maskValue(cookie.value) }));
}
diffCookies(before, after) {
const beforeByName = new Map(before.map(cookie => [cookie.name, cookie.value]));
const afterByName = new Map(after.map(cookie => [cookie.name, cookie.value]));
return {
added: [...afterByName.keys()].filter(name => !beforeByName.has(name)),
changed: [...afterByName.entries()]
.filter(([name, value]) => beforeByName.has(name) && beforeByName.get(name) !== value)
.map(([name]) => name),
removed: [...beforeByName.keys()].filter(name => !afterByName.has(name)),
};
}
/**
* Klammert einen Request mit je einem Cookie-Snapshot davor und danach.
* Der Snapshot davor wird vor dem Absenden geloggt, damit er auch dann
* im Log steht, wenn der Request haengt oder wirft.
*/
async loggedRequest(label, method, url, exec, details = {}, phase = 'request') {
const before = this.snapshotCookies();
this.logLoginDetail(`${label}: ${method} ${url}`, {
phase,
method,
url,
cookies: this.formatCookies(before),
...details,
});
try {
const response = await exec();
const after = this.snapshotCookies();
const responseText = typeof response.data === 'string' ? response.data : '';
this.logLoginDetail(`${label}: ${method} ${url} -> ${response.status}`, {
phase: 'response',
method,
url,
status: response.status,
responseUrl: this.getResponseUrl(response) || null,
contentType: this.getHeader(response, 'content-type'),
setCookie: this.formatSetCookieHeader(response.headers['set-cookie']),
istLoginSeite: this.isLoginPageText(responseText),
cookies: this.formatCookies(after),
cookieChanges: this.diffCookies(before, after),
});
return response;
}
catch (error) {
const after = this.snapshotCookies();
this.logLoginDetail(`${label}: ${method} ${url} -> Fehler`, {
phase: 'error',
method,
url,
fehler: error instanceof Error ? error.message : String(error),
cookies: this.formatCookies(after),
cookieChanges: this.diffCookies(before, after),
});
throw error;
}
}
} }
exports.AgrarmonitorConnector = AgrarmonitorConnector; exports.AgrarmonitorConnector = AgrarmonitorConnector;
//# sourceMappingURL=AgrarmonitorConnector.js.map //# sourceMappingURL=AgrarmonitorConnector.js.map
+1 -1
View File
File diff suppressed because one or more lines are too long
+251 -11
View File
@@ -30,6 +30,22 @@ type RetryableAxiosRequestConfig = AxiosRequestConfig & {
_agrarmonitorLoginRequest?: boolean; _agrarmonitorLoginRequest?: boolean;
}; };
type CookieRecord = {
name: string;
value: string;
domain: string | null;
path: string | null;
expires: string;
httpOnly: boolean;
secure: boolean;
};
type CookieChanges = {
added: string[];
changed: string[];
removed: string[];
};
type AgrarmonitorCustomerPage = { type AgrarmonitorCustomerPage = {
data?: unknown; data?: unknown;
meta?: { meta?: {
@@ -661,6 +677,14 @@ export class AgrarmonitorConnector implements AgrarmonitorConnectorResult {
} }
this.logger?.info?.('Fuehre Agrarmonitor-Login durch'); this.logger?.info?.('Fuehre Agrarmonitor-Login durch');
this.logLoginDetail('Login gestartet', {
phase: 'login-start',
baseUrl: this.baseUrl,
username: this.options.username,
loginStrategy: this.loginStrategy,
autoRetry: this.autoRetry,
cookies: this.formatCookies(this.snapshotCookies()),
});
if (this.loginStrategy === 'auto') { if (this.loginStrategy === 'auto') {
this.logger?.warn?.('loginStrategy "auto" ist veraltet; verwende Redirect-Login'); this.logger?.warn?.('loginStrategy "auto" ist veraltet; verwende Redirect-Login');
@@ -674,17 +698,33 @@ export class AgrarmonitorConnector implements AgrarmonitorConnectorResult {
await this.options.cookieStore.save(this.cookieJar); await this.options.cookieStore.save(this.cookieJar);
this.logger?.info?.('Agrarmonitor-Login erfolgreich'); this.logger?.info?.('Agrarmonitor-Login erfolgreich');
this.logLoginDetail('Login abgeschlossen', {
phase: 'login-done',
cookies: this.formatCookies(this.snapshotCookies()),
});
} }
private async performRedirectLogin(): Promise<void> { private async performRedirectLogin(): Promise<void> {
const loginPageResponse = await this.http.get('/', this.loginRequestConfig()); const loginPageResponse = await this.loggedRequest('Loginseite', 'GET', '/', () =>
this.http.get('/', this.loginRequestConfig())
);
const loginPageText = typeof loginPageResponse.data === 'string' ? loginPageResponse.data : ''; const loginPageText = typeof loginPageResponse.data === 'string' ? loginPageResponse.data : '';
if (!this.isLoginPageText(loginPageText)) { if (!this.isLoginPageText(loginPageText)) {
this.logLoginDetail('Keine Loginseite erhalten, Session ist bereits gueltig', {
phase: 'login-skip',
url: '/',
});
return; return;
} }
const nonce = this.extractNonce(loginPageText, 'input[name="nonce"]'); const nonce = this.extractNonce(loginPageText, 'input[name="nonce"]');
this.logLoginDetail('Nonce aus Loginseite gelesen', {
phase: 'nonce',
url: '/',
nonce: this.maskNonce(nonce),
});
const loginData = { const loginData = {
username: this.options.username, username: this.options.username,
passwort: this.options.password, passwort: this.options.password,
@@ -700,7 +740,16 @@ export class AgrarmonitorConnector implements AgrarmonitorConnectorResult {
_agrarmonitorLoginRequest: true, _agrarmonitorLoginRequest: true,
}; };
const response = await this.http.post('/login/api/login.php', loginData, loginPostConfig); const response = await this.loggedRequest(
'Login-POST',
'POST',
'/login/api/login.php',
() => this.http.post('/login/api/login.php', loginData, loginPostConfig),
{
contentType: 'application/json',
body: { ...loginData, passwort: '***', nonce: this.maskNonce(nonce) },
}
);
const responseText = typeof response.data === 'string' ? response.data : ''; const responseText = typeof response.data === 'string' ? response.data : '';
@@ -712,8 +761,27 @@ export class AgrarmonitorConnector implements AgrarmonitorConnectorResult {
private async isSessionValid(options: { skipAutoRetry?: boolean } = {}): Promise<boolean> { private async isSessionValid(options: { skipAutoRetry?: boolean } = {}): Promise<boolean> {
try { try {
const response = await this.http.get('/', options.skipAutoRetry ? this.loginRequestConfig() : undefined); const response = await this.http.get('/', options.skipAutoRetry ? this.loginRequestConfig() : undefined);
return !this.isLoginRequiredResponse(response); const grund = this.loginRequiredReason(response);
} catch {
this.logLoginDetail(`Session-Pruefung: ${grund ? 'ungueltig' : 'gueltig'}`, {
phase: 'session-check',
url: '/',
status: response.status,
gueltig: grund === null,
grund,
cookies: this.formatCookies(this.snapshotCookies()),
});
return grund === null;
} catch (error) {
this.logLoginDetail('Session-Pruefung fehlgeschlagen', {
phase: 'session-check',
url: '/',
gueltig: false,
grund: 'request-fehler',
fehler: error instanceof Error ? error.message : String(error),
});
return false; return false;
} }
} }
@@ -725,14 +793,25 @@ export class AgrarmonitorConnector implements AgrarmonitorConnectorResult {
} }
private isLoginRequiredResponse(response: AxiosResponse): boolean { private isLoginRequiredResponse(response: AxiosResponse): boolean {
const responseUrl = this.getResponseUrl(response); return this.loginRequiredReason(response) !== null;
}
/**
* Liefert das Kriterium, an dem eine abgelaufene Session erkannt wurde,
* oder null fuer eine gueltige Session.
*/
private loginRequiredReason(response: AxiosResponse): string | null {
const responseText = typeof response.data === 'string' ? response.data : ''; const responseText = typeof response.data === 'string' ? response.data : '';
return ( if (response.status === 401 || response.status === 403) {
response.status === 401 || return `status-${response.status}`;
response.status === 403 || }
this.isLoginPageText(responseText)
); if (this.isLoginPageText(responseText)) {
return 'login-markup';
}
return null;
} }
private async retryAfterLogin(config: RetryableAxiosRequestConfig): Promise<AxiosResponse> { private async retryAfterLogin(config: RetryableAxiosRequestConfig): Promise<AxiosResponse> {
@@ -742,10 +821,27 @@ export class AgrarmonitorConnector implements AgrarmonitorConnectorResult {
config._agrarmonitorRetry = true; config._agrarmonitorRetry = true;
const method = (config.method ?? 'get').toUpperCase();
const url = config.url ?? '/';
this.logger?.info?.('Agrarmonitor-Session abgelaufen, erneuter Login wird ausgefuehrt'); this.logger?.info?.('Agrarmonitor-Session abgelaufen, erneuter Login wird ausgefuehrt');
this.logLoginDetail(`Session abgelaufen, wiederhole ${method} ${url} nach erneutem Login`, {
phase: 'session-expired',
method,
url,
cookies: this.formatCookies(this.snapshotCookies()),
});
await this.login(); await this.login();
return this.http.request(config); return this.loggedRequest(
'Wiederholung nach Login',
method,
url,
() => this.http.request(config),
{},
'retry'
);
} }
private createDateienLivesearchParams(suchstring: string): Record<string, string | number> { private createDateienLivesearchParams(suchstring: string): Record<string, string | number> {
@@ -991,4 +1087,148 @@ export class AgrarmonitorConnector implements AgrarmonitorConnectorResult {
private maskNonce(nonce: string): string { private maskNonce(nonce: string): string {
return nonce.length <= 10 ? nonce : `${nonce.slice(0, 10)}...`; return nonce.length <= 10 ? nonce : `${nonce.slice(0, 10)}...`;
} }
private get debugLogging(): boolean {
return typeof this.logger?.debug === 'function';
}
/**
* Detail-Logging des Login-Flows. Anders als logDebug faellt das bewusst
* NICHT auf info zurueck - ohne debug-Logger bleiben die Details still.
*/
private logLoginDetail(message: string, meta: Record<string, unknown>): void {
this.logger?.debug?.(message, meta);
}
private maskValue(value: string): string {
if (!value) {
return '(leer)';
}
return value.length <= 8 ? `\u2026(len=${value.length})` : `${value.slice(0, 4)}\u2026(len=${value.length})`;
}
/**
* Liest die Cookies des Jars fuer eine URL aus. Liefert eine leere Liste,
* solange kein debug-Logger haengt - dann wird gar nicht erst serialisiert.
*/
private snapshotCookies(url = this.baseUrl): CookieRecord[] {
if (!this.debugLogging) {
return [];
}
try {
return this.cookieJar.getCookiesSync(url).map(cookie => ({
name: cookie.key,
value: cookie.value,
domain: cookie.domain ?? null,
path: cookie.path ?? null,
expires: cookie.expires instanceof Date ? cookie.expires.toISOString() : 'Session',
httpOnly: Boolean(cookie.httpOnly),
secure: Boolean(cookie.secure),
}));
} catch {
return [];
}
}
/**
* Maskiert die Werte im rohen Set-Cookie-Header - sonst stuende die neue
* Session-ID im Klartext im Log, obwohl der Jar-Snapshot sie maskiert.
*/
private formatSetCookieHeader(header: unknown): string[] | null {
if (!Array.isArray(header)) {
return null;
}
return header.map(entry => {
const [pair, ...attributes] = String(entry).split(';');
const separator = pair.indexOf('=');
if (separator === -1) {
return [pair, ...attributes].join(';');
}
const name = pair.slice(0, separator);
const value = pair.slice(separator + 1);
return [`${name}=${this.maskValue(value)}`, ...attributes].join(';');
});
}
/** Wie snapshotCookies, aber mit maskierten Werten - nur das geht ins Log. */
private formatCookies(cookies: CookieRecord[]): CookieRecord[] {
return cookies.map(cookie => ({ ...cookie, value: this.maskValue(cookie.value) }));
}
private diffCookies(before: CookieRecord[], after: CookieRecord[]): CookieChanges {
const beforeByName = new Map(before.map(cookie => [cookie.name, cookie.value]));
const afterByName = new Map(after.map(cookie => [cookie.name, cookie.value]));
return {
added: [...afterByName.keys()].filter(name => !beforeByName.has(name)),
changed: [...afterByName.entries()]
.filter(([name, value]) => beforeByName.has(name) && beforeByName.get(name) !== value)
.map(([name]) => name),
removed: [...beforeByName.keys()].filter(name => !afterByName.has(name)),
};
}
/**
* Klammert einen Request mit je einem Cookie-Snapshot davor und danach.
* Der Snapshot davor wird vor dem Absenden geloggt, damit er auch dann
* im Log steht, wenn der Request haengt oder wirft.
*/
private async loggedRequest(
label: string,
method: string,
url: string,
exec: () => Promise<AxiosResponse>,
details: Record<string, unknown> = {},
phase: 'request' | 'retry' = 'request'
): Promise<AxiosResponse> {
const before = this.snapshotCookies();
this.logLoginDetail(`${label}: ${method} ${url}`, {
phase,
method,
url,
cookies: this.formatCookies(before),
...details,
});
try {
const response = await exec();
const after = this.snapshotCookies();
const responseText = typeof response.data === 'string' ? response.data : '';
this.logLoginDetail(`${label}: ${method} ${url} -> ${response.status}`, {
phase: 'response',
method,
url,
status: response.status,
responseUrl: this.getResponseUrl(response) || null,
contentType: this.getHeader(response, 'content-type'),
setCookie: this.formatSetCookieHeader(response.headers['set-cookie']),
istLoginSeite: this.isLoginPageText(responseText),
cookies: this.formatCookies(after),
cookieChanges: this.diffCookies(before, after),
});
return response;
} catch (error) {
const after = this.snapshotCookies();
this.logLoginDetail(`${label}: ${method} ${url} -> Fehler`, {
phase: 'error',
method,
url,
fehler: error instanceof Error ? error.message : String(error),
cookies: this.formatCookies(after),
cookieChanges: this.diffCookies(before, after),
});
throw error;
}
}
} }
+183
View File
@@ -0,0 +1,183 @@
const assert = require('node:assert/strict');
const http = require('node:http');
const test = require('node:test');
const { AgrarmonitorConnector, MemoryCookieStore } = require('../dist');
const PASSWORT = 'sup3rgeheim-passwort';
const SESSION_VALUE = 'abcdef0123456789abcdef';
const loginPage = `
<html>
<head><title>Anmeldung - AGRARMONITOR</title></head>
<body>
<form>
<input name="nonce" value="test-nonce" />
<button>Einloggen</button>
</form>
</body>
</html>
`;
function createServer() {
let loggedIn = false;
const requests = [];
const server = http.createServer((req, res) => {
requests.push({ method: req.method, url: req.url });
if (req.method === 'GET' && req.url === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(loggedIn ? '<html><body>Mein AM</body></html>' : loginPage);
return;
}
// Laeuft die Session ab, liefert die Seite wieder das Login-Markup aus.
if (req.method === 'GET' && req.url === '/geschuetzt') {
if (!loggedIn) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(loginPage);
return;
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end('<html><body>Geschuetzter Inhalt</body></html>');
return;
}
if (req.method === 'POST' && req.url === '/login/api/login.php') {
let body = '';
req.on('data', chunk => {
body += chunk;
});
req.on('end', () => {
loggedIn = true;
res.writeHead(200, {
'Content-Type': 'application/json',
'Set-Cookie': `agrar_session=${SESSION_VALUE}; Path=/`,
});
res.end(JSON.stringify({ success: true }));
});
return;
}
res.writeHead(404);
res.end('not found');
});
return {
server,
requests,
expireSession() {
loggedIn = false;
},
};
}
function createRecordingLogger() {
const entries = [];
const record = level => (message, meta) => entries.push({ level, message, meta });
return {
entries,
debug: record('debug'),
info: record('info'),
warn: record('warn'),
error: record('error'),
debugEntries: () => entries.filter(entry => entry.level === 'debug'),
text: () => entries.map(entry => `${entry.message} ${JSON.stringify(entry.meta ?? null)}`).join('\n'),
};
}
async function startConnector(t, { logger } = {}) {
const harness = createServer();
await new Promise(resolve => harness.server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise(resolve => harness.server.close(resolve)));
const connector = new AgrarmonitorConnector({
baseUrl: `http://127.0.0.1:${harness.server.address().port}`,
username: 'demo',
password: PASSWORT,
cookieStore: new MemoryCookieStore(),
logger,
});
await connector.init();
return { connector, ...harness };
}
function loginPostEntries(logger) {
return logger.debugEntries().filter(entry => entry.meta?.url === '/login/api/login.php');
}
test('logs cookie snapshots before and after the login request', async t => {
const logger = createRecordingLogger();
await startConnector(t, { logger });
const [request, response, ...rest] = loginPostEntries(logger);
assert.equal(rest.length, 0, 'genau ein Request- und ein Response-Eintrag fuer den Login-POST');
assert.equal(request?.meta.phase, 'request');
assert.equal(request?.meta.method, 'POST');
assert.ok(Array.isArray(request?.meta.cookies), 'Cookies vor dem Request werden protokolliert');
assert.equal(response?.meta.phase, 'response');
assert.equal(response?.meta.status, 200);
const cookieNames = response.meta.cookies.map(cookie => cookie.name);
assert.ok(cookieNames.includes('agrar_session'), 'Cookies nach dem Request enthalten die neue Session');
assert.deepEqual(response.meta.cookieChanges.added, ['agrar_session']);
});
test('masks cookie values and never logs the password', async t => {
const logger = createRecordingLogger();
await startConnector(t, { logger });
const text = logger.text();
assert.ok(!text.includes(PASSWORT), 'das Passwort darf in keinem Log-Eintrag stehen');
assert.ok(!text.includes(SESSION_VALUE), 'der volle Cookie-Wert darf nicht im Log stehen');
const response = loginPostEntries(logger).at(-1);
const session = response.meta.cookies.find(cookie => cookie.name === 'agrar_session');
assert.equal(session.value, `abcd…(len=${SESSION_VALUE.length})`);
assert.equal(session.path, '/');
});
test('logs the re-login triggered by an expired session', async t => {
const logger = createRecordingLogger();
const { connector, expireSession } = await startConnector(t, { logger });
logger.entries.length = 0;
expireSession();
const response = await connector.http.get('/geschuetzt');
assert.equal(response.status, 200);
assert.match(response.data, /Geschuetzter Inhalt/);
const retry = logger
.debugEntries()
.find(entry => entry.meta?.phase === 'retry' && entry.meta?.url === '/geschuetzt');
assert.ok(retry, 'der wiederholte Request wird protokolliert');
assert.equal(retry.meta.method, 'GET');
assert.ok(Array.isArray(retry.meta.cookies), 'Cookies des Wiederholungs-Requests werden protokolliert');
assert.ok(loginPostEntries(logger).length >= 2, 'der erneute Login wird ebenfalls protokolliert');
});
test('stays silent when the logger has no debug method', async t => {
const entries = [];
const logger = { info: (message, meta) => entries.push({ message, meta }) };
await startConnector(t, { logger });
assert.ok(entries.length > 0, 'info-Meldungen bleiben erhalten');
assert.ok(
entries.every(entry => entry.meta?.phase === undefined),
'ohne debug-Methode werden keine Request-Details ausgegeben'
);
});