diff --git a/projects/ui/src/lib/components/po-helper/index.ts b/projects/ui/src/lib/components/po-helper/index.ts index c01fcc4a3e..2bdd85eddc 100644 --- a/projects/ui/src/lib/components/po-helper/index.ts +++ b/projects/ui/src/lib/components/po-helper/index.ts @@ -1,3 +1,5 @@ export * from './po-helper.module'; export * from './po-helper.component'; export * from './interfaces/po-helper.interface'; +export * from './po-helper-content-utils'; +export * from './po-helper-content.pipe'; diff --git a/projects/ui/src/lib/components/po-helper/interfaces/po-helper.interface.ts b/projects/ui/src/lib/components/po-helper/interfaces/po-helper.interface.ts index d8ca316279..4a542eeba9 100644 --- a/projects/ui/src/lib/components/po-helper/interfaces/po-helper.interface.ts +++ b/projects/ui/src/lib/components/po-helper/interfaces/po-helper.interface.ts @@ -1,12 +1,9 @@ /** - * @usedBy PoHelperComponent + * @usedBy PoHelperComponent, PoPageDefaultComponent * * @description * - * *Interface* que define as opções de configuração do componente po-helper. - * - * Permite customizar o conteúdo, título, tipo do ícone, modo de abertura do popover, ações customizadas e eventos. - * + * Interface para configuração das opções de ajuda (*helper*). */ export interface PoHelperOptions { /** @@ -26,6 +23,14 @@ export interface PoHelperOptions { * @description * * Texto explicativo exibido no popover. + * + * Suporta formatação básica com as tags `` (negrito), `` (negrito), `` (itálico), `` (itálico) e + * `` (sublinhado). + * + * Exemplo: + * ```typescript + * content: 'Texto importante com destaque e sublinhado' + * ``` */ content?: string; diff --git a/projects/ui/src/lib/components/po-helper/po-helper-content-utils.spec.ts b/projects/ui/src/lib/components/po-helper/po-helper-content-utils.spec.ts new file mode 100644 index 0000000000..27ef9bbdb8 --- /dev/null +++ b/projects/ui/src/lib/components/po-helper/po-helper-content-utils.spec.ts @@ -0,0 +1,147 @@ +import { parseHelperContent } from './po-helper-content-utils'; + +describe('PoHelperSafeParser', () => { + describe('parseHelperContent', () => { + it('should return empty array for null/undefined/empty content', () => { + expect(parseHelperContent(null)).toEqual([]); + expect(parseHelperContent(undefined)).toEqual([]); + expect(parseHelperContent('')).toEqual([]); + }); + + it('should return single fragment for plain text without tags', () => { + const result = parseHelperContent('Texto simples'); + expect(result).toEqual([{ text: 'Texto simples', bold: false, italic: false, underline: false }]); + }); + + it('should parse bold tag correctly', () => { + const result = parseHelperContent('Texto negrito normal'); + expect(result).toEqual([ + { text: 'Texto ', bold: false, italic: false, underline: false }, + { text: 'negrito', bold: true, italic: false, underline: false }, + { text: ' normal', bold: false, italic: false, underline: false } + ]); + }); + + it('should parse italic tag correctly', () => { + const result = parseHelperContent('Texto itálico normal'); + expect(result).toEqual([ + { text: 'Texto ', bold: false, italic: false, underline: false }, + { text: 'itálico', bold: false, italic: true, underline: false }, + { text: ' normal', bold: false, italic: false, underline: false } + ]); + }); + + it('should parse underline tag correctly', () => { + const result = parseHelperContent('Texto sublinhado normal'); + expect(result).toEqual([ + { text: 'Texto ', bold: false, italic: false, underline: false }, + { text: 'sublinhado', bold: false, italic: false, underline: true }, + { text: ' normal', bold: false, italic: false, underline: false } + ]); + }); + + it('should parse nested tags correctly', () => { + const result = parseHelperContent('negrito e itálico'); + expect(result).toEqual([{ text: 'negrito e itálico', bold: true, italic: true, underline: false }]); + }); + + it('should parse multiple nested tags', () => { + const result = parseHelperContent('todos'); + expect(result).toEqual([{ text: 'todos', bold: true, italic: true, underline: true }]); + }); + + it('should handle mixed formatting in sequence', () => { + const result = parseHelperContent('negrito e itálico'); + expect(result).toEqual([ + { text: 'negrito', bold: true, italic: false, underline: false }, + { text: ' e ', bold: false, italic: false, underline: false }, + { text: 'itálico', bold: false, italic: true, underline: false } + ]); + }); + + it('should sanitize disallowed tags (script)', () => { + const result = parseHelperContent('Texto seguro'); + expect(result).toEqual([{ text: 'Texto alert("xss") seguro', bold: false, italic: false, underline: false }]); + }); + + it('should sanitize disallowed tags (div, span, a)', () => { + const result = parseHelperContent('
conteúdo
texto link'); + expect(result).toEqual([{ text: 'conteúdo texto link', bold: false, italic: false, underline: false }]); + }); + + it('should sanitize tags with attributes', () => { + const result = parseHelperContent('negrito'); + expect(result).toEqual([{ text: 'negrito', bold: true, italic: false, underline: false }]); + }); + + it('should handle unclosed tags gracefully', () => { + const result = parseHelperContent('negrito sem fechar'); + expect(result).toEqual([{ text: 'negrito sem fechar', bold: true, italic: false, underline: false }]); + }); + + it('should handle extra closing tags gracefully', () => { + const result = parseHelperContent('texto normal'); + expect(result).toEqual([ + { text: 'texto', bold: false, italic: false, underline: false }, + { text: ' normal', bold: false, italic: false, underline: false } + ]); + }); + + it('should handle case-insensitive tags', () => { + const result = parseHelperContent('negrito itálico'); + expect(result).toEqual([ + { text: 'negrito', bold: true, italic: false, underline: false }, + { text: ' ', bold: false, italic: false, underline: false }, + { text: 'itálico', bold: false, italic: true, underline: false } + ]); + }); + + it('should prevent XSS via event handlers in allowed tags', () => { + const result = parseHelperContent('texto'); + expect(result).toEqual([{ text: 'texto', bold: true, italic: false, underline: false }]); + }); + + it('should prevent XSS via img tag with onerror', () => { + const result = parseHelperContent('texto'); + expect(result).toEqual([{ text: 'texto', bold: false, italic: false, underline: false }]); + }); + + it('should handle partially overlapping tags', () => { + const result = parseHelperContent('bold bold+italic italic'); + expect(result).toEqual([ + { text: 'bold ', bold: true, italic: false, underline: false }, + { text: 'bold+italic', bold: true, italic: true, underline: false }, + { text: ' italic', bold: false, italic: true, underline: false } + ]); + }); + + it('should parse as bold', () => { + const result = parseHelperContent('Texto importante normal'); + expect(result).toEqual([ + { text: 'Texto ', bold: false, italic: false, underline: false }, + { text: 'importante', bold: true, italic: false, underline: false }, + { text: ' normal', bold: false, italic: false, underline: false } + ]); + }); + + it('should parse as italic', () => { + const result = parseHelperContent('Texto enfatizado normal'); + expect(result).toEqual([ + { text: 'Texto ', bold: false, italic: false, underline: false }, + { text: 'enfatizado', bold: false, italic: true, underline: false }, + { text: ' normal', bold: false, italic: false, underline: false } + ]); + }); + + it('should handle mixed , , , , together', () => { + const result = parseHelperContent('bold italic underline'); + expect(result).toEqual([ + { text: 'bold', bold: true, italic: false, underline: false }, + { text: ' ', bold: false, italic: false, underline: false }, + { text: 'italic', bold: false, italic: true, underline: false }, + { text: ' ', bold: false, italic: false, underline: false }, + { text: 'underline', bold: false, italic: false, underline: true } + ]); + }); + }); +}); diff --git a/projects/ui/src/lib/components/po-helper/po-helper-content-utils.ts b/projects/ui/src/lib/components/po-helper/po-helper-content-utils.ts new file mode 100644 index 0000000000..c4ba8f2cab --- /dev/null +++ b/projects/ui/src/lib/components/po-helper/po-helper-content-utils.ts @@ -0,0 +1,126 @@ +/** + * @description + * + * Utilitário de "Safe Parser" para o componente po-helper. + * + * Permite o uso de tags de formatação básica (``, ``, ``, ``, ``) no conteúdo do helper, sem + * utilizar `innerHTML`, garantindo proteção contra ataques XSS. + * + * Qualquer tag HTML que não esteja na whitelist é sanitizada (removida), preservando apenas o texto interno. + */ + +/** + * Representa um fragmento de texto com suas propriedades de formatação. + */ +export interface PoHelperTextFragment { + /** Conteúdo textual do fragmento */ + text: string; + /** Indica se o fragmento deve ser exibido em negrito */ + bold: boolean; + /** Indica se o fragmento deve ser exibido em itálico */ + italic: boolean; + /** Indica se o fragmento deve ser exibido com sublinhado */ + underline: boolean; +} + +/** Tags permitidas pelo safe parser */ +const ALLOWED_TAGS = ['b', 'i', 'u', 'strong', 'em']; + +/** Mapa de normalização: tags semânticas são convertidas para suas equivalentes de formatação */ +const TAG_NORMALIZE_MAP: Record = { + strong: 'b', + em: 'i' +}; + +/** + * Regex para capturar tags HTML (abertura e fechamento). + * Captura: tag name e se é fechamento (/). + */ +const TAG_REGEX = /<(\/?)(\w+)(?:\s[^>]*)?\/?>/gi; + +/** + * Remove todas as tags HTML que não estão na whitelist, preservando o texto interno. + * + * @param input String com possíveis tags HTML + * @returns String com apenas as tags permitidas + */ +function sanitizeContent(input: string): string { + return input.replace(TAG_REGEX, (_match, closing, tagName) => { + const normalizedTag = tagName.toLowerCase(); + if (ALLOWED_TAGS.includes(normalizedTag)) { + const outputTag = TAG_NORMALIZE_MAP[normalizedTag] || normalizedTag; + return closing ? `` : `<${outputTag}>`; + } + return ''; + }); +} + +/** Mapa de contadores por tag */ +interface TagCounters { + b: number; + i: number; + u: number; +} + +/** + * Incrementa o contador da tag de abertura. + */ +function incrementTagCounter(counters: TagCounters, tag: string): void { + counters[tag]++; +} + +/** + * Decrementa o contador da tag de fechamento. + */ +function decrementTagCounter(counters: TagCounters, tag: string): void { + counters[tag] = Math.max(0, counters[tag] - 1); +} + +/** + * Faz o parsing de uma string com tags de formatação permitidas (``, ``, ``) + * e retorna um array de fragmentos com as propriedades de formatação aplicadas. + * + * Tags não permitidas são removidas (sanitizadas). Tags aninhadas são suportadas. + * + * @param content String de conteúdo com possíveis tags de formatação + * @returns Array de fragmentos de texto com informações de formatação + */ +export function parseHelperContent(content: string): Array { + if (!content) { + return []; + } + + const sanitized = sanitizeContent(content); + const fragments: Array = []; + const counters: TagCounters = { b: 0, i: 0, u: 0 }; + + const splitRegex = /(<\/?[biu]>)/gi; + const parts = sanitized.split(splitRegex); + + for (const part of parts) { + if (!part) { + continue; + } + + const tagMatch = /^<(\/?)([biu])>$/i.exec(part); + + if (tagMatch) { + const isClosing = tagMatch[1] === '/'; + const tag = tagMatch[2].toLowerCase(); + if (isClosing) { + decrementTagCounter(counters, tag); + } else { + incrementTagCounter(counters, tag); + } + } else { + fragments.push({ + text: part, + bold: counters.b > 0, + italic: counters.i > 0, + underline: counters.u > 0 + }); + } + } + + return fragments; +} diff --git a/projects/ui/src/lib/components/po-helper/po-helper-content.pipe.spec.ts b/projects/ui/src/lib/components/po-helper/po-helper-content.pipe.spec.ts new file mode 100644 index 0000000000..f40aa14994 --- /dev/null +++ b/projects/ui/src/lib/components/po-helper/po-helper-content.pipe.spec.ts @@ -0,0 +1,62 @@ +import { PoHelperContentPipe } from './po-helper-content.pipe'; + +describe('PoHelperContentPipe', () => { + let pipe: PoHelperContentPipe; + + beforeEach(() => { + pipe = new PoHelperContentPipe(); + }); + + it('should create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('should return empty array for null content', () => { + expect(pipe.transform(null)).toEqual([]); + }); + + it('should return empty array for undefined content', () => { + expect(pipe.transform(undefined)).toEqual([]); + }); + + it('should return empty array for empty string', () => { + expect(pipe.transform('')).toEqual([]); + }); + + it('should return single fragment for plain text', () => { + const result = pipe.transform('Texto simples'); + expect(result).toEqual([{ text: 'Texto simples', bold: false, italic: false, underline: false }]); + }); + + it('should parse bold tag', () => { + const result = pipe.transform('Texto negrito'); + expect(result[1]).toEqual({ text: 'negrito', bold: true, italic: false, underline: false }); + }); + + it('should parse italic tag', () => { + const result = pipe.transform('Texto itálico'); + expect(result[1]).toEqual({ text: 'itálico', bold: false, italic: true, underline: false }); + }); + + it('should parse underline tag', () => { + const result = pipe.transform('Texto sublinhado'); + expect(result[1]).toEqual({ text: 'sublinhado', bold: false, italic: false, underline: true }); + }); + + it('should normalize strong to bold', () => { + const result = pipe.transform('bold'); + expect(result[0]).toEqual({ text: 'bold', bold: true, italic: false, underline: false }); + }); + + it('should normalize em to italic', () => { + const result = pipe.transform('italic'); + expect(result[0]).toEqual({ text: 'italic', bold: false, italic: true, underline: false }); + }); + + it('should sanitize script tags', () => { + const result = pipe.transform('safe'); + const allText = result.map(f => f.text).join(''); + expect(allText).not.toContain('safe text', + type: 'info' + }); + const fragments = component['contentFragments'](); + const allText = fragments.map(f => f.text).join(''); + expect(allText).toContain('safe'); + expect(allText).toContain('text'); + expect(allText).not.toContain('