Compare commits

...

37 Commits

Author SHA1 Message Date
Alejandro Celaya
c2649395f8 Merge pull request #2398 from shlinkio/develop
Release 4.4.6
2025-03-20 09:24:27 +01:00
Alejandro Celaya
b7d9ba8258 Merge pull request #2397 from acelaya-forks/feature/endroid-fix
Fix error intrduced by endroid/qr-code 6.0.4
2025-03-20 09:19:58 +01:00
Alejandro Celaya
6526cf8c44 Fix error intrduced by endroid/qr-code 6.0.4 2025-03-20 09:16:53 +01:00
Alejandro Celaya
a85afb2bee Merge pull request #2394 from acelaya-forks/feature/fix-artifact-removal
Update geekyeggo/delete-artifact action to v5
2025-03-14 18:00:47 +01:00
Alejandro Celaya
8b4067efbe Update geekyeggo/delete-artifact action to v5 2025-03-14 17:57:55 +01:00
Alejandro Celaya
c7c2272fab Update changelog 2025-03-14 17:53:23 +01:00
Alejandro Celaya
bc77750713 Merge pull request #2392 from wuuei/patch-1
Fix Matomo country logging by sending country code instead of country
2025-03-14 17:51:37 +01:00
Alejandro Celaya
1ceb38f50b Test actual arguments set to matomo tracker when sending visits 2025-03-14 17:40:37 +01:00
wuuei
d273b56144 Lock "endroid/qr-code" to 6.0.3 so that unit tests complete 2025-03-14 15:21:55 +00:00
wuuei
5cd7305666 Fix code style to resolve failing check 2025-03-14 15:20:49 +00:00
wuuei
3040a22c02 Fix Matomo country logging by sending country code instead of country name
Matomo expects the country code in lowercase for accurate logging and proper flag display
2025-03-13 15:33:00 +01:00
Alejandro Celaya
6991138812 Merge pull request #2379 from shlinkio/develop
Release 4.4.5
2025-03-01 09:41:16 +01:00
Alejandro Celaya
5eb1808217 Update CHANGELOG.md with V4.4.5 2025-03-01 09:14:37 +01:00
Alejandro Celaya
5eb14c5315 Merge pull request #2375 from acelaya-forks/feature/deprecation-error-reporting
Disable deprecation warnings when running in production envs
2025-02-21 21:18:44 +01:00
Alejandro Celaya
a18360a4d6 Disable deprecation warnings when running in production envs 2025-02-21 21:13:29 +01:00
Alejandro Celaya
104b1e7d04 Merge pull request #2371 from shlinkio/develop
Release 4.4.4
2025-02-19 19:40:28 +01:00
Alejandro Celaya
af2d67695b Merge pull request #2370 from acelaya-forks/feature/missing-join-fix
Fix 500 error when listing non-orphan visits with short-url-depending API key
2025-02-19 19:37:36 +01:00
Alejandro Celaya
449a588796 Fix 500 error when listing non-orphan visits with short-url-depending API key 2025-02-19 19:33:44 +01:00
Alejandro Celaya
7bbc938743 Merge pull request #2369 from acelaya-forks/feature/redis-cluster-fix
Downgrade to symfony/lock 7.1.6
2025-02-19 17:55:53 +01:00
Alejandro Celaya
766758ff9b Downgrade to symfony/lock 7.1.6 2025-02-19 17:45:52 +01:00
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
35 changed files with 431 additions and 166 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

@@ -96,7 +96,7 @@ jobs:
- upload-coverage
runs-on: ubuntu-24.04
steps:
- uses: geekyeggo/delete-artifact@v2
- uses: geekyeggo/delete-artifact@v5
with:
name: |
coverage-*

View File

@@ -11,10 +11,10 @@ jobs:
matrix:
include:
- runtime: 'rr'
platforms: 'linux/amd64' # TODO Build on linux/arm64/v8 again. For some reason it is failing
platforms: 'linux/arm64/v8,linux/amd64'
- runtime: 'rr'
tag-suffix: 'roadrunner'
platforms: 'linux/amd64' # TODO Build on linux/arm64/v8 again. For some reason it is failing
platforms: 'linux/arm64/v8,linux/amd64'
uses: shlinkio/github-actions/.github/workflows/docker-publish-image.yml@main
secrets: inherit
with:

View File

@@ -45,6 +45,6 @@ jobs:
needs: ['publish']
runs-on: ubuntu-24.04
steps:
- uses: geekyeggo/delete-artifact@v2
- uses: geekyeggo/delete-artifact@v5
with:
name: dist-files-*

View File

@@ -4,6 +4,103 @@ 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.6] - 2025-03-20
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2391](https://github.com/shlinkio/shlink/issues/2391) When sending visits to Matomo, send the country code, not the country name.
* Fix error with new option introduced by `endroid/qr-code` 6.0.4.
## [4.4.5] - 2025-03-01
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2373](https://github.com/shlinkio/shlink/issues/2373) Ensure deprecation warnings do not end up escalated to `ErrorException`s by `ProblemDetailsMiddleware`.
In order to do this, Shlink will entirely ignore deprecation warnings when running in production, as those do not mean something is not working, but only that something will break in future versions.
## [4.4.4] - 2025-02-19
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2366](https://github.com/shlinkio/shlink/issues/2366) Fix error "Cannot use 'SCRIPT' with redis-cluster" thrown when creating a lock while using a redis cluster.
* [#2368](https://github.com/shlinkio/shlink/issues/2368) Fix error when listing non-orphan visits using API key with `AUTHORED_SHORT_URLS` role.
## [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.

View File

@@ -18,13 +18,13 @@
"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",
"doctrine/orm": "^3.3",
"donatj/phpuseragentparser": "^1.10",
"endroid/qr-code": "^6.0",
"endroid/qr-code": "^6.0.5",
"friendsofphp/proxy-manager-lts": "^1.0",
"geoip2/geoip2": "^3.1",
"guzzlehttp/guzzle": "^7.9",
@@ -56,28 +56,29 @@
"spiral/roadrunner-jobs": "^4.6",
"symfony/console": "^7.2",
"symfony/filesystem": "^7.2",
"symfony/lock": "^7.2",
"symfony/lock": "7.1.6",
"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"
},
"conflict": {
"symfony/var-exporter": ">=6.3.9,<=6.4.0"
"symfony/var-exporter": ">=6.3.9,<=6.4.0",
"phpunit/phpunit": "12.0.9"
},
"autoload": {
"psr-4": {
@@ -154,16 +155,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

@@ -11,6 +11,7 @@ use function Shlinkio\Shlink\Core\enumValues;
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
// Set current directory to the project's root directory
chdir(dirname(__DIR__));
require 'vendor/autoload.php';
@@ -21,7 +22,11 @@ loadEnvVarsFromConfig(
enumValues(EnvVars::class),
);
// This is one of the first files loaded. Configure the timezone and memory limit here
// This is one of the first files loaded. Set global configuration here
error_reporting(
// Set a less strict error reporting for prod, where deprecation warnings should be ignored
EnvVars::isProdEnv() ? E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED : E_ALL,
);
ini_set('memory_limit', EnvVars::MEMORY_LIMIT->loadFromEnv());
date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv());

View File

@@ -1,5 +1,4 @@
display_errors=On
error_reporting=-1
log_errors_max_len=0
zend.assertions=1
assert.exception=1

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

@@ -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

@@ -38,6 +38,7 @@ final readonly class QrCodeParams
public int $size,
public int $margin,
public WriterInterface $writer,
public array $writerOptions,
public ErrorCorrectionLevel $errorCorrectionLevel,
public RoundBlockSizeMode $roundBlockSizeMode,
public ColorInterface $color,
@@ -49,11 +50,13 @@ final readonly class QrCodeParams
public static function fromRequest(ServerRequestInterface $request, QrCodeOptions $defaults): self
{
$query = $request->getQueryParams();
[$writer, $writerOptions] = self::resolveWriterAndWriterOptions($query, $defaults);
return new self(
size: self::resolveSize($query, $defaults),
margin: self::resolveMargin($query, $defaults),
writer: self::resolveWriter($query, $defaults),
writer: $writer,
writerOptions: $writerOptions,
errorCorrectionLevel: self::resolveErrorCorrection($query, $defaults),
roundBlockSizeMode: self::resolveRoundBlockSize($query, $defaults),
color: self::resolveColor($query, $defaults),
@@ -83,14 +86,17 @@ final readonly class QrCodeParams
return max($intMargin, 0);
}
private static function resolveWriter(array $query, QrCodeOptions $defaults): WriterInterface
/**
* @return array{WriterInterface, array}
*/
private static function resolveWriterAndWriterOptions(array $query, QrCodeOptions $defaults): array
{
$qFormat = self::normalizeParam($query['format'] ?? '');
$format = contains($qFormat, self::SUPPORTED_FORMATS) ? $qFormat : self::normalizeParam($defaults->format);
return match ($format) {
'svg' => new SvgWriter(),
default => new PngWriter(),
'svg' => [new SvgWriter(), []],
default => [new PngWriter(), [PngWriter::WRITER_OPTION_NUMBER_OF_COLORS => null]],
};
}

View File

@@ -45,6 +45,7 @@ readonly class QrCodeAction implements MiddlewareInterface
$params = QrCodeParams::fromRequest($request, $this->options);
$qrCodeBuilder = new Builder(
writer: $params->writer,
writerOptions: $params->writerOptions,
data: $this->stringifier->stringify($shortUrl),
errorCorrectionLevel: $params->errorCorrectionLevel,
size: $params->size,

View File

@@ -11,6 +11,8 @@ use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Repository\VisitIterationRepositoryInterface;
use Throwable;
use function strtolower;
readonly class MatomoVisitSender implements MatomoVisitSenderInterface
{
public function __construct(
@@ -60,7 +62,7 @@ readonly class MatomoVisitSender implements MatomoVisitSenderInterface
if ($location !== null) {
$tracker
->setCity($location->cityName)
->setCountry($location->countryName)
->setCountry(strtolower($location->countryCode))
->setLatitude($location->latitude)
->setLongitude($location->longitude);
}

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

@@ -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

@@ -21,6 +21,7 @@ use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Spec\CountOfNonOrphanVisits;
use Shlinkio\Shlink\Core\Visit\Spec\CountOfOrphanVisits;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use const PHP_INT_MAX;
@@ -177,7 +178,12 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
$qb = $this->createAllVisitsQueryBuilder($filtering);
$qb->andWhere($qb->expr()->isNotNull('v.shortUrl'));
$this->applySpecification($qb, $filtering->apiKey?->inlinedSpec());
$apiKey = $filtering->apiKey;
if (ApiKey::isShortUrlRestricted($apiKey)) {
$qb->join('v.shortUrl', 's');
}
$this->applySpecification($qb, $apiKey?->inlinedSpec(), 'v');
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset);
}

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

@@ -470,22 +470,18 @@ class VisitRepositoryTest extends DatabaseTestCase
#[Test]
public function findNonOrphanVisitsReturnsExpectedResult(): void
{
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => 'https://1']));
$this->getEntityManager()->persist($shortUrl);
$this->createVisitsForShortUrl($shortUrl, 7);
$authoredApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()));
$this->getEntityManager()->persist($authoredApiKey);
$shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => 'https://2']));
$this->getEntityManager()->persist($shortUrl2);
$this->createVisitsForShortUrl($shortUrl2, 4);
$shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => 'https://3']));
$this->getEntityManager()->persist($shortUrl3);
$this->createVisitsForShortUrl($shortUrl3, 10);
$this->createShortUrlsAndVisits(withDomain: false, visitsAmount: 7);
$this->createShortUrlsAndVisits(withDomain: false, apiKey: $authoredApiKey, visitsAmount: 4);
$this->createShortUrlsAndVisits(withDomain: false, visitsAmount: 10);
$this->getEntityManager()->flush();
self::assertCount(21, $this->repo->findNonOrphanVisits(new VisitsListFiltering()));
self::assertCount(21, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::allTime())));
self::assertCount(4, $this->repo->findNonOrphanVisits(new VisitsListFiltering(apiKey: $authoredApiKey)));
self::assertCount(7, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::since(
Chronos::parse('2016-01-05')->endOfDay(),
))));
@@ -503,11 +499,11 @@ class VisitRepositoryTest extends DatabaseTestCase
self::assertCount(3, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::between(
Chronos::parse('2016-01-03')->startOfDay(),
Chronos::parse('2016-01-08')->endOfDay(),
), false, null, 10, 10)));
self::assertCount(15, $this->repo->findNonOrphanVisits(new VisitsListFiltering(null, true)));
self::assertCount(10, $this->repo->findNonOrphanVisits(new VisitsListFiltering(null, false, null, 10)));
self::assertCount(1, $this->repo->findNonOrphanVisits(new VisitsListFiltering(null, false, null, 10, 20)));
self::assertCount(5, $this->repo->findNonOrphanVisits(new VisitsListFiltering(null, false, null, 5, 5)));
), limit: 10, offset: 10)));
self::assertCount(15, $this->repo->findNonOrphanVisits(new VisitsListFiltering(excludeBots: true)));
self::assertCount(10, $this->repo->findNonOrphanVisits(new VisitsListFiltering(limit: 10)));
self::assertCount(1, $this->repo->findNonOrphanVisits(new VisitsListFiltering(limit: 10, offset: 20)));
self::assertCount(5, $this->repo->findNonOrphanVisits(new VisitsListFiltering(limit: 5, offset: 5)));
}
#[Test]
@@ -535,6 +531,7 @@ class VisitRepositoryTest extends DatabaseTestCase
bool|string $withDomain = true,
array $tags = [],
ApiKey|null $apiKey = null,
int $visitsAmount = 6,
): array {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => 'https://longUrl',
@@ -545,7 +542,7 @@ class VisitRepositoryTest extends DatabaseTestCase
$shortCode = $shortUrl->getShortCode();
$this->getEntityManager()->persist($shortUrl);
$this->createVisitsForShortUrl($shortUrl);
$this->createVisitsForShortUrl($shortUrl, $visitsAmount);
if ($withDomain !== false) {
$shortUrlWithDomain = ShortUrl::create(ShortUrlCreation::fromRawData([

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

@@ -43,6 +43,9 @@ class MatomoVisitSenderTest extends TestCase
}
#[Test, DataProvider('provideTrackerMethods')]
/**
* @param array<string, string[]> $invokedMethods
*/
public function visitIsSentToMatomo(Visit $visit, string|null $originalIpAddress, array $invokedMethods): void
{
$tracker = $this->createMock(MatomoTracker::class);
@@ -66,8 +69,8 @@ class MatomoVisitSenderTest extends TestCase
)->willReturn($tracker);
}
foreach ($invokedMethods as $invokedMethod) {
$tracker->expects($this->once())->method($invokedMethod)->willReturn($tracker);
foreach ($invokedMethods as $invokedMethod => $args) {
$tracker->expects($this->once())->method($invokedMethod)->with(...$args)->willReturn($tracker);
}
$this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker);
@@ -81,18 +84,28 @@ class MatomoVisitSenderTest extends TestCase
yield 'located regular visit' => [
Visit::forValidShortUrl(ShortUrl::withLongUrl('https://shlink.io'), Visitor::empty())
->locate(VisitLocation::fromGeolocation(new Location(
countryCode: 'countryCode',
countryCode: 'US',
countryName: 'countryName',
regionName: 'regionName',
city: 'city',
latitude: 123,
longitude: 123,
longitude: 456,
timeZone: 'timeZone',
))),
'1.2.3.4',
['setCity', 'setCountry', 'setLatitude', 'setLongitude', 'setIp'],
[
'setCity' => ['city'],
'setCountry' => ['us'],
'setLatitude' => [123],
'setLongitude' => [456],
'setIp' => ['1.2.3.4'],
],
];
yield 'fallback IP' => [
Visit::forBasePath(Visitor::fromParams(remoteAddress: '5.6.7.8')),
null,
['setIp' => ['5.6.7.0']],
];
yield 'fallback IP' => [Visit::forBasePath(Visitor::fromParams(remoteAddress: '1.2.3.4')), null, ['setIp']];
}
#[Test, DataProvider('provideUrlsToTrack')]

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

@@ -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);