diff --git a/src/cms/components/contentstack-editable-components.module.spec.ts b/src/cms/components/contentstack-editable-components.module.spec.ts new file mode 100644 index 0000000..a72a7f4 --- /dev/null +++ b/src/cms/components/contentstack-editable-components.module.spec.ts @@ -0,0 +1,46 @@ +import { TestBed } from '@angular/core/testing'; +import { Config, ConfigModule } from '@spartacus/core'; +import { ContentstackEditableComponentsModule } from './contentstack-editable-components.module'; +import { ContentstackEditableParagraphComponent } from './contentstack-editable-paragraph.component'; +import { ContentstackEditableBannerComponent } from './contentstack-editable-banner.component'; +import { ContentstackEditableProductCarouselComponent } from './contentstack-editable-product-carousel.component'; + +/** + * Registration check for the connector's editable renderers: the tab paragraph + * typeCode must resolve to the editable paragraph renderer, and the existing + * registrations must be unchanged. + */ +describe('ContentstackEditableComponentsModule (cmsComponents registrations)', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ConfigModule.forRoot(), ContentstackEditableComponentsModule], + }); + }); + + afterEach(() => TestBed.resetTestingModule()); + + function cmsComponents(): Record { + return (TestBed.inject(Config) as { cmsComponents?: Record }) + .cmsComponents as Record; + } + + it('maps CMSTabParagraphComponent to ContentstackEditableParagraphComponent', () => { + expect(cmsComponents()['CMSTabParagraphComponent']?.component).toBe( + ContentstackEditableParagraphComponent, + ); + }); + + it('keeps the existing registrations unchanged', () => { + const registry = cmsComponents(); + expect(registry['CMSParagraphComponent']?.component).toBe( + ContentstackEditableParagraphComponent, + ); + expect(registry['SimpleBannerComponent']?.component).toBe(ContentstackEditableBannerComponent); + expect(registry['SimpleResponsiveBannerComponent']?.component).toBe( + ContentstackEditableBannerComponent, + ); + expect(registry['ProductCarouselComponent']?.component).toBe( + ContentstackEditableProductCarouselComponent, + ); + }); +}); diff --git a/src/cms/components/contentstack-editable-components.module.ts b/src/cms/components/contentstack-editable-components.module.ts index c0d23f0..f08cccb 100644 --- a/src/cms/components/contentstack-editable-components.module.ts +++ b/src/cms/components/contentstack-editable-components.module.ts @@ -17,7 +17,8 @@ import { ContentstackEditableProductCarouselComponent } from './contentstack-edi * the edit tags are inert outside preview builds (no `entry.$` ⇒ the directive * removes the attribute), so normal delivery/production rendering is unchanged. * - * Covers `CMSParagraphComponent`, the banner typeCodes (`SimpleBannerComponent`, + * Covers `CMSParagraphComponent` and `CMSTabParagraphComponent` (the tab paragraph + * renders through the same paragraph renderer), the banner typeCodes (`SimpleBannerComponent`, * `SimpleResponsiveBannerComponent`), and `ProductCarouselComponent` (whose * editable renderer keeps the live SAP product hydration and only adds an * editable tag on the carousel title). @@ -29,6 +30,9 @@ import { ContentstackEditableProductCarouselComponent } from './contentstack-edi CMSParagraphComponent: { component: ContentstackEditableParagraphComponent, }, + CMSTabParagraphComponent: { + component: ContentstackEditableParagraphComponent, + }, SimpleBannerComponent: { component: ContentstackEditableBannerComponent, }, diff --git a/src/cms/components/contentstack-editable-paragraph.component.spec.ts b/src/cms/components/contentstack-editable-paragraph.component.spec.ts index 30450a0..1759b25 100644 --- a/src/cms/components/contentstack-editable-paragraph.component.spec.ts +++ b/src/cms/components/contentstack-editable-paragraph.component.spec.ts @@ -47,4 +47,84 @@ describe('ContentstackEditableParagraphComponent', () => { expect(el.hasAttribute('data-cslp')).toBe(false); expect(el.innerHTML).toContain('

Hello

'); }); + + describe('CMSTabParagraphComponent entries (TC-5)', () => { + const TAB_TAG = 'cms_tab_paragraph_component.blt2.en-us.content'; + + it('emits the field-level data-cslp for a tagged tab paragraph (preview)', async () => { + const fixture = await render({ + content: '

Tab body

', + $: { content: { 'data-cslp': TAB_TAG } }, + }); + const el = fixture.nativeElement.querySelector('.cx-paragraph') as HTMLElement; + expect(el.getAttribute('data-cslp')).toBe(TAB_TAG); + expect(el.innerHTML).toContain('

Tab body

'); + }); + + it('renders no data-cslp for an untagged tab paragraph (delivery)', async () => { + const fixture = await render({ content: '

Tab body

' }); + const el = fixture.nativeElement.querySelector('.cx-paragraph') as HTMLElement; + expect(el.hasAttribute('data-cslp')).toBe(false); + expect(el.innerHTML).toContain('

Tab body

'); + }); + }); + + describe('editor HTML sanitization (TC-6)', () => { + it('keeps editor formatting while removing active and unsafe markup', async () => { + const fixture = await render({ + content: + '

Heading

' + + '

Bold italic

' + + '
  • One
' + + '
Cell
' + + '

Help

' + + '' + + '' + + '' + + '' + + '

Styled

' + + 'Bad link', + }); + const el = fixture.nativeElement.querySelector('.cx-paragraph') as HTMLElement; + + // Kept: editor formatting. + expect(el.querySelector('h2.intro-title')?.textContent).toBe('Heading'); + expect(el.querySelector('strong')?.textContent).toBe('Bold'); + expect(el.querySelector('em')?.textContent).toBe('italic'); + expect(el.querySelector('ul > li')?.textContent).toBe('One'); + expect(el.querySelector('table td')?.textContent).toBe('Cell'); + expect(el.querySelector('a[href="/help"]')?.textContent).toBe('Help'); + + // Removed: style, iframe, script, inline handlers, style attributes and javascript: URLs. + expect(el.querySelector('style')).toBeNull(); + expect(el.querySelector('iframe')).toBeNull(); + expect(el.querySelector('script')).toBeNull(); + expect(el.querySelector('img')?.hasAttribute('onerror')).toBeFalsy(); + expect(el.querySelector('p[onclick]')).toBeNull(); + expect(el.querySelector('[style]')).toBeNull(); + // Angular's sanitizer neutralises the javascript: URL by prefixing it with `unsafe:`. + const badLink = Array.from(el.querySelectorAll('a')).find( + (a) => a.textContent === 'Bad link', + ); + expect(badLink?.getAttribute('href')).toMatch(/^unsafe:/); + expect(el.querySelector('a[href^="javascript:"]')).toBeNull(); + expect((window as unknown as { pwned?: boolean }).pwned).toBeUndefined(); + }); + }); + + describe('empty content (TC-7)', () => { + it('renders an empty .cx-paragraph when Content is empty', async () => { + const fixture = await render({ content: '' }); + const el = fixture.nativeElement.querySelector('.cx-paragraph') as HTMLElement; + expect(el).toBeTruthy(); + expect(el.innerHTML.trim()).toBe(''); + }); + + it('renders an empty .cx-paragraph when Content is missing', async () => { + const fixture = await render({}); + const el = fixture.nativeElement.querySelector('.cx-paragraph') as HTMLElement; + expect(el).toBeTruthy(); + expect(el.innerHTML.trim()).toBe(''); + }); + }); }); diff --git a/src/cms/converters/contentstack-field-mapper.spec.ts b/src/cms/converters/contentstack-field-mapper.spec.ts index 42d8750..83f634a 100644 --- a/src/cms/converters/contentstack-field-mapper.spec.ts +++ b/src/cms/converters/contentstack-field-mapper.spec.ts @@ -69,21 +69,21 @@ describe('ContentstackFieldMapper', () => { }); }); - describe('CMSParagraphComponent', () => { - it('passes content through, defaulting to an empty string', () => { - expect(mapper.map('CMSParagraphComponent', { content: '

Hi

' })).toEqual({ - content: '

Hi

', - }); - expect(mapper.map('CMSParagraphComponent', {})).toEqual({ content: '' }); + describe.each(['CMSParagraphComponent', 'CMSTabParagraphComponent'])('%s', (typeCode) => { + const editorHtml = + '

Heading

' + + '

Bold and italic

' + + '
  • One
  • Two
' + + '
Cell
' + + '

Help

'; + + it('passes editor HTML through unchanged', () => { + expect(mapper.map(typeCode, { content: editorHtml })).toEqual({ content: editorHtml }); }); - }); - describe('CMSTabParagraphComponent', () => { - it('maps identically to CMSParagraphComponent (both render via the stock paragraph component)', () => { - expect(mapper.map('CMSTabParagraphComponent', { content: '

Tab body

' })).toEqual({ - content: '

Tab body

', - }); - expect(mapper.map('CMSTabParagraphComponent', {})).toEqual({ content: '' }); + it('defaults empty or missing Content to an empty string', () => { + expect(mapper.map(typeCode, { content: '' })).toEqual({ content: '' }); + expect(mapper.map(typeCode, {})).toEqual({ content: '' }); }); }); diff --git a/src/contentstack-cms-feature.module.ts b/src/contentstack-cms-feature.module.ts index 085c1ee..c2a0ce5 100644 --- a/src/contentstack-cms-feature.module.ts +++ b/src/contentstack-cms-feature.module.ts @@ -32,7 +32,7 @@ import { * only needs to supply credentials via its own `provideConfig(...)`. * 4. Imports {@link ContentstackEditableComponentsModule} — registers * connector-owned editable renderers for seeded starter-pack component types - * (currently `CMSParagraphComponent`) that emit a **field-level** `data-cslp` + * (currently `CMSParagraphComponent` and `CMSTabParagraphComponent`) that emit a **field-level** `data-cslp` * tag so Contentstack Visual Builder can inline-edit them. They render * identically to the stock Spartacus components and the edit tags are inert * outside preview builds, so this is transparent on normal delivery builds.