From e7de83285ea1e8957a077c62c76bd64ff820a839 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Wed, 4 Mar 2026 14:47:33 -0800 Subject: [PATCH 1/2] fix: validation, proxy, ebs --- example.env | 6 ++ package-lock.json | 40 +++++++- package.json | 3 +- src/actions/submit/index.js | 5 +- src/context.js | 3 +- src/ebs.js | 176 ++++++++++++++++++++++++++++++++++++ src/proxy.js | 34 +++++++ src/types.d.ts | 1 + 8 files changed, 258 insertions(+), 10 deletions(-) create mode 100644 src/ebs.js create mode 100644 src/proxy.js diff --git a/example.env b/example.env index 6d59aee..2b48300 100644 --- a/example.env +++ b/example.env @@ -28,5 +28,11 @@ AIO_SCOPES=AdobeID,openid,read_organizations,additional_info.projectedProductCon AIO_EVENTS_PROVIDER_ID_STAGE=ce40dfc9-9a94-41cc-a05a-1334d674fc8c AIO_EVENTS_PROVIDER_ID_PROD=838bd20b-5725-4f72-96f7-d486799cdabf +# EBS SOAP API base URL +EBS_BASE_URL=x + # Productbus service token with emails:send permission EMAIL_TOKEN=x + +# helix-proxy token for the org/site +PROXY_TOKEN=x \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 00bb37b..fb9d024 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,10 @@ "name": "vitamix-forms", "version": "0.0.1", "dependencies": { - "@adobe/aio-lib-state": "5.3.1", "@adobe/aio-sdk": "^6", "@adobe/exc-app": "^1.3.0", "cloudevents": "^4.0.2", + "fast-xml-parser": "^5.4.2", "node-fetch": "^2.6.0", "regenerator-runtime": "^0.13.5", "uuid": "^8.0.0" @@ -5689,6 +5689,25 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/xml-builder/node_modules/fast-xml-parser": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz", + "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/@aws/lambda-invoke-store": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", @@ -18260,10 +18279,22 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", + "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/fast-xml-parser": { - "version": "5.3.6", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz", - "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.2.tgz", + "integrity": "sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==", "funding": [ { "type": "github", @@ -18272,6 +18303,7 @@ ], "license": "MIT", "dependencies": { + "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { diff --git a/package.json b/package.json index f9c484f..9b356c8 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@adobe/aio-sdk": "^6", "@adobe/exc-app": "^1.3.0", "cloudevents": "^4.0.2", + "fast-xml-parser": "^5.4.2", "node-fetch": "^2.6.0", "regenerator-runtime": "^0.13.5", "uuid": "^8.0.0" @@ -36,4 +37,4 @@ "engines": { "node": ">=18" } -} \ No newline at end of file +} diff --git a/src/actions/submit/index.js b/src/actions/submit/index.js index 79bd51d..37e93a1 100644 --- a/src/actions/submit/index.js +++ b/src/actions/submit/index.js @@ -18,9 +18,6 @@ function validatePayload(data) { if (!data.formId || typeof data.formId !== 'string') { return 'missing or invalid formId'; } - if (!data.data || typeof data.data !== 'object') { - return 'missing or invalid data'; - } // check that formId looks valid // these are further validated in the processor action @@ -41,7 +38,7 @@ function validatePayload(data) { } // nested properties in data - Object.values(data.data).forEach((val) => { + Object.values(data.data ?? data ?? {}).forEach((val) => { if (typeof val === 'object' && val !== null) { return 'payload contains nested data'; } diff --git a/src/context.js b/src/context.js index 2779609..b917cc8 100644 --- a/src/context.js +++ b/src/context.js @@ -20,13 +20,14 @@ export default async function createContext(owParams) { AIO_IMSORGID, AIO_EVENTS_PROVIDER_ID, EMAIL_TOKEN, + PROXY_TOKEN, ...data } = owParams; const token = await getAccessToken(AIO_CLIENTID, AIO_CLIENTSECRET, AIO_SCOPES); return { - env: { ORG, SITE, SHEET, EMAIL_TOKEN }, + env: { ORG, SITE, SHEET, EMAIL_TOKEN, PROXY_TOKEN }, // @ts-ignore log: Core.Logger('main', { level: LOG_LEVEL }), data, diff --git a/src/ebs.js b/src/ebs.js new file mode 100644 index 0000000..1ef3bfc --- /dev/null +++ b/src/ebs.js @@ -0,0 +1,176 @@ +import { XMLParser } from 'fast-xml-parser'; +import { proxyFetch } from './proxy.js'; + +const PATHS = { + queryOrder: '/soa-infra/services/OTC/VITOTCQueryOrder/vitotcqueryorderbpel_client_ep', + validateSerialNumber: '/soa-infra/services/OTC/VITOTCValidateSerialNum/vitotcvalidateserialnumbpel_client_ep', + createRegistration: '/soa-infra/services/OTC/VITOTCProdRegistration/vitotcproductregbpel_client_ep', +}; + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + removeNSPrefix: true, +}); + +/** + * Escape XML special characters to prevent injection + * @param {string} str + * @returns {string} + */ +function escapeXml(str) { + if (str == null) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * @param {string} xml + * @returns {object} + */ +function parseResponse(xml) { + const parsed = parser.parse(xml); + return parsed?.Envelope?.Body ?? parsed; +} + +/** + * @param {Context} ctx + * @param {string} baseUrl + * @param {string} path + * @param {string} xml + * @returns {Promise<{ status: number, body: object, raw: string }>} + */ +async function soapFetch(ctx, baseUrl, path, xml) { + const url = `${baseUrl}${path}`; + const resp = await proxyFetch(ctx, url, { + method: 'POST', + headers: { 'Content-Type': 'text/xml; charset=utf-8' }, + body: xml, + }); + const raw = await resp.text(); + const body = parseResponse(raw); + return { status: resp.status, body, raw }; +} + +/** + * Query an order by key via VITOTCQueryOrder SOAP API + * @param {Context} ctx + * @param {string} baseUrl - EBS SOAP base URL + * @param {string} orderKey - order key (e.g. "omstg1000031076") + * @returns {Promise<{ status: number, body: object, raw: string }>} + */ +export async function queryOrder(ctx, baseUrl, orderKey) { + const requestId = crypto.randomUUID(); + const xml = [ + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'); + console.log('querying order:', xml); + return soapFetch(ctx, baseUrl, PATHS.queryOrder, xml); +} + +/** + * Validate a product serial number via VITOTCValidateSerialNum SOAP API + * @param {Context} ctx + * @param {string} baseUrl - EBS SOAP base URL + * @param {string} serialNumber - serial number (e.g. "067881201029626223") + * @returns {Promise<{ status: number, body: object, raw: string }>} + */ +export async function validateSerialNumber(ctx, baseUrl, serialNumber) { + const requestId = crypto.randomUUID(); + const xml = [ + '', + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'); + return soapFetch(ctx, baseUrl, PATHS.validateSerialNumber, xml); +} + +/** + * @typedef {object} RegistrationData + * @property {string} formCode + * @property {string} purchaseLocation + * @property {string} purchaseDate - ISO date string (e.g. "2026-02-22T00:00:00") + * @property {string} [prefix] + * @property {string} [suffix] + * @property {string} address1 + * @property {string} city + * @property {string} region - state/province code (e.g. "OH") + * @property {string} postalCode + * @property {string} geoCode + * @property {string} country - country code (e.g. "US") + * @property {string} mobile + * @property {string} email + * @property {string} firstName + * @property {string} lastName + * @property {string} [middleName] + * @property {string} serialNumber + */ + +/** + * Create a product registration via VITOTCProdRegistration SOAP API + * @param {Context} ctx + * @param {string} baseUrl - EBS SOAP base URL + * @param {RegistrationData} data + * @returns {Promise<{ status: number, body: object, raw: string }>} + */ +export async function createProductRegistration(ctx, baseUrl, data) { + const requestId = crypto.randomUUID(); + const e = escapeXml; + const xml = [ + '', + '', + ' ', + ' ', + ` `, + ` `, + ` `, + ' ', + ` ${e(data.address1)}`, + ` ${e(data.city)}`, + ` ${e(data.region)}`, + ` ${e(data.postalCode)}`, + ` ${e(data.geoCode)}`, + ` ${e(data.country)}`, + ' ', + ` ${e(data.mobile)}`, + ` ${e(data.email)}`, + ` ${e(data.firstName)}`, + ` ${e(data.lastName)}`, + ` ${e(data.middleName ?? '')}`, + ' ', + ` `, + ' ', + ' ', + ' ', + '', + ].join('\n'); + return soapFetch(ctx, baseUrl, PATHS.createRegistration, xml); +} diff --git a/src/proxy.js b/src/proxy.js new file mode 100644 index 0000000..d48d4de --- /dev/null +++ b/src/proxy.js @@ -0,0 +1,34 @@ +/** + * Proxy URL for the org/site + * @param {string} org + * @param {string} site + * @returns {string} + */ +const PROXY_URL = (org, site) => `https://lqmig3v5eb.execute-api.us-east-1.amazonaws.com/helix-services/proxy/v1/${org}/${site}` + +/** + * Fetch via proxy + * @param {Context} ctx + * @param {string} url + * @param {RequestInit} opts + * @returns {Promise} + */ +export async function proxyFetch(ctx, url, opts) { + const proxyUrl = PROXY_URL(ctx.env.ORG, ctx.env.SITE); + console.log('proxy fetching:', opts.method ?? 'GET', url, '=>', proxyUrl); + const resp = await fetch(proxyUrl, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${ctx.env.PROXY_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url, + ...(opts ?? {}), + }), + }) + if (!resp.ok) { + throw new Error(`Proxy fetch failed: ${resp.status} ${resp.statusText} - ${resp.headers.get('x-error') ?? ''}`); + } + return resp; +} \ No newline at end of file diff --git a/src/types.d.ts b/src/types.d.ts index b229f37..fb31b51 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -6,6 +6,7 @@ declare global { SITE: string; SHEET: string; EMAIL_TOKEN: string; + PROXY_TOKEN: string; } export interface EventsConfig { From 4335d89e3b2616cf822187c4cc7a132c82ce9cb2 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Wed, 4 Mar 2026 14:56:59 -0800 Subject: [PATCH 2/2] fix: validation --- test/submit.test.js | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/test/submit.test.js b/test/submit.test.js index 283c5ff..0dae4bc 100644 --- a/test/submit.test.js +++ b/test/submit.test.js @@ -75,18 +75,20 @@ describe('submit action', () => { expect(result.error.headers['x-error']).toBe('missing or invalid formId'); }); - test('rejects missing data', async () => { - mockMakeContext.mockResolvedValue(makeCtx({ data: { formId: 'test-form' } })); + test('accepts flat payload (no nested data object)', async () => { + mockMakeContext.mockResolvedValue(makeCtx({ + data: { formId: 'test-form', name: 'Alice', email: 'alice@test.com' }, + })); const result = await main({}); - expect(result.error.statusCode).toBe(400); - expect(result.error.headers['x-error']).toBe('missing or invalid data'); + expect(result.statusCode).toBe(201); }); - test('rejects non-object data', async () => { - mockMakeContext.mockResolvedValue(makeCtx({ data: { formId: 'test-form', data: 'string' } })); + test('accepts flat payload where data field is a non-object', async () => { + mockMakeContext.mockResolvedValue(makeCtx({ + data: { formId: 'test-form', data: 'string', name: 'Bob' }, + })); const result = await main({}); - expect(result.error.statusCode).toBe(400); - expect(result.error.headers['x-error']).toBe('missing or invalid data'); + expect(result.statusCode).toBe(201); }); test.each([ @@ -155,6 +157,19 @@ describe('submit action', () => { ); }); + test('publishes flat payload properties as form data (formId stripped)', async () => { + mockMakeContext.mockResolvedValue(makeCtx({ + data: { formId: 'test-form', name: 'Alice', email: 'alice@test.com' }, + })); + await main({}); + + const eventData = mockPublishEvent.mock.calls[0][2]; + expect(eventData.formId).toBe('test-form'); + expect(eventData.data.name).toBe('Alice'); + expect(eventData.data.email).toBe('alice@test.com'); + expect(eventData.data).not.toHaveProperty('formId'); + }); + test('adds server-generated timestamp and IP', async () => { mockMakeContext.mockResolvedValue(makeCtx()); await main({});