mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-01 21:00:58 +00:00
* refactor: stabilize model identities for domain migration * refactor: extract module platform context * refactor: assign models to domain contexts * refactor: extract ai platform context * refactor: extract storage platform context * refactor: extract mail platform context * refactor: extract pdf platform context * refactor: extract operations platform context * refactor: move installation into operations platform * refactor: extract money domain context * refactor: extract taxation domain context * refactor: extract catalog domain context * refactor: extract metadata domain context * refactor: extract reporting domain context * refactor: extract purchases domain context * refactor: extract receivables domain context * refactor: extract accounts domain context * refactor: complete reporting statement boundary * refactor: extract contacts domain context * refactor: extract sales domain context * refactor: remove legacy application layers * fix: migrate legacy bouncer role identities
210 lines
9.4 KiB
PHP
210 lines
9.4 KiB
PHP
<?php
|
|
|
|
use App\Platform\Modules\Marketplace\CanonicalJson;
|
|
use App\Platform\Modules\Marketplace\MarketplaceInstaller;
|
|
use App\Platform\Modules\Models\Module;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
afterEach(function () {
|
|
File::deleteDirectory(base_path('Modules/SecureProbe'));
|
|
File::deleteDirectory(base_path('Modules/.staging'));
|
|
File::deleteDirectory(base_path('Modules/.backups'));
|
|
});
|
|
|
|
it('installs an exact signed marketplace archive', function () {
|
|
[$archive, $manifest, $keypair] = marketplaceRelease();
|
|
fakeMarketplaceRelease($archive, $manifest, $keypair);
|
|
|
|
$result = app(MarketplaceInstaller::class)->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('reinstalls the same version after a code-only uninstall record', function () {
|
|
Module::query()->create([
|
|
'name' => 'SecureProbe',
|
|
'slug' => 'secure-probe',
|
|
'version' => '1.0.0',
|
|
'installed' => false,
|
|
'enabled' => false,
|
|
'state' => 'uninstalled',
|
|
]);
|
|
[$archive, $manifest, $keypair] = marketplaceRelease();
|
|
fakeMarketplaceRelease($archive, $manifest, $keypair);
|
|
|
|
$result = app(MarketplaceInstaller::class)->install('secure-probe', '1.0.0', 'stable');
|
|
|
|
expect($result['success'])->toBeTrue()
|
|
->and(Module::query()->where('name', 'SecureProbe')->value('installed'))->toBeTrue()
|
|
->and(Module::query()->where('name', 'SecureProbe')->value('version'))->toBe('1.0.0');
|
|
});
|
|
|
|
it('rejects reinstalling the same version while it remains installed', function () {
|
|
Module::query()->create([
|
|
'name' => 'SecureProbe',
|
|
'slug' => 'secure-probe',
|
|
'version' => '1.0.0',
|
|
'installed' => true,
|
|
'enabled' => false,
|
|
'state' => 'installed',
|
|
]);
|
|
[$archive, $manifest, $keypair] = marketplaceRelease();
|
|
fakeMarketplaceRelease($archive, $manifest, $keypair);
|
|
|
|
$result = app(MarketplaceInstaller::class)->install('secure-probe', '1.0.0', 'stable');
|
|
|
|
expect($result['success'])->toBeFalse()
|
|
->and($result['error'])->toContain('reinstalling the same release');
|
|
});
|
|
|
|
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'
|
|
<?php
|
|
|
|
use Illuminate\Database\Migrations\Migration;
|
|
|
|
return new class extends Migration
|
|
{
|
|
public function up(): void
|
|
{
|
|
throw new RuntimeException('probe migration failed');
|
|
}
|
|
|
|
public function down(): void {}
|
|
};
|
|
PHP,
|
|
]);
|
|
fakeMarketplaceRelease($archive, $manifest, $keypair);
|
|
|
|
$result = app(MarketplaceInstaller::class)->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, mixed>, 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'
|
|
<?php
|
|
|
|
namespace Modules\SecureProbe\Providers;
|
|
|
|
use Illuminate\Support\ServiceProvider;
|
|
|
|
class SecureProbeServiceProvider extends ServiceProvider {}
|
|
PHP);
|
|
foreach ($extraEntries as $name => $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),
|
|
]);
|
|
}
|