Skip to content

Commit 1dbd1ea

Browse files
author
jie-dai_sfemu
committed
Sync from monorepo
Template version: 1.0.0-alpha.0 Uses NPM packages @salesforce/storefront-next-* v1.0.0-alpha.0 Synced by: jie-dai_sfemu Monorepo SHA: 0e568cf93594173acf57e0e12acbe9d7589b2bd4 Latest change: 0e568cf93 - fix(shopper-context): dwsourcecode_* cookie stores bare source-code string @W-22584718 (#1885)
1 parent ca3c789 commit 1dbd1ea

5 files changed

Lines changed: 146 additions & 72 deletions

File tree

docs/README-SHOPPER-CONTEXT.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,12 @@ To add a validation layer on commerce API requests, consider the [Shopper Contex
4242

4343
Shopper Context uses two `httpOnly` cookies to persist qualifier state on the server. This avoids re-reading context from SCAPI on every request and enables the middleware to include qualifiers in subsequent GET calls without additional API round-trips.
4444

45-
| Cookie | Base Name | Default Expiry | Purpose |
46-
|--------|-----------|---------------|---------|
47-
| Shopper Context | `storefront-next-context` | 6 hours | All qualifiers except source code |
48-
| Source Code | `dwsourcecode` | 30 days | Source code qualifier |
45+
| Cookie | Base Name | Default Expiry | Format | Purpose |
46+
|--------|-----------|---------------|--------|---------|
47+
| Shopper Context | `storefront-next-context` | 6 hours | JSON object | All qualifiers except source code |
48+
| Source Code | `dwsourcecode` | 30 days | Bare string | Source code qualifier |
49+
50+
The `dwsourcecode_*` cookie stores the bare source-code string (e.g. `email`) rather than a JSON object so SFRA storefronts running side-by-side can read the same cookie name and value format directly.
4951

5052
On each request the middleware reads current state from cookies, merges in any new qualifiers, sends the merged context to SCAPI if anything changed, and writes updated cookies back. New values overwrite existing keys; unmentioned keys are preserved.
5153

pnpm-lock.yaml

Lines changed: 12 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/shopper-context/server-utils.server.test.ts

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,14 @@ describe('shopper-context-utils', () => {
300300
}
301301
}
302302
});
303+
304+
test('should strip CR/LF from sourceCode value (cookie-header safety)', () => {
305+
// Node's Headers.append rejects CR/LF in Set-Cookie values. Sanitize at extraction so
306+
// both the SCAPI body and the bare-string cookie write are safe.
307+
const url = new URL('https://example.com?src=email%0D%0AMax-Age%3D0');
308+
const result = extractQualifiersFromUrl(url);
309+
expect(result.sourceCodeQualifiers.sourceCode).toBe('emailMax-Age=0');
310+
});
303311
});
304312

305313
describe('extractQualifiersFromInput', () => {
@@ -925,8 +933,8 @@ describe('shopper-context-utils', () => {
925933

926934
test('should merge new context with existing cookie context', async () => {
927935
mockParse
928-
.mockResolvedValueOnce(JSON.stringify({ existingKey: 'existing' })) // shopper context cookie
929-
.mockResolvedValueOnce(JSON.stringify({ sourceCode: 'old-source' })); // source code cookie
936+
.mockResolvedValueOnce(JSON.stringify({ existingKey: 'existing' })) // shopper context cookie (JSON)
937+
.mockResolvedValueOnce('old-source'); // source code cookie (bare string)
930938

931939
const newShopperContext = { deviceType: 'mobile' };
932940
const newSourceCodeContext = { sourceCode: 'new-source' };
@@ -939,11 +947,8 @@ describe('shopper-context-utils', () => {
939947
cookieHeader: 'some-cookie-header',
940948
});
941949

942-
// Verify serialize was called with JSON-encoded merged context (cookie-utils stores strings)
943-
expect(mockSerialize).toHaveBeenCalledWith(
944-
JSON.stringify({ sourceCode: 'new-source' }),
945-
expect.any(Object)
946-
);
950+
// Source-code cookie is the bare string; shopper-context cookie is JSON-encoded.
951+
expect(mockSerialize).toHaveBeenCalledWith('new-source', expect.any(Object));
947952
expect(mockSerialize).toHaveBeenCalledWith(
948953
JSON.stringify({
949954
existingKey: 'existing',
@@ -1072,5 +1077,57 @@ describe('shopper-context-utils', () => {
10721077
expect.any(Object)
10731078
);
10741079
});
1080+
1081+
test('does not resurrect a deleted source code on later qualifier-only updates', async () => {
1082+
// After ?src= clears the source-code cookie, `parseAllCookies` drops it and `parse()`
1083+
// returns null. A subsequent qualifier-only update must NOT re-send the old sourceCode.
1084+
mockParse
1085+
.mockResolvedValueOnce(null) // shopper context cookie (no qualifier yet)
1086+
.mockResolvedValueOnce(null); // source code cookie (post-delete)
1087+
1088+
const newShopperContext = { deviceType: 'mobile' };
1089+
const newSourceCodeContext = {};
1090+
1091+
await updateShopperContext({
1092+
context: mockContext,
1093+
usid: 'test-usid',
1094+
newShopperContext,
1095+
newSourceCodeContext,
1096+
cookieHeader: 'some-header',
1097+
});
1098+
1099+
expect(mockCreateShopperContext).toHaveBeenCalledTimes(1);
1100+
const apiBody = mockCreateShopperContext.mock.calls[0][2];
1101+
expect(apiBody).not.toHaveProperty('sourceCode');
1102+
});
1103+
1104+
test('preserves persisted source code on qualifier-only updates and does not rewrite the source-code cookie', async () => {
1105+
// A persisted bare-string `dwsourcecode_*` cookie must be merged into the SCAPI body,
1106+
// and a qualifier-only update (no newSourceCodeContext) must NOT re-serialize it.
1107+
mockParse
1108+
.mockResolvedValueOnce(null) // shopper context cookie (empty)
1109+
.mockResolvedValueOnce('persisted-source'); // source code cookie (bare string)
1110+
1111+
const newShopperContext = { deviceType: 'mobile' };
1112+
const newSourceCodeContext = {};
1113+
1114+
const result = await updateShopperContext({
1115+
context: mockContext,
1116+
usid: 'test-usid',
1117+
newShopperContext,
1118+
newSourceCodeContext,
1119+
cookieHeader: 'some-header',
1120+
});
1121+
1122+
// SCAPI body includes the persisted source code (merged from the cookie).
1123+
expect(mockCreateShopperContext).toHaveBeenCalledTimes(1);
1124+
const apiBody = mockCreateShopperContext.mock.calls[0][2];
1125+
expect(apiBody.sourceCode).toBe('persisted-source');
1126+
1127+
// Only the shopper-context cookie was re-serialized; source-code cookie was left alone.
1128+
expect(mockSerialize).toHaveBeenCalledTimes(1);
1129+
expect(mockSerialize).toHaveBeenCalledWith(JSON.stringify({ deviceType: 'mobile' }), expect.any(Object));
1130+
expect(result.setCookieHeaders).toHaveLength(1);
1131+
});
10751132
});
10761133
});

src/lib/shopper-context/server-utils.server.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,9 @@ function extractQualifiersFromEntries(entries: Iterable<[string, string]>): {
127127
apiFieldName =
128128
qualifierMapping[QUALIFIER_MAPPING_API_FIELD_NAME] ?? qualifierMapping[QUALIFIER_MAPPING_PARAM_NAME];
129129
if (apiFieldName === SOURCE_CODE_API_FIELD_NAME) {
130-
sourceCodeQualifiers[apiFieldName] = searchParamValue.trim();
130+
// Strip CR/LF before the value reaches the bare-string `dwsourcecode_*` cookie
131+
// (Node `Headers.append` rejects CR/LF in Set-Cookie values, dropping the write).
132+
sourceCodeQualifiers[apiFieldName] = searchParamValue.replace(/[\r\n]/g, '').trim();
131133
} else {
132134
qualifiers[apiFieldName] = normalizeArrayQualifierValue(apiFieldName, searchParamValue);
133135
}
@@ -241,9 +243,10 @@ export async function updateShopperContext({
241243
const currentShopperContext = cookieHeader
242244
? parseJsonToStringRecord(await shopperContextCookieHandler.parse(cookieHeader))
243245
: {};
244-
const currentSourceCodeContext = cookieHeader
245-
? parseJsonToStringRecord(await sourceCodeCookieHandler.parse(cookieHeader))
246-
: {};
246+
// Source-code cookie is stored as a bare string for SFRA hybrid compatibility — SFRA reads
247+
// the same `dwsourcecode_*` cookie name and expects the plain source-code value.
248+
const rawSourceCode = cookieHeader ? await sourceCodeCookieHandler.parse(cookieHeader) : null;
249+
const currentSourceCodeContext: Record<string, string> = rawSourceCode ? { sourceCode: rawSourceCode } : {};
247250

248251
// Compute effective context by merging new with current
249252
const effectiveShopperContext = computeEffectiveShopperContext(newShopperContext, currentShopperContext);
@@ -262,10 +265,12 @@ export async function updateShopperContext({
262265
await createShopperContext(context, usid, shopperContextBody);
263266
}
264267

265-
// Serialize updated cookies as Set-Cookie headers (cookie-utils stores string values; we JSON-encode objects)
268+
// Serialize updated cookies as Set-Cookie headers. The shopper-context cookie is JSON-encoded
269+
// (carries multiple qualifiers); the source-code cookie is a bare string so SFRA storefronts
270+
// sharing the same `dwsourcecode_*` cookie name can read it directly.
266271
try {
267272
if (hasNewSourceCodeContext) {
268-
const header = await sourceCodeCookieHandler.serialize(JSON.stringify(effectiveSourceCodeContext), {
273+
const header = await sourceCodeCookieHandler.serialize(effectiveSourceCodeContext.sourceCode ?? '', {
269274
maxAge: SOURCE_CODE_COOKIE_EXPIRY_SECONDS,
270275
});
271276
setCookieHeaders.push(header);

0 commit comments

Comments
 (0)