Compare commits

...

31 Commits

Author SHA1 Message Date
Alejandro Celaya
bee9f2a9cc Merge pull request #2364 from shlinkio/develop
Release 4.4.3
2025-02-15 11:28:09 +01:00
Alejandro Celaya
63d943d59d Merge pull request #2363 from acelaya-forks/feature/find-url-perf
Fix unique_short_code_plus_domain index in Microsoft SQL
2025-02-15 11:24:26 +01:00
Alejandro Celaya
053e1f3073 Update changelog 2025-02-15 11:19:30 +01:00
Alejandro Celaya
f3da345bf3 Fix unique_short_code_plus_domain index in Microsoft SQL 2025-02-15 11:17:14 +01:00
Alejandro Celaya
745255736a Simplify query to find short URL when domain is null 2025-02-14 10:20:50 +01:00
Alejandro Celaya
8fd53afe3f Merge pull request #2361 from acelaya-forks/feature/lock-downgrade
Downgrade symfony/lock to v7.2.0 to work around redis issue
2025-02-14 08:52:33 +01:00
Alejandro Celaya
259635ea2a Downgrade symfony/lock to v7.2.0 to work around redis issue 2025-02-14 08:40:06 +01:00
Alejandro Celaya
a1f2e6dc5c Merge pull request #2359 from acelaya-forks/feature/multi-proxy-fix
Workaround for IP resolution from x-Forwarded-For with multiple proxies
2025-02-13 22:03:36 +01:00
Alejandro Celaya
81e07bf08d Merge pull request #2358 from acelaya-forks/feature/phpunit-12
Update to PHPUnit 12
2025-02-13 21:59:00 +01:00
Alejandro Celaya
c650a3e665 Workaround for IP resolution from x-Forwarded-For with multiple proxies 2025-02-13 21:52:38 +01:00
Alejandro Celaya
65c01034ff Update to PHPUnit 12 2025-02-13 10:35:58 +01:00
Alejandro Celaya
48f910aaaa Merge pull request #2355 from acelaya-forks/feature/openapi-warnings
Remove suppressed warnings when running openapi tools
2025-02-05 08:43:28 +01:00
Alejandro Celaya
e511e15a87 Remove suppressed warnings when running openapi tools 2025-02-05 08:39:22 +01:00
Alejandro Celaya
888dc84d3f Merge pull request #2348 from shlinkio/develop
Release 4.4.2
2025-01-29 12:08:51 +01:00
Alejandro Celaya
ed09bf90eb Tag v4.4.2 in changelog 2025-01-29 12:05:53 +01:00
Alejandro Celaya
0ddfcb75dd Merge pull request #2347 from acelaya-forks/feature/docker-arm
Get back docker image building for ARM architecture
2025-01-29 12:02:19 +01:00
Alejandro Celaya
193be55f0c Get back docker image building for ARM architecture 2025-01-29 11:59:42 +01:00
Alejandro Celaya
3ba7ad3839 Merge pull request #2345 from shlinkio/develop
Release 4.4.1 - fixes
2025-01-28 15:53:49 +01:00
Alejandro Celaya
7ffb64eee1 Do not build docker image for ARM 2025-01-28 15:51:20 +01:00
Alejandro Celaya
0a2cc554c6 Build docker image with buildx 0.19.2 2025-01-28 15:38:47 +01:00
Alejandro Celaya
7c2b918d5d Merge pull request #2344 from shlinkio/develop
Release 4.4.1
2025-01-28 10:15:24 +01:00
Alejandro Celaya
af783dea57 Add v4.4.1 to changelog 2025-01-28 10:12:15 +01:00
Alejandro Celaya
a68a17f6b4 Merge pull request #2343 from acelaya-forks/feature/defensive-title-encoding
Fix error when creating short URL for page with unsupported encoding
2025-01-28 10:11:04 +01:00
Alejandro Celaya
e9fe1ac5d4 Fix error when creating short URL for page with unsupported encoding 2025-01-28 10:04:30 +01:00
Alejandro Celaya
88e97f18ad Merge pull request #2342 from acelaya-forks/feature/too-many-connections
Close connections after every async job that uses the db
2025-01-27 15:48:22 +01:00
Alejandro Celaya
3372a2a9c8 Close connections after every async job that uses the db 2025-01-27 15:45:37 +01:00
Alejandro Celaya
f02a8c876c Merge pull request #2340 from acelaya-forks/feature/update-shlink-deps
Update shlink packages
2025-01-25 16:16:42 +01:00
Alejandro Celaya
1549509eb8 Update shlink packages 2025-01-25 16:13:40 +01:00
Alejandro Celaya
62fde5a8e2 Update changelog 2025-01-13 08:47:19 +01:00
Alejandro Celaya
221e061ea6 Merge pull request #2332 from MaZe3D/develop
Add ADDRESS environment vairable to define the listening interface.
2025-01-13 08:45:20 +01:00
Mark Orlando Zeller
9ad565f8c8 Add ADDRESS environment vairable to define the listening interface. 2025-01-10 22:10:51 +01:00
31 changed files with 456 additions and 154 deletions

View File

@@ -8,3 +8,5 @@ on:
jobs:
build-docker-image:
uses: shlinkio/github-actions/.github/workflows/docker-image-build-ci.yml@main
with:
platforms: 'linux/arm64/v8,linux/amd64'

View File

