diff --git a/src/embed/search.spec.ts b/src/embed/search.spec.ts index 6a953923..f855cd0f 100644 --- a/src/embed/search.spec.ts +++ b/src/embed/search.spec.ts @@ -77,6 +77,37 @@ describe('Search embed tests', () => { }); }); + test('getUpdateEmbedParamsObject sends dataSources as a real ARRAY, not the URL-encoded string', async () => { + // The iframe URL correctly carries dataSources as a JSON-encoded + // string (asserted above), but the UpdateEmbedParams postMessage + // payload is spread into app state unparsed — a string there reaches + // the $sources GraphQL variable ([GUID!]) and fails GUID coercion + // (SCAL-334713, fired from beforePrerenderVisible on prerender-show). + const dataSources = ['4dd30af7-9ed7-4847-8f28-b65b44a841d8']; + const searchEmbed = new SearchEmbed(getRootEl(), { + ...defaultViewConfig, + dataSources, + }); + const params = await (searchEmbed as any).getUpdateEmbedParamsObject(); + expect(params.dataSources).toEqual(dataSources); + + // Single-GUID `dataSource` prop (the customer's shape) normalizes to + // a one-element array the same way the app's URL parser would. + const singleSourceEmbed = new SearchEmbed(getRootEl(), { + ...defaultViewConfig, + dataSource: '4dd30af7-9ed7-4847-8f28-b65b44a841d8', + }); + const singleParams = await (singleSourceEmbed as any).getUpdateEmbedParamsObject(); + expect(singleParams.dataSources).toEqual([ + '4dd30af7-9ed7-4847-8f28-b65b44a841d8', + ]); + + // The normalization only reverses JSON serialization: non-JSON + // strings and already-structured values pass through untouched. + expect(singleParams.embedApp).toBe(true); + expect(singleParams.authType).toBe('None'); + }); + test('should pass in search query', async () => { const dataSources = ['data-source-1']; const searchOptions = { diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 10ed5d53..b835eb86 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -38,6 +38,7 @@ import { isUndefined, getHostEventsConfig, getValueFromWindow, + deserializeParam, } from '../utils'; import { getCustomActions } from '../utils/custom-actions'; import { @@ -706,11 +707,14 @@ export class TsEmbed { } protected async getUpdateEmbedParamsObject() { - let queryParams = this.getEmbedParamsObject(); + const queryParams = this.getEmbedParamsObject(); + // Values are URL-serialized (e.g. dataSources as '["guid"]'); parse + // them back so the event payload matches a URL load (SCAL-334713). + Object.keys(queryParams).forEach((key) => { + queryParams[key] = deserializeParam(queryParams[key]); + }); const appInitData = await this.getAppInitData(); - queryParams = { ...this.viewConfig, ...queryParams, ...appInitData }; - - return queryParams; + return { ...this.viewConfig, ...queryParams, ...appInitData }; } /** diff --git a/src/utils.spec.ts b/src/utils.spec.ts index 65dc0459..e01c1364 100644 --- a/src/utils.spec.ts +++ b/src/utils.spec.ts @@ -1,5 +1,6 @@ import { getQueryParamString, + deserializeParam, getFilterQuery, getCssDimension, getEncodedQueryParamsString, @@ -348,7 +349,50 @@ describe('unit test for utils', () => { }); }); +describe('deserializeParam', () => { + // Inverse of URL param serialization — must mirror the app's URL parser. + test('parses a JSON-string array back to a real array (SCAL-334713)', () => { + expect(deserializeParam('["4dd30af7-9ed7-4847-8f28-b65b44a841d8"]')).toEqual([ + '4dd30af7-9ed7-4847-8f28-b65b44a841d8', + ]); + }); + + test('does not URI-decode strings — raw %xx content must survive', () => { + expect(deserializeParam('%5Bcommit%20date%5D%5Brevenue%5D')).toBe( + '%5Bcommit%20date%5D%5Brevenue%5D', + ); + }); + + test('parses JSON objects and booleans', () => { + expect(deserializeParam('{"a":1}')).toEqual({ a: 1 }); + expect(deserializeParam('true')).toBe(true); + }); + + test('keeps numeric strings as strings, matching the app URL parser', () => { + expect(deserializeParam('123')).toBe('123'); + expect(deserializeParam('1.5')).toBe('1.5'); + }); + + test('keeps plain non-JSON strings unchanged', () => { + expect(deserializeParam('local-host')).toBe('local-host'); + expect(deserializeParam('AuthServerCookieless')).toBe('AuthServerCookieless'); + }); + + test('keeps a malformed percent-sequence string as-is', () => { + expect(deserializeParam('100% legit')).toBe('100% legit'); + }); + + test('passes non-string values through untouched', () => { + const arr = ['a']; + expect(deserializeParam(arr)).toBe(arr); + expect(deserializeParam(true)).toBe(true); + expect(deserializeParam(42)).toBe(42); + expect(deserializeParam(undefined)).toBeUndefined(); + }); +}); + describe('Fullscreen Utility Functions', () => { + let originalExitFullscreen: any; let mockIframe: HTMLIFrameElement; diff --git a/src/utils.ts b/src/utils.ts index 33786a2f..bcdb6a06 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -85,6 +85,22 @@ const serializeParam = (value: any) => { return JSON.stringify(value); }; +/** + * Inverse of serializeParam: JSON.parse with fallback to the raw string, + * matching how the app parses the same values from the iframe URL. + */ +export const deserializeParam = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + const parsed = JSON.parse(value); + // Numeric strings stay strings ('123' !== 123), same as the app's + // URL parser — param consumers expect string ids/versions. + return typeof parsed === 'number' ? value : parsed; + } catch (e) { + return value; + } +}; + /** * Convert a value to a string: * in case of an array, we convert it to CSV.