@faststore/core 4.9.3-dev.0 → 4.9.3-dev.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
1
 
2
- > @faststore/core@4.9.2 generate /home/runner/work/faststore/faststore/packages/core
2
+ > @faststore/core@4.9.3-dev.1 generate /home/runner/work/faststore/faststore/packages/core
3
3
  > pnpm run gen-types && pnpm run cache-graphql
4
4
 
5
5
 
6
- > @faststore/core@4.9.2 gen-types /home/runner/work/faststore/faststore/packages/core
6
+ > @faststore/core@4.9.3-dev.1 gen-types /home/runner/work/faststore/faststore/packages/core
7
7
  > node ../cli/bin/run generate-types .
8
8
 
9
9
  [STARTED] Parse Configuration
@@ -19,7 +19,7 @@
19
19
  [COMPLETED] Generate to @generated/
20
20
  [COMPLETED] Generate outputs
21
21
 
22
- > @faststore/core@4.9.2 cache-graphql /home/runner/work/faststore/faststore/packages/core
22
+ > @faststore/core@4.9.3-dev.1 cache-graphql /home/runner/work/faststore/faststore/packages/core
23
23
  > node ../cli/bin/run cache-graphql --config=./discovery.config.default.js --queries=./@generated/persisted-documents.json
24
24
 
25
25
  [Info] - Config file location: /home/runner/work/faststore/faststore/packages/core/discovery.config.default.js
@@ -1,5 +1,5 @@
1
1
 
2
- > @faststore/core@4.9.2 test /home/runner/work/faststore/faststore/packages/core
2
+ > @faststore/core@4.9.3-dev.1 test /home/runner/work/faststore/faststore/packages/core
3
3
  > vitest run
4
4
 
5
5
 
@@ -7,23 +7,27 @@
7
7
 
8
8
  Not implemented: navigation to another Document
9
9
  Not implemented: navigation to another Document
10
- ✓  browser  test/sdk/ui/useScrollRestoration.browser.test.ts (43 tests) 1025ms
11
10
  Browserslist: browsers data (caniuse-lite) is 8 months old. Please run:
12
11
  npx update-browserslist-db@latest
13
12
  Why you should do it regularly: https://github.com/browserslist/update-db#readme
14
- ✓  browser  test/components/account/cards/MyAccountListCards.browser.test.tsx (16 tests) 2299ms
15
- ✓ shows the tab bar and renders Personal cards by default  888ms
16
- ✓ paginates when there are more than 25 cards  355ms
17
- ✓  browser  test/components/account/ContractSwitcher.browser.test.tsx (22 tests) 2772ms
18
- ✓ shows an error state when the list fails to load  455ms
19
- ✓  browser  test/components/account/orders/BuyingPolicyAlert.browser.test.tsx (13 tests) 2283ms
20
- ✓ opens the reject confirmation modal with the default English copy  1093ms
21
- ✓  browser  test/components/account/OrganizationDrawer.browser.test.tsx (10 tests) 1261ms
22
- ✓ renders the menu view with contract details and manage footer  697ms
23
- ✓  browser  test/components/CartRecommendationShelf/CartRecommendationShelf.browser.test.tsx (13 tests) 286ms
24
- ✓  browser  test/sdk/localization/useBindingSelector.browser.test.tsx (9 tests) 126ms
25
- ✓  browser  test/components/ui/ProductGallery/GalleryPageHeightLock.browser.test.tsx (8 tests) 107ms
26
- ✓  browser  test/sdk/session/reconcileSessionLocale.browser.test.ts (5 tests) 17ms
13
+ ✓  browser  test/sdk/ui/useScrollRestoration.browser.test.ts (43 tests) 1011ms
14
+ ✓  browser  test/components/account/ContractSwitcher.browser.test.tsx (22 tests) 2790ms
15
+ ✓ shows an error state when the list fails to load  760ms
16
+ ✓ requires selecting a contract before Confirm is enabled, then switches (REQ-03)  308ms
17
+ ✓  browser  test/components/account/cards/MyAccountListCards.browser.test.tsx (16 tests) 3127ms
18
+ ✓ shows the tab bar and renders Personal cards by default  1504ms
19
+ ✓ paginates when there are more than 25 cards  504ms
20
+ ✓  browser  test/components/account/orders/BuyingPolicyAlert.browser.test.tsx (13 tests) 2487ms
21
+ ✓ opens the reject confirmation modal with the default English copy  805ms
22
+ ✓ opens the reject confirmation modal with CMS-provided labels  387ms
23
+ ✓  browser  test/components/CartRecommendationShelf/CartRecommendationShelf.browser.test.tsx (13 tests) 457ms
24
+ ✓  browser  test/sdk/session/reconcileSessionLocale.browser.test.ts (6 tests) 22ms
25
+ ✓  browser  test/components/account/OrganizationDrawer.browser.test.tsx (10 tests) 1931ms
26
+ ✓ renders the menu view with contract details and manage footer  1038ms
27
+ ✓ opens the contract switcher and returns to the menu on back  359ms
28
+ ✓  browser  test/sdk/localization/useBindingSelector.browser.test.tsx (9 tests) 121ms
29
+ ✓  browser  test/components/ui/ProductGallery/GalleryPageHeightLock.browser.test.tsx (8 tests) 83ms
30
+ ✓  browser  test/sdk/recommendations/useRecommendationShelf.browser.test.tsx (12 tests) 92ms
27
31
  stderr | test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx
28
32
  Error in optimistic validation: TypeError: Failed to parse URL from /api/graphql?operationName=ValidateCartMutation&operationHash=3c71b0d3fc2bade14df68abfa271858beb361b12
29
33
   at node:internal/deps/undici/undici:16416:13
@@ -53,12 +57,10 @@ Browserslist: browsers data (caniuse-lite) is 8 months old. Please run:
53
57
  stderr | test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx > RecommendationShelf > renders nothing when there is an error
54
58
  Error fetching recommendations undefined boom for campaign vrn:recommendations:acc:rec-top-items-v2:campaign-1
55
59
 
56
- ✓  browser  test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx (8 tests) 95ms
57
- ✓  browser  test/sdk/recommendations/useRecommendationShelf.browser.test.tsx (12 tests) 42ms
58
- ✓  browser  test/components/CartSidebar/CartSidebar.browser.test.tsx (5 tests) 56ms
59
- ✓  browser  test/components/sections/Account/AccountOrderDetails.browser.test.tsx (4 tests) 113ms
60
- ✓  browser  test/components/navigation/Navbar.browser.test.tsx (3 tests) 51ms
61
- ✓  browser  test/sdk/session/getInitialSession.browser.test.ts (6 tests) 7ms
60
+ ✓  browser  test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx (8 tests) 119ms
61
+ ✓  browser  test/sdk/session/getInitialSession.browser.test.ts (7 tests) 9ms
62
+ ✓  browser  test/components/CartSidebar/CartSidebar.browser.test.tsx (5 tests) 83ms
63
+ ✓  browser  test/components/sections/Account/AccountOrderDetails.browser.test.tsx (4 tests) 69ms
62
64
  stderr | test/pages/password-protection.browser.test.tsx > PasswordProtectionLogin > posts the password and redirects to the requested return path
63
65
  Warning: An update to PasswordProtectionLogin inside a test was not wrapped in act(...).
64
66
 
