diff --git a/documentation/sap-business-one-service-layer.md b/documentation/sap-business-one-service-layer.md index ffccbaf..5fccb92 100644 --- a/documentation/sap-business-one-service-layer.md +++ b/documentation/sap-business-one-service-layer.md @@ -31,3 +31,27 @@ HTTP API hlavičky: - `X-SAP-B1-Language` volitelně - `X-SAP-B1-Reject-Unauthorized` volitelně - `X-SAP-B1-Timeout-Ms` volitelně + +## Detaily komunikace se Service Layer + +- **Login** – `POST /b1s/v1/Login` s `{ CompanyDB, UserName, Password, Language? }`. + `Language` se posílá jako celé číslo (Service Layer očekává `Edm.Int32`); nenumerická + hodnota se vynechá. Z odpovědi se čtou cookies `B1SESSION` a `ROUTEID` (s fallbackem na + `SessionId` v body) a `SessionTimeout` (minuty) pro výpočet expirace. +- **Autentizované requesty** – posílají `Cookie: B1SESSION=…; ROUTEID=…`. Při `401` + connector jednou provede re-login a request zopakuje; re-login nesnižuje retry budget, + takže funguje i při `SAP_B1_RETRY_COUNT=0`. +- **Logout** – `POST /b1s/v1/Logout` se posílá **s aktivní session cookie**, jinak by + Service Layer nevěděl, kterou session ukončit, a nechal by ji běžet až do timeoutu. +- **Stránkování** – následuje `odata.nextLink` i `@odata.nextLink`. U absolutních + nextLinků se zachová i query string (`$skip` apod.), takže `listAll` nezacyklí. + +## Reverse proxy a Swagger + +Aplikace běží za AppFactory proxy na `/apps/` (Caddy `handle_path` prefix před +předáním do containeru odstraní). Proto: + +- OpenAPI `servers` obsahuje `ROOT_PATH` (`/apps/`), takže Swagger `Try it out` + volá `…/apps//api/`. +- Swagger UI načítá OpenAPI dokument z `ROOT_PATH + /openapi.json`, ne z kořene domény. +- Lokálně bez `ROOT_PATH` se vše chová relativně ke kořeni (`/openapi.json`, server `/`). diff --git a/src/auth/SessionManager.ts b/src/auth/SessionManager.ts index 400ebf6..9b16483 100644 --- a/src/auth/SessionManager.ts +++ b/src/auth/SessionManager.ts @@ -55,7 +55,8 @@ export class SessionManager { CompanyDB: this.config.companyDB, UserName: this.config.username, Password: this.config.password, - Language: this.config.language + // Service Layer expects Language as an integer language code (Edm.Int32). + Language: this.normalizeLanguage(this.config.language) }, { skipAuth: true } ); @@ -90,8 +91,15 @@ export class SessionManager { return; } + const cookieHeader = this.buildCookieHeader(this.session); + try { - await this.http.post("/Logout", undefined, { skipAuth: false }); + // Logout must carry the active session cookie, otherwise the Service Layer + // cannot identify which session to terminate and leaves it open until timeout. + await this.http.post("/Logout", undefined, { + skipAuth: true, + headers: { Cookie: cookieHeader } + }); } finally { this.session = undefined; } @@ -111,6 +119,15 @@ export class SessionManager { this.session = undefined; } + private normalizeLanguage(language: string | undefined): number | undefined { + if (language === undefined || language === "") { + return undefined; + } + + const numeric = Number(language); + return Number.isInteger(numeric) ? numeric : undefined; + } + private isExpired(session: SapB1Session): boolean { return session.expiresAt !== undefined && session.expiresAt <= Date.now(); } diff --git a/src/client/SapB1Client.ts b/src/client/SapB1Client.ts index 06e200e..eaa1b37 100644 --- a/src/client/SapB1Client.ts +++ b/src/client/SapB1Client.ts @@ -91,6 +91,8 @@ export class SapB1Client { } }; + let reloginAttempted = false; + for (let attempt = 0; attempt <= this.config.retryCount; attempt += 1) { try { if (!requestConfig.skipAuth) { @@ -106,11 +108,15 @@ export class SapB1Client { return options.schema ? options.schema.parse(response.data) : (response.data as T); } catch (error) { const sapError = this.toSapB1Error(error, method, path); - const canRelogin = sapError.status === 401 && !requestConfig.skipAuth && attempt === 0; + const canRelogin = sapError.status === 401 && !requestConfig.skipAuth && !reloginAttempted; if (canRelogin) { + // A single re-login on session expiry must not eat into the retry budget, + // so step the counter back and retry the request with a fresh session. + reloginAttempted = true; this.sessionManager.clearSession(); await this.sessionManager.getSession(true); + attempt -= 1; continue; } @@ -162,7 +168,7 @@ export class SapB1Client { private normalizeNextLink(nextLink: string): string { try { const parsed = new URL(nextLink); - return parsed.pathname.replace(/^.*\/b1s\/v1\/?/, ""); + return parsed.pathname.replace(/^.*\/b1s\/v1\/?/, "") + parsed.search; } catch { return nextLink.replace(/^\/?b1s\/v1\/?/, ""); } diff --git a/src/index.ts b/src/index.ts index d98c8b2..7b377cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -610,7 +610,12 @@ app.get("/openapi.json", (_req, res) => { addResourceEndpoints(); -app.use("/docs", swaggerUi.serveFiles(undefined, swaggerOptions("")), swaggerUi.setup(undefined, swaggerOptions(""))); +// Behind the AppFactory reverse proxy the container is reached via /apps/ and +// the prefix is stripped (handle_path) before requests arrive here. The Swagger UI page is +// therefore served at the public /apps//docs, so the OpenAPI document URL and the +// "Try it out" base path must carry ROOT_PATH; otherwise the browser would resolve them +// against the bare origin and bypass the app. +app.use("/docs", swaggerUi.serveFiles(undefined, swaggerOptions(rootPath)), swaggerUi.setup(undefined, swaggerOptions(rootPath))); if (rootPath) { app.get(rootPath, (_req, res) => { diff --git a/tests/auth.test.ts b/tests/auth.test.ts index fa37f2b..b8eb84a 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -42,6 +42,29 @@ describe("SessionManager", () => { expect(sessionManager.buildCookieHeader(session)).toBe("B1SESSION=abc123; ROUTEID=.node1"); }); + it("sends the language as an integer code to the Login action", async () => { + const http = { + post: vi.fn().mockResolvedValue({ + data: { SessionId: "abc123", SessionTimeout: 30 }, + headers: {} + }) + }; + + const sessionManager = new SessionManager(http as never, { ...config, language: "3" }); + await sessionManager.getSession(); + + expect(http.post).toHaveBeenCalledWith( + "/Login", + { + CompanyDB: "SBODEMOUS", + UserName: "manager", + Password: "secret", + Language: 3 + }, + { skipAuth: true } + ); + }); + it("logs out and clears the current session", async () => { const http = { post: vi @@ -57,7 +80,10 @@ describe("SessionManager", () => { await sessionManager.getSession(); await sessionManager.logout(); - expect(http.post).toHaveBeenLastCalledWith("/Logout", undefined, { skipAuth: false }); + expect(http.post).toHaveBeenLastCalledWith("/Logout", undefined, { + skipAuth: true, + headers: { Cookie: "B1SESSION=abc123" } + }); expect(sessionManager.currentSession).toBeUndefined(); }); }); diff --git a/tests/client.test.ts b/tests/client.test.ts index e4377a3..0efe2f1 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -84,4 +84,71 @@ describe("SapB1Client", () => { expect(loginCalls).toBe(2); expect(resourceCalls).toBe(2); }); + + it("re-logins after a 401 even when retries are disabled", async () => { + let resourceCalls = 0; + let loginCalls = 0; + const http = axios.create({ + adapter: async (requestConfig) => { + if (requestConfig.url === "/Login") { + loginCalls += 1; + return { + ...response({ SessionId: `session-${loginCalls}`, SessionTimeout: 30 }, requestConfig), + headers: { "set-cookie": [`B1SESSION=session-${loginCalls}; Path=/b1s/v1`] } + }; + } + + resourceCalls += 1; + + if (resourceCalls === 1) { + return Promise.reject({ + isAxiosError: true, + message: "Unauthorized", + response: { status: 401, data: { error: { message: { value: "Session expired" } } } } + }); + } + + return response({ value: [] }, requestConfig); + } + }); + + const client = new SapB1Client({ ...config, retryCount: 0 }, { http }); + await expect(client.get("Items")).resolves.toEqual({ value: [] }); + expect(loginCalls).toBe(2); + expect(resourceCalls).toBe(2); + }); + + it("follows an absolute nextLink while preserving its query string", async () => { + const requestedUrls: string[] = []; + const http = axios.create({ + adapter: async (requestConfig) => { + if (requestConfig.url === "/Login") { + return { + ...response({ SessionId: "abc123", SessionTimeout: 30 }, requestConfig), + headers: { "set-cookie": ["B1SESSION=abc123; Path=/b1s/v1"] } + }; + } + + requestedUrls.push(String(requestConfig.url)); + + if (requestConfig.url === "Items") { + return response( + { + value: [{ ItemCode: "A1" }], + "odata.nextLink": "https://sap.example.local:50000/b1s/v1/Items?$skip=20" + }, + requestConfig + ); + } + + return response({ value: [{ ItemCode: "A2" }] }, requestConfig); + } + }); + + const client = new SapB1Client(config, { http }); + const all = await client.getAll<{ ItemCode: string }>("Items"); + + expect(all).toEqual([{ ItemCode: "A1" }, { ItemCode: "A2" }]); + expect(requestedUrls).toEqual(["Items", "Items?$skip=20"]); + }); });