diff --git a/Services/VendorUpdateCheckerService.php b/Services/VendorUpdateCheckerService.php index d483c61..3fced47 100644 --- a/Services/VendorUpdateCheckerService.php +++ b/Services/VendorUpdateCheckerService.php @@ -6,6 +6,8 @@ namespace Core\Mod\Uptelligence\Services; use Core\Mod\Uptelligence\Models\UpstreamTodo; use Core\Mod\Uptelligence\Models\Vendor; +use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; @@ -17,6 +19,13 @@ use Illuminate\Support\Facades\RateLimiter; */ class VendorUpdateCheckerService { + /** + * In-memory cache for AltumCode plugin versions. + * + * Avoids redundant HTTP calls when checking multiple plugins in one run. + */ + protected ?array $altumPluginVersionsCache = null; + /** * Check all active vendors for updates. * @@ -42,6 +51,8 @@ class VendorUpdateCheckerService { // Determine check method based on source type and git URL $result = match (true) { + $this->isAltumPlatform($vendor) && $vendor->isLicensed() => $this->checkAltumProduct($vendor), + $this->isAltumPlatform($vendor) && $vendor->isPlugin() => $this->checkAltumPlugin($vendor), $vendor->isOss() && $this->isGitHubUrl($vendor->git_repo_url) => $this->checkGitHub($vendor), $vendor->isOss() && $this->isGiteaUrl($vendor->git_repo_url) => $this->checkGitea($vendor), default => $this->skipCheck($vendor), @@ -89,10 +100,10 @@ class VendorUpdateCheckerService ->retry(3, function (int $attempt) { return (int) pow(2, $attempt - 1) * 1000; }, function (\Exception $exception) { - if ($exception instanceof \Illuminate\Http\Client\ConnectionException) { + if ($exception instanceof ConnectionException) { return true; } - if ($exception instanceof \Illuminate\Http\Client\RequestException) { + if ($exception instanceof RequestException) { $status = $exception->response?->status(); return $status >= 500 || $status === 429; @@ -273,6 +284,125 @@ class VendorUpdateCheckerService ); } + /** + * Check if vendor belongs to the AltumCode platform. + */ + protected function isAltumPlatform(Vendor $vendor): bool + { + return $vendor->plugin_platform === Vendor::PLATFORM_ALTUM; + } + + /** + * Check an AltumCode licensed product for updates. + * + * Fetches the product's info.php endpoint (e.g. https://66analytics.com/info.php) + * and reads the latest_release_version from the JSON response. + */ + protected function checkAltumProduct(Vendor $vendor): array + { + $url = "https://{$vendor->slug}.com/info.php"; + + try { + $response = Http::timeout(5)->get($url); + } catch (\Exception $e) { + Log::warning('Uptelligence: AltumCode product check failed', [ + 'vendor' => $vendor->slug, + 'url' => $url, + 'error' => $e->getMessage(), + ]); + + return $this->errorResult("AltumCode product check failed: {$e->getMessage()}"); + } + + if (! $response->successful()) { + Log::warning('Uptelligence: AltumCode product info request failed', [ + 'vendor' => $vendor->slug, + 'status' => $response->status(), + ]); + + return $this->errorResult("AltumCode product info error: {$response->status()}"); + } + + $data = $response->json(); + $latestVersion = $this->normaliseVersion($data['latest_release_version'] ?? ''); + + return $this->buildResult( + vendor: $vendor, + latestVersion: $latestVersion, + releaseInfo: [ + 'source' => 'altum_product', + 'url' => $url, + ] + ); + } + + /** + * Check an AltumCode plugin for updates. + * + * Fetches the plugin versions endpoint and looks up the plugin by ID + * (stripping the 'altum-plugin-' prefix from the vendor slug). + */ + protected function checkAltumPlugin(Vendor $vendor): array + { + $versions = $this->getAltumPluginVersions(); + + if ($versions === null) { + return $this->errorResult('Failed to fetch AltumCode plugin versions'); + } + + // Strip 'altum-plugin-' prefix from slug to get the plugin ID + $pluginId = str_replace('altum-plugin-', '', $vendor->slug); + + if (! isset($versions[$pluginId])) { + return $this->errorResult("Plugin '{$pluginId}' not found in AltumCode registry"); + } + + $latestVersion = $this->normaliseVersion($versions[$pluginId]['version'] ?? ''); + + return $this->buildResult( + vendor: $vendor, + latestVersion: $latestVersion, + releaseInfo: [ + 'source' => 'altum_plugin', + 'plugin_id' => $pluginId, + ] + ); + } + + /** + * Fetch AltumCode plugin versions with in-memory caching. + * + * @return array|null + */ + protected function getAltumPluginVersions(): ?array + { + if ($this->altumPluginVersionsCache !== null) { + return $this->altumPluginVersionsCache; + } + + try { + $response = Http::timeout(5)->get('https://dev.altumcode.com/plugins-versions'); + } catch (\Exception $e) { + Log::warning('Uptelligence: AltumCode plugin versions fetch failed', [ + 'error' => $e->getMessage(), + ]); + + return null; + } + + if (! $response->successful()) { + Log::warning('Uptelligence: AltumCode plugin versions request failed', [ + 'status' => $response->status(), + ]); + + return null; + } + + $this->altumPluginVersionsCache = $response->json() ?? []; + + return $this->altumPluginVersionsCache; + } + /** * Skip check for vendors that don't support auto-checking. */ diff --git a/tests/Unit/AltumCodeCheckerTest.php b/tests/Unit/AltumCodeCheckerTest.php new file mode 100644 index 0000000..8c92ce0 --- /dev/null +++ b/tests/Unit/AltumCodeCheckerTest.php @@ -0,0 +1,364 @@ +service = new VendorUpdateCheckerService; +}); + +// --------------------------------------------------------------------------- +// isAltumPlatform +// --------------------------------------------------------------------------- + +it('identifies altum platform vendors', function () { + $vendor = new Vendor([ + 'slug' => '66analytics', + 'plugin_platform' => Vendor::PLATFORM_ALTUM, + ]); + + $method = new ReflectionMethod($this->service, 'isAltumPlatform'); + $method->setAccessible(true); + + expect($method->invoke($this->service, $vendor))->toBeTrue(); +}); + +it('rejects non-altum platform vendors', function () { + $vendor = new Vendor([ + 'slug' => 'some-wp-plugin', + 'plugin_platform' => Vendor::PLATFORM_WORDPRESS, + ]); + + $method = new ReflectionMethod($this->service, 'isAltumPlatform'); + $method->setAccessible(true); + + expect($method->invoke($this->service, $vendor))->toBeFalse(); +}); + +it('rejects vendors with no platform set', function () { + $vendor = new Vendor([ + 'slug' => 'some-oss', + 'plugin_platform' => null, + ]); + + $method = new ReflectionMethod($this->service, 'isAltumPlatform'); + $method->setAccessible(true); + + expect($method->invoke($this->service, $vendor))->toBeFalse(); +}); + +// --------------------------------------------------------------------------- +// checkAltumProduct +// --------------------------------------------------------------------------- + +it('fetches latest version from altum product info endpoint', function () { + Http::fake([ + 'https://66analytics.com/info.php' => Http::response([ + 'latest_release_version' => '42.0.0', + 'product_name' => '66analytics', + ], 200), + ]); + + $vendor = new Vendor([ + 'slug' => '66analytics', + 'name' => '66analytics', + 'plugin_platform' => Vendor::PLATFORM_ALTUM, + 'source_type' => Vendor::SOURCE_LICENSED, + 'current_version' => '41.0.0', + ]); + + $method = new ReflectionMethod($this->service, 'checkAltumProduct'); + $method->setAccessible(true); + + $result = $method->invoke($this->service, $vendor); + + expect($result) + ->toBeArray() + ->and($result['status'])->toBe('success') + ->and($result['latest'])->toBe('42.0.0') + ->and($result['has_update'])->toBeTrue() + ->and($result['release_info']['source'])->toBe('altum_product'); +}); + +it('detects no update when product version is current', function () { + Http::fake([ + 'https://66biolinks.com/info.php' => Http::response([ + 'latest_release_version' => '39.0.0', + ], 200), + ]); + + $vendor = new Vendor([ + 'slug' => '66biolinks', + 'name' => '66biolinks', + 'plugin_platform' => Vendor::PLATFORM_ALTUM, + 'source_type' => Vendor::SOURCE_LICENSED, + 'current_version' => '39.0.0', + ]); + + $method = new ReflectionMethod($this->service, 'checkAltumProduct'); + $method->setAccessible(true); + + $result = $method->invoke($this->service, $vendor); + + expect($result['status'])->toBe('success') + ->and($result['has_update'])->toBeFalse() + ->and($result['latest'])->toBe('39.0.0'); +}); + +it('returns error when altum product endpoint fails', function () { + Http::fake([ + 'https://66analytics.com/info.php' => Http::response('Server Error', 500), + ]); + + $vendor = new Vendor([ + 'slug' => '66analytics', + 'name' => '66analytics', + 'plugin_platform' => Vendor::PLATFORM_ALTUM, + 'source_type' => Vendor::SOURCE_LICENSED, + 'current_version' => '40.0.0', + ]); + + $method = new ReflectionMethod($this->service, 'checkAltumProduct'); + $method->setAccessible(true); + + $result = $method->invoke($this->service, $vendor); + + expect($result['status'])->toBe('error') + ->and($result['message'])->toContain('500'); +}); + +it('returns error when altum product has no version in response', function () { + Http::fake([ + 'https://66pusher.com/info.php' => Http::response([ + 'product_name' => '66pusher', + // No latest_release_version key + ], 200), + ]); + + $vendor = new Vendor([ + 'slug' => '66pusher', + 'name' => '66pusher', + 'plugin_platform' => Vendor::PLATFORM_ALTUM, + 'source_type' => Vendor::SOURCE_LICENSED, + 'current_version' => '5.0.0', + ]); + + $method = new ReflectionMethod($this->service, 'checkAltumProduct'); + $method->setAccessible(true); + + $result = $method->invoke($this->service, $vendor); + + expect($result['status'])->toBe('error') + ->and($result['message'])->toContain('Could not determine latest version'); +}); + +// --------------------------------------------------------------------------- +// checkAltumPlugin +// --------------------------------------------------------------------------- + +it('fetches latest version for an altum plugin', function () { + Http::fake([ + 'https://dev.altumcode.com/plugins-versions' => Http::response([ + 'affiliate' => ['version' => '2.0.1'], + 'email-notifications' => ['version' => '3.1.0'], + ], 200), + ]); + + $vendor = new Vendor([ + 'slug' => 'altum-plugin-affiliate', + 'name' => 'AltumCode Affiliate Plugin', + 'plugin_platform' => Vendor::PLATFORM_ALTUM, + 'source_type' => Vendor::SOURCE_PLUGIN, + 'current_version' => '1.5.0', + ]); + + $method = new ReflectionMethod($this->service, 'checkAltumPlugin'); + $method->setAccessible(true); + + $result = $method->invoke($this->service, $vendor); + + expect($result['status'])->toBe('success') + ->and($result['latest'])->toBe('2.0.1') + ->and($result['has_update'])->toBeTrue() + ->and($result['release_info']['source'])->toBe('altum_plugin') + ->and($result['release_info']['plugin_id'])->toBe('affiliate'); +}); + +it('returns error when plugin is not found in altum registry', function () { + Http::fake([ + 'https://dev.altumcode.com/plugins-versions' => Http::response([ + 'affiliate' => ['version' => '2.0.1'], + ], 200), + ]); + + $vendor = new Vendor([ + 'slug' => 'altum-plugin-nonexistent', + 'name' => 'Nonexistent Plugin', + 'plugin_platform' => Vendor::PLATFORM_ALTUM, + 'source_type' => Vendor::SOURCE_PLUGIN, + 'current_version' => '1.0.0', + ]); + + $method = new ReflectionMethod($this->service, 'checkAltumPlugin'); + $method->setAccessible(true); + + $result = $method->invoke($this->service, $vendor); + + expect($result['status'])->toBe('error') + ->and($result['message'])->toContain('nonexistent') + ->and($result['message'])->toContain('not found'); +}); + +it('returns error when plugin versions endpoint fails', function () { + Http::fake([ + 'https://dev.altumcode.com/plugins-versions' => Http::response('Bad Gateway', 502), + ]); + + $vendor = new Vendor([ + 'slug' => 'altum-plugin-affiliate', + 'name' => 'AltumCode Affiliate Plugin', + 'plugin_platform' => Vendor::PLATFORM_ALTUM, + 'source_type' => Vendor::SOURCE_PLUGIN, + 'current_version' => '1.0.0', + ]); + + $method = new ReflectionMethod($this->service, 'checkAltumPlugin'); + $method->setAccessible(true); + + $result = $method->invoke($this->service, $vendor); + + expect($result['status'])->toBe('error') + ->and($result['message'])->toContain('Failed to fetch'); +}); + +// --------------------------------------------------------------------------- +// In-memory cache +// --------------------------------------------------------------------------- + +it('caches plugin versions across multiple calls', function () { + Http::fake([ + 'https://dev.altumcode.com/plugins-versions' => Http::response([ + 'affiliate' => ['version' => '2.0.1'], + 'email-notifications' => ['version' => '3.1.0'], + ], 200), + ]); + + $method = new ReflectionMethod($this->service, 'getAltumPluginVersions'); + $method->setAccessible(true); + + // First call — should hit the HTTP endpoint + $first = $method->invoke($this->service); + expect($first)->toBeArray()->toHaveKey('affiliate'); + + // Second call — should use cache (no additional HTTP request) + $second = $method->invoke($this->service); + expect($second)->toBe($first); + + // Verify HTTP was only called once + Http::assertSentCount(1); +}); + +// --------------------------------------------------------------------------- +// Match routing via checkVendor (integration-level) +// --------------------------------------------------------------------------- + +it('routes altum licensed vendor through checkAltumProduct', function () { + Http::fake([ + 'https://66analytics.com/info.php' => Http::response([ + 'latest_release_version' => '42.0.0', + ], 200), + ]); + + $vendor = Mockery::mock(Vendor::class)->makePartial(); + $vendor->shouldReceive('getAttribute')->with('slug')->andReturn('66analytics'); + $vendor->shouldReceive('getAttribute')->with('name')->andReturn('66analytics'); + $vendor->shouldReceive('getAttribute')->with('plugin_platform')->andReturn(Vendor::PLATFORM_ALTUM); + $vendor->shouldReceive('getAttribute')->with('source_type')->andReturn(Vendor::SOURCE_LICENSED); + $vendor->shouldReceive('getAttribute')->with('current_version')->andReturn('41.0.0'); + $vendor->shouldReceive('getAttribute')->with('git_repo_url')->andReturn(null); + $vendor->shouldReceive('update')->once()->with(Mockery::on(function ($data) { + return array_key_exists('last_checked_at', $data); + })); + $vendor->shouldReceive('isLicensed')->andReturn(true); + $vendor->shouldReceive('isPlugin')->andReturn(false); + $vendor->shouldReceive('isOss')->andReturn(false); + + // Use a partial mock of the service to prevent createUpdateTodo from hitting DB + $service = Mockery::mock(VendorUpdateCheckerService::class)->makePartial(); + $service->shouldAllowMockingProtectedMethods(); + $service->shouldReceive('createUpdateTodo')->once(); + + $result = $service->checkVendor($vendor); + + expect($result['status'])->toBe('success') + ->and($result['latest'])->toBe('42.0.0') + ->and($result['has_update'])->toBeTrue(); + + Http::assertSent(function ($request) { + return $request->url() === 'https://66analytics.com/info.php'; + }); +}); + +it('routes altum plugin vendor through checkAltumPlugin', function () { + Http::fake([ + 'https://dev.altumcode.com/plugins-versions' => Http::response([ + 'affiliate' => ['version' => '2.0.1'], + ], 200), + ]); + + $vendor = Mockery::mock(Vendor::class)->makePartial(); + $vendor->shouldReceive('getAttribute')->with('slug')->andReturn('altum-plugin-affiliate'); + $vendor->shouldReceive('getAttribute')->with('name')->andReturn('Affiliate Plugin'); + $vendor->shouldReceive('getAttribute')->with('plugin_platform')->andReturn(Vendor::PLATFORM_ALTUM); + $vendor->shouldReceive('getAttribute')->with('source_type')->andReturn(Vendor::SOURCE_PLUGIN); + $vendor->shouldReceive('getAttribute')->with('current_version')->andReturn('1.5.0'); + $vendor->shouldReceive('getAttribute')->with('git_repo_url')->andReturn(null); + $vendor->shouldReceive('update')->once()->with(Mockery::on(function ($data) { + return array_key_exists('last_checked_at', $data); + })); + $vendor->shouldReceive('isLicensed')->andReturn(false); + $vendor->shouldReceive('isPlugin')->andReturn(true); + $vendor->shouldReceive('isOss')->andReturn(false); + + // Use a partial mock of the service to prevent createUpdateTodo from hitting DB + $service = Mockery::mock(VendorUpdateCheckerService::class)->makePartial(); + $service->shouldAllowMockingProtectedMethods(); + $service->shouldReceive('createUpdateTodo')->once(); + + $result = $service->checkVendor($vendor); + + expect($result['status'])->toBe('success') + ->and($result['latest'])->toBe('2.0.1') + ->and($result['has_update'])->toBeTrue(); + + Http::assertSent(function ($request) { + return $request->url() === 'https://dev.altumcode.com/plugins-versions'; + }); +}); + +it('normalises version with v prefix from altum product', function () { + Http::fake([ + 'https://66socialproof.com/info.php' => Http::response([ + 'latest_release_version' => 'v7.0.0', + ], 200), + ]); + + $vendor = new Vendor([ + 'slug' => '66socialproof', + 'name' => '66socialproof', + 'plugin_platform' => Vendor::PLATFORM_ALTUM, + 'source_type' => Vendor::SOURCE_LICENSED, + 'current_version' => '6.0.0', + ]); + + $method = new ReflectionMethod($this->service, 'checkAltumProduct'); + $method->setAccessible(true); + + $result = $method->invoke($this->service, $vendor); + + expect($result['latest'])->toBe('7.0.0') + ->and($result['has_update'])->toBeTrue(); +});