@@ -4,7 +4,67 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
# [4.4.0] - 2024-12-27
## [4.4.3] - 2025-02-15
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2351](https://github.com/shlinkio/shlink/issues/2351) Fix visitor IP address resolution when Shlink is served behind more than one reverse proxy.
This regression was introduced due to a change in behavior in `akrabat/rka-ip-address-middleware`, that now picks the first address from the right after excluding all trusted proxies.
Since Shlink does not set trusted proxies, this means the first IP from the right is now picked instead of the first from the left, so we now reverse the list before trying to resolve the IP.
In the future, Shlink will allow you to define trusted proxies, to avoid other potential side effects because of this reversing of the list.
* [#2354](https://github.com/shlinkio/shlink/issues/2354) Fix error "NOSCRIPT No matching script. Please use EVAL" thrown when creating a lock in redis.
* [#2319](https://github.com/shlinkio/shlink/issues/2319) Fix unique index for `short_code` and `domain_id` in `short_urls` table not being used in Microsoft SQL engines for rows where `domain_id` is `null`.
## [4.4.2] - 2025-01-29
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2346](https://github.com/shlinkio/shlink/issues/2346) Get back docker images for ARM architectures.
## [4.4.1] - 2025-01-28
### Added
* [#2331](https://github.com/shlinkio/shlink/issues/2331) Add `ADDRESS` env var which allows to customize the IP address to which RoadRunner binds, when using the official docker image.
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2341](https://github.com/shlinkio/shlink/issues/2341) Ensure all asynchronous jobs that interact with the database do not leave idle connections open.
* [#2334](https://github.com/shlinkio/shlink/issues/2334) Improve how page titles are encoded to UTF-8, falling back from mbstring to iconv if available, and ultimately using the original title in case of error, but never causing the short URL creation to fail.
## [4.4.0] - 2024-12-27
### Added
* [#2265](https://github.com/shlinkio/shlink/issues/2265) Add a new `REDIRECT_EXTRA_PATH_MODE` option that accepts three values:
@@ -40,7 +100,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* *Nothing*
# [4.3.1] - 2024-11-25
## [4.3.1] - 2024-11-25
### Added
* *Nothing*

View File

@@ -18,7 +18,7 @@
"ext-json": "*",
"ext-mbstring": "*",
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.5",
"akrabat/ip-address-middleware": "^2.6",
"cakephp/chronos": "^3.1",
"doctrine/dbal": "^4.2",
"doctrine/migrations": "^3.8",
@@ -43,12 +43,12 @@
"pagerfanta/core": "^3.8",
"ramsey/uuid": "^4.7",
"shlinkio/doctrine-specification": "^2.2",
"shlinkio/shlink-common": "^6.6",
"shlinkio/shlink-config": "^3.4",
"shlinkio/shlink-event-dispatcher": "^4.1",
"shlinkio/shlink-importer": "^5.5",
"shlinkio/shlink-installer": "^9.4",
"shlinkio/shlink-ip-geolocation": "^4.2",
"shlinkio/shlink-common": "^7.0",
"shlinkio/shlink-config": "^4.0",
"shlinkio/shlink-event-dispatcher": "^4.2",
"shlinkio/shlink-importer": "^5.6",
"shlinkio/shlink-installer": "^9.5",
"shlinkio/shlink-ip-geolocation": "^4.3",
"shlinkio/shlink-json": "^1.2",
"spiral/roadrunner": "^2024.3",
"spiral/roadrunner-cli": "^2.6",
@@ -56,23 +56,23 @@
"spiral/roadrunner-jobs": "^4.6",
"symfony/console": "^7.2",
"symfony/filesystem": "^7.2",
"symfony/lock": "^7.2",
"symfony/lock": "7.2.0",
"symfony/process": "^7.2",
"symfony/string": "^7.2"
},
"require-dev": {
"devizzent/cebe-php-openapi": "^1.1.2",
"devster/ubench": "^2.1",
"phpstan/phpstan": "^2.0",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-doctrine": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-symfony": "^2.0",
"phpunit/php-code-coverage": "^11.0",
"phpunit/phpcov": "^10.0",
"phpunit/phpunit": "^11.5",
"phpunit/php-code-coverage": "^12.0",
"phpunit/phpcov": "^11.0",
"phpunit/phpunit": "^12.0",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.4.0",
"shlinkio/shlink-test-utils": "^4.2",
"shlinkio/shlink-test-utils": "^4.3.1",
"symfony/var-dumper": "^7.2",
"veewee/composer-run-parallel": "^1.4"
},
@@ -154,16 +154,8 @@
"@test:cli",
"phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov"
],
"openapi:validate": "@php -d error_reporting=\"E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED\" vendor/bin/php-openapi validate docs/swagger/swagger.json",
"openapi:inline": "@php -d error_reporting=\"E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED\" vendor/bin/php-openapi inline docs/swagger/swagger.json docs/swagger/openapi-inlined.json",
"swagger:validate": [
"echo \"This command is deprecated. Use openapi:validate instead\"",
"@openapi:validate"
],
"swagger:inline": [
"echo \"This command is deprecated. Use openapi:inline instead\"",
"@openapi:inline"
],
"openapi:validate": "php-openapi validate docs/swagger/swagger.json",
"openapi:inline": "php-openapi inline docs/swagger/swagger.json docs/swagger/openapi-inlined.json",
"clean:dev": "rm -f data/database.sqlite && rm -f config/params/generated_config.php"
},
"config": {

View File

@@ -2,8 +2,10 @@
declare(strict_types=1);
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use RKA\Middleware\IpAddress;
use RKA\Middleware\Mezzio\IpAddressFactory;
use Shlinkio\Shlink\Core\Middleware\ReverseForwardedAddressesMiddlewareDecorator;
use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE;
@@ -30,8 +32,19 @@ return [
'dependencies' => [
'factories' => [
IpAddress::class => IpAddressFactory::class,
// IpAddress::class => IpAddressFactory::class,
'actual_ip_address_middleware' => IpAddressFactory::class,
ReverseForwardedAddressesMiddlewareDecorator::class => ConfigAbstractFactory::class,
],
'aliases' => [
// Make sure the decorated middleware is resolved when getting IpAddress::class, to make this decoration
// transparent for other parts of the code
IpAddress::class => ReverseForwardedAddressesMiddlewareDecorator::class,
],
],
ConfigAbstractFactory::class => [
ReverseForwardedAddressesMiddlewareDecorator::class => ['actual_ip_address_middleware'],
],
];

View File

@@ -7,7 +7,7 @@ server:
command: 'php -dopcache.enable_cli=1 -dopcache.validate_timestamps=0 ../../bin/roadrunner-worker.php'
http:
address: '0.0.0.0:${PORT:-8080}'
address: '${ADDRESS:-0.0.0.0}:${PORT:-8080}'
middleware: ['static']
static:
dir: '../../public'

View File

@@ -144,7 +144,7 @@ services:
shlink_mercure:
container_name: shlink_mercure
image: dunglas/mercure:v0.15
image: dunglas/mercure:v0.18
ports:
- "3080:80"
environment:

View File

@@ -15,7 +15,6 @@ use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Tester\CommandTester;
class GenerateKeyCommandTest extends TestCase
@@ -27,7 +26,7 @@ class GenerateKeyCommandTest extends TestCase
{
$this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class);
$roleResolver = $this->createMock(RoleResolverInterface::class);
$roleResolver->method('determineRoles')->with($this->isInstanceOf(InputInterface::class))->willReturn([]);
$roleResolver->method('determineRoles')->willReturn([]);
$command = new GenerateKeyCommand($this->apiKeyService, $roleResolver);
$this->commandTester = CliTestUtils::testerForCommand($command);

View File

@@ -40,11 +40,11 @@ class CreateDatabaseCommandTest extends TestCase
{
$locker = $this->createMock(LockFactory::class);
$lock = $this->createMock(SharedLockInterface::class);
$lock->method('acquire')->withAnyParameters()->willReturn(true);
$locker->method('createLock')->withAnyParameters()->willReturn($lock);
$lock->method('acquire')->willReturn(true);
$locker->method('createLock')->willReturn($lock);
$phpExecutableFinder = $this->createMock(PhpExecutableFinder::class);
$phpExecutableFinder->method('find')->with($this->isFalse())->willReturn('/usr/local/bin/php');
$phpExecutableFinder->method('find')->willReturn('/usr/local/bin/php');
$this->processHelper = $this->createMock(ProcessRunnerInterface::class);
$this->schemaManager = $this->createMock(AbstractSchemaManager::class);
@@ -60,7 +60,7 @@ class CreateDatabaseCommandTest extends TestCase
$em->method('getMetadataFactory')->willReturn($this->metadataFactory);
$noDbNameConn = $this->createMock(Connection::class);
$noDbNameConn->method('createSchemaManager')->withAnyParameters()->willReturn($this->schemaManager);
$noDbNameConn->method('createSchemaManager')->willReturn($this->schemaManager);
$command = new CreateDatabaseCommand($locker, $this->processHelper, $phpExecutableFinder, $em, $noDbNameConn);
$this->commandTester = CliTestUtils::testerForCommand($command);

View File

@@ -25,11 +25,11 @@ class MigrateDatabaseCommandTest extends TestCase
{
$locker = $this->createMock(LockFactory::class);
$lock = $this->createMock(SharedLockInterface::class);
$lock->method('acquire')->withAnyParameters()->willReturn(true);
$locker->method('createLock')->withAnyParameters()->willReturn($lock);
$lock->method('acquire')->willReturn(true);
$locker->method('createLock')->willReturn($lock);
$phpExecutableFinder = $this->createMock(PhpExecutableFinder::class);
$phpExecutableFinder->method('find')->with($this->isFalse())->willReturn('/usr/local/bin/php');
$phpExecutableFinder->method('find')->willReturn('/usr/local/bin/php');
$this->processHelper = $this->createMock(ProcessRunnerInterface::class);

View File

@@ -70,7 +70,7 @@ class CreateShortUrlCommandTest extends TestCase
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willThrowException(
NonUniqueSlugException::fromSlug('my-slug'),
);
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
$this->stringifier->method('stringify')->willReturn('');
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--custom-slug' => 'my-slug']);
$output = $this->commandTester->getDisplay();
@@ -112,7 +112,7 @@ class CreateShortUrlCommandTest extends TestCase
return true;
}),
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching(ShortUrl::createFake()));
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
$this->stringifier->method('stringify')->willReturn('');
$input['longUrl'] = 'http://domain.com/foo/bar';
$this->commandTester->execute($input);
@@ -139,7 +139,7 @@ class CreateShortUrlCommandTest extends TestCase
return true;
}),
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl));
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
$this->stringifier->method('stringify')->willReturn('');
$options['longUrl'] = 'http://domain.com/foo/bar';
$this->commandTester->execute($options);

