diff --git a/CHANGELOG.md b/CHANGELOG.md index d4abd94..fa9fed6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 0.1.1 Under development +- feat(panel-view): add filterable tables, trace frames, safe links, and SQL text styling. + ## 0.1.0 September 11, 2026 - feat: initial development release. diff --git a/src/Exception/Message.php b/src/Exception/Message.php new file mode 100644 index 0000000..bc32e54 --- /dev/null +++ b/src/Exception/Message.php @@ -0,0 +1,101 @@ +value, ...$argument); + } +} diff --git a/src/Panel.php b/src/Panel.php index 306a6db..57c75bb 100644 --- a/src/Panel.php +++ b/src/Panel.php @@ -41,7 +41,7 @@ abstract public function present(array $data): PanelView; /** * Returns the icon identifier declared by the extension. * - * @throws InvalidArgumentException If the icon identifier is empty. + * @throws InvalidArgumentException if the icon identifier is empty. * * @return string Host-interpreted icon identifier. */ @@ -53,7 +53,7 @@ final public function icon(): string /** * Returns the stable panel identifier declared by the extension. * - * @throws InvalidArgumentException If the panel identifier is empty. + * @throws InvalidArgumentException if the panel identifier is empty. * * @return string Identifier used to associate the panel with captured data. */ @@ -65,7 +65,7 @@ final public function id(): string /** * Returns the navigation title declared by the extension. * - * @throws InvalidArgumentException If the panel title is empty. + * @throws InvalidArgumentException if the panel title is empty. * * @return string Human-readable panel title. */ @@ -80,7 +80,7 @@ final public function name(): string * @param string $value Metadata value to validate. * @param string $field Constant name included in the validation error. * - * @throws InvalidArgumentException If the metadata value is empty. + * @throws InvalidArgumentException if the metadata value is empty. * * @return string Unmodified metadata value. */ diff --git a/src/PanelView.php b/src/PanelView.php index 0efd57b..6cf6145 100644 --- a/src/PanelView.php +++ b/src/PanelView.php @@ -6,12 +6,18 @@ use InvalidArgumentException; use JsonSerializable; +use PHPForge\Debug\Exception\Message; use function count; +use function in_array; use function is_array; use function is_float; use function is_int; use function is_string; +use function parse_url; +use function strpbrk; +use function strtolower; +use function trim; /** * Builds immutable panel descriptions without exposing the host's markup or styles. @@ -24,9 +30,11 @@ * and {@see self::isActive()}. Nothing outside this class can build or alter a shape. * * @phpstan-type BadgeInline array{kind: 'badge', label: string, tone: Tone} - * @phpstan-type TextInline array{kind: 'text', value: string, style: 'code'|'plain'|'preview'|'strong'} + * @phpstan-type LinkInline array{kind: 'link', label: string, href: string, external: bool} + * @phpstan-type TextInline array{kind: 'text', value: string, style: 'code'|'plain'|'preview'|'sql'|'strong'} + * @phpstan-type TraceInline array{kind: 'trace', frames: list>} * @phpstan-type ValueInline array{kind: 'value', value: mixed, typeOnly: bool} - * @phpstan-type Inline BadgeInline|TextInline|ValueInline + * @phpstan-type Inline BadgeInline|LinkInline|TextInline|TraceInline|ValueInline * @phpstan-type Pair array{label: string, value: Inline} * @phpstan-type TextPair array{label: string, value: TextInline} * @phpstan-type EmptyStateBlock array{kind: 'emptyState', title: string, paragraphs: list} @@ -38,7 +46,8 @@ * headers: list, * rows: list>, * styles: array, - * collapsible: bool + * collapsible: bool, + * filterable: bool * } * @phpstan-type Block array{ * kind: 'disclosure', @@ -111,7 +120,7 @@ public function blocks(): array * @param Tone $tone Callout tone interpreted by the host frontend. * @param mixed ...$content Ordered factory-produced inline values, scalars, or `null`. * - * @throws InvalidArgumentException If an argument is not an accepted inline value. + * @throws InvalidArgumentException if an argument is not an accepted inline value. * * @return self New view with the callout appended. */ @@ -171,7 +180,7 @@ public function disclosure(string $title, string $content): self * @param mixed ...$paragraphs Ordered explanations, each one paragraph: a scalar, a factory-produced inline value, * or a list of inline values and scalars. * - * @throws InvalidArgumentException If a paragraph is neither an inline value nor a list of them. + * @throws InvalidArgumentException if a paragraph is neither an inline value nor a list of them. * * @return self New view with the empty state appended. */ @@ -237,6 +246,30 @@ public function jsonSerialize(): array ]; } + /** + * Creates an inline navigation link the host renders as an anchor. + * + * Only relative targets and the `http`, `https`, and `mailto` schemes are accepted, so a captured value can never + * turn into an executable target. + * + * @param string $label Link text; the host escapes it. + * @param string $href Relative target, or an absolute `http`, `https`, or `mailto` URL. + * @param bool $external Whether the host opens the target in a new browsing context. + * + * @throws InvalidArgumentException if the target declares a scheme the host must not follow. + * + * @return LinkInline Inline link accepted by every content method. + */ + public static function link(string $label, string $href, bool $external = false): array + { + return [ + 'kind' => 'link', + 'label' => $label, + 'href' => self::target($href), + 'external' => $external, + ]; + } + /** * Appends labeled overview fields, using array keys as labels. * @@ -245,7 +278,7 @@ public function jsonSerialize(): array * @param array $values Labeled values in display order, each an inline value or a scalar. * @param bool $compact Whether to request compact presentation from the host. * - * @throws InvalidArgumentException If a value is not an accepted inline value. + * @throws InvalidArgumentException if a value is not an accepted inline value. * * @return self New view with the overview appended. */ @@ -265,7 +298,7 @@ public function overview(array $values, bool $compact = false): self * * @param mixed ...$content Ordered factory-produced inline values, scalars, or `null`. * - * @throws InvalidArgumentException If an argument is not an accepted inline value. + * @throws InvalidArgumentException if an argument is not an accepted inline value. * * @return self New view with the paragraph appended. */ @@ -290,6 +323,22 @@ public static function preview(string $value): array ]; } + /** + * Creates inline text the host highlights as an SQL statement. + * + * @param string $value Statement text; the host escapes it. + * + * @return TextInline Inline text accepted by every content method. + */ + public static function sql(string $value): array + { + return [ + 'kind' => 'text', + 'value' => $value, + 'style' => 'sql', + ]; + } + /** * Creates emphasized inline text. * @@ -347,13 +396,19 @@ public function summaryMetrics(): array * @param array $rows Rows of inline or plain values in display order. * @param bool $collapsible Whether the host may collapse the table. * @param array $styles Optional {@see ColumnStyle} cases keyed by column index. + * @param bool $filterable Whether to request the host's in-place row filter for the table. * - * @throws InvalidArgumentException If a header, row width, cell, or column style is invalid. + * @throws InvalidArgumentException if a header, row width, cell, or column style is invalid. * * @return self New view with the table appended. */ - public function table(array $headers, array $rows, bool $collapsible = false, array $styles = []): self - { + public function table( + array $headers, + array $rows, + bool $collapsible = false, + array $styles = [], + bool $filterable = false, + ): self { $columns = self::headers($headers); return $this->append( @@ -363,10 +418,12 @@ public function table(array $headers, array $rows, bool $collapsible = false, ar 'rows' => self::rows($rows, count($columns)), 'styles' => self::styles($styles, count($columns)), 'collapsible' => $collapsible, + 'filterable' => $filterable, ], ); } + /** * Creates plain inline text. * @@ -416,6 +473,43 @@ public function toolbarMetrics(): array return $this->toolbar; } + /** + * Creates the captured source frames of a call site, which the host renders through its own frame renderer. + * + * Frames travel as captured data, never as markup, so the host keeps ownership of the source-link format. + * + * @param array $frames Captured frames in call order, each an array of frame fields. + * + * @throws InvalidArgumentException if a frame is not an array of fields. + * + * @return TraceInline Inline trace accepted by every content method. + */ + public static function trace(array $frames): array + { + $captured = []; + + foreach ($frames as $frame) { + if (is_array($frame) === false) { + throw new InvalidArgumentException( + Message::TRACE_FRAME_INVALID->getMessage(), + ); + } + + $fields = []; + + foreach ($frame as $key => $value) { + $fields[(string) $key] = $value; + } + + $captured[] = $fields; + } + + return [ + 'kind' => 'trace', + 'frames' => $captured, + ]; + } + /** * Creates a captured diagnostic value the host formats and escapes. * @@ -455,7 +549,7 @@ private function append(array $block): self * * @param array $headers Column headings to validate. * - * @throws InvalidArgumentException If a heading is not a string. + * @throws InvalidArgumentException if a heading is not a string. * * @return list Validated column headings in display order. */ @@ -466,7 +560,7 @@ private static function headers(array $headers): array foreach ($headers as $header) { if (is_string($header) === false) { throw new InvalidArgumentException( - 'Debug panel table headers must be plain strings.', + Message::TABLE_HEADER_INVALID->getMessage(), ); } @@ -481,7 +575,7 @@ private static function headers(array $headers): array * * @param mixed $value Inline value or plain value to normalize. * - * @throws InvalidArgumentException If the value is not an accepted inline input. + * @throws InvalidArgumentException if the value is not an accepted inline input. * * @return Inline Normalized inline value. */ @@ -503,7 +597,7 @@ private static function inline(mixed $value): array * * @param array $value Candidate inline value. * - * @throws InvalidArgumentException If the array was not produced by an inline factory. + * @throws InvalidArgumentException if the array was not produced by an inline factory. * * @return Inline Validated inline value. */ @@ -517,10 +611,26 @@ private static function inlineShape(array $value): array return ['kind' => 'badge', 'label' => $label, 'tone' => $tone]; } + $href = $value['href'] ?? null; + $external = $value['external'] ?? null; + + if ($kind === 'link' && is_string($label) && is_string($href) && is_bool($external)) { + return [ + 'kind' => 'link', + 'label' => $label, + 'href' => self::target($href), + 'external' => $external, + ]; + } + $text = $value['value'] ?? null; $style = $value['style'] ?? null; - if ($kind === 'text' && is_string($text) && in_array($style, ['code', 'plain', 'preview', 'strong'], true)) { + if ( + $kind === 'text' + && is_string($text) + && in_array($style, ['code', 'plain', 'preview', 'sql', 'strong'], true) + ) { return [ 'kind' => 'text', 'value' => $text, @@ -528,6 +638,12 @@ private static function inlineShape(array $value): array ]; } + $frames = $value['frames'] ?? null; + + if ($kind === 'trace' && is_array($frames)) { + return self::trace($frames); + } + $typeOnly = $value['typeOnly'] ?? null; if ($kind === 'value' && array_key_exists('value', $value) && is_bool($typeOnly)) { @@ -547,7 +663,7 @@ private static function inlineShape(array $value): array * @param array $content Inline values or plain values in display order. * @param Tone|null $tone Callout tone, or `null` for an ordinary paragraph. * - * @throws InvalidArgumentException If an item is not an accepted inline value. + * @throws InvalidArgumentException if an item is not an accepted inline value. * * @return ParagraphBlock Validated paragraph block. */ @@ -571,7 +687,7 @@ private static function paragraphBlock(array $content, Tone|null $tone): array * * @param mixed $paragraph Paragraph description. * - * @throws InvalidArgumentException If the description is neither an inline value nor a list of them. + * @throws InvalidArgumentException if the description is neither an inline value nor a list of them. * * @return ParagraphBlock Validated paragraph block. */ @@ -583,7 +699,7 @@ private static function paragraphOf(mixed $paragraph): array if (array_is_list($paragraph) === false) { throw new InvalidArgumentException( - 'Debug panel paragraphs must be scalars, inline values, or lists of them.', + Message::PARAGRAPH_CONTENT_INVALID->getMessage(), ); } @@ -596,7 +712,7 @@ private static function paragraphOf(mixed $paragraph): array * @param array $rows Rows to validate. * @param int $columns Number of declared columns. * - * @throws InvalidArgumentException If a row is not a list or its width differs from the headers. + * @throws InvalidArgumentException if a row is not a list or its width differs from the headers. * * @return list> Validated rows in display order. */ @@ -607,7 +723,7 @@ private static function rows(array $rows, int $columns): array foreach ($rows as $row) { if (is_array($row) === false || array_is_list($row) === false || count($row) !== $columns) { throw new InvalidArgumentException( - 'Debug panel table rows must be lists whose width matches the headers.', + Message::TABLE_ROW_WIDTH_INVALID->getMessage(), ); } @@ -629,7 +745,7 @@ private static function rows(array $rows, int $columns): array * @param array $styles Column styles keyed by column index. * @param int $columns Number of declared columns. * - * @throws InvalidArgumentException If a key is not an existing column index or a value is not a style. + * @throws InvalidArgumentException if a key is not an existing column index or a value is not a style. * * @return array Validated column styles. */ @@ -640,13 +756,13 @@ private static function styles(array $styles, int $columns): array foreach ($styles as $column => $style) { if (is_int($column) === false || $column < 0 || $column >= $columns) { throw new InvalidArgumentException( - 'Debug panel column styles must be keyed by an existing column index.', + Message::COLUMN_STYLE_KEY_INVALID->getMessage(), ); } if ($style instanceof ColumnStyle === false) { throw new InvalidArgumentException( - 'Debug panel column styles must be ' . ColumnStyle::class . ' cases.', + Message::COLUMN_STYLE_INVALID->getMessage(ColumnStyle::class), ); } @@ -656,6 +772,46 @@ private static function styles(array $styles, int $columns): array return $result; } + /** + * Rejects a link target the host must not follow. + * + * Characters a browser strips while resolving a URL are rejected first, because they move the scheme: a tab, a line + * break, or a surrounding space turns `" javascript:alert(1)"` into an executable target after the check. + * + * @param string $href Candidate target. + * + * @throws InvalidArgumentException if the target carries characters a browser strips, cannot be parsed, or declares + * a scheme other than `http`, `https`, or `mailto`. + * + * @return string Unmodified target. + */ + private static function target(string $href): string + { + if (strpbrk($href, "\t\n\r") !== false || trim($href, "\x00..\x20") !== $href) { + throw new InvalidArgumentException( + Message::LINK_TARGET_NORMALIZED->getMessage(), + ); + } + + $parts = parse_url($href); + + if ($parts === false) { + throw new InvalidArgumentException( + Message::LINK_TARGET_UNPARSABLE->getMessage(), + ); + } + + $scheme = $parts['scheme'] ?? null; + + if ($scheme !== null && in_array(strtolower($scheme), ['http', 'https', 'mailto'], true) === false) { + throw new InvalidArgumentException( + Message::LINK_TARGET_SCHEME_INVALID->getMessage($scheme), + ); + } + + return $href; + } + /** * Builds the rejection naming the value that cannot be displayed inline. * @@ -666,8 +822,7 @@ private static function styles(array $styles, int $columns): array private static function unsupportedInline(mixed $value): InvalidArgumentException { return new InvalidArgumentException( - 'Debug panel inline content must be a scalar, null, or a PanelView inline value. Got ' - . get_debug_type($value) . '.', + Message::INLINE_CONTENT_INVALID->getMessage(get_debug_type($value)), ); } } diff --git a/tests/FluentPanelViewTest.php b/tests/FluentPanelViewTest.php index c712c0d..3cef310 100644 --- a/tests/FluentPanelViewTest.php +++ b/tests/FluentPanelViewTest.php @@ -66,6 +66,7 @@ public function testDefinitionKeepsEveryContentOptionAndOrder(): void ], 'styles' => [0 => ColumnStyle::MONOSPACE], 'collapsible' => true, + 'filterable' => false, ], [ 'kind' => 'paragraph', diff --git a/tests/PanelViewTest.php b/tests/PanelViewTest.php index 4d71a89..2ea0d51 100644 --- a/tests/PanelViewTest.php +++ b/tests/PanelViewTest.php @@ -6,7 +6,8 @@ use InvalidArgumentException; use PHPForge\Debug\{ColumnStyle, PanelView, Tone}; -use PHPForge\Debug\Tests\Provider\InlineScalarProvider; +use PHPForge\Debug\Exception\Message; +use PHPForge\Debug\Tests\Provider\{InlineScalarProvider, LinkTargetProvider}; use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\TestCase; use stdClass; @@ -14,7 +15,7 @@ /** * Unit tests for the validated shapes {@see PanelView} exports to the host renderer. * - * {@see InlineScalarProvider} for test case data providers. + * {@see InlineScalarProvider} and {@see LinkTargetProvider} for test case data providers. */ final class PanelViewTest extends TestCase { @@ -33,13 +34,43 @@ public function testDefaultsPreserveNonIntrusivePresentation(): void [ ['kind' => 'heading', 'title' => 'Title', 'section' => false], ['kind' => 'overview', 'fields' => [], 'compact' => false], - ['kind' => 'table', 'headers' => [], 'rows' => [], 'styles' => [], 'collapsible' => false], + [ + 'kind' => 'table', + 'headers' => [], + 'rows' => [], + 'styles' => [], + 'collapsible' => false, + 'filterable' => false, + ], ], $view->blocks(), 'Every presentation hint must stay opt-in.', ); } + public function testForgedSqlAndTraceValuesSurviveTheInlineRebuild(): void + { + self::assertSame( + [ + [ + 'kind' => 'paragraph', + 'content' => [ + ['kind' => 'text', 'value' => 'SELECT 1', 'style' => 'sql'], + ['kind' => 'trace', 'frames' => [['file' => '/app/x.php']]], + ], + 'tone' => null, + ], + ], + PanelView::create() + ->paragraph( + ['kind' => 'text', 'value' => 'SELECT 1', 'style' => 'sql'], + ['kind' => 'trace', 'frames' => [['file' => '/app/x.php']]], + ) + ->blocks(), + 'Both new inline values must survive the rebuild unchanged.', + ); + } + public function testInlineFactoriesDescribeContentStyleAndTone(): void { self::assertSame( @@ -82,6 +113,47 @@ public function testInlineFactoriesDescribeContentStyleAndTone(): void PanelView::value(null, typeOnly: true), 'Type-only presentation must be explicit.', ); + self::assertSame( + ['kind' => 'link', 'label' => 'View full phpinfo', 'href' => '/debug/php-info', 'external' => false], + PanelView::link('View full phpinfo', '/debug/php-info'), + 'Links must stay in the same browsing context by default.', + ); + self::assertSame( + ['kind' => 'link', 'label' => 'Docs', 'href' => 'https://example.test/d', 'external' => true], + PanelView::link('Docs', 'https://example.test/d', true), + 'A new browsing context must be requested explicitly.', + ); + self::assertSame( + ['kind' => 'text', 'value' => 'SELECT 1', 'style' => 'sql'], + PanelView::sql('SELECT 1'), + 'Statement highlighting must be requested explicitly.', + ); + self::assertSame( + ['kind' => 'trace', 'frames' => [['file' => '/app/x.php', 'line' => 7], ['0' => 'bare']]], + PanelView::trace([['file' => '/app/x.php', 'line' => 7], ['bare']]), + 'Frames must travel as captured fields, with keys normalized to strings.', + ); + } + + #[DataProviderExternal(LinkTargetProvider::class, 'accepted')] + public function testLinkTargetsWithoutAnExecutableSchemeAreAccepted(string $href): void + { + self::assertSame( + [ + [ + 'kind' => 'overview', + 'fields' => [ + [ + 'label' => 'Target', + 'value' => ['kind' => 'link', 'label' => 'Open', 'href' => $href, 'external' => false], + ], + ], + 'compact' => false, + ], + ], + PanelView::create()->overview(['Target' => PanelView::link('Open', $href)])->blocks(), + 'An accepted target must travel unmodified.', + ); } public function testMetricsAndFieldsShareOneLabelAndValueShape(): void @@ -179,6 +251,7 @@ public function testTableKeepsEveryStyledColumnUnderItsIndex(): void 'rows' => [[PanelView::text('home'), PanelView::text('cached'), PanelView::text('3')]], 'styles' => [0 => ColumnStyle::MONOSPACE, 2 => ColumnStyle::NUMBER], 'collapsible' => false, + 'filterable' => false, ], ], $view->blocks(), @@ -190,27 +263,83 @@ public function testThrowInvalidArgumentExceptionForAssociativeParagraph(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( - 'Debug panel paragraphs must be scalars, inline values, or lists of them.', + Message::PARAGRAPH_CONTENT_INVALID->getMessage(), ); PanelView::create()->emptyState('Empty', ['first' => 'A']); } + #[DataProviderExternal(LinkTargetProvider::class, 'normalized')] + public function testThrowInvalidArgumentExceptionForBrowserNormalizedLinkTarget(string $href): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + Message::LINK_TARGET_NORMALIZED->getMessage(), + ); + + PanelView::link( + 'Open', + $href, + ); + } + + #[DataProviderExternal(LinkTargetProvider::class, 'rejected')] + public function testThrowInvalidArgumentExceptionForExecutableLinkTarget(string $href, string $scheme): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + Message::LINK_TARGET_SCHEME_INVALID->getMessage($scheme), + ); + + PanelView::link( + 'Open', + $href, + ); + } + + public function testThrowInvalidArgumentExceptionForForgedExecutableLink(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + Message::LINK_TARGET_SCHEME_INVALID->getMessage('javascript'), + ); + + PanelView::create() + ->paragraph( + [ + 'kind' => 'link', + 'label' => 'Open', + 'href' => 'javascript:alert(1)', + 'external' => false, + ], + ); + } + public function testThrowInvalidArgumentExceptionForForgedInlineValue(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( - 'Debug panel inline content must be a scalar, null, or a PanelView inline value. Got array.', + Message::INLINE_CONTENT_INVALID->getMessage('array'), ); PanelView::create()->paragraph(['kind' => 'text']); } + public function testThrowInvalidArgumentExceptionForNonArrayTraceFrame(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + Message::TRACE_FRAME_INVALID->getMessage(), + ); + + PanelView::trace(['not a frame']); + } + public function testThrowInvalidArgumentExceptionForNonColumnStyle(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( - 'Debug panel column styles must be ' . ColumnStyle::class . ' cases.', + Message::COLUMN_STYLE_INVALID->getMessage(ColumnStyle::class), ); PanelView::create()->table(['One'], [['a']], styles: [0 => 'pill']); @@ -220,7 +349,7 @@ public function testThrowInvalidArgumentExceptionForNonInlineObject(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( - 'Debug panel inline content must be a scalar, null, or a PanelView inline value. Got stdClass.', + Message::INLINE_CONTENT_INVALID->getMessage('stdClass'), ); PanelView::create()->paragraph(new stdClass()); @@ -230,7 +359,7 @@ public function testThrowInvalidArgumentExceptionForNonListRow(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( - 'Debug panel table rows must be lists whose width matches the headers.', + Message::TABLE_ROW_WIDTH_INVALID->getMessage(), ); PanelView::create()->table(['One'], ['not a row']); @@ -240,7 +369,7 @@ public function testThrowInvalidArgumentExceptionForNonStringHeader(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( - 'Debug panel table headers must be plain strings.', + Message::TABLE_HEADER_INVALID->getMessage(), ); PanelView::create()->table([1], [[1]]); @@ -250,7 +379,7 @@ public function testThrowInvalidArgumentExceptionForRowWidthMismatch(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( - 'Debug panel table rows must be lists whose width matches the headers.', + Message::TABLE_ROW_WIDTH_INVALID->getMessage(), ); PanelView::create()->table(['One'], [[]]); @@ -260,9 +389,22 @@ public function testThrowInvalidArgumentExceptionForUnknownStyledColumn(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( - 'Debug panel column styles must be keyed by an existing column index.', + Message::COLUMN_STYLE_KEY_INVALID->getMessage(), ); PanelView::create()->table(['One'], [['a']], styles: [1 => ColumnStyle::PILL]); } + + public function testThrowInvalidArgumentExceptionForUnparsableLinkTarget(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + Message::LINK_TARGET_UNPARSABLE->getMessage(), + ); + + PanelView::link( + 'Open', + 'http://:80', + ); + } } diff --git a/tests/Provider/LinkTargetProvider.php b/tests/Provider/LinkTargetProvider.php new file mode 100644 index 0000000..70593af --- /dev/null +++ b/tests/Provider/LinkTargetProvider.php @@ -0,0 +1,60 @@ + + */ + public static function accepted(): iterable + { + yield 'absolute path' => ['/debug/php-info']; + yield 'colon inside a fragment' => ['#a:b']; + yield 'colon inside a query' => ['?at=a:b']; + yield 'colon opening the target' => [':relative']; + yield 'fragment' => ['#queries']; + yield 'http' => ['http://example.test/']; + yield 'https with uppercase scheme' => ['HTTPS://example.test/']; + yield 'mailto' => ['mailto:dev@example.test']; + yield 'protocol relative' => ['//cdn.example.test/a.css']; + yield 'query only' => ['?panel=db&tag=1']; + yield 'relative path with a colon after the first segment' => ['view/a:b']; + yield 'relative path' => ['view?panel=db']; + } + + /** + * @return iterable + */ + public static function normalized(): iterable + { + yield 'carriage return inside the scheme' => ["java\rscript:alert(1)"]; + yield 'leading newline' => ["\njavascript:alert(1)"]; + yield 'leading null byte' => ["\0javascript:alert(1)"]; + yield 'leading space' => [' javascript:alert(1)']; + yield 'leading tab' => ["\tjavascript:alert(1)"]; + yield 'newline inside the scheme' => ["java\nscript:alert(1)"]; + yield 'trailing space' => ['https://example.test/ ']; + } + + /** + * @return iterable + */ + public static function rejected(): iterable + { + yield 'about' => ['about:blank', 'about']; + yield 'colon-led digits a browser resolves relative' => ['1:30-report', '1']; + yield 'data' => ['data:text/html;base64,PHN2Zz4=', 'data']; + yield 'file' => ['file:///etc/passwd', 'file']; + yield 'javascript with mixed case' => ['JavaScript:alert(1)', 'JavaScript']; + yield 'javascript' => ['javascript:alert(1)', 'javascript']; + yield 'vbscript' => ['vbscript:msgbox(1)', 'vbscript']; + } +}