Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<string, { component?: unknown }> {
return (TestBed.inject(Config) as { cmsComponents?: Record<string, { component?: unknown }> })
.cmsComponents as Record<string, { component?: unknown }>;
}

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,
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -29,6 +30,9 @@ import { ContentstackEditableProductCarouselComponent } from './contentstack-edi
CMSParagraphComponent: {
component: ContentstackEditableParagraphComponent,
},
CMSTabParagraphComponent: {
component: ContentstackEditableParagraphComponent,
},
SimpleBannerComponent: {
component: ContentstackEditableBannerComponent,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,84 @@ describe('ContentstackEditableParagraphComponent', () => {
expect(el.hasAttribute('data-cslp')).toBe(false);
expect(el.innerHTML).toContain('<p>Hello</p>');
});

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: '<p>Tab body</p>',
$: { 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('<p>Tab body</p>');
});

it('renders no data-cslp for an untagged tab paragraph (delivery)', async () => {
const fixture = await render({ content: '<p>Tab body</p>' });
const el = fixture.nativeElement.querySelector('.cx-paragraph') as HTMLElement;
expect(el.hasAttribute('data-cslp')).toBe(false);
expect(el.innerHTML).toContain('<p>Tab body</p>');
});
});

describe('editor HTML sanitization (TC-6)', () => {
it('keeps editor formatting while removing active and unsafe markup', async () => {
const fixture = await render({
content:
'<h2 class="intro-title">Heading</h2>' +
'<p><strong>Bold</strong> <em>italic</em></p>' +
'<ul><li>One</li></ul>' +
'<table><tbody><tr><td>Cell</td></tr></tbody></table>' +
'<p><a href="/help">Help</a></p>' +
'<style>p { color: red; }</style>' +
'<iframe src="https://evil.example"></iframe>' +
'<script>window.pwned = true;</script>' +
'<img src="x" onerror="alert(1)">' +
'<p style="color: red" onclick="alert(1)">Styled</p>' +
'<a href="javascript:alert(1)">Bad link</a>',
});
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('');
});
});
});
26 changes: 13 additions & 13 deletions src/cms/converters/contentstack-field-mapper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,21 +69,21 @@ describe('ContentstackFieldMapper', () => {
});
});

describe('CMSParagraphComponent', () => {
it('passes content through, defaulting to an empty string', () => {
expect(mapper.map('CMSParagraphComponent', { content: '<p>Hi</p>' })).toEqual({
content: '<p>Hi</p>',
});
expect(mapper.map('CMSParagraphComponent', {})).toEqual({ content: '' });
describe.each(['CMSParagraphComponent', 'CMSTabParagraphComponent'])('%s', (typeCode) => {
const editorHtml =
'<h2 class="intro-title">Heading</h2>' +
'<p><strong>Bold</strong> and <em>italic</em></p>' +
'<ul><li>One</li><li>Two</li></ul>' +
'<table><tbody><tr><td>Cell</td></tr></tbody></table>' +
'<p><a href="/help">Help</a></p>';

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: '<p>Tab body</p>' })).toEqual({
content: '<p>Tab body</p>',
});
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: '' });
});
});

Expand Down
2 changes: 1 addition & 1 deletion src/contentstack-cms-feature.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading