OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
<?php
namespace Tests\Unit\app\Core\Support;
use LasseRafn\InitialAvatarGenerator\InitialAvatar;
use LasseRafn\Initials\Initials;
use Leantime\Core\Support\Avatarcreator;
use Leantime\Core\UI\Theme;
use SVG\SVG;
use Unit\TestCase;
class AvatarcreatorTest extends TestCase
{
private $avatarGenerator;
private $initials;
private $theme;
private $avatarCreator;
protected function setUp(): void
{
parent::setUp();
$this->avatarGenerator = $this->createMock(InitialAvatar::class);
$this->avatarGenerator->method('background')->willReturn($this->avatarGenerator);
$this->avatarGenerator->method('font')->willReturn($this->avatarGenerator);
$this->avatarGenerator->method('color')->willReturn($this->avatarGenerator);
$this->avatarGenerator->method('generateSvg')->willReturn(SVG::fromString('<svg></svg>'));
$this->initials = $this->createMock(Initials::class);
$this->theme = $this->createMock(Theme::class);
$this->avatarCreator = new Avatarcreator(
$this->avatarGenerator,
$this->initials,
$this->theme
);
}
public function test_set_background_color()
{
$this->avatarGenerator->expects($this->once())
->method('background')
->with('#ffffff');
$this->avatarCreator->setBackground('#ffffff');
}
public function test_set_file_prefix()
{
$this->avatarCreator->setFilePrefix('test-prefix');
$this->assertEquals('test-prefix', $this->avatarCreator->getFilePrefix());
}
public function test_set_initials_with_valid_name()
{
$this->initials->expects($this->once())
->method('name')
->with('john-doe');
$this->avatarGenerator->expects($this->once())
->method('name')
->with('john-doe');
$this->avatarCreator->setInitials('John Doe');
}
public function test_set_initials_with_empty_name()
{
$this->initials->expects($this->once())
->method('name')
->with('👻');
$this->avatarCreator->setInitials('');
}
public function test_get_initials()
{
$this->initials->expects($this->once())
->method('getInitials')
->willReturn('JD');
$this->assertEquals('JD', $this->avatarCreator->getInitials());
}
public function test_get_avatar_with_cache_hit()
{
$this->initials->method('getInitials')->willReturn('JD');
// Create test file
$cacheDir = storage_path('framework/cache/avatars');
if (! is_dir($cacheDir)) {
mkdir($cacheDir, 0777, true);
}
$testFile = $cacheDir.'/user-jd.svg';
file_put_contents($testFile, '<svg>test</svg>');
$result = $this->avatarCreator->getAvatar('John Doe');
$this->assertEquals(SVG::fromString('<svg>test</svg>'), $result);
unlink($testFile);
}
public function test_get_avatar_with_cache_miss()
{
$this->initials->method('getInitials')->willReturn('JD');
$this->avatarGenerator->method('generateSvg')
->willReturn(SVG::fromString('<svg></svg>'));
$result = $this->avatarCreator->getAvatar('John Doe');
$cacheDir = storage_path('framework/cache/avatars');
$testFile = $cacheDir.'/user-jd.svg';
$this->assertFileExists($testFile);
}
public function test_get_avatar_with_special_characters()
{
$this->initials->method('getInitials')->willReturn('JD');
$this->avatarGenerator->method('generateSvg')
->willReturn(SVG::fromString('<svg></svg>'));
$result = $this->avatarCreator->getAvatar('John@Doe#$%');
$cacheDir = storage_path('framework/cache/avatars');
$testFile = $cacheDir.'/user-jd.svg';
$this->assertFileExists($testFile);
}
public function test_get_avatar_with_non_latin_characters()
{
$this->initials->method('getInitials')->willReturn('李王');
$this->avatarGenerator->method('generateSvg')
->willReturn(SVG::fromString('<svg></svg>'));
$result = $this->avatarCreator->getAvatar('李王');
$cacheDir = storage_path('framework/cache/avatars');
$testFile = $cacheDir.'/user-李王.svg';
$this->assertFileExists($testFile);
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace Tests\Unit\App\Core\Support;
use Carbon\CarbonImmutable;
use Leantime\Core\Language;
use Leantime\Core\Support\CarbonMacros;
use Unit\TestCase;
class CarbonMacrosTest extends TestCase
{
private CarbonMacros $carbonMacros;
private Language $languageMock;
protected function setUp(): void
{
parent::setUp();
$this->languageMock = $this->createMock(Language::class);
$this->languageMock->method('__')
->willReturnCallback(function ($key) {
return match ($key) {
'language.dateformat' => 'm/d/Y',
'language.timeformat' => 'h:i A',
'language.dayNamesShort' => 'zo,ma,di,wo,do,vr,za',
'language.dayNamesMin' => 'zo,ma,di,wo,do,vr,za',
'language.monthNamesShort' => 'jan,feb,mrt,apr,mei,jun,jul,aug,sep,okt,nov,dec',
default => $key
};
});
app()->instance(Language::class, $this->languageMock);
// Initialize with test values
$this->carbonMacros = new CarbonMacros(
userTimezone: 'America/Los_Angeles',
userLanguage: 'en_US',
userDateFormat: 'm/d/Y',
userTimeFormat: 'h:i A',
dbFormat: 'Y-m-d H:i:s',
dbTimezone: 'UTC'
);
// Mix in the macros to CarbonImmutable
CarbonImmutable::mixin($this->carbonMacros);
}
public function test_format_date_for_user(): void
{
$date = CarbonImmutable::create(2023, 12, 25, 14, 30, 0, 'UTC');
$formatted = $date->formatDateForUser();
// Should be formatted according to user's timezone (PST) and format (m/d/Y)
$this->assertEquals('12/25/2023', $formatted);
}
public function test_format_time_for_user(): void
{
$date = CarbonImmutable::create(2023, 12, 25, 14, 30, 0, 'UTC');
$formatted = $date->formatTimeForUser();
// UTC 14:30 is 06:30 AM in PST
$this->assertEquals('06:30 AM', $formatted);
}
public function test_format_24h_time_for_user(): void
{
$date = CarbonImmutable::create(2023, 12, 25, 14, 30, 0, 'UTC');
$formatted = $date->format24HTimeForUser();
// UTC 14:30 is 06:30 in PST
$this->assertEquals('06:30', $formatted);
}
public function test_format_date_time_for_db(): void
{
// Create a date in user's timezone
$date = CarbonImmutable::create(2023, 12, 25, 6, 30, 0, 'America/Los_Angeles');
$formatted = $date->formatDateTimeForDb();
// Should be converted to UTC and formatted as Y-m-d H:i:s
$this->assertEquals('2023-12-25 14:30:00', $formatted);
}
public function test_set_to_user_timezone(): void
{
$date = CarbonImmutable::create(2023, 12, 25, 14, 30, 0, 'UTC');
$converted = $date->setToUserTimezone();
$this->assertEquals('America/Los_Angeles', $converted->timezone->getName());
$this->assertEquals('06:30', $converted->format('H:i'));
}
public function test_set_to_db_timezone(): void
{
$date = CarbonImmutable::create(2023, 12, 25, 6, 30, 0, 'America/Los_Angeles');
$converted = $date->setToDbTimezone();
$this->assertEquals('UTC', $converted->timezone->getName());
$this->assertEquals('14:30', $converted->format('H:i'));
}
public function test_dutch_language_support(): void
{
$macros = new CarbonMacros(
userTimezone: 'Europe/Amsterdam',
userLanguage: 'nl_NL',
userDateFormat: 'd-m-Y',
userTimeFormat: 'H:i',
dbFormat: 'Y-m-d H:i:s',
dbTimezone: 'UTC'
);
CarbonImmutable::mixin($macros);
$date = CarbonImmutable::create(2023, 12, 25, 14, 30, 0, 'UTC');
$formatted = $date->formatDateForUser();
$this->assertEquals('25-12-2023', $formatted);
}
}

View File

@@ -0,0 +1,290 @@
<?php
namespace Tests\Unit\App\Core\Support;
use Carbon\CarbonImmutable;
use Carbon\Exceptions\InvalidDateException;
use Carbon\Exceptions\InvalidFormatException;
use Leantime\Core\Configuration\Environment;
use Leantime\Core\Language;
use Leantime\Core\Support\CarbonMacros;
use Leantime\Core\Support\DateTimeHelper;
use Unit\TestCase;
class DateTimeHelperTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
private DateTimeHelper $dateTimeHelper;
private Environment $environmentMock;
private Language $languageMock;
protected function setUp(): void
{
parent::setUp();
// Mock the Environment class
$this->environmentMock = $this->make(Environment::class, [
'defaultTimezone' => 'UTC',
'language' => 'en-US',
]);
app()->instance(Environment::class, $this->environmentMock);
$this->languageMock = $this->createMock(Language::class);
$this->languageMock->method('__')->willReturnCallback(function ($index) {
$map = [
'language.dateformat' => 'm/d/Y',
'language.timeformat' => 'h:i A',
];
return $map[$index] ?? null;
});
app()->instance(\Leantime\Core\Language::class, $this->languageMock);
// Register mocks with the application container
//
// app()->instance(Language::class, $this->languageMock);
// America Los_Angeles is UTC - 8 so all db times need to come back from UTC - 8 hours
CarbonImmutable::mixin(new CarbonMacros(
'America/Los_Angeles',
'en-US',
'm/d/Y',
'h:i A'
));
// Create the DateTimeHelper instance
$this->dateTimeHelper = new DateTimeHelper;
}
public function test_parse_iso8601_with_timezone_offset_midnight(): void
{
// Test ISO 8601 with timezone offset (2025-04-16T00:00:00-04:00)
$dateString = '2025-04-16T00:00:00-04:00';
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
$this->assertEquals('00', $parsedDate->format('H'));
$this->assertEquals('00', $parsedDate->format('i'));
$this->assertEquals('00', $parsedDate->format('s'));
$this->assertEquals('-04:00', $parsedDate->format('P'));
}
public function test_parse_iso8601_with_timezone_offset(): void
{
// Test ISO 8601 with timezone offset (2025-04-16T23:59:59-04:00)
$dateString = '2025-04-16T23:59:59-04:00';
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
$this->assertEquals('23', $parsedDate->format('H'));
$this->assertEquals('59', $parsedDate->format('i'));
$this->assertEquals('59', $parsedDate->format('s'));
$this->assertEquals('-04:00', $parsedDate->format('P'));
}
public function test_parse_iso8601_with_timezone_offset_hhmm(): void
{
// Test ISO 8601 with timezone offset (2025-04-16T23:59:59-0400)
$dateString = '2025-04-16T23:59:59-0400';
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
$this->assertEquals('23', $parsedDate->format('H'));
$this->assertEquals('59', $parsedDate->format('i'));
$this->assertEquals('59', $parsedDate->format('s'));
$this->assertEquals('-04:00', $parsedDate->format('P'));
}
public function test_parse_iso8601_with_timezone_offset_hh(): void
{
// Test ISO 8601 with timezone offset (2025-04-16T23:59:59-04)
$dateString = '2025-04-16T23:59:59-04';
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
$this->assertEquals('23', $parsedDate->format('H'));
$this->assertEquals('59', $parsedDate->format('i'));
$this->assertEquals('59', $parsedDate->format('s'));
$this->assertEquals('-04:00', $parsedDate->format('P'));
}
public function test_parse_iso8601_with_zulu_time(): void
{
// Test ISO 8601 with Z/Zulu time (2025-04-16T23:59:59Z)
$dateString = '2025-04-16T23:59:59Z';
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
$this->assertEquals('23', $parsedDate->format('H'));
$this->assertEquals('59', $parsedDate->format('i'));
$this->assertEquals('59', $parsedDate->format('s'));
// Z time should be parsed as UTC
$this->assertEquals('+00:00', $parsedDate->format('P'));
}
public function test_parse_iso8601_without_timezone(): void
{
// Test ISO 8601 without timezone (2025-04-16T23:59:59)
$dateString = '2025-04-16T23:59:59';
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
$this->assertEquals('23', $parsedDate->format('H'));
$this->assertEquals('59', $parsedDate->format('i'));
$this->assertEquals('59', $parsedDate->format('s'));
}
public function test_parse_user_date_format(): void
{
// Test parsing date in user format (m/d/Y)
$dateString = '04/16/2025';
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
}
public function test_parse_user_date_and_time_format(): void
{
// Test parsing date and time in user format (m/d/Y h:i A)
$dateString = '04/16/2025';
$timeString = '11:59 PM';
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString, $timeString);
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
$this->assertEquals('23', $parsedDate->format('H'));
$this->assertEquals('59', $parsedDate->format('i'));
}
public function test_parse_user_date_with_start_of_day(): void
{
// Test parsing date with start of day
$dateString = '04/16/2025';
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString, 'start');
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
$this->assertEquals('00', $parsedDate->format('H'));
$this->assertEquals('00', $parsedDate->format('i'));
$this->assertEquals('00', $parsedDate->format('s'));
}
public function test_parse_user_date_with_end_of_day(): void
{
// Test parsing date with end of day
$dateString = '04/16/2025';
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString, 'end');
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
$this->assertEquals('23', $parsedDate->format('H'));
$this->assertEquals('59', $parsedDate->format('i'));
$this->assertEquals('59', $parsedDate->format('s'));
}
public function test_invalid_date_string(): void
{
// Test with invalid date string
$this->expectException(InvalidFormatException::class);
$this->dateTimeHelper->parseUserDateTime('not-a-date');
}
public function test_empty_date_string(): void
{
// Test with empty date string
$this->expectException(InvalidDateException::class);
$this->dateTimeHelper->parseUserDateTime('');
}
public function test_parse_db_date_time(): void
{
// Test parsing DB date time
$dbDate = '2025-04-16 23:59:59';
$parsedDate = $this->dateTimeHelper->parseDbDateTime($dbDate);
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
$this->assertEquals('23', $parsedDate->format('H'));
$this->assertEquals('59', $parsedDate->format('i'));
$this->assertEquals('59', $parsedDate->format('s'));
}
public function test_parse_user_24h_time(): void
{
// Test parsing 24h time
$timeString = '23:59';
$parsedTime = $this->dateTimeHelper->parseUser24hTime($timeString);
$this->assertInstanceOf(CarbonImmutable::class, $parsedTime);
$this->assertEquals('23', $parsedTime->format('H'));
$this->assertEquals('59', $parsedTime->format('i'));
}
public function test_user_now(): void
{
// Test user now returns current time
$now = $this->dateTimeHelper->userNow();
$this->assertInstanceOf(CarbonImmutable::class, $now);
// Should be within a few seconds of now
$this->assertLessThan(5, abs(time() - $now->timestamp));
}
public function test_db_now(): void
{
// Test db now returns current time in UTC
$now = $this->dateTimeHelper->dbNow();
$this->assertInstanceOf(CarbonImmutable::class, $now);
// Should be within a few seconds of now
$this->assertLessThan(5, abs(time() - $now->timestamp));
// Should be in UTC timezone
$this->assertEquals('UTC', $now->timezone->getName());
}
public function test_is_valid_date_string(): void
{
// Test valid date strings
$this->assertTrue($this->dateTimeHelper->isValidDateString('2025-04-16 23:59:59'));
$this->assertTrue($this->dateTimeHelper->isValidDateString('2025-04-16T23:59:59-04:00'));
// Test invalid date strings
$this->assertFalse($this->dateTimeHelper->isValidDateString(''));
$this->assertFalse($this->dateTimeHelper->isValidDateString(null));
$this->assertFalse($this->dateTimeHelper->isValidDateString('1969-12-31 00:00:00'));
$this->assertFalse($this->dateTimeHelper->isValidDateString('0000-00-00 00:00:00'));
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Unit\app\Core\Support;
use Carbon\CarbonImmutable;
use Leantime\Core\Support\CarbonMacros;
use Leantime\Core\Support\Format;
use Tests\DateTimeHelper;
use Tests\Language;
use Tests\MockObject;
use Unit\TestCase;
class FormatTest extends TestCase
{
/**
* @var DateTimeHelper|MockObject
*/
private $carbonMacrosMock;
/**
* @var Language|MockObject
*/
private $languageMock;
protected function setUp(): void
{
parent::setUp();
$this->languageMock = $this->createMock(\Leantime\Core\Language::class);
app()->instance(\Leantime\Core\Support\CarbonMacros::class, $this->carbonMacrosMock);
app()->instance(\Leantime\Core\Language::class, $this->languageMock);
// America Los_Angeles is UTC - 8 so all db times need to come back from UTC - 8 hours
CarbonImmutable::mixin(new CarbonMacros(
'America/Los_Angeles',
'en-US',
'm/d/Y',
'h:i A'
));
}
public function test_date(): void
{
$formattedDateString = '12/31/2021';
$dbDate = '2022-01-01 00:00:00';
$format = new Format($dbDate, '');
$this->assertSame($formattedDateString, $format->date());
}
public function test_time(): void
{
$formattedTimeString = '04:00 PM';
$dbDate = '2022-01-01 00:00:00';
$format = new Format($dbDate, '');
$this->assertSame($formattedTimeString, $format->time());
}
public function test_time24(): void
{
$formattedTimeString = '16:00';
$dbDate = '2022-01-01 00:00:00';
$format = new Format($dbDate, '');
$this->assertSame($formattedTimeString, $format->time24());
}
// Similarly you can add tests for other 'Format' class methods.
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Unit\app\Core\Support;
use Leantime\Core\Support\NameSanitizer;
use Unit\TestCase;
/**
* Regression tests for the invite-spam abuse fix: person names were stored and
* emailed raw, letting spammers use the firstname field as an email payload.
* The sanitizer must strip abuse vectors (contact numbers, URLs, emails, bidi
* tricks) while letting legitimate names in any script through unchanged.
*/
class NameSanitizerTest extends TestCase
{
public function test_legitimate_names_pass_unchanged(): void
{
$this->assertSame('Marcel', NameSanitizer::clean('Marcel'));
$this->assertSame('María José', NameSanitizer::clean('María José'));
$this->assertSame('汪小明', NameSanitizer::clean('汪小明'));
$this->assertSame('محمد علي', NameSanitizer::clean('محمد علي'));
$this->assertSame("O'Connor-Smith", NameSanitizer::clean("O'Connor-Smith"));
}
public function test_strips_contact_number_from_spam_payload(): void
{
// The actual payload from the 2026-07 abuse reports
$this->assertStringNotContainsString('992600898', NameSanitizer::clean('+汪汪992600898-ن颂58嗏،Virtual'));
}
public function test_strips_html(): void
{
$this->assertSame('alert(1)', NameSanitizer::clean('<script>alert(1)</script>'));
}
public function test_strips_urls_and_emails(): void
{
$this->assertSame('Buy cheap', NameSanitizer::clean('Buy http://spam.example.com cheap'));
$this->assertSame('Visit now', NameSanitizer::clean('Visit www.spam.example now'));
$this->assertSame('mail me', NameSanitizer::clean('mail spam@evil.example me'));
}
public function test_strips_control_and_bidi_characters(): void
{
$this->assertSame('JohnSmith', NameSanitizer::clean("John\u{202E}Smith"));
$this->assertSame('AB', NameSanitizer::clean("A\u{200B}\u{0000}B"));
}
public function test_caps_length_and_handles_non_strings(): void
{
$this->assertSame(50, mb_strlen(NameSanitizer::clean(str_repeat('A', 200))));
$this->assertSame('', NameSanitizer::clean(null));
$this->assertSame('', NameSanitizer::clean(12345));
$this->assertSame('', NameSanitizer::clean(['array']));
}
public function test_collapses_whitespace(): void
{
$this->assertSame('John Smith', NameSanitizer::clean(" John Smith \n"));
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace Unit\app\Core\Support;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Uri;
use Leantime\Core\Support\OutboundUrlGuard;
use Unit\TestCase;
/**
* Covers the SSRF guard's address classification and redirect re-validation using IP literals and
* direct calls, so nothing here depends on live DNS or the network.
*/
class OutboundUrlGuardTest extends TestCase
{
/**
* @dataProvider ipProvider
*/
public function test_is_ip_allowed(string $ip, bool $expected): void
{
$this->assertSame($expected, OutboundUrlGuard::isIpAllowed($ip));
}
public static function ipProvider(): array
{
return [
'loopback v4' => ['127.0.0.1', false],
'private 10/8' => ['10.1.2.3', false],
'private 172.16/12' => ['172.16.5.5', false],
'private 192.168/16' => ['192.168.1.1', false],
'cgnat 100.64/10' => ['100.64.0.1', false],
'link-local metadata' => ['169.254.169.254', false],
'reserved 0.0.0.0/8' => ['0.0.0.0', false],
'public v4 (google dns)' => ['8.8.8.8', true],
'public v4 (cloudflare)' => ['1.1.1.1', true],
'loopback v6' => ['::1', false],
'public v6 (cloudflare)' => ['2606:4700:4700::1111', true],
'ipv4-mapped loopback' => ['::ffff:127.0.0.1', false],
'ipv4-mapped cgnat' => ['::ffff:100.64.0.1', false],
'ipv4-mapped public' => ['::ffff:8.8.8.8', true],
];
}
/**
* @dataProvider urlProvider
*/
public function test_is_allowed_url(string $url, bool $expected): void
{
$this->assertSame($expected, OutboundUrlGuard::isAllowedUrl($url));
}
public static function urlProvider(): array
{
return [
'loopback literal' => ['http://127.0.0.1/feed.ics', false],
'cgnat literal' => ['http://100.64.0.1/', false],
'metadata literal' => ['http://169.254.169.254/latest/meta-data/', false],
'public literal' => ['https://8.8.8.8/', true],
'non-http scheme' => ['ftp://8.8.8.8/', false],
'file scheme' => ['file:///etc/passwd', false],
'garbage' => ['not-a-url', false],
];
}
public function test_redirect_options_block_disallowed_hop(): void
{
$onRedirect = OutboundUrlGuard::redirectOptions()['on_redirect'];
$this->expectException(\RuntimeException::class);
$onRedirect(new Request('GET', 'https://8.8.8.8/'), new Response(302), new Uri('http://169.254.169.254/'));
}
public function test_redirect_options_allow_public_hop(): void
{
$onRedirect = OutboundUrlGuard::redirectOptions()['on_redirect'];
// A public → public redirect must not throw.
$onRedirect(new Request('GET', 'https://8.8.8.8/'), new Response(302), new Uri('https://1.1.1.1/'));
$this->assertTrue(true);
}
}