@faststore/core 4.10.1 → 4.10.2-dev.0

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 (79) hide show
  1. package/.turbo/turbo-generate.log +3 -3
  2. package/.turbo/turbo-test.log +304 -252
  3. package/@generated/graphql.ts +1 -0
  4. package/@generated/schema.graphql +1 -1
  5. package/CHANGELOG.md +82 -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 +18 -4
  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 +101 -60
  21. package/src/pages/pvt/account/403.tsx +5 -4
  22. package/src/pages/pvt/account/cards/index.tsx +5 -5
  23. package/src/pages/pvt/account/orders/[id].tsx +5 -5
  24. package/src/pages/pvt/account/orders/index.tsx +5 -5
  25. package/src/pages/pvt/account/profile.tsx +5 -5
  26. package/src/pages/pvt/account/quotes/index.tsx +5 -5
  27. package/src/pages/pvt/account/security.tsx +5 -5
  28. package/src/pages/pvt/account/user-details.tsx +6 -6
  29. package/src/sdk/account/getB2BSessionClaims.ts +1 -1
  30. package/src/sdk/account/getIsB2BBuyer.ts +28 -0
  31. package/src/sdk/account/refreshToken.ts +89 -11
  32. package/src/sdk/account/refreshTokenPaths.ts +25 -0
  33. package/src/sdk/account/useRefreshToken.ts +15 -11
  34. package/src/sdk/product/isProductNotFound.ts +13 -0
  35. package/src/sdk/product/usePageProductsQuery.ts +80 -21
  36. package/src/sdk/product/useProductNotFoundRedirect.ts +43 -0
  37. package/src/sdk/product/useProductQuery.ts +29 -0
  38. package/src/sdk/session/index.ts +46 -34
  39. package/src/server/cms/index.ts +59 -7
  40. package/src/server/cms/pdp.ts +10 -5
  41. package/src/server/cms/plp.ts +10 -5
  42. package/src/server/content/promiseCache.ts +68 -0
  43. package/src/server/content/service.ts +71 -51
  44. package/src/utils/getCheckoutParentDomain.ts +53 -0
  45. package/src/utils/isLocalHost.ts +2 -16
  46. package/src/utils/shouldBypassRefreshToken.ts +63 -0
  47. package/src/utils/trustedForwardedHost.ts +18 -0
  48. package/test/components/account/Layout.test.tsx +1 -1
  49. package/test/components/account/Menu.browser.test.tsx +7 -5
  50. package/test/components/account/OrganizationDrawer.browser.test.tsx +13 -13
  51. package/test/components/account/OrganizationDrawerBody.test.tsx +3 -3
  52. package/test/components/account/OrganizationSignInButton.browser.test.tsx +19 -4
  53. package/test/components/navigation/Navbar.browser.test.tsx +3 -3
  54. package/test/components/navigation/NavbarAccountArea.browser.test.tsx +5 -5
  55. package/test/components/navigation/NavbarSlider.browser.test.tsx +3 -3
  56. package/test/experimental/myAccountCmsServerSideProps.test.ts +5 -5
  57. package/test/pages/api/graphql.test.ts +175 -0
  58. package/test/pages/api/logout.test.ts +106 -0
  59. package/test/pages/api/switch-contract.test.ts +443 -15
  60. package/test/pages/pvt/account/403.test.ts +67 -0
  61. package/test/pages/pvt/account/cards/index.test.ts +3 -3
  62. package/test/sdk/account/getIsB2BBuyer.test.ts +52 -0
  63. package/test/sdk/account/refreshToken.browser.test.ts +10 -1
  64. package/test/sdk/account/refreshToken.test.ts +150 -18
  65. package/test/sdk/account/useRefreshToken.browser.test.ts +72 -0
  66. package/test/sdk/product/usePageProductsQuery.browser.test.ts +260 -0
  67. package/test/sdk/product/usePageProductsQuery.deliveryPromise.browser.test.ts +130 -0
  68. package/test/sdk/product/useProductNotFoundRedirect.browser.test.ts +83 -0
  69. package/test/sdk/product/useProductQuery.browser.test.ts +154 -0
  70. package/test/sdk/session/refreshTokenRoute.browser.test.ts +106 -0
  71. package/test/sdk/session/validateSession.browser.test.ts +111 -5
  72. package/test/server/cms/index.test.ts +102 -9
  73. package/test/server/content/promiseCache.test.ts +150 -0
  74. package/test/server/content/service.test.ts +277 -11
  75. package/test/utils/shouldBypassRefreshToken.browser.test.ts +38 -0
  76. package/test/utils/shouldBypassRefreshToken.test.ts +98 -0
  77. package/test/utils/trustedForwardedHost.test.ts +21 -0
  78. package/src/sdk/account/getIsRepresentative.ts +0 -20
  79. package/test/utils/isLocalHost.browser.test.ts +0 -37
