diff --git a/.env.example b/.env.example index ce7f7fd..9243f9d 100644 --- a/.env.example +++ b/.env.example @@ -64,5 +64,9 @@ AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" +# Health interval in hours HEALTH_CHECK_INTERVAL=24 +# Orphan site storage period in days CLEANUP_SITE_DELAY=7 +# Cache time in minutes +TUF_REPO_CACHETIME=5 diff --git a/app/Http/Controllers/Api/V1/SiteController.php b/app/Http/Controllers/Api/V1/SiteController.php index b63c749..2137562 100644 --- a/app/Http/Controllers/Api/V1/SiteController.php +++ b/app/Http/Controllers/Api/V1/SiteController.php @@ -3,10 +3,10 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; +use App\Http\Traits\ApiResponse; use App\Jobs\CheckSiteHealth; use App\Models\Site; use App\RemoteSite\Connection; -use App\Traits\ApiResponse; use Carbon\Carbon; use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\ServerException; diff --git a/app/Traits/ApiResponse.php b/app/Http/Traits/ApiResponse.php similarity index 97% rename from app/Traits/ApiResponse.php rename to app/Http/Traits/ApiResponse.php index a619d73..531b4cd 100644 --- a/app/Traits/ApiResponse.php +++ b/app/Http/Traits/ApiResponse.php @@ -1,6 +1,6 @@ app->singleton(StorageInterface::class, function ($app) { + // Setup loader + $httpLoader = new HttpLoader( + self::REPO_PATH, + App::make(Client::class) + ); + + $sizeCheckingLoader = new SizeCheckingLoader($httpLoader); + + // Setup storage + $storage = new EloquentModelStorage(TufMetadata::findOrFail(1)); + + // Create updater + $updater = new Updater( + $sizeCheckingLoader, + $storage + ); + + // Fetch Updates + $updater->refresh(); + + $storage->persist(); + + return $storage; + }); + } +} diff --git a/app/RemoteSite/Connection.php b/app/RemoteSite/Connection.php index 508a7e7..7b2cab7 100644 --- a/app/RemoteSite/Connection.php +++ b/app/RemoteSite/Connection.php @@ -5,10 +5,9 @@ namespace App\RemoteSite; use App\Enum\HttpMethod; -use App\Enum\WebserviceEndpoint; use App\RemoteSite\Responses\FinalizeUpdate as FinalizeUpdateResponse; -use App\RemoteSite\Responses\HealthCheck as HealthCheckResponse; use App\RemoteSite\Responses\GetUpdate as GetUpdateResponse; +use App\RemoteSite\Responses\HealthCheck as HealthCheckResponse; use App\RemoteSite\Responses\PrepareUpdate as PrepareUpdateResponse; use App\RemoteSite\Responses\ResponseInterface; use GuzzleHttp\Client; diff --git a/app/Enum/WebserviceEndpoint.php b/app/RemoteSite/WebserviceEndpoint.php similarity index 97% rename from app/Enum/WebserviceEndpoint.php rename to app/RemoteSite/WebserviceEndpoint.php index fcf91ae..b5b00ac 100644 --- a/app/Enum/WebserviceEndpoint.php +++ b/app/RemoteSite/WebserviceEndpoint.php @@ -1,7 +1,8 @@ + */ + protected array $container = []; + + public function __construct(TufMetadata $model) + { + $this->model = $model; + + foreach (self::METADATA_COLUMNS as $column) { + if ($this->model->$column === null) { + continue; + } + + $this->write($column, $this->model->$column); + } + } + + public function read(string $name): ?string + { + return $this->container[$name] ?? null; + } + + public function write(string $name, string $data): void + { + $this->container[$name] = $data; + } + + public function delete(string $name): void + { + unset($this->container[$name]); + } + + public function persist(): bool + { + foreach (self::METADATA_COLUMNS as $column) { + if (!\array_key_exists($column, $this->container)) { + continue; + } + + $this->model->$column = $this->container[$column]; + } + + return $this->model->save(); + } +} diff --git a/app/TUF/HttpLoader.php b/app/TUF/HttpLoader.php new file mode 100644 index 0000000..6f78d1c --- /dev/null +++ b/app/TUF/HttpLoader.php @@ -0,0 +1,36 @@ +http->get($this->repositoryPath . $locator); + } catch (RequestException $e) { + if ($e->getResponse()?->getStatusCode() !== 200) { + throw new RepoFileNotFound(); + } + + throw new HttpLoaderException($e->getMessage(), $e->getCode(), $e); + } + + // Rewind to start + $response->getBody()->rewind(); + + // Return response + return Create::promiseFor($response->getBody()); + } +} diff --git a/app/TUF/HttpLoaderException.php b/app/TUF/HttpLoaderException.php new file mode 100644 index 0000000..eec543b --- /dev/null +++ b/app/TUF/HttpLoaderException.php @@ -0,0 +1,9 @@ +updateStorage = App::make(StorageInterface::class); + } + + public function getReleases(): mixed + { + // Cache response to avoid to make constant calls on the fly + return Cache::remember( + 'cms_targets', + (int) config('autoupdates.tuf_repo_cachetime') * 60, // @phpstan-ignore-line + function () { + $targets = $this->updateStorage->getTargets(); + + // Make sure we have a valid list of targets + if (is_null($targets)) { + throw new MetadataException("Empty targetlist in metadata"); + } + + // Convert format + return (new Collection($targets->getSigned()['targets'])) + ->mapWithKeys(function (mixed $target) { + if (!is_array($target) || empty($target['custom']) || !is_array($target['custom'])) { + throw new MetadataException("Empty target custom attribute"); + } + + return [$target['custom']['version'] => $target['custom']]; + }); + } + ); + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 0a3ca7b..d7401ad 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -4,4 +4,5 @@ App\Providers\AppServiceProvider::class, App\Providers\HorizonServiceProvider::class, App\Providers\HttpclientServiceProvider::class, + App\Providers\TUFServiceProvider::class ]; diff --git a/composer.json b/composer.json index 6d5fa5f..b7eca3f 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,8 @@ "laravel/framework": "^11.9", "laravel/horizon": "^5.29", "laravel/octane": "^2.5", - "laravel/tinker": "^2.9" + "laravel/tinker": "^2.9", + "php-tuf/php-tuf": "1.0.1" }, "require-dev": { "fakerphp/faker": "^1.23", @@ -69,6 +70,13 @@ "php-http/discovery": true } }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/joomla-backports/php-tuf.git", + "no-api": true + } + ], "minimum-stability": "stable", "prefer-stable": true } diff --git a/composer.lock b/composer.lock index c069a73..6f6b775 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "34c997ee01aff538ce065acd047bca2d", + "content-hash": "963e76a188e36703600986d399e17d19", "packages": [ { "name": "brick/math", @@ -771,33 +771,29 @@ }, { "name": "guzzlehttp/promises", - "version": "2.0.4", + "version": "1.5.3", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455" + "reference": "67ab6e18aaa14d753cc148911d273f6e6cb6721e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/f9c436286ab2892c7db7be8c8da4ef61ccf7b455", - "reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455", + "url": "https://api.github.com/repos/guzzle/promises/zipball/67ab6e18aaa14d753cc148911d273f6e6cb6721e", + "reference": "67ab6e18aaa14d753cc148911d273f6e6cb6721e", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0" + "php": ">=5.5" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.39 || ^9.6.20" + "symfony/phpunit-bridge": "^4.4 || ^5.1" }, "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, "autoload": { + "files": [ + "src/functions_include.php" + ], "psr-4": { "GuzzleHttp\\Promise\\": "src/" } @@ -834,7 +830,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.0.4" + "source": "https://github.com/guzzle/promises/tree/1.5.3" }, "funding": [ { @@ -850,7 +846,7 @@ "type": "tidelift" } ], - "time": "2024-10-17T10:06:22+00:00" + "time": "2023-05-21T12:31:43+00:00" }, { "name": "guzzlehttp/psr7", @@ -2585,6 +2581,216 @@ ], "time": "2024-10-15T16:15:16+00:00" }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "paragonie/sodium_compat", + "version": "v1.21.1", + "source": { + "type": "git", + "url": "https://github.com/paragonie/sodium_compat.git", + "reference": "bb312875dcdd20680419564fe42ba1d9564b9e37" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/bb312875dcdd20680419564fe42ba1d9564b9e37", + "reference": "bb312875dcdd20680419564fe42ba1d9564b9e37", + "shasum": "" + }, + "require": { + "paragonie/random_compat": ">=1", + "php": "^5.2.4|^5.3|^5.4|^5.5|^5.6|^7|^8" + }, + "require-dev": { + "phpunit/phpunit": "^3|^4|^5|^6|^7|^8|^9" + }, + "suggest": { + "ext-libsodium": "PHP < 7.0: Better performance, password hashing (Argon2i), secure memory management (memzero), and better security.", + "ext-sodium": "PHP >= 7.0: Better performance, password hashing (Argon2i), secure memory management (memzero), and better security." + }, + "type": "library", + "autoload": { + "files": [ + "autoload.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "ISC" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com" + }, + { + "name": "Frank Denis", + "email": "jedisct1@pureftpd.org" + } + ], + "description": "Pure PHP implementation of libsodium; uses the PHP extension if it exists", + "keywords": [ + "Authentication", + "BLAKE2b", + "ChaCha20", + "ChaCha20-Poly1305", + "Chapoly", + "Curve25519", + "Ed25519", + "EdDSA", + "Edwards-curve Digital Signature Algorithm", + "Elliptic Curve Diffie-Hellman", + "Poly1305", + "Pure-PHP cryptography", + "RFC 7748", + "RFC 8032", + "Salpoly", + "Salsa20", + "X25519", + "XChaCha20-Poly1305", + "XSalsa20-Poly1305", + "Xchacha20", + "Xsalsa20", + "aead", + "cryptography", + "ecdh", + "elliptic curve", + "elliptic curve cryptography", + "encryption", + "libsodium", + "php", + "public-key cryptography", + "secret-key cryptography", + "side-channel resistant" + ], + "support": { + "issues": "https://github.com/paragonie/sodium_compat/issues", + "source": "https://github.com/paragonie/sodium_compat/tree/v1.21.1" + }, + "time": "2024-04-22T22:05:04+00:00" + }, + { + "name": "php-tuf/php-tuf", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/joomla-backports/php-tuf.git", + "reference": "ca2039924ddc6ccd510c67e88e3b337b2395989f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/joomla-backports/php-tuf/zipball/ca2039924ddc6ccd510c67e88e3b337b2395989f", + "reference": "ca2039924ddc6ccd510c67e88e3b337b2395989f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^1.5", + "guzzlehttp/psr7": "^2.4", + "paragonie/sodium_compat": "^1.13", + "php": "^8", + "symfony/polyfill-php81": "^1.27", + "symfony/validator": "^4.4 || ^5 || ^6" + }, + "require-dev": { + "guzzlehttp/guzzle": "^6.5 || ^7.2", + "phpspec/prophecy": "^1.16", + "phpspec/prophecy-phpunit": "^2", + "phpunit/phpunit": "^9", + "slevomat/coding-standard": "^8.2", + "squizlabs/php_codesniffer": "^3.7", + "symfony/phpunit-bridge": "^5" + }, + "suggest": { + "ext-sodium": "Provides faster verification of updates" + }, + "type": "library", + "autoload": { + "psr-4": { + "Tuf\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tuf\\Tests\\": "tests/" + } + }, + "scripts": { + "coverage": [ + "@putenv XDEBUG_MODE=coverage", + "phpunit --coverage-text --color=always --testdox" + ], + "fixtures": [ + "pipenv install", + "pipenv run python generate_fixtures.py" + ], + "phpcs": [ + "phpcs" + ], + "phpcbf": [ + "phpcbf" + ], + "test": [ + "phpunit --testdox" + ], + "lint": [ + "find src -name '*.php' -exec php -l {} \\;" + ] + }, + "license": [ + "MIT" + ], + "description": "PHP implementation of The Update Framework (TUF)", + "time": "2024-03-11T19:10:07+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.3", @@ -4806,6 +5012,82 @@ ], "time": "2024-09-09T11:45:10+00:00" }, + { + "name": "symfony/polyfill-php81", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, { "name": "symfony/polyfill-php83", "version": "v1.31.0", @@ -5602,6 +5884,103 @@ ], "time": "2024-09-25T14:20:29+00:00" }, + { + "name": "symfony/validator", + "version": "v6.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/validator.git", + "reference": "7541055cdaf54ff95f0735bf703d313374e8b20b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/validator/zipball/7541055cdaf54ff95f0735bf703d313374e8b20b", + "reference": "7541055cdaf54ff95f0735bf703d313374e8b20b", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php83": "^1.27", + "symfony/translation-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/annotations": "<1.13", + "doctrine/lexer": "<1.1", + "symfony/dependency-injection": "<5.4", + "symfony/expression-language": "<5.4", + "symfony/http-kernel": "<5.4", + "symfony/intl": "<5.4", + "symfony/property-info": "<5.4", + "symfony/translation": "<5.4.35|>=6.0,<6.3.12|>=6.4,<6.4.3|>=7.0,<7.0.3", + "symfony/yaml": "<5.4" + }, + "require-dev": { + "doctrine/annotations": "^1.13|^2", + "egulias/email-validator": "^2.1.10|^3|^4", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/http-client": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/intl": "^5.4|^6.0|^7.0", + "symfony/mime": "^5.4|^6.0|^7.0", + "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/property-info": "^5.4|^6.0|^7.0", + "symfony/translation": "^5.4.35|~6.3.12|^6.4.3|^7.0.3", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Validator\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/Resources/bin/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to validate values", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/validator/tree/v6.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-11-08T15:28:48+00:00" + }, { "name": "symfony/var-dumper", "version": "v7.1.8", diff --git a/config/autoupdates.php b/config/autoupdates.php index c7bd1cf..9f60fd5 100644 --- a/config/autoupdates.php +++ b/config/autoupdates.php @@ -3,4 +3,5 @@ return [ 'healthcheck_interval' => env('HEALTH_CHECK_INTERVAL', 24), 'cleanup_site_delay' => env('CLEANUP_SITE_DELAY', 7), + 'tuf_repo_cachetime' => env('TUF_REPO_CACHETIME', 5), ]; diff --git a/database/migrations/2024_11_17_100935_create_tuf_metadata_table.php b/database/migrations/2024_11_17_100935_create_tuf_metadata_table.php new file mode 100644 index 0000000..6767b29 --- /dev/null +++ b/database/migrations/2024_11_17_100935_create_tuf_metadata_table.php @@ -0,0 +1,36 @@ +id(); + $table->text('root')->nullable(); + $table->text('targets')->nullable(); + $table->text('snapshot')->nullable(); + $table->text('timestamp')->nullable(); + $table->text('mirrors')->nullable(); + }); + + $row = new TufMetadata(); + $row->id = 1; + $row->root = '{"signed":{"_type":"root","spec_version":"1.0","version":2,"expires":"2025-03-02T11:22:17Z","keys":{"07eb082f367c034a95878687f6648aa76d93652b6ee73e58817053d89af6c44f":{"keytype":"ed25519","scheme":"ed25519","keyid_hash_algorithms":["sha256","sha512"],"keyval":{"public":"9b2af2d9b9727227735253d795bd27ea8f0e294a5f3603e822dc5052b44802b9"}},"1b1b1dd55b2c1c7258714cf1c1ae06f23e4607b28c762d016a9d81c48ffe5669":{"keytype":"ed25519","scheme":"ed25519","keyid_hash_algorithms":["sha256","sha512"],"keyval":{"public":"a18e5ebabc19d5d5984b601a292ece61ba3662ab2d071dc520da5bd4f8948799"}},"2dcaf3d0e552f150792f7c636d45429246dcfa34ac35b46a44f5c87cd17d457e":{"keytype":"ed25519","scheme":"ed25519","keyid_hash_algorithms":["sha256","sha512"],"keyval":{"public":"cb0a7a131961a20edea051d6dc2b091fb650bd399bd8514adb67b3c60db9f8f9"}},"31dd7c7290d664c9b88c0dead2697175293ea7df81b7f24153a37370fd3901c3":{"keytype":"ed25519","scheme":"ed25519","keyid_hash_algorithms":["sha256","sha512"],"keyval":{"public":"589d029a68b470deff1ca16dbf3eea6b5b3fcba0ae7bb52c468abc7fb058b2a2"}},"9e41a9d62d94c6a1c8a304f62c5bd72d84a9f286f27e8327cedeacb09e5156cc":{"keytype":"ed25519","scheme":"ed25519","keyid_hash_algorithms":["sha256","sha512"],"keyval":{"public":"6043c8bacc76ac5c9750f45454dd865c6ca1fc57d69e14cc192cfd420f6a66a9"}}},"roles":{"root":{"keyids":["1b1b1dd55b2c1c7258714cf1c1ae06f23e4607b28c762d016a9d81c48ffe5669","2dcaf3d0e552f150792f7c636d45429246dcfa34ac35b46a44f5c87cd17d457e"],"threshold":1},"snapshot":{"keyids":["07eb082f367c034a95878687f6648aa76d93652b6ee73e58817053d89af6c44f","2dcaf3d0e552f150792f7c636d45429246dcfa34ac35b46a44f5c87cd17d457e"],"threshold":1},"targets":{"keyids":["31dd7c7290d664c9b88c0dead2697175293ea7df81b7f24153a37370fd3901c3"],"threshold":1},"timestamp":{"keyids":["9e41a9d62d94c6a1c8a304f62c5bd72d84a9f286f27e8327cedeacb09e5156cc"],"threshold":1}},"consistent_snapshot":true},"signatures":[{"keyid":"2dcaf3d0e552f150792f7c636d45429246dcfa34ac35b46a44f5c87cd17d457e","sig":"2a225a560ec0837b721d4c5e379fedbd3c7c9079a94e6b31e47e0184c8b95421b6036b4286c5d90f29ab4c468d79a712fdb65e96511394ceb3aa8e2b3983a501"},{"keyid":"1b1b1dd55b2c1c7258714cf1c1ae06f23e4607b28c762d016a9d81c48ffe5669","sig":"8ce0b2a7bdc1e6dcba12081f440510df0a593c072dcf591631c2dd0f456844a7da63be8e8ac31ffbddf42641fde84dc733a336031d182c2163b4c1eaf2117005"}]}'; + $row->save(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tuf_metadata'); + } +}; diff --git a/tests/Unit/TUF/EloquentModelStorageTest.php b/tests/Unit/TUF/EloquentModelStorageTest.php new file mode 100644 index 0000000..cc29ed3 --- /dev/null +++ b/tests/Unit/TUF/EloquentModelStorageTest.php @@ -0,0 +1,96 @@ +getModelMock(['root' => 'rootfoo']); + $object = new EloquentModelStorage($model); + + $this->assertEquals('rootfoo', $this->getInternalStorageValue($object)['root']); + } + + public function testConstructorIgnoresNonMetadataColumns() + { + $model = $this->getModelMock(['foobar' => 'aaa']); + $object = new EloquentModelStorage($model); + + $this->assertArrayNotHasKey('foobar', $this->getInternalStorageValue($object)); + } + + public function testReadReturnsStorageValueForExistingColumns() + { + $object = new EloquentModelStorage($this->getModelMock(['root' => 'foobar'])); + $this->assertEquals('foobar', $object->read('root')); + } + + public function testReadReturnsNullForNonexistentColumns() + { + $object = new EloquentModelStorage($this->getModelMock([])); + $this->assertNull($object->read('foobar')); + } + + public function testWriteUpdatesGivenInternalStorageValue() + { + $object = new EloquentModelStorage($this->getModelMock(['root' => 'foo'])); + $object->write('root', 'bar'); + + $this->assertEquals('bar', $this->getInternalStorageValue($object)['root']); + } + + public function testWriteCreatesNewInternalStorageValue() + { + $object = new EloquentModelStorage($this->getModelMock(['root' => 'foo'])); + $object->write('targets', 'bar'); + + $this->assertEquals('bar', $this->getInternalStorageValue($object)['targets']); + } + + public function testDeleteRemovesRowFromInternalStorage() + { + $object = new EloquentModelStorage($this->getModelMock(['root' => 'foo'])); + $object->delete('root'); + + $this->assertArrayNotHasKey('root', $this->getInternalStorageValue($object)); + } + + public function testPersistUpdatesTableObjectState() + { + $modelMock = $this->getModelMock(['root' => 'foo', 'targets' => 'Joomla', 'nonexistent' => 'value']); + + $modelMock + ->expects($this->once()) + ->method('save') + ->willReturn(true); + + $object = new EloquentModelStorage($modelMock); + $this->assertTrue($object->persist()); + } + + protected function getModelMock(array $mockData) + { + $model = $this->getMockBuilder(TufMetadata::class) + ->onlyMethods(['save']) + ->getMock(); + + // Write mock data to mock table + foreach (EloquentModelStorage::METADATA_COLUMNS as $column) { + $model->$column = (!empty($mockData[$column])) ? $mockData[$column] : null; + } + + return $model; + } + + protected function getInternalStorageValue($class) + { + $reflectionProperty = new \ReflectionProperty(EloquentModelStorage::class, 'container'); + + return $reflectionProperty->getValue($class); + } +} diff --git a/tests/Unit/TUF/HttpLoaderTest.php b/tests/Unit/TUF/HttpLoaderTest.php new file mode 100644 index 0000000..66cd3e1 --- /dev/null +++ b/tests/Unit/TUF/HttpLoaderTest.php @@ -0,0 +1,86 @@ +createMock(Stream::class); + + $object = new HttpLoader( + self::REPOPATHMOCK, + $this->getHttpClientMock(200, $responseBody, 'root.json') + ); + + $object->load('root.json', 2048); + } + + public function testLoaderForwardsReturnedBodyFromHttpClient() + { + $responseBody = $this->createMock(Stream::class); + + $object = new HttpLoader( + self::REPOPATHMOCK, + $this->getHttpClientMock(200, $responseBody, 'root.json') + ); + + $this->assertSame( + $responseBody, + $object->load('root.json', 2048)->wait() + ); + } + + public function testLoaderThrowsExceptionForNon200Response() + { + $this->expectException(RepoFileNotFound::class); + + $responseBody = $this->createMock(Stream::class); + + $object = new HttpLoader( + self::REPOPATHMOCK, + $this->getHttpClientMock(400, $responseBody, 'root.json') + ); + + $object->load('root.json', 2048); + } + + protected function getHttpClientMock(int $responseCode, Stream $responseBody, string $expectedFile) + { + $responseMock = $this->createMock(Response::class); + $responseMock->method('getBody')->willReturn($responseBody); + + $httpClientMock = $this->createMock(Client::class); + + if ($responseCode !== 200) { + $httpClientMock->expects($this->once()) + ->method('get') + ->with(self::REPOPATHMOCK . $expectedFile) + ->willThrowException(new RequestException( + "Request Exception", + new Request('GET', self::REPOPATHMOCK . $expectedFile), + new Response($responseCode) + )); + } else { + $httpClientMock->expects($this->once()) + ->method('get') + ->with(self::REPOPATHMOCK . $expectedFile) + ->willReturn($responseMock); + } + + return $httpClientMock; + } +} diff --git a/tests/Unit/TUF/TufFetcherTest.php b/tests/Unit/TUF/TufFetcherTest.php new file mode 100644 index 0000000..dae63ce --- /dev/null +++ b/tests/Unit/TUF/TufFetcherTest.php @@ -0,0 +1,97 @@ + $this->getStorageMock([ + "Joomla_5.1.2-Stable-Upgrade_Package.zip" => [ + "custom" => [ + "description" => "Joomla! 5.1.2 Release", + "version" => "5.1.2" + ] + ], + "Joomla_5.2.1-Stable-Upgrade_Package.zip" => [ + "custom" => [ + "description" => "Joomla! 5.2.1 Release", + "version" => "5.2.1" + ] + ] + ])); + + $object = new TufFetcher(); + $result = $object->getReleases(); + + $this->assertEquals([ + "5.1.2" => [ + "description" => "Joomla! 5.1.2 Release", + "version" => "5.1.2" + ], + "5.2.1" => [ + "description" => "Joomla! 5.2.1 Release", + "version" => "5.2.1" + ], + ], $result->toArray()); + } + + public function testGetReleasesThrowsExceptionOnEmptyTargetlist() + { + $this->expectExceptionMessage("Empty targetlist in metadata"); + + App::bind(StorageInterface::class, fn () => $this->getStorageMock([])); + + $object = new TufFetcher(); + $object->getReleases(); + } + + public function testGetReleasesThrowsExceptionOnMissingCustom() + { + $this->expectExceptionMessage("Empty target custom attribute"); + + App::bind(StorageInterface::class, fn () => $this->getStorageMock([ + "Joomla_5.1.2-Stable-Upgrade_Package.zip" => [ + "foobar" => "nocustom" + ] + ])); + + $object = new TufFetcher(); + $object->getReleases(); + } + + protected function getStorageMock(array $targets) + { + $targetsMock = $this->getMockBuilder(TargetsMetadata::class) + ->disableOriginalConstructor() + ->getMock(); + + $targetsMock->method('getSigned')->willReturn(["targets" => $targets]); + + $storageMock = $this->getMockBuilder(EloquentModelStorage::class) + ->disableOriginalConstructor() + ->getMock(); + + if (!count($targets)) { + $storageMock->method('getTargets')->willReturn(null); + } else { + $storageMock->method('getTargets')->willReturn($targetsMock); + } + + return $storageMock; + } +}