diff --git a/app/Enum/WebserviceEndpoint.php b/app/Enum/WebserviceEndpoint.php index d5165a5..fcf91ae 100644 --- a/app/Enum/WebserviceEndpoint.php +++ b/app/Enum/WebserviceEndpoint.php @@ -2,10 +2,62 @@ namespace App\Enum; +use App\RemoteSite\Responses\FinalizeUpdate; +use App\RemoteSite\Responses\GetUpdate; +use App\RemoteSite\Responses\HealthCheck; +use App\RemoteSite\Responses\PrepareUpdate; + enum WebserviceEndpoint: string { - case HEALTH_CHECK = "/api/index.php/v1/joomlaupdate/healthcheck"; - case FETCH_UPDATES = "/api/index.php/v1/joomlaupdate/fetchUpdate"; - case PREPARE_UPDATE = "/api/index.php/v1/joomlaupdate/prepareUpdate"; - case FINALIZE_UPDATE = "/api/index.php/v1/joomlaupdate/finalizeUpdate"; + case checkHealth = "/api/index.php/v1/joomlaupdate/healthcheck"; + case getUpdate = "/api/index.php/v1/joomlaupdate/getUpdate"; + case prepareUpdate = "/api/index.php/v1/joomlaupdate/prepareUpdate"; + case finalizeUpdate = "/api/index.php/v1/joomlaupdate/finalizeUpdate"; + + public function getMethod(): HttpMethod + { + switch ($this->name) { + case self::checkHealth->name: + case self::getUpdate->name: + return HttpMethod::GET; + + // no break + case self::prepareUpdate->name: + case self::finalizeUpdate->name: + return HttpMethod::POST; + } + + throw new \ValueError("No method defined"); + } + + public function getResponseClass(): string + { + switch ($this->name) { + case self::checkHealth->name: + return HealthCheck::class; + case self::getUpdate->name: + return GetUpdate::class; + case self::prepareUpdate->name: + return PrepareUpdate::class; + case self::finalizeUpdate->name: + return FinalizeUpdate::class; + } + } + + public function getUrl(): string + { + return $this->value; + } + + public static function tryFromName(string $name): ?static + { + $reflection = new \ReflectionEnum(static::class); + + if (!$reflection->hasCase($name)) { + return null; + } + + /** @var static */ + return $reflection->getCase($name)->getValue(); + } } diff --git a/app/Jobs/UpdateSite.php b/app/Jobs/UpdateSite.php index 5fb1211..bd16e83 100644 --- a/app/Jobs/UpdateSite.php +++ b/app/Jobs/UpdateSite.php @@ -6,12 +6,16 @@ use App\Models\Site; use App\RemoteSite\Connection; +use App\RemoteSite\Responses\PrepareUpdate; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; +use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\Log; class UpdateSite implements ShouldQueue { use Queueable; + protected int $preUpdateCode; /** * Create a new job instance. @@ -30,5 +34,92 @@ public function handle(): void // Test connection and get current version $healthResult = $connection->checkHealth(); + + // Check the version + if (version_compare($healthResult->cms_version, $this->targetVersion, ">=")) { + Log::info("Site is already up to date: " . $this->site->id); + + return; + } + + // Store pre-update response code + $this->preUpdateCode = $this->site->getFrontendStatus(); + + // Let site fetch available updates + $updateResult = $connection->getUpdate(); + + // Check if update is found and return if not + if (is_null($updateResult->availableUpdate)) { + Log::info("No update available for site: " . $this->site->id); + + return; + } + + // Check the version and return if it does not match + if ($updateResult->availableUpdate !== $this->targetVersion) { + Log::info("Update version mismatch for site: " . $this->site->id); + + return; + } + + $prepareResult = $connection->prepareUpdate($this->targetVersion); + + // Perform the actual extraction + $this->performExtraction($prepareResult); + + // Run the postupdate steps + if (!$connection->finalizeUpdate()->success) { + throw new \Exception("Update for site failed in postprocessing: " . $this->site->id); + } + + // Compare codes + if ($this->site->getFrontendStatus() !== $this->preUpdateCode) { + throw new \Exception("Status code has changed after update for site: " . $this->site->id); + } + } + + protected function performExtraction(PrepareUpdate $prepareResult): void + { + /** Create a separate connection with the extraction password **/ + $connection = App::makeWith(Connection::class, [ + "baseUrl" => $this->site->url, + "key" => $prepareResult->password + ]); + + // Ping server + $pingResult = $connection->performExtractionRequest(["task" => "ping"]); + + if (empty($pingResult["message"]) || $pingResult["message"] === 'Invalid login') { + throw new \Exception( + "Invalid ping response for site: " . $this->site->id + ); + } + + // Start extraction + $stepResult = $connection->performExtractionRequest(["task" => "startExtract"]); + + // Run actual core update + while (array_key_exists("done", $stepResult) && $stepResult["done"] !== true) { + if ($stepResult["status"] !== true) { + throw new \Exception( + "Invalid extract response for site: " . $this->site->id + ); + } + + // Make next extraction step + $stepResult = $connection->performExtractionRequest( + [ + "task" => "stepExtract", + "instance" => $stepResult["instance"] + ] + ); + } + + // Clean up restore + $connection->performExtractionRequest( + [ + "task" => "finalizeUpdate" + ] + ); } } diff --git a/app/Models/Site.php b/app/Models/Site.php index 90290c1..c38481b 100644 --- a/app/Models/Site.php +++ b/app/Models/Site.php @@ -5,7 +5,9 @@ namespace App\Models; use App\RemoteSite\Connection; +use GuzzleHttp\Client; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\App; class Site extends Model { @@ -45,4 +47,12 @@ public function getConnectionAttribute(): Connection { return new Connection($this->url, $this->key); } + + public function getFrontendStatus(): int + { + /** @var Client $httpClient */ + $httpClient = App::make(Client::class); + + return $httpClient->get($this->url)->getStatusCode(); + } } diff --git a/app/RemoteSite/Connection.php b/app/RemoteSite/Connection.php index 8b1534f..508a7e7 100644 --- a/app/RemoteSite/Connection.php +++ b/app/RemoteSite/Connection.php @@ -6,7 +6,11 @@ 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\PrepareUpdate as PrepareUpdateResponse; +use App\RemoteSite\Responses\ResponseInterface; use GuzzleHttp\Client; use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Psr7\Request; @@ -14,27 +18,43 @@ use Illuminate\Support\Facades\App; use Psr\Http\Message\RequestInterface; +/** + * @method HealthCheckResponse checkHealth() + * @method GetUpdateResponse getUpdate() + * @method PrepareUpdateResponse prepareUpdate(string $targetVersion) + * @method FinalizeUpdateResponse finalizeUpdate() + */ class Connection { public function __construct(protected readonly string $baseUrl, protected readonly string $key) { } - public function checkHealth(): HealthCheckResponse + public function __call(string $method, array $arguments): ResponseInterface { - $healthData = $this->performWebserviceRequest( - HttpMethod::GET, - WebserviceEndpoint::HEALTH_CHECK + $endpoint = WebserviceEndpoint::tryFromName($method); + + if (is_null($endpoint)) { + throw new \BadMethodCallException(); + } + + // Call + $data = $this->performWebserviceRequest( + $endpoint->getMethod(), + $endpoint->getUrl(), + ...$arguments ); - return HealthCheckResponse::from($healthData['data']['attributes']); + $responseClass = $endpoint->getResponseClass(); + + return $responseClass::from($data); } public function performExtractionRequest(array $requestData): array { $request = new Request( 'POST', - $this->baseUrl . 'extract.php' + $this->baseUrl . '/extract.php' ); $data['password'] = $this->key; @@ -55,12 +75,12 @@ public function performExtractionRequest(array $requestData): array protected function performWebserviceRequest( HttpMethod $method, - WebserviceEndpoint $endpoint, + string $endpoint, array $requestData = [] ): array { $request = new Request( $method->name, - $this->baseUrl . $endpoint->value, + $this->baseUrl . $endpoint, [ 'X-JUpdate-Token' => $this->key ] @@ -85,7 +105,7 @@ protected function performWebserviceRequest( ); } - return $responseData; + return $responseData['data']['attributes']; } protected function performHttpRequest( diff --git a/app/RemoteSite/Responses/FinalizeUpdate.php b/app/RemoteSite/Responses/FinalizeUpdate.php new file mode 100644 index 0000000..e90427e --- /dev/null +++ b/app/RemoteSite/Responses/FinalizeUpdate.php @@ -0,0 +1,13 @@ +assertTrue(true); - } -} diff --git a/tests/Unit/Jobs/UpdateSiteTest.php b/tests/Unit/Jobs/UpdateSiteTest.php new file mode 100644 index 0000000..a6cb236 --- /dev/null +++ b/tests/Unit/Jobs/UpdateSiteTest.php @@ -0,0 +1,195 @@ +getSiteMock(['checkHealth' => $this->getHealthCheckMock(["cms_version" => "1.0.0"])]); + + Log::spy(); + + $object = new UpdateSite($site, "1.0.0"); + $object->handle(); + + Log::shouldHaveReceived('info') + ->once() + ->withArgs(function ($message) { + return str_contains($message, 'Site is already up to date'); + }); + + $this->assertTrue(true); + } + + public function testJobQuitsIfNoUpdateIsAvailable() + { + $site = $this->getSiteMock( + [ + 'checkHealth' => $this->getHealthCheckMock(), + 'getUpdate' => $this->getGetUpdateMock(null) + ] + ); + + Log::spy(); + + $object = new UpdateSite($site, "1.0.1"); + $object->handle(); + + Log::shouldHaveReceived('info') + ->once() + ->withArgs(function ($message) { + return str_contains($message, 'No update available for site'); + }); + + $this->assertTrue(true); + } + + public function testJobQuitsIfAvailabelUpdateDoesNotMatchTargetVersion() + { + $site = $this->getSiteMock( + [ + 'checkHealth' => $this->getHealthCheckMock(), + 'getUpdate' => $this->getGetUpdateMock("1.0.2") + ] + ); + + Log::spy(); + + $object = new UpdateSite($site, "1.0.1"); + $object->handle(); + + Log::shouldHaveReceived('info') + ->once() + ->withArgs(function ($message) { + return str_contains($message, 'Update version mismatch for site'); + }); + + $this->assertTrue(true); + } + + public function testJobFailsIfFinalizeUpdateReturnsFalse() + { + $this->expectExceptionMessage("Update for site failed in postprocessing: 1"); + + $site = $this->getSiteMock( + [ + 'checkHealth' => $this->getHealthCheckMock(), + 'getUpdate' => $this->getGetUpdateMock("1.0.1"), + 'prepareUpdate' => $this->getPrepareUpdateMock(), + 'finalizeUpdate' => $this->getFinalizeUpdateMock(false) + ] + ); + + App::bind(Connection::class, fn () => $this->getSuccessfulExtractionMock()); + + $object = new UpdateSite($site, "1.0.1"); + $object->handle(); + } + + protected function getSiteMock(array $responses) + { + $connectionMock = $this->getMockBuilder(Connection::class) + ->disableOriginalConstructor() + ->getMock(); + + $connectionMock + ->method("__call") + ->willReturnCallback( + function ($method) use ($responses) { + return $responses[$method]; + } + ); + + $siteMock = $this->getMockBuilder(Site::class) + ->onlyMethods(['getConnectionAttribute', 'getFrontendStatus']) + ->getMock(); + + $siteMock->method('getConnectionAttribute')->willReturn($connectionMock); + $siteMock->method('getFrontendStatus')->willReturn(200); + $siteMock->id = 1; + $siteMock->url = "http://example.org"; + + return $siteMock; + } + + protected function getHealthCheckMock($overrides = []) + { + $defaults = [ + "php_version" => "1.0.0", + "db_type" => "mysqli", + "db_version" => "1.0.0", + "cms_version" => "1.0.0", + "server_os" => "Joomla OS 1.0.0" + ]; + + return HealthCheck::from([ + ...$defaults, + ...$overrides + ]); + } + + protected function getGetUpdateMock($version) + { + return GetUpdate::from([ + "availableUpdate" => $version + ]); + } + + protected function getFinalizeUpdateMock(bool $success) + { + return FinalizeUpdate::from([ + "success" => $success + ]); + } + + protected function getPrepareUpdateMock($overrides = []) + { + $defaults = [ + "password" => "foobar123", + "filesize" => 123456 + ]; + + return PrepareUpdate::from([ + ...$defaults, + ...$overrides + ]); + } + + protected function getSuccessfulExtractionMock() + { + $connectionMock = $this->getMockBuilder(Connection::class) + ->disableOriginalConstructor() + ->getMock(); + + $connectionMock + ->method("performExtractionRequest") + ->willReturnCallback( + function ($data) { + switch ($data["task"]) { + case "ping": + return ["message" => "Success"]; + + case "startExtract": + return ["done" => true]; + + case "finalizeUpdate": + return ["success" => true]; + } + } + ); + + return $connectionMock; + } +}