tt-rss/tests/mocked/UrlHelperTest.php
2025-11-07 10:01:07 +00:00

582 lines
20 KiB
PHP

<?php
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
#[Group('mocked')]
final class UrlHelperTest extends TestCase {
// ===== build_url() Tests =====
public function testBuildUrlWithAllParts(): void {
$parts = [
'scheme' => 'https',
'host' => 'example.com',
'path' => '/path/to/page',
'query' => 'param=value',
'fragment' => 'section'
];
$result = UrlHelper::build_url($parts);
$this->assertEquals('https://example.com/path/to/page?param=value#section', $result);
}
public function testBuildUrlWithMinimalParts(): void {
$parts = [
'scheme' => 'http',
'host' => 'example.com'
];
$result = UrlHelper::build_url($parts);
$this->assertEquals('http://example.com', $result);
}
public function testBuildUrlWithPathOnly(): void {
$parts = [
'scheme' => 'https',
'host' => 'example.com',
'path' => '/about'
];
$result = UrlHelper::build_url($parts);
$this->assertEquals('https://example.com/about', $result);
}
public function testBuildUrlWithNonStandardPort(): void {
$parts = [
'scheme' => 'http',
'host' => 'example.org',
'port' => 8080
];
$result = UrlHelper::build_url($parts);
$this->assertEquals('http://example.org:8080', $result);
}
public function testBuildUrlWithNonStandardPortAndPath(): void {
$parts = [
'scheme' => 'http',
'host' => 'example.org',
'port' => 8080,
'path' => '/test.jpg'
];
$result = UrlHelper::build_url($parts);
$this->assertEquals('http://example.org:8080/test.jpg', $result);
}
public function testBuildUrlWithNonStandardPortAndAllParts(): void {
$parts = [
'scheme' => 'https',
'host' => 'example.org',
'port' => 8443,
'path' => '/api/endpoint',
'query' => 'key=value',
'fragment' => 'section'
];
$result = UrlHelper::build_url($parts);
$this->assertEquals('https://example.org:8443/api/endpoint?key=value#section', $result);
}
public function testBuildUrlWithStandardHttpPort(): void {
$parts = [
'scheme' => 'http',
'host' => 'example.com',
'port' => 80
];
$result = UrlHelper::build_url($parts);
$this->assertEquals('http://example.com:80', $result);
}
public function testBuildUrlWithStandardHttpsPort(): void {
$parts = [
'scheme' => 'https',
'host' => 'example.com',
'port' => 443
];
$result = UrlHelper::build_url($parts);
$this->assertEquals('https://example.com:443', $result);
}
public function testBuildUrlWithPortAsString(): void {
$parts = [
'scheme' => 'http',
'host' => 'example.org',
'port' => '8080'
];
$result = UrlHelper::build_url($parts);
$this->assertEquals('http://example.org:8080', $result);
}
// ===== rewrite_relative() - Absolute URLs =====
public function testRewriteRelativeWithAbsoluteUrl(): void {
$base = 'https://example.com/page';
$rel = 'https://other.com/resource';
$result = UrlHelper::rewrite_relative($base, $rel);
$this->assertEquals('https://other.com/resource', $result);
}
public function testRewriteRelativeWithEmptyRelativeUrl(): void {
$base = 'https://example.com/page';
$rel = '';
$result = UrlHelper::rewrite_relative($base, $rel);
$this->assertEquals($base, $result);
}
// ===== rewrite_relative() - Protocol-Relative URLs =====
public function testRewriteRelativeWithProtocolRelativeUrl(): void {
$base = 'https://example.com/page';
$rel = '//cdn.example.com/resource.js';
$result = UrlHelper::rewrite_relative($base, $rel);
$this->assertEquals('https://cdn.example.com/resource.js', $result);
}
// ===== rewrite_relative() - Relative Paths =====
public function testRewriteRelativeWithRelativePath(): void {
$base = 'https://example.com/blog/post';
$rel = 'image.jpg';
$result = UrlHelper::rewrite_relative($base, $rel);
$this->assertEquals('https://example.com/blog/image.jpg', $result);
}
public function testRewriteRelativeWithAbsolutePath(): void {
$base = 'https://example.com/blog/post';
$rel = '/assets/style.css';
$result = UrlHelper::rewrite_relative($base, $rel);
$this->assertEquals('https://example.com/assets/style.css', $result);
}
public function testRewriteRelativeWithDotSlashPath(): void {
$base = 'https://example.com/blog/post';
$rel = './image.png';
$result = UrlHelper::rewrite_relative($base, $rel);
$this->assertEquals('https://example.com/blog/image.png', $result);
}
public function testRewriteRelativeWithBaseUrlEndingInSlash(): void {
$base = 'https://example.com/blog/';
$rel = 'image.jpg';
$result = UrlHelper::rewrite_relative($base, $rel);
$this->assertEquals('https://example.com/blog/image.jpg', $result);
}
// ===== rewrite_relative() - Port Preservation =====
public function testRewriteRelativePreservesPortFromBaseUrl(): void {
$base = 'http://example.org:8080/blog/post';
$rel = 'image.jpg';
$result = UrlHelper::rewrite_relative($base, $rel);
$this->assertEquals('http://example.org:8080/blog/image.jpg', $result);
}
public function testRewriteRelativePreservesPortWithAbsolutePath(): void {
$base = 'http://example.org:8080/blog/post';
$rel = '/assets/image.jpg';
$result = UrlHelper::rewrite_relative($base, $rel);
$this->assertEquals('http://example.org:8080/assets/image.jpg', $result);
}
public function testRewriteRelativePreservesPortWithDotSlashPath(): void {
$base = 'http://example.org:8080/blog/post';
$rel = './image.jpg';
$result = UrlHelper::rewrite_relative($base, $rel);
$this->assertEquals('http://example.org:8080/blog/image.jpg', $result);
}
public function testRewriteRelativePreservesPortWithQueryString(): void {
$base = 'http://example.org:8080/blog/post';
$rel = 'api/data?key=value';
$result = UrlHelper::rewrite_relative($base, $rel);
$this->assertEquals('http://example.org:8080/blog/api/data?key=value', $result);
}
public function testRewriteRelativePreservesNonStandardHttpsPort(): void {
$base = 'https://example.org:8443/feed.xml';
$rel = 'article.html';
$result = UrlHelper::rewrite_relative($base, $rel);
$this->assertEquals('https://example.org:8443/article.html', $result);
}
public function testRewriteRelativeWithBaseUrlPortButAbsoluteRelUrl(): void {
$base = 'http://example.org:8080/blog/post';
$rel = 'https://other.com/resource';
$result = UrlHelper::rewrite_relative($base, $rel);
// Absolute URLs should not inherit port from base
$this->assertEquals('https://other.com/resource', $result);
}
public function testRewriteRelativeWithBaseUrlPortAndProtocolRelativeUrl(): void {
$base = 'http://example.org:8080/blog/post';
$rel = '//cdn.example.com/resource.js';
$result = UrlHelper::rewrite_relative($base, $rel);
// Protocol-relative URLs should not inherit port from base
$this->assertEquals('https://cdn.example.com/resource.js', $result);
}
// ===== rewrite_relative() - Special Schemes =====
public function testRewriteRelativeAllowsMagnetForAnchorHref(): void {
$base = 'https://example.com/page';
$rel = 'magnet:?xt=urn:btih:123';
$result = UrlHelper::rewrite_relative($base, $rel, 'a', 'href');
$this->assertEquals('magnet:?xt=urn:btih:123', $result);
}
public function testRewriteRelativeAllowsMailtoForAnchorHref(): void {
$base = 'https://example.com/page';
$rel = 'mailto:test@example.com';
$result = UrlHelper::rewrite_relative($base, $rel, 'a', 'href');
$this->assertEquals('mailto:test@example.com', $result);
}
public function testRewriteRelativeAllowsTelForAnchorHref(): void {
$base = 'https://example.com/page';
$rel = 'tel:+1234567890';
$result = UrlHelper::rewrite_relative($base, $rel, 'a', 'href');
$this->assertEquals('tel:+1234567890', $result);
}
public function testRewriteRelativeRejectsMagnetForNonAnchor(): void {
$base = 'https://example.com/page';
$rel = 'magnet:?xt=urn:btih:123';
$result = UrlHelper::rewrite_relative($base, $rel, 'img', 'src');
// Should rewrite as relative path since not allowed for img
$this->assertNotEquals('magnet:?xt=urn:btih:123', $result);
}
// ===== rewrite_relative() - Data URLs for Images =====
public function testRewriteRelativeAllowsDataUrlForImgSrc(): void {
$base = 'https://example.com/page';
$rel = 'data:image/png;base64,iVBORw0KGgo=';
$result = UrlHelper::rewrite_relative($base, $rel, 'img', 'src');
$this->assertEquals('data:image/png;base64,iVBORw0KGgo=', $result);
}
public function testRewriteRelativeAllowsDataUrlWithWebp(): void {
$base = 'https://example.com/page';
$rel = 'data:image/webp;base64,UklGRiQAAABXRUJQ';
$result = UrlHelper::rewrite_relative($base, $rel, 'img', 'src');
$this->assertEquals('data:image/webp;base64,UklGRiQAAABXRUJQ', $result);
}
public function testRewriteRelativeAllowsDataUrlWithSvg(): void {
$base = 'https://example.com/page';
$rel = 'data:image/svg;base64,PHN2ZyB4bWxucz0iaHR0cDov';
$result = UrlHelper::rewrite_relative($base, $rel, 'img', 'src');
$this->assertEquals('data:image/svg;base64,PHN2ZyB4bWxucz0iaHR0cDov', $result);
}
public function testRewriteRelativeRejectsDataUrlForNonImage(): void {
$base = 'https://example.com/page';
$rel = 'data:text/html;base64,PHRpdGxlPg==';
$result = UrlHelper::rewrite_relative($base, $rel, 'img', 'src');
// Regex only checks for image/* at start, so non-image MIME types get rewritten as relative paths
$this->assertStringContainsString('text/html', $result);
$this->assertNotEquals($rel, $result); // Should not return as-is
}
public function testRewriteRelativeRejectsDataUrlForNonImgElement(): void {
$base = 'https://example.com/page';
$rel = 'data:image/png;base64,iVBORw0KGgo=';
$result = UrlHelper::rewrite_relative($base, $rel, 'a', 'href');
// Data URLs for non-img elements get rewritten as relative paths
$this->assertStringContainsString('image/png', $result);
$this->assertNotEquals($rel, $result); // Should not return as-is
}
// ===== rewrite_relative() - Content Type-Based Schemes =====
public function testRewriteRelativeAllowsMagnetForBittorrentContentType(): void {
$base = 'https://example.com/page';
$rel = 'magnet:?xt=urn:btih:456';
$result = UrlHelper::rewrite_relative($base, $rel, '', '', 'application/x-bittorrent');
$this->assertEquals('magnet:?xt=urn:btih:456', $result);
}
// ===== rewrite_relative() - Invalid URLs =====
public function testRewriteRelativeWithInvalidCharactersInUrl(): void {
$base = 'https://example.com/page';
$rel = 'ht!tp://invalid url with spaces';
$result = UrlHelper::rewrite_relative($base, $rel);
// Invalid URLs without scheme/host are treated as relative paths
$this->assertStringContainsString('example.com', $result);
$this->assertNotEquals($rel, $result);
}
// ===== validate() - Basic Validation =====
public function testValidateAcceptsValidHttpsUrl(): void {
$result = UrlHelper::validate('https://example.com/page');
$this->assertEquals('https://example.com/page', $result);
}
public function testValidateAcceptsValidHttpUrl(): void {
$result = UrlHelper::validate('http://example.com/page');
$this->assertEquals('http://example.com/page', $result);
}
public function testValidateFixesProtocolRelativeUrl(): void {
$result = UrlHelper::validate('//example.com/resource');
$this->assertEquals('https://example.com/resource', $result);
}
public function testValidateRejectsUrlWithoutHost(): void {
$result = UrlHelper::validate('http:///path');
$this->assertFalse($result);
}
public function testValidateRejectsMissingScheme(): void {
$result = UrlHelper::validate('example.com/page');
$this->assertFalse($result);
}
public function testValidateRejectsInvalidScheme(): void {
$result = UrlHelper::validate('ftp://example.com/file');
$this->assertFalse($result);
}
public function testValidateCleansUrl(): void {
$result = UrlHelper::validate(' https://example.com/page ');
// clean() should strip tags and trim
$this->assertEquals('https://example.com/page', $result);
}
// ===== has_disallowed_ip() Tests =====
public function testIsDisallowedIpDetectsLocalhost(): void {
$this->assertTrue(UrlHelper::has_disallowed_ip('http://localhost/path'));
$this->assertTrue(UrlHelper::has_disallowed_ip('https://localhost:8080/'));
$this->assertTrue(UrlHelper::has_disallowed_ip('http://LOCALHOST/'));
}
public function testIsDisallowedIpDetects127Loopback(): void {
$this->assertTrue(UrlHelper::has_disallowed_ip('http://127.0.0.1/'));
$this->assertTrue(UrlHelper::has_disallowed_ip('http://127.0.0.1:8080/path'));
$this->assertTrue(UrlHelper::has_disallowed_ip('http://127.1.2.3/'));
$this->assertTrue(UrlHelper::has_disallowed_ip('https://127.255.255.255/'));
}
public function testIsDisallowedIpDetectsIPv6Loopback(): void {
$this->assertTrue(UrlHelper::has_disallowed_ip('http://[::1]/'));
$this->assertTrue(UrlHelper::has_disallowed_ip('http://[::1]:8080/path'));
$this->assertTrue(UrlHelper::has_disallowed_ip('https://[::1]:443/'));
$this->assertTrue(UrlHelper::has_disallowed_ip('http://[0:0:0:0:0:0:0:1]/'));
$this->assertTrue(UrlHelper::has_disallowed_ip('https://[0:0:0:0:0:0:0:1]:443/'));
}
public function testIsDisallowedIpAllowsExternalHosts(): void {
$this->assertFalse(UrlHelper::has_disallowed_ip('http://example.com/'));
$this->assertFalse(UrlHelper::has_disallowed_ip('https://example.org:8080/'));
$this->assertFalse(UrlHelper::has_disallowed_ip('http://8.8.8.8/'));
}
public function testIsDisallowedIpReturnsFalseForInvalidUrl(): void {
$this->assertFalse(UrlHelper::has_disallowed_ip('not-a-url'));
$this->assertFalse(UrlHelper::has_disallowed_ip(''));
}
// ===== has_disallowed_ip() - Link-Local / Cloud Metadata Tests =====
public function testIsDisallowedIpBlocksLinkLocalOnAllPorts(): void {
// 169.254.x.x is link-local (cloud metadata) - always blocked
$this->assertTrue(UrlHelper::has_disallowed_ip('http://169.254.169.254/'));
$this->assertTrue(UrlHelper::has_disallowed_ip('https://169.254.0.1/'));
$this->assertTrue(UrlHelper::has_disallowed_ip('http://169.254.169.254:80/'));
$this->assertTrue(UrlHelper::has_disallowed_ip('http://169.254.169.254:8080/'));
}
// ===== has_disallowed_ip() - RFC 1918 Private IP Tests =====
public function testIsDisallowedIpAllowsPrivateIP10OnStandardPorts(): void {
$this->assertFalse(UrlHelper::has_disallowed_ip('http://10.0.0.1/'));
$this->assertFalse(UrlHelper::has_disallowed_ip('http://10.255.255.255/path'));
$this->assertFalse(UrlHelper::has_disallowed_ip('https://10.0.0.1/'));
$this->assertFalse(UrlHelper::has_disallowed_ip('https://10.0.0.1:443/'));
$this->assertFalse(UrlHelper::has_disallowed_ip('http://10.0.0.1:80/'));
}
public function testIsDisallowedIpBlocksPrivateIP10OnNonStandardPorts(): void {
$this->assertTrue(UrlHelper::has_disallowed_ip('http://10.0.0.1:8080/'));
$this->assertTrue(UrlHelper::has_disallowed_ip('https://10.0.0.1:8443/'));
$this->assertTrue(UrlHelper::has_disallowed_ip('http://10.0.0.1:6379/'));
$this->assertTrue(UrlHelper::has_disallowed_ip('http://10.255.255.255:3000/'));
}
public function testIsDisallowedIpAllowsPrivateIP192OnStandardPorts(): void {
$this->assertFalse(UrlHelper::has_disallowed_ip('http://192.168.0.1/'));
$this->assertFalse(UrlHelper::has_disallowed_ip('https://192.168.255.255/'));
$this->assertFalse(UrlHelper::has_disallowed_ip('https://192.168.1.100:443/'));
$this->assertFalse(UrlHelper::has_disallowed_ip('http://192.168.1.1:80/'));
}
public function testIsDisallowedIpBlocksPrivateIP192OnNonStandardPorts(): void {
$this->assertTrue(UrlHelper::has_disallowed_ip('http://192.168.1.1:8080/'));
$this->assertTrue(UrlHelper::has_disallowed_ip('https://192.168.1.1:8443/'));
$this->assertTrue(UrlHelper::has_disallowed_ip('http://192.168.0.1:9200/'));
}
public function testIsDisallowedIpAllowsPrivateIP172OnStandardPorts(): void {
$this->assertFalse(UrlHelper::has_disallowed_ip('http://172.16.0.1/'));
$this->assertFalse(UrlHelper::has_disallowed_ip('https://172.31.255.255/'));
$this->assertFalse(UrlHelper::has_disallowed_ip('http://172.20.10.5:80/'));
$this->assertFalse(UrlHelper::has_disallowed_ip('https://172.16.0.1:443/'));
}
public function testIsDisallowedIpBlocksPrivateIP172OnNonStandardPorts(): void {
$this->assertTrue(UrlHelper::has_disallowed_ip('http://172.16.0.1:8080/'));
$this->assertTrue(UrlHelper::has_disallowed_ip('https://172.31.0.1:8443/'));
$this->assertTrue(UrlHelper::has_disallowed_ip('http://172.20.10.5:5432/'));
}
public function testIsDisallowedIpAllowsNonPrivateIP172(): void {
// 172.15.x.x and 172.32.x.x are NOT in private range (172.16.0.0/12)
$this->assertFalse(UrlHelper::has_disallowed_ip('http://172.15.0.1/'));
$this->assertFalse(UrlHelper::has_disallowed_ip('http://172.32.0.1/'));
$this->assertFalse(UrlHelper::has_disallowed_ip('http://172.15.0.1:8080/'));
$this->assertFalse(UrlHelper::has_disallowed_ip('http://172.32.0.1:9999/'));
}
// ===== Edge Cases =====
public function testRewriteRelativeWithBaseUrlWithoutPath(): void {
$base = 'https://example.com';
$rel = 'page.html';
$result = UrlHelper::rewrite_relative($base, $rel);
// dirname('') returns '.', with_trailing_slash adds '/', so becomes '/page.html'
$this->assertEquals('https://example.com/page.html', $result);
}
public function testRewriteRelativePreservesQueryAndFragment(): void {
$base = 'https://example.com/blog/post';
$rel = 'image.jpg?size=large#top';
$result = UrlHelper::rewrite_relative($base, $rel);
// Query and fragment should be preserved during URL parsing
$this->assertStringContainsString('image.jpg', $result);
}
// ===== url_to_youtube_vid() Tests =====
public function testUrlToYoutubeVidExtractsFromStandardUrl(): void {
$url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
$result = UrlHelper::url_to_youtube_vid($url);
$this->assertEquals('dQw4w9WgXcQ', $result);
}
public function testUrlToYoutubeVidExtractsFromEmbedUrl(): void {
$url = 'https://www.youtube.com/embed/dQw4w9WgXcQ';
$result = UrlHelper::url_to_youtube_vid($url);
$this->assertEquals('dQw4w9WgXcQ', $result);
}
public function testUrlToYoutubeVidExtractsFromVUrl(): void {
$url = 'https://www.youtube.com/v/dQw4w9WgXcQ';
$result = UrlHelper::url_to_youtube_vid($url);
$this->assertEquals('dQw4w9WgXcQ', $result);
}
public function testUrlToYoutubeVidExtractsFromYoutuBeShortUrl(): void {
$url = 'https://youtu.be/dQw4w9WgXcQ';
$result = UrlHelper::url_to_youtube_vid($url);
$this->assertEquals('dQw4w9WgXcQ', $result);
}
public function testUrlToYoutubeVidRewritesToNoCookieDomain(): void {
$url = 'https://www.youtube.com/watch?v=test123';
// Method rewrites youtube.com to youtube-nocookie.com internally
$result = UrlHelper::url_to_youtube_vid($url);
$this->assertEquals('test123', $result);
}
public function testUrlToYoutubeVidHandlesVideoIdWithHyphensAndUnderscores(): void {
$url = 'https://www.youtube.com/watch?v=abc-DEF_123';
$result = UrlHelper::url_to_youtube_vid($url);
$this->assertEquals('abc-DEF_123', $result);
}
public function testUrlToYoutubeVidReturnsFalseForNonYoutubeUrl(): void {
$url = 'https://vimeo.com/123456';
$result = UrlHelper::url_to_youtube_vid($url);
$this->assertFalse($result);
}
public function testUrlToYoutubeVidReturnsFalseForMalformedYoutubeUrl(): void {
$url = 'https://www.youtube.com/notavalidpath';
$result = UrlHelper::url_to_youtube_vid($url);
$this->assertFalse($result);
}
public function testUrlToYoutubeVidHandlesWatchUrlWithAdditionalParams(): void {
$url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ&feature=share&t=10';
// Regex extracts video ID from query string
$result = UrlHelper::url_to_youtube_vid($url);
$this->assertEquals('dQw4w9WgXcQ', $result);
}
public function testUrlToYoutubeVidReturnsFalseForEmptyVideoId(): void {
$url = 'https://www.youtube.com/watch?v=';
$result = UrlHelper::url_to_youtube_vid($url);
$this->assertFalse($result);
}
public function testUrlToYoutubeVidHandlesHttpProtocol(): void {
$url = 'http://www.youtube.com/watch?v=test456';
$result = UrlHelper::url_to_youtube_vid($url);
$this->assertEquals('test456', $result);
}
public function testUrlToYoutubeVidHandlesYoutuBeWithExtraPath(): void {
$url = 'https://youtu.be/dQw4w9WgXcQ?t=42';
// Regex should still extract video ID even with query params
$result = UrlHelper::url_to_youtube_vid($url);
$this->assertEquals('dQw4w9WgXcQ', $result);
}
}