first
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
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",
|
||||
companyDB: "SBODEMOUS",
|
||||
username: "manager",
|
||||
password: "secret",
|
||||
timeout: 30_000,
|
||||
rejectUnauthorized: true,
|
||||
retryCount: 2,
|
||||
retryDelayMs: 0
|
||||
};
|
||||
|
||||
describe("SessionManager", () => {
|
||||
it("logs in and stores B1SESSION and ROUTEID cookies", async () => {
|
||||
const http = {
|
||||
post: vi.fn().mockResolvedValue({
|
||||
data: { SessionId: "fallback", SessionTimeout: 30 },
|
||||
headers: {
|
||||
"set-cookie": ["B1SESSION=abc123; Path=/b1s/v1", "ROUTEID=.node1; Path=/b1s/v1"]
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const sessionManager = new SessionManager(http as never, config);
|
||||
const session = await sessionManager.getSession();
|
||||
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
"/Login",
|
||||
{
|
||||
CompanyDB: "SBODEMOUS",
|
||||
UserName: "manager",
|
||||
Password: "secret",
|
||||
Language: undefined
|
||||
},
|
||||
{ skipAuth: true }
|
||||
);
|
||||
expect(session.b1session).toBe("abc123");
|
||||
expect(session.routeId).toBe(".node1");
|
||||
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("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
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
data: { SessionId: "abc123" },
|
||||
headers: {}
|
||||
})
|
||||
.mockResolvedValueOnce({ data: {}, headers: {} })
|
||||
};
|
||||
|
||||
const sessionManager = new SessionManager(http as never, config);
|
||||
await sessionManager.getSession();
|
||||
await sessionManager.logout();
|
||||
|
||||
expect(http.post).toHaveBeenLastCalledWith("/Logout", undefined, {
|
||||
skipAuth: true,
|
||||
headers: { Cookie: "B1SESSION=abc123" }
|
||||
});
|
||||
expect(sessionManager.currentSession).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import axios, { AxiosAdapter, AxiosResponse } from "axios";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SapB1Client } from "../src/client/SapB1Client";
|
||||
import { SapB1Config } from "../src/config";
|
||||
|
||||
const config: SapB1Config = {
|
||||
baseUrl: "https://sap.example.local:50000",
|
||||
companyDB: "SBODEMOUS",
|
||||
username: "manager",
|
||||
password: "secret",
|
||||
timeout: 30_000,
|
||||
rejectUnauthorized: true,
|
||||
retryCount: 1,
|
||||
retryDelayMs: 0
|
||||
};
|
||||
|
||||
function response(data: unknown, requestConfig: Parameters<AxiosAdapter>[0], status = 200): AxiosResponse {
|
||||
return {
|
||||
data,
|
||||
status,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config: requestConfig
|
||||
};
|
||||
}
|
||||
|
||||
describe("SapB1Client", () => {
|
||||
it("adds session cookies to resource requests", async () => {
|
||||
const urls: string[] = [];
|
||||
const cookies: unknown[] = [];
|
||||
const http = axios.create({
|
||||
adapter: async (requestConfig) => {
|
||||
urls.push(String(requestConfig.url));
|
||||
cookies.push(requestConfig.headers?.Cookie);
|
||||
|
||||
if (requestConfig.url === "/Login") {
|
||||
return {
|
||||
...response({ SessionId: "abc123", SessionTimeout: 30 }, requestConfig),
|
||||
headers: { "set-cookie": ["B1SESSION=abc123; Path=/b1s/v1", "ROUTEID=.node1; Path=/b1s/v1"] }
|
||||
};
|
||||
}
|
||||
|
||||
return response({ value: [{ CardCode: "C001" }] }, requestConfig);
|
||||
}
|
||||
});
|
||||
|
||||
const client = new SapB1Client(config, { http });
|
||||
const result = await client.get("BusinessPartners", { top: 1 });
|
||||
|
||||
expect(result).toEqual({ value: [{ CardCode: "C001" }] });
|
||||
expect(urls).toEqual(["/Login", "BusinessPartners"]);
|
||||
expect(cookies[1]).toBe("B1SESSION=abc123; ROUTEID=.node1");
|
||||
});
|
||||
|
||||
it("re-logins once after a 401 response", 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, { http });
|
||||
await expect(client.get("Items")).resolves.toEqual({ value: [] });
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildODataParams, extractNextLink } from "../src/client/odata";
|
||||
|
||||
describe("OData helpers", () => {
|
||||
it("builds SAP Service Layer OData query parameters", () => {
|
||||
expect(
|
||||
buildODataParams({
|
||||
select: ["CardCode", "CardName"],
|
||||
filter: "CardType eq 'cCustomer'",
|
||||
top: 25,
|
||||
skip: 50,
|
||||
orderby: ["CardName asc", "CardCode desc"]
|
||||
})
|
||||
).toEqual({
|
||||
$select: "CardCode,CardName",
|
||||
$filter: "CardType eq 'cCustomer'",
|
||||
$top: 25,
|
||||
$skip: 50,
|
||||
$orderby: "CardName asc,CardCode desc"
|
||||
});
|
||||
});
|
||||
|
||||
it("supports old and new nextLink field names", () => {
|
||||
expect(extractNextLink({ "@odata.nextLink": "Items?$skip=20" })).toBe("Items?$skip=20");
|
||||
expect(extractNextLink({ "odata.nextLink": "Items?$skip=40" })).toBe("Items?$skip=40");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BusinessPartnersResource } from "../src/resources/BusinessPartners";
|
||||
import { OrdersResource } from "../src/resources/Documents";
|
||||
|
||||
describe("resources", () => {
|
||||
it("formats string keys for SAP Service Layer entity URLs", async () => {
|
||||
const client = {
|
||||
get: vi.fn().mockResolvedValue({ CardCode: "C'001" })
|
||||
};
|
||||
const resource = new BusinessPartnersResource(client as never);
|
||||
|
||||
await resource.get("C'001");
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith("BusinessPartners('C''001')", undefined, expect.anything());
|
||||
});
|
||||
|
||||
it("blocks delete for marketing documents by default", async () => {
|
||||
const resource = new OrdersResource({} as never);
|
||||
|
||||
await expect(() => resource.delete(1)).toThrow("Orders does not support delete through this connector");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import axios, { AxiosAdapter, AxiosResponse } from "axios";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SapBusinessOneServiceLayer } from "../src/SapBusinessOneServiceLayer";
|
||||
import { SapB1Config } from "../src/config";
|
||||
|
||||
const config: SapB1Config = {
|
||||
baseUrl: "https://sap.example.local:50000",
|
||||
companyDB: "SBODEMOUS",
|
||||
username: "manager",
|
||||
password: "secret",
|
||||
timeout: 30_000,
|
||||
rejectUnauthorized: true,
|
||||
retryCount: 0,
|
||||
retryDelayMs: 0
|
||||
};
|
||||
|
||||
function response(data: unknown, requestConfig: Parameters<AxiosAdapter>[0], status = 200): AxiosResponse {
|
||||
return {
|
||||
data,
|
||||
status,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config: requestConfig
|
||||
};
|
||||
}
|
||||
|
||||
describe("getSystemInfo", () => {
|
||||
it("collects version, entity sets and customization metadata, isolating section errors", async () => {
|
||||
const http = axios.create({
|
||||
adapter: async (requestConfig) => {
|
||||
const url = String(requestConfig.url);
|
||||
|
||||
if (url === "/Login") {
|
||||
return {
|
||||
...response({ SessionId: "abc123", Version: "1000230", SessionTimeout: 30 }, requestConfig),
|
||||
headers: { "set-cookie": ["B1SESSION=abc123; Path=/b1s/v1"] }
|
||||
};
|
||||
}
|
||||
|
||||
if (url === "/Logout") {
|
||||
return response({}, requestConfig);
|
||||
}
|
||||
|
||||
if (url === "/") {
|
||||
return response(
|
||||
{ value: [{ name: "Items", url: "Items" }, { name: "BusinessPartners", url: "BusinessPartners" }] },
|
||||
requestConfig
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "UserFieldsMD") {
|
||||
return response({ value: [{ TableName: "OCRD", FieldID: 0, Name: "MyField", Type: "db_Alpha" }] }, requestConfig);
|
||||
}
|
||||
|
||||
if (url === "UserTablesMD") {
|
||||
return response({ value: [{ TableName: "MY_TABLE", TableDescription: "Custom table" }] }, requestConfig);
|
||||
}
|
||||
|
||||
if (url === "UserObjectsMD") {
|
||||
return response({ value: [] }, requestConfig);
|
||||
}
|
||||
|
||||
if (url === "CompanyService_GetAdminInfo") {
|
||||
return Promise.reject({
|
||||
isAxiosError: true,
|
||||
message: "Forbidden",
|
||||
response: { status: 403, data: { error: { message: { value: "No permission" } } } }
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected request URL: ${url}`);
|
||||
}
|
||||
});
|
||||
|
||||
const sap = new SapBusinessOneServiceLayer(config, { http });
|
||||
const info = await sap.getSystemInfo();
|
||||
|
||||
expect(info.serviceLayer).toEqual({ version: "1000230", sessionTimeoutMinutes: 30 });
|
||||
expect(info.entitySets).toEqual(["BusinessPartners", "Items"]);
|
||||
expect(info.userFields).toEqual([{ TableName: "OCRD", FieldID: 0, Name: "MyField", Type: "db_Alpha" }]);
|
||||
expect(info.userTables).toEqual([{ TableName: "MY_TABLE", TableDescription: "Custom table" }]);
|
||||
expect(info.userObjects).toEqual([]);
|
||||
expect(info.adminInfo).toEqual({ error: "No permission" });
|
||||
});
|
||||
|
||||
it("keeps login info available after logout so the login endpoint can report the version", async () => {
|
||||
const http = axios.create({
|
||||
adapter: async (requestConfig) => {
|
||||
if (requestConfig.url === "/Login") {
|
||||
return {
|
||||
...response({ SessionId: "abc123", Version: "1000230", SessionTimeout: 30 }, requestConfig),
|
||||
headers: { "set-cookie": ["B1SESSION=abc123; Path=/b1s/v1"] }
|
||||
};
|
||||
}
|
||||
|
||||
return response({}, requestConfig);
|
||||
}
|
||||
});
|
||||
|
||||
const sap = new SapBusinessOneServiceLayer(config, { http });
|
||||
await sap.login();
|
||||
await sap.logout();
|
||||
|
||||
expect(sap.client.loginInfo).toEqual({ version: "1000230", sessionTimeoutMinutes: 30 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user