import { isPreviewCopySlug, isPreviewSlug, isValidPreviewId, previewSlug, resolvePreviewSegment } from '@akinon/next/utils/preview'; import { decodePzValue, encodePzValue, getPzSegmentsConfig } from '@akinon/next/utils/pz-segments'; import { signPreviewToken, verifyPreviewToken } from '@akinon/next/utils/preview-token'; import { webcrypto } from 'node:crypto'; import { TextEncoder as NodeTextEncoder } from 'node:util'; // jsdom ships neither Web Crypto nor TextEncoder; the token code needs both. if (!globalThis.crypto?.subtle) { Object.defineProperty(globalThis, 'crypto', { value: webcrypto }); } if (typeof globalThis.TextEncoder === 'undefined') { Object.defineProperty(globalThis, 'TextEncoder', { value: NodeTextEncoder }); } /** * The half of the preview contract this app owns: which named preview a * request belongs to, and the widget slug that preview's copies live under. * The theme editor builds the same slugs — if these change, previews stop * resolving and every page silently falls back to live. */ const req = (cookie?: string, draft = true) => ({ cookies: { get: (name: string) => { if (name === '__prerender_bypass') { return draft ? { value: 'bypass' } : undefined; } return name === 'pz-preview-id' && cookie !== undefined ? { value: cookie } : undefined; } } }); describe('preview id validation', () => { it('accepts the ids the editor produces', () => { expect(isValidPreviewId('staging')).toBe(true); expect(isValidPreviewId('black-friday')).toBe(true); expect(isValidPreviewId('kis-kampanyasi-2026')).toBe(true); expect(isValidPreviewId('a'.repeat(32))).toBe(true); }); it('rejects anything that could escape the slug', () => { // Ids arrive from a cookie, so this is a trust boundary. expect(isValidPreviewId('')).toBe(false); expect(isValidPreviewId('-leading')).toBe(false); expect(isValidPreviewId('Upper')).toBe(false); expect(isValidPreviewId('has space')).toBe(false); expect(isValidPreviewId('../etc')).toBe(false); expect(isValidPreviewId('a'.repeat(33))).toBe(false); expect(isValidPreviewId(undefined)).toBe(false); // `--` is the pz segment separator; the editor never produces it. expect(isValidPreviewId('a--b')).toBe(false); expect(isValidPreviewId('trailing-')).toBe(false); }); }); describe('preview slugs', () => { it('suffixes the live slug with the preview id', () => { expect(previewSlug('homepage-body-new', 'staging')).toBe( 'homepage-body-new-preview-staging' ); expect(previewSlug('homepage-body-new', 'black-friday')).toBe( 'homepage-body-new-preview-black-friday' ); }); it('recognises its own output so previews are never nested', () => { expect(isPreviewSlug('homepage-body-new-preview-staging')).toBe(true); expect(isPreviewSlug('homepage-body-new')).toBe(false); // The old single-preview suffix carries no id and is not one of ours. expect(isPreviewSlug('homepage-body-new-preview')).toBe(false); }); it('only treats a slug as a copy when it belongs to THIS preview', () => { expect(isPreviewCopySlug('header-preview-staging', 'staging')).toBe(true); // Another preview's copy is not ours to skip. expect(isPreviewCopySlug('header-preview-winter', 'staging')).toBe(false); // A live custom page whose name merely looks like a copy still gets its // overlay — the shape-only check used to skip it and render live. expect(isPreviewCopySlug('page-summer-preview-2026', 'staging')).toBe( false ); expect(isPreviewCopySlug('header', '')).toBe(false); }); }); describe('the preview id inside the pz segment', () => { const settings = { pzSegments: { segments: [{ name: 'preview' }] } }; const config = getPzSegmentsConfig(settings); const url = encodeURIComponent('https://shop.test/collections/summer--sale'); it('round-trips a url containing the separator with a preview id after it', () => { const encoded = encodePzValue( { locale: 'en', currency: 'usd', url, preview: 'staging' }, config ); expect(decodePzValue(encoded, config)).toEqual({ locale: 'en', currency: 'usd', url, preview: 'staging' }); }); it('round-trips such a url when no preview is active', () => { const encoded = encodePzValue( { locale: 'en', currency: 'usd', url, preview: '' }, config ); expect(decodePzValue(encoded, config)).toEqual({ locale: 'en', currency: 'usd', url, preview: '' }); }); it('still reads a plain segment positionally', () => { expect(decodePzValue('tr--try--%2F--kis', config)).toEqual({ locale: 'tr', currency: 'try', url: '%2F', preview: 'kis' }); }); }); describe('resolvePreviewSegment', () => { it('passes a valid cookie through to the pz segment', () => { expect(resolvePreviewSegment(req('staging'))).toBe('staging'); }); it('yields no preview for a missing or tampered cookie', () => { expect(resolvePreviewSegment(req())).toBe(''); expect(resolvePreviewSegment(req(''))).toBe(''); expect(resolvePreviewSegment(req('../../evil'))).toBe(''); expect(resolvePreviewSegment(req('Staging'))).toBe(''); }); it('ignores the id cookie unless draft mode is on', () => { // An anonymous visitor forging pz-preview-id must not get a private // cache key per request — without the draft cookie the id is inert. expect(resolvePreviewSegment(req('staging', false))).toBe(''); }); }); describe('signed preview links', () => { const secret = 'k3y'; const now = 1_800_000_000_000; // ms const inAnHour = Math.floor(now / 1000) + 3600; it('round-trips the preview id and expiry it was minted with', async () => { const token = await signPreviewToken( { previewId: 'kis-kampanyasi', expiresAt: inAnHour }, secret ); expect(token.startsWith('v1.kis-kampanyasi.')).toBe(true); expect(await verifyPreviewToken(token, secret, now)).toEqual({ ok: true, claims: { previewId: 'kis-kampanyasi', expiresAt: inAnHour } }); }); it('dies at its expiry', async () => { const token = await signPreviewToken( { previewId: 'staging', expiresAt: inAnHour }, secret ); expect(await verifyPreviewToken(token, secret, inAnHour * 1000)).toEqual({ ok: false, reason: 'expired' }); }); it('rejects tampering with the id, the expiry or the key', async () => { const token = await signPreviewToken( { previewId: 'staging', expiresAt: inAnHour }, secret ); const [v, , exp, sig] = token.split('.'); expect( (await verifyPreviewToken(`${v}.winter.${exp}.${sig}`, secret, now)).ok ).toBe(false); expect( (await verifyPreviewToken(`${v}.staging.${exp + 1}.${sig}`, secret, now)) .ok ).toBe(false); expect((await verifyPreviewToken(token, 'other-key', now)).ok).toBe(false); }); it('treats anything else as malformed — including the old raw secret', async () => { expect(await verifyPreviewToken('pz-preview-local', secret, now)).toEqual({ ok: false, reason: 'malformed' }); expect((await verifyPreviewToken('', secret, now)).ok).toBe(false); expect( (await verifyPreviewToken('v1.Bad Id.123.sig', secret, now)).ok ).toBe(false); }); it('refuses to mint for an invalid id', async () => { await expect( signPreviewToken({ previewId: '../x', expiresAt: inAnHour }, secret) ).rejects.toThrow(); }); });