diff --git a/app/Http/Controllers/Admin/Modules/ModuleInstallationController.php b/app/Http/Controllers/Admin/Modules/ModuleInstallationController.php index ed8ca3ab..440a4adf 100644 --- a/app/Http/Controllers/Admin/Modules/ModuleInstallationController.php +++ b/app/Http/Controllers/Admin/Modules/ModuleInstallationController.php @@ -15,7 +15,11 @@ class ModuleInstallationController extends Controller { $this->authorize('manage modules'); - $response = ModuleInstaller::download($request->module, $request->version); + $response = ModuleInstaller::download( + (string) $request->slug, + (string) $request->version, + $request->checksum_sha256 ? (string) $request->checksum_sha256 : null, + ); return response()->json($response); } @@ -33,7 +37,7 @@ class ModuleInstallationController extends Controller { $this->authorize('manage modules'); - $path = ModuleInstaller::unzip($request->module, $request->path); + $path = ModuleInstaller::unzip($request->module_name ?? $request->module, $request->path); return response()->json([ 'success' => true, @@ -45,7 +49,7 @@ class ModuleInstallationController extends Controller { $this->authorize('manage modules'); - $response = ModuleInstaller::copyFiles($request->module, $request->path); + $response = ModuleInstaller::copyFiles($request->module_name ?? $request->module, $request->path); return response()->json([ 'success' => $response, @@ -56,7 +60,7 @@ class ModuleInstallationController extends Controller { $this->authorize('manage modules'); - $response = ModuleInstaller::complete($request->module, $request->version); + $response = ModuleInstaller::complete($request->module_name ?? $request->module, $request->version); return response()->json([ 'success' => $response, diff --git a/app/Http/Controllers/Admin/Modules/ModulesController.php b/app/Http/Controllers/Admin/Modules/ModulesController.php index 34b8b8ba..c2c5102e 100644 --- a/app/Http/Controllers/Admin/Modules/ModulesController.php +++ b/app/Http/Controllers/Admin/Modules/ModulesController.php @@ -18,7 +18,13 @@ class ModulesController extends Controller { $this->authorize('manage modules'); - return ModuleInstaller::getModules(); + $response = ModuleInstaller::getModules(); + + if (($response['status'] ?? 0) !== 200 || ! isset($response['body']->modules)) { + return response()->json(['error' => 'marketplace_unavailable'], 503); + } + + return ModuleResource::collection(collect($response['body']->modules)); } public function show(Request $request, string $module) @@ -27,13 +33,19 @@ class ModulesController extends Controller $response = ModuleInstaller::getModule($module); - if (! $response->success) { - return response()->json($response); + if (($response['status'] ?? 0) === 404) { + return response()->json(['error' => 'not_found'], 404); } - return (new ModuleResource($response->module)) + if (($response['status'] ?? 0) !== 200 || ! isset($response['body']->data)) { + return response()->json(['error' => 'marketplace_unavailable'], 503); + } + + return (new ModuleResource($response['body']->data)) ->additional(['meta' => [ - 'modules' => ModuleResource::collection(collect($response->modules)), + 'modules' => ModuleResource::collection( + collect($response['body']->meta->modules ?? []) + ), ]]); } diff --git a/app/Http/Requests/UnzipUpdateRequest.php b/app/Http/Requests/UnzipUpdateRequest.php index cb4492ed..22301969 100644 --- a/app/Http/Requests/UnzipUpdateRequest.php +++ b/app/Http/Requests/UnzipUpdateRequest.php @@ -25,7 +25,11 @@ class UnzipUpdateRequest extends FormRequest 'regex:/^[\.\/\w\-]+$/', ], 'module' => [ - 'required', + 'nullable', + 'string', + ], + 'module_name' => [ + 'required_without:module', 'string', ], ]; diff --git a/app/Http/Resources/ModuleResource.php b/app/Http/Resources/ModuleResource.php index a93aee03..1def6327 100644 --- a/app/Http/Resources/ModuleResource.php +++ b/app/Http/Resources/ModuleResource.php @@ -3,11 +3,9 @@ namespace App\Http\Resources; use App\Models\Module as ModelsModule; -use App\Models\Setting; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; -use Nwidart\Modules\Facades\Module; class ModuleResource extends JsonResource { @@ -19,8 +17,7 @@ class ModuleResource extends JsonResource */ public function toArray($request): array { - $this->checkPurchased(); - $this->installed_module = ModelsModule::where('name', $this->module_name)->first(); + $installedModule = ModelsModule::where('name', $this->module_name)->first(); return [ 'id' => $this->id, @@ -28,107 +25,75 @@ class ModuleResource extends JsonResource 'cover' => $this->cover, 'slug' => $this->slug, 'module_name' => $this->module_name, + 'access_tier' => $this->access_tier ?? 'public', 'faq' => $this->faq, 'highlights' => $this->highlights, - 'installed_module_version' => $this->getInstalledModuleVersion(), - 'installed_module_version_updated_at' => $this->getInstalledModuleUpdatedAt(), - 'latest_module_version' => $this->latest_module_version->module_version, - 'latest_module_version_updated_at' => $this->latest_module_version->created_at, + 'installed_module_version' => $this->getInstalledModuleVersion($installedModule), + 'installed_module_version_updated_at' => $this->getInstalledModuleUpdatedAt($installedModule), + 'latest_module_version' => $this->latest_module_version, + 'latest_module_version_updated_at' => $this->latest_module_version_updated_at, + 'latest_min_invoiceshelf_version' => $this->latest_min_invoiceshelf_version ?? null, + 'latest_module_checksum_sha256' => $this->latest_module_checksum_sha256 ?? null, 'is_dev' => $this->is_dev, 'license' => $this->license, 'long_description' => $this->long_description, 'monthly_price' => $this->monthly_price, 'name' => $this->name, - 'purchased' => $this->purchased, + 'purchased' => $this->purchased ?? true, 'reviews' => $this->reviews ?? [], 'screenshots' => $this->screenshots, 'short_description' => $this->short_description, 'type' => $this->type, 'yearly_price' => $this->yearly_price, - 'author_name' => $this->author->name, - 'author_avatar' => $this->author->avatar, - 'installed' => $this->moduleInstalled(), - 'enabled' => $this->moduleEnabled(), - 'update_available' => $this->updateAvailable(), + 'author_name' => $this->author_name, + 'author_avatar' => $this->author_avatar, + 'installed' => $this->moduleInstalled($installedModule), + 'enabled' => $this->moduleEnabled($installedModule), + 'update_available' => $this->updateAvailable($installedModule), 'video_link' => $this->video_link, 'video_thumbnail' => $this->video_thumbnail, 'links' => $this->links, ]; } - public function getInstalledModuleVersion() + public function getInstalledModuleVersion(?ModelsModule $installedModule): ?string { - if (isset($this->installed_module) && $this->installed_module->installed) { - return $this->installed_module->version; + if ($installedModule && $installedModule->installed) { + return $installedModule->version; } return null; } - public function getInstalledModuleUpdatedAt() + public function getInstalledModuleUpdatedAt(?ModelsModule $installedModule): ?string { - if (isset($this->installed_module) && $this->installed_module->installed) { - return $this->installed_module->updated_at; + if ($installedModule && $installedModule->installed) { + return $installedModule->updated_at?->toIso8601String(); } return null; } - public function moduleInstalled() + public function moduleInstalled(?ModelsModule $installedModule): bool { - if (isset($this->installed_module) && $this->installed_module->installed) { - return true; - } - - return false; + return (bool) ($installedModule?->installed); } - public function moduleEnabled() + public function moduleEnabled(?ModelsModule $installedModule): bool { - if (isset($this->installed_module) && $this->installed_module->installed) { - return $this->installed_module->enabled; - } - - return false; + return (bool) ($installedModule?->installed && $installedModule?->enabled); } - public function updateAvailable() + public function updateAvailable(?ModelsModule $installedModule): bool { - if (! isset($this->installed_module)) { + if (! $installedModule || ! $installedModule->installed) { return false; } - if (! $this->installed_module->installed) { + if (! isset($this->latest_module_version) || ! is_string($this->latest_module_version)) { return false; } - if (! isset($this->latest_module_version)) { - return false; - } - - if (version_compare($this->installed_module->version, $this->latest_module_version->module_version, '>=')) { - return false; - } - - if (version_compare(Setting::getSetting('version'), $this->latest_module_version->invoiceshelf_version, '<')) { - return false; - } - - return true; - } - - public function checkPurchased() - { - if ($this->purchased) { - return true; - } - - if (Module::has($this->module_name)) { - $module = Module::find($this->module_name); - $module->disable(); - ModelsModule::where('name', $this->module_name)->update(['enabled' => false]); - } - - return false; + return version_compare($installedModule->version, $this->latest_module_version, '<'); } } diff --git a/app/Policies/ModulesPolicy.php b/app/Policies/ModulesPolicy.php index f5c6e886..b650a616 100644 --- a/app/Policies/ModulesPolicy.php +++ b/app/Policies/ModulesPolicy.php @@ -11,6 +11,10 @@ class ModulesPolicy public function manageModules(User $user) { + if ($user->isSuperAdmin()) { + return true; + } + if ($user->isOwner()) { return true; } diff --git a/app/Services/Module/ModuleInstaller.php b/app/Services/Module/ModuleInstaller.php index 52e3964c..4d38610f 100644 --- a/app/Services/Module/ModuleInstaller.php +++ b/app/Services/Module/ModuleInstaller.php @@ -4,7 +4,6 @@ namespace App\Services\Module; use App\Events\ModuleEnabledEvent; use App\Events\ModuleInstalledEvent; -use App\Http\Resources\ModuleResource; use App\Models\Module as ModelsModule; use App\Models\Setting; use App\Traits\SiteApi; @@ -19,89 +18,101 @@ class ModuleInstaller { use SiteApi; - public static function getModules() + private static function marketplaceToken(): ?string { - $data = null; - if (env('APP_ENV') === 'development') { - $url = 'api/marketplace/modules?is_dev=1'; - } else { - $url = 'api/marketplace/modules'; - } - $token = Setting::getSetting('api_token'); - $response = static::getRemote($url, ['timeout' => 100, 'track_redirects' => true], $token); - if ($response && ($response->getStatusCode() == 401)) { - return response()->json(['error' => 'invalid_token']); + if (! is_string($token) || trim($token) === '') { + return null; } - if ($response && ($response->getStatusCode() == 200)) { - $data = $response->getBody()->getContents(); - } - - $data = json_decode($data); - - return ModuleResource::collection(collect($data->modules)); + return $token; } - public static function getModule($module) + private static function decodeMarketplaceJson($response): array { - $data = null; - if (env('APP_ENV') === 'development') { - $url = 'api/marketplace/modules/'.$module.'?is_dev=1'; - } else { - $url = 'api/marketplace/modules/'.$module; + if ($response instanceof RequestException || ! $response) { + return [ + 'status' => 0, + 'body' => null, + ]; } - $token = Setting::getSetting('api_token'); - $response = static::getRemote($url, ['timeout' => 100, 'track_redirects' => true], $token); + $body = $response->getBody()->getContents(); - if ($response && ($response->getStatusCode() == 401)) { - return (object) ['success' => false, 'error' => 'invalid_token']; - } - - if ($response && ($response->getStatusCode() == 200)) { - $data = $response->getBody()->getContents(); - } - - $data = json_decode($data); - - return $data; + return [ + 'status' => $response->getStatusCode(), + 'body' => $body !== '' ? json_decode($body) : null, + ]; } - public static function upload($request) + public static function getModules(): array { - // Create temp directory - $temp_dir = storage_path('app/temp-'.md5(mt_rand())); + $url = env('APP_ENV') === 'development' + ? 'api/marketplace/modules?is_dev=1' + : 'api/marketplace/modules'; - if (! File::isDirectory($temp_dir)) { - File::makeDirectory($temp_dir); + $token = static::marketplaceToken(); + $decoded = static::decodeMarketplaceJson( + static::getRemote($url, ['timeout' => 100, 'track_redirects' => true], $token) + ); + + if ($decoded['status'] === 401 && $token !== null) { + $decoded = static::decodeMarketplaceJson( + static::getRemote($url, ['timeout' => 100, 'track_redirects' => true], null) + ); } - $path = $request->file('avatar')->storeAs( + return $decoded; + } + + public static function getModule(string $module): array + { + $url = env('APP_ENV') === 'development' + ? 'api/marketplace/modules/'.$module.'?is_dev=1' + : 'api/marketplace/modules/'.$module; + + $token = static::marketplaceToken(); + $decoded = static::decodeMarketplaceJson( + static::getRemote($url, ['timeout' => 100, 'track_redirects' => true], $token) + ); + + if ($decoded['status'] === 401 && $token !== null) { + $decoded = static::decodeMarketplaceJson( + static::getRemote($url, ['timeout' => 100, 'track_redirects' => true], null) + ); + } + + return $decoded; + } + + public static function upload($request): string + { + $tempDir = storage_path('app/temp-'.md5(mt_rand())); + + if (! File::isDirectory($tempDir)) { + File::makeDirectory($tempDir); + } + + return $request->file('avatar')->storeAs( 'temp-'.md5(mt_rand()), $request->module.'.zip', 'local' ); - - return $path; } - public static function download($module, $version) + public static function download(string $slug, string $version, ?string $checksumSha256 = null): array|bool { $data = null; $path = null; - if (env('APP_ENV') === 'development') { - $url = "api/marketplace/modules/file/{$module}?version={$version}&is_dev=1"; - } else { - $url = "api/marketplace/modules/file/{$module}?version={$version}"; - } + $url = env('APP_ENV') === 'development' + ? "api/marketplace/modules/file/{$slug}?version={$version}&is_dev=1" + : "api/marketplace/modules/file/{$slug}?version={$version}"; - $token = Setting::getSetting('api_token'); + $token = static::marketplaceToken(); $response = static::getRemote($url, ['timeout' => 100, 'track_redirects' => true], $token); - // Exception if ($response instanceof RequestException) { return [ 'success' => false, @@ -112,84 +123,102 @@ class ModuleInstaller ]; } - if ($response && ($response->getStatusCode() == 401 || $response->getStatusCode() == 404 || $response->getStatusCode() == 500)) { - return json_decode($response->getBody()->getContents()); + if ($response && $response->getStatusCode() === 401 && $token !== null) { + $response = static::getRemote($url, ['timeout' => 100, 'track_redirects' => true], null); } - if ($response && ($response->getStatusCode() == 200)) { + if ($response instanceof RequestException || ! $response) { + return [ + 'success' => false, + 'error' => 'Download Exception', + ]; + } + + if ($response && $response->getStatusCode() !== 200) { + $decoded = json_decode($response->getBody()->getContents(), true); + + return [ + 'success' => false, + 'error' => $decoded['error'] ?? 'Module download failed', + ]; + } + + if ($response && $response->getStatusCode() === 200) { $data = $response->getBody()->getContents(); } - // Create temp directory - $temp_dir = storage_path('app/temp-'.md5(mt_rand())); + $tempDir = storage_path('app/temp-'.md5(mt_rand())); - if (! File::isDirectory($temp_dir)) { - File::makeDirectory($temp_dir); + if (! File::isDirectory($tempDir)) { + File::makeDirectory($tempDir); } - $zip_file_path = $temp_dir.'/upload.zip'; - - // Add content to the Zip file - $uploaded = is_int(file_put_contents($zip_file_path, $data)) ? true : false; + $zipFilePath = $tempDir.'/upload.zip'; + $uploaded = is_int(file_put_contents($zipFilePath, $data)); if (! $uploaded) { return false; } + if ($checksumSha256 && hash_file('sha256', $zipFilePath) !== $checksumSha256) { + File::delete($zipFilePath); + + return [ + 'success' => false, + 'error' => 'Checksum verification failed', + ]; + } + return [ 'success' => true, - 'path' => $zip_file_path, + 'path' => $zipFilePath, ]; } - public static function unzip($module, $zip_file_path) + public static function unzip($module, $zipFilePath): string { - if (! file_exists($zip_file_path)) { + if (! file_exists($zipFilePath)) { throw new \Exception('Zip file not found'); } - $temp_extract_dir = storage_path('app/temp2-'.md5(mt_rand())); + $tempExtractDir = storage_path('app/temp2-'.md5(mt_rand())); - if (! File::isDirectory($temp_extract_dir)) { - File::makeDirectory($temp_extract_dir); + if (! File::isDirectory($tempExtractDir)) { + File::makeDirectory($tempExtractDir); } - // Unzip the file + $zip = new ZipArchive; - if ($zip->open($zip_file_path)) { - $zip->extractTo($temp_extract_dir); + if ($zip->open($zipFilePath)) { + $zip->extractTo($tempExtractDir); } $zip->close(); + File::delete($zipFilePath); - // Delete zip file - File::delete($zip_file_path); - - return $temp_extract_dir; + return $tempExtractDir; } - public static function copyFiles($module, $temp_extract_dir) + public static function copyFiles($module, $tempExtractDir): bool { if (! File::isDirectory(base_path('Modules'))) { File::makeDirectory(base_path('Modules')); } - // Delete Existing Module directory - if (! File::isDirectory(base_path('Modules').'/'.$module)) { + if (File::isDirectory(base_path('Modules').'/'.$module)) { File::deleteDirectory(base_path('Modules').'/'.$module); } - if (! File::copyDirectory($temp_extract_dir, base_path('Modules').'/')) { + if (! File::copyDirectory($tempExtractDir, base_path('Modules').'/')) { return false; } - // Delete temp directory - File::deleteDirectory($temp_extract_dir); + File::deleteDirectory($tempExtractDir); return true; } - public static function deleteFiles($json) + public static function deleteFiles($json): bool { $files = json_decode($json); @@ -200,7 +229,7 @@ class ModuleInstaller return true; } - public static function complete($module, $version) + public static function complete($module, $version): bool { Module::register(); @@ -208,7 +237,10 @@ class ModuleInstaller Artisan::call("module:seed $module --force"); Artisan::call("module:enable $module"); - $module = ModelsModule::updateOrCreate(['name' => $module], ['version' => $version, 'installed' => true, 'enabled' => true]); + $module = ModelsModule::updateOrCreate( + ['name' => $module], + ['version' => $version, 'installed' => true, 'enabled' => true] + ); ModuleInstalledEvent::dispatch($module); ModuleEnabledEvent::dispatch($module); @@ -219,12 +251,11 @@ class ModuleInstaller public static function checkToken(string $token) { $url = 'api/marketplace/ping'; - $response = static::getRemote($url, ['timeout' => 100, 'track_redirects' => true], $token); + $normalizedToken = trim($token) !== '' ? $token : null; + $response = static::getRemote($url, ['timeout' => 100, 'track_redirects' => true], $normalizedToken); - if ($response && ($response->getStatusCode() == 200)) { - $data = $response->getBody()->getContents(); - - return response()->json(json_decode($data)); + if ($response && $response->getStatusCode() === 200) { + return response()->json(json_decode($response->getBody()->getContents())); } return response()->json(['error' => 'invalid_token']); diff --git a/resources/scripts/api/services/module.service.ts b/resources/scripts/api/services/module.service.ts index 516f3468..d9c6db15 100644 --- a/resources/scripts/api/services/module.service.ts +++ b/resources/scripts/api/services/module.service.ts @@ -1,26 +1,30 @@ import { client } from '../client' import { API } from '../endpoints' import type { ApiResponse } from '@/scripts/types/api' - -export interface Module { - name: string - slug: string - description: string - version: string - enabled: boolean - installed: boolean - [key: string]: unknown -} +import type { Module } from '@/scripts/types/domain/module' export interface ModuleCheckResponse { error?: string success?: boolean + authenticated?: boolean + premium?: boolean +} + +export interface ModuleDetailMeta { + modules: Module[] } export interface ModuleInstallPayload { - module: string + slug: string + module_name: string version: string - api_token?: string + checksum_sha256?: string | null + path?: string +} + +export interface ModuleDetailResponse { + data: Module + meta: ModuleDetailMeta } export const moduleService = { @@ -29,7 +33,7 @@ export const moduleService = { return data }, - async get(module: string): Promise { + async get(module: string): Promise { const { data } = await client.get(`${API.MODULES}/${module}`) return data }, diff --git a/resources/scripts/types/domain/module.ts b/resources/scripts/types/domain/module.ts index e2c2e2ea..3145c86f 100644 --- a/resources/scripts/types/domain/module.ts +++ b/resources/scripts/types/domain/module.ts @@ -1,17 +1,13 @@ -export interface ModuleAuthor { - name: string - avatar: string -} - export interface ModuleVersion { module_version: string - invoiceshelf_version: string + invoiceshelf_version: string | null created_at: string } export interface ModuleLink { - name: string - url: string + icon: string + label: string + link: string } export interface ModuleReview { @@ -38,12 +34,15 @@ export interface Module { cover: string | null slug: string module_name: string + access_tier: 'public' | 'premium' faq: ModuleFaq[] | null highlights: string[] | null installed_module_version: string | null installed_module_version_updated_at: string | null latest_module_version: string latest_module_version_updated_at: string + latest_min_invoiceshelf_version: string | null + latest_module_checksum_sha256: string | null is_dev: boolean license: string | null long_description: string | null diff --git a/tests/Feature/Admin/Modules/ModuleAuthorizationTest.php b/tests/Feature/Admin/Modules/ModuleAuthorizationTest.php new file mode 100644 index 00000000..afc3c14a --- /dev/null +++ b/tests/Feature/Admin/Modules/ModuleAuthorizationTest.php @@ -0,0 +1,22 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); + + Sanctum::actingAs(User::find(1), ['*']); +}); + +it('allows super admins to validate marketplace tokens without a company header in admin mode', function () { + getJson('/api/v1/modules/check?api_token=test-marketplace-token') + ->assertOk() + ->assertJson([ + 'error' => 'invalid_token', + ]); +}); diff --git a/tests/Feature/Admin/Modules/ModuleResourceTest.php b/tests/Feature/Admin/Modules/ModuleResourceTest.php new file mode 100644 index 00000000..815bc967 --- /dev/null +++ b/tests/Feature/Admin/Modules/ModuleResourceTest.php @@ -0,0 +1,101 @@ + 7, + 'slug' => 'sales-tax-us', + 'name' => 'Sales Tax US', + 'module_name' => 'SalesTaxUs', + 'access_tier' => 'premium', + 'cover' => 'https://example.com/cover.png', + 'short_description' => 'Short description', + 'long_description' => 'Long description', + 'highlights' => ['Fast install'], + 'screenshots' => [['url' => 'https://example.com/shot.png', 'title' => 'Shot']], + 'faq' => [['question' => 'Q', 'answer' => 'A']], + 'links' => [['icon' => 'BookOpenIcon', 'label' => 'Docs', 'link' => 'https://example.com/docs']], + 'video_link' => null, + 'video_thumbnail' => null, + 'type' => 'integration', + 'is_dev' => false, + 'author_name' => 'InvoiceShelf', + 'author_avatar' => 'https://example.com/avatar.png', + 'latest_module_version' => '1.2.0', + 'latest_module_version_updated_at' => now()->toIso8601String(), + 'latest_min_invoiceshelf_version' => '3.0.0', + 'latest_module_checksum_sha256' => hash('sha256', 'sales-tax-us-1.2.0'), + 'installed_module_version' => null, + 'installed_module_version_updated_at' => null, + 'installed' => false, + 'enabled' => false, + 'update_available' => false, + 'reviews' => [], + 'average_rating' => null, + 'monthly_price' => null, + 'yearly_price' => null, + 'purchased' => true, + 'license' => null, + ]; + + $data = (new ModuleResource($payload))->toArray(Request::create('/')); + + expect($data)->toMatchArray([ + 'slug' => 'sales-tax-us', + 'module_name' => 'SalesTaxUs', + 'access_tier' => 'premium', + 'author_name' => 'InvoiceShelf', + 'latest_module_version' => '1.2.0', + 'latest_min_invoiceshelf_version' => '3.0.0', + ]); +}); + +it('overlays installed module state and update availability locally', function () { + InstalledModule::query()->create([ + 'name' => 'SalesTaxUs', + 'version' => '1.0.0', + 'installed' => true, + 'enabled' => true, + ]); + + $payload = (object) [ + 'id' => 7, + 'slug' => 'sales-tax-us', + 'name' => 'Sales Tax US', + 'module_name' => 'SalesTaxUs', + 'access_tier' => 'public', + 'cover' => null, + 'short_description' => null, + 'long_description' => null, + 'highlights' => null, + 'screenshots' => null, + 'faq' => null, + 'links' => null, + 'video_link' => null, + 'video_thumbnail' => null, + 'type' => null, + 'is_dev' => false, + 'author_name' => 'InvoiceShelf', + 'author_avatar' => null, + 'latest_module_version' => '1.1.0', + 'latest_module_version_updated_at' => now()->toIso8601String(), + 'latest_min_invoiceshelf_version' => '3.0.0', + 'latest_module_checksum_sha256' => hash('sha256', 'sales-tax-us-1.1.0'), + 'reviews' => [], + 'average_rating' => null, + 'monthly_price' => null, + 'yearly_price' => null, + 'purchased' => true, + 'license' => null, + ]; + + $data = (new ModuleResource($payload))->toArray(Request::create('/')); + + expect($data['installed'])->toBeTrue() + ->and($data['enabled'])->toBeTrue() + ->and($data['installed_module_version'])->toBe('1.0.0') + ->and($data['update_available'])->toBeTrue(); +});