View File

@@ -47,7 +47,7 @@ class LocateVisitsCommandTest extends TestCase
$locker = $this->createMock(Lock\LockFactory::class);
$this->lock = $this->createMock(Lock\SharedLockInterface::class);
$locker->method('createLock')->with($this->isString(), 600.0, false)->willReturn($this->lock);
$locker->method('createLock')->willReturn($this->lock);
$command = new LocateVisitsCommand($this->visitService, $this->visitToLocation, $locker);
@@ -67,7 +67,7 @@ class LocateVisitsCommandTest extends TestCase
$location = VisitLocation::fromGeolocation(Location::empty());
$mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
$this->lock->method('acquire')->willReturn(true);
$this->visitService->expects($this->exactly($expectedUnlocatedCalls))
->method('locateUnlocatedVisits')
->withAnyParameters()
@@ -83,7 +83,7 @@ class LocateVisitsCommandTest extends TestCase
$this->visitToLocation->expects(
$this->exactly($expectedUnlocatedCalls + $expectedEmptyCalls + $expectedAllCalls),
)->method('resolveVisitLocation')->withAnyParameters()->willReturn(Location::emptyInstance());
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
$this->downloadDbCommand->method('run')->willReturn(ExitCode::EXIT_SUCCESS);
$this->commandTester->setInputs(['y']);
$this->commandTester->execute($args);
@@ -108,15 +108,15 @@ class LocateVisitsCommandTest extends TestCase
public function localhostAndEmptyAddressesAreIgnored(IpCannotBeLocatedException $e, string $message): void
{
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty());
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
$location = VisitLocation::fromGeolocation(Location::empty());
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
$this->lock->method('acquire')->willReturn(true);
$this->visitService->expects($this->once())
->method('locateUnlocatedVisits')
->withAnyParameters()
->willReturnCallback($this->invokeHelperMethods($visit, $location));
$this->visitToLocation->expects($this->once())->method('resolveVisitLocation')->willThrowException($e);
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
$this->downloadDbCommand->method('run')->willReturn(ExitCode::EXIT_SUCCESS);
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
@@ -137,7 +137,7 @@ class LocateVisitsCommandTest extends TestCase
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams(remoteAddress: '1.2.3.4'));
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
$this->lock->method('acquire')->willReturn(true);
$this->visitService->expects($this->once())
->method('locateUnlocatedVisits')
->withAnyParameters()
@@ -145,7 +145,7 @@ class LocateVisitsCommandTest extends TestCase
$this->visitToLocation->expects($this->once())->method('resolveVisitLocation')->willThrowException(
IpCannotBeLocatedException::forError(WrongIpException::fromIpAddress('1.2.3.4')),
);
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
$this->downloadDbCommand->method('run')->willReturn(ExitCode::EXIT_SUCCESS);
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
@@ -165,11 +165,11 @@ class LocateVisitsCommandTest extends TestCase
#[Test]
public function noActionIsPerformedIfLockIsAcquired(): void
{
$this->lock->method('acquire')->with($this->isFalse())->willReturn(false);
$this->lock->method('acquire')->willReturn(false);
$this->visitService->expects($this->never())->method('locateUnlocatedVisits');
$this->visitToLocation->expects($this->never())->method('resolveVisitLocation');
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
$this->downloadDbCommand->method('run')->willReturn(ExitCode::EXIT_SUCCESS);
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();
@@ -183,8 +183,8 @@ class LocateVisitsCommandTest extends TestCase
#[Test]
public function showsProperMessageWhenGeoLiteUpdateFails(): void
{
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_FAILURE);
$this->lock->method('acquire')->willReturn(true);
$this->downloadDbCommand->method('run')->willReturn(ExitCode::EXIT_FAILURE);
$this->visitService->expects($this->never())->method('locateUnlocatedVisits');
$this->commandTester->execute([]);
@@ -196,8 +196,8 @@ class LocateVisitsCommandTest extends TestCase
#[Test]
public function providingAllFlagOnItsOwnDisplaysNotice(): void
{
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
$this->lock->method('acquire')->willReturn(true);
$this->downloadDbCommand->method('run')->willReturn(ExitCode::EXIT_SUCCESS);
$this->commandTester->execute(['--all' => true]);
$output = $this->commandTester->getDisplay();
@@ -208,7 +208,7 @@ class LocateVisitsCommandTest extends TestCase
#[Test, DataProvider('provideAbortInputs')]
public function processingAllCancelsCommandIfUserDoesNotActivelyAgreeToConfirmation(array $inputs): void
{
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
$this->downloadDbCommand->method('run')->willReturn(ExitCode::EXIT_SUCCESS);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Execution aborted');

View File

@@ -25,11 +25,8 @@ class CliTestUtils
$command = $generator->testDouble(
Command::class,
mockObject: true,
markAsMockObject: true,
callOriginalConstructor: false,
callOriginalClone: false,
cloneArguments: false,
allowMockingUnknownTypes: false,
);
$command->method('getName')->willReturn($name);
$command->method('isEnabled')->willReturn(true);

View File

@@ -27,8 +27,8 @@ class ProcessRunnerTest extends TestCase
$this->helper = $this->createMock(ProcessHelper::class);
$this->formatter = $this->createMock(DebugFormatterHelper::class);
$helperSet = $this->createMock(HelperSet::class);
$helperSet->method('get')->with('debug_formatter')->willReturn($this->formatter);
$this->helper->method('getHelperSet')->with()->willReturn($helperSet);
$helperSet->method('get')->willReturn($this->formatter);
$this->helper->method('getHelperSet')->willReturn($helperSet);
$this->process = $this->createMock(Process::class);
$this->output = $this->createMock(OutputInterface::class);

View File

@@ -227,6 +227,7 @@ return [
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [
'httpClient',
Config\Options\UrlShortenerOptions::class,
'Logger_Shlink',
],
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [
Config\Options\TrackingOptions::class,

View File

@@ -73,6 +73,9 @@ return (static function (): array {
],
'delegators' => [
EventDispatcher\Matomo\SendVisitToMatomo::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\Mercure\NotifyVisitToMercure::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
@@ -94,6 +97,9 @@ return (static function (): array {
EventDispatcher\LocateUnlocatedVisits::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\UpdateGeoLiteDb::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
],
],

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Platforms\SQLServerPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Fix an incorrectly generated unique index in Microsoft SQL, on short_urls table, for short_code + domain_id columns.
* The index was generated only for rows where both columns were not null, which is not the desired behavior, as
* domain_id can be null.
* This is due to a bug in doctrine/dbal: https://github.com/doctrine/dbal/issues/3671
*
* FIXME DO NOT DELETE THIS MIGRATION! IT IS NOT POSSIBLE TO DO THIS IN ENTITY CONFIG CODE WHILE THE BUG EXISTS
*/
final class Version20250215100756 extends AbstractMigration
{
public function up(Schema $schema): void
{
$this->skipIf(! $this->isMicrosoftSql());
// Drop the existing unique index
$shortUrls = $schema->getTable('short_urls');
$shortUrls->dropIndex('unique_short_code_plus_domain');
}
public function postUp(Schema $schema): void
{
// The only way to get the index properly generated is by hardcoding the SQL.
// Since this migration is run Microsoft SQL only, it is safe to use this approach.
$this->connection->executeStatement(
'CREATE UNIQUE INDEX unique_short_code_plus_domain ON short_urls (short_code, domain_id);',
);
}
private function isMicrosoftSql(): bool
{
return $this->connection->getDatabasePlatform() instanceof SQLServerPlatform;
}
}

View File

@@ -11,7 +11,7 @@ class CloseDbConnectionEventListener
/** @var callable */
private $wrapped;
public function __construct(private ReopeningEntityManagerInterface $em, callable $wrapped)
public function __construct(private readonly ReopeningEntityManagerInterface $em, callable $wrapped)
{
$this->wrapped = $wrapped;
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use function array_reverse;
use function explode;
use function implode;
/**
* Decorates a middleware to make sure it gets called with a list of reversed addresses in `X-Forwarded-For`.
*
* This is a workaround for a change in behavior introduced in akrabat/ip-address-middleware 2.5, which now
* takes the first non-trusted-proxy address in that header, starting from the right, instead of the first
* address starting from the left.
* That change breaks Shlink's visitor IP resolution when more than one proxy is used, and trusted proxies
* are not explicitly set for akrabat/ip-address-middleware (which Shlink does not do).
*
* A proper solution would require allowing trusted proxies to be configurable, and apply this logic conditionally, only
* if trusted proxies are not set.
*
* @see https://github.com/akrabat/ip-address-middleware/pull/51
*/
readonly class ReverseForwardedAddressesMiddlewareDecorator implements MiddlewareInterface
{
public const string FORWARDED_FOR_HEADER = 'X-Forwarded-For';
public function __construct(private MiddlewareInterface $wrappedMiddleware)
{
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if ($request->hasHeader(self::FORWARDED_FOR_HEADER)) {
$request = $request->withHeader(
self::FORWARDED_FOR_HEADER,
implode(',', array_reverse(explode(',', $request->getHeaderLine(self::FORWARDED_FOR_HEADER)))),
);
}
return $this->wrappedMiddleware->process($request, $handler);
}
}

View File

@@ -4,14 +4,19 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
use Closure;
use Fig\Http\Message\RequestMethodInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\RequestOptions;
use Laminas\Stdlib\ErrorHandler;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Throwable;
use function function_exists;
use function html_entity_decode;
use function iconv;
use function mb_convert_encoding;
use function preg_match;
use function str_contains;
@@ -30,9 +35,14 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH
// Matches the charset inside a Content-Type header
private const string CHARSET_VALUE = '/charset=([^;]+)/i';
/**
* @param (Closure(): bool)|null $isIconvInstalled
*/
public function __construct(
private ClientInterface $httpClient,
private UrlShortenerOptions $options,
private LoggerInterface $logger,
private Closure|null $isIconvInstalled = null,
) {
}
@@ -58,7 +68,7 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH
}
$title = $this->tryToResolveTitle($response, $contentType);
return $title !== null ? $data->withResolvedTitle($title) : $data;
return $title !== null ? $data->withResolvedTitle(html_entity_decode(trim($title))) : $data;
}
private function fetchUrl(string $url): ResponseInterface|null
@@ -84,6 +94,7 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH
{
$collectedBody = '';
$body = $response->getBody();
// With streaming enabled, we can walk the body until the </title> tag is found, and then stop
while (! str_contains($collectedBody, '</title>') && ! $body->eof()) {
$collectedBody .= $body->read(1024);
@@ -95,12 +106,48 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH
return null;
}
// Get the page's charset from Content-Type header
preg_match(self::CHARSET_VALUE, $contentType, $charsetMatches);
$titleInOriginalEncoding = $titleMatches[1];
$title = isset($charsetMatches[1])
? mb_convert_encoding($titleMatches[1], 'utf8', $charsetMatches[1])
: $titleMatches[1];
return html_entity_decode(trim($title));
// Get the page's charset from Content-Type header, or return title as is if not found
preg_match(self::CHARSET_VALUE, $contentType, $charsetMatches);
if (! isset($charsetMatches[1])) {
return $titleInOriginalEncoding;
}
$pageCharset = $charsetMatches[1];
return $this->encodeToUtf8WithMbString($titleInOriginalEncoding, $pageCharset)
?? $this->encodeToUtf8WithIconv($titleInOriginalEncoding, $pageCharset)
?? $titleInOriginalEncoding;
}
private function encodeToUtf8WithMbString(string $titleInOriginalEncoding, string $pageCharset): string|null
{
try {
return mb_convert_encoding($titleInOriginalEncoding, 'utf-8', $pageCharset);
} catch (Throwable $e) {
$this->logger->warning('It was impossible to encode page title in UTF-8 with mb_convert_encoding. {e}', [
'e' => $e,
]);
return null;
}
}
private function encodeToUtf8WithIconv(string $titleInOriginalEncoding, string $pageCharset): string|null
{
$isIconvInstalled = ($this->isIconvInstalled ?? fn () => function_exists('iconv'))();
if (! $isIconvInstalled) {
$this->logger->warning('Missing iconv extension. Skipping title encoding');
return null;
}
try {
ErrorHandler::start();
$title = iconv($pageCharset, 'utf-8', $titleInOriginalEncoding);
ErrorHandler::stop(throw: true);
return $title ?: null;
} catch (Throwable $e) {
$this->logger->warning('It was impossible to encode page title in UTF-8 with iconv. {e}', ['e' => $e]);
return null;
}
}
}

View File

@@ -31,24 +31,33 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
$ordering = $dbPlatform instanceof PostgreSQLPlatform ? 'ASC' : 'DESC';
$isStrict = $shortUrlMode === ShortUrlMode::STRICT;
$qb = $this->createQueryBuilder('s');
$qb->leftJoin('s.domain', 'd')
->where($qb->expr()->eq($isStrict ? 's.shortCode' : 'LOWER(s.shortCode)', ':shortCode'))
->setParameter('shortCode', $isStrict ? $identifier->shortCode : strtolower($identifier->shortCode))
->andWhere($qb->expr()->orX(
$qb->expr()->isNull('s.domain'),
$qb->expr()->eq('d.authority', ':domain'),
))
->setParameter('domain', $identifier->domain);
// FIXME The `LOWER(s.shortCode)` condition in non-strict mode drops performance dramatically.
// Investigate if the case-insensitive check can be done natively by the DB engine.
// Since we order by domain, we will have first the URL matching provided domain, followed by the one
// with no domain (if any), so it is safe to fetch 1 max result, and we will get:
// * The short URL matching both the short code and the domain, or
// * The short URL matching the short code but without any domain, or
// * No short URL at all
$qb->orderBy('s.domain', $ordering)
$qb = $this->createQueryBuilder('s');
$qb->where($qb->expr()->eq($isStrict ? 's.shortCode' : 'LOWER(s.shortCode)', ':shortCode'))
->setParameter('shortCode', $isStrict ? $identifier->shortCode : strtolower($identifier->shortCode))
->setMaxResults(1);
// If $domain is null, do not join with domains nor do $qb->expr()->eq('d.authority', ':domain')
$domain = $identifier->domain;
if ($domain === null) {
$qb->andWhere($qb->expr()->isNull('s.domain'));
} else {
$qb->leftJoin('s.domain', 'd')
->andWhere($qb->expr()->orX(
$qb->expr()->isNull('s.domain'),
$qb->expr()->eq('d.authority', ':domain'),
))
->setParameter('domain', $domain)
// Since we order by domain, we will have first the URL matching provided domain, followed by the one
// with no domain (if any), so it is safe to fetch 1 max result, and we will get:
// * The short URL matching both the short code and the domain, or
// * The short URL matching the short code but without any domain, or
// * No short URL at all
->orderBy('s.domain', $ordering);
}
return $qb->getQuery()->getOneOrNullResult();
}

View File

@@ -90,7 +90,8 @@ class RedirectTest extends ApiTestCase
];
$ipAddressConfig = require __DIR__ . '/../../../../config/autoload/ip-address.global.php';
foreach ($ipAddressConfig['rka']['ip_address']['headers_to_inspect'] as $header) {
$headers = $ipAddressConfig['rka']['ip_address']['headers_to_inspect'];
foreach ($headers as $header) {
yield sprintf('rule: IP address in "%s" header', $header) => [
[
RequestOptions::HEADERS => [$header => $header !== 'Forwarded' ? '1.2.3.4' : 'for=1.2.3.4'],
@@ -98,6 +99,15 @@ class RedirectTest extends ApiTestCase
'https://example.com/static-ip-address',
];
}
yield 'rule: IP address in "X-Forwarded-For" together with proxy addresses' => [
[
RequestOptions::HEADERS => [
'X-Forwarded-For' => '1.2.3.4, 192.168.1.1, 192.168.1.2',
],
],
'https://example.com/static-ip-address',
];
}
/**

View File

@@ -79,9 +79,7 @@ class QrCodeActionTest extends TestCase
string $expectedContentType,
): void {
$code = 'abc123';
$this->urlResolver->method('resolveEnabledShortUrl')->with(
ShortUrlIdentifier::fromShortCodeAndDomain($code, ''),
)->willReturn(ShortUrl::createFake());
$this->urlResolver->method('resolveEnabledShortUrl')->willReturn(ShortUrl::createFake());
$handler = $this->createMock(RequestHandlerInterface::class);
$req = (new ServerRequest())->withAttribute('shortCode', $code)->withQueryParams($query);
@@ -109,9 +107,7 @@ class QrCodeActionTest extends TestCase
int $expectedSize,
): void {
$code = 'abc123';
$this->urlResolver->method('resolveEnabledShortUrl')->with(
ShortUrlIdentifier::fromShortCodeAndDomain($code, ''),
)->willReturn(ShortUrl::createFake());
$this->urlResolver->method('resolveEnabledShortUrl')->willReturn(ShortUrl::createFake());
$handler = $this->createMock(RequestHandlerInterface::class);
$resp = $this->action($defaultOptions)->process($req->withAttribute('shortCode', $code), $handler);
@@ -199,9 +195,7 @@ class QrCodeActionTest extends TestCase
->withQueryParams(['size' => 250, 'roundBlockSize' => $roundBlockSize])
->withAttribute('shortCode', $code);
$this->urlResolver->method('resolveEnabledShortUrl')->with(
ShortUrlIdentifier::fromShortCodeAndDomain($code, ''),
)->willReturn(ShortUrl::withLongUrl('https://shlink.io'));
$this->urlResolver->method('resolveEnabledShortUrl')->willReturn(ShortUrl::withLongUrl('https://shlink.io'));
$handler = $this->createMock(RequestHandlerInterface::class);
$resp = $this->action($defaultOptions)->process($req, $handler);
@@ -242,9 +236,7 @@ class QrCodeActionTest extends TestCase
->withQueryParams(['color' => $queryColor])
->withAttribute('shortCode', $code);
$this->urlResolver->method('resolveEnabledShortUrl')->with(
ShortUrlIdentifier::fromShortCodeAndDomain($code),
)->willReturn(ShortUrl::withLongUrl('https://shlink.io'));
$this->urlResolver->method('resolveEnabledShortUrl')->willReturn(ShortUrl::withLongUrl('https://shlink.io'));
$handler = $this->createMock(RequestHandlerInterface::class);
$resp = $this->action(
@@ -306,9 +298,7 @@ class QrCodeActionTest extends TestCase
->withAttribute('shortCode', $code)
->withQueryParams($query);
$this->urlResolver->method('resolveEnabledShortUrl')->with(
ShortUrlIdentifier::fromShortCodeAndDomain($code),
)->willReturn(ShortUrl::withLongUrl('https://shlink.io'));
$this->urlResolver->method('resolveEnabledShortUrl')->willReturn(ShortUrl::withLongUrl('https://shlink.io'));
$handler = $this->createMock(RequestHandlerInterface::class);
$resp = $this->action(new QrCodeOptions(size: 250, logoUrl: $logoUrl))->process($req, $handler);

View File

@@ -37,7 +37,7 @@ class RedirectActionTest extends TestCase
$this->redirectRespHelper = $this->createMock(RedirectResponseHelperInterface::class);
$redirectBuilder = $this->createMock(ShortUrlRedirectionBuilderInterface::class);
$redirectBuilder->method('buildShortUrlRedirect')->withAnyParameters()->willReturn(self::LONG_URL);
$redirectBuilder->method('buildShortUrlRedirect')->willReturn(self::LONG_URL);
$this->action = new RedirectAction(
$this->urlResolver,

View File

@@ -43,7 +43,7 @@ class GeolocationDbUpdaterTest extends TestCase
$this->dbUpdater = $this->createMock(DbUpdaterInterface::class);
$this->lock = $this->createMock(Lock\SharedLockInterface::class);
$this->lock->method('acquire')->with($this->isTrue())->willReturn(true);
$this->lock->method('acquire')->willReturn(true);
$this->em = $this->createMock(EntityManagerInterface::class);
$this->repo = $this->createMock(EntityRepository::class);
@@ -291,7 +291,7 @@ class GeolocationDbUpdaterTest extends TestCase
private function geolocationDbUpdater(TrackingOptions|null $options = null): GeolocationDbUpdater
{
$locker = $this->createMock(Lock\LockFactory::class);
$locker->method('createLock')->with($this->isString())->willReturn($this->lock);
$locker->method('createLock')->willReturn($this->lock);
return new GeolocationDbUpdater($this->dbUpdater, $locker, $options ?? new TrackingOptions(), $this->em, 3);
}

View File

@@ -104,7 +104,7 @@ class ImportedLinksProcessorTest extends TestCase
];
$expectedCalls = count($urls);
$this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo);
$this->em->method('getRepository')->willReturn($this->repo);
$this->repo->expects($this->exactly($expectedCalls))->method('findOneByImportedUrl')->willReturn(null);
$this->shortCodeHelper->expects($this->exactly($expectedCalls))
->method('ensureShortCodeUniqueness')
@@ -138,7 +138,7 @@ class ImportedLinksProcessorTest extends TestCase
new ImportedShlinkUrl(ImportSource::BITLY, 'https://baz', [], Chronos::now(), null, 'baz', null),
];
$this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo);
$this->em->method('getRepository')->willReturn($this->repo);
$this->repo->expects($this->exactly(3))->method('findOneByImportedUrl')->willReturn(null);
$this->shortCodeHelper->expects($this->exactly(3))->method('ensureShortCodeUniqueness')->willReturn(true);
$this->em->expects($this->exactly(3))->method('persist')->with(
@@ -167,7 +167,7 @@ class ImportedLinksProcessorTest extends TestCase
new ImportedShlinkUrl(ImportSource::BITLY, 'https://baz3', [], Chronos::now(), null, 'baz3', null),
];
$this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo);
$this->em->method('getRepository')->willReturn($this->repo);
$this->repo->expects($this->exactly(count($urls)))->method('findOneByImportedUrl')->willReturnCallback(
fn (ImportedShlinkUrl $url): ShortUrl|null => contains(
$url->longUrl,
@@ -195,7 +195,7 @@ class ImportedLinksProcessorTest extends TestCase
new ImportedShlinkUrl(ImportSource::BITLY, 'https://baz3', [], Chronos::now(), null, 'baz3', 'bar'),
];
$this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo);
$this->em->method('getRepository')->willReturn($this->repo);
$this->repo->expects($this->exactly(count($urls)))->method('findOneByImportedUrl')->willReturn(null);
$this->shortCodeHelper->expects($this->exactly(7))->method('ensureShortCodeUniqueness')->willReturnCallback(
fn ($_, bool $hasCustomSlug) => ! $hasCustomSlug,
@@ -219,7 +219,7 @@ class ImportedLinksProcessorTest extends TestCase
int $amountOfPersistedVisits,
ShortUrl|null $foundShortUrl,
): void {
$this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo);
$this->em->method('getRepository')->willReturn($this->repo);
$this->repo->expects($this->once())->method('findOneByImportedUrl')->willReturn($foundShortUrl);
$this->shortCodeHelper->expects($this->exactly($foundShortUrl === null ? 1 : 0))
->method('ensureShortCodeUniqueness')
@@ -276,7 +276,7 @@ class ImportedLinksProcessorTest extends TestCase
#[Test, DataProvider('provideFoundShortUrls')]
public function visitsArePersistedWithProperShortUrl(ShortUrl $originalShortUrl, ShortUrl|null $foundShortUrl): void
{
$this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo);
$this->em->method('getRepository')->willReturn($this->repo);
$this->repo->expects($this->once())->method('findOneByImportedUrl')->willReturn($originalShortUrl);
if (!$originalShortUrl->getId()) {
$this->em->expects($this->never())->method('find');

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Middleware;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Middleware\ReverseForwardedAddressesMiddlewareDecorator;
class ReverseForwardedAddressesMiddlewareDecoratorTest extends TestCase
{
private ReverseForwardedAddressesMiddlewareDecorator $middleware;
private MockObject & MiddlewareInterface $decoratedMiddleware;
private MockObject & RequestHandlerInterface $requestHandler;
protected function setUp(): void
{
$this->decoratedMiddleware = $this->createMock(MiddlewareInterface::class);
$this->requestHandler = $this->createMock(RequestHandlerInterface::class);
$this->middleware = new ReverseForwardedAddressesMiddlewareDecorator($this->decoratedMiddleware);
}
#[Test]
public function processesRequestAsIsWhenHeadersIsNotFound(): void
{
$request = ServerRequestFactory::fromGlobals();
$this->decoratedMiddleware->expects($this->once())->method('process')->with(
$request,
$this->requestHandler,
)->willReturn(new Response());
$this->middleware->process($request, $this->requestHandler);
}
#[Test]
public function revertsListOfAddressesWhenHeaderIsFound(): void
{
$request = ServerRequestFactory::fromGlobals()->withHeader(
ReverseForwardedAddressesMiddlewareDecorator::FORWARDED_FOR_HEADER,
'1.2.3.4,5.6.7.8,9.10.11.12',
);
$this->decoratedMiddleware->expects($this->once())->method('process')->with(
$this->callback(fn (ServerRequestInterface $req): bool => $req->getHeaderLine(
ReverseForwardedAddressesMiddlewareDecorator::FORWARDED_FOR_HEADER,
) === '9.10.11.12,5.6.7.8,1.2.3.4'),
$this->requestHandler,
)->willReturn(new Response());
$this->middleware->process($request, $this->requestHandler);
}
}

View File

@@ -16,6 +16,7 @@ use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\MockObject\Builder\InvocationMocker;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelper;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
@@ -25,10 +26,12 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
private const string LONG_URL = 'http://foobar.com/12345/hello?foo=bar';
private MockObject & ClientInterface $httpClient;
private MockObject & LoggerInterface $logger;
protected function setUp(): void
{
$this->httpClient = $this->createMock(ClientInterface::class);
$this->logger = $this->createMock(LoggerInterface::class);
}
#[Test]
@@ -90,14 +93,59 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
}
#[Test]
#[TestWith(['TEXT/html; charset=utf-8'], 'charset')]
#[TestWith(['TEXT/html'], 'no charset')]
public function titleIsUpdatedWhenItCanBeResolvedFromResponse(string $contentType): void
#[TestWith(['TEXT/html', false], 'no charset')]
#[TestWith(['TEXT/html; charset=utf-8', false], 'mbstring-supported charset')]
#[TestWith(['TEXT/html; charset=Windows-1255', true], 'mbstring-unsupported charset')]
public function titleIsUpdatedWhenItCanBeResolvedFromResponse(string $contentType, bool $expectsWarning): void
{
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
$this->expectRequestToBeCalled()->willReturn($this->respWithTitle($contentType));
if ($expectsWarning) {
$this->logger->expects($this->once())->method('warning')->with(
'It was impossible to encode page title in UTF-8 with mb_convert_encoding. {e}',
$this->isArray(),
);
} else {
$this->logger->expects($this->never())->method('warning');
}
$result = $this->helper(autoResolveTitles: true)->processTitle($data);
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
$result = $this->helper(autoResolveTitles: true, iconvEnabled: true)->processTitle($data);
self::assertNotSame($data, $result);
self::assertEquals('Resolved "title"', $result->title);
}
#[Test]
#[TestWith([
'contentType' => 'text/html; charset=Windows-1255',
'iconvEnabled' => false,
'expectedSecondMessage' => 'Missing iconv extension. Skipping title encoding',
])]
#[TestWith([
'contentType' => 'text/html; charset=foo',
'iconvEnabled' => true,
'expectedSecondMessage' => 'It was impossible to encode page title in UTF-8 with iconv. {e}',
])]
public function warningsLoggedWhenTitleCannotBeEncodedToUtf8(
string $contentType,
bool $iconvEnabled,
string $expectedSecondMessage,
): void {
$this->expectRequestToBeCalled()->willReturn($this->respWithTitle($contentType));
$callCount = 0;
$this->logger->expects($this->exactly(2))->method('warning')->with($this->callback(
function (string $message) use (&$callCount, $expectedSecondMessage): bool {
$callCount++;
if ($callCount === 1) {
return $message === 'It was impossible to encode page title in UTF-8 with mb_convert_encoding. {e}';
}
return $message === $expectedSecondMessage;
},
));
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
$result = $this->helper(autoResolveTitles: true, iconvEnabled: $iconvEnabled)->processTitle($data);
self::assertNotSame($data, $result);
self::assertEquals('Resolved "title"', $result->title);
@@ -143,11 +191,13 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
return $body;
}
private function helper(bool $autoResolveTitles = false): ShortUrlTitleResolutionHelper
private function helper(bool $autoResolveTitles = false, bool $iconvEnabled = false): ShortUrlTitleResolutionHelper
{
return new ShortUrlTitleResolutionHelper(
$this->httpClient,
new UrlShortenerOptions(autoResolveTitles: $autoResolveTitles),
$this->logger,
fn () => $iconvEnabled,
);
}
}

View File

@@ -116,7 +116,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase
{
$repo = $this->createMock(DomainRepository::class);
$repo->expects($this->exactly(3))->method('findOneBy')->with($this->isArray())->willReturn(null);
$this->em->method('getRepository')->with(Domain::class)->willReturn($repo);
$this->em->method('getRepository')->willReturn($repo);
$authority = 'foo.com';
$domain1 = $this->resolver->resolveDomain($authority);
@@ -135,7 +135,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase
{
$tagRepo = $this->createMock(TagRepository::class);
$tagRepo->expects($this->exactly(6))->method('findOneBy')->with($this->isArray())->willReturn(null);
$this->em->method('getRepository')->with(Tag::class)->willReturn($tagRepo);
$this->em->method('getRepository')->willReturn($tagRepo);
$tags = ['foo', 'bar'];
[$foo1, $bar1] = $this->resolver->resolveTags($tags);

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\ShortUrl;
use Cake\Chronos\Chronos;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use Laminas\ServiceManager\Exception\ServiceNotFoundException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
@@ -24,7 +24,7 @@ use Shlinkio\Shlink\Core\ShortUrl\UrlShortener;
class UrlShortenerTest extends TestCase
{
private UrlShortener $urlShortener;
private MockObject & EntityManager $em;
private MockObject & EntityManagerInterface $em;
private MockObject & ShortUrlTitleResolutionHelperInterface $titleResolutionHelper;
private MockObject & ShortCodeUniquenessHelperInterface $shortCodeHelper;
private MockObject & EventDispatcherInterface $dispatcher;
@@ -35,12 +35,9 @@ class UrlShortenerTest extends TestCase
$this->titleResolutionHelper = $this->createMock(ShortUrlTitleResolutionHelperInterface::class);
$this->shortCodeHelper = $this->createMock(ShortCodeUniquenessHelperInterface::class);
// FIXME Should use the interface, but it doe snot define wrapInTransaction explicitly
$this->em = $this->createMock(EntityManager::class);
$this->em = $this->createMock(EntityManagerInterface::class);
$this->em->method('persist')->willReturnCallback(fn (ShortUrl $shortUrl) => $shortUrl->setId('10'));
$this->em->method('wrapInTransaction')->with($this->isCallable())->willReturnCallback(
fn (callable $callback) => $callback(),
);
$this->em->method('wrapInTransaction')->willReturnCallback(fn (callable $callback) => $callback());
$this->dispatcher = $this->createMock(EventDispatcherInterface::class);
$this->repo = $this->createMock(ShortUrlRepositoryInterface::class);

View File

@@ -108,14 +108,8 @@ class VisitsStatsHelperTest extends TestCase
range(0, 1),
);
$repo2 = $this->createMock(VisitRepository::class);
$repo2->method('findVisitsByShortCode')->with(
$identifier,
$this->isInstanceOf(VisitsListFiltering::class),
)->willReturn($list);
$repo2->method('countVisitsByShortCode')->with(
$identifier,
$this->isInstanceOf(VisitsCountFiltering::class),
)->willReturn(1);
$repo2->method('findVisitsByShortCode')->willReturn($list);
$repo2->method('countVisitsByShortCode')->willReturn(1);
$this->em->expects($this->exactly(2))->method('getRepository')->willReturnMap([
[ShortUrl::class, $repo],
@@ -168,10 +162,8 @@ class VisitsStatsHelperTest extends TestCase
range(0, 1),
);
$repo2 = $this->createMock(VisitRepository::class);
$repo2->method('findVisitsByTag')->with($tag, $this->isInstanceOf(VisitsListFiltering::class))->willReturn(
$list,
);
$repo2->method('countVisitsByTag')->with($tag, $this->isInstanceOf(VisitsCountFiltering::class))->willReturn(1);
$repo2->method('findVisitsByTag')->willReturn($list);
$repo2->method('countVisitsByTag')->willReturn(1);
$this->em->expects($this->exactly(2))->method('getRepository')->willReturnMap([
[Tag::class, $repo],
@@ -209,14 +201,8 @@ class VisitsStatsHelperTest extends TestCase
range(0, 1),
);
$repo2 = $this->createMock(VisitRepository::class);
$repo2->method('findVisitsByDomain')->with(
$domain,
$this->isInstanceOf(VisitsListFiltering::class),
)->willReturn($list);
$repo2->method('countVisitsByDomain')->with(
$domain,
$this->isInstanceOf(VisitsCountFiltering::class),
)->willReturn(1);
$repo2->method('findVisitsByDomain')->willReturn($list);
$repo2->method('countVisitsByDomain')->willReturn(1);
$this->em->expects($this->exactly(2))->method('getRepository')->willReturnMap([
[Domain::class, $repo],
@@ -239,14 +225,8 @@ class VisitsStatsHelperTest extends TestCase
range(0, 1),
);
$repo2 = $this->createMock(VisitRepository::class);
$repo2->method('findVisitsByDomain')->with(
Domain::DEFAULT_AUTHORITY,
$this->isInstanceOf(VisitsListFiltering::class),
)->willReturn($list);
$repo2->method('countVisitsByDomain')->with(
Domain::DEFAULT_AUTHORITY,
$this->isInstanceOf(VisitsCountFiltering::class),
)->willReturn(1);
$repo2->method('findVisitsByDomain')->willReturn($list);
$repo2->method('countVisitsByDomain')->willReturn(1);
$this->em->expects($this->exactly(2))->method('getRepository')->willReturnMap([
[Domain::class, $repo],

View File

@@ -30,9 +30,7 @@ class CreateShortUrlContentNegotiationMiddlewareTest extends TestCase
public function whenNoJsonResponseIsReturnedNoFurtherOperationsArePerformed(): void
{
$expectedResp = new Response();
$this->requestHandler->method('handle')->with($this->isInstanceOf(ServerRequestInterface::class))->willReturn(
$expectedResp,
);
$this->requestHandler->method('handle')->willReturn($expectedResp);
$resp = $this->middleware->process(new ServerRequest(), $this->requestHandler);