@@ -0,0 +1,106 @@
1
+ import type { NextApiRequest, NextApiResponse } from 'next'
2
+ import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
3
+
4
+ const mockConfig = vi.hoisted(() => ({
5
+ default: {
6
+ experimental: { refreshToken: true } as Record<string, unknown>,
7
+ } as { experimental: Record<string, unknown>; secureSubdomain?: string },
8
+ }))
9
+
10
+ vi.mock('discovery.config', () => ({ __esModule: true, ...mockConfig }))
11
+
12
+ function createReqRes(cookie = 'VtexIdclientAutCookie_test-store=token') {
13
+ const res = {
14
+ status: vi.fn().mockReturnThis(),
15
+ json: vi.fn(),
16
+ end: vi.fn(),
17
+ setHeader: vi.fn(),
18
+ }
19
+
20
+ return {
21
+ req: {
22
+ method: 'POST',
23
+ headers: { host: 'store.example.com', cookie },
24
+ } as unknown as NextApiRequest,
25
+ res: res as unknown as NextApiResponse & { setHeader: Mock },
26
+ }
27
+ }
28
+
29
+ const setCookiesFrom = (res: { setHeader: Mock }): string[] =>
30
+ (res.setHeader.mock.calls.find(
31
+ ([name]) => String(name).toLowerCase() === 'set-cookie'
32
+ )?.[1] as string[] | undefined) ?? []
33
+
34
+ async function getHandler() {
35
+ const mod = await import('../../../src/pages/api/fs/logout')
36
+ return mod.default
37
+ }
38
+
39
+ describe('/api/fs/logout', () => {
40
+ beforeEach(() => {
41
+ vi.resetModules()
42
+ mockConfig.default.experimental = { refreshToken: true }
43
+ mockConfig.default.secureSubdomain = undefined
44
+ })
45
+
46
+ it('expires vid_rt on every path it can be scoped to, even though the request never carries it', async () => {
47
+ const handler = await getHandler()
48
+ const { req, res } = createReqRes()
49
+
50
+ await handler(req, res)
51
+
52
+ const vidRt = setCookiesFrom(res).filter((cookie) =>
53
+ cookie.startsWith('vid_rt=;')
54
+ )
55
+
56
+ for (const path of [
57
+ '/api/vtexid/refreshtoken/webstore',
58
+ '/api/authenticator/refreshtoken/webstore',
59
+ '/api/authenticator/v1/refreshtoken/webstore',
60
+ ]) {
61
+ expect(vidRt.some((cookie) => cookie.includes(`path=${path}`))).toBe(true)
62
+ }
63
+ })
64
+
65
+ it('does not touch vid_rt when refresh token is disabled', async () => {
66
+ mockConfig.default.experimental = {}
67
+ const handler = await getHandler()
68
+ const { req, res } = createReqRes()
69
+
70
+ await handler(req, res)
71
+
72
+ expect(
73
+ setCookiesFrom(res).some((cookie) => cookie.startsWith('vid_rt='))
74
+ ).toBe(false)
75
+ })
76
+
77
+ it('expires the auth cookie on the parent shared with a sibling checkout host', async () => {
78
+ mockConfig.default.secureSubdomain = 'https://secure.example.com'
79
+ const handler = await getHandler()
80
+ const { req, res } = createReqRes()
81
+
82
+ await handler(req, res)
83
+
84
+ expect(
85
+ setCookiesFrom(res).filter((cookie) =>
86
+ cookie.startsWith('VtexIdclientAutCookie_test-store=;')
87
+ )
88
+ ).toEqual(
89
+ expect.arrayContaining([expect.stringContaining('; domain=example.com;')])
90
+ )
91
+ })
92
+
93
+ it('does not touch the parent domain when checkout runs on the store host', async () => {
94
+ mockConfig.default.secureSubdomain = 'https://store.example.com'
95
+ const handler = await getHandler()
96
+ const { req, res } = createReqRes()
97
+
98
+ await handler(req, res)
99
+
100
+ expect(
101
+ setCookiesFrom(res).some((cookie) =>
102
+ cookie.includes('; domain=example.com;')
103
+ )
104
+ ).toBe(false)
105
+ })
106
+ })
@@ -3,11 +3,19 @@ import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
3
3
 
