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']; +}