Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.69.0] - 2026-08-11

### Fixed
- The `CheckoutOrderFormOwnership` cookie is now preserved across every request
to the order form, so Checkout stops masking `clientProfileData` and
`shippingData` after a shopper has gained ownership. Three leaks were closed:
an empty ownership cookie returned by Checkout (which it emits whenever a
cart is created) is no longer forwarded over a valid one; a newly issued
ownership is written back into `vtex.ownerId` so the remaining Checkout calls
of the same request use it instead of the value `@withOwnerId` snapshotted
from the incoming request; and `updateOrderFormShipping` now forwards the
ownership cookie that the `shippingData` attachment rotates, which was
previously discarded. The ownership cookie is also forwarded by the
`orderForm` query regardless of `enableOrderFormOptimization`, since no other
app sets it.
- Every Checkout route now reads the ownership cookie back from its response,
through a client middleware rather than per-method plumbing. Previously only
the three methods built on the `*Raw` verbs could see their response headers,
which left `patch` routes such as `addItem` and `updateItems` unable to
capture a rotation at all, since `HttpClient` exposes no `patchRaw`. The
middleware also warns when Checkout hands back an empty ownership while the
request already holds one.

## [0.68.0] - 2026-06-12

### Fixed
Expand Down
6 changes: 2 additions & 4 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "checkout-graphql",
"vendor": "vtex",
"version": "0.68.0",
"version": "0.69.0",
"title": "Checkout GraphQL",
"description": "Checkout GraphQL API",
"builders": {
Expand All @@ -15,9 +15,7 @@
},
"credentialType": "absolute",
"mustUpdateAt": "2019-11-05",
"registries": [
"smartcheckout"
],
"registries": ["smartcheckout"],
"policies": [
{
"name": "vtex.messages:translate-messages"
Expand Down
11 changes: 6 additions & 5 deletions node/__fixtures__/shipping.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { AddressType, DELIVERY, PICKUP_IN_POINT } from '../constants'
import { EMPTY_ORDER_FORM } from './orderForm'
import { Clients } from '../clients'

const SLA = {
deliveryIds: [],
Expand Down Expand Up @@ -313,8 +312,10 @@ export const ORDER_FORM_WITH_EMPTY_SHIPPING_DATA = {
...EMPTY_ORDER_FORM,
}

export const clients = ({
checkout: {
updateOrderFormShipping: jest.fn(),
export const shippingContext = ({
clients: {
checkout: {
updateOrderFormShipping: jest.fn(),
},
},
} as unknown) as Clients
} as unknown) as Context
83 changes: 83 additions & 0 deletions node/__tests__/orderForm-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,28 @@ describe('queries.orderForm — happy path', () => {

expect(ctx.cookies.set).not.toHaveBeenCalled()
})

it('forwards the ownership cookie even when enableOrderFormOptimization is false', async () => {
const ctx = setupQueryContext()
ctx.clients.checkout.orderFormRaw.mockResolvedValue({
data: baseOrderForm(),
headers: {
'set-cookie': [
'checkout.vtex.com=__ofid=order-1; domain=oldhost.com',
'CheckoutOrderFormOwnership=owner-1; domain=oldhost.com',
],
},
})

await queries.orderForm(null, {}, toContext(ctx))

expect(ctx.cookies.set).toHaveBeenCalledTimes(1)
expect(ctx.cookies.set).toHaveBeenCalledWith(
'CheckoutOrderFormOwnership',
'owner-1',
expect.any(Object)
)
})
})

describe('queries.orderForm — broken cookie recovery', () => {
Expand Down Expand Up @@ -432,6 +454,67 @@ describe('forwardCheckoutCookies', () => {
expect(ctx.cookies.set).not.toHaveBeenCalled()
})

/**
* Checkout issues an empty `CheckoutOrderFormOwnership` whenever a cart is
* created. Writing it through would revoke the ownership the shopper already
* holds and make Checkout mask their profile and shipping data from then on.
*/
it('never overwrites the ownership cookie with an empty value', async () => {
const ctx = buildCtx()
const headers = {
'set-cookie': [
'CheckoutOrderFormOwnership=; domain=oldhost.com',
'checkout.vtex.com=__ofid=order-1; domain=oldhost.com',
],
}

await forwardCheckoutCookies(headers, toContext(ctx))

expect(ctx.cookies.set).toHaveBeenCalledTimes(1)
expect(ctx.cookies.set).toHaveBeenCalledWith(
'checkout.vtex.com',
'__ofid=order-1',
expect.any(Object)
)
})

it('keeps the ownership already in context when checkout returns an empty one', async () => {
const ctx = makeContext({
headers: { 'x-forwarded-host': 'newhost.com' },
vtex: { ownerId: 'owner-1' },
})

await forwardCheckoutCookies(
{ 'set-cookie': ['CheckoutOrderFormOwnership=; domain=oldhost.com'] },
toContext(ctx)
)

expect(ctx.vtex.ownerId).toBe('owner-1')
})

/**
* `@withOwnerId` snapshots the cookie off the incoming request, so without
* this sync every later Checkout call of the same request would keep sending
* the stale (usually empty) ownership and get masked data back.
*/
it('syncs a newly issued ownership into vtex.ownerId for the rest of the request', async () => {
const ctx = makeContext({
headers: { 'x-forwarded-host': 'newhost.com' },
vtex: { ownerId: undefined },
})

await forwardCheckoutCookies(
{
'set-cookie': [
'CheckoutOrderFormOwnership=owner-2; domain=oldhost.com',
],
},
toContext(ctx)
)

expect(ctx.vtex.ownerId).toBe('owner-2')
})

it('respects a custom allow list', async () => {
const ctx = buildCtx()
const headers = {
Expand Down
128 changes: 128 additions & 0 deletions node/__tests__/ownership-middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { keepOwnership } from '../clients/ownership'

/**
* `keepOwnership` is the only place that captures `CheckoutOrderFormOwnership`
* for routes whose verb has no raw variant (`patch`, used by `addItem` and
* `updateItems`). It runs as a client middleware, so these tests drive it the
* way koa-compose does: call it with a middleware context and a `next` that
* populates the response.
*/

const makeIOContext = (ownerId?: string) =>
(({
ownerId,
logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn() },
} as unknown) as CustomIOContext)

const makeMiddlewareContext = (setCookies?: string[]) =>
({
config: { url: '/api/checkout/pub/orderForm/of-1/items' },
response: setCookies
? { headers: { 'set-cookie': setCookies } }
: undefined,
} as any)

const runMiddleware = async (
ioContext: CustomIOContext,
setCookies?: string[]
) => {
const middlewareContext = makeMiddlewareContext()
const next = jest.fn(async () => {
// The response only exists once the downstream middlewares have run.
middlewareContext.response = makeMiddlewareContext(setCookies).response
})

await keepOwnership(ioContext)(middlewareContext, next)

return { middlewareContext, next }
}

describe('keepOwnership', () => {
it('adopts an ownership issued by any checkout route', async () => {
const ioContext = makeIOContext()

await runMiddleware(ioContext, [
'CheckoutOrderFormOwnership=owner-1; domain=host.com; path=/',
])

expect(ioContext.ownerId).toBe('owner-1')
})

it('replaces a stale ownership with the one just rotated', async () => {
const ioContext = makeIOContext('owner-1')

await runMiddleware(ioContext, [
'CheckoutOrderFormOwnership=owner-2; domain=host.com',
])

expect(ioContext.ownerId).toBe('owner-2')
})

it('waits for the downstream middlewares before reading the response', async () => {
const ioContext = makeIOContext()

const { next } = await runMiddleware(ioContext, [
'CheckoutOrderFormOwnership=owner-1; domain=host.com',
])

expect(next).toHaveBeenCalledTimes(1)
expect(ioContext.ownerId).toBe('owner-1')
})

it('keeps the current ownership when checkout returns an empty one', async () => {
const ioContext = makeIOContext('owner-1')

await runMiddleware(ioContext, [
'CheckoutOrderFormOwnership=; domain=host.com',
])

expect(ioContext.ownerId).toBe('owner-1')
expect(ioContext.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message:
'Checkout returned an empty ownership cookie; keeping the current one',
})
)
})

it('stays quiet when checkout returns an empty ownership and there is none to lose', async () => {
const ioContext = makeIOContext()

await runMiddleware(ioContext, [
'CheckoutOrderFormOwnership=; domain=host.com',
])

expect(ioContext.ownerId).toBeUndefined()
expect(ioContext.logger.warn).not.toHaveBeenCalled()
})

it('ignores cookies other than the ownership one', async () => {
const ioContext = makeIOContext('owner-1')

await runMiddleware(ioContext, [
'checkout.vtex.com=__ofid=order-1; domain=host.com',
'.ASPXAUTH=hash; domain=host.com',
])

expect(ioContext.ownerId).toBe('owner-1')
expect(ioContext.logger.warn).not.toHaveBeenCalled()
})

it('is a no-op when the response carries no cookies', async () => {
const ioContext = makeIOContext('owner-1')

await runMiddleware(ioContext, [])

expect(ioContext.ownerId).toBe('owner-1')
expect(ioContext.logger.warn).not.toHaveBeenCalled()
})

it('is a no-op when the request produced no response', async () => {
const ioContext = makeIOContext('owner-1')

await runMiddleware(ioContext)

expect(ioContext.ownerId).toBe('owner-1')
expect(ioContext.logger.warn).not.toHaveBeenCalled()
})
})
17 changes: 9 additions & 8 deletions node/__tests__/shipping-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
selectAddress,
selectShippingOption,
} from '../utils/shipping'
import { makeClientsMock, toClients } from '../__fixtures__/clients'
import { makeClientsMock } from '../__fixtures__/clients'
import { makeContext, toContext } from '../__fixtures__/context'
import {
makeAddress,
makeLogisticsInfo,
Expand Down Expand Up @@ -270,10 +271,9 @@ describe('utils/shipping — getShippingInfo totalizer auto-correction', () => {
const totalizersRef = orderForm.totalizers
const shippingTotalizerRef = orderForm.totalizers[0]

await getShippingInfo({
clients: toClients(clientsMock),
orderForm,
})
const ctx = toContext(makeContext({ clients: clientsMock }))

await getShippingInfo({ ctx, orderForm })

expect(clientsMock.checkout.updateOrderFormShipping).toHaveBeenCalledTimes(
1
Expand All @@ -283,7 +283,8 @@ describe('utils/shipping — getShippingInfo totalizer auto-correction', () => {
expect.objectContaining({
logisticsInfo: expect.any(Array),
selectedAddresses: [baseDeliveryAddress],
})
}),
ctx
)

// Intentional mutation contract — see suite-level comment above.
Expand All @@ -303,7 +304,7 @@ describe('utils/shipping — getShippingInfo totalizer auto-correction', () => {
const orderForm = buildOrderForm(100, 100)

await getShippingInfo({
clients: toClients(clientsMock),
ctx: toContext(makeContext({ clients: clientsMock })),
orderForm,
})

Expand All @@ -318,7 +319,7 @@ describe('utils/shipping — getShippingInfo totalizer auto-correction', () => {
orderForm.totalizers = []

await getShippingInfo({
clients: toClients(clientsMock),
ctx: toContext(makeContext({ clients: clientsMock })),
orderForm,
})

Expand Down
Loading
Loading