4
4
  import type { SwitchContractResponse } from '../../../src/pages/api/fs/switch-contract'
5
5
 
6
- vi.mock('discovery.config', () => ({
7
- __esModule: true,
8
- default: { api: { storeId: 'test-store' } },
6
+ const mockConfig = vi.hoisted(() => ({
7
+ default: {
8
+ api: { storeId: 'test-store' },
9
+ experimental: { refreshToken: true } as Record<string, unknown>,
10
+ } as {
11
+ api: { storeId: string }
12
+ experimental: Record<string, unknown>
13
+ secureSubdomain?: string
14
+ },
9
15
  }))
10
16
 
17
+ vi.mock('discovery.config', () => ({ __esModule: true, ...mockConfig }))
18
+
11
19
  const CONTRACT_ID = '69b5d80e-8f61-44e0-a5cb-db54c326699c'
12
20
  const ORDER_FORM_COOKIES =
13
21
  'checkout.vtex.com=__ofid=abc123; CheckoutOrderFormOwnership=owner-1'
@@ -74,6 +82,7 @@ const setCookiesFrom = (res: { setHeader: Mock }): string[] => {
74
82
  describe('/api/fs/switch-contract', () => {
75
83
  beforeEach(() => {
76
84
  vi.resetModules()
85
+ mockConfig.default.secureSubdomain = undefined
77
86
  global.fetch = vi.fn().mockResolvedValue({
78
87
  ok: true,
79
88
  status: 200,
@@ -185,20 +194,299 @@ describe('/api/fs/switch-contract', () => {
185
194
  expect(res.json).toHaveBeenCalledWith({ success: true })
186
195
  })
187
196
 
188
- it('carries Max-Age from expiresIn and Secure behind https', async () => {
197
+ it('writes the auth cookie with the same scope VTEX ID uses', async () => {
189
198
  const handler = await getHandler()
190
199
  const { req, res } = createReqRes()
191
200
 
192
201
  await handler(req, res)
193
202
 
194
203
  const authCookie = setCookiesFrom(res).find((cookie) =>
204
+ cookie.startsWith('VtexIdclientAutCookie_test-store=new-contract-token')
205
+ )
206
+
207
+ expect(authCookie).toBe(
208
+ 'VtexIdclientAutCookie_test-store=new-contract-token; Domain=store.example.com; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=None'
209
+ )
210
+ })
211
+
212
+ it('stays Secure even when the proxy forwards plain http', async () => {
213
+ const handler = await getHandler()
214
+ const { req, res } = createReqRes({
215
+ headers: { 'x-forwarded-proto': 'http' },
216
+ })
217
+
218
+ await handler(req, res)
219
+
220
+ const authCookie = setCookiesFrom(res).find((cookie) =>
221
+ cookie.startsWith('VtexIdclientAutCookie_test-store=new-contract-token')
222
+ )
223
+
224
+ expect(authCookie).toContain('; Secure')
225
+ })
226
+
227
+ it('expires a leftover host-only auth cookie before setting the scoped one', async () => {
228
+ const handler = await getHandler()
229
+ const { req, res } = createReqRes()
230
+
231
+ await handler(req, res)
232
+
233
+ const authCookies = setCookiesFrom(res).filter((cookie) =>
234
+ cookie.startsWith('VtexIdclientAutCookie_test-store=')
235
+ )
236
+
237
+ expect(authCookies).toHaveLength(3)
238
+ expect(authCookies[0]).toMatch(/^VtexIdclientAutCookie_test-store=;/)
239
+ expect(authCookies[0]).toContain('max-age=0')
240
+ expect(authCookies[0]).toContain('path=/')
241
+ // Host-only: no Domain, so it cannot touch VTEX ID's scoped cookie.
242
+ expect(authCookies[0].toLowerCase()).not.toContain('domain=')
243
+ expect(authCookies[2]).toContain('Domain=store.example.com')
244
+ })
245
+
246
+ it('expires the copy VTEX ID refresh leaves on the parent domain', async () => {
247
+ const handler = await getHandler()
248
+ const { req, res } = createReqRes()
249
+
250
+ await handler(req, res)
251
+
252
+ const authCookies = setCookiesFrom(res).filter((cookie) =>
253
+ cookie.startsWith('VtexIdclientAutCookie_test-store=')
254
+ )
255
+
256
+ expect(authCookies[1]).toMatch(/^VtexIdclientAutCookie_test-store=;/)
257
+ expect(authCookies[1]).toContain('max-age=0')
258
+ expect(authCookies[1]).toContain('path=/')
259
+ expect(authCookies[1]).toContain('domain=example.com')
260
+ // The new token is never written to the parent domain.
261
+ expect(
262
+ authCookies.filter((cookie) => cookie.includes('new-contract-token'))
263
+ ).toEqual([expect.stringContaining('Domain=store.example.com;')])
264
+ })
265
+
266
+ it.each([
267
+ ['a single-label parent', 'store.com', []],
268
+ ['a deep host', 'a.b.store.com', ['b.store.com', 'store.com']],
269
+ ['a two-level suffix', 'www.store.com.br', ['store.com.br', 'com.br']],
270
+ ['a .co.uk store', 'www.store.co.uk', ['store.co.uk', 'co.uk']],
271
+ ['a .com.au store', 'shop.store.com.au', ['store.com.au', 'com.au']],
272
+ ])('expires every parent domain for %s', async (_label, host, parents) => {
273
+ const handler = await getHandler()
274
+ const { req, res } = createReqRes({ headers: { host } })
275
+
276
+ await handler(req, res)
277
+
278
+ const expiredDomains = setCookiesFrom(res)
279
+ .filter((cookie) =>
280
+ cookie.startsWith('VtexIdclientAutCookie_test-store=;')
281
+ )
282
+ .map((cookie) => cookie.match(/domain=([^;]+)/)?.[1])
283
+ .filter(Boolean)
284
+
285
+ expect(expiredDomains).toEqual(parents)
286
+ })
287
+
288
+ it.each(['www.store.com.br', 'www.store.co.uk', 'shop.store.com.au'])(
289
+ 'writes the new token only on the request host for %s, never on a parent or public suffix',
290
+ async (host) => {
291
+ const handler = await getHandler()
292
+ const { req, res } = createReqRes({ headers: { host } })
293
+
294
+ await handler(req, res)
295
+
296
+ // Parents (including public suffixes such as co.uk, which the browser
297
+ // rejects) only ever get expiries; the token is scoped to the host.
298
+ expect(
299
+ setCookiesFrom(res).filter((cookie) =>
300
+ cookie.includes('new-contract-token')
301
+ )
302
+ ).toEqual([expect.stringContaining(`; Domain=${host}; Path=/;`)])
303
+ }
304
+ )
305
+
306
+ describe('with checkout on a sibling host', () => {
307
+ const tokenCookies = (cookies: string[]) =>
308
+ cookies.filter((cookie) => cookie.includes('new-contract-token'))
309
+
310
+ it('writes the same token on the host and on the shared parent', async () => {
311
+ mockConfig.default.secureSubdomain = 'https://secure.example.com'
312
+ const handler = await getHandler()
313
+ const { req, res } = createReqRes()
314
+
315
+ await handler(req, res)
316
+
317
+ const cookies = setCookiesFrom(res)
318
+
319
+ expect(tokenCookies(cookies)).toEqual([
320
+ 'VtexIdclientAutCookie_test-store=new-contract-token; Domain=store.example.com; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=None',
321
+ 'VtexIdclientAutCookie_test-store=new-contract-token; Domain=example.com; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=None',
322
+ ])
323
+ expect(
324
+ cookies.some(
325
+ (cookie) =>
326
+ cookie.startsWith('VtexIdclientAutCookie_test-store=;') &&
327
+ cookie.includes('domain=example.com;')
328
+ )
329
+ ).toBe(false)
330
+ })
331
+
332
+ it('still expires the parents above the shared one', async () => {
333
+ mockConfig.default.secureSubdomain = 'https://secure.b.store.com'
334
+ const handler = await getHandler()
335
+ const { req, res } = createReqRes({ headers: { host: 'a.b.store.com' } })
336
+
337
+ await handler(req, res)
338
+
339
+ const cookies = setCookiesFrom(res)
340
+ const expiredDomains = cookies
341
+ .filter((cookie) =>
342
+ cookie.startsWith('VtexIdclientAutCookie_test-store=;')
343
+ )
344
+ .map((cookie) => cookie.match(/domain=([^;]+)/)?.[1])
345
+ .filter(Boolean)
346
+
347
+ expect(expiredDomains).toEqual(['store.com'])
348
+ expect(tokenCookies(cookies)).toEqual([
349
+ expect.stringContaining('; Domain=a.b.store.com; Path=/;'),
350
+ expect.stringContaining('; Domain=b.store.com; Path=/;'),
351
+ ])
352
+ })
353
+
354
+ it.each([
355
+ ['on the store host', 'https://store.example.com'],
356
+ ['on a subdomain of the store host', 'https://secure.store.example.com'],
357
+ ['on an unrelated domain', 'https://secure.other.org'],
358
+ ['with an invalid URL', 'not a url'],
359
+ ])('writes only the host copy with checkout %s', async (_label, url) => {
360
+ mockConfig.default.secureSubdomain = url
361
+ const handler = await getHandler()
362
+ const { req, res } = createReqRes()
363
+
364
+ await handler(req, res)
365
+
366
+ expect(tokenCookies(setCookiesFrom(res))).toEqual([
367
+ expect.stringContaining('; Domain=store.example.com; Path=/;'),
368
+ ])
369
+ })
370
+
371
+ it('keeps preview hosts host-only', async () => {
372
+ mockConfig.default.secureSubdomain = 'https://secure.vtex.app'
373
+ const handler = await getHandler()
374
+ const { req, res } = createReqRes({
375
+ headers: { host: 'sfj-abc--test-store.preview.vtex.app' },
376
+ })
377
+
378
+ await handler(req, res)
379
+
380
+ const cookies = tokenCookies(setCookiesFrom(res))
381
+
382
+ expect(cookies).toHaveLength(1)
383
+ expect(cookies[0].toLowerCase()).not.toContain('domain=')
384
+ })
385
+
386
+ it('keeps vid_rt on the store host', async () => {
387
+ mockConfig.default.secureSubdomain = 'https://secure.example.com'
388
+ global.fetch = vi.fn().mockResolvedValue({
389
+ ok: true,
390
+ status: 200,
391
+ json: async () => successPayload,
392
+ headers: {
393
+ getSetCookie: () => [
394
+ 'vid_rt=rt-1; path=/api/authenticator/v1/refreshtoken/webstore; secure; httponly; Domain=test-store.myvtex.com',
395
+ ],
396
+ },
397
+ })
398
+ const handler = await getHandler()
399
+ const { req, res } = createReqRes()
400
+
401
+ await handler(req, res)
402
+
403
+ expect(
404
+ setCookiesFrom(res).filter((cookie) => cookie.startsWith('vid_rt='))
405
+ ).toEqual([expect.stringMatching(/; Domain=store\.example\.com$/)])
406
+ })
407
+ })
408
+
409
+ it('scopes the cookie to an {account}.vtex.app production host', async () => {
410
+ // Unlike previews, this domain receives the browser host in `Host`.
411
+ const handler = await getHandler()
412
+ const { req, res } = createReqRes({
413
+ headers: { host: 'test-store.vtex.app' },
414
+ })
415
+
416
+ await handler(req, res)
417
+
418
+ const authCookies = setCookiesFrom(res).filter((cookie) =>
419
+ cookie.includes('new-contract-token')
420
+ )
421
+
422
+ expect(authCookies).toEqual([
423
+ expect.stringContaining('; Domain=test-store.vtex.app; Path=/;'),
424
+ ])
425
+ })
426
+
427
+ it.each([
428
+ ['a preview Host', { host: 'sfj-abc--test-store.preview.vtex.app' }],
429
+ [
430
+ 'a preview x-forwarded-host behind an internal Host',
431
+ {
432
+ host: 'discovery.internal',
433
+ 'x-forwarded-host': 'sfj-abc--test-store.preview.vtex.app',
434
+ },
435
+ ],
436
+ ['a .localhost Host', { host: 'store.localhost:3000' }],
437
+ ['a trailing-dot FQDN', { host: 'store.example.com.' }],
438
+ ])('writes a single host-only cookie for %s', async (_label, headers) => {
439
+ const handler = await getHandler()
440
+ const { req, res } = createReqRes({ headers })
441
+
442
+ await handler(req, res)
443
+
444
+ const authCookies = setCookiesFrom(res).filter((cookie) =>
195
445
  cookie.startsWith('VtexIdclientAutCookie_test-store=')
196
446
  )
197
447
 
198
- expect(authCookie).toContain('Max-Age=3600')
199
- expect(authCookie).toContain('Path=/')
200
- expect(authCookie).toContain('SameSite=Lax')
201
- expect(authCookie).toContain('Secure')
448
+ expect(authCookies).toHaveLength(1)
449
+ expect(authCookies[0]).toContain('new-contract-token; Path=/;')
450
+ expect(authCookies[0].toLowerCase()).not.toContain('domain=')
451
+ })
452
+
453
+ it('does not let x-forwarded-host choose the Domain', async () => {
454
+ const handler = await getHandler()
455
+ const { req, res } = createReqRes({
456
+ headers: { 'x-forwarded-host': 'attacker.example.org' },
457
+ })
458
+
459
+ await handler(req, res)
460
+
461
+ const cookies = setCookiesFrom(res)
462
+
463
+ expect(cookies).toEqual(
464
+ expect.arrayContaining([
465
+ expect.stringContaining(
466
+ 'new-contract-token; Domain=store.example.com;'
467
+ ),
468
+ ])
469
+ )
470
+ expect(
471
+ cookies.some((cookie) => cookie.includes('attacker.example.org'))
472
+ ).toBe(false)
473
+ })
474
+
475
+ it('strips the port from the Host header', async () => {
476
+ const handler = await getHandler()
477
+ const { req, res } = createReqRes({
478
+ headers: { host: 'store.example.com:443' },
479
+ })
480
+
481
+ await handler(req, res)
482
+
483
+ expect(setCookiesFrom(res)).toEqual(
484
+ expect.arrayContaining([
485
+ expect.stringContaining(
486
+ 'new-contract-token; Domain=store.example.com; Path=/'
487
+ ),
488
+ ])
489
+ )
202
490
  })
203
491
 
204
492
  it.each([
@@ -222,7 +510,7 @@ describe('/api/fs/switch-contract', () => {
222
510
  await handler(req, res)
223
511
 
224
512
  const authCookie = setCookiesFrom(res).find((cookie) =>
225
- cookie.startsWith('VtexIdclientAutCookie_test-store=')
513
+ cookie.startsWith('VtexIdclientAutCookie_test-store=new-contract-token')
226
514
  )
227
515
 
228
516
  // Max-Age=0 would delete the very credential this response installs.
@@ -231,19 +519,154 @@ describe('/api/fs/switch-contract', () => {
231
519
  }
232
520
  )
233
521
 
234
- it('omits Secure on plain http', async () => {
522
+ it.each([
523
+ ['localhost', 'localhost:3000'],
524
+ ['127.0.0.1', '127.0.0.1:3000'],
525
+ ['a LAN IP', '192.168.1.10'],
526
+ ['an unsafe host', 'store.example.com;evil=1'],
527
+ ])('writes a single host-only cookie on %s', async (_label, host) => {
235
528
  const handler = await getHandler()
236
529
  const { req, res } = createReqRes({
237
- headers: { 'x-forwarded-proto': 'http' },
530
+ headers: { host, 'x-forwarded-proto': 'http' },
238
531
  })
239
532
 
240
533
  await handler(req, res)
241
534
 
242
- const authCookie = setCookiesFrom(res).find((cookie) =>
535
+ const authCookies = setCookiesFrom(res).filter((cookie) =>
243
536
  cookie.startsWith('VtexIdclientAutCookie_test-store=')
244
537
  )
245
538
 
246
- expect(authCookie).not.toContain('Secure')
539
+ // No Domain means the cookie already replaces the host-only one, so
540
+ // expiring it in the same batch would delete the new credential.
541
+ expect(authCookies).toEqual([
542
+ 'VtexIdclientAutCookie_test-store=new-contract-token; Path=/; Max-Age=3600; HttpOnly; SameSite=Lax',
543
+ ])
544
+ })
545
+
546
+ it.each([
547
+ ['a LAN IP', '192.168.1.10'],
548
+ ['an IPv6 literal', '[2001:db8::1]'],
549
+ ])(
550
+ 'keeps the host-only cookie Secure on %s over https',
551
+ async (_label, host) => {
552
+ const handler = await getHandler()
553
+ const { req, res } = createReqRes({
554
+ headers: { host, 'x-forwarded-proto': 'https' },
555
+ })
556
+
557
+ await handler(req, res)
558
+
559
+ const authCookies = setCookiesFrom(res).filter((cookie) =>
560
+ cookie.startsWith('VtexIdclientAutCookie_test-store=')
561
+ )
562
+
563
+ expect(authCookies).toEqual([
564
+ 'VtexIdclientAutCookie_test-store=new-contract-token; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Lax',
565
+ ])
566
+ }
567
+ )
568
+
569
+ it.each([
570
+ ['plain http', false, ''],
571
+ ['a TLS connection', true, '; Secure'],
572
+ ])(
573
+ 'follows the connection without x-forwarded-proto on %s',
574
+ async (_label, encrypted, secureAttr) => {
575
+ const handler = await getHandler()
576
+ const { req, res } = createReqRes({
577
+ headers: { host: '192.168.1.10', 'x-forwarded-proto': undefined },
578
+ socket: { encrypted } as unknown as NextApiRequest['socket'],
579
+ })
580
+
581
+ await handler(req, res)
582
+
583
+ const authCookies = setCookiesFrom(res).filter((cookie) =>
584
+ cookie.startsWith('VtexIdclientAutCookie_test-store=')
585
+ )
586
+
587
+ expect(authCookies).toEqual([
588
+ `VtexIdclientAutCookie_test-store=new-contract-token; Path=/; Max-Age=3600; HttpOnly${secureAttr}; SameSite=Lax`,
589
+ ])
590
+ }
591
+ )
592
+
593
+ it('relays the upstream vid_rt re-scoped to the storefront host', async () => {
594
+ ;(global.fetch as Mock).mockResolvedValue({
595
+ ok: true,
596
+ status: 200,
597
+ json: async () => successPayload,
598
+ headers: {
599
+ getSetCookie: () => [
600
+ 'vid_rt=rt-1; expires=Thu, 22 Oct 2027 00:00:00 GMT; domain=test-store.myvtex.com; path=/api/authenticator/v1/refreshtoken/webstore; secure; samesite=strict; httponly',
601
+ 'VtexIdclientAutCookie_test-store=upstream-token; domain=test-store.myvtex.com; path=/',
602
+ ],
603
+ },
604
+ })
605
+ const handler = await getHandler()
606
+ const { req, res } = createReqRes()
607
+
608
+ await handler(req, res)
609
+
610
+ const cookies = setCookiesFrom(res)
611
+ const vidRt = cookies.filter((cookie) => cookie.startsWith('vid_rt='))
612
+
613
+ expect(vidRt).toEqual([
614
+ 'vid_rt=rt-1; expires=Thu, 22 Oct 2027 00:00:00 GMT; path=/api/authenticator/v1/refreshtoken/webstore; secure; samesite=strict; httponly; Domain=store.example.com',
615
+ ])
616
+ // Only vid_rt is relayed; the auth cookie still comes from the payload.
617
+ expect(cookies.some((cookie) => cookie.includes('upstream-token'))).toBe(
618
+ false
619
+ )
620
+ })
621
+
622
+ it('does not relay vid_rt when refresh token is disabled', async () => {
623
+ mockConfig.default.experimental = {}
624
+ ;(global.fetch as Mock).mockResolvedValue({
625
+ ok: true,
626
+ status: 200,
627
+ json: async () => successPayload,
628
+ headers: {
629
+ getSetCookie: () => [
630
+ 'vid_rt=rt-1; domain=test-store.myvtex.com; path=/api/authenticator/v1/refreshtoken/webstore; secure; httponly',
631
+ ],
632
+ },
633
+ })
634
+ const handler = await getHandler()
635
+ const { req, res } = createReqRes()
636
+
637
+ try {
638
+ await handler(req, res)
639
+ } finally {
640
+ mockConfig.default.experimental = { refreshToken: true }
641
+ }
642
+
643
+ const cookies = setCookiesFrom(res)
644
+ expect(cookies.some((cookie) => cookie.startsWith('vid_rt='))).toBe(false)
645
+ // The contract switch itself still goes through.
646
+ expect(
647
+ cookies.some((cookie) => cookie.includes('new-contract-token'))
648
+ ).toBe(true)
649
+ })
650
+
651
+ it('relays vid_rt host-only when the auth cookie is host-only', async () => {
652
+ ;(global.fetch as Mock).mockResolvedValue({
653
+ ok: true,
654
+ status: 200,
655
+ json: async () => successPayload,
656
+ headers: {
657
+ getSetCookie: () => [
658
+ 'vid_rt=rt-1; domain=test-store.myvtex.com; path=/api/vtexid/refreshtoken/webstore; httponly',
659
+ ],
660
+ },
661
+ })
662
+ const handler = await getHandler()
663
+ const { req, res } = createReqRes({ headers: { host: 'localhost:3000' } })
664
+
665
+ await handler(req, res)
666
+
667
+ expect(
668
+ setCookiesFrom(res).filter((cookie) => cookie.startsWith('vid_rt='))
669
+ ).toEqual(['vid_rt=rt-1; path=/api/vtexid/refreshtoken/webstore; httponly'])
247
670
  })
248
671
 
249
672
  it('also emits the accountAuthCookie when present', async () => {
@@ -263,9 +686,14 @@ describe('/api/fs/switch-contract', () => {
263
686
 
264
687
  await handler(req, res)
265
688
 
266
- expect(setCookiesFrom(res)).toEqual(
689
+ const cookies = setCookiesFrom(res)
690
+
691
+ expect(cookies).toEqual(
267
692
  expect.arrayContaining([
268
- expect.stringContaining('VtexIdclientAutCookie_account=account-token'),
693
+ expect.stringMatching(/^VtexIdclientAutCookie_account=;.*max-age=0/),
694
+ expect.stringContaining(
695
+ 'VtexIdclientAutCookie_account=account-token; Domain=store.example.com'
696
+ ),
269
697
  ])
270
698
  )
271
699
  })
@@ -0,0 +1,67 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const mockExecute = vi.hoisted(() => vi.fn())
4
+
5
+ vi.mock('src/server', () => ({ execute: mockExecute }))
6
+ vi.mock('src/server/cms/global', () => ({
7
+ injectGlobalSections: vi.fn(() => ({ sections: [], settings: {} })),
8
+ }))
9
+ vi.mock('src/server/cms/fetchMyAccountPageContent', () => ({
10
+ fetchMyAccountPageContent: vi.fn(async () => ({ sections: [] })),
11
+ }))
12
+ vi.mock('src/components/cms/GlobalSections', () => ({
13
+ getGlobalSectionsData: vi.fn(() => [
14
+ Promise.resolve(null),
15
+ Promise.resolve(null),
16
+ Promise.resolve(null),
17
+ ]),
18
+ }))
19
+
20
+ import storeConfig from 'discovery.config'
21
+ import { getServerSideProps } from 'src/pages/pvt/account/403'
22
+
23
+ function makeContext(host: string) {
24
+ return {
25
+ req: { headers: { host } },
26
+ query: {},
27
+ previewData: undefined,
28
+ locale: undefined,
29
+ } as any
30
+ }
31
+
32
+ describe('/pvt/account/403 getServerSideProps refresh-token bypass', () => {
33
+ const originalRefreshToken = storeConfig.experimental.refreshToken
34
+
35
+ beforeEach(() => {
36
+ storeConfig.experimental.refreshToken = true
37
+ vi.spyOn(console, 'error').mockImplementation(() => {})
38
+ mockExecute.mockResolvedValue({
39
+ data: null,
40
+ errors: [{ extensions: { status: 401 } }],
41
+ })
42
+ })
43
+
44
+ afterEach(() => {
45
+ storeConfig.experimental.refreshToken = originalRefreshToken
46
+ vi.restoreAllMocks()
47
+ })
48
+
49
+ it.each([
50
+ 'localhost:3000',
51
+ '127.0.0.1:3000',
52
+ 'brandless-abc123.preview.vtex.app',
53
+ ])('does not ask for a token refresh on %s', async (host) => {
54
+ const result: any = await getServerSideProps(makeContext(host))
55
+
56
+ expect(result.props.needsRefreshToken).toBe(false)
57
+ })
58
+
59
+ it.each(['brandless.vtex.app', 'brandless.fast.store'])(
60
+ 'asks for a token refresh on the production host %s',
61
+ async (host) => {
62
+ const result: any = await getServerSideProps(makeContext(host))
63
+
64
+ expect(result.props.needsRefreshToken).toBe(true)
65
+ }
66
+ )
67
+ })