From 8579e4f85fa94e6e7ec9caa34f9f427d01190c9b Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski <5760249+gdarko@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:49:42 +0200 Subject: [PATCH] feat: add secure module marketplace runtime (#745) --- .env.example | 7 + .../Http/Controllers/HelloWorldController.php | 56 -- .../app/Providers/EventServiceProvider.php | 27 - .../Providers/HelloWorldServiceProvider.php | 122 ---- .../app/Providers/RouteServiceProvider.php | 50 -- Modules/HelloWorld/composer.json | 33 - Modules/HelloWorld/config/config.php | 5 - .../seeders/HelloWorldDatabaseSeeder.php | 16 - Modules/HelloWorld/lang/en/menu.php | 5 - Modules/HelloWorld/lang/en/settings.php | 12 - Modules/HelloWorld/module.json | 11 - Modules/HelloWorld/package.json | 12 - Modules/HelloWorld/resources/assets/js/app.js | 0 .../HelloWorld/resources/assets/sass/app.scss | 0 Modules/HelloWorld/resources/dist/init.js | 83 --- Modules/HelloWorld/resources/js/init.ts | 23 - .../resources/js/pages/DashboardPage.vue | 49 -- Modules/HelloWorld/resources/js/vue-shim.js | 37 - .../views/components/layouts/master.blade.php | 30 - .../resources/views/index.blade.php | 5 - Modules/HelloWorld/routes/api.php | 8 - Modules/HelloWorld/routes/web.php | 8 - Modules/HelloWorld/vite.config.js | 69 -- .../Modules/MarketplacePairingController.php | 97 +++ .../Modules/ModuleInstallationController.php | 61 +- .../Admin/Modules/ModulesController.php | 32 +- .../Company/General/BootstrapController.php | 1 - .../Modules/CompanyModulesController.php | 8 +- .../Modules/ModuleSettingsController.php | 6 +- .../Controllers/Modules/ScriptController.php | 18 +- .../Controllers/Modules/StyleController.php | 18 +- .../InstallMarketplaceModuleRequest.php | 30 + app/Http/Resources/ModuleResource.php | 81 ++- app/Models/MarketplaceCredential.php | 20 + app/Models/MarketplaceOperation.php | 19 + app/Models/Module.php | 9 + app/Policies/ModulesPolicy.php | 10 +- app/Providers/AppServiceProvider.php | 2 + app/Services/Marketplace/CanonicalJson.php | 36 + .../Marketplace/DatabaseActivator.php | 114 ++++ .../Marketplace/MarketplaceClient.php | 121 ++++ .../Marketplace/MarketplaceInstaller.php | 638 ++++++++++++++++++ .../Marketplace/ModuleRuntimeAutoloader.php | 64 ++ app/Support/Module/ModuleAssetVersion.php | 20 + app/Support/Module/ModuleInstaller.php | 232 ------- app/Traits/SiteApi.php | 2 +- bootstrap/app.php | 6 + composer.json | 2 +- config/invoiceshelf.php | 37 +- config/modules.php | 14 +- ...2902_create_marketplace_runtime_tables.php | 54 ++ docker/production/docker-compose.mysql.yml | 2 + docker/production/docker-compose.sqlite.yml | 2 + public/openapi.json | 181 +---- resources/scripts/api/endpoints.ts | 8 +- resources/scripts/api/index.ts | 1 - resources/scripts/api/services/index.ts | 2 +- .../scripts/api/services/module.service.ts | 68 +- .../scripts/features/admin/modules/store.ts | 185 ++--- .../admin/modules/views/ModuleDetailView.vue | 8 +- .../admin/modules/views/ModuleIndexView.vue | 258 ++----- resources/scripts/types/domain/module.ts | 21 +- resources/views/app.blade.php | 6 +- routes/api.php | 12 +- storage/app/.gitignore | 1 - storage/app/modules_statuses.json | 3 - .../Admin/Modules/ModuleAuthorizationTest.php | 22 - .../Admin/Modules/ModuleResourceTest.php | 2 +- .../Feature/Company/BootstrapSecurityTest.php | 26 + .../Modules/HelloWorldIntegrationTest.php | 113 ---- .../Company/Modules/ModuleMakeStubTest.php | 12 +- .../Feature/Marketplace/CanonicalJsonTest.php | 15 + .../Marketplace/MarketplaceClientTest.php | 41 ++ .../Marketplace/MarketplaceInstallerTest.php | 172 +++++ .../Marketplace/MarketplacePairingTest.php | 97 +++ .../ModuleRuntimeAutoloaderTest.php | 26 + .../Feature/Modules/ModuleAssetCacheTest.php | 87 +++ tests/Pest.php | 7 +- tests/Unit/MarketplaceConfigurationTest.php | 48 ++ 79 files changed, 2084 insertions(+), 1762 deletions(-) delete mode 100644 Modules/HelloWorld/app/Http/Controllers/HelloWorldController.php delete mode 100644 Modules/HelloWorld/app/Providers/EventServiceProvider.php delete mode 100644 Modules/HelloWorld/app/Providers/HelloWorldServiceProvider.php delete mode 100644 Modules/HelloWorld/app/Providers/RouteServiceProvider.php delete mode 100644 Modules/HelloWorld/composer.json delete mode 100644 Modules/HelloWorld/config/config.php delete mode 100644 Modules/HelloWorld/database/seeders/HelloWorldDatabaseSeeder.php delete mode 100644 Modules/HelloWorld/lang/en/menu.php delete mode 100644 Modules/HelloWorld/lang/en/settings.php delete mode 100644 Modules/HelloWorld/module.json delete mode 100644 Modules/HelloWorld/package.json delete mode 100644 Modules/HelloWorld/resources/assets/js/app.js delete mode 100644 Modules/HelloWorld/resources/assets/sass/app.scss delete mode 100644 Modules/HelloWorld/resources/dist/init.js delete mode 100644 Modules/HelloWorld/resources/js/init.ts delete mode 100644 Modules/HelloWorld/resources/js/pages/DashboardPage.vue delete mode 100644 Modules/HelloWorld/resources/js/vue-shim.js delete mode 100644 Modules/HelloWorld/resources/views/components/layouts/master.blade.php delete mode 100644 Modules/HelloWorld/resources/views/index.blade.php delete mode 100644 Modules/HelloWorld/routes/api.php delete mode 100644 Modules/HelloWorld/routes/web.php delete mode 100644 Modules/HelloWorld/vite.config.js create mode 100644 app/Http/Controllers/Admin/Modules/MarketplacePairingController.php create mode 100644 app/Http/Requests/InstallMarketplaceModuleRequest.php create mode 100644 app/Models/MarketplaceCredential.php create mode 100644 app/Models/MarketplaceOperation.php create mode 100644 app/Services/Marketplace/CanonicalJson.php create mode 100644 app/Services/Marketplace/DatabaseActivator.php create mode 100644 app/Services/Marketplace/MarketplaceClient.php create mode 100644 app/Services/Marketplace/MarketplaceInstaller.php create mode 100644 app/Services/Marketplace/ModuleRuntimeAutoloader.php create mode 100644 app/Support/Module/ModuleAssetVersion.php create mode 100644 database/migrations/2026_08_04_222902_create_marketplace_runtime_tables.php delete mode 100644 storage/app/modules_statuses.json delete mode 100644 tests/Feature/Admin/Modules/ModuleAuthorizationTest.php create mode 100644 tests/Feature/Company/BootstrapSecurityTest.php delete mode 100644 tests/Feature/Company/Modules/HelloWorldIntegrationTest.php create mode 100644 tests/Feature/Marketplace/CanonicalJsonTest.php create mode 100644 tests/Feature/Marketplace/MarketplaceClientTest.php create mode 100644 tests/Feature/Marketplace/MarketplaceInstallerTest.php create mode 100644 tests/Feature/Marketplace/MarketplacePairingTest.php create mode 100644 tests/Feature/Marketplace/ModuleRuntimeAutoloaderTest.php create mode 100644 tests/Feature/Modules/ModuleAssetCacheTest.php create mode 100644 tests/Unit/MarketplaceConfigurationTest.php diff --git a/.env.example b/.env.example index 6c943abc..5c25463c 100644 --- a/.env.example +++ b/.env.example @@ -55,3 +55,10 @@ DOMPDF_ENABLE_REMOTE=false # Defaults to https://invoiceshelf.com. Override to point at a local website # checkout for development: # INVOICESHELF_BASE_URL=http://invoiceshelf-website.test + +# Secure module marketplace. The official Ed25519 public key is built in. +# Add rotation keys, or replace a key ID during an emergency rotation, with a +# JSON key-id to base64 public-key map. +# MARKETPLACE_PUBLIC_KEYS={"marketplace-2027":"base64-ed25519-public-key"} +# MARKETPLACE_CHANNEL=stable +# MARKETPLACE_MODULE_API_VERSION=1.0.0 diff --git a/Modules/HelloWorld/app/Http/Controllers/HelloWorldController.php b/Modules/HelloWorld/app/Http/Controllers/HelloWorldController.php deleted file mode 100644 index f83a554e..00000000 --- a/Modules/HelloWorld/app/Http/Controllers/HelloWorldController.php +++ /dev/null @@ -1,56 +0,0 @@ -> - */ - protected $listen = []; - - /** - * Indicates if events should be discovered. - * - * @var bool - */ - protected static $shouldDiscoverEvents = true; - - /** - * Configure the proper event listeners for email verification. - */ - protected function configureEmailVerification(): void {} -} diff --git a/Modules/HelloWorld/app/Providers/HelloWorldServiceProvider.php b/Modules/HelloWorld/app/Providers/HelloWorldServiceProvider.php deleted file mode 100644 index d318ef84..00000000 --- a/Modules/HelloWorld/app/Providers/HelloWorldServiceProvider.php +++ /dev/null @@ -1,122 +0,0 @@ -name); - - // ---------------------------------------------------------------- - // Module script (Vue page registration) - // ---------------------------------------------------------------- - // Registers a compiled JS file that the host app injects as a - // diff --git a/Modules/HelloWorld/resources/js/vue-shim.js b/Modules/HelloWorld/resources/js/vue-shim.js deleted file mode 100644 index e5fbabaa..00000000 --- a/Modules/HelloWorld/resources/js/vue-shim.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Vue runtime shim for InvoiceShelf modules. - * - * The host app exposes its Vue instance on window.__invoiceshelf_vue. - * This shim provides a Proxy-based default export that lazily resolves - * Vue APIs on first access — avoiding the crash that happens when the - * module script evaluates before the host has set the global. - * - * For named exports (used by SFC compiled templates), we use a Proxy - * as the module namespace. Vite's lib mode with a default export - * from a Proxy works because the compiled SFC template accesses - * the APIs at render time (long after the host has initialized), - * not at module evaluation time. - */ - -function getVue() { - const vue = window.__invoiceshelf_vue - if (!vue) { - throw new Error( - '[InvoiceShelf Module] Host Vue runtime not available. ' + - 'Ensure the module script loads after the host app.' - ) - } - return vue -} - -// Proxy that forwards all property access to the host's Vue at call time -const vueProxy = new Proxy({}, { - get(_, key) { - return getVue()[key] - }, - has(_, key) { - return key in getVue() - }, -}) - -export default vueProxy diff --git a/Modules/HelloWorld/resources/views/components/layouts/master.blade.php b/Modules/HelloWorld/resources/views/components/layouts/master.blade.php deleted file mode 100644 index 8f45477f..00000000 --- a/Modules/HelloWorld/resources/views/components/layouts/master.blade.php +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - HelloWorld Module - {{ config('app.name', 'Laravel') }} - - - - - - - - - - {{-- Vite CSS --}} - {{-- {{ module_vite('build-helloworld', 'resources/assets/sass/app.scss') }} --}} - - - - {{ $slot }} - - {{-- Vite JS --}} - {{-- {{ module_vite('build-helloworld', 'resources/assets/js/app.js') }} --}} - - diff --git a/Modules/HelloWorld/resources/views/index.blade.php b/Modules/HelloWorld/resources/views/index.blade.php deleted file mode 100644 index 6c350d07..00000000 --- a/Modules/HelloWorld/resources/views/index.blade.php +++ /dev/null @@ -1,5 +0,0 @@ - -

Hello World

- -

Module: {!! config('helloworld.name') !!}

-
diff --git a/Modules/HelloWorld/routes/api.php b/Modules/HelloWorld/routes/api.php deleted file mode 100644 index ad8f2410..00000000 --- a/Modules/HelloWorld/routes/api.php +++ /dev/null @@ -1,8 +0,0 @@ -prefix('v1')->group(function () { - Route::apiResource('helloworlds', HelloWorldController::class)->names('helloworld'); -}); diff --git a/Modules/HelloWorld/routes/web.php b/Modules/HelloWorld/routes/web.php deleted file mode 100644 index 208babb4..00000000 --- a/Modules/HelloWorld/routes/web.php +++ /dev/null @@ -1,8 +0,0 @@ -group(function () { - Route::resource('helloworlds', HelloWorldController::class)->names('helloworld'); -}); diff --git a/Modules/HelloWorld/vite.config.js b/Modules/HelloWorld/vite.config.js deleted file mode 100644 index f1d593f0..00000000 --- a/Modules/HelloWorld/vite.config.js +++ /dev/null @@ -1,69 +0,0 @@ -import { defineConfig } from 'vite' -import vue from '@vitejs/plugin-vue' -import { resolve } from 'path' - -/** - * Module build config. - * - * Produces a single `resources/dist/init.js` ES module that the host app - * loads via @else - + @php($version = \App\Support\Module\ModuleAssetVersion::forPath($path)) + @endif @endforeach diff --git a/routes/api.php b/routes/api.php index d6111e0b..f6ec51ed 100644 --- a/routes/api.php +++ b/routes/api.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Admin\CompaniesController; use App\Http\Controllers\Admin\CountriesController; use App\Http\Controllers\Admin\CurrenciesController; use App\Http\Controllers\Admin\FontController; +use App\Http\Controllers\Admin\Modules\MarketplacePairingController; use App\Http\Controllers\Admin\Modules\ModuleInstallationController; use App\Http\Controllers\Admin\Modules\ModulesController; use App\Http\Controllers\Admin\Settings\AiConfigurationController; @@ -521,16 +522,15 @@ Route::prefix('/v1')->group(function () { Route::prefix('/modules')->group(function () { Route::get('/', [ModulesController::class, 'index']); - Route::get('/check', [ModulesController::class, 'checkToken']); + Route::get('/pairing', [MarketplacePairingController::class, 'status']); + Route::post('/pairing/start', [MarketplacePairingController::class, 'start']); + Route::post('/pairing/poll', [MarketplacePairingController::class, 'poll']); + Route::delete('/pairing', [MarketplacePairingController::class, 'disconnect']); Route::get('/{module}', [ModulesController::class, 'show']); Route::post('/{module}/enable', [ModulesController::class, 'enable']); Route::post('/{module}/disable', [ModulesController::class, 'disable']); - Route::post('/download', [ModuleInstallationController::class, 'download']); - Route::post('/upload', [ModuleInstallationController::class, 'upload']); - Route::post('/unzip', [ModuleInstallationController::class, 'unzip']); - Route::post('/copy', [ModuleInstallationController::class, 'copy']); - Route::post('/complete', [ModuleInstallationController::class, 'complete']); + Route::post('/install', [ModuleInstallationController::class, 'install']); // Per-slug settings (schema-driven, per-company storage) Route::get('/{slug}/settings', [ModuleSettingsController::class, 'show']); diff --git a/storage/app/.gitignore b/storage/app/.gitignore index 8b58e1f2..711d1cbd 100644 --- a/storage/app/.gitignore +++ b/storage/app/.gitignore @@ -2,4 +2,3 @@ !public/ !templates/ !.gitignore -!modules_statuses.json diff --git a/storage/app/modules_statuses.json b/storage/app/modules_statuses.json deleted file mode 100644 index 3c96ecbe..00000000 --- a/storage/app/modules_statuses.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "HelloWorld": true -} \ No newline at end of file diff --git a/tests/Feature/Admin/Modules/ModuleAuthorizationTest.php b/tests/Feature/Admin/Modules/ModuleAuthorizationTest.php deleted file mode 100644 index afc3c14a..00000000 --- a/tests/Feature/Admin/Modules/ModuleAuthorizationTest.php +++ /dev/null @@ -1,22 +0,0 @@ - '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 index 815bc967..6d641396 100644 --- a/tests/Feature/Admin/Modules/ModuleResourceTest.php +++ b/tests/Feature/Admin/Modules/ModuleResourceTest.php @@ -5,7 +5,7 @@ use App\Models\Module as InstalledModule; use Illuminate\Http\Request; it('maps the marketplace payload shape expected by the admin modules ui', function () { - $payload = (object) [ + $payload = [ 'id' => 7, 'slug' => 'sales-tax-us', 'name' => 'Sales Tax US', diff --git a/tests/Feature/Company/BootstrapSecurityTest.php b/tests/Feature/Company/BootstrapSecurityTest.php new file mode 100644 index 00000000..9ed247a2 --- /dev/null +++ b/tests/Feature/Company/BootstrapSecurityTest.php @@ -0,0 +1,26 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); + + $user = User::findOrFail(1); + $this->withHeaders(['company' => $user->companies()->firstOrFail()->id]); + Sanctum::actingAs($user, ['*']); +}); + +test('bootstrap does not expose the retired marketplace API token', function () { + Setting::setSetting('api_token', 'legacy-marketplace-token'); + + getJson('/api/v1/bootstrap') + ->assertOk() + ->assertJsonMissingPath('global_settings.api_token') + ->assertDontSee('legacy-marketplace-token'); +}); diff --git a/tests/Feature/Company/Modules/HelloWorldIntegrationTest.php b/tests/Feature/Company/Modules/HelloWorldIntegrationTest.php deleted file mode 100644 index a1958a6e..00000000 --- a/tests/Feature/Company/Modules/HelloWorldIntegrationTest.php +++ /dev/null @@ -1,113 +0,0 @@ - 'DatabaseSeeder', '--force' => true]); - Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); - - $user = User::find(1); - $this->companyId = $user->companies()->first()->id; - $this->withHeaders([ - 'company' => $this->companyId, - ]); - Sanctum::actingAs($user, ['*']); - - // Mark the module as activated at the InvoiceShelf instance level so it - // shows up in the company-context Active Modules index. - Module::query()->updateOrCreate( - ['name' => 'HelloWorld'], - ['version' => '1.0.0', 'installed' => true, 'enabled' => true], - ); -}); - -test('bootstrap merges HelloWorld into main_menu under modules group', function () { - $response = getJson('api/v1/bootstrap')->assertOk(); - - $mainMenu = collect($response->json('main_menu')); - $helloWorld = $mainMenu->firstWhere('name', 'module-hello-world'); - - expect($helloWorld)->not->toBeNull(); - expect($helloWorld['link'])->toBe('/admin/modules/hello-world/dashboard'); - expect($helloWorld['icon'])->toBe('HandRaisedIcon'); - expect($helloWorld['group'])->toBe('modules'); -}); - -test('HelloWorld appears in the company Active Modules index with translated display name', function () { - $response = getJson('api/v1/company-modules')->assertOk(); - - // The DB row stores PascalCase but the controller normalizes to kebab-case - // for the URL/registry slug. - $row = collect($response->json('data'))->firstWhere('slug', 'hello-world'); - expect($row)->not->toBeNull(); - expect($row['name'])->toBe('HelloWorld'); - expect($row['display_name'])->toBe('Hello World'); - expect($row['has_settings'])->toBeTrue(); - expect($row['menu']['title'])->toBe('Hello World'); - expect($row['menu']['icon'])->toBe('HandRaisedIcon'); -}); - -test('GET module settings returns the translated HelloWorld schema with defaults', function () { - $response = getJson('api/v1/modules/hello-world/settings')->assertOk(); - - $sections = $response->json('schema.sections'); - expect($sections)->toHaveCount(2); - expect($sections[0]['title'])->toBe('Greeting'); - - $fields = collect($sections[0]['fields'])->keyBy('key'); - expect($fields)->toHaveKeys(['greeting', 'recipient', 'show_emoji']); - expect($fields['greeting']['type'])->toBe('text'); - expect($fields['greeting']['label'])->toBe('Greeting message'); - expect($fields['greeting']['rules'])->toContain('required'); - - // Defaults flow through when nothing has been saved yet - $values = $response->json('values'); - expect($values['greeting'])->toBe('Hello, world!'); - expect($values['show_emoji'])->toBeTrue(); -}); - -test('PUT module settings persists values per company', function () { - putJson('api/v1/modules/hello-world/settings', [ - 'greeting' => 'Bonjour!', - 'recipient' => 'Marie', - 'show_emoji' => false, - 'tone' => 'formal', - 'note' => 'A custom welcome.', - ])->assertOk(); - - expect(CompanySetting::getSetting('module.hello-world.greeting', $this->companyId)) - ->toBe('Bonjour!'); - expect(CompanySetting::getSetting('module.hello-world.show_emoji', $this->companyId)) - ->toBe('0'); - expect(CompanySetting::getSetting('module.hello-world.tone', $this->companyId)) - ->toBe('formal'); - - // Re-fetch and confirm the values round-trip through the show endpoint - $response = getJson('api/v1/modules/hello-world/settings')->assertOk(); - expect($response->json('values.greeting'))->toBe('Bonjour!'); - expect($response->json('values.tone'))->toBe('formal'); -}); - -test('PUT rejects when required fields are missing', function () { - putJson('api/v1/modules/hello-world/settings', [ - 'recipient' => 'No greeting given', - ])->assertStatus(422) - ->assertJsonValidationErrors(['greeting', 'tone']); -}); diff --git a/tests/Feature/Company/Modules/ModuleMakeStubTest.php b/tests/Feature/Company/Modules/ModuleMakeStubTest.php index bfe17dcb..e30f8003 100644 --- a/tests/Feature/Company/Modules/ModuleMakeStubTest.php +++ b/tests/Feature/Company/Modules/ModuleMakeStubTest.php @@ -10,8 +10,7 @@ use Illuminate\Support\Facades\File; * and the starter i18n files that the boilerplate references. * * The test generates a throwaway module, inspects the generated files, then - * cleans up (including nwidart's status entry) so the rest of the suite is - * unaffected. + * cleans it up so the rest of the suite is unaffected. */ beforeEach(function () { $this->scaffoldModule = 'ScaffoldProbe'; @@ -28,15 +27,6 @@ afterEach(function () { File::deleteDirectory($this->scaffoldPath); } - // nwidart writes module activation state to storage/app/modules_statuses.json - // when module:make auto-enables the new module. Remove our scaffold entry - // so the file doesn't accumulate stale test data across runs. - $statusesFile = storage_path('app/modules_statuses.json'); - if (File::exists($statusesFile)) { - $statuses = json_decode(File::get($statusesFile), true) ?? []; - unset($statuses[$this->scaffoldModule]); - File::put($statusesFile, json_encode($statuses, JSON_PRETTY_PRINT)); - } }); test('module:make generates a ServiceProvider that uses InvoiceShelf\\Modules\\Registry', function () { diff --git a/tests/Feature/Marketplace/CanonicalJsonTest.php b/tests/Feature/Marketplace/CanonicalJsonTest.php new file mode 100644 index 00000000..ea829b5d --- /dev/null +++ b/tests/Feature/Marketplace/CanonicalJsonTest.php @@ -0,0 +1,15 @@ + '1.0.0', + 'compatibility' => ['php' => '8.4', 'extensions' => ['zip', 'sodium']], + 'artifact' => ['bytes' => 10.0, 'sha256' => 'abc'], + ]; + + expect(CanonicalJson::encode($manifest))->toBe( + '{"artifact":{"bytes":10.0,"sha256":"abc"},"compatibility":{"extensions":["zip","sodium"],"php":"8.4"},"version":"1.0.0"}', + ); +}); diff --git a/tests/Feature/Marketplace/MarketplaceClientTest.php b/tests/Feature/Marketplace/MarketplaceClientTest.php new file mode 100644 index 00000000..2633a313 --- /dev/null +++ b/tests/Feature/Marketplace/MarketplaceClientTest.php @@ -0,0 +1,41 @@ +set('invoiceshelf.base_url', 'http://marketplace.test:8080'); + $destination = tempnam(sys_get_temp_dir(), 'marketplace-artifact-'); + + Http::fake([ + 'http://marketplace.test:8080/artifacts/secure-probe.zip' => Http::response('archive'), + ]); + + try { + $response = app(MarketplaceClient::class)->artifact( + 'http://marketplace.test:8080/artifacts/secure-probe.zip', + $destination, + ); + + expect($response->successful())->toBeTrue(); + Http::assertSent(fn ($request): bool => $request->url() === 'http://marketplace.test:8080/artifacts/secure-probe.zip'); + } finally { + unlink($destination); + } +}); + +it('rejects HTTP artifacts from unrelated marketplace origins', function (string $url) { + config()->set('invoiceshelf.base_url', 'http://marketplace.test:8080'); + $destination = tempnam(sys_get_temp_dir(), 'marketplace-artifact-'); + + try { + expect(fn () => app(MarketplaceClient::class)->artifact($url, $destination)) + ->toThrow(RuntimeException::class, 'unsafe artifact URL'); + Http::assertNothingSent(); + } finally { + unlink($destination); + } +})->with([ + 'different host' => 'http://artifacts.test:8080/secure-probe.zip', + 'different port' => 'http://marketplace.test/secure-probe.zip', +]); diff --git a/tests/Feature/Marketplace/MarketplaceInstallerTest.php b/tests/Feature/Marketplace/MarketplaceInstallerTest.php new file mode 100644 index 00000000..3c22425a --- /dev/null +++ b/tests/Feature/Marketplace/MarketplaceInstallerTest.php @@ -0,0 +1,172 @@ +install('secure-probe', '1.0.0', 'stable'); + + expect($result['success'])->toBeTrue() + ->and(base_path('Modules/SecureProbe/module.json'))->toBeFile() + ->and(Module::query()->where('name', 'SecureProbe')->value('version'))->toBe('1.0.0'); +}); + +it('rejects a release signed by an unknown key before downloading an artifact', function () { + [$archive, $manifest, $keypair] = marketplaceRelease(); + fakeMarketplaceRelease($archive, $manifest, $keypair); + config()->set('invoiceshelf.marketplace.public_keys', ['other-key' => base64_encode(sodium_crypto_sign_publickey($keypair))]); + + $result = app(MarketplaceInstaller::class)->install('secure-probe', '1.0.0', 'stable'); + + expect($result['success'])->toBeFalse()->and($result['error'])->toContain('unknown signing key'); + Http::assertNotSent(fn ($request) => str_starts_with($request->url(), 'https://artifacts.test/')); +}); + +it('rejects envelope integrity metadata that differs from the signed manifest', function () { + [$archive, $manifest, $keypair] = marketplaceRelease(); + fakeMarketplaceRelease($archive, $manifest, $keypair, ['bytes' => strlen($archive) + 1]); + + $result = app(MarketplaceInstaller::class)->install('secure-probe', '1.0.0', 'stable'); + + expect($result['success'])->toBeFalse()->and($result['error'])->toContain('integrity fields'); +}); + +it('rejects missing required PHP extensions before downloading an artifact', function () { + [$archive, $manifest, $keypair] = marketplaceRelease(['compatibility' => [ + 'invoiceshelf' => '^3.0.0', 'module_api' => '^1.0.0', 'php' => '^8.4.0', 'extensions' => ['ext-no-such-extension'], + ]]); + fakeMarketplaceRelease($archive, $manifest, $keypair); + + $result = app(MarketplaceInstaller::class)->install('secure-probe', '1.0.0', 'stable'); + + expect($result['success'])->toBeFalse()->and($result['error'])->toContain('extension'); + Http::assertNotSent(fn ($request) => str_starts_with($request->url(), 'https://artifacts.test/')); +}); + +it('rejects path traversal archives even with valid signed integrity metadata', function () { + [$archive, $manifest, $keypair] = marketplaceRelease([], ['SecureProbe/../escape.php' => 'unsafe']); + fakeMarketplaceRelease($archive, $manifest, $keypair); + + $result = app(MarketplaceInstaller::class)->install('secure-probe', '1.0.0', 'stable'); + + expect($result['success'])->toBeFalse()->and($result['error'])->toContain('unsafe path') + ->and(base_path('Modules/SecureProbe'))->not->toBeDirectory(); +}); + +it('restores installation state when a module migration fails', function () { + [$archive, $manifest, $keypair] = marketplaceRelease([], [ + 'SecureProbe/database/migrations/2026_08_05_000000_fail_probe.php' => <<<'PHP' +install('secure-probe', '1.0.0', 'stable'); + + expect($result['success'])->toBeFalse() + ->and(base_path('Modules/SecureProbe'))->not->toBeDirectory() + ->and(Module::query()->where('name', 'SecureProbe')->value('state'))->toBe('failed'); +}); + +it('rejects package composer metadata that does not match the official module contract', function () { + [$archive, $manifest, $keypair] = marketplaceRelease([], [], ['license' => 'MIT']); + fakeMarketplaceRelease($archive, $manifest, $keypair); + + $result = app(MarketplaceInstaller::class)->install('secure-probe', '1.0.0', 'stable'); + + expect($result['success'])->toBeFalse() + ->and($result['error'])->toContain('composer manifest'); +}); + +/** @return array{string, array, string} */ +function marketplaceRelease(array $changes = [], array $extraEntries = [], array $composerChanges = []): array +{ + config()->set('app.version', '3.0.0'); + config()->set('invoiceshelf.marketplace.module_api_version', '1.0.0'); + $zipPath = tempnam(sys_get_temp_dir(), 'marketplace-test-'); + $zip = new ZipArchive; + $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); + $module = [ + 'name' => 'SecureProbe', 'alias' => 'secure_probe', 'description' => 'Secure test module', 'keywords' => [], 'priority' => 0, + 'providers' => ['Modules\\SecureProbe\\Providers\\SecureProbeServiceProvider'], 'aliases' => [], 'files' => [], 'requires' => [], + 'schema_version' => 1, 'slug' => 'secure-probe', 'version' => '1.0.0', 'license' => 'AGPL-3.0-only', + 'compatibility' => ['invoiceshelf' => '^3.0.0', 'module_api' => '^1.0.0', 'php' => '^8.4.0', 'extensions' => []], + 'module_dependencies' => [], 'migration_policy' => 'forward-only', 'dependency_policy' => 'host-provided-only', 'assets' => ['dist/app.js'], + ]; + $zip->addFromString('SecureProbe/module.json', json_encode($module, JSON_THROW_ON_ERROR)); + $zip->addFromString('SecureProbe/composer.json', json_encode([ + 'name' => 'invoiceshelf/module-secure-probe', + 'license' => 'AGPL-3.0-only', + 'require' => ['php' => '^8.4', 'invoiceshelf/modules' => '^3.0'], + ...$composerChanges, + ], JSON_THROW_ON_ERROR)); + $zip->addFromString('SecureProbe/dist/app.js', 'export {}'); + $zip->addFromString('SecureProbe/app/Providers/SecureProbeServiceProvider.php', <<<'PHP' + $contents) { + $zip->addFromString($name, $contents); + } + $zip->close(); + $archive = (string) file_get_contents($zipPath); + unlink($zipPath); + + $keypair = sodium_crypto_sign_keypair(); + $manifest = [ + 'schema_version' => 1, 'slug' => 'secure-probe', 'module_name' => 'SecureProbe', 'version' => '1.0.0', + 'channel' => 'stable', 'publication' => 'published', 'compatibility' => $module['compatibility'], + 'artifact' => ['sha256' => hash('sha256', $archive), 'bytes' => strlen($archive)], 'key_id' => 'test-key', + 'source_commit' => str_repeat('a', 40), 'released_at' => '2026-08-05T12:00:00Z', + ]; + foreach ($changes as $key => $value) { + $manifest[$key] = $value; + } + + return [$archive, $manifest, $keypair]; +} + +function fakeMarketplaceRelease(string $archive, array $manifest, string $keypair, array $artifactChanges = []): void +{ + config()->set('invoiceshelf.base_url', 'https://marketplace.test'); + config()->set('invoiceshelf.marketplace.public_keys', ['test-key' => base64_encode(sodium_crypto_sign_publickey($keypair))]); + $artifact = [...$manifest['artifact'], 'download_url' => 'https://artifacts.test/secure-probe.zip', 'expires_at' => now()->addMinute()->toIso8601String(), ...$artifactChanges]; + $envelope = [ + 'success' => true, 'manifest' => $manifest, + 'signature' => base64_encode(sodium_crypto_sign_detached(CanonicalJson::encode($manifest), sodium_crypto_sign_secretkey($keypair))), + 'key_id' => 'test-key', 'release_state' => 'published', 'yanked_reason' => null, 'artifact' => $artifact, + ]; + Http::fake([ + 'https://marketplace.test/api/marketplace/v1/modules/secure-probe/releases/1.0.0/download' => Http::response($envelope), + 'https://artifacts.test/*' => Http::response($archive), + ]); +} diff --git a/tests/Feature/Marketplace/MarketplacePairingTest.php b/tests/Feature/Marketplace/MarketplacePairingTest.php new file mode 100644 index 00000000..c538c458 --- /dev/null +++ b/tests/Feature/Marketplace/MarketplacePairingTest.php @@ -0,0 +1,97 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); + Sanctum::actingAs(User::findOrFail(1), ['*']); + config()->set('invoiceshelf.base_url', 'https://marketplace.test'); +}); + +it('starts pairing with installation compatibility metadata', function () { + Http::fake([ + 'https://marketplace.test/api/marketplace/v1/device/code' => Http::response([ + 'success' => true, 'device_code' => 'device-code', 'user_code' => 'ABCD1234', + 'verification_uri' => 'https://marketplace.test/pair', 'expires_in' => 600, 'interval' => 5, + ], 201), + ]); + + postJson('/api/v1/modules/pairing/start') + ->assertCreated() + ->assertJsonPath('user_code', 'ABCD1234'); + + Http::assertSent(function ($request): bool { + $data = $request->data(); + + return $request->url() === 'https://marketplace.test/api/marketplace/v1/device/code' + && filled($data['installation_name'] ?? null) + && isset($data['module_api_version'], $data['php_version'], $data['extensions']) + && collect($data['extensions'])->every( + fn ($extension): bool => is_string($extension) + && preg_match('/^ext-[a-z0-9][a-z0-9_-]*$/', $extension) === 1, + ); + }); +}); + +it('stores only the encrypted opaque installation token after pairing', function () { + Http::fake([ + 'https://marketplace.test/api/marketplace/v1/device/code' => Http::response([ + 'success' => true, 'device_code' => 'device-code', 'user_code' => 'ABCD1234', + 'verification_uri' => 'https://marketplace.test/pair', 'expires_in' => 600, 'interval' => 5, + ], 201), + 'https://marketplace.test/api/marketplace/v1/device/token' => Http::response([ + 'success' => true, 'installation_token' => 'opaque-installation-token', 'installation' => ['id' => 17, 'name' => 'Local'], + ]), + ]); + + postJson('/api/v1/modules/pairing/start')->assertCreated(); + postJson('/api/v1/modules/pairing/poll')->assertOk()->assertJsonPath('status', 'paired'); + + $credential = MarketplaceCredential::query()->sole(); + expect($credential->credential)->not->toContain('opaque-installation-token') + ->and(Crypt::decryptString($credential->credential))->toBe('opaque-installation-token') + ->and($credential->device_id)->toBe('17'); +}); + +it('reports pending device approval without storing a credential', function () { + Http::fake([ + 'https://marketplace.test/api/marketplace/v1/device/code' => Http::response([ + 'success' => true, 'device_code' => 'device-code', 'user_code' => 'ABCD1234', + 'verification_uri' => 'https://marketplace.test/pair', 'expires_in' => 600, 'interval' => 5, + ], 201), + 'https://marketplace.test/api/marketplace/v1/device/token' => Http::response([ + 'success' => false, 'error' => 'authorization_pending', 'interval' => 5, + ], 428), + ]); + + postJson('/api/v1/modules/pairing/start')->assertCreated(); + postJson('/api/v1/modules/pairing/poll')->assertOk()->assertJsonPath('status', 'pending'); + + expect(MarketplaceCredential::query()->doesntExist())->toBeTrue(); +}); + +it('revokes the remote installation when disconnecting locally', function () { + MarketplaceCredential::query()->create([ + 'credential' => Crypt::encryptString('opaque-installation-token'), + 'paired_at' => now(), + ]); + Http::fake([ + 'https://marketplace.test/api/marketplace/v1/device' => Http::response(['success' => true]), + ]); + + deleteJson('/api/v1/modules/pairing')->assertOk()->assertJsonPath('success', true); + + expect(MarketplaceCredential::query()->exists())->toBeFalse(); + Http::assertSent(fn ($request) => $request->method() === 'DELETE' + && $request->url() === 'https://marketplace.test/api/marketplace/v1/device' + && $request->hasHeader('Authorization', 'Bearer opaque-installation-token')); +}); diff --git a/tests/Feature/Marketplace/ModuleRuntimeAutoloaderTest.php b/tests/Feature/Marketplace/ModuleRuntimeAutoloaderTest.php new file mode 100644 index 00000000..ca960929 --- /dev/null +++ b/tests/Feature/Marketplace/ModuleRuntimeAutoloaderTest.php @@ -0,0 +1,26 @@ + 'AutoloadProbe'], JSON_THROW_ON_ERROR)); + File::put($modulePath.'/app/Providers/AutoloadProbeServiceProvider.php', <<<'PHP' +toBeTrue(); +}); diff --git a/tests/Feature/Modules/ModuleAssetCacheTest.php b/tests/Feature/Modules/ModuleAssetCacheTest.php new file mode 100644 index 00000000..77ff1f72 --- /dev/null +++ b/tests/Feature/Modules/ModuleAssetCacheTest.php @@ -0,0 +1,87 @@ +assetDirectory = storage_path('app/module-asset-cache-test'); + File::ensureDirectoryExists($this->assetDirectory); +}); + +afterEach(function () { + Registry::flush(); + File::deleteDirectory($this->assetDirectory); +}); + +test('script responses use immutable caching only for the current content version', function () { + $path = $this->assetDirectory.'/cache-probe.js'; + File::put($path, 'export const version = "1.0.0";'); + Registry::registerScript('cache-probe', $path); + $firstVersion = ModuleAssetVersion::forPath($path); + + $unversionedResponse = get('/modules/scripts/cache-probe') + ->assertOk() + ->assertHeader('Content-Type', 'application/javascript') + ->assertSee('version = "1.0.0";', false); + + expect($unversionedResponse->headers->get('Cache-Control'))->toContain('no-store'); + + File::put($path, 'export const version = "1.0.1";'); + $currentVersion = ModuleAssetVersion::forPath($path); + + expect($currentVersion)->not->toBe($firstVersion); + + $staleResponse = get('/modules/scripts/cache-probe?v='.$firstVersion) + ->assertOk() + ->assertSee('version = "1.0.1";', false); + + expect($staleResponse->headers->get('Cache-Control'))->toContain('no-store'); + + $versionedResponse = get('/modules/scripts/cache-probe?v='.$currentVersion) + ->assertOk() + ->assertSee('version = "1.0.1";', false); + + expect($versionedResponse->headers->get('Cache-Control'))->toContain('public') + ->toContain('max-age=31536000') + ->toContain('immutable'); +}); + +test('style responses use immutable caching only for the current content version', function () { + $path = $this->assetDirectory.'/cache-probe.css'; + File::put($path, '.cache-probe { color: red; }'); + Registry::registerStyle('cache-probe', $path); + $version = ModuleAssetVersion::forPath($path); + + $outdatedResponse = get('/modules/styles/cache-probe?v=outdated') + ->assertOk() + ->assertSee('.cache-probe { color: red; }', false); + + expect($outdatedResponse->headers->get('Content-Type'))->toStartWith('text/css') + ->and($outdatedResponse->headers->get('Cache-Control'))->toContain('no-store'); + + $versionedResponse = get('/modules/styles/cache-probe?v='.$version) + ->assertOk() + ->assertSee('.cache-probe { color: red; }', false); + + expect($versionedResponse->headers->get('Cache-Control'))->toContain('public') + ->toContain('max-age=31536000') + ->toContain('immutable'); +}); + +test('the application shell content-versions local module asset URLs', function () { + $scriptPath = $this->assetDirectory.'/layout-probe.js'; + $stylePath = $this->assetDirectory.'/layout-probe.css'; + File::put($scriptPath, 'export const version = "1.0.1";'); + File::put($stylePath, '.layout-probe { color: blue; }'); + Registry::registerScript('layout-probe', $scriptPath); + Registry::registerStyle('layout-probe', $stylePath); + + $html = view('app')->render(); + + expect($html)->toContain('/modules/scripts/layout-probe?v='.ModuleAssetVersion::forPath($scriptPath)) + ->toContain('/modules/styles/layout-probe?v='.ModuleAssetVersion::forPath($stylePath)); +}); diff --git a/tests/Pest.php b/tests/Pest.php index efe351cc..2deca95e 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -6,8 +6,7 @@ use Tests\TestCase; uses(TestCase::class, RefreshDatabase::class)->in('Feature'); uses(TestCase::class, RefreshDatabase::class)->in('Unit'); -// The module-system tests scaffold real modules on disk (Modules/ScaffoldProbe) and -// toggle the shared modules_statuses.json — global, filesystem-level state that paratest -// does NOT isolate per worker (it only isolates the database). Tag them so CI can run -// this group serially, after the parallel pass, to avoid cross-worker collisions. +// The module-system tests scaffold real modules on disk (Modules/ScaffoldProbe). +// Paratest isolates the database but not that shared filesystem path, so run this +// group serially after the parallel pass to avoid cross-worker collisions. uses()->group('modules')->in('Feature/Company/Modules'); diff --git a/tests/Unit/MarketplaceConfigurationTest.php b/tests/Unit/MarketplaceConfigurationTest.php new file mode 100644 index 00000000..59ef72ab --- /dev/null +++ b/tests/Unit/MarketplaceConfigurationTest.php @@ -0,0 +1,48 @@ +toBe([OFFICIAL_MARKETPLACE_KEY_ID => OFFICIAL_MARKETPLACE_PUBLIC_KEY]); +}); + +test('marketplace public-key configuration adds and rotates trusted keys', function () { + $keys = marketplacePublicKeysConfigFor(json_encode([ + 'rotated-modules-2027-01' => 'additional-public-key', + OFFICIAL_MARKETPLACE_KEY_ID => 'replacement-public-key', + ], JSON_THROW_ON_ERROR)); + + expect($keys)->toBe([ + OFFICIAL_MARKETPLACE_KEY_ID => 'replacement-public-key', + 'rotated-modules-2027-01' => 'additional-public-key', + ]); +}); + +function marketplacePublicKeysConfigFor(?string $override): array +{ + $previous = getenv('MARKETPLACE_PUBLIC_KEYS'); + + if ($override === null) { + putenv('MARKETPLACE_PUBLIC_KEYS'); + unset($_ENV['MARKETPLACE_PUBLIC_KEYS'], $_SERVER['MARKETPLACE_PUBLIC_KEYS']); + } else { + putenv("MARKETPLACE_PUBLIC_KEYS={$override}"); + $_ENV['MARKETPLACE_PUBLIC_KEYS'] = $override; + $_SERVER['MARKETPLACE_PUBLIC_KEYS'] = $override; + } + + $configuration = require config_path('invoiceshelf.php'); + + if ($previous === false) { + putenv('MARKETPLACE_PUBLIC_KEYS'); + unset($_ENV['MARKETPLACE_PUBLIC_KEYS'], $_SERVER['MARKETPLACE_PUBLIC_KEYS']); + } else { + putenv("MARKETPLACE_PUBLIC_KEYS={$previous}"); + $_ENV['MARKETPLACE_PUBLIC_KEYS'] = $previous; + $_SERVER['MARKETPLACE_PUBLIC_KEYS'] = $previous; + } + + return $configuration['marketplace']['public_keys']; +}