@@ -144,6 +146,8 @@ act(() => {
144
146
  This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act
145
147
  at PasswordProtectionLogin (/home/runner/work/faststore/faststore/packages/core/src/pages/password-protection/index.tsx:19:69)
146
148
 
149
+ ✓  browser  test/components/navigation/Navbar.browser.test.tsx (3 tests) 100ms
150
+ ✓  browser  test/components/account/OrganizationDrawerFooter.browser.test.tsx (8 tests) 508ms
147
151
  stderr | test/pages/password-protection.browser.test.tsx > PasswordProtectionLogin > uses the fallback invalid-password message when API omits an error
148
152
  Warning: An update to PasswordProtectionLogin inside a test was not wrapped in act(...).
149
153
 
@@ -288,7 +292,8 @@ act(() => {
288
292
  This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act
289
293
  at PasswordProtectionLogin (/home/runner/work/faststore/faststore/packages/core/src/pages/password-protection/index.tsx:19:69)
290
294
 
291
- ✓  browser  test/pages/password-protection.browser.test.tsx (6 tests) 632ms
295
+ ✓  browser  test/pages/password-protection.browser.test.tsx (6 tests) 983ms
296
+ ✓ renders the password protection form  410ms
292
297
  stderr | test/components/cms/RenderSections.browser.test.tsx
293
298
  Error in optimistic validation: TypeError: Cannot read properties of undefined (reading 'read')
294
299
  at waitForSessionValidated (/home/runner/work/faststore/faststore/packages/core/src/sdk/cart/waitForSessionValidated.ts:16:32)
@@ -297,10 +302,9 @@ This ensures that you're testing the behavior the user would see in the browser.
297
302
  at a (/home/runner/work/faststore/faststore/packages/sdk/dist/es/index.mjs:357:23)
298
303
   at processTicksAndRejections (node:internal/process/task_queues:103:5)
299
304
 
300
- ✓  browser  test/components/account/OrganizationDrawerFooter.browser.test.tsx (8 tests) 298ms
301
- ✓  browser  test/sdk/account/useSwitchContract.browser.test.ts (5 tests) 31ms
302
- ✓  browser  test/components/cms/RenderSections.browser.test.tsx (5 tests) 62ms
303
- ✓  browser  test/components/navigation/NavbarSlider.browser.test.tsx (2 tests) 37ms
305
+ ✓  browser  test/sdk/account/useSwitchContract.browser.test.ts (5 tests) 56ms
306
+ ✓  browser  test/components/navigation/NavbarSlider.browser.test.tsx (2 tests) 42ms
307
+ ✓  browser  test/components/cms/RenderSections.browser.test.tsx (5 tests) 92ms
304
308
  stderr | test/sdk/localization/store-url.browser.test.ts
305
309
  Error in optimistic validation: TypeError: Cannot read properties of undefined (reading 'read')
306
310
  at waitForSessionValidated (/home/runner/work/faststore/faststore/packages/core/src/sdk/cart/waitForSessionValidated.ts:16:32)
@@ -309,15 +313,15 @@ This ensures that you're testing the behavior the user would see in the browser.
309
313
  at a (/home/runner/work/faststore/faststore/packages/sdk/dist/es/index.mjs:357:23)
310
314
   at processTicksAndRejections (node:internal/process/task_queues:103:5)
311
315
 
312
- ✓  browser  test/components/account/Menu.browser.test.tsx (6 tests) 279ms
313
- ✓  browser  test/sdk/localization/store-url.browser.test.ts (3 tests) 4ms
314
- ✓  browser  test/sdk/account/useAvailableContracts.browser.test.ts (6 tests) 27ms
315
- ✓  browser  test/components/templates/SearchPage/SearchWrapper.browser.test.tsx (3 tests) 30ms
316
- ✓  browser  test/sdk/recommendations/useRecommendationShelf.sessionFallback.browser.test.tsx (2 tests) 124ms
317
- ✓  browser  test/components/sections/Account/AccountListQuotes.browser.test.tsx (4 tests) 95ms
318
- ✓  browser  test/components/navigation/NavbarAccountArea.browser.test.tsx (4 tests) 47ms
319
- ✓  browser  test/components/CartRecommendationShelf/CartRecommendationProductCard.browser.test.tsx (3 tests) 47ms
320
- ✓  browser  test/sdk/account/changeContractToken.browser.test.ts (9 tests) 18ms
316
+ ✓  browser  test/sdk/localization/store-url.browser.test.ts (3 tests) 6ms
317
+ ✓  browser  test/components/account/Menu.browser.test.tsx (6 tests) 385ms
318
+ ✓  browser  test/sdk/account/useAvailableContracts.browser.test.ts (6 tests) 42ms
319
+ ✓  browser  test/components/templates/SearchPage/SearchWrapper.browser.test.tsx (3 tests) 35ms
320
+ ✓  browser  test/sdk/recommendations/useRecommendationShelf.sessionFallback.browser.test.tsx (2 tests) 128ms
321
+ ✓  browser  test/components/sections/Account/AccountListQuotes.browser.test.tsx (4 tests) 135ms
322
+ ✓  browser  test/components/navigation/NavbarAccountArea.browser.test.tsx (4 tests) 78ms
323
+ ✓  browser  test/sdk/account/changeContractToken.browser.test.ts (9 tests) 22ms
324
+ ✓  browser  test/components/CartRecommendationShelf/CartRecommendationProductCard.browser.test.tsx (3 tests) 54ms
321
325
  stderr | test/components/account/doLogout.browser.test.ts
322
326
  Error in optimistic validation: TypeError: Cannot read properties of undefined (reading 'read')
323
327
  at waitForSessionValidated (/home/runner/work/faststore/faststore/packages/core/src/sdk/cart/waitForSessionValidated.ts:16:32)
@@ -326,9 +330,9 @@ This ensures that you're testing the behavior the user would see in the browser.
326
330
  at a (/home/runner/work/faststore/faststore/packages/sdk/dist/es/index.mjs:357:23)
327
331
   at processTicksAndRejections (node:internal/process/task_queues:103:5)
328
332
 
329
- ✓  browser  test/components/account/doLogout.browser.test.ts (1 test) 25ms
330
- ✓  browser  test/components/account/OrganizationDrawerHeader.browser.test.tsx (5 tests) 265ms
331
- ✓  browser  test/components/ui/LocalizationButton.browser.test.tsx (3 tests) 37ms
333
+ ✓  browser  test/components/account/doLogout.browser.test.ts (1 test) 39ms
334
+ ✓  browser  test/components/ui/LocalizationButton.browser.test.tsx (3 tests) 48ms
335
+ ✓  browser  test/components/account/OrganizationDrawerHeader.browser.test.tsx (5 tests) 359ms
332
336
  stderr | test/sdk/account/clearPersistedSessionState.browser.test.ts
333
337
  Error in optimistic validation: TypeError: Cannot read properties of undefined (reading 'read')
334
338
  at waitForSessionValidated (/home/runner/work/faststore/faststore/packages/core/src/sdk/cart/waitForSessionValidated.ts:16:32)
@@ -337,14 +341,14 @@ This ensures that you're testing the behavior the user would see in the browser.
337
341
  at a (/home/runner/work/faststore/faststore/packages/sdk/dist/es/index.mjs:357:23)
338
342
   at processTicksAndRejections (node:internal/process/task_queues:103:5)
339
343
 
340
- ✓  browser  test/sdk/account/clearPersistedSessionState.browser.test.ts (4 tests) 14ms
341
- ✓  browser  test/components/sections/Account/AccountOrderDelivery.browser.test.tsx (2 tests) 71ms
342
- ✓  browser  test/components/account/orders/DeliveryOptionAccordion.browser.test.tsx (2 tests) 80ms
343
- ✓  browser  test/components/sections/Account/AccountOrderStatus.browser.test.tsx (3 tests) 77ms
344
- ✓  browser  test/components/sections/Account/AccountListCards.browser.test.tsx (3 tests) 67ms
345
- ✓  browser  test/components/region/useRegion.browser.test.ts (1 test) 20ms
346
- ✓  browser  test/components/sections/Account/AccountOrdersList.browser.test.tsx (2 tests) 22ms
347
- ✓  browser  test/components/account/orders/SummaryCard.browser.test.tsx (2 tests) 77ms
344
+ ✓  browser  test/sdk/account/clearPersistedSessionState.browser.test.ts (4 tests) 19ms
345
+ ✓  browser  test/components/sections/Account/AccountOrderDelivery.browser.test.tsx (2 tests) 102ms
346
+ ✓  browser  test/components/account/orders/DeliveryOptionAccordion.browser.test.tsx (2 tests) 104ms
347
+ ✓  browser  test/components/sections/Account/AccountOrderStatus.browser.test.tsx (3 tests) 144ms
348
+ ✓  browser  test/components/region/useRegion.browser.test.ts (1 test) 17ms
349
+ ✓  browser  test/components/sections/Account/AccountListCards.browser.test.tsx (3 tests) 92ms
350
+ ✓  browser  test/components/sections/Account/AccountOrdersList.browser.test.tsx (2 tests) 30ms
351
+ ✓  browser  test/components/account/orders/SummaryCard.browser.test.tsx (2 tests) 105ms
348
352
  stderr | test/sdk/session/sessionStore.browser.test.ts
349
353
  Error in optimistic validation: TypeError: Cannot read properties of undefined (reading 'read')
350
354
  at waitForSessionValidated (/home/runner/work/faststore/faststore/packages/core/src/sdk/cart/waitForSessionValidated.ts:16:32)
@@ -353,9 +357,9 @@ This ensures that you're testing the behavior the user would see in the browser.
353
357
  at a (/home/runner/work/faststore/faststore/packages/sdk/dist/es/index.mjs:357:23)
354
358
   at processTicksAndRejections (node:internal/process/task_queues:103:5)
355
359
 
356
- ✓  browser  test/sdk/session/sessionStore.browser.test.ts (2 tests) 9ms
357
- ✓  browser  test/components/account/OrganizationSignInButton.browser.test.tsx (2 tests) 186ms
358
- ✓  browser  test/components/account/orders/DeliveryOptionAccordionDeliveryInfo.browser.test.tsx (1 test) 26ms
360
+ ✓  browser  test/sdk/session/sessionStore.browser.test.ts (2 tests) 8ms
361
+ ✓  browser  test/components/account/OrganizationSignInButton.browser.test.tsx (2 tests) 270ms
362
+ ✓  browser  test/components/account/orders/DeliveryOptionAccordionDeliveryInfo.browser.test.tsx (1 test) 31ms
359
363
  stderr | test/sdk/session/validateSession.browser.test.ts
360
364
  Error in optimistic validation: TypeError: Cannot read properties of undefined (reading 'read')
361
365
  at waitForSessionValidated (/home/runner/work/faststore/faststore/packages/core/src/sdk/cart/waitForSessionValidated.ts:16:32)
@@ -364,14 +368,14 @@ This ensures that you're testing the behavior the user would see in the browser.
364
368
  at a (/home/runner/work/faststore/faststore/packages/sdk/dist/es/index.mjs:357:23)
365
369
   at processTicksAndRejections (node:internal/process/task_queues:103:5)
366
370
 
367
- ✓  browser  test/sdk/session/validateSession.browser.test.ts (1 test) 4ms
368
- ✓  browser  test/components/account/orders/StatusCard.browser.test.tsx (2 tests) 88ms
369
- ✓  browser  test/utils/isLocalHost.browser.test.ts (3 tests) 3ms
371
+ ✓  browser  test/sdk/session/validateSession.browser.test.ts (1 test) 7ms
372
+ ✓  browser  test/components/account/orders/StatusCard.browser.test.tsx (2 tests) 107ms
373
+ ✓  browser  test/utils/isLocalHost.browser.test.ts (3 tests) 4ms
370
374
  ✓  browser  test/sdk/session/reconcileSessionLocale.disabled.browser.test.ts (1 test) 3ms
371
- ✓  browser  test/components/account/orders/DeliveryCard.browser.test.tsx (1 test) 36ms
372
- ✓  browser  test/components/account/orders/DeliveryOptionAccordionProducts.browser.test.tsx (1 test) 37ms
373
- ✓  browser  test/components/skeletons/ProductGridSkeleton.browser.test.tsx (2 tests) 30ms
374
- ✓  node  test/utils/localization/bindingPaths.test.ts (71 tests) 19ms
375
+ ✓  browser  test/components/account/orders/DeliveryCard.browser.test.tsx (1 test) 63ms
376
+ ✓  browser  test/components/account/orders/DeliveryOptionAccordionProducts.browser.test.tsx (1 test) 51ms
377
+ ✓  browser  test/components/skeletons/ProductGridSkeleton.browser.test.tsx (2 tests) 45ms
378
+ ✓  node  test/utils/localization/bindingPaths.test.ts (71 tests) 31ms
375
379
  stderr | test/sdk/account/refreshToken.browser.test.ts
376
380
  Error in optimistic validation: TypeError: Cannot read properties of undefined (reading 'read')
377
381
  at waitForSessionValidated (/home/runner/work/faststore/faststore/packages/core/src/sdk/cart/waitForSessionValidated.ts:16:32)
@@ -380,13 +384,14 @@ This ensures that you're testing the behavior the user would see in the browser.
380
384
  at a (/home/runner/work/faststore/faststore/packages/sdk/dist/es/index.mjs:357:23)
381
385
   at processTicksAndRejections (node:internal/process/task_queues:103:5)
382
386
 
383
- ✓  browser  test/sdk/account/refreshToken.browser.test.ts (1 test) 4ms
384
- ✓  node  test/server/password-protection-service.test.ts (22 tests) 27ms
385
- ✓  node  test/pages/api/switch-contract.test.ts (25 tests) 71ms
386
- ✓  browser  test/components/account/orders/FilterFacetPendingApproval.browser.test.tsx (1 test) 156ms
387
- ✓  node  test/utils/match-url.test.ts (10 tests) 5ms
388
- ✓  node  test/sdk/localization/useBindingSelector.test.tsx (22 tests) 12ms
389
- ✓  node  test/pages/api/unlock.test.ts (15 tests) 124ms
387
+ ✓  browser  test/sdk/account/refreshToken.browser.test.ts (1 test) 6ms
388
+ ✓  node  test/server/password-protection-service.test.ts (22 tests) 37ms
389
+ ✓  node  test/pages/api/switch-contract.test.ts (25 tests) 92ms
390
+ ✓  browser  test/components/account/orders/FilterFacetPendingApproval.browser.test.tsx (1 test) 230ms
391
+ ✓  node  test/utils/match-url.test.ts (10 tests) 9ms
392
+ ✓  node  test/sdk/localization/useBindingSelector.test.tsx (22 tests) 15ms
393
+ ✓  node  test/pages/api/unlock.test.ts (15 tests) 141ms
394
+ ✓  node  test/sdk/session/syncSalesChannelFromOrderForm.test.ts (14 tests) 12ms
390
395
  stderr | test/components/search/SearchInputComponent.test.tsx
391
396
  Warning: forwardRef render functions accept exactly two parameters: props and ref. Did you forget to use the ref parameter?
392
397
 
@@ -450,24 +455,24 @@ Warning: React does not recognize the `providerProps` prop on a DOM element. If
450
455
  at Suspense
451
456
  at Wrapper (/home/runner/work/faststore/faststore/packages/core/test/components/search/SearchInputComponent.test.tsx:106:20)
452
457
 
453
- ✓  node  test/sdk/localization/bindingSelector.test.ts (18 tests) 8ms
454
- ✓  node  test/components/search/SearchInputComponent.test.tsx (6 tests) 63ms
455
- ✓  node  test/components/quotes/MyAccountListQuotesTable.test.tsx (15 tests) 319ms
456
- ✓  node  test/sdk/search/useSearchHistory.test.ts (13 tests) 32ms
458
+ ✓  node  test/sdk/localization/bindingSelector.test.ts (18 tests) 12ms
459
+ ✓  node  test/components/search/SearchInputComponent.test.tsx (6 tests) 94ms
460
+ ✓  node  test/components/quotes/MyAccountListQuotesTable.test.tsx (15 tests) 474ms
461
+ ✓  node  test/sdk/search/useSearchHistory.test.ts (13 tests) 48ms
457
462
  stderr | test/pages/pvt/account/cards/index.test.ts > Cards page getServerSideProps > redirects to /pvt/account/403 when the query fails with 401/403
458
463
  { extensions: { status: 401 } }
459
464
 
460
465
  stderr | test/pages/pvt/account/cards/index.test.ts > Cards page getServerSideProps > renders the error state (no redirect) when the query fails with a non-auth status
461
466
  { extensions: { status: 502 } }
462
467
 
463
- ✓  node  test/pages/pvt/account/cards/index.test.ts (8 tests) 8ms
464
- ✓  node  test/components/quotes/MyAccountListQuotes.test.tsx (11 tests) 800ms
468
+ ✓  node  test/pages/pvt/account/cards/index.test.ts (8 tests) 11ms
469
+ ✓  node  test/components/quotes/MyAccountListQuotes.test.tsx (11 tests) 832ms
465
470
  ✓ navigates with the search term on blur  358ms
466
- ✓ navigates without a label param when the search is cleared  356ms
467
- ✓  node  test/components/quotes/quoteFilters.test.ts (26 tests) 28ms
468
- ✓  node  test/utils/cookieCacheBusting.test.ts (10 tests) 11ms
469
- ✓  node  test/utils/productJsonLd.test.ts (36 tests) 11ms
470
- ✓  node  test/experimental/myAccountCmsServerSideProps.test.ts (5 tests) 8ms
471
+ ✓ navigates without a label param when the search is cleared  360ms
472
+ ✓  node  test/components/quotes/quoteFilters.test.ts (26 tests) 29ms
473
+ ✓  node  test/utils/productJsonLd.test.ts (36 tests) 14ms
474
+ ✓  node  test/utils/cookieCacheBusting.test.ts (10 tests) 17ms
475
+ ✓  node  test/experimental/myAccountCmsServerSideProps.test.ts (5 tests) 22ms
471
476
  stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > propagates the upstream 400 from a wrapped BadRequestError instead of 500
472
477
  Graphql execution returned with error: [
473
478
  {
@@ -535,7 +540,7 @@ Warning: React does not recognize the `providerProps` prop on a DOM element. If
535
540
 
536
541
  stderr | test/pages/api/graphql.test.ts > /api/graphql request handling > maps UnauthorizedError thrown by execute to its status
537
542
  Something unexpected occurred querying Graphql endpoint:
538
- ie [FastStoreError]: expired
543
+ ce [FastStoreError]: expired
539
544
  at /home/runner/work/faststore/faststore/packages/core/test/pages/api/graphql.test.ts:202:33
540
545
  at /home/runner/work/faststore/faststore/packages/core/test/pages/api/graphql.test.ts:207:39
541
546
  at file:///home/runner/work/faststore/faststore/node_modules/.pnpm/@vitest+runner@4.0.18/node_modules/@vitest/runner/dist/index.js:1024:60
@@ -577,71 +582,73 @@ Warning: React does not recognize the `providerProps` prop on a DOM element. If
577
582
  at trace (file:///home/runner/work/faststore/faststore/node_modules/.pnpm/vitest@4.0.18_@opentelemetry+api@1.9.0_@types+node@20.19.13_jiti@2.6.1_jsdom@27.4.0_sas_13be941c57d55f08f06eeaf1c180ddfe/node_modules/vitest/dist/chunks/test.B8ej_ZHS.js:239:21)
578
583
  at runTest (file:///home/runner/work/faststore/faststore/node_modules/.pnpm/@vitest+runner@4.0.18/node_modules/@vitest/runner/dist/index.js:1653:12)
579
584
 
580
- ✓  node  test/pages/api/graphql.test.ts (19 tests) 28ms
581
- ✓  node  test/utils/clearCookies.test.ts (20 tests) 20ms
582
- ✓  node  test/utils/multipleTemplates.test.ts (8 tests) 6ms
585
+ ✓  node  test/pages/api/graphql.test.ts (19 tests) 31ms
586
+ ✓  node  test/utils/clearCookies.test.ts (20 tests) 28ms
587
+ ✓  node  test/utils/multipleTemplates.test.ts (8 tests) 5ms
583
588
  stderr | test/sdk/account/useSetPassword.test.ts > useSetPassword > maps wrongcredentials error responses to the existing message
584
589
  Set password request failed: Bad Request
585
590
 
586
- ✓  node  test/sdk/account/useSetPassword.test.ts (6 tests) 38ms
587
- ✓  node  test/sdk/orderEntry/useOrderEntryOperation.test.ts (8 tests) 30ms
588
- ✓  node  test/utils/localization/localizeRedirectDestination.test.ts (11 tests) 5ms
589
- ✓  node  test/components/quotes/MyAccountQuotesFilterSlider.test.tsx (8 tests) 159ms
590
- ✓  node  test/components/quotes/MyAccountQuotesStatusSelector.test.tsx (14 tests) 163ms
591
- ✓  node  test/utils/localization/hreflang.test.ts (8 tests) 4ms
592
- ✓  node  test/sdk/account/getMyAccountRoutes.test.ts (13 tests) 7ms
593
- ✓  node  test/components/quotes/MyAccountQuotesSelectedTags.test.tsx (10 tests) 85ms
594
- ✓  node  test/sdk/redirects/index.test.ts (7 tests) 37ms
595
- ✓  node  test/sdk/orderEntry/useOrderEntryUpload.test.ts (8 tests) 36ms
596
- ✓  node  test/proxy.localization.test.ts (5 tests) 37ms
591
+ ✓  node  test/sdk/account/useSetPassword.test.ts (6 tests) 31ms
592
+ ✓  node  test/sdk/orderEntry/useOrderEntryOperation.test.ts (8 tests) 43ms
593
+ ✓  node  test/utils/localization/localizeRedirectDestination.test.ts (11 tests) 7ms
594
+ ✓  node  test/components/quotes/MyAccountQuotesFilterSlider.test.tsx (8 tests) 226ms
595
+ ✓  node  test/components/quotes/MyAccountQuotesStatusSelector.test.tsx (14 tests) 190ms
596
+ ✓  node  test/utils/localization/hreflang.test.ts (8 tests) 7ms
597
+ ✓  node  test/sdk/account/getMyAccountRoutes.test.ts (13 tests) 8ms
598
+ ✓  node  test/components/quotes/MyAccountQuotesSelectedTags.test.tsx (10 tests) 124ms
599
+ ✓  node  test/sdk/redirects/index.test.ts (7 tests) 51ms
600
+ ✓  node  test/sdk/orderEntry/useOrderEntryUpload.test.ts (8 tests) 58ms
601
+ ✓  node  test/proxy.localization.test.ts (5 tests) 35ms
602
+ ✓  node  test/sdk/orderEntry/useOrderEntry.test.ts (8 tests) 49ms
597
603
  ✓  node  test/components/account/orders/getDeliveryOptionLabel.test.ts (21 tests) 7ms
598
- ✓  node  test/sdk/orderEntry/useOrderEntry.test.ts (8 tests) 40ms
599
- ✓  node  test/sdk/graphql/retryPolicy.test.ts (18 tests) 22ms
600
- ✓  node  test/server/cms/myAccountDefaultSections.test.ts (9 tests) 6ms
601
- ✓  node  test/utils/productJsonLdRendering.test.tsx (5 tests) 13ms
602
- ✓  node  test/sdk/session/syncSalesChannelFromOrderForm.test.ts (6 tests) 5ms
603
- ✓  node  test/server/index.test.ts (7 tests) 786ms
604
- ✓  node  test/proxy.test.ts (4 tests) 13ms
605
- ✓  node  test/server/webops-api.test.ts (9 tests) 11ms
606
- ✓  node  test/sdk/analytics/useStartRecommendationSession.test.ts (6 tests) 40ms
607
- ✓  node  test/components/account/orders/StatusCard.test.ts (3 tests) 25ms
608
- ✓  node  test/sdk/localization/store-url.test.ts (4 tests) 4ms
609
- ✓  node  test/sdk/orderEntry/useOrderFormItems.test.ts (7 tests) 27ms
610
- ✓  node  test/sdk/cart/waitForSessionValidated.test.ts (5 tests) 9ms
611
- ✓  node  test/sdk/account/refreshToken.test.ts (5 tests) 10ms
604
+ ✓  node  test/sdk/graphql/retryPolicy.test.ts (18 tests) 23ms
605
+ ✓  node  test/server/cms/myAccountDefaultSections.test.ts (9 tests) 8ms
606
+ ✓  node  test/utils/productJsonLdRendering.test.tsx (5 tests) 12ms
607
+ ✓  node  test/utils/trustedForwardedHost.test.ts (27 tests) 8ms
608
+ ✓  node  test/server/index.test.ts (7 tests) 746ms
609
+ ✓  node  test/proxy.test.ts (4 tests) 23ms
610
+ ✓  node  test/server/webops-api.test.ts (9 tests) 14ms
611
+ ✓  node  test/sdk/localization/store-url.test.ts (4 tests) 5ms
612
+ ✓  node  test/sdk/analytics/useStartRecommendationSession.test.ts (6 tests) 77ms
613
+ ✓  node  test/components/account/orders/StatusCard.test.ts (3 tests) 32ms
614
+ ✓  node  test/sdk/orderEntry/useOrderFormItems.test.ts (7 tests) 41ms
615
+ ✓  node  test/sdk/cart/waitForSessionValidated.test.ts (5 tests) 13ms
616
+ ✓  node  test/sdk/account/refreshToken.test.ts (5 tests) 12ms
612
617
  ✓  node  test/server/content/service.test.ts (5 tests) 4ms
613
- ✓  node  test/server/cms/global.test.ts (3 tests) 3ms
614
- ✓  node  test/sdk/auth/useAuth.test.ts (5 tests) 33ms
618
+ ✓  node  test/server/cms/global.test.ts (3 tests) 6ms
619
+ ✓  node  test/sdk/auth/useAuth.test.ts (5 tests) 44ms
615
620
  ✓  node  test/utils/getRequestHostname.test.ts (12 tests) 5ms
616
- ✓  node  test/components/account/Layout.test.tsx (6 tests) 46ms
617
- ✓  node  test/server/rootSpan.test.ts (4 tests) 39ms
618
- ✓  node  test/pages/api/preview.test.ts (2 tests) 7ms
619
- ✓  node  test/utils/validateSessionRefreshToken.test.ts (6 tests) 4ms
620
- ✓  node  test/components/search/SearchInput.test.ts (6 tests) 6ms
621
- ✓  node  test/sdk/recommendations/vrn.test.ts (5 tests) 3ms
621
+ ✓  node  test/components/account/Layout.test.tsx (6 tests) 77ms
622
+ ✓  node  test/server/rootSpan.test.ts (4 tests) 48ms
623
+ ✓  node  test/pages/api/preview.test.ts (2 tests) 9ms
624
+ ✓  node  test/components/search/SearchInput.test.ts (6 tests) 13ms
625
+ ✓  node  test/utils/validateSessionRefreshToken.test.ts (6 tests) 6ms
626
+ ✓  node  test/sdk/recommendations/vrn.test.ts (5 tests) 6ms
627
+ ✓  node  test/components/region/useRegion.test.ts (1 test) 24ms
622
628
  ✓  node  test/server/cms/index.test.ts (2 tests) 4ms
623
- ✓  node  test/utils/retry.test.ts (5 tests) 12ms
624
- ✓  node  test/sdk/account/getB2BSessionClaims.test.ts (4 tests) 5ms
625
- ✓  node  test/utils/userOrderStatus.test.ts (4 tests) 4ms
626
- ✓  node  test/sdk/recommendations/useRecommendationUserId.test.ts (4 tests) 190ms
627
- ✓  node  test/components/account/OrganizationDrawerBody.test.tsx (3 tests) 33ms
629
+ ✓  node  test/utils/retry.test.ts (5 tests) 17ms
630
+ ✓  node  test/sdk/account/getB2BSessionClaims.test.ts (4 tests) 7ms
631
+ ✓  node  test/sdk/recommendations/useRecommendationUserId.test.ts (4 tests) 194ms
632
+ ✓  node  test/utils/userOrderStatus.test.ts (4 tests) 5ms
628
633
  ✓  node  test/utils/unlockResponse.test.ts (11 tests) 5ms
629
- ✓  node  test/sdk/recommendations/useRecommendations.test.ts (2 tests) 4ms
630
- ✓  node  test/utils/isLocalHost.test.ts (7 tests) 3ms
631
- ✓  node  test/components/quotes/MyAccountQuoteStatusBadge.test.tsx (4 tests) 41ms
632
- ✓  node  test/sdk/session/toSessionInput.test.ts (3 tests) 3ms
634
+ ✓  node  test/sdk/recommendations/useRecommendations.test.ts (2 tests) 6ms
635
+ ✓  node  test/components/account/OrganizationDrawerBody.test.tsx (3 tests) 43ms
636
+ ✓  node  test/utils/isLocalHost.test.ts (7 tests) 5ms
637
+ ✓  node  test/sdk/session/toSessionInput.test.ts (3 tests) 5ms
638
+ ✓  node  test/components/quotes/MyAccountQuoteStatusBadge.test.tsx (4 tests) 49ms
633
639
  ✓  node  test/components/account/orders/getOrderTotalLabel.test.ts (6 tests) 4ms
634
640
  ✓  node  test/components/account/orders/listOrdersLabels.test.ts (2 tests) 3ms
635
- ✓  node  test/utils/getCookie.test.ts (4 tests) 3ms
636
- ✓  node  test/sdk/session/isSignInAreaResolved.test.ts (3 tests) 3ms
637
- ✓  node  test/sdk/analytics/utils.test.ts (2 tests) 3ms
638
- ✓  node  test/components/auth/ProfileChallenge.test.tsx (3 tests) 25ms
641
+ ✓  node  test/utils/getCookie.test.ts (4 tests) 5ms
642
+ ✓  node  test/sdk/session/isSignInAreaResolved.test.ts (3 tests) 4ms
643
+ ✓  node  test/utils/filterChannel.test.ts (2 tests) 4ms
644
+ ✓  node  test/components/auth/ProfileChallenge.test.tsx (3 tests) 43ms
639
645
  ✓  node  test/instrumentation.test.ts (3 tests) 5ms
640
- ✓  node  test/components/account/orders/orderDetailsLabels.test.ts (1 test) 3ms
646
+ ✓  node  test/sdk/analytics/utils.test.ts (2 tests) 3ms
647
+ ✓  node  test/components/account/orders/orderDetailsLabels.test.ts (1 test) 4ms
641
648
  ✓  node  test/sdk/account/clearPersistedSessionState.test.ts (1 test) 8ms
642
649
 
643
-  Test Files  127 passed (127)
644
-  Tests  992 passed (992)
645
-  Start at  13:35:02
646
-  Duration  48.01s (transform 11.84s, setup 0ms, import 44.44s, tests 17.34s, environment 57.43s)
650
+  Test Files  130 passed (130)
651
+  Tests  1032 passed (1032)
652
+  Start at  00:35:08
653
+  Duration  62.92s (transform 13.32s, setup 0ms, import 56.50s, tests 21.42s, environment 78.13s)
647
654
 
package/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [4.9.3-dev.2](https://github.com/vtex/faststore/compare/v4.9.3-dev.1...v4.9.3-dev.2) (2026-10-07)
7
+
8
+ ### Bug Fixes
9
+
10
+ - address security & functional concerns from dk review ([d0b6106](https://github.com/vtex/faststore/commit/d0b610688ec551d8105c74750927be4ab1af7224))
11
+ - **core:** harden x-forwarded-host handling and drop orderNumber fallback ([c1faa35](https://github.com/vtex/faststore/commit/c1faa35fe070ff8b51d4f07a5f1f33da4b17d836)), closes [#3178](https://github.com/vtex/faststore/issues/3178)
12
+ - **core:** ignore multi-value x-forwarded-host and cover port and case ([b81d5d9](https://github.com/vtex/faststore/commit/b81d5d91721f9f8205251ad39e607b094386083c))
13
+ - **core:** log ignored x-forwarded-host and cover CR/LF hostnames ([adaa816](https://github.com/vtex/faststore/commit/adaa81608b7e72759272e1d6d23ca6257b9a4d59))
14
+ - **core:** make preview cookies host-only instead of rewriting their domain ([8e4901e](https://github.com/vtex/faststore/commit/8e4901ec7d127336bc27c0ae8e52c9cadea1a5a3))
15
+ - **core:** reject hostnames that would inject Set-Cookie attributes ([059a437](https://github.com/vtex/faststore/commit/059a4377c3b1b8f66aeed2b73ce130e1ab827f77))
16
+ - resolve ValidateCartMutation infinite loop in preview environments (SFS-3034) ([1cc9a5e](https://github.com/vtex/faststore/commit/1cc9a5efb1617f3d032623c4ee5f687ad5e2ccd9)), closes [#3178](https://github.com/vtex/faststore/issues/3178) [#3178](https://github.com/vtex/faststore/issues/3178) [#3190](https://github.com/vtex/faststore/issues/3190) [#3188](https://github.com/vtex/faststore/issues/3188) [#1467584](https://github.com/vtex/faststore/issues/1467584)
17
+
18
+ ## [4.9.3-dev.1](https://github.com/vtex/faststore/compare/v4.9.3-dev.0...v4.9.3-dev.1) (2026-10-06)
19
+
20
+ ### Bug Fixes
21
+
22
+ - keep empty carts from moving the session sales channel ([#3509](https://github.com/vtex/faststore/issues/3509)) ([600fb7d](https://github.com/vtex/faststore/commit/600fb7d9403553c12bd01d6b3955f18c48f91054)), closes [#3435](https://github.com/vtex/faststore/issues/3435) [#3435](https://github.com/vtex/faststore/issues/3435)
23
+
6
24
  ## [4.9.3-dev.0](https://github.com/vtex/faststore/compare/v4.9.1-dev.0...v4.9.3-dev.0) (2026-10-05)
7
25
 
8
26
  **Note:** Version bump only for package @faststore/core
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faststore/core",
3
- "version": "4.9.3-dev.0",
3
+ "version": "4.9.3-dev.2",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -71,11 +71,11 @@
71
71
  "style-loader": "^3.3.1",
72
72
  "swr": "^2.2.5",
73
73
  "use-sync-external-store": "^1.6.0",
74
- "@faststore/api": "4.9.3-dev.0",
75
- "@faststore/lighthouse": "4.9.3-dev.0",
76
- "@faststore/diagnostics": "4.9.3-dev.0",
77
- "@faststore/ui": "4.9.3-dev.0",
78
- "@faststore/sdk": "4.9.3-dev.0"
74
+ "@faststore/api": "4.9.3-dev.2",
75
+ "@faststore/diagnostics": "4.9.3-dev.2",
76
+ "@faststore/lighthouse": "4.9.3-dev.2",
77
+ "@faststore/sdk": "4.9.3-dev.2",
78
+ "@faststore/ui": "4.9.3-dev.2"
79
79
  },
80
80
  "devDependencies": {
81
81
  "@cypress/code-coverage": "^3.12.1",
@@ -41,8 +41,10 @@ export default function useRegion(): UseRegionValues {
41
41
 
42
42
  try {
43
43
  // `session` usually comes from `useSession()`, which also carries UI state
44
+ // and filters session-only channel keys (sales channel markers).
44
45
  const newSession = {
45
46
  ...toSessionInput(session),
47
+ channel: sessionStore.read()?.channel ?? session.channel,
46
48
  postalCode,
47
49
  geoCoordinates: null, // Revalidate geo coordinates in API when users set a new postal code
48
50
  } as Session
@@ -11,6 +11,11 @@ import discoveryConfig from 'discovery.config'
11
11
  import { getJWTAutCookie } from 'src/utils/getCookie'
12
12
  import { getRequestHostname } from 'src/utils/getRequestHostname'
13
13
  import { isLocalHost } from 'src/utils/isLocalHost'
14
+ import {
15
+ isHostAllowed,
16
+ removeCookieDomain,
17
+ singleForwardedHost,
18
+ } from 'src/utils/trustedForwardedHost'
14
19
  import { shouldForceRefreshTokenForValidateSession } from 'src/utils/validateSessionRefreshToken'
15
20
  import { execute } from '../../server'
16
21
  import { logger } from '@faststore/diagnostics'
@@ -56,22 +61,10 @@ const shouldReplaceCookieDomain = ({
56
61
  }
57
62
 
58
63
  /**
59
- * Determines if host is eligible for domain normalization.
60
- */
61
- const isAllowedHost = ({
62
- host,
63
- allowList,
64
- }: {
65
- host: string
66
- allowList: string[]
67
- }) => {
68
- const normalizedHost = host.toLowerCase()
69
-
70
- return allowList.some((suffix) => normalizedHost.endsWith(suffix))
71
- }
72
-
73
- /**
74
- * Ensure the cookie domain matches the current host so the browser can store it.
64
+ * On allowlisted hosts (previews, localhost) the upstream cookie domain does not
65
+ * match the browser host, so the Domain attribute is dropped and the cookie
66
+ * becomes host-only. The host is never written into the cookie, so a forged
67
+ * x-forwarded-host cannot choose the cookie scope.
75
68
  */
76
69
  const normalizeSetCookieDomain = ({
77
70
  request,
@@ -85,20 +78,31 @@ const normalizeSetCookieDomain = ({
85
78
  return setCookie
86
79
  }
87
80
 
88
- const host = getRequestHostname(request.headers.host)
81
+ // The preview ingress forwards a client-supplied x-forwarded-host as is, so
82
+ // it only decides whether the Domain is dropped, never which domain is used.
83
+ const forwardedHeader = request.headers['x-forwarded-host']
84
+ const forwardedHost = getRequestHostname(singleForwardedHost(forwardedHeader))
85
+ if (forwardedHeader && !forwardedHost) {
86
+ // The value is client-controllable, so it is not logged.
87
+ OTELLogger('warn', 'Ignoring multi-value or malformed x-forwarded-host')
88
+ }
89
+ const host =
90
+ forwardedHost && isHostAllowed(forwardedHost, ALLOWED_HOST_SUFFIXES)
91
+ ? forwardedHost
92
+ : getRequestHostname(request.headers.host)
89
93
  if (!host) {
90
94
  return setCookie
91
95
  }
92
96
  const cookieDomain = domainMatch[1]
93
97
 
94
98
  if (
95
- !isAllowedHost({ host, allowList: ALLOWED_HOST_SUFFIXES }) ||
99
+ !isHostAllowed(host, ALLOWED_HOST_SUFFIXES) ||
96
100
  !shouldReplaceCookieDomain({ cookieDomain, host })
97
101
  ) {
98
102
  return setCookie
99
103
  }
100
104
 
101
- return setCookie.replace(MATCH_DOMAIN_REGEXP, `; domain=${host}`)
105
+ return removeCookieDomain(setCookie)
102
106
  }
103
107
 
104
108
  const parseRequest = (request: NextApiRequest) => {
@@ -18,7 +18,7 @@ import {
18
18
  sessionStore,
19
19
  toSessionInput,
20
20
  } from '../session'
21
- import { syncSalesChannelFromOrderForm } from '../session/syncSalesChannelFromOrderForm'
21
+ import { syncSessionWithValidatedCart } from '../session/syncSalesChannelFromOrderForm'
22
22
  import { createValidationStore, useStore } from '../useStore'
23
23
  import { waitForSessionValidated } from './waitForSessionValidated'
24
24
 
@@ -162,14 +162,16 @@ const validateCart = async (cart: Cart): Promise<Cart | null> => {
162
162
  },
163
163
  })
164
164
 
165
- const adoptedSalesChannel = validated?.order?.salesChannel
166
- if (adoptedSalesChannel) {
167
- syncSalesChannelFromOrderForm(
168
- adoptedSalesChannel,
169
- () => sessionStore.read(),
170
- (session) => sessionStore.setSilent(session)
171
- )
172
- }
165
+ syncSessionWithValidatedCart(
166
+ {
167
+ adoptedSalesChannel: validated?.order?.salesChannel,
168
+ // `null` means the browser cart is already valid.
169
+ itemCount: (validated ? validated.order.acceptedOffer : cart.items)
170
+ .length,
171
+ },
172
+ () => sessionStore.read(),
173
+ (session) => sessionStore.setSilent(session)
174
+ )
173
175
 
174
176
  return (
175
177
  validated && {
@@ -47,6 +47,10 @@ export const useLocalizationConfig = (params?: { url?: string | URL }) => {
47
47
  const session = sessionRef.current
48
48
  const channel = JSON.parse(session.channel ?? '{}') ?? {}
49
49
  channel.salesChannel = settings.salesChannel
50
+ // The URL is the SC intent: validateCart never adopts over it.
51
+ channel.salesChannelSource = 'url'
52
+ // Adoption never applies on the URL SC, so a recorded rejection is moot.
53
+ delete channel.rejectedSalesChannel
50
54
 
51
55
  const newSession = {
52
56
  ...session,
@@ -46,6 +46,10 @@ export function getInitialSession(
46
46
  const settings = getSettings()
47
47
  const channel = JSON.parse(defaults.channel ?? '{}') ?? {}
48
48
  channel.salesChannel = settings.salesChannel
49
+ // The URL is the SC intent: validateCart never adopts over it.
50
+ channel.salesChannelSource = 'url'
51
+ // Adoption never applies on the URL SC, so a recorded rejection is moot.
52
+ delete channel.rejectedSalesChannel
49
53
 
50
54
  return {
51
55
  ...defaults,
@@ -81,6 +85,10 @@ export function reconcileSessionLocale(fromIDB: Session): Session {
81
85
  const settings = getSettings()
82
86
  const channel = safeParseChannel(fromIDB.channel)
83
87
  channel.salesChannel = settings.salesChannel
88
+ // The URL is the SC intent: validateCart never adopts over it.
89
+ channel.salesChannelSource = 'url'
90
+ // Adoption never applies on the URL SC, so a recorded rejection is moot.
91
+ delete channel.rejectedSalesChannel
84
92
 
85
93
  return {
86
94
  ...fromIDB,
@@ -35,8 +35,9 @@ function readSalesChannel(channel: Record<string, unknown>): string {
35
35
  * (e.g. after Quick Order) without revalidating the cart.
36
36
  *
37
37
  * Uses `sessionStore.setSilent` so we do not trigger `cartStore.set` mid-flight
38
- * (that race previously wiped SC-exclusive items). Marks the channel as
39
- * explicit (`hasOnlyDefaultSalesChannel: false`) so `validateSession` keeps it.
38
+ * (that race previously wiped SC-exclusive items). Marks the channel with
39
+ * `salesChannelSource: 'orderForm'` so `validateSession` keeps it until the
40
+ * cart is empty (see `releaseAdoptedSalesChannel`).
40
41
  */
41
42
  export function syncSalesChannelFromOrderForm(
42
43
  salesChannel: string | null | undefined,
@@ -61,8 +62,60 @@ export function syncSalesChannelFromOrderForm(
61
62
  ...channel,
62
63
  salesChannel: nextSalesChannel,
63
64
  hasOnlyDefaultSalesChannel: false,
65
+ salesChannelSource: 'orderForm',
64
66
  }),
65
67
  })
66
68
 
67
69
  return true
68
70
  }
71
+
72
+ /**
73
+ * Drops the orderForm adoption marker and any rejected SC once the cart has no
74
+ * items, so Session Manager can again reject or replace the sales channel on
75
+ * the next `validateSession`. The SC itself is kept: an empty `salesChannel`
76
+ * would reach Checkout and catalog queries.
77
+ */
78
+ export function releaseAdoptedSalesChannel(
79
+ readSession: () => Session,
80
+ setSilent: (session: Session) => void
81
+ ): boolean {
82
+ const session = readSession()
83
+ const { salesChannelSource, rejectedSalesChannel, ...channel } =
84
+ parseChannelRecord(session.channel)
85
+ const isAdopted = salesChannelSource === 'orderForm'
86
+
87
+ if (!isAdopted && rejectedSalesChannel === undefined) {
88
+ return false
89
+ }
90
+
91
+ setSilent({
92
+ ...session,
93
+ channel: JSON.stringify(
94
+ isAdopted || salesChannelSource === undefined
95
+ ? channel
96
+ : { ...channel, salesChannelSource }
97
+ ),
98
+ })
99
+
100
+ return true
101
+ }
102
+
103
+ /**
104
+ * Applies the sales channel outcome of a `validateCart` to the session: adopt
105
+ * the orderForm SC when the API returned one, release the adoption once the
106
+ * cart is empty.
107
+ */
108
+ export function syncSessionWithValidatedCart(
109
+ {
110
+ adoptedSalesChannel,
111
+ itemCount,
112
+ }: { adoptedSalesChannel: string | null | undefined; itemCount: number },
113
+ readSession: () => Session,
114
+ setSilent: (session: Session) => void
115
+ ) {
116
+ syncSalesChannelFromOrderForm(adoptedSalesChannel, readSession, setSilent)
117
+
118
+ if (itemCount === 0) {
119
+ releaseAdoptedSalesChannel(readSession, setSilent)
120
+ }
121
+ }
@@ -0,0 +1,50 @@
1
+ // The result is written verbatim into a Set-Cookie Domain attribute, so
2
+ // characters such as ";" or spaces would inject cookie attributes.
3
+ const HOSTNAME_PATTERN = /^[a-z0-9.-]+$/
4
+
5
+ /**
6
+ * An entry starting with "." matches subdomains only; any other entry matches
7
+ * the host itself and its subdomains. Matching is anchored on a label boundary,
8
+ * so "evil-localhost" never matches "localhost".
9
+ */
10
+ export function isHostAllowed(host: string, allowList: string[]) {
11
+ const normalizedHost = host.toLowerCase()
12
+
13
+ if (!HOSTNAME_PATTERN.test(normalizedHost)) {
14
+ return false
15
+ }
16
+
17
+ return allowList.some((entry) =>
18
+ entry.startsWith('.')
19
+ ? normalizedHost.endsWith(entry)
20
+ : normalizedHost === entry || normalizedHost.endsWith(`.${entry}`)
21
+ )
22
+ }
23
+
24
+ /**
25
+ * Returns the `x-forwarded-host` value only when exactly one is present.
26
+ *
27
+ * Which hop of a proxy chain is trustworthy is not known here: with several
28
+ * values the leftmost one is whatever the client sent. So a multi-value header
29
+ * (comma-joined by Node, or an array) is ignored and the caller falls back to
30
+ * `host`. A single value still assumes the ingress overwrites the header.
31
+ */
32
+ export function singleForwardedHost(
33
+ header: string | string[] | undefined
34
+ ): string | undefined {
35
+ if (Array.isArray(header)) {
36
+ return header.length === 1 ? singleForwardedHost(header[0]) : undefined
37
+ }
38
+
39
+ const value = header?.trim()
40
+
41
+ return value && !value.includes(',') ? value : undefined
42
+ }
43
+
44
+ /**
45
+ * Drops every Domain attribute so the cookie becomes host-only: the browser
46
+ * scopes it to the exact host it requested, whatever header picked this path.
47
+ */
48
+ export function removeCookieDomain(setCookie: string): string {
49
+ return setCookie.replaceAll(/;\s*domain=[^;]*/gi, '')
50
+ }
@@ -110,9 +110,9 @@ export const toArray = <T>(x: T[] | T | undefined) =>
110
110
  Array.isArray(x) ? x : x ? [x] : []
111
111
 
112
112
  /**
113
- * Filters the channel object by removing the hasOnlyDefaultSalesChannel key.
114
- * This key is used only in the useAuth hook and is only required to send on the ValidateSession mutation,
115
- * so we remove it from the session's channel object to avoid unnecessary cache invalidations and query executions
113
+ * Filters the channel object by removing the hasOnlyDefaultSalesChannel, salesChannelSource and rejectedSalesChannel keys.
114
+ * These keys are session-only (useAuth and the ValidateSession/ValidateCart mutations),
115
+ * so we remove them from the session's channel object to avoid unnecessary cache invalidations and query executions
116
116
  *
117
117
  * @param channel - The channel string to filter.
118
118
  * @returns The filtered channel string.
@@ -122,8 +122,12 @@ export function filterChannel(channel: string): string {
122
122
  return '{}'
123
123
  }
124
124
  try {
125
- const { hasOnlyDefaultSalesChannel, ...filteredChannel } =
126
- JSON.parse(channel)
125
+ const {
126
+ hasOnlyDefaultSalesChannel,
127
+ salesChannelSource,
128
+ rejectedSalesChannel,
129
+ ...filteredChannel
130
+ } = JSON.parse(channel)
127
131
  return JSON.stringify(filteredChannel)
128
132
  } catch {
129
133
  console.warn('[filterChannel] Invalid channel JSON:', channel)
@@ -7,7 +7,7 @@ const mockSessionSet = vi.hoisted(() => vi.fn())
7
7
 
8
8
  vi.mock('src/sdk/session', async () => ({
9
9
  validateSession: mockValidateSession,
10
- sessionStore: { set: mockSessionSet },
10
+ sessionStore: { set: mockSessionSet, read: vi.fn() },
11
11
  toSessionInput: (await import('src/sdk/session/toSessionInput'))
12
12
  .toSessionInput,
13
13
  }))
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { act, renderHook } from '@testing-library/react'
6
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
7
+
8
+ const { mockRead, mockSet, mockValidateSession } = vi.hoisted(() => ({
9
+ mockRead: vi.fn(),
10
+ mockSet: vi.fn(),
11
+ mockValidateSession: vi.fn(),
12
+ }))
13
+
14
+ vi.mock('src/sdk/session', async () => ({
15
+ sessionStore: { read: mockRead, set: mockSet },
16
+ validateSession: mockValidateSession,
17
+ toSessionInput: (await import('src/sdk/session/toSessionInput'))
18
+ .toSessionInput,
19
+ }))
20
+ vi.mock('discovery.config', () => ({ deliveryPromise: { enabled: false } }))
21
+ vi.mock('src/sdk/product', () => ({ getProductCount: vi.fn() }))
22
+
23
+ import useRegion from '../../../src/components/region/RegionModal/useRegion'
24
+
25
+ // What `useSession()` hands to region components: `filterChannel` already
26
+ // stripped the session-only keys.
27
+ const filteredSession = {
28
+ locale: 'en-US',
29
+ currency: { code: 'USD', symbol: '$' },
30
+ country: 'USA',
31
+ channel: JSON.stringify({ salesChannel: '4', regionId: '' }),
32
+ postalCode: null,
33
+ } as any
34
+
35
+ const storedChannel = JSON.stringify({
36
+ salesChannel: '4',
37
+ regionId: '',
38
+ hasOnlyDefaultSalesChannel: false,
39
+ rejectedSalesChannel: '6',
40
+ })
41
+
42
+ describe('useRegion', () => {
43
+ beforeEach(() => {
44
+ vi.clearAllMocks()
45
+ mockRead.mockReturnValue({ ...filteredSession, channel: storedChannel })
46
+ mockValidateSession.mockImplementation(async (session) => session)
47
+ })
48
+
49
+ it('validates the new postal code with the stored channel, keeping session-only keys', async () => {
50
+ const { result } = renderHook(() => useRegion())
51
+
52
+ await act(() =>
53
+ result.current.setRegion({
54
+ session: filteredSession,
55
+ postalCode: '10001',
56
+ errorMessage: 'error',
57
+ })
58
+ )
59
+
60
+ expect(mockValidateSession).toHaveBeenCalledWith(
61
+ expect.objectContaining({ channel: storedChannel, postalCode: '10001' })
62
+ )
63
+ expect(mockSet).toHaveBeenCalledWith(
64
+ expect.objectContaining({ channel: storedChannel })
65
+ )
66
+ })
67
+ })
@@ -134,6 +134,14 @@ describe('getInitialSession (browser, localization enabled)', () => {
134
134
  expect(channel.regionId).toBe('region-abc')
135
135
  })
136
136
 
137
+ it('marks the URL-derived salesChannel as intentional', () => {
138
+ stubHref('https://store.example.com/en-US/some/path')
139
+
140
+ const result = getInitialSession(defaults)
141
+
142
+ expect(JSON.parse(result.channel ?? '{}').salesChannelSource).toBe('url')
143
+ })
144
+
137
145
  it('preserves non-locale defaults (country, person, etc.)', () => {
138
146
  stubHref('https://store.example.com/en-US/some/path')
139
147
 
@@ -165,6 +165,22 @@ describe('reconcileSessionLocale (browser, localization enabled)', () => {
165
165
  expect(channel.customKey).toBe('keep-me')
166
166
  })
167
167
 
168
+ it('marks the URL salesChannel as intentional, replacing an orderForm adoption', () => {
169
+ stubHref('https://store.example.com/pt-BR/some/path')
170
+
171
+ const payload: Session = {
172
+ ...STALE,
173
+ channel:
174
+ '{"salesChannel":"4","salesChannelSource":"orderForm","rejectedSalesChannel":"6"}',
175
+ }
176
+
177
+ const channel = JSON.parse(reconcileSessionLocale(payload).channel ?? '{}')
178
+
179
+ expect(channel.salesChannel).toBe('1')
180
+ expect(channel.salesChannelSource).toBe('url')
181
+ expect(channel).not.toHaveProperty('rejectedSalesChannel')
182
+ })
183
+
168
184
  it('survives a malformed channel JSON in the payload (does not throw, still reconciles)', () => {
169
185
  stubHref('https://store.example.com/pt-BR/some/path')
170
186
 
@@ -1,6 +1,10 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
2
  import type { Session } from '@faststore/sdk'
3
- import { syncSalesChannelFromOrderForm } from '../../../src/sdk/session/syncSalesChannelFromOrderForm'
3
+ import {
4
+ releaseAdoptedSalesChannel,
5
+ syncSalesChannelFromOrderForm,
6
+ syncSessionWithValidatedCart,
7
+ } from '../../../src/sdk/session/syncSalesChannelFromOrderForm'
4
8
 
5
9
  const baseSession = (
6
10
  salesChannel: string,
@@ -44,6 +48,7 @@ describe('syncSalesChannelFromOrderForm', () => {
44
48
  expect(JSON.parse(next.channel ?? '{}')).toMatchObject({
45
49
  salesChannel: '2',
46
50
  hasOnlyDefaultSalesChannel: false,
51
+ salesChannelSource: 'orderForm',
47
52
  })
48
53
  })
49
54
 
@@ -123,3 +128,155 @@ describe('syncSalesChannelFromOrderForm', () => {
123
128
  )
124
129
  })
125
130
  })
131
+
132
+ describe('releaseAdoptedSalesChannel', () => {
133
+ const withChannel = (channel: Record<string, unknown>) =>
134
+ baseSession('', true, JSON.stringify(channel))
135
+
136
+ it('drops the orderForm marker and keeps the rest of the channel', () => {
137
+ const setSilent = vi.fn()
138
+ const released = releaseAdoptedSalesChannel(
139
+ () =>
140
+ withChannel({
141
+ salesChannel: '4',
142
+ regionId: 'r1',
143
+ hasOnlyDefaultSalesChannel: false,
144
+ salesChannelSource: 'orderForm',
145
+ }),
146
+ setSilent
147
+ )
148
+
149
+ expect(released).toBe(true)
150
+ expect(JSON.parse(setSilent.mock.calls[0][0].channel)).toEqual({
151
+ salesChannel: '4',
152
+ regionId: 'r1',
153
+ hasOnlyDefaultSalesChannel: false,
154
+ })
155
+ })
156
+
157
+ it('drops a rejected SC together with the adoption marker', () => {
158
+ const setSilent = vi.fn()
159
+
160
+ releaseAdoptedSalesChannel(
161
+ () =>
162
+ withChannel({
163
+ salesChannel: '4',
164
+ salesChannelSource: 'orderForm',
165
+ rejectedSalesChannel: '6',
166
+ }),
167
+ setSilent
168
+ )
169
+
170
+ expect(JSON.parse(setSilent.mock.calls[0][0].channel)).toEqual({
171
+ salesChannel: '4',
172
+ })
173
+ })
174
+
175
+ it('drops a rejected SC left after the adoption was dropped', () => {
176
+ const setSilent = vi.fn()
177
+
178
+ expect(
179
+ releaseAdoptedSalesChannel(
180
+ () => withChannel({ salesChannel: '4', rejectedSalesChannel: '6' }),
181
+ setSilent
182
+ )
183
+ ).toBe(true)
184
+ expect(JSON.parse(setSilent.mock.calls[0][0].channel)).toEqual({
185
+ salesChannel: '4',
186
+ })
187
+ })
188
+
189
+ it('keeps the URL marker when dropping a rejected SC', () => {
190
+ const setSilent = vi.fn()
191
+
192
+ releaseAdoptedSalesChannel(
193
+ () =>
194
+ withChannel({
195
+ salesChannel: '3',
196
+ salesChannelSource: 'url',
197
+ rejectedSalesChannel: '6',
198
+ }),
199
+ setSilent
200
+ )
201
+
202
+ expect(JSON.parse(setSilent.mock.calls[0][0].channel)).toEqual({
203
+ salesChannel: '3',
204
+ salesChannelSource: 'url',
205
+ })
206
+ })
207
+
208
+ it('is a no-op without the orderForm marker', () => {
209
+ const setSilent = vi.fn()
210
+
211
+ expect(
212
+ releaseAdoptedSalesChannel(
213
+ () => withChannel({ salesChannel: '4' }),
214
+ setSilent
215
+ )
216
+ ).toBe(false)
217
+ expect(
218
+ releaseAdoptedSalesChannel(
219
+ () => withChannel({ salesChannel: '3', salesChannelSource: 'url' }),
220
+ setSilent
221
+ )
222
+ ).toBe(false)
223
+ expect(
224
+ releaseAdoptedSalesChannel(() => baseSession('', true, '{'), setSilent)
225
+ ).toBe(false)
226
+ expect(setSilent).not.toHaveBeenCalled()
227
+ })
228
+ })
229
+
230
+ describe('syncSessionWithValidatedCart', () => {
231
+ const store = (channel: Record<string, unknown>) => {
232
+ let session = baseSession('', true, JSON.stringify(channel))
233
+ const setSilent = vi.fn((next: Session) => {
234
+ session = next
235
+ })
236
+
237
+ return {
238
+ read: () => session,
239
+ setSilent,
240
+ channel: () => JSON.parse(session.channel ?? '{}'),
241
+ }
242
+ }
243
+
244
+ it('adopts the returned SC and keeps the marker while the cart has items', () => {
245
+ const s = store({ salesChannel: '2' })
246
+
247
+ syncSessionWithValidatedCart(
248
+ { adoptedSalesChannel: '4', itemCount: 1 },
249
+ s.read,
250
+ s.setSilent
251
+ )
252
+
253
+ expect(s.channel()).toMatchObject({
254
+ salesChannel: '4',
255
+ salesChannelSource: 'orderForm',
256
+ })
257
+ })
258
+
259
+ it('releases the adoption once the validated cart is empty', () => {
260
+ const s = store({ salesChannel: '4', salesChannelSource: 'orderForm' })
261
+
262
+ syncSessionWithValidatedCart(
263
+ { adoptedSalesChannel: null, itemCount: 0 },
264
+ s.read,
265
+ s.setSilent
266
+ )
267
+
268
+ expect(s.channel()).toEqual({ salesChannel: '4' })
269
+ })
270
+
271
+ it('leaves the session untouched when nothing was adopted and the cart has items', () => {
272
+ const s = store({ salesChannel: '4', salesChannelSource: 'orderForm' })
273
+
274
+ syncSessionWithValidatedCart(
275
+ { adoptedSalesChannel: undefined, itemCount: 2 },
276
+ s.read,
277
+ s.setSilent
278
+ )
279
+
280
+ expect(s.setSilent).not.toHaveBeenCalled()
281
+ })
282
+ })
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { filterChannel } from 'src/utils/utilities'
4
+
5
+ describe('filterChannel', () => {
6
+ it('strips session-only keys so they never reach queries or cache keys', () => {
7
+ expect(
8
+ JSON.parse(
9
+ filterChannel(
10
+ JSON.stringify({
11
+ salesChannel: '4',
12
+ regionId: 'r1',
13
+ hasOnlyDefaultSalesChannel: false,
14
+ salesChannelSource: 'orderForm',
15
+ rejectedSalesChannel: '6',
16
+ })
17
+ )
18
+ )
19
+ ).toEqual({ salesChannel: '4', regionId: 'r1' })
20
+ })
21
+
22
+ it('returns an empty object for an empty channel', () => {
23
+ expect(filterChannel('')).toBe('{}')
24
+ })
25
+ })
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ singleForwardedHost,
5
+ isHostAllowed,
6
+ removeCookieDomain,
7
+ } from '../../src/utils/trustedForwardedHost'
8
+
9
+ const ALLOW_LIST = ['localhost', '.vtex.app', '.localhost']
10
+
11
+ describe('isHostAllowed', () => {
12
+ it.each([
13
+ 'localhost',
14
+ 'store.vtex.app',
15
+ 'sfj-1--acc.preview.vtex.app',
16
+ 'a.localhost',
17
+ ])('allows %s', (host) => {
18
+ expect(isHostAllowed(host, ALLOW_LIST)).toBe(true)
19
+ })
20
+
21
+ it.each([
22
+ 'evil-localhost',
23
+ 'notlocalhost',
24
+ 'evil-vtex.app',
25
+ 'vtex.app.evil.com',
26
+ 'evil.com',
27
+ ])('rejects %s', (host) => {
28
+ expect(isHostAllowed(host, ALLOW_LIST)).toBe(false)
29
+ })
30
+ })
31
+
32
+ describe('isHostAllowed normalization', () => {
33
+ it('is case-insensitive', () => {
34
+ expect(isHostAllowed('Store.VTEX.app', ALLOW_LIST)).toBe(true)
35
+ expect(isHostAllowed('LOCALHOST', ALLOW_LIST)).toBe(true)
36
+ })
37
+
38
+ it.each([
39
+ 'x;max-age=0;y.vtex.app',
40
+ 'a b.vtex.app',
41
+ 'x"y.vtex.app',
42
+ 'x=y.vtex.app',
43
+ 'x,y.vtex.app',
44
+ 'x\r\nSet-Cookie: evil=1.vtex.app',
45
+ 'x\ny.vtex.app',
46
+ 'x\ry.vtex.app',
47
+ ])('rejects %s, which would inject cookie attributes', (host) => {
48
+ expect(isHostAllowed(host, ALLOW_LIST)).toBe(false)
49
+ })
50
+
51
+ it('accepts punycode hostnames', () => {
52
+ expect(isHostAllowed('xn--lja-bma.vtex.app', ALLOW_LIST)).toBe(true)
53
+ })
54
+
55
+ it('expects a hostname: a port is not stripped here', () => {
56
+ expect(isHostAllowed('store.vtex.app:8080', ALLOW_LIST)).toBe(false)
57
+ expect(isHostAllowed('evil.com:vtex.app', ALLOW_LIST)).toBe(false)
58
+ })
59
+ })
60
+
61
+ describe('singleForwardedHost', () => {
62
+ it('returns the value when there is exactly one', () => {
63
+ expect(singleForwardedHost('a.vtex.app')).toBe('a.vtex.app')
64
+ expect(singleForwardedHost(' a.vtex.app ')).toBe('a.vtex.app')
65
+ expect(singleForwardedHost(['a.vtex.app'])).toBe('a.vtex.app')
66
+ })
67
+
68
+ it('returns undefined when missing or empty', () => {
69
+ expect(singleForwardedHost(undefined)).toBeUndefined()
70
+ expect(singleForwardedHost('')).toBeUndefined()
71
+ expect(singleForwardedHost([])).toBeUndefined()
72
+ })
73
+
74
+ it('ignores multi-value headers instead of picking one', () => {
75
+ expect(singleForwardedHost('a.vtex.app, evil.com')).toBeUndefined()
76
+ expect(singleForwardedHost('evil.com, a.vtex.app')).toBeUndefined()
77
+ expect(singleForwardedHost(['a.vtex.app', 'evil.com'])).toBeUndefined()
78
+ })
79
+ })
80
+
81
+ describe('removeCookieDomain', () => {
82
+ it('drops the Domain attribute and keeps the rest', () => {
83
+ expect(
84
+ removeCookieDomain(
85
+ 'checkout.vtex.com=__ofid=abc; expires=Sun, 04 Apr 2027 18:31:26 GMT; domain=acc.vtexcommercestable.com.br; path=/; secure; samesite=lax; httponly'
86
+ )
87
+ ).toBe(
88
+ 'checkout.vtex.com=__ofid=abc; expires=Sun, 04 Apr 2027 18:31:26 GMT; path=/; secure; samesite=lax; httponly'
89
+ )
90
+ })
91
+
92
+ it('is case-insensitive and removes repeated Domain attributes', () => {
93
+ expect(removeCookieDomain('a=1; Domain=x.com; DOMAIN=y.com; Path=/')).toBe(
94
+ 'a=1; Path=/'
95
+ )
96
+ })
97
+
98
+ it('does not touch a cookie whose name is domain', () => {
99
+ expect(removeCookieDomain('domain=1; path=/')).toBe('domain=1; path=/')
100
+ })
101
+
102
+ it('keeps cookies without a Domain attribute unchanged', () => {
103
+ expect(removeCookieDomain('a=1; path=/')).toBe('a=1; path=/')
104
+ })
105
+ })