@faststore/core 4.10.1-dev.5 → 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 (63) hide show
  1. package/.turbo/turbo-generate.log +3 -3
  2. package/.turbo/turbo-test.log +169 -157
  3. package/@generated/graphql.ts +1 -0
  4. package/@generated/schema.graphql +1 -1
  5. package/CHANGELOG.md +39 -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/usePageProductsQuery.ts +80 -21
  35. package/src/sdk/product/useProductNotFoundRedirect.ts +43 -0
  36. package/src/sdk/product/useProductQuery.ts +29 -0
  37. package/src/sdk/session/index.ts +4 -4
  38. package/src/utils/getCheckoutParentDomain.ts +53 -0
  39. package/src/utils/trustedForwardedHost.ts +18 -0
  40. package/test/components/account/Layout.test.tsx +1 -1
  41. package/test/components/account/Menu.browser.test.tsx +7 -5
  42. package/test/components/account/OrganizationDrawer.browser.test.tsx +13 -13
  43. package/test/components/account/OrganizationDrawerBody.test.tsx +3 -3
  44. package/test/components/account/OrganizationSignInButton.browser.test.tsx +19 -4
  45. package/test/components/navigation/Navbar.browser.test.tsx +3 -3
  46. package/test/components/navigation/NavbarAccountArea.browser.test.tsx +5 -5
  47. package/test/components/navigation/NavbarSlider.browser.test.tsx +3 -3
  48. package/test/experimental/myAccountCmsServerSideProps.test.ts +5 -5
  49. package/test/pages/api/logout.test.ts +106 -0
  50. package/test/pages/api/switch-contract.test.ts +443 -15
  51. package/test/pages/pvt/account/cards/index.test.ts +3 -3
  52. package/test/sdk/account/getIsB2BBuyer.test.ts +52 -0
  53. package/test/sdk/account/refreshToken.browser.test.ts +10 -1
  54. package/test/sdk/account/refreshToken.test.ts +150 -18
  55. package/test/sdk/account/useRefreshToken.browser.test.ts +4 -1
  56. package/test/sdk/product/usePageProductsQuery.browser.test.ts +260 -0
  57. package/test/sdk/product/usePageProductsQuery.deliveryPromise.browser.test.ts +130 -0
  58. package/test/sdk/product/useProductNotFoundRedirect.browser.test.ts +83 -0
  59. package/test/sdk/product/useProductQuery.browser.test.ts +154 -0
  60. package/test/sdk/session/refreshTokenRoute.browser.test.ts +106 -0
  61. package/test/sdk/session/validateSession.browser.test.ts +4 -1
  62. package/test/utils/trustedForwardedHost.test.ts +21 -0
  63. package/src/sdk/account/getIsRepresentative.ts +0 -20
@@ -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
- }