This commit is contained in:
2026-11-02 08:08:05 +02:00
parent 13a6cb9922
commit 504b01b244
246 changed files with 24499 additions and 185 deletions
+128
View File
@@ -0,0 +1,128 @@
<?php
use Symfony\Component\Process\Process;
use Tempo\Distribution\Release;
require_once __DIR__.'/../../packages/module-updates/Release.php';
beforeEach(function () {
$this->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', '<?php // synthetic package');
file_put_contents($this->source.'/src/Example.php', '<?php // first implementation');
file_put_contents($this->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', '<?php // improved implementation');
$next = Release::publish($this->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', '<?php // improved implementation');
$next = Release::publish($this->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', '<?php // local modification');
file_put_contents($this->source.'/src/Example.php', '<?php // new version');
$next = Release::publish($this->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', '<?php // tampered');
} elseif ($mode === 'traverse') {
$zip->addFromString('payload/../../outside.php', '<?php // outside');
} else {
$zip->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', '<?php require_once __DIR__."/src/Example.php";');
file_put_contents($this->source.'/src/Example.php', '<?php namespace StandaloneClient; final class Example { public static function answer(): int { return 42; } }');
$release = Release::publish($this->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();
}
});