@faststore/core 4.10.1-dev.3 → 4.10.1-dev.5

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/CHANGELOG.md CHANGED
@@ -3,6 +3,17 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [4.10.1-dev.5](https://github.com/vtex/faststore/compare/v4.10.1-dev.4...v4.10.1-dev.5) (2026-10-28)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **core:** bypass refresh-token flow on WebOps preview hosts ([0c8db36](https://github.com/vtex/faststore/commit/0c8db363b2f62ff054e3f3524cd15951b3aafe91))
11
+ - **core:** detect preview hosts from x-forwarded-host on the server ([ec5d8a7](https://github.com/vtex/faststore/commit/ec5d8a7c70eca06c90153fe7cb5b58aa192bfb46))
12
+
13
+ ## [4.10.1-dev.4](https://github.com/vtex/faststore/compare/v4.10.1-dev.3...v4.10.1-dev.4) (2026-10-28)
14
+
15
+ **Note:** Version bump only for package @faststore/core
16
+
6
17
  ## [4.10.1-dev.3](https://github.com/vtex/faststore/compare/v4.10.1-dev.2...v4.10.1-dev.3) (2026-10-27)
7
18
 
8
19
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faststore/core",
3
- "version": "4.10.1-dev.3",
3
+ "version": "4.10.1-dev.5",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -71,11 +71,11 @@
71
71
  "style-loader": "^3.3.1",
72
72
  "swr": "^2.2.5",
73
73
  "use-sync-external-store": "^1.6.0",
74
- "@faststore/diagnostics": "4.10.1-dev.3",
75
- "@faststore/lighthouse": "4.10.1-dev.3",
76
- "@faststore/sdk": "4.10.1-dev.3",
77
- "@faststore/ui": "4.10.1-dev.3",
78
- "@faststore/api": "4.10.1-dev.3"
74
+ "@faststore/api": "4.10.1-dev.5",
75
+ "@faststore/diagnostics": "4.10.1-dev.5",
76
+ "@faststore/lighthouse": "4.10.1-dev.5",
77
+ "@faststore/ui": "4.10.1-dev.5",
78
+ "@faststore/sdk": "4.10.1-dev.5"
79
79
  },
80
80
  "devDependencies": {
81
81
  "@cypress/code-coverage": "^3.12.1",
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  BadRequestError,
3
+ type CacheControl,
3
4
  UnauthorizedError,
4
5
  isFastStoreError,
5
6
  stringifyCacheControl,
@@ -10,7 +11,7 @@ import type { NextApiHandler, NextApiRequest } from 'next'
10
11
  import discoveryConfig from 'discovery.config'
11
12
  import { getJWTAutCookie } from 'src/utils/getCookie'
12
13
  import { getRequestHostname } from 'src/utils/getRequestHostname'
13
- import { isLocalHost } from 'src/utils/isLocalHost'
14
+ import { shouldBypassRefreshTokenForRequest } from 'src/utils/shouldBypassRefreshToken'
14
15
  import {
15
16
  isHostAllowed,
16
17
  removeCookieDomain,
@@ -260,6 +261,87 @@ const respondToUnexpectedError = (
260
261
  response.status(500).end()
261
262
  }
262
263
 
264
+ /**
265
+ * With `experimental.refreshToken` on, rejects a ValidateSession with 401 when
266
+ * the token needs a refresh, so the client runs the refresh-token flow first.
267
+ *
268
+ * Skipped on localhost and WebOps previews: neither proxies VTEX ID, so the
269
+ * refresh would fail and log out a manually injected auth cookie.
270
+ */
271
+ const assertValidateSessionTokenIsFresh = ({
272
+ request,
273
+ operationName,
274
+ sessionRefreshAfter,
275
+ }: {
276
+ request: NextApiRequest
277
+ operationName: string | undefined
278
+ sessionRefreshAfter: string | null | undefined
279
+ }) => {
280
+ if (
281
+ operationName !== 'ValidateSession' ||
282
+ !discoveryConfig.experimental?.refreshToken ||
283
+ shouldBypassRefreshTokenForRequest(request.headers)
284
+ ) {
285
+ return
286
+ }
287
+
288
+ const jwt = getJWTAutCookie({
289
+ headers: request.headers,
290
+ account: discoveryConfig.api.storeId,
291
+ })
292
+
293
+ if (shouldForceRefreshTokenForValidateSession({ jwt, sessionRefreshAfter })) {
294
+ throw new UnauthorizedError(
295
+ 'Unauthorized: Token expired. Please login again or refresh the page.'
296
+ )
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Cache-Control for a successful response: the directives collected while
302
+ * executing the operation when there are any, otherwise a default
303
+ * `s-maxage`/`stale-while-revalidate` for GET queries and `no-store` for
304
+ * everything else.
305
+ */
306
+ const getSuccessCacheControl = ({
307
+ request,
308
+ operationName,
309
+ cacheControl,
310
+ hasAuthCookie,
311
+ }: {
312
+ request: NextApiRequest
313
+ operationName: string | undefined
314
+ cacheControl: CacheControl | undefined
315
+ hasAuthCookie: boolean
316
+ }) => {
317
+ if (cacheControl) {
318
+ return stringifyCacheControl(cacheControl, hasAuthCookie)
319
+ }
320
+
321
+ if (
322
+ request.method !== 'GET' ||
323
+ !operationName?.toLowerCase()?.endsWith('query')
324
+ ) {
325
+ return 'no-cache, no-store'
326
+ }
327
+
328
+ const graphqlCacheControl = discoveryConfig?.experimental?.graphqlCacheControl
329
+
330
+ const maxAge =
331
+ graphqlCacheControl?.maxAge && graphqlCacheControl.maxAge > 0
332
+ ? graphqlCacheControl.maxAge
333
+ : DEFAULT_MAX_AGE // 5 minutes
334
+
335
+ const staleWhileRevalidate =
336
+ graphqlCacheControl?.staleWhileRevalidate &&
337
+ graphqlCacheControl.staleWhileRevalidate > 0
338
+ ? graphqlCacheControl.staleWhileRevalidate
339
+ : DEFAULT_STALE_WHILE_REVALIDATE // 1 hour
340
+
341
+ const scope = hasAuthCookie ? 'private' : 'public'
342
+ return `${scope}, s-maxage=${maxAge}, stale-while-revalidate=${staleWhileRevalidate}`
343
+ }
344
+
263
345
  const handler: NextApiHandler = async (request, response) => {
264
346
  if (request.method !== 'POST' && request.method !== 'GET') {
265
347
  response.status(405).end()
@@ -286,29 +368,11 @@ const handler: NextApiHandler = async (request, response) => {
286
368
  operation?.__meta__?.operationHash
287
369
  )
288
370
 
289
- const isLocal = isLocalHost(getRequestHostname(request.headers.host))
290
-
291
- if (
292
- !isLocal &&
293
- operation.__meta__.operationName === 'ValidateSession' &&
294
- discoveryConfig.experimental?.refreshToken
295
- ) {
296
- const jwt = getJWTAutCookie({
297
- headers: request.headers,
298
- account: discoveryConfig.api.storeId,
299
- })
300
-
301
- const shouldRefreshToken = shouldForceRefreshTokenForValidateSession({
302
- jwt,
303
- sessionRefreshAfter: variables?.session?.refreshAfter,
304
- })
305
-
306
- if (shouldRefreshToken) {
307
- throw new UnauthorizedError(
308
- 'Unauthorized: Token expired. Please login again or refresh the page.'
309
- )
310
- }
311
- }
371
+ assertValidateSessionTokenIsFresh({
372
+ request,
373
+ operationName: operation.__meta__.operationName,
374
+ sessionRefreshAfter: variables?.session?.refreshAfter,
375
+ })
312
376
 
313
377
  // Prevents to call ValidateSession or ValidateCartMutation without session (required) and get GraphQLError
314
378
  const doNotRun =
@@ -338,39 +402,15 @@ const handler: NextApiHandler = async (request, response) => {
338
402
 
339
403
  const hasAuthCookie = hasVtexIdclientAutCookie(request)
340
404
 
341
- if (extensions.cacheControl) {
342
- const cacheControl = stringifyCacheControl(
343
- extensions.cacheControl,
344
- hasAuthCookie
345
- )
346
- response.setHeader('cache-control', cacheControl)
347
- } else if (
348
- request.method === 'GET' &&
349
- operation.__meta__.operationName?.toLowerCase()?.endsWith('query')
350
- ) {
351
- const maxAge =
352
- discoveryConfig?.experimental?.graphqlCacheControl?.maxAge &&
353
- discoveryConfig?.experimental?.graphqlCacheControl?.maxAge > 0
354
- ? discoveryConfig.experimental.graphqlCacheControl.maxAge
355
- : DEFAULT_MAX_AGE // 5 minutes
356
-
357
- const staleWhileRevalidate =
358
- discoveryConfig?.experimental?.graphqlCacheControl
359
- ?.staleWhileRevalidate &&
360
- discoveryConfig?.experimental?.graphqlCacheControl
361
- ?.staleWhileRevalidate > 0
362
- ? discoveryConfig.experimental.graphqlCacheControl
363
- .staleWhileRevalidate
364
- : DEFAULT_STALE_WHILE_REVALIDATE // 1 hour
365
-
366
- const scope = hasAuthCookie ? 'private' : 'public'
367
- response.setHeader(
368
- 'cache-control',
369
- `${scope}, s-maxage=${maxAge}, stale-while-revalidate=${staleWhileRevalidate}`
370
- )
371
- } else {
372
- response.setHeader('cache-control', 'no-cache, no-store')
373
- }
405
+ response.setHeader(
406
+ 'cache-control',
407
+ getSuccessCacheControl({
408
+ request,
409
+ operationName: operation.__meta__.operationName,
410
+ cacheControl: extensions.cacheControl,
411
+ hasAuthCookie,
412
+ })
413
+ )
374
414
 
375
415
  const setCookieValues = Array.from(extensions.cookies.values())
376
416
  if (setCookieValues.length > 0 && !hasErrors) {
@@ -24,8 +24,7 @@ import PageProvider from 'src/sdk/overrides/PageProvider'
24
24
  import { execute } from 'src/server'
25
25
  import { injectGlobalSections } from 'src/server/cms/global'
26
26
  import { fetchMyAccountPageContent } from 'src/server/cms/fetchMyAccountPageContent'
27
- import { getRequestHostname } from 'src/utils/getRequestHostname'
28
- import { isLocalHost } from 'src/utils/isLocalHost'
27
+ import { shouldBypassRefreshTokenForRequest } from 'src/utils/shouldBypassRefreshToken'
29
28
  import { withLocaleValidationSSR } from 'src/utils/localization/withLocaleValidation'
30
29
  import { getMyAccountRedirect } from 'src/utils/myAccountRedirect'
31
30
 
@@ -137,14 +136,16 @@ const getServerSidePropsBase: GetServerSideProps<
137
136
 
138
137
  const statusCode: number = (account.errors[0] as any)?.extensions?.status
139
138
 
140
- const isLocal = isLocalHost(getRequestHostname(context.req.headers.host))
139
+ const bypassRefreshToken = shouldBypassRefreshTokenForRequest(
140
+ context.req.headers
141
+ )
141
142
 
142
143
  return {
143
144
  props: {
144
145
  globalSections: globalSectionsResult,
145
146
  pageSections: pageContent.sections,
146
147
  needsRefreshToken:
147
- !isLocal &&
148
+ !bypassRefreshToken &&
148
149
  (statusCode === 401 || statusCode === 403) &&
149
150
  storeConfig.experimental?.refreshToken,
150
151
  fromPage,
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useState } from 'react'
2
- import { isLocalHostBrowser } from 'src/utils/isLocalHost'
2
+ import { shouldBypassRefreshTokenBrowser } from 'src/utils/shouldBypassRefreshToken'
3
3
  import { logoutAndClearSession, sessionStore } from '../session'
4
4
  import { isRefreshTokenSuccessful, refreshTokenRequest } from './refreshToken'
5
5
 
@@ -13,13 +13,12 @@ export const useRefreshToken = (
13
13
  const handleRefreshTokenAndUpdateSession = async () => {
14
14
  if (!needsRefreshToken) return
15
15
 
16
- // The refresh-token endpoint lives on the production origin and relies on
17
- // the `vid_rt` cookie scoped to that origin. From localhost the request is
18
- // cross-origin, so the cookie is not sent and the refresh always fails —
19
- // which would also wipe the manually injected `VtexIdclientAutCookie_<account>`
16
+ // The refresh-token endpoint relies on VTEX ID, which neither localhost
17
+ // nor WebOps previews proxy, so the refresh always fails there — which
18
+ // would also wipe the manually injected `VtexIdclientAutCookie_<account>`
20
19
  // via `logoutAndClearSession`. Bail out and fall back to the static 403
21
20
  // view so developers can keep testing logged-in flows.
22
- if (isLocalHostBrowser()) {
21
+ if (shouldBypassRefreshTokenBrowser()) {
23
22
  setShouldShow403(true)
24
23
  return
25
24
  }
@@ -8,7 +8,7 @@ import type {
8
8
  ValidateSessionMutationVariables,
9
9
  } from '@generated/graphql'
10
10
  import deepEqual from 'fast-deep-equal'
11
- import { isLocalHostBrowser } from 'src/utils/isLocalHost'
11
+ import { shouldBypassRefreshTokenBrowser } from 'src/utils/shouldBypassRefreshToken'
12
12
  import { filterChannel } from 'src/utils/utilities'
13
13
  import storeConfig from '../../../discovery.config'
14
14
  import {
@@ -109,6 +109,43 @@ async function handleRefreshToken(session: Session): Promise<Session | null> {
109
109
  return null
110
110
  }
111
111
 
112
+ /**
113
+ * The refresh-token flow runs only when `experimental.refreshToken` is on and
114
+ * the host can reach VTEX ID: localhost and WebOps previews do not proxy it.
115
+ */
116
+ function canRefreshToken(): boolean {
117
+ return (
118
+ !!storeConfig.experimental?.refreshToken &&
119
+ !shouldBypassRefreshTokenBrowser()
120
+ )
121
+ }
122
+
123
+ /**
124
+ * With deliveryPromise on and no postalCode in the session, fills it from the
125
+ * B2B saved address or from the shopper location.
126
+ */
127
+ async function syncDeliveryPromisePostalCode(session: Session) {
128
+ if (
129
+ !storeConfig.deliveryPromise?.enabled ||
130
+ (session.postalCode &&
131
+ session.postalCode !== storeConfig.session.postalCode)
132
+ ) {
133
+ return
134
+ }
135
+
136
+ // Case B2B: If a B2B shopper is logged in and a saved address is available, the postalCode field is automatically updated with the postal code from that address by the B2B session apps (shopper-session and profile-session).
137
+ if (session.b2b?.savedPostalCode) {
138
+ sessionStore.set({
139
+ ...session,
140
+ postalCode: session.b2b.savedPostalCode,
141
+ })
142
+ return
143
+ }
144
+
145
+ const sessionWithLocation = await getPostalCode(session)
146
+ !!sessionWithLocation && sessionStore.set(sessionWithLocation)
147
+ }
148
+
112
149
  function isRefreshAfterExpired(session: Session): boolean {
113
150
  return (
114
151
  !!session.refreshAfter &&
@@ -126,12 +163,8 @@ export const validateSession = async (session: Session) => {
126
163
  // If the refreshToken is enabled and the refreshAfter is expired, refresh the token.
127
164
  // On success, continue to the validation flow with the refreshed session.
128
165
  // On failure (logoutAndClearSession already triggered), bail out.
129
- // Skipped in local environments where the refresh token infrastructure is unavailable.
130
- if (
131
- !isLocalHostBrowser() &&
132
- storeConfig.experimental?.refreshToken &&
133
- isRefreshAfterExpired(session)
134
- ) {
166
+ // Skipped on localhost and WebOps previews, where the refresh token infrastructure is unavailable.
167
+ if (canRefreshToken() && isRefreshAfterExpired(session)) {
135
168
  const refreshed = await handleRefreshToken(session)
136
169
  if (!refreshed) {
137
170
  return null
@@ -139,23 +172,7 @@ export const validateSession = async (session: Session) => {
139
172
  session = refreshed
140
173
  }
141
174
 
142
- // If deliveryPromise is enabled and there is no postalCode in the session
143
- if (
144
- storeConfig.deliveryPromise?.enabled &&
145
- (!session.postalCode ||
146
- session.postalCode === storeConfig.session.postalCode)
147
- ) {
148
- // Case B2B: If a B2B shopper is logged in and a saved address is available, the postalCode field is automatically updated with the postal code from that address by the B2B session apps (shopper-session and profile-session).
149
- if (session.b2b && session.b2b?.savedPostalCode) {
150
- sessionStore.set({
151
- ...session,
152
- postalCode: session.b2b.savedPostalCode,
153
- })
154
- } else {
155
- const sessionWithLocation = await getPostalCode(session)
156
- !!sessionWithLocation && sessionStore.set(sessionWithLocation)
157
- }
158
- }
175
+ await syncDeliveryPromisePostalCode(session)
159
176
 
160
177
  try {
161
178
  // Prevents to call ValidateSession without session (required) and get Error
@@ -173,12 +190,7 @@ export const validateSession = async (session: Session) => {
173
190
 
174
191
  return data.validateSession
175
192
  } catch (error) {
176
- const shouldRefreshToken =
177
- !isLocalHostBrowser() &&
178
- error?.status === 401 &&
179
- storeConfig.experimental?.refreshToken
180
-
181
- if (shouldRefreshToken) {
193
+ if (error?.status === 401 && canRefreshToken()) {
182
194
  const refreshed = await handleRefreshToken(session)
183
195
  if (refreshed) {
184
196
  sessionStore.set(refreshed)
@@ -1,9 +1,5 @@
1
1
  /**
2
2
  * Hostnames that represent a local development environment.
3
- *
4
- * These are the only hosts where the FastStore app skips the refresh-token
5
- * flow so developers can drive the app with a manually injected
6
- * `VtexIdclientAutCookie_<account>` cookie regardless of feature flags.
7
3
  */
8
4
  const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1'])
9
5
 
@@ -11,9 +7,8 @@ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1'])
11
7
  * Returns `true` when the given hostname corresponds to a local development
12
8
  * environment (`localhost` / `127.0.0.1`).
13
9
  *
14
- * Refresh-token and other production-only flows MUST be bypassed on these
15
- * hostnames so that developers can test logged-in scenarios with manually
16
- * injected cookies, independent of any experimental flag.
10
+ * To decide whether to skip the refresh-token flow, use
11
+ * `shouldBypassRefreshToken`, which also covers WebOps preview hosts.
17
12
  *
18
13
  * The input MUST be a bare hostname (no port). Use {@link getRequestHostname}
19
14
  * on server-side request headers before calling this helper.
@@ -22,12 +17,3 @@ export function isLocalHost(hostname: string | null | undefined): boolean {
22
17
  if (!hostname) return false
23
18
  return LOCAL_HOSTNAMES.has(hostname.toLowerCase())
24
19
  }
25
-
26
- /**
27
- * Browser-side wrapper that reads `window.location.hostname`. Returns `false`
28
- * when `window` is not available (SSR/Node).
29
- */
30
- export function isLocalHostBrowser(): boolean {
31
- if (typeof window === 'undefined') return false
32
- return isLocalHost(window.location.hostname)
33
- }
@@ -0,0 +1,63 @@
1
+ import { getRequestHostname } from './getRequestHostname'
2
+ import { isLocalHost } from './isLocalHost'
3
+ import { isHostAllowed, singleForwardedHost } from './trustedForwardedHost'
4
+
5
+ /**
6
+ * WebOps preview deployments are served under `<preview>.preview.vtex.app`.
7
+ *
8
+ * Only this subdomain is matched: `{account}.vtex.app` is a production default
9
+ * domain and MUST keep the refresh-token flow.
10
+ */
11
+ const PREVIEW_HOST_SUFFIXES = ['.preview.vtex.app']
12
+
13
+ /**
14
+ * Returns `true` when the refresh-token flow must be skipped for the given
15
+ * hostname: local development (`localhost` / `127.0.0.1`) and WebOps previews.
16
+ *
17
+ * Neither environment proxies VTEX ID, so `/api/vtexid/refreshtoken/webstore`
18
+ * always fails there and the resulting logout wipes a manually injected
19
+ * `VtexIdclientAutCookie_<account>` cookie. Skipping the flow lets developers
20
+ * test logged-in scenarios with that cookie alone.
21
+ *
22
+ * The input MUST be a bare hostname (no port). Use {@link getRequestHostname}
23
+ * on server-side request headers before calling this helper. A trailing dot
24
+ * (fully qualified name) is ignored.
25
+ */
26
+ export function shouldBypassRefreshToken(
27
+ hostname: string | null | undefined
28
+ ): boolean {
29
+ if (!hostname) return false
30
+ const normalizedHostname = hostname.toLowerCase().replace(/\.$/, '')
31
+ return (
32
+ isLocalHost(normalizedHostname) ||
33
+ isHostAllowed(normalizedHostname, PREVIEW_HOST_SUFFIXES)
34
+ )
35
+ }
36
+
37
+ /**
38
+ * Server-side check from the request headers. Behind the WebOps preview
39
+ * ingress the browser host arrives in `x-forwarded-host` rather than `Host`
40
+ * (see `normalizeSetCookieDomain` in `pages/api/graphql.ts`), so both are
41
+ * checked. Both are client-controlled: a forged value only skips the forced
42
+ * refresh, which grants nothing since the JWT is still validated upstream.
43
+ */
44
+ export function shouldBypassRefreshTokenForRequest(headers: {
45
+ host?: string
46
+ 'x-forwarded-host'?: string | string[]
47
+ }): boolean {
48
+ return (
49
+ shouldBypassRefreshToken(getRequestHostname(headers.host)) ||
50
+ shouldBypassRefreshToken(
51
+ getRequestHostname(singleForwardedHost(headers['x-forwarded-host']))
52
+ )
53
+ )
54
+ }
55
+
56
+ /**
57
+ * Browser-side wrapper that reads `window.location.hostname`. Returns `false`
58
+ * when `window` is not available (SSR/Node).
59
+ */
60
+ export function shouldBypassRefreshTokenBrowser(): boolean {
61
+ if (globalThis.window === undefined) return false
62
+ return shouldBypassRefreshToken(globalThis.window.location.hostname)
63
+ }
@@ -9,14 +9,23 @@ import { GraphQLError } from 'graphql'
9
9
  import type { NextApiRequest, NextApiResponse } from 'next'
10
10
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
11
11
 
12
+ import discoveryConfig from '../../../discovery.config'
12
13
  import handler from '../../../src/pages/api/graphql'
13
14
  import { execute } from '../../../src/server'
15
+ import { shouldForceRefreshTokenForValidateSession } from '../../../src/utils/validateSessionRefreshToken'
14
16
 
15
17
  vi.mock('../../../src/server', () => ({
16
18
  execute: vi.fn(),
17
19
  }))
18
20
 
21
+ vi.mock('../../../src/utils/validateSessionRefreshToken', () => ({
22
+ shouldForceRefreshTokenForValidateSession: vi.fn(),
23
+ }))
24
+
19
25
  const mockedExecute = vi.mocked(execute)
26
+ const mockedShouldForceRefreshToken = vi.mocked(
27
+ shouldForceRefreshTokenForValidateSession
28
+ )
20
29
 
21
30
  const createResponse = ({ headersSent = false } = {}) => {
22
31
  const res = {
@@ -229,3 +238,169 @@ describe('/api/graphql request handling', () => {
229
238
  expect(res.end).toHaveBeenCalled()
230
239
  })
231
240
  })
241
+
242
+ describe('/api/graphql ValidateSession refresh-token bypass', () => {
243
+ const originalRefreshToken = discoveryConfig.experimental.refreshToken
244
+
245
+ const createValidateSessionRequest = (
246
+ host: string,
247
+ extraHeaders: Record<string, string> = {}
248
+ ) =>
249
+ ({
250
+ method: 'GET',
251
+ url: '/api/graphql',
252
+ headers: { host, ...extraHeaders },
253
+ query: {
254
+ operationName: 'ValidateSession',
255
+ operationHash: 'hash',
256
+ variables: JSON.stringify({ session: {} }),
257
+ },
258
+ }) as unknown as NextApiRequest
259
+
260
+ beforeEach(() => {
261
+ vi.clearAllMocks()
262
+ discoveryConfig.experimental.refreshToken = true
263
+ mockedShouldForceRefreshToken.mockReturnValue(true)
264
+ mockedExecute.mockResolvedValue({
265
+ data: { validateSession: null },
266
+ errors: undefined,
267
+ extensions: { cookies: new Map(), cacheControl: undefined },
268
+ } as never)
269
+ })
270
+
271
+ afterEach(() => {
272
+ discoveryConfig.experimental.refreshToken = originalRefreshToken
273
+ })
274
+
275
+ it.each([
276
+ 'localhost:3000',
277
+ '127.0.0.1:3000',
278
+ 'brandless-abc123.preview.vtex.app',
279
+ ])('does not force a 401 on %s', async (host) => {
280
+ const res = createResponse()
281
+ await handler(createValidateSessionRequest(host), res)
282
+
283
+ expect(mockedShouldForceRefreshToken).not.toHaveBeenCalled()
284
+ expect(res.status).not.toHaveBeenCalledWith(401)
285
+ expect(mockedExecute).toHaveBeenCalled()
286
+ })
287
+
288
+ it('does not force a 401 on a preview behind the ingress', async () => {
289
+ const res = createResponse()
290
+ await handler(
291
+ createValidateSessionRequest('faststore-internal:3000', {
292
+ 'x-forwarded-host': 'brandless-abc123.preview.vtex.app',
293
+ }),
294
+ res
295
+ )
296
+
297
+ expect(mockedShouldForceRefreshToken).not.toHaveBeenCalled()
298
+ expect(res.status).not.toHaveBeenCalledWith(401)
299
+ expect(mockedExecute).toHaveBeenCalled()
300
+ })
301
+
302
+ it.each(['brandless.vtex.app', 'brandless.fast.store'])(
303
+ 'forces a 401 on the production host %s',
304
+ async (host) => {
305
+ const res = createResponse()
306
+ await handler(createValidateSessionRequest(host), res)
307
+
308
+ expect(res.status).toHaveBeenCalledWith(401)
309
+ expect(mockedExecute).not.toHaveBeenCalled()
310
+ }
311
+ )
312
+ })
313
+
314
+ describe('/api/graphql success cache-control', () => {
315
+ const originalGraphqlCacheControl =
316
+ discoveryConfig.experimental.graphqlCacheControl
317
+
318
+ const mockExecuteWithCacheControl = (cacheControl?: unknown) => {
319
+ mockedExecute.mockResolvedValue({
320
+ data: {},
321
+ errors: undefined,
322
+ extensions: { cookies: new Map(), cacheControl },
323
+ } as never)
324
+ }
325
+
326
+ beforeEach(() => {
327
+ vi.clearAllMocks()
328
+ })
329
+
330
+ afterEach(() => {
331
+ discoveryConfig.experimental.graphqlCacheControl =
332
+ originalGraphqlCacheControl
333
+ })
334
+
335
+ it('uses the cache-control collected while executing the operation', async () => {
336
+ mockExecuteWithCacheControl({ scope: 'public', sMaxAge: 120 })
337
+
338
+ const res = createResponse()
339
+ await handler(createRequest(), res)
340
+
341
+ expect(res.setHeader).toHaveBeenCalledWith(
342
+ 'cache-control',
343
+ 'public, s-maxage=120, stale-while-revalidate=0'
344
+ )
345
+ })
346
+
347
+ it('makes collected cache-control private when there is an auth cookie', async () => {
348
+ mockExecuteWithCacheControl({ scope: 'public', sMaxAge: 120 })
349
+
350
+ const req = createRequest()
351
+ req.headers.cookie = 'VtexIdclientAutCookie_store=jwt'
352
+ const res = createResponse()
353
+ await handler(req, res)
354
+
355
+ expect(res.setHeader).toHaveBeenCalledWith(
356
+ 'cache-control',
357
+ 'private, s-maxage=120, stale-while-revalidate=0'
358
+ )
359
+ })
360
+
361
+ it('falls back to the default max-age for GET queries without collected cache-control', async () => {
362
+ discoveryConfig.experimental.graphqlCacheControl = {
363
+ maxAge: 0,
364
+ staleWhileRevalidate: 0,
365
+ }
366
+ mockExecuteWithCacheControl()
367
+
368
+ const res = createResponse()
369
+ await handler(createRequest(), res)
370
+
371
+ expect(res.setHeader).toHaveBeenCalledWith(
372
+ 'cache-control',
373
+ 'public, s-maxage=300, stale-while-revalidate=3600'
374
+ )
375
+ })
376
+
377
+ it('uses the configured max-age and stale-while-revalidate for GET queries', async () => {
378
+ discoveryConfig.experimental.graphqlCacheControl = {
379
+ maxAge: 60,
380
+ staleWhileRevalidate: 30,
381
+ }
382
+ mockExecuteWithCacheControl()
383
+
384
+ const req = createRequest()
385
+ req.headers.cookie = 'VtexIdclientAutCookie_store=jwt'
386
+ const res = createResponse()
387
+ await handler(req, res)
388
+
389
+ expect(res.setHeader).toHaveBeenCalledWith(
390
+ 'cache-control',
391
+ 'private, s-maxage=60, stale-while-revalidate=30'
392
+ )
393
+ })
394
+
395
+ it('does not cache operations that are not GET queries', async () => {
396
+ mockExecuteWithCacheControl()
397
+
398
+ const res = createResponse()
399
+ await handler(createRequest('SubscribeToNewsletter'), res)
400
+
401
+ expect(res.setHeader).toHaveBeenCalledWith(
402
+ 'cache-control',
403
+ 'no-cache, no-store'
404
+ )
405
+ })
406
+ })