Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 63 additions & 26 deletions src/actions/submit/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -345,14 +345,44 @@ async function handleNewsletter(ctx, formId, data) {
};
}

/**
* On stage, log the request/response pair for a submission to aid debugging.
*
* Gated to stage submissions (formId prefixed `stage/`, i.e. originating from a
* non-production referer) so production submissions — which carry real PII — are
* never logged. Best-effort: a logging failure must never affect the response.
*
* @param {Context} ctx
* @param {string} formId
* @param {unknown} request - the submission data as processed
* @param {RuntimeResponse} response - the response returned to the caller
*/
function logStageSubmission(ctx, formId, request, response) {
if (!formId || !formId.startsWith('stage/')) {
return;
}
try {
const { statusCode, body } = response?.error ?? response ?? {};
ctx.log.info(`[stage-submission] ${JSON.stringify({ formId, request, response: { statusCode, body } })}`);
} catch (err) {
ctx.log.warn(`failed to log stage submission for formId=${formId}: ${err.message}`);
}
}

/**
* HTTP action: receives form submissions, validates, and publishes a `form.submitted` event.
* @param {Object} params
* @returns {Promise<RuntimeResponse>}
*/
export async function main(params) {
/** @type {Context} */
let ctx;
/** @type {string} */
let formId;
/** @type {Record<string, unknown>} */
let data;
try {
const ctx = await makeContext(params);
ctx = await makeContext(params);
const { log } = ctx;

if (ctx.info.method !== 'POST') {
Expand All @@ -369,9 +399,8 @@ export async function main(params) {

const isProdReferer = ctx.info.headers['referer']?.includes(PROD_ORIGIN) || false;

/** @type {string} */
// @ts-ignore
let formId = ctx.data.formId;
formId = ctx.data.formId;

// if the origin of the submission isn't the production origin
// add the `stage` prefix to the formId (if not present)
Expand All @@ -381,44 +410,52 @@ export async function main(params) {
}

// get submission data, it may be in the data object or the root of the payload
/** @type {Record<string, unknown>} */
// @ts-ignore
let data = ctx.data.data;
data = ctx.data.data;
if (typeof data !== 'object') {
data = ctx.data;
delete data.formId;
}

/** @type {RuntimeResponse} */
let response;
if (formId.endsWith('/product-registration')) {
return await handleProductRegistration(ctx, formId, data);
response = await handleProductRegistration(ctx, formId, data);
} else if (formId.endsWith('/order-status')) {
return await handleOrderStatus(ctx, formId, data);
response = await handleOrderStatus(ctx, formId, data);
} else if (formId.endsWith('/newsletter')) {
return await handleNewsletter(ctx, formId, data);
response = await handleNewsletter(ctx, formId, data);
} else {
// add timestamp and IP - these can't be set by the payload
delete data.IP;
delete data.timestamp;
data = {
timestamp: new Date().toISOString(),
IP: ctx.info.headers['x-forwarded-for'] || ctx.info.headers['x-real-ip'] || ctx.info.headers['cf-connecting-ip'] || 'unknown',
...data,
};

log.info(`publishing form.submitted event for formId=${formId}`);
await publishEvent(ctx, 'form.submitted', { formId, data });

response = {
statusCode: 201,
headers: { 'content-type': 'application/json' },
body: { formId },
};
}

// add timestamp and IP - these can't be set by the payload
delete data.IP;
delete data.timestamp;
data = {
timestamp: new Date().toISOString(),
IP: ctx.info.headers['x-forwarded-for'] || ctx.info.headers['x-real-ip'] || ctx.info.headers['cf-connecting-ip'] || 'unknown',
...data,
};

log.info(`publishing form.submitted event for formId=${formId}`);
await publishEvent(ctx, 'form.submitted', { formId, data });

return {
statusCode: 201,
headers: { 'content-type': 'application/json' },
body: { formId },
};
logStageSubmission(ctx, formId, data, response);
return response;
} catch (error) {
const response = error.response ?? errorResponse(500, 'server error');
if (ctx) {
logStageSubmission(ctx, formId, data, response);
}
if (error.response) {
return error.response;
}
console.error('fatal error: ', error);
return errorResponse(500, 'server error');
return response;
}
}
62 changes: 62 additions & 0 deletions test/submit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1007,4 +1007,66 @@ describe('submit action', () => {
expect(result).toEqual({ statusCode: 503, body: 'unavailable' });
});
});

// -- stage submission logging --------------------------------------------

describe('stage submission logging', () => {
/** Find the [stage-submission] log line and parse its JSON payload. */
function getLoggedSubmission(ctx) {
const call = ctx.log.info.mock.calls.find(([msg]) => typeof msg === 'string' && msg.startsWith('[stage-submission] '));
return call ? JSON.parse(call[0].replace('[stage-submission] ', '')) : null;
}

test('logs request/response pair for a stage submission (non-prod referer)', async () => {
const ctx = makeCtx({
data: { formId: 'contact-us', data: { name: 'John', email: '[email protected]' } },
info: { method: 'POST', headers: { 'content-type': 'application/json', 'x-forwarded-for': '1.2.3.4', referer: 'https://main--vitamix--aemsites.aem.page/' }, path: '/submit' },
});
mockMakeContext.mockResolvedValue(ctx);

const result = await main({});
expect(result.statusCode).toBe(201);

const logged = getLoggedSubmission(ctx);
expect(logged).not.toBeNull();
expect(logged.formId).toBe('stage/contact-us');
expect(logged.request).toMatchObject({ name: 'John', email: '[email protected]' });
expect(logged.response).toEqual({ statusCode: 201, body: { formId: 'stage/contact-us' } });
});

test('does NOT log for a production submission', async () => {
const ctx = makeCtx(); // referer is www.vitamix.com
mockMakeContext.mockResolvedValue(ctx);

await main({});
expect(getLoggedSubmission(ctx)).toBeNull();
});

test('logs the error response when a handler throws on stage', async () => {
const err = new Error('ebs down');
err.response = { error: { statusCode: 502, body: 'bad gateway' } };
mockCreateProductRegistration.mockRejectedValue(err);

const ctx = makeCtx({
data: {
formId: 'us/product-registration',
data: {
acceptTerms: 'yes', address: '1 Main', city: 'Cleveland', postalCode: '44100', province: 'OH',
email: '[email protected]', firstName: 'J', lastName: 'D', phone: '5551212',
purchasedFrom: 'Amazon', purchasedOn: '2026-01-01', serialNumber: '012345678901234567',
},
},
info: { method: 'POST', headers: { 'content-type': 'application/json', referer: 'https://main--vitamix--aemsites.aem.page/' }, path: '/submit' },
});
mockMakeContext.mockResolvedValue(ctx);

const result = await main({});
expect(result.error.statusCode).toBe(502);

const logged = getLoggedSubmission(ctx);
expect(logged).not.toBeNull();
expect(logged.formId).toBe('stage/us/product-registration');
expect(logged.response.statusCode).toBe(502);
});
});
});
Loading