auth fix
This commit is contained in:
@@ -2,7 +2,7 @@ import "../client/axiosTypes";
|
|||||||
import { AxiosInstance } from "axios";
|
import { AxiosInstance } from "axios";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { SapB1Config } from "../config";
|
import { SapB1Config } from "../config";
|
||||||
import { SapB1Error } from "../errors/SapB1Error";
|
import { SapB1Error, toSapB1Error } from "../errors/SapB1Error";
|
||||||
import { SapB1Logger, noopLogger, sanitizeLogMeta } from "../utils/logger";
|
import { SapB1Logger, noopLogger, sanitizeLogMeta } from "../utils/logger";
|
||||||
|
|
||||||
const LoginResponseSchema = z.object({
|
const LoginResponseSchema = z.object({
|
||||||
@@ -78,11 +78,22 @@ export class SessionManager {
|
|||||||
|
|
||||||
return this.session;
|
return this.session;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SapB1Error) {
|
// Route login failures through the shared normalizer so the SAP status/code and
|
||||||
throw error;
|
// 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).
|
||||||
throw new SapB1Error({ message: "SAP B1 login failed", cause: error });
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import "./axiosTypes";
|
import "./axiosTypes";
|
||||||
import https from "node:https";
|
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 { z } from "zod";
|
||||||
import { SessionManager } from "../auth/SessionManager";
|
import { SessionManager } from "../auth/SessionManager";
|
||||||
import { SapB1Config, SapB1ConfigSchema } from "../config";
|
import { SapB1Config, SapB1ConfigSchema } from "../config";
|
||||||
import { SapB1Error } from "../errors/SapB1Error";
|
import { SapB1Error, toSapB1Error } from "../errors/SapB1Error";
|
||||||
import { ODataQuery, ODataResponse } from "../types/odata";
|
import { ODataQuery, ODataResponse } from "../types/odata";
|
||||||
import { buildODataParams, extractNextLink } from "./odata";
|
import { buildODataParams, extractNextLink } from "./odata";
|
||||||
import { SapB1Logger, noopLogger, sanitizeLogMeta } from "../utils/logger";
|
import { SapB1Logger, noopLogger, sanitizeLogMeta } from "../utils/logger";
|
||||||
@@ -107,7 +107,7 @@ export class SapB1Client {
|
|||||||
const response = await this.http.request(requestConfig);
|
const response = await this.http.request(requestConfig);
|
||||||
return options.schema ? options.schema.parse(response.data) : (response.data as T);
|
return options.schema ? options.schema.parse(response.data) : (response.data as T);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const sapError = this.toSapB1Error(error, method, path);
|
const sapError = toSapB1Error(error, method, path);
|
||||||
const canRelogin = sapError.status === 401 && !requestConfig.skipAuth && !reloginAttempted;
|
const canRelogin = sapError.status === 401 && !requestConfig.skipAuth && !reloginAttempted;
|
||||||
|
|
||||||
if (canRelogin) {
|
if (canRelogin) {
|
||||||
@@ -132,34 +132,6 @@ export class SapB1Client {
|
|||||||
throw new SapB1Error({ message: "SAP B1 request failed after retries", method, url: path });
|
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 {
|
private buildServiceLayerBaseUrl(baseUrl: string): string {
|
||||||
const trimmed = baseUrl.replace(/\/+$/, "");
|
const trimmed = baseUrl.replace(/\/+$/, "");
|
||||||
return trimmed.endsWith("/b1s/v1") ? trimmed : `${trimmed}/b1s/v1`;
|
return trimmed.endsWith("/b1s/v1") ? trimmed : `${trimmed}/b1s/v1`;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import axios, { AxiosError, Method } from "axios";
|
||||||
|
|
||||||
export interface SapB1ErrorDetails {
|
export interface SapB1ErrorDetails {
|
||||||
status?: number;
|
status?: number;
|
||||||
code?: string;
|
code?: string;
|
||||||
@@ -27,3 +29,40 @@ export class SapB1Error extends Error {
|
|||||||
this.cause = details.cause;
|
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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { AxiosError } from "axios";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { SessionManager } from "../src/auth/SessionManager";
|
import { SessionManager } from "../src/auth/SessionManager";
|
||||||
import { SapB1Config } from "../src/config";
|
import { SapB1Config } from "../src/config";
|
||||||
|
import { SapB1Error } from "../src/errors/SapB1Error";
|
||||||
|
|
||||||
const config: SapB1Config = {
|
const config: SapB1Config = {
|
||||||
baseUrl: "https://sap.example.local:50000",
|
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 () => {
|
it("logs out and clears the current session", async () => {
|
||||||
const http = {
|
const http = {
|
||||||
post: vi
|
post: vi
|
||||||
|
|||||||
Reference in New Issue
Block a user