From c99bd6d137f4c03f46ef9fdc5747ac1b56dcfe98 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Fri, 17 Jul 2026 15:36:49 -0700 Subject: [PATCH] fix: log submissions on stage --- src/actions/submit/index.js | 89 ++++++++++++++++++++++++++----------- test/submit.test.js | 62 ++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 26 deletions(-) diff --git a/src/actions/submit/index.js b/src/actions/submit/index.js index dfeaec4..182a078 100644 --- a/src/actions/submit/index.js +++ b/src/actions/submit/index.js @@ -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} */ export async function main(params) { + /** @type {Context} */ + let ctx; + /** @type {string} */ + let formId; + /** @type {Record} */ + let data; try { - const ctx = await makeContext(params); + ctx = await makeContext(params); const { log } = ctx; if (ctx.info.method !== 'POST') { @@ -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) @@ -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} */ // @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; } } diff --git a/test/submit.test.js b/test/submit.test.js index ab3f0a7..eaf6c55 100644 --- a/test/submit.test.js +++ b/test/submit.test.js @@ -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: 'john@test.com' } }, + 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: 'john@test.com' }); + 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: 'j@test.com', 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); + }); + }); });