@faststore/core 4.10.1-dev.6 → 4.10.1-dev.7

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.
Files changed (60) hide show
  1. package/.turbo/turbo-generate.log +3 -3
  2. package/.turbo/turbo-test.log +176 -167
  3. package/@generated/graphql.ts +1 -0
  4. package/@generated/schema.graphql +1 -1
  5. package/CHANGELOG.md +29 -0
  6. package/package.json +6 -6
  7. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +3 -3
  8. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerBody.tsx +5 -7
  9. package/src/components/account/Drawer/OrganizationSignInButton/OrganizationSignInButton.tsx +4 -1
  10. package/src/components/account/Layout/Layout.tsx +5 -7
  11. package/src/components/account/Menu/Menu.tsx +1 -1
  12. package/src/components/navigation/Navbar/Navbar.tsx +3 -3
  13. package/src/components/navigation/Navbar/NavbarAccountArea.tsx +5 -5
  14. package/src/components/navigation/NavbarSlider/NavbarSlider.tsx +3 -3
  15. package/src/experimental/myAccountCmsServerSideProps.ts +3 -3
  16. package/src/experimental/myAccountServerSideProps.ts +4 -4
  17. package/src/pages/[slug]/p.tsx +11 -2
  18. package/src/pages/api/fs/logout.ts +18 -10
  19. package/src/pages/api/fs/switch-contract.ts +170 -21
  20. package/src/pages/api/graphql.ts +4 -3
  21. package/src/pages/pvt/account/cards/index.tsx +5 -5
  22. package/src/pages/pvt/account/orders/[id].tsx +5 -5
  23. package/src/pages/pvt/account/orders/index.tsx +5 -5
  24. package/src/pages/pvt/account/profile.tsx +5 -5
  25. package/src/pages/pvt/account/quotes/index.tsx +5 -5
  26. package/src/pages/pvt/account/security.tsx +5 -5
  27. package/src/pages/pvt/account/user-details.tsx +6 -6
  28. package/src/sdk/account/getB2BSessionClaims.ts +1 -1
  29. package/src/sdk/account/getIsB2BBuyer.ts +28 -0
  30. package/src/sdk/account/refreshToken.ts +89 -11
  31. package/src/sdk/account/refreshTokenPaths.ts +25 -0
  32. package/src/sdk/account/useRefreshToken.ts +10 -5
  33. package/src/sdk/product/isProductNotFound.ts +13 -0
  34. package/src/sdk/product/useProductNotFoundRedirect.ts +43 -0
  35. package/src/sdk/product/useProductQuery.ts +29 -0
  36. package/src/sdk/session/index.ts +4 -4
  37. package/src/utils/getCheckoutParentDomain.ts +53 -0
  38. package/src/utils/trustedForwardedHost.ts +18 -0
  39. package/test/components/account/Layout.test.tsx +1 -1
  40. package/test/components/account/Menu.browser.test.tsx +7 -5
  41. package/test/components/account/OrganizationDrawer.browser.test.tsx +13 -13
  42. package/test/components/account/OrganizationDrawerBody.test.tsx +3 -3
  43. package/test/components/account/OrganizationSignInButton.browser.test.tsx +19 -4
  44. package/test/components/navigation/Navbar.browser.test.tsx +3 -3
  45. package/test/components/navigation/NavbarAccountArea.browser.test.tsx +5 -5
  46. package/test/components/navigation/NavbarSlider.browser.test.tsx +3 -3
  47. package/test/experimental/myAccountCmsServerSideProps.test.ts +5 -5
  48. package/test/pages/api/logout.test.ts +106 -0
  49. package/test/pages/api/switch-contract.test.ts +443 -15
  50. package/test/pages/pvt/account/cards/index.test.ts +3 -3
  51. package/test/sdk/account/getIsB2BBuyer.test.ts +52 -0
  52. package/test/sdk/account/refreshToken.browser.test.ts +10 -1
  53. package/test/sdk/account/refreshToken.test.ts +150 -18
  54. package/test/sdk/account/useRefreshToken.browser.test.ts +4 -1
  55. package/test/sdk/product/useProductNotFoundRedirect.browser.test.ts +83 -0
  56. package/test/sdk/product/useProductQuery.browser.test.ts +154 -0
  57. package/test/sdk/session/refreshTokenRoute.browser.test.ts +106 -0
  58. package/test/sdk/session/validateSession.browser.test.ts +4 -1
  59. package/test/utils/trustedForwardedHost.test.ts +21 -0
  60. package/src/sdk/account/getIsRepresentative.ts +0 -20
@@ -13,15 +13,29 @@ vi.mock('src/utils/utilities', () => ({
13
13
  }))
14
14
 
15
15
  import {
16
+ REFRESH_AFTER_FALLBACK_SECONDS,
17
+ getNextRefreshAfter,
18
+ getRefreshAfterSeconds,
19
+ isB2BSession,
16
20
  isRefreshTokenSuccessful,
17
21
  refreshTokenRequest,
18
22
  } from '../../../src/sdk/account/refreshToken'
19
23
 
20
- describe('refreshTokenRequest (node)', () => {
24
+ const AUTHENTICATOR_URL =
25
+ 'https://b2bfaststore.vtexfaststore.com/api/authenticator/v1/refreshtoken/webstore'
26
+ const LEGACY_URL =
27
+ 'https://b2bfaststore.vtexfaststore.com/api/vtexid/refreshtoken/webstore'
28
+
29
+ const authenticatorSuccess = {
30
+ authStatus: 'Success',
31
+ refreshAfter: 1792717383,
32
+ }
33
+
34
+ describe('refreshTokenRequest (node, B2B session)', () => {
21
35
  beforeEach(() => {
22
36
  mockFetch.mockResolvedValue({
23
37
  status: 200,
24
- json: async () => ({ status: 'success', refreshAfter: '123' }),
38
+ json: async () => authenticatorSuccess,
25
39
  })
26
40
  })
27
41
 
@@ -29,16 +43,14 @@ describe('refreshTokenRequest (node)', () => {
29
43
  vi.clearAllMocks()
30
44
  })
31
45
 
32
- it('posts to the store origin with Host header on the server', async () => {
33
- const result = await refreshTokenRequest()
46
+ it('posts to the Authenticator route on the store origin with Host header on the server', async () => {
47
+ const result = await refreshTokenRequest({ isB2B: true })
34
48
 
35
- expect(result).toEqual({ status: 'success', refreshAfter: '123' })
49
+ expect(result).toEqual(authenticatorSuccess)
36
50
  expect(mockFetch).toHaveBeenCalledTimes(1)
37
51
 
38
52
  const [url, init] = mockFetch.mock.calls[0]
39
- expect(url).toBe(
40
- 'https://b2bfaststore.vtexfaststore.com/api/vtexid/refreshtoken/webstore'
41
- )
53
+ expect(url).toBe(AUTHENTICATOR_URL)
42
54
  expect(init.method).toBe('POST')
43
55
  expect(init.credentials).toBe('include')
44
56
  expect(init.headers).toMatchObject({
@@ -48,13 +60,35 @@ describe('refreshTokenRequest (node)', () => {
48
60
  expect(JSON.parse(init.body as string)).toEqual({})
49
61
  })
50
62
 
51
- it('retries when the response is not 200 and returns undefined after failures', async () => {
52
- mockFetch
53
- .mockResolvedValueOnce({ status: 500, json: async () => ({}) })
54
- .mockResolvedValueOnce({ status: 500, json: async () => ({}) })
55
- .mockResolvedValueOnce({ status: 500, json: async () => ({}) })
63
+ it('does not fall back to the legacy route when the Authenticator fails', async () => {
64
+ mockFetch.mockResolvedValue({ status: 401, json: async () => ({}) })
56
65
 
57
- const result = await refreshTokenRequest()
66
+ const result = await refreshTokenRequest({ isB2B: true })
67
+
68
+ expect(result).toBeUndefined()
69
+ expect(mockFetch).toHaveBeenCalledTimes(3)
70
+ expect(
71
+ mockFetch.mock.calls.every(([url]) => url === AUTHENTICATOR_URL)
72
+ ).toBe(true)
73
+ })
74
+
75
+ it('returns the Authenticator answer as is when it does not report success', async () => {
76
+ mockFetch.mockResolvedValue({
77
+ status: 200,
78
+ json: async () => ({ authStatus: 'WrongCredentials' }),
79
+ })
80
+
81
+ const result = await refreshTokenRequest({ isB2B: true })
82
+
83
+ expect(result).toEqual({ authStatus: 'WrongCredentials' })
84
+ expect(isRefreshTokenSuccessful(result)).toBe(false)
85
+ expect(mockFetch).toHaveBeenCalledTimes(1)
86
+ })
87
+
88
+ it('retries non-200 answers on the Authenticator route only', async () => {
89
+ mockFetch.mockResolvedValue({ status: 500, json: async () => ({}) })
90
+
91
+ const result = await refreshTokenRequest({ isB2B: true })
58
92
 
59
93
  expect(result).toBeUndefined()
60
94
  expect(mockFetch).toHaveBeenCalledTimes(3)
@@ -65,23 +99,121 @@ describe('refreshTokenRequest (node)', () => {
65
99
  .mockRejectedValueOnce(new Error('network'))
66
100
  .mockResolvedValueOnce({
67
101
  status: 200,
68
- json: async () => ({ status: 'success' }),
102
+ json: async () => authenticatorSuccess,
69
103
  })
70
104
 
71
- const result = await refreshTokenRequest()
105
+ const result = await refreshTokenRequest({ isB2B: true })
72
106
 
73
- expect(result).toEqual({ status: 'success' })
107
+ expect(result).toEqual(authenticatorSuccess)
74
108
  expect(mockFetch).toHaveBeenCalledTimes(2)
109
+ expect(mockFetch.mock.calls[1][0]).toBe(AUTHENTICATOR_URL)
110
+ })
111
+ })
112
+
113
+ describe('refreshTokenRequest (node, non-B2B session)', () => {
114
+ beforeEach(() => {
115
+ mockFetch.mockResolvedValue({
116
+ status: 200,
117
+ json: async () => ({ status: 'Success', refreshAfter: '123' }),
118
+ })
119
+ })
120
+
121
+ afterEach(() => {
122
+ vi.clearAllMocks()
123
+ })
124
+
125
+ it('only calls the legacy route, as VTEX ID recommends outside B2B', async () => {
126
+ const result = await refreshTokenRequest()
127
+
128
+ expect(result).toEqual({ status: 'Success', refreshAfter: '123' })
129
+ expect(mockFetch.mock.calls.map(([url]) => url)).toEqual([LEGACY_URL])
130
+ })
131
+
132
+ it('retries 5xx on the legacy route only', async () => {
133
+ mockFetch.mockResolvedValue({ status: 500, json: async () => ({}) })
134
+
135
+ const result = await refreshTokenRequest({ isB2B: false })
136
+
137
+ expect(result).toBeUndefined()
138
+ expect(mockFetch).toHaveBeenCalledTimes(3)
139
+ expect(mockFetch.mock.calls.every(([url]) => url === LEGACY_URL)).toBe(true)
140
+ })
141
+ })
142
+
143
+ describe('isB2BSession', () => {
144
+ it('is true when the session has both a unit and a contract', () => {
145
+ expect(
146
+ isB2BSession({ b2b: { unitId: 'unit-1', customerId: 'contract-1' } })
147
+ ).toBe(true)
148
+ })
149
+
150
+ it.each([
151
+ ['no b2b', { b2b: null }],
152
+ ['an empty unitId', { b2b: { unitId: '', customerId: 'contract-1' } }],
153
+ ['an empty customerId', { b2b: { unitId: 'unit-1', customerId: '' } }],
154
+ ['no ids', { b2b: {} }],
155
+ ])('is false with %s', (_, session) => {
156
+ // `b2b` exists for every isRepresentative token, which can be true
157
+ // outside B2B, so the ids decide.
158
+ expect(isB2BSession(session)).toBe(false)
159
+ })
160
+
161
+ it('is false without a session', () => {
162
+ expect(isB2BSession(null)).toBe(false)
163
+ expect(isB2BSession(undefined)).toBe(false)
75
164
  })
76
165
  })
77
166
 
78
167
  describe('isRefreshTokenSuccessful', () => {
79
- it('returns true when status is success (case-insensitive)', () => {
168
+ it('accepts the legacy status field (case-insensitive)', () => {
80
169
  expect(isRefreshTokenSuccessful({ status: 'Success' })).toBe(true)
81
170
  })
82
171
 
172
+ it('accepts the Authenticator authStatus field', () => {
173
+ expect(isRefreshTokenSuccessful({ authStatus: 'Success' })).toBe(true)
174
+ })
175
+
83
176
  it('returns false when status is missing or not success', () => {
84
177
  expect(isRefreshTokenSuccessful(undefined)).toBe(false)
85
178
  expect(isRefreshTokenSuccessful({ status: 'failed' })).toBe(false)
179
+ expect(isRefreshTokenSuccessful({ authStatus: 'failed' })).toBe(false)
180
+ })
181
+ })
182
+
183
+ describe('getRefreshAfterSeconds', () => {
184
+ it('keeps epoch seconds from the Authenticator', () => {
185
+ expect(getRefreshAfterSeconds({ refreshAfter: 1792717383 })).toBe(
186
+ '1792717383'
187
+ )
188
+ expect(getRefreshAfterSeconds({ refreshAfter: '1792717383' })).toBe(
189
+ '1792717383'
190
+ )
191
+ })
192
+
193
+ it('converts the legacy ISO date to epoch seconds', () => {
194
+ expect(
195
+ getRefreshAfterSeconds({ refreshAfter: '2026-10-20T05:36:52+00:00' })
196
+ ).toBe(String(Date.UTC(2026, 9, 20, 5, 36, 52) / 1000))
197
+ })
198
+
199
+ it('returns null when the value is missing or unparseable', () => {
200
+ expect(getRefreshAfterSeconds(undefined)).toBeNull()
201
+ expect(getRefreshAfterSeconds({})).toBeNull()
202
+ expect(getRefreshAfterSeconds({ refreshAfter: '' })).toBeNull()
203
+ expect(getRefreshAfterSeconds({ refreshAfter: 'soon' })).toBeNull()
204
+ })
205
+ })
206
+
207
+ describe('getNextRefreshAfter', () => {
208
+ it('uses the refreshAfter from the response', () => {
209
+ expect(getNextRefreshAfter({ refreshAfter: 1792717383 }, 100)).toBe(
210
+ '1792717383'
211
+ )
212
+ })
213
+
214
+ it('falls back to a short delay from now when the response has none', () => {
215
+ expect(getNextRefreshAfter({ authStatus: 'Success' }, 100)).toBe(
216
+ String(100 + REFRESH_AFTER_FALLBACK_SECONDS)
217
+ )
86
218
  })
87
219
  })
@@ -4,7 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
4
  const mockRefreshTokenRequest = vi.hoisted(() => vi.fn())
5
5
  const mockLogoutAndClearSession = vi.hoisted(() => vi.fn())
6
6
 
7
- vi.mock('../../../src/sdk/account/refreshToken', () => ({
7
+ vi.mock('../../../src/sdk/account/refreshToken', async (importOriginal) => ({
8
+ ...(await importOriginal<
9
+ typeof import('../../../src/sdk/account/refreshToken')
10
+ >()),
8
11
  refreshTokenRequest: mockRefreshTokenRequest,
9
12
  isRefreshTokenSuccessful: () => false,
10
13
  }))
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { renderHook } from '@testing-library/react'
6
+ import { afterEach, describe, expect, it, vi } from 'vitest'
7
+
8
+ const mockReplace = vi.hoisted(() => vi.fn())
9
+
10
+ vi.mock('next/router', () => ({
11
+ useRouter: () => ({ replace: mockReplace }),
12
+ }))
13
+
14
+ import { useProductNotFoundRedirect } from '../../../src/sdk/product/useProductNotFoundRedirect'
15
+
16
+ describe('useProductNotFoundRedirect', () => {
17
+ afterEach(() => {
18
+ vi.clearAllMocks()
19
+ })
20
+
21
+ it('replaces the PDP with the 404 page when the BFF answers 404', () => {
22
+ renderHook(() => useProductNotFoundRedirect({ status: 404 }))
23
+
24
+ expect(mockReplace).toHaveBeenCalledWith('/404')
25
+ })
26
+
27
+ it('redirects without waiting for the session', () => {
28
+ // A segment restriction rejects the product for any shopper, B2B or not,
29
+ // so the hook does not read the session at all.
30
+ const { result } = renderHook(() =>
31
+ useProductNotFoundRedirect({ status: 404 })
32
+ )
33
+
34
+ expect(result.current).toBe(true)
35
+ expect(mockReplace).toHaveBeenCalledTimes(1)
36
+ })
37
+
38
+ it('navigates once even when the router changes identity', () => {
39
+ // The mocked `useRouter` returns a new object on every render.
40
+ const { rerender } = renderHook(() =>
41
+ useProductNotFoundRedirect({ status: 404 })
42
+ )
43
+ rerender()
44
+ rerender()
45
+
46
+ expect(mockReplace).toHaveBeenCalledTimes(1)
47
+ })
48
+
49
+ it('redirects again for a later not-found after one that cleared', () => {
50
+ const { rerender } = renderHook(
51
+ ({ error }: { error: unknown }) => useProductNotFoundRedirect(error),
52
+ { initialProps: { error: { status: 404 } as unknown } }
53
+ )
54
+ rerender({ error: undefined })
55
+ rerender({ error: { status: 404 } })
56
+
57
+ expect(mockReplace).toHaveBeenCalledTimes(2)
58
+ })
59
+
60
+ it('recognizes a NotFoundError carried in the GraphQL extensions', () => {
61
+ renderHook(() =>
62
+ useProductNotFoundRedirect({ extensions: { type: 'NotFoundError' } })
63
+ )
64
+
65
+ expect(mockReplace).toHaveBeenCalledTimes(1)
66
+ })
67
+
68
+ it('does nothing while the refetch has no error', () => {
69
+ const { result } = renderHook(() => useProductNotFoundRedirect(undefined))
70
+
71
+ expect(mockReplace).not.toHaveBeenCalled()
72
+ expect(result.current).toBe(false)
73
+ })
74
+
75
+ it('does nothing for errors other than not found', () => {
76
+ const { result } = renderHook(() =>
77
+ useProductNotFoundRedirect({ status: 500 })
78
+ )
79
+
80
+ expect(mockReplace).not.toHaveBeenCalled()
81
+ expect(result.current).toBe(false)
82
+ })
83
+ })
@@ -0,0 +1,154 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { renderHook, waitFor } from '@testing-library/react'
6
+ import { createElement, type ReactNode } from 'react'
7
+ import { SWRConfig } from 'swr'
8
+ import { afterEach, describe, expect, it, vi } from 'vitest'
9
+
10
+ const mockRequest = vi.hoisted(() => vi.fn())
11
+ const mockReplace = vi.hoisted(() => vi.fn())
12
+ const mockUseSession = vi.hoisted(() => vi.fn())
13
+ const mockSharedRetry = vi.hoisted(() => vi.fn())
14
+
15
+ vi.mock('@generated', () => ({
16
+ gql: () => ({
17
+ __meta__: { operationName: 'ClientProductQuery', operationHash: 'hash' },
18
+ }),
19
+ }))
20
+ vi.mock('src/sdk/graphql/request', () => ({ request: mockRequest }))
21
+ vi.mock('src/utils/cookieCacheBusting', () => ({
22
+ getClientCacheBustingValue: () => null,
23
+ }))
24
+ vi.mock('src/sdk/session', () => ({ useSession: mockUseSession }))
25
+ // Spies on the shared retry policy while keeping its behavior. SWR calls
26
+ // `onErrorRetry` synchronously when the error lands, so whether a retry was
27
+ // scheduled is known without waiting on the clock.
28
+ vi.mock('src/sdk/graphql/retryPolicy', async (importOriginal) => {
29
+ const actual =
30
+ await importOriginal<typeof import('src/sdk/graphql/retryPolicy')>()
31
+ mockSharedRetry.mockImplementation(actual.onErrorRetry)
32
+
33
+ return { ...actual, onErrorRetry: mockSharedRetry }
34
+ })
35
+ vi.mock('next/router', () => ({
36
+ useRouter: () => ({ replace: mockReplace }),
37
+ }))
38
+
39
+ import { useProductNotFoundRedirect } from '../../../src/sdk/product/useProductNotFoundRedirect'
40
+ import { useProductQuery } from '../../../src/sdk/product/useProductQuery'
41
+
42
+ const fallbackData = { product: { id: '123' } } as any
43
+
44
+ // The real PDP wiring: the refetch error feeds the redirect hook.
45
+ const usePdpClientQuery = () => {
46
+ const productQuery = useProductQuery('123', fallbackData)
47
+ useProductNotFoundRedirect(productQuery.error)
48
+
49
+ return productQuery
50
+ }
51
+
52
+ // SWR's cache is global; isolate it so a test never reads another one's result.
53
+ const isolatedCache = ({ children }: { children: ReactNode }) =>
54
+ createElement(SWRConfig, { value: { provider: () => new Map() } }, children)
55
+
56
+ describe('useProductQuery', () => {
57
+ afterEach(() => {
58
+ vi.clearAllMocks()
59
+ })
60
+
61
+ it('exposes a failed refetch as `error` and stops validating', async () => {
62
+ mockUseSession.mockReturnValue({
63
+ channel: '{"salesChannel":"1"}',
64
+ locale: 'en-US',
65
+ b2b: null,
66
+ })
67
+ mockRequest.mockRejectedValue({ status: 404, message: 'Not Found' })
68
+
69
+ const { result } = renderHook(usePdpClientQuery, { wrapper: isolatedCache })
70
+
71
+ await waitFor(() => expect(result.current.error).toEqual(expect.anything()))
72
+
73
+ expect(result.current.error).toMatchObject({ status: 404 })
74
+ expect(result.current.isValidating).toBe(false)
75
+ // The data stays the static product, and the failed call is not retried.
76
+ expect(result.current.data).toBe(fallbackData)
77
+ expect(mockRequest).toHaveBeenCalledTimes(1)
78
+ // Shoppers without a B2B session are sent to the 404 page too.
79
+ await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/404'))
80
+ })
81
+
82
+ it('does not retry a NotFoundError carried in the GraphQL extensions', async () => {
83
+ mockUseSession.mockReturnValue({
84
+ channel: '{"salesChannel":"1"}',
85
+ locale: 'en-US',
86
+ b2b: { customerId: 'contract-137' },
87
+ })
88
+ mockRequest.mockRejectedValue({ extensions: { type: 'NotFoundError' } })
89
+
90
+ renderHook(usePdpClientQuery, { wrapper: isolatedCache })
91
+
92
+ await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/404'))
93
+ // The shared policy is what schedules a retry; it is never reached.
94
+ expect(mockSharedRetry).not.toHaveBeenCalled()
95
+ expect(mockRequest).toHaveBeenCalledTimes(1)
96
+ })
97
+
98
+ it('sends a B2B shopper to the 404 page when the refetch answers 404', async () => {
99
+ mockUseSession.mockReturnValue({
100
+ channel: '{"salesChannel":"1"}',
101
+ locale: 'en-US',
102
+ b2b: { customerId: 'contract-137' },
103
+ })
104
+ mockRequest.mockRejectedValue({ status: 404, message: 'Not Found' })
105
+
106
+ renderHook(usePdpClientQuery, { wrapper: isolatedCache })
107
+
108
+ await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/404'))
109
+ })
110
+
111
+ it('retries a transient failure instead of treating it as not found', async () => {
112
+ mockUseSession.mockReturnValue({
113
+ channel: '{"salesChannel":"1"}',
114
+ locale: 'en-US',
115
+ b2b: { customerId: 'contract-137' },
116
+ })
117
+ const fresh = { product: { id: '123', name: 'Fresh' } }
118
+ mockRequest
119
+ .mockRejectedValueOnce({ status: 500, message: 'Internal Server Error' })
120
+ .mockResolvedValue(fresh)
121
+
122
+ const fastRetries = ({ children }: { children: ReactNode }) =>
123
+ createElement(
124
+ SWRConfig,
125
+ { value: { provider: () => new Map(), errorRetryInterval: 1 } },
126
+ children
127
+ )
128
+
129
+ const { result } = renderHook(usePdpClientQuery, { wrapper: fastRetries })
130
+
131
+ await waitFor(() => expect(result.current.data).toEqual(fresh))
132
+
133
+ expect(mockRequest).toHaveBeenCalledTimes(2)
134
+ expect(mockSharedRetry).toHaveBeenCalledTimes(1)
135
+ expect(mockReplace).not.toHaveBeenCalled()
136
+ })
137
+
138
+ it('keeps the product and does not redirect when the refetch succeeds', async () => {
139
+ mockUseSession.mockReturnValue({
140
+ channel: '{"salesChannel":"1"}',
141
+ locale: 'en-US',
142
+ b2b: { customerId: 'contract-137' },
143
+ })
144
+ const fresh = { product: { id: '123', name: 'Fresh' } }
145
+ mockRequest.mockResolvedValue(fresh)
146
+
147
+ const { result } = renderHook(usePdpClientQuery, { wrapper: isolatedCache })
148
+
149
+ await waitFor(() => expect(result.current.data).toEqual(fresh))
150
+
151
+ expect(result.current.error).toBeUndefined()
152
+ expect(mockReplace).not.toHaveBeenCalled()
153
+ })
154
+ })
@@ -0,0 +1,106 @@
1
+ import type { Session } from '@faststore/sdk'
2
+ import {
3
+ afterEach,
4
+ beforeAll,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ it,
9
+ vi,
10
+ } from 'vitest'
11
+
12
+ const mockRequest = vi.hoisted(() => vi.fn())
13
+ const mockRefreshTokenRequest = vi.hoisted(() => vi.fn())
14
+
15
+ vi.mock('../../../src/sdk/graphql/request', () => ({
16
+ request: mockRequest,
17
+ }))
18
+
19
+ // Keeps the real `isB2BSession`, so the route choice is exercised end to end.
20
+ vi.mock('../../../src/sdk/account/refreshToken', async (importOriginal) => ({
21
+ ...(await importOriginal<
22
+ typeof import('../../../src/sdk/account/refreshToken')
23
+ >()),
24
+ refreshTokenRequest: mockRefreshTokenRequest,
25
+ }))
26
+
27
+ import storeConfig from '../../../discovery.config'
28
+ import { validateSession } from '../../../src/sdk/session'
29
+
30
+ // An already expired `refreshAfter` (in seconds) triggers the refresh.
31
+ const expiredSession: Session = {
32
+ locale: 'en-US',
33
+ currency: { code: 'USD', symbol: '$' },
34
+ country: 'USA',
35
+ channel: '{"salesChannel":"1","regionId":""}',
36
+ deliveryMode: null,
37
+ addressType: null,
38
+ city: null,
39
+ postalCode: '10001',
40
+ geoCoordinates: null,
41
+ person: null,
42
+ b2b: null,
43
+ marketingData: null,
44
+ refreshAfter: '1',
45
+ }
46
+
47
+ const withB2b = (b2b: Partial<NonNullable<Session['b2b']>>) =>
48
+ ({ ...expiredSession, b2b }) as Session
49
+
50
+ // The store validates its initial session when the module loads. Let that
51
+ // call settle once, so no test depends on calls left by another one.
52
+ beforeAll(async () => {
53
+ mockRequest.mockResolvedValue({ validateSession: null })
54
+ await vi.waitFor(() => expect(mockRequest).toHaveBeenCalled())
55
+ })
56
+
57
+ describe('validateSession refresh route', () => {
58
+ const originalLocation = window.location
59
+ const originalRefreshToken = storeConfig.experimental.refreshToken
60
+
61
+ beforeEach(() => {
62
+ // Local hosts skip the refresh, so run on a production host.
63
+ Object.defineProperty(window, 'location', {
64
+ configurable: true,
65
+ value: { ...originalLocation, hostname: 'brandless.fast.store' },
66
+ })
67
+ storeConfig.experimental.refreshToken = true
68
+ mockRequest.mockReset()
69
+ mockRequest.mockResolvedValue({ validateSession: null })
70
+ mockRefreshTokenRequest.mockReset()
71
+ mockRefreshTokenRequest.mockResolvedValue({
72
+ authStatus: 'Success',
73
+ refreshAfter: Math.floor(Date.now() / 1000) + 3600,
74
+ })
75
+ })
76
+
77
+ afterEach(() => {
78
+ storeConfig.experimental.refreshToken = originalRefreshToken
79
+ Object.defineProperty(window, 'location', {
80
+ configurable: true,
81
+ value: originalLocation,
82
+ })
83
+ })
84
+
85
+ it('uses the Authenticator route for a buyer with a unit and a contract', async () => {
86
+ await validateSession(
87
+ withB2b({ unitId: 'unit-1', customerId: 'contract-1' })
88
+ )
89
+
90
+ expect(mockRefreshTokenRequest).toHaveBeenCalledWith({ isB2B: true })
91
+ })
92
+
93
+ it('keeps the legacy route when `b2b` has no unit or contract', async () => {
94
+ // `b2b` exists for every isRepresentative token, which can be true
95
+ // outside B2B.
96
+ await validateSession(withB2b({ unitId: '', customerId: '' }))
97
+
98
+ expect(mockRefreshTokenRequest).toHaveBeenCalledWith({ isB2B: false })
99
+ })
100
+
101
+ it('keeps the legacy route without a B2B session', async () => {
102
+ await validateSession(expiredSession)
103
+
104
+ expect(mockRefreshTokenRequest).toHaveBeenCalledWith({ isB2B: false })
105
+ })
106
+ })
@@ -16,7 +16,10 @@ vi.mock('../../../src/sdk/graphql/request', () => ({
16
16
  request: mockRequest,
17
17
  }))
18
18
 
19
- vi.mock('../../../src/sdk/account/refreshToken', () => ({
19
+ vi.mock('../../../src/sdk/account/refreshToken', async (importOriginal) => ({
20
+ ...(await importOriginal<
21
+ typeof import('../../../src/sdk/account/refreshToken')
22
+ >()),
20
23
  refreshTokenRequest: mockRefreshTokenRequest,
21
24
  isRefreshTokenSuccessful: () => true,
22
25
  }))
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
3
  import {
4
+ isCookieSafeHostname,
4
5
  singleForwardedHost,
5
6
  isHostAllowed,
6
7
  removeCookieDomain,
@@ -103,3 +104,23 @@ describe('removeCookieDomain', () => {
103
104
  expect(removeCookieDomain('a=1; path=/')).toBe('a=1; path=/')
104
105
  })
105
106
  })
107
+
108
+ describe('isCookieSafeHostname', () => {
109
+ it.each(['store.example.com', 'b2b-store.vtexfaststore.com', 'Store.COM'])(
110
+ 'accepts %s',
111
+ (host) => {
112
+ expect(isCookieSafeHostname(host)).toBe(true)
113
+ }
114
+ )
115
+
116
+ it.each([
117
+ 'store.com;evil=1',
118
+ 'store.com path=/',
119
+ 'store.com,other.com',
120
+ 'store.com\r\nSet-Cookie: evil=1',
121
+ 'store.com%0d%0a',
122
+ '',
123
+ ])('rejects %j', (host) => {
124
+ expect(isCookieSafeHostname(host)).toBe(false)
125
+ })
126
+ })
@@ -1,20 +0,0 @@
1
- import { parse } from 'cookie'
2
- import { parseJwt } from 'src/utils/getCookie'
3
-
4
- type GetIsRepresentativeParams = {
5
- headers?: Record<string, string>
6
- account: string
7
- }
8
-
9
- export function getIsRepresentative({
10
- headers,
11
- account,
12
- }: GetIsRepresentativeParams): boolean {
13
- const authCookie = parse(headers?.cookie ?? '')?.[
14
- 'VtexIdclientAutCookie_' + account
15
- ]
16
- const jwt = parseJwt(authCookie)
17
-
18
- const isRepresentative = jwt?.isRepresentative
19
- return !!isRepresentative
20
- }