root = sys_get_temp_dir().'/sgc-update-test-'.bin2hex(random_bytes(8)); mkdir($this->root, 0700); $this->source = $this->root.'/source'; $this->channel = $this->root.'/channel'; $this->host = $this->root.'/host'; mkdir($this->source.'/src', 0700, true); mkdir($this->host, 0700); file_put_contents($this->host.'/composer.json', json_encode(['name' => 'tempo/sgf-tempo'])); file_put_contents($this->host.'/business-data.sentinel', 'preserved'); file_put_contents($this->source.'/bootstrap.php', 'source.'/src/Example.php', 'source.'/module.json', json_encode(['package' => 'tempo/purchasing', 'host_contract' => 1, 'minimum_php' => '8.3.0', 'database_changes' => false, 'commercial_permissions' => false, 'capabilities' => ['synthetic.example']])); }); afterEach(function () { $root = realpath($this->root); $temporary = str_replace('\\', '/', realpath(sys_get_temp_dir())); if ($root === false || ! str_starts_with(str_replace('\\', '/', $root), $temporary.'/sgc-update-test-')) { throw new LogicException('Cleanup outside generated test workspace rejected.'); } $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); foreach ($files as $file) { if ($file->isDir() && ! $file->isLink()) { rmdir($file->getPathname()); } else { unlink($file->getPathname()); } } rmdir($root); }); test('unchanged source publishes no duplicate and improvements create immutable patch versions', function () { $first = Release::publish($this->source, $this->channel); $same = Release::publish($this->source, $this->channel); expect($first['version'])->toBe('0.1.0')->and($same['changed'])->toBeFalse(); $oldHash = hash_file('sha256', $this->channel.'/'.$first['archive']); file_put_contents($this->source.'/src/Example.php', 'source, $this->channel); expect($next['version'])->toBe('0.1.1')->and($next['source_sha256'])->not->toBe($first['source_sha256']) ->and(hash_file('sha256', $this->channel.'/'.$first['archive']))->toBe($oldHash); }); test('installation update retry rollback and reactivation preserve host data and earlier versions', function () { $first = Release::publish($this->source, $this->channel); Release::install($this->host, $this->channel.'/'.$first['archive'], $first['manifest_sha256']); file_put_contents($this->source.'/src/Example.php', 'source, $this->channel); $installed = Release::install($this->host, $this->channel.'/'.$next['archive'], $next['manifest_sha256']); expect($installed['version'])->toBe('0.1.1')->and($installed['previous'])->toBe('0.1.0'); expect(Release::install($this->host, $this->channel.'/'.$next['archive'], $next['manifest_sha256'])['changed'])->toBeFalse(); expect(Release::rollback($this->host)['version'])->toBe('0.1.0'); expect(Release::install($this->host, $this->channel.'/'.$next['archive'], $next['manifest_sha256'])['version'])->toBe('0.1.1'); expect(file_get_contents($this->host.'/business-data.sentinel'))->toBe('preserved') ->and(is_file($this->host.'/packages/purchasing/releases/0.1.0/src/Example.php'))->toBeTrue(); expect(fn () => Release::install($this->host, $this->channel.'/'.$first['archive'], $first['manifest_sha256']))->toThrow(RuntimeException::class); }); test('tampered installed code is not overwritten by an update', function () { $first = Release::publish($this->source, $this->channel); Release::install($this->host, $this->channel.'/'.$first['archive'], $first['manifest_sha256']); file_put_contents($this->host.'/packages/purchasing/releases/0.1.0/src/Example.php', 'source.'/src/Example.php', 'source, $this->channel); $pointer = file_get_contents($this->host.'/packages/purchasing/current.json'); expect(fn () => Release::install($this->host, $this->channel.'/'.$next['archive'], $next['manifest_sha256']))->toThrow(RuntimeException::class); expect(file_get_contents($this->host.'/packages/purchasing/current.json'))->toBe($pointer); }); test('archive rejects tampering traversal missing files and a wrong trusted manifest', function () { $first = Release::publish($this->source, $this->channel); $original = $this->channel.'/'.$first['archive']; expect(fn () => Release::inspect($original, str_repeat('0', 64)))->toThrow(RuntimeException::class); foreach (['tamper', 'traverse', 'missing'] as $mode) { $archive = $this->root.'/'.$mode.'.zip'; copy($original, $archive); $zip = new ZipArchive; $zip->open($archive); if ($mode === 'tamper') { $zip->addFromString('payload/src/Example.php', 'addFromString('payload/../../outside.php', 'deleteName('payload/src/Example.php'); } $zip->close(); expect(fn () => Release::install($this->host, $archive, $first['manifest_sha256']))->toThrow(RuntimeException::class); } expect(Release::current($this->host))->toBeNull()->and(is_file($this->root.'/outside.php'))->toBeFalse(); }); test('release refuses secrets data and schema permission changes', function () { file_put_contents($this->source.'/.env', 'SYNTHETIC_SECRET'); expect(fn () => Release::publish($this->source, $this->channel))->toThrow(RuntimeException::class); unlink($this->source.'/.env'); $module = json_decode(file_get_contents($this->source.'/module.json'), true); $module['database_changes'] = true; file_put_contents($this->source.'/module.json', json_encode($module)); expect(fn () => Release::publish($this->source, $this->channel))->toThrow(RuntimeException::class); }); test('client executes an installed artifact with its source directory unavailable', function () { file_put_contents($this->source.'/bootstrap.php', 'source.'/src/Example.php', 'source, $this->channel); Release::install($this->host, $this->channel.'/'.$release['archive'], $release['manifest_sha256']); mkdir($this->host.'/packages/module-updates', 0700); copy(__DIR__.'/../../packages/module-updates/Release.php', $this->host.'/packages/module-updates/Release.php'); copy(__DIR__.'/../../packages/module-updates/purchasing-bootstrap.php', $this->host.'/packages/purchasing/bootstrap.php'); rename($this->source, $this->source.'.unavailable'); expect(is_dir($this->source))->toBeFalse(); $code = 'require $argv[1]; echo json_encode(["answer"=>\StandaloneClient\Example::answer(),"files"=>get_included_files()]);'; $process = new Process([PHP_BINARY, '-r', $code, $this->host.'/packages/purchasing/bootstrap.php'], $this->host); $process->mustRun(); $result = json_decode($process->getOutput(), true, 32, JSON_THROW_ON_ERROR); expect($result['answer'])->toBe(42); $prefix = strtolower(str_replace('\\', '/', realpath($this->host))).'/'; foreach ($result['files'] as $file) { expect(str_starts_with(strtolower(str_replace('\\', '/', realpath($file))), $prefix))->toBeTrue(); } });