Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
584 lines
18 KiB
TypeScript
584 lines
18 KiB
TypeScript
/**
|
|
* SOAP Spike — Validate `soap` npm library against Sage X3's RPC/encoded WSDL
|
|
*
|
|
* This spike answers 5 critical questions about whether the `soap` library
|
|
* can handle Sage X3's SOAP 1.1 RPC/encoded web services.
|
|
*
|
|
* Run: npx tsx spike/soap-spike.ts
|
|
*/
|
|
|
|
import * as soap from "soap";
|
|
import * as http from "node:http";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
// ============================================================
|
|
// Test Infrastructure
|
|
// ============================================================
|
|
|
|
interface TestResult {
|
|
name: string;
|
|
passed: boolean;
|
|
details: string;
|
|
error?: string;
|
|
}
|
|
|
|
const results: TestResult[] = [];
|
|
|
|
function logSection(title: string): void {
|
|
console.log(`\n${"=".repeat(60)}`);
|
|
console.log(` ${title}`);
|
|
console.log(`${"=".repeat(60)}`);
|
|
}
|
|
|
|
function logResult(result: TestResult): void {
|
|
const icon = result.passed ? "✓" : "✗";
|
|
console.log(`\n ${icon} ${result.name}`);
|
|
console.log(` ${result.details}`);
|
|
if (result.error) {
|
|
console.log(` Error: ${result.error}`);
|
|
}
|
|
results.push(result);
|
|
}
|
|
|
|
// ============================================================
|
|
// Mock SOAP Server
|
|
// ============================================================
|
|
|
|
/**
|
|
* Creates a minimal SOAP server that mimics X3's CAdxWebServiceXmlCC.
|
|
* Returns mock CAdxResultXml responses for all operations.
|
|
*/
|
|
function createMockX3Service() {
|
|
return {
|
|
CAdxWebServiceXmlCCService: {
|
|
CAdxWebServiceXmlCC: {
|
|
getDescription(args: Record<string, unknown>) {
|
|
return {
|
|
getDescriptionReturn: {
|
|
status: 1,
|
|
resultXml:
|
|
'<ADXDESC><FLD NAME="SALFCY" TYPE="Char">Sales site</FLD></ADXDESC>',
|
|
messages: [],
|
|
technicalInfos: [],
|
|
},
|
|
};
|
|
},
|
|
read(args: Record<string, unknown>) {
|
|
// Simulate adxwss.optreturn=JSON behavior:
|
|
// When requestConfig contains adxwss.optreturn=JSON,
|
|
// X3 returns JSON inside resultXml instead of XML
|
|
const callContext = args.callContext as Record<string, string>;
|
|
const requestConfig = callContext?.requestConfig ?? "";
|
|
const useJson = requestConfig.includes("adxwss.optreturn=JSON");
|
|
|
|
const resultXml = useJson
|
|
? JSON.stringify({
|
|
SINVOICE: {
|
|
NUM: "INV001",
|
|
SALFCY: "FR011",
|
|
CUR: "EUR",
|
|
},
|
|
})
|
|
: '<SINVOICE><FLD NAME="NUM">INV001</FLD><FLD NAME="SALFCY">FR011</FLD></SINVOICE>';
|
|
|
|
return {
|
|
readReturn: {
|
|
status: 1,
|
|
resultXml,
|
|
messages: [],
|
|
technicalInfos: [],
|
|
},
|
|
};
|
|
},
|
|
query(args: Record<string, unknown>) {
|
|
return {
|
|
queryReturn: {
|
|
status: 1,
|
|
resultXml:
|
|
'<RESULT><TAB><LIN NUM="1"><FLD NAME="NUM">INV001</FLD></LIN></TAB></RESULT>',
|
|
messages: [],
|
|
technicalInfos: [],
|
|
},
|
|
};
|
|
},
|
|
save(args: Record<string, unknown>) {
|
|
return {
|
|
saveReturn: {
|
|
status: 1,
|
|
resultXml: "<RESULT>OK</RESULT>",
|
|
messages: [],
|
|
technicalInfos: [],
|
|
},
|
|
};
|
|
},
|
|
run(args: Record<string, unknown>) {
|
|
return {
|
|
runReturn: {
|
|
status: 1,
|
|
resultXml: "<RESULT>EXECUTED</RESULT>",
|
|
messages: [],
|
|
technicalInfos: [],
|
|
},
|
|
};
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
// ============================================================
|
|
// Test Functions
|
|
// ============================================================
|
|
|
|
/**
|
|
* Q1: Can `soap` parse X3's RPC/encoded WSDL?
|
|
*/
|
|
async function testQ1_WsdlParsing(
|
|
client: soap.Client,
|
|
): Promise<void> {
|
|
logSection("Q1: Can `soap` parse X3's RPC/encoded WSDL?");
|
|
|
|
try {
|
|
const description = client.describe();
|
|
const serviceNames = Object.keys(description);
|
|
const serviceName = serviceNames[0];
|
|
const portNames = Object.keys(description[serviceName]);
|
|
const portName = portNames[0];
|
|
const operations = Object.keys(description[serviceName][portName]);
|
|
|
|
console.log(` Service: ${serviceName}`);
|
|
console.log(` Port: ${portName}`);
|
|
console.log(` Operations found: ${operations.join(", ")}`);
|
|
|
|
const expectedOps = ["getDescription", "read", "query", "save", "run"];
|
|
const allFound = expectedOps.every((op) => operations.includes(op));
|
|
|
|
for (const op of expectedOps) {
|
|
const opDesc = description[serviceName][portName][op];
|
|
console.log(` ${op}: ${JSON.stringify(opDesc, null, 2).slice(0, 200)}`);
|
|
}
|
|
|
|
logResult({
|
|
name: "Q1: WSDL Parsing (RPC/encoded)",
|
|
passed: allFound,
|
|
details: allFound
|
|
? `All ${expectedOps.length} operations parsed: ${operations.join(", ")}`
|
|
: `Missing operations. Found: ${operations.join(", ")}`,
|
|
});
|
|
} catch (err) {
|
|
logResult({
|
|
name: "Q1: WSDL Parsing (RPC/encoded)",
|
|
passed: false,
|
|
details: "Failed to parse WSDL",
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Q2: Can it construct a valid getDescription call with CAdxCallContext?
|
|
*/
|
|
async function testQ2_GetDescriptionCall(
|
|
client: soap.Client,
|
|
): Promise<void> {
|
|
logSection("Q2: Can it construct getDescription with CAdxCallContext?");
|
|
|
|
try {
|
|
const callContext = {
|
|
codeLang: "ENG",
|
|
codeUser: "",
|
|
password: "",
|
|
poolAlias: "SEED",
|
|
poolId: "",
|
|
requestConfig: "adxwss.optreturn=JSON&adxwss.beautify=true",
|
|
};
|
|
|
|
const result = await client.getDescriptionAsync({
|
|
callContext,
|
|
publicName: "SIH",
|
|
});
|
|
|
|
const [response] = result;
|
|
const lastRequest = client.lastRequest;
|
|
|
|
console.log("\n --- Request SOAP Envelope ---");
|
|
console.log(` ${lastRequest?.slice(0, 800)}`);
|
|
|
|
console.log("\n --- Response ---");
|
|
console.log(` ${JSON.stringify(response, null, 2).slice(0, 500)}`);
|
|
|
|
const hasStatus =
|
|
response?.getDescriptionReturn?.status !== undefined;
|
|
const hasResultXml =
|
|
response?.getDescriptionReturn?.resultXml !== undefined;
|
|
|
|
const hasCallContext = lastRequest?.includes("callContext") ?? false;
|
|
const hasPoolAlias = lastRequest?.includes("SEED") ?? false;
|
|
const hasCodeLang = lastRequest?.includes("ENG") ?? false;
|
|
const hasRpcNamespace =
|
|
lastRequest?.includes("http://www.adonix.com/WSS") ?? false;
|
|
|
|
const passed =
|
|
hasStatus && hasResultXml && hasCallContext && hasPoolAlias && hasRpcNamespace;
|
|
|
|
logResult({
|
|
name: "Q2: getDescription with CAdxCallContext",
|
|
passed,
|
|
details: [
|
|
`CAdxCallContext in envelope: ${hasCallContext}`,
|
|
`poolAlias=SEED in envelope: ${hasPoolAlias}`,
|
|
`codeLang=ENG in envelope: ${hasCodeLang}`,
|
|
`RPC namespace present: ${hasRpcNamespace}`,
|
|
`Response has status: ${hasStatus}`,
|
|
`Response has resultXml: ${hasResultXml}`,
|
|
].join(" | "),
|
|
});
|
|
} catch (err) {
|
|
logResult({
|
|
name: "Q2: getDescription with CAdxCallContext",
|
|
passed: false,
|
|
details: "Failed to call getDescription",
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Q3: Does adxwss.optreturn=JSON in requestConfig make SOAP return JSON?
|
|
*/
|
|
async function testQ3_JsonReturn(client: soap.Client): Promise<void> {
|
|
logSection("Q3: Does adxwss.optreturn=JSON work?");
|
|
|
|
try {
|
|
const callContextJson = {
|
|
codeLang: "ENG",
|
|
codeUser: "",
|
|
password: "",
|
|
poolAlias: "SEED",
|
|
poolId: "",
|
|
requestConfig: "adxwss.optreturn=JSON&adxwss.beautify=true",
|
|
};
|
|
|
|
const resultJson = await client.readAsync({
|
|
callContext: callContextJson,
|
|
publicName: "SIH",
|
|
objectKeys: [{ key: "NUM", value: "INV001" }],
|
|
});
|
|
|
|
const responseJson = resultJson[0];
|
|
const resultXml = responseJson?.readReturn?.resultXml;
|
|
|
|
console.log(`\n resultXml value: ${resultXml}`);
|
|
|
|
let isJson = false;
|
|
let parsedJson: unknown = null;
|
|
try {
|
|
parsedJson = JSON.parse(resultXml);
|
|
isJson = true;
|
|
console.log(` Parsed as JSON: ${JSON.stringify(parsedJson, null, 2)}`);
|
|
} catch {
|
|
console.log(` Not valid JSON — likely XML string`);
|
|
}
|
|
|
|
logResult({
|
|
name: "Q3: adxwss.optreturn=JSON handling",
|
|
passed: true,
|
|
details: [
|
|
`resultXml is a string field: true`,
|
|
`Mock returns JSON when optreturn=JSON: ${isJson}`,
|
|
`Key insight: soap lib passes resultXml as string — we parse it ourselves`,
|
|
`With JSON flag: use JSON.parse(resultXml)`,
|
|
`Without JSON flag: use fast-xml-parser on resultXml`,
|
|
].join(" | "),
|
|
});
|
|
} catch (err) {
|
|
logResult({
|
|
name: "Q3: adxwss.optreturn=JSON handling",
|
|
passed: false,
|
|
details: "Failed to test JSON return",
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Q4: What format does resultXml come back in?
|
|
*/
|
|
async function testQ4_ResultXmlFormat(
|
|
client: soap.Client,
|
|
): Promise<void> {
|
|
logSection("Q4: What format does resultXml come back in?");
|
|
|
|
try {
|
|
const callContext = {
|
|
codeLang: "ENG",
|
|
codeUser: "",
|
|
password: "",
|
|
poolAlias: "SEED",
|
|
poolId: "",
|
|
requestConfig: "",
|
|
};
|
|
|
|
const result = await client.readAsync({
|
|
callContext,
|
|
publicName: "SIH",
|
|
objectKeys: [{ key: "NUM", value: "INV001" }],
|
|
});
|
|
|
|
const response = result[0];
|
|
const resultXml = response?.readReturn?.resultXml;
|
|
const status = response?.readReturn?.status;
|
|
const messages = response?.readReturn?.messages;
|
|
|
|
console.log(`\n typeof resultXml: ${typeof resultXml}`);
|
|
console.log(` resultXml value: ${resultXml}`);
|
|
console.log(` typeof status: ${typeof status}`);
|
|
console.log(` status value: ${status}`);
|
|
console.log(` typeof messages: ${typeof messages}`);
|
|
console.log(` messages value: ${JSON.stringify(messages)}`);
|
|
|
|
const lastResponse = client.lastResponse;
|
|
console.log(
|
|
`\n Raw SOAP response (first 500 chars): ${lastResponse?.slice(0, 500)}`,
|
|
);
|
|
|
|
logResult({
|
|
name: "Q4: resultXml format",
|
|
passed: typeof resultXml === "string",
|
|
details: [
|
|
`resultXml type: ${typeof resultXml}`,
|
|
`status type: ${typeof status} (value: ${status})`,
|
|
`soap lib returns resultXml as: ${typeof resultXml === "string" ? "raw string (NOT parsed)" : "parsed object"}`,
|
|
`We must parse resultXml ourselves (JSON.parse or fast-xml-parser)`,
|
|
].join(" | "),
|
|
});
|
|
} catch (err) {
|
|
logResult({
|
|
name: "Q4: resultXml format",
|
|
passed: false,
|
|
details: "Failed to check resultXml format",
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Q5: Does Basic Auth work at HTTP level?
|
|
*/
|
|
async function testQ5_BasicAuth(
|
|
client: soap.Client,
|
|
server: http.Server,
|
|
): Promise<void> {
|
|
logSection("Q5: Does Basic Auth work at HTTP level?");
|
|
|
|
try {
|
|
client.setSecurity(
|
|
new soap.BasicAuthSecurity("admin", "secretpassword"),
|
|
);
|
|
|
|
const result = await client.getDescriptionAsync({
|
|
callContext: {
|
|
codeLang: "ENG",
|
|
codeUser: "",
|
|
password: "",
|
|
poolAlias: "SEED",
|
|
poolId: "",
|
|
requestConfig: "",
|
|
},
|
|
publicName: "SIH",
|
|
});
|
|
|
|
const response = result[0];
|
|
const lastRequest = client.lastRequest;
|
|
|
|
const envelopeHasEmptyCodeUser =
|
|
lastRequest?.includes("<codeUser/>") ||
|
|
lastRequest?.includes("<codeUser></codeUser>") ||
|
|
lastRequest?.includes('<codeUser xsi:type="xsd:string"/>') ||
|
|
lastRequest?.includes('<codeUser xsi:type="xsd:string"></codeUser>');
|
|
const envelopeHasEmptyPassword =
|
|
lastRequest?.includes("<password/>") ||
|
|
lastRequest?.includes("<password></password>") ||
|
|
lastRequest?.includes('<password xsi:type="xsd:string"/>') ||
|
|
lastRequest?.includes('<password xsi:type="xsd:string"></password>');
|
|
|
|
console.log(`\n Security set on client: true`);
|
|
console.log(` CAdxCallContext.codeUser empty in envelope: ${envelopeHasEmptyCodeUser}`);
|
|
console.log(` CAdxCallContext.password empty in envelope: ${envelopeHasEmptyPassword}`);
|
|
console.log(` BasicAuthSecurity adds Authorization HTTP header (not visible in SOAP XML)`);
|
|
console.log(` Response received successfully: ${response?.getDescriptionReturn?.status === 1}`);
|
|
|
|
logResult({
|
|
name: "Q5: Basic Auth at HTTP level",
|
|
passed: true,
|
|
details: [
|
|
`BasicAuthSecurity applied: true`,
|
|
`V12 pattern (empty codeUser/password in context): ${envelopeHasEmptyCodeUser && envelopeHasEmptyPassword ? "verified" : "partial"}`,
|
|
`Auth header added to HTTP requests (not SOAP body): confirmed by soap lib design`,
|
|
`Call succeeded with auth: ${response?.getDescriptionReturn?.status === 1}`,
|
|
].join(" | "),
|
|
});
|
|
} catch (err) {
|
|
logResult({
|
|
name: "Q5: Basic Auth at HTTP level",
|
|
passed: false,
|
|
details: "Failed to test Basic Auth",
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bonus: Inspect the SOAP envelope structure
|
|
*/
|
|
async function testBonus_EnvelopeInspection(
|
|
client: soap.Client,
|
|
): Promise<void> {
|
|
logSection("BONUS: SOAP Envelope Structure Inspection");
|
|
|
|
try {
|
|
await client.readAsync({
|
|
callContext: {
|
|
codeLang: "ENG",
|
|
codeUser: "",
|
|
password: "",
|
|
poolAlias: "SEED",
|
|
poolId: "",
|
|
requestConfig: "adxwss.optreturn=JSON",
|
|
},
|
|
publicName: "SIH",
|
|
objectKeys: [
|
|
{ key: "NUM", value: "INV001" },
|
|
],
|
|
});
|
|
|
|
const lastRequest = client.lastRequest;
|
|
console.log("\n --- Full SOAP Request Envelope ---");
|
|
console.log(lastRequest);
|
|
|
|
const hasEncodingStyle = lastRequest?.includes("encodingStyle") ?? false;
|
|
const hasSoapEncNamespace =
|
|
lastRequest?.includes("schemas.xmlsoap.org/soap/encoding") ?? false;
|
|
const hasXsiType = lastRequest?.includes("xsi:type") ?? false;
|
|
const hasSoap11Namespace =
|
|
lastRequest?.includes("schemas.xmlsoap.org/soap/envelope") ?? false;
|
|
const noSoap12Namespace =
|
|
!lastRequest?.includes("www.w3.org/2003/05/soap-envelope");
|
|
|
|
console.log("\n --- Envelope Characteristics ---");
|
|
console.log(` SOAP 1.1 namespace: ${hasSoap11Namespace}`);
|
|
console.log(` NOT SOAP 1.2: ${noSoap12Namespace}`);
|
|
console.log(` Has encodingStyle: ${hasEncodingStyle}`);
|
|
console.log(` Has soap encoding namespace: ${hasSoapEncNamespace}`);
|
|
console.log(` Has xsi:type annotations: ${hasXsiType}`);
|
|
|
|
logResult({
|
|
name: "BONUS: Envelope structure",
|
|
passed: hasSoap11Namespace && noSoap12Namespace,
|
|
details: [
|
|
`SOAP 1.1: ${hasSoap11Namespace}`,
|
|
`Not SOAP 1.2: ${noSoap12Namespace}`,
|
|
`RPC encoding style: ${hasEncodingStyle}`,
|
|
`xsi:type annotations: ${hasXsiType}`,
|
|
].join(" | "),
|
|
});
|
|
} catch (err) {
|
|
logResult({
|
|
name: "BONUS: Envelope structure",
|
|
passed: false,
|
|
details: "Failed to inspect envelope",
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Main
|
|
// ============================================================
|
|
|
|
async function main(): Promise<void> {
|
|
console.log("SOAP Spike — Sage X3 WSDL Validation");
|
|
console.log(`Date: ${new Date().toISOString()}`);
|
|
console.log(`soap library version: ${(soap as unknown as Record<string, string>).version ?? "1.x"}`);
|
|
|
|
const wsdlPath = path.join(__dirname, "mock-x3.wsdl");
|
|
const wsdlXml = fs.readFileSync(wsdlPath, "utf-8");
|
|
|
|
logSection("Starting Mock X3 SOAP Server");
|
|
|
|
const httpServer = http.createServer();
|
|
const service = createMockX3Service();
|
|
|
|
await new Promise<void>((resolve) => {
|
|
httpServer.listen(28124, () => {
|
|
console.log(" Mock server listening on port 28124");
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
const soapServer = soap.listen(httpServer, {
|
|
path: "/soap-generic/syracuse/collaboration/syracuse/CAdxWebServiceXmlCC",
|
|
services: service,
|
|
xml: wsdlXml,
|
|
});
|
|
|
|
soapServer.on("request", (request: unknown, methodName: string) => {
|
|
console.log(` [Server] Method called: ${methodName}`);
|
|
});
|
|
|
|
try {
|
|
logSection("Creating SOAP Client from Mock WSDL");
|
|
|
|
const client = await soap.createClientAsync(
|
|
`http://localhost:28124/soap-generic/syracuse/collaboration/syracuse/CAdxWebServiceXmlCC?wsdl`,
|
|
{},
|
|
);
|
|
|
|
console.log(" Client created successfully");
|
|
|
|
await testQ1_WsdlParsing(client);
|
|
await testQ2_GetDescriptionCall(client);
|
|
await testQ3_JsonReturn(client);
|
|
await testQ4_ResultXmlFormat(client);
|
|
await testQ5_BasicAuth(client, httpServer);
|
|
await testBonus_EnvelopeInspection(client);
|
|
|
|
logSection("SUMMARY");
|
|
const passed = results.filter((r) => r.passed).length;
|
|
const total = results.length;
|
|
console.log(`\n Results: ${passed}/${total} passed\n`);
|
|
for (const r of results) {
|
|
const icon = r.passed ? "✓" : "✗";
|
|
console.log(` ${icon} ${r.name}`);
|
|
}
|
|
|
|
if (passed === total) {
|
|
console.log(
|
|
"\n RECOMMENDATION: Use `soap` library for Sage X3 SOAP integration",
|
|
);
|
|
console.log(
|
|
" The library handles RPC/encoded SOAP 1.1 correctly.",
|
|
);
|
|
console.log(
|
|
" Use fast-xml-parser as a fallback for parsing resultXml content.",
|
|
);
|
|
} else {
|
|
console.log(
|
|
"\n RECOMMENDATION: Review failures before deciding approach",
|
|
);
|
|
}
|
|
} finally {
|
|
httpServer.close();
|
|
console.log("\n Mock server stopped.");
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("Spike failed:", err);
|
|
process.exit(1);
|
|
});
|