diff --git a/packages/sdks-tests/src/e2e-tests/symbols.spec.ts b/packages/sdks-tests/src/e2e-tests/symbols.spec.ts index 3affc18bbc2..837e51116d5 100644 --- a/packages/sdks-tests/src/e2e-tests/symbols.spec.ts +++ b/packages/sdks-tests/src/e2e-tests/symbols.spec.ts @@ -1,7 +1,11 @@ import type { Page } from '@playwright/test'; import { expect } from '@playwright/test'; import { DEFAULT_TEXT_SYMBOL, FRENCH_TEXT_SYMBOL } from '../specs/symbol-with-locale.js'; -import { FIRST_SYMBOL_CONTENT, SECOND_SYMBOL_CONTENT } from '../specs/symbols.js'; +import { + FIRST_SYMBOL_CONTENT, + SECOND_SYMBOL_CONTENT, + GLOBAL_SYMBOL_OWNER_ID, +} from '../specs/symbols.js'; import { excludeGen2, checkIsGen1React, @@ -245,4 +249,79 @@ test.describe('Symbols', () => { const symbols = page.locator(selector); await expect(symbols).toHaveCount(2); }); + + test.describe('global symbols', () => { + const symbolUrlMatch = /https:\/\/cdn\.builder\.io\/api\/v3\/content\/symbol\.*/; + + /** + * When a symbol block has `global: true` and `ownerId` set to a different space, + * the SDK must use `ownerId` as the apiKey in the CDN fetch — not the current space's apiKey. + * + * Uses `/symbols-with-global` which has block[1] marked as global with GLOBAL_SYMBOL_OWNER_ID. + */ + test('uses ownerId as apiKey when fetching a global symbol', async ({ + page, + packageName, + sdk, + }) => { + test.skip(checkIsGen1React(sdk)); + test.fail(SSR_FETCHING_PACKAGES.includes(packageName)); + + const capturedApiKeys: string[] = []; + + await page.route(symbolUrlMatch, route => { + const url = new URL(route.request().url()); + capturedApiKeys.push(url.searchParams.get('apiKey') || ''); + + return route.fulfill({ + status: 200, + json: { results: [FIRST_SYMBOL_CONTENT] }, + }); + }); + + await page.goto('/symbols-with-global'); + + // Wait for symbols to render (ensures the CDN fetch has completed) + await expect(page.getByText('default description').locator('visible=true')).toBeVisible(); + + // At least one fetch must have used the global symbol owner's apiKey + expect(capturedApiKeys).toContain(GLOBAL_SYMBOL_OWNER_ID); + }); + + /** + * When a symbol block does not have the `global` attribute, the SDK must always use the current + * space's apiKey — even if `ownerId` is present on the symbol data. + * + * Uses `/symbols-without-content` which has no global symbols (no `global: true`). + * All fetches must use the same apiKey (the current space key). + */ + test('non-global symbol does not use ownerId as apiKey', async ({ page, packageName, sdk }) => { + test.skip(checkIsGen1React(sdk)); + test.fail(SSR_FETCHING_PACKAGES.includes(packageName)); + + const capturedApiKeys: string[] = []; + + await page.route(symbolUrlMatch, route => { + const url = new URL(route.request().url()); + capturedApiKeys.push(url.searchParams.get('apiKey') || ''); + + return route.fulfill({ + status: 200, + json: { results: [FIRST_SYMBOL_CONTENT] }, + }); + }); + + await page.goto('/symbols-without-content'); + + // Wait for symbols to render (ensures the CDN fetch has completed) + await expect(page.getByText('default description').locator('visible=true')).toBeVisible(); + + await expect(capturedApiKeys.length).toBeGreaterThanOrEqual(1); + + // All fetches must use the same apiKey — no cross-space key should appear + const uniqueKeys = Array.from(new Set(capturedApiKeys)); + expect(uniqueKeys.length).toBe(1); + expect(uniqueKeys[0]).not.toBe(GLOBAL_SYMBOL_OWNER_ID); + }); + }); }); diff --git a/packages/sdks-tests/src/specs/index.ts b/packages/sdks-tests/src/specs/index.ts index 49e3fa443a0..5ae41bee3a5 100644 --- a/packages/sdks-tests/src/specs/index.ts +++ b/packages/sdks-tests/src/specs/index.ts @@ -46,7 +46,7 @@ import { CONTENT as symbolAbTest } from './symbol-ab-test.js'; import { CONTENT as symbolBindings } from './symbol-bindings.js'; import { CONTENT as symbolWithInputBinding } from './symbol-with-input-binding.js'; import { CONTENT as symbolWithLocale } from './symbol-with-locale.js'; -import { CONTENT_WITHOUT_SYMBOLS, CONTENT as symbols } from './symbols.js'; +import { CONTENT_WITHOUT_SYMBOLS, CONTENT_WITH_GLOBAL_SYMBOL, CONTENT as symbols } from './symbols.js'; import { TABS } from './tabs.js'; import { CONTENT as textBlock } from './text-block.js'; import { CONTENT as textEval } from './text-eval.js'; @@ -137,6 +137,7 @@ export const PAGES: Record = { '/symbols': { content: symbols }, '/js-code': { content: JS_CODE_CONTENT }, '/symbols-without-content': { content: CONTENT_WITHOUT_SYMBOLS }, + '/symbols-with-global': { content: CONTENT_WITH_GLOBAL_SYMBOL }, '/symbol-bindings': { content: symbolBindings }, '/symbol-with-locale': { content: symbolWithLocale }, '/link-url': { content: linkUrl }, diff --git a/packages/sdks-tests/src/specs/symbols.ts b/packages/sdks-tests/src/specs/symbols.ts index 05e1661804a..cccf3edd8f4 100644 --- a/packages/sdks-tests/src/specs/symbols.ts +++ b/packages/sdks-tests/src/specs/symbols.ts @@ -738,3 +738,21 @@ const splitUpContent = () => { export const { CONTENT_WITHOUT_SYMBOLS, FIRST_SYMBOL_CONTENT, SECOND_SYMBOL_CONTENT } = splitUpContent(); + +/** + * The API key of the space that owns the global symbol — different from the current space. + */ +export const GLOBAL_SYMBOL_OWNER_ID = 'global-owner-space-api-key-abc123'; + +/** + * A page content where: + * - block[1] is a GLOBAL symbol (global: true, ownerId = GLOBAL_SYMBOL_OWNER_ID) + * - block[2] is a LOCAL symbol (no global/ownerId — uses current space apiKey by default) + */ +export const CONTENT_WITH_GLOBAL_SYMBOL = (() => { + const clone = JSON.parse(JSON.stringify(CONTENT_WITHOUT_SYMBOLS)); + // Mark the first symbol as global, owned by a different space. + clone.data.blocks[1].component.options.symbol.global = true; + clone.data.blocks[1].component.options.symbol.ownerId = GLOBAL_SYMBOL_OWNER_ID; + return clone; +})(); diff --git a/packages/sdks/src/blocks/symbol/symbol.helpers.ts b/packages/sdks/src/blocks/symbol/symbol.helpers.ts index 762ff853a32..d42406a87ef 100644 --- a/packages/sdks/src/blocks/symbol/symbol.helpers.ts +++ b/packages/sdks/src/blocks/symbol/symbol.helpers.ts @@ -10,6 +10,8 @@ export interface SymbolInfo { content?: BuilderContent; inline?: boolean; dynamic?: boolean; + ownerId?: string; + global?: boolean; } export const fetchSymbolContent = async ({ @@ -35,7 +37,7 @@ export const fetchSymbolContent = async ({ ) { return fetchOneEntry({ model: symbol.model, - apiKey: builderContextValue.apiKey, + apiKey: (symbol.global && symbol.ownerId) ? symbol.ownerId : builderContextValue.apiKey, apiVersion: builderContextValue.apiVersion, ...(symbol?.entry && { query: { diff --git a/packages/sdks/src/blocks/symbol/symbol.lite.tsx b/packages/sdks/src/blocks/symbol/symbol.lite.tsx index 10ed644a73b..7eced0b524d 100644 --- a/packages/sdks/src/blocks/symbol/symbol.lite.tsx +++ b/packages/sdks/src/blocks/symbol/symbol.lite.tsx @@ -132,7 +132,7 @@ export default function Symbol(props: SymbolProps) { nonce={props.builderContext.value.nonce} isNestedRender apiVersion={props.builderContext.value.apiVersion} - apiKey={props.builderContext.value.apiKey!} + apiKey={(props.symbol?.global && props.symbol?.ownerId) ? props.symbol.ownerId : props.builderContext.value.apiKey!} context={{ ...props.builderContext.value.context, symbolId: props.builderBlock?.id, diff --git a/packages/sdks/src/blocks/symbol/symbol.types.ts b/packages/sdks/src/blocks/symbol/symbol.types.ts index 80b0b4f5fe7..3904e35655a 100644 --- a/packages/sdks/src/blocks/symbol/symbol.types.ts +++ b/packages/sdks/src/blocks/symbol/symbol.types.ts @@ -12,6 +12,8 @@ export interface SymbolInfo { content?: BuilderContent; inline?: boolean; dynamic?: boolean; + ownerId?: string; + global?: boolean; } export interface SymbolProps