@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.
- package/.turbo/turbo-generate.log +3 -3
- package/.turbo/turbo-test.log +176 -167
- package/@generated/graphql.ts +1 -0
- package/@generated/schema.graphql +1 -1
- package/CHANGELOG.md +29 -0
- package/package.json +6 -6
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +3 -3
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerBody.tsx +5 -7
- package/src/components/account/Drawer/OrganizationSignInButton/OrganizationSignInButton.tsx +4 -1
- package/src/components/account/Layout/Layout.tsx +5 -7
- package/src/components/account/Menu/Menu.tsx +1 -1
- package/src/components/navigation/Navbar/Navbar.tsx +3 -3
- package/src/components/navigation/Navbar/NavbarAccountArea.tsx +5 -5
- package/src/components/navigation/NavbarSlider/NavbarSlider.tsx +3 -3
- package/src/experimental/myAccountCmsServerSideProps.ts +3 -3
- package/src/experimental/myAccountServerSideProps.ts +4 -4
- package/src/pages/[slug]/p.tsx +11 -2
- package/src/pages/api/fs/logout.ts +18 -10
- package/src/pages/api/fs/switch-contract.ts +170 -21
- package/src/pages/api/graphql.ts +4 -3
- package/src/pages/pvt/account/cards/index.tsx +5 -5
- package/src/pages/pvt/account/orders/[id].tsx +5 -5
- package/src/pages/pvt/account/orders/index.tsx +5 -5
- package/src/pages/pvt/account/profile.tsx +5 -5
- package/src/pages/pvt/account/quotes/index.tsx +5 -5
- package/src/pages/pvt/account/security.tsx +5 -5
- package/src/pages/pvt/account/user-details.tsx +6 -6
- package/src/sdk/account/getB2BSessionClaims.ts +1 -1
- package/src/sdk/account/getIsB2BBuyer.ts +28 -0
- package/src/sdk/account/refreshToken.ts +89 -11
- package/src/sdk/account/refreshTokenPaths.ts +25 -0
- package/src/sdk/account/useRefreshToken.ts +10 -5
- package/src/sdk/product/isProductNotFound.ts +13 -0
- package/src/sdk/product/useProductNotFoundRedirect.ts +43 -0
- package/src/sdk/product/useProductQuery.ts +29 -0
- package/src/sdk/session/index.ts +4 -4
- package/src/utils/getCheckoutParentDomain.ts +53 -0
- package/src/utils/trustedForwardedHost.ts +18 -0
- package/test/components/account/Layout.test.tsx +1 -1
- package/test/components/account/Menu.browser.test.tsx +7 -5
- package/test/components/account/OrganizationDrawer.browser.test.tsx +13 -13
- package/test/components/account/OrganizationDrawerBody.test.tsx +3 -3
- package/test/components/account/OrganizationSignInButton.browser.test.tsx +19 -4
- package/test/components/navigation/Navbar.browser.test.tsx +3 -3
- package/test/components/navigation/NavbarAccountArea.browser.test.tsx +5 -5
- package/test/components/navigation/NavbarSlider.browser.test.tsx +3 -3
- package/test/experimental/myAccountCmsServerSideProps.test.ts +5 -5
- package/test/pages/api/logout.test.ts +106 -0
- package/test/pages/api/switch-contract.test.ts +443 -15
- package/test/pages/pvt/account/cards/index.test.ts +3 -3
- package/test/sdk/account/getIsB2BBuyer.test.ts +52 -0
- package/test/sdk/account/refreshToken.browser.test.ts +10 -1
- package/test/sdk/account/refreshToken.test.ts +150 -18
- package/test/sdk/account/useRefreshToken.browser.test.ts +4 -1
- package/test/sdk/product/useProductNotFoundRedirect.browser.test.ts +83 -0
- package/test/sdk/product/useProductQuery.browser.test.ts +154 -0
- package/test/sdk/session/refreshTokenRoute.browser.test.ts +106 -0
- package/test/sdk/session/validateSession.browser.test.ts +4 -1
- package/test/utils/trustedForwardedHost.test.ts +21 -0
- 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
|
-
|
|
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 () =>
|
|
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(
|
|
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('
|
|
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 () =>
|
|
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(
|
|
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('
|
|
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
|
-
}
|