Skip to content

Commit 8fb6ded

Browse files
EvilGenius13claude
andcommitted
fix(theme): preserve preview_theme_id on password-protected theme dev sessions
Session priming ran the preview HEAD before POST /password, so the digest-bearing _shopify_essential returned by the password step lacked preview_theme_id and won the merge, causing SFR to serve the live theme. Swap the order: POST /password first (cold), then the preview HEAD last forwarding the digest cookie so SFR stamps preview_theme_id into the same _shopify_essential. Still 2 fetches for password stores, 1 otherwise. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent eb559a4 commit 8fb6ded

3 files changed

Lines changed: 168 additions & 88 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/theme': patch
3+
---
4+
5+
Fix "Theme ID mismatch" on password-protected stores during `theme dev` by preserving preview_theme_id in the session cookie

packages/theme/src/cli/utilities/theme-environment/storefront-session.test.ts

Lines changed: 118 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,9 @@ describe('Storefront API', () => {
5757
})
5858

5959
test('retrieves _shopify_essential and storefront_digest cookies when a password is provided', async () => {
60-
// Given
60+
// Given: POST /password runs first (cold, returns the digest), then the
61+
// preview HEAD runs last and owns the final _shopify_essential.
6162
vi.mocked(shopifyFetch)
62-
.mockResolvedValueOnce(
63-
response({
64-
status: 200,
65-
headers: {'set-cookie': '_shopify_essential=:AABBCCDDEEFFGGHH==123:; path=/; HttpOnly'},
66-
}),
67-
)
6863
.mockResolvedValueOnce(
6964
response({
7065
status: 302,
@@ -74,6 +69,12 @@ describe('Storefront API', () => {
7469
},
7570
}),
7671
)
72+
.mockResolvedValueOnce(
73+
response({
74+
status: 200,
75+
headers: {'set-cookie': '_shopify_essential=:AABBCCDDEEFFGGHH==123:; path=/; HttpOnly'},
76+
}),
77+
)
7778

7879
// When
7980
const cookies = await getStorefrontSessionCookies(
@@ -89,12 +90,6 @@ describe('Storefront API', () => {
8990

9091
test('passes caller-provided headers when retrieving session cookies', async () => {
9192
vi.mocked(shopifyFetch)
92-
.mockResolvedValueOnce(
93-
response({
94-
status: 200,
95-
headers: {'set-cookie': '_shopify_essential=:AABBCCDDEEFFGGHH==123:; path=/; HttpOnly'},
96-
}),
97-
)
9893
.mockResolvedValueOnce(
9994
response({
10095
status: 302,
@@ -104,6 +99,12 @@ describe('Storefront API', () => {
10499
},
105100
}),
106101
)
102+
.mockResolvedValueOnce(
103+
response({
104+
status: 200,
105+
headers: {'set-cookie': '_shopify_essential=:AABBCCDDEEFFGGHH==123:; path=/; HttpOnly'},
106+
}),
107+
)
107108

108109
await getStorefrontSessionCookies(
109110
'https://example-store.myshopify.com',
@@ -117,9 +118,10 @@ describe('Storefront API', () => {
117118
},
118119
)
119120

121+
// Password POST runs first, then the preview HEAD.
120122
expect(shopifyFetch).toHaveBeenNthCalledWith(
121123
1,
122-
'https://example-store.myshopify.com?preview_theme_id=123456&_fd=0&pb=0',
124+
'https://example-store.myshopify.com/password',
123125
expect.objectContaining({
124126
headers: expect.objectContaining({
125127
Signature: 'signature-value',
@@ -130,7 +132,7 @@ describe('Storefront API', () => {
130132
)
131133
expect(shopifyFetch).toHaveBeenNthCalledWith(
132134
2,
133-
'https://example-store.myshopify.com/password',
135+
'https://example-store.myshopify.com?preview_theme_id=123456&_fd=0&pb=0',
134136
expect.objectContaining({
135137
headers: expect.objectContaining({
136138
Signature: 'signature-value',
@@ -141,6 +143,59 @@ describe('Storefront API', () => {
141143
)
142144
})
143145

146+
test('runs POST /password first, then forwards the digest essential to the trailing preview HEAD', async () => {
147+
const digestEssential = ':DIGEST==123:'
148+
const finalEssential = ':DIGEST+PREVIEW==456:'
149+
150+
// Given: password POST returns the digest-bearing essential; the preview
151+
// HEAD then returns the final essential carrying both fields.
152+
vi.mocked(shopifyFetch)
153+
.mockResolvedValueOnce(
154+
response({
155+
status: 302,
156+
headers: {
157+
'set-cookie': `_shopify_essential=${digestEssential}; path=/; HttpOnly`,
158+
location: 'https://example-store.myshopify.com/',
159+
},
160+
}),
161+
)
162+
.mockResolvedValueOnce(
163+
response({
164+
status: 200,
165+
headers: {'set-cookie': `_shopify_essential=${finalEssential}; path=/; HttpOnly`},
166+
}),
167+
)
168+
169+
// When
170+
const cookies = await getStorefrontSessionCookies(
171+
'https://example-store.myshopify.com',
172+
'example-store.myshopify.com',
173+
'123456',
174+
'password',
175+
)
176+
177+
// Then: exactly two fetches, in order — POST /password, then the preview HEAD.
178+
expect(shopifyFetch).toHaveBeenCalledTimes(2)
179+
expect(shopifyFetch).toHaveBeenNthCalledWith(
180+
1,
181+
'https://example-store.myshopify.com/password',
182+
expect.objectContaining({method: 'POST'}),
183+
)
184+
// The preview HEAD carries the preview_theme_id AND forwards the digest essential.
185+
expect(shopifyFetch).toHaveBeenNthCalledWith(
186+
2,
187+
'https://example-store.myshopify.com?preview_theme_id=123456&_fd=0&pb=0',
188+
expect.objectContaining({
189+
method: 'HEAD',
190+
headers: expect.objectContaining({
191+
Cookie: `_shopify_essential=${digestEssential}`,
192+
}),
193+
}),
194+
)
195+
// The final cookie is the trailing preview HEAD's essential (both fields).
196+
expect(cookies).toEqual({_shopify_essential: finalEssential})
197+
})
198+
144199
test(`throws an ShopifyEssentialError when _shopify_essential can't be defined`, async () => {
145200
// Given
146201
vi.mocked(shopifyFetch)
@@ -185,21 +240,13 @@ describe('Storefront API', () => {
185240
})
186241

187242
test('throws an error when the password is wrong', async () => {
188-
// Given
189-
vi.mocked(shopifyFetch)
190-
.mockResolvedValueOnce(
191-
response({
192-
status: 200,
193-
headers: {'set-cookie': '_shopify_essential=:AABBCCDDEEFFGGHH==123:; path=/; HttpOnly'},
194-
text: () => Promise.resolve(''),
195-
}),
196-
)
197-
.mockResolvedValueOnce(
198-
response({
199-
status: 401,
200-
text: () => Promise.resolve(''),
201-
}),
202-
)
243+
// Given: the password POST runs first and rejects the credentials.
244+
vi.mocked(shopifyFetch).mockResolvedValueOnce(
245+
response({
246+
status: 401,
247+
text: () => Promise.resolve(''),
248+
}),
249+
)
203250

204251
// When
205252
const cookies = getStorefrontSessionCookies(
@@ -255,25 +302,27 @@ describe('Storefront API', () => {
255302
})
256303

257304
test('handles storefront_digest migration to _shopify_essential cookie', async () => {
258-
const originalEssential = ':AABBCCDDEEFFGGHH==123:'
259-
const authenticatedEssential = ':NEWESSENTIAL==456:'
305+
const digestEssential = ':AABBCCDDEEFFGGHH==123:'
306+
const finalEssential = ':NEWESSENTIAL==456:'
260307

308+
// Given: password POST mints the digest-bearing essential first; the
309+
// trailing preview HEAD returns the final essential carrying both fields.
261310
vi.mocked(shopifyFetch)
262-
.mockResolvedValueOnce(
263-
response({
264-
status: 200,
265-
headers: {'set-cookie': `_shopify_essential=${originalEssential}; path=/; HttpOnly`},
266-
}),
267-
)
268311
.mockResolvedValueOnce(
269312
response({
270313
status: 302,
271314
headers: {
272-
'set-cookie': `_shopify_essential=${authenticatedEssential}; path=/; HttpOnly`,
315+
'set-cookie': `_shopify_essential=${digestEssential}; path=/; HttpOnly`,
273316
location: 'https://example-store.myshopify.com/',
274317
},
275318
}),
276319
)
320+
.mockResolvedValueOnce(
321+
response({
322+
status: 200,
323+
headers: {'set-cookie': `_shopify_essential=${finalEssential}; path=/; HttpOnly`},
324+
}),
325+
)
277326

278327
// When
279328
const cookies = await getStorefrontSessionCookies(
@@ -283,32 +332,34 @@ describe('Storefront API', () => {
283332
'password',
284333
)
285334

286-
// Then
335+
// Then: the trailing preview HEAD's essential wins.
287336
expect(cookies).toEqual({
288-
_shopify_essential: authenticatedEssential,
337+
_shopify_essential: finalEssential,
289338
})
290339
})
291340

292341
test('handles theme kit access with _shopify_essential cookies', async () => {
293-
const originalEssential = ':AABBCCDDEEFFGGHH==123:'
294-
const authenticatedEssential = ':NEWESSENTIAL==456:'
342+
const digestEssential = ':AABBCCDDEEFFGGHH==123:'
343+
const finalEssential = ':NEWESSENTIAL==456:'
295344

345+
// Given: password POST first, preview HEAD last (same order as the
346+
// standard flow) even when routed through the theme kit access domain.
296347
vi.mocked(shopifyFetch)
297-
.mockResolvedValueOnce(
298-
response({
299-
status: 200,
300-
headers: {'set-cookie': `_shopify_essential=${originalEssential}; path=/; HttpOnly`},
301-
}),
302-
)
303348
.mockResolvedValueOnce(
304349
response({
305350
status: 302,
306351
headers: {
307-
'set-cookie': `_shopify_essential=${authenticatedEssential}; path=/; HttpOnly`,
352+
'set-cookie': `_shopify_essential=${digestEssential}; path=/; HttpOnly`,
308353
location: 'https://example-store.myshopify.com/',
309354
},
310355
}),
311356
)
357+
.mockResolvedValueOnce(
358+
response({
359+
status: 200,
360+
headers: {'set-cookie': `_shopify_essential=${finalEssential}; path=/; HttpOnly`},
361+
}),
362+
)
312363

313364
// When
314365
const cookies = await getStorefrontSessionCookies(
@@ -320,19 +371,15 @@ describe('Storefront API', () => {
320371

321372
// Then
322373
expect(cookies).toEqual({
323-
_shopify_essential: authenticatedEssential,
374+
_shopify_essential: finalEssential,
324375
})
325376
})
326377

327378
test('handles case when storefront_digest is present (non-migrated case)', async () => {
328-
// Given: storefront_digest is still being used
379+
// Given: storefront_digest is still a standalone cookie. Password POST
380+
// runs first and returns only the digest; the trailing preview HEAD
381+
// mints the _shopify_essential.
329382
vi.mocked(shopifyFetch)
330-
.mockResolvedValueOnce(
331-
response({
332-
status: 200,
333-
headers: {'set-cookie': '_shopify_essential=:AABBCCDDEEFFGGHH==123:; path=/; HttpOnly'},
334-
}),
335-
)
336383
.mockResolvedValueOnce(
337384
response({
338385
status: 302,
@@ -342,6 +389,12 @@ describe('Storefront API', () => {
342389
},
343390
}),
344391
)
392+
.mockResolvedValueOnce(
393+
response({
394+
status: 200,
395+
headers: {'set-cookie': '_shopify_essential=:AABBCCDDEEFFGGHH==123:; path=/; HttpOnly'},
396+
}),
397+
)
345398

346399
// When
347400
const cookies = await getStorefrontSessionCookies(
@@ -359,21 +412,13 @@ describe('Storefront API', () => {
359412
})
360413

361414
test('throws error when pasword page does not return a 302', async () => {
362-
// Given: password redirects correctly but _shopify_essential doesn't change (shouldn't happen)
363-
const sameEssential = ':AABBCCDDEEFFGGHH==123:'
364-
365-
vi.mocked(shopifyFetch)
366-
.mockResolvedValueOnce(
367-
response({
368-
status: 200,
369-
headers: {'set-cookie': `_shopify_essential=${sameEssential}; path=/; HttpOnly`},
370-
}),
371-
)
372-
.mockResolvedValueOnce(
373-
response({
374-
status: 200,
375-
}),
376-
)
415+
// Given: the password POST runs first and does not redirect to the
416+
// storefront (no 302), so the session cannot be created.
417+
vi.mocked(shopifyFetch).mockResolvedValueOnce(
418+
response({
419+
status: 200,
420+
}),
421+
)
377422

378423
// When
379424
const cookies = getStorefrontSessionCookies(

0 commit comments

Comments
 (0)