def78d4754
- Vimeo regex now rejects URLs like vimeo.com/123abc that were silently truncated to ID 123 and produced broken iframes. Negative lookahead (?![A-Za-z0-9]) ensures the captured digits are not followed by alphanumerics. Two false-positive test cases added. - Spec corrected: HtmlInline nodes ARE filtered regardless of insertion path; the implementation uses a dedicated MediaEmbedNode + renderer to bypass the filter only for trusted programmatic embeds. Components list updated to include the two extra files. - Plan Task 6 regex updated for consistency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
204 lines
6.9 KiB
PHP
204 lines
6.9 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Markdown;
|
|
|
|
use App\Markdown\MediaUrlResolver;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use Tests\TestCase;
|
|
|
|
class MediaUrlResolverTest extends TestCase
|
|
{
|
|
private MediaUrlResolver $resolver;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->resolver = new MediaUrlResolver();
|
|
}
|
|
|
|
#[DataProvider('nonMediaUrls')]
|
|
public function test_returns_null_for_non_media_urls(string $url): void
|
|
{
|
|
$this->assertNull($this->resolver->resolve($url));
|
|
}
|
|
|
|
public static function nonMediaUrls(): array
|
|
{
|
|
return [
|
|
'normal image' => ['/photo.jpg'],
|
|
'svg' => ['/icon.svg'],
|
|
'png' => ['/avatar.png'],
|
|
'no extension' => ['/foo'],
|
|
'empty string' => [''],
|
|
'javascript scheme' => ['javascript:alert(1)'],
|
|
'host-only' => ['http://'],
|
|
'youtu.be lookalike host' => ['https://example.com/youtu.be-fake/abc'],
|
|
];
|
|
}
|
|
|
|
#[DataProvider('videoUrls')]
|
|
public function test_video_urls_produce_video_tag(string $url): void
|
|
{
|
|
$html = $this->resolver->resolve($url);
|
|
$this->assertNotNull($html);
|
|
$this->assertStringStartsWith('<video', $html);
|
|
$this->assertStringContainsString('controls', $html);
|
|
$this->assertStringContainsString('class="kb-video"', $html);
|
|
}
|
|
|
|
public static function videoUrls(): array
|
|
{
|
|
return [
|
|
'mp4' => ['/demo.mp4'],
|
|
'webm' => ['/demo.webm'],
|
|
'ogv' => ['/demo.ogv'],
|
|
'mov' => ['/demo.mov'],
|
|
'm4v' => ['/demo.m4v'],
|
|
'uppercase extension' => ['/demo.MP4'],
|
|
'with query string' => ['https://example.com/path/demo.mp4?token=abc'],
|
|
'absolute http' => ['https://example.com/demo.mp4'],
|
|
];
|
|
}
|
|
|
|
public function test_video_url_is_html_escaped(): void
|
|
{
|
|
$html = $this->resolver->resolve('/path/with"quote.mp4');
|
|
$this->assertNotNull($html);
|
|
$this->assertStringNotContainsString('"quote.mp4"', $html);
|
|
$this->assertStringContainsString('"', $html);
|
|
}
|
|
|
|
#[DataProvider('audioUrls')]
|
|
public function test_audio_urls_produce_audio_tag(string $url): void
|
|
{
|
|
$html = $this->resolver->resolve($url);
|
|
$this->assertNotNull($html);
|
|
$this->assertStringStartsWith('<audio', $html);
|
|
$this->assertStringContainsString('controls', $html);
|
|
$this->assertStringContainsString('class="kb-audio"', $html);
|
|
}
|
|
|
|
public static function audioUrls(): array
|
|
{
|
|
return [
|
|
'mp3' => ['/clip.mp3'],
|
|
'wav' => ['/clip.wav'],
|
|
'ogg' => ['/clip.ogg'],
|
|
'm4a' => ['/clip.m4a'],
|
|
];
|
|
}
|
|
|
|
#[DataProvider('youtubeUrls')]
|
|
public function test_youtube_urls_produce_iframe(string $url): void
|
|
{
|
|
$html = $this->resolver->resolve($url);
|
|
$this->assertNotNull($html);
|
|
$this->assertStringStartsWith('<iframe', $html);
|
|
$this->assertStringContainsString('youtube-nocookie.com/embed/dQw4w9WgXcQ', $html);
|
|
$this->assertStringContainsString('class="kb-embed kb-embed-youtube"', $html);
|
|
$this->assertStringContainsString('loading="lazy"', $html);
|
|
$this->assertStringContainsString('allowfullscreen', $html);
|
|
}
|
|
|
|
public static function youtubeUrls(): array
|
|
{
|
|
return [
|
|
'short youtu.be' => ['https://youtu.be/dQw4w9WgXcQ'],
|
|
'watch v=' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ'],
|
|
'shorts' => ['https://www.youtube.com/shorts/dQw4w9WgXcQ'],
|
|
'embed' => ['https://www.youtube.com/embed/dQw4w9WgXcQ'],
|
|
'mobile' => ['https://m.youtube.com/watch?v=dQw4w9WgXcQ'],
|
|
'no www watch' => ['https://youtube.com/watch?v=dQw4w9WgXcQ'],
|
|
];
|
|
}
|
|
|
|
#[DataProvider('invalidYoutubeUrls')]
|
|
public function test_invalid_youtube_urls_return_null(string $url): void
|
|
{
|
|
$this->assertNull($this->resolver->resolve($url));
|
|
}
|
|
|
|
public static function invalidYoutubeUrls(): array
|
|
{
|
|
return [
|
|
'too short id' => ['https://youtu.be/short'],
|
|
'host mismatch' => ['https://example.com/watch?v=dQw4w9WgXcQ'],
|
|
'XSS attempt in id' => ['https://youtu.be/abc"><script>'],
|
|
];
|
|
}
|
|
|
|
#[DataProvider('youtubeTimestampUrls')]
|
|
public function test_youtube_timestamp_normalizes_to_start(string $url, int $expectedStart): void
|
|
{
|
|
$html = $this->resolver->resolve($url);
|
|
$this->assertNotNull($html);
|
|
$this->assertStringContainsString("?start={$expectedStart}", $html);
|
|
}
|
|
|
|
public static function youtubeTimestampUrls(): array
|
|
{
|
|
return [
|
|
't=30s' => ['https://youtu.be/dQw4w9WgXcQ?t=30s', 30],
|
|
't=30 (no suffix)' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30', 30],
|
|
't=1m20s' => ['https://youtu.be/dQw4w9WgXcQ?t=1m20s', 80],
|
|
't=1h2m3s' => ['https://youtu.be/dQw4w9WgXcQ?t=1h2m3s', 3723],
|
|
'start=45' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=45', 45],
|
|
];
|
|
}
|
|
|
|
#[DataProvider('vimeoUrls')]
|
|
public function test_vimeo_urls_produce_iframe(string $url): void
|
|
{
|
|
$html = $this->resolver->resolve($url);
|
|
$this->assertNotNull($html);
|
|
$this->assertStringStartsWith('<iframe', $html);
|
|
$this->assertStringContainsString('player.vimeo.com/video/123456789', $html);
|
|
$this->assertStringContainsString('dnt=1', $html);
|
|
$this->assertStringContainsString('class="kb-embed kb-embed-vimeo"', $html);
|
|
}
|
|
|
|
public static function vimeoUrls(): array
|
|
{
|
|
return [
|
|
'vimeo.com' => ['https://vimeo.com/123456789'],
|
|
'www.vimeo.com' => ['https://www.vimeo.com/123456789'],
|
|
'player.vimeo.com' => ['https://player.vimeo.com/video/123456789'],
|
|
];
|
|
}
|
|
|
|
#[DataProvider('vimeoTimestampUrls')]
|
|
public function test_vimeo_timestamp_preserved_as_hash(string $url): void
|
|
{
|
|
$html = $this->resolver->resolve($url);
|
|
$this->assertNotNull($html);
|
|
$this->assertStringContainsString('#t=30s', $html);
|
|
}
|
|
|
|
public static function vimeoTimestampUrls(): array
|
|
{
|
|
return [
|
|
'hash form' => ['https://vimeo.com/123456789#t=30s'],
|
|
'query form' => ['https://vimeo.com/123456789?t=30s'],
|
|
];
|
|
}
|
|
|
|
public function test_vimeo_invalid_id_returns_null(): void
|
|
{
|
|
$this->assertNull($this->resolver->resolve('https://vimeo.com/notanumber'));
|
|
}
|
|
|
|
#[DataProvider('vimeoFalsePositives')]
|
|
public function test_vimeo_false_positives_return_null(string $url): void
|
|
{
|
|
$this->assertNull($this->resolver->resolve($url));
|
|
}
|
|
|
|
public static function vimeoFalsePositives(): array
|
|
{
|
|
return [
|
|
'digits then letter' => ['https://vimeo.com/123abc'],
|
|
'digits then x' => ['https://vimeo.com/123x'],
|
|
];
|
|
}
|
|
}
|