From a8279a8d5cf56c9ee64df136ec63d7c2641aba04 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:04:31 +0200 Subject: [PATCH] auth fix --- src/auth/SessionManager.ts | 23 ++++++++++++++++------ src/client/SapB1Client.ts | 34 +++------------------------------ src/errors/SapB1Error.ts | 39 ++++++++++++++++++++++++++++++++++++++ tests/auth.test.ts | 34 +++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 37 deletions(-) diff --git a/src/auth/SessionManager.ts b/src/auth/SessionManager.ts index 9b16483..befb1ca 100644 --- a/src/auth/SessionManager.ts +++ b/src/auth/SessionManager.ts @@ -2,7 +2,7 @@ import "../client/axiosTypes"; import { AxiosInstance } from "axios"; import { z } from "zod"; import { SapB1Config } from "../config"; -import { SapB1Error } from "../errors/SapB1Error"; +import { SapB1Error, toSapB1Error } from "../errors/SapB1Error"; import { SapB1Logger, noopLogger, sanitizeLogMeta } from "../utils/logger"; const LoginResponseSchema = z.object({ @@ -78,11 +78,22 @@ export class SessionManager { return this.session; } catch (error) { - if (error instanceof SapB1Error) { - throw error; - } - - throw new SapB1Error({ message: "SAP B1 login failed", cause: error }); + // Route login failures through the shared normalizer so the SAP status/code and + // rejection message survive into the response. That is what lets the caller tell a + // SAP-side rejection (wrong CompanyDB/user/password -> HTTP 401/400 with a SAP body) + // apart from a transport problem (wrong BaseUrl/port, TLS, timeout -> no HTTP status). + const sapError = toSapB1Error(error, "POST", "/Login", "SAP B1 login failed"); + this.logger.error( + "SAP B1 login failed", + sanitizeLogMeta({ + baseUrl: this.config.baseUrl, + companyDB: this.config.companyDB, + status: sapError.status, + code: sapError.code, + message: sapError.message + }) + ); + throw sapError; } } diff --git a/src/client/SapB1Client.ts b/src/client/SapB1Client.ts index eaa1b37..ef02a70 100644 --- a/src/client/SapB1Client.ts +++ b/src/client/SapB1Client.ts @@ -1,10 +1,10 @@ import "./axiosTypes"; import https from "node:https"; -import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, Method } from "axios"; +import axios, { AxiosInstance, AxiosRequestConfig, Method } from "axios"; import { z } from "zod"; import { SessionManager } from "../auth/SessionManager"; import { SapB1Config, SapB1ConfigSchema } from "../config"; -import { SapB1Error } from "../errors/SapB1Error"; +import { SapB1Error, toSapB1Error } from "../errors/SapB1Error"; import { ODataQuery, ODataResponse } from "../types/odata"; import { buildODataParams, extractNextLink } from "./odata"; import { SapB1Logger, noopLogger, sanitizeLogMeta } from "../utils/logger"; @@ -107,7 +107,7 @@ export class SapB1Client { const response = await this.http.request(requestConfig); return options.schema ? options.schema.parse(response.data) : (response.data as T); } catch (error) { - const sapError = this.toSapB1Error(error, method, path); + const sapError = toSapB1Error(error, method, path); const canRelogin = sapError.status === 401 && !requestConfig.skipAuth && !reloginAttempted; if (canRelogin) { @@ -132,34 +132,6 @@ export class SapB1Client { throw new SapB1Error({ message: "SAP B1 request failed after retries", method, url: path }); } - private toSapB1Error(error: unknown, method: Method, path: string): SapB1Error { - if (error instanceof SapB1Error) { - return error; - } - - if (axios.isAxiosError(error)) { - const axiosError = error as AxiosError<{ error?: { code?: string; message?: { value?: string } | string } }>; - const status = axiosError.response?.status; - const sapError = axiosError.response?.data?.error; - const message = - typeof sapError?.message === "string" - ? sapError.message - : sapError?.message?.value || axiosError.message || "SAP B1 request failed"; - - return new SapB1Error({ - status, - code: sapError?.code, - message, - method, - url: path, - retryable: status === 408 || status === 429 || (status !== undefined && status >= 500), - cause: error - }); - } - - return new SapB1Error({ message: "SAP B1 request failed", method, url: path, cause: error }); - } - private buildServiceLayerBaseUrl(baseUrl: string): string { const trimmed = baseUrl.replace(/\/+$/, ""); return trimmed.endsWith("/b1s/v1") ? trimmed : `${trimmed}/b1s/v1`; diff --git a/src/errors/SapB1Error.ts b/src/errors/SapB1Error.ts index 1eea8a6..dd63638 100644 --- a/src/errors/SapB1Error.ts +++ b/src/errors/SapB1Error.ts @@ -1,3 +1,5 @@ +import axios, { AxiosError, Method } from "axios"; + export interface SapB1ErrorDetails { status?: number; code?: string; @@ -27,3 +29,40 @@ export class SapB1Error extends Error { this.cause = details.cause; } } + +// Shared normalizer so every SAP call (including login) surfaces the SAP status/code +// and message when the Service Layer actively rejected the request, and falls back to +// the transport-level message (ECONNREFUSED, timeout, TLS, ...) otherwise. This is what +// lets a caller tell "SAP rejected it" apart from "we never reached SAP". +export function toSapB1Error( + error: unknown, + method?: Method, + path?: string, + fallbackMessage = "SAP B1 request failed" +): SapB1Error { + if (error instanceof SapB1Error) { + return error; + } + + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError<{ error?: { code?: string; message?: { value?: string } | string } }>; + const status = axiosError.response?.status; + const sapError = axiosError.response?.data?.error; + const message = + typeof sapError?.message === "string" + ? sapError.message + : sapError?.message?.value || axiosError.message || fallbackMessage; + + return new SapB1Error({ + status, + code: sapError?.code, + message, + method, + url: path, + retryable: status === 408 || status === 429 || (status !== undefined && status >= 500), + cause: error + }); + } + + return new SapB1Error({ message: fallbackMessage, method, url: path, cause: error }); +} diff --git a/tests/auth.test.ts b/tests/auth.test.ts index b8eb84a..ec24aee 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -1,6 +1,8 @@ +import { AxiosError } from "axios"; import { describe, expect, it, vi } from "vitest"; import { SessionManager } from "../src/auth/SessionManager"; import { SapB1Config } from "../src/config"; +import { SapB1Error } from "../src/errors/SapB1Error"; const config: SapB1Config = { baseUrl: "https://sap.example.local:50000", @@ -65,6 +67,38 @@ describe("SessionManager", () => { ); }); + it("surfaces the SAP status, code and message when SAP rejects the login", async () => { + const rejection = new AxiosError("Request failed with status code 401", "ERR_BAD_REQUEST"); + rejection.response = { + status: 401, + statusText: "Unauthorized", + headers: {}, + config: {} as never, + data: { error: { code: "301", message: { value: "Invalid company database" } } } + }; + const http = { post: vi.fn().mockRejectedValue(rejection) }; + + const sessionManager = new SessionManager(http as never, config); + const error = await sessionManager.getSession().catch((e) => e); + + expect(error).toBeInstanceOf(SapB1Error); + expect(error.status).toBe(401); + expect(error.code).toBe("301"); + expect(error.message).toBe("Invalid company database"); + }); + + it("keeps the generic login message for a transport error with no HTTP response", async () => { + const transportError = new AxiosError("connect ECONNREFUSED 10.0.0.1:50000", "ECONNREFUSED"); + const http = { post: vi.fn().mockRejectedValue(transportError) }; + + const sessionManager = new SessionManager(http as never, config); + const error = await sessionManager.getSession().catch((e) => e); + + expect(error).toBeInstanceOf(SapB1Error); + expect(error.status).toBeUndefined(); + expect(error.message).toBe("connect ECONNREFUSED 10.0.0.1:50000"); + }); + it("logs out and clears the current session", async () => { const http = { post: vi