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
+48
View File
@@ -0,0 +1,48 @@
<?php
use Tempo\Purchasing\Economic\ProposalScoring;
use Tempo\Purchasing\Economic\QuotationPricing;
require_once __DIR__.'/../../packages/purchasing/bootstrap.php';
function economicRow(int $id, int $price, int $quality = 8): array
{
return ['id' => $id, 'eligible' => true, 'subtotal_minor' => $price, 'delivery_days' => 2,
'weights' => ['price' => 50, 'quality' => 50, 'delivery' => 0, 'history' => 0, 'payment' => 0],
'evaluation_count' => 2, 'qualified_evaluations' => array_fill(0, 2, ['quality' => (string) $quality, 'history' => '8', 'payment' => '8'])];
}
test('shared unit pricing preserves discounts and half up net unit cents', function () {
$pricing = new QuotationPricing;
expect($pricing->unit('19.99', 'percent', '12.50'))->toBe(['gross_unit_minor' => 1999, 'discount_type' => 'percent', 'discount_value' => '12.50', 'net_unit_minor' => 1749])
->and($pricing->unit('0.03', 'percent', '50')['net_unit_minor'])->toBe(2)
->and($pricing->unit('100', 'amount', '7.50')['net_unit_minor'])->toBe(9250)
->and($pricing->unit('100')['net_unit_minor'])->toBe(10000);
});
test('shared pricing rejects invalid discounts without Laravel or a database', function () {
foreach ([['0', 'none', '0'], ['100', 'percent', '101'], ['100', 'amount', '100'], ['100', 'none', '1'], ['100', 'unknown', '0']] as $input) {
expect(fn () => (new QuotationPricing)->unit(...$input))->toThrow(InvalidArgumentException::class);
}
});
test('shared scoring preserves exact weighted results all ties and host supplied ineligibility', function () {
$rows = [economicRow(1, 10000), economicRow(2, 20000, 10), economicRow(3, 10000), [...economicRow(4, 1, 10), 'eligible' => false]];
expect((new ProposalScoring)->rank($rows, 2))->toBe(['scores' => [1 => 900, 2 => 750, 3 => 900], 'top_ids' => [1, 3]]);
});
test('shared scoring retains the existing reference basis when eligible rows lack sufficient evaluations', function () {
$pending = economicRow(5, 5000);
$pending['qualified_evaluations'] = [$pending['qualified_evaluations'][0]];
$pending['evaluation_count'] = 1;
expect((new ProposalScoring)->rank([economicRow(1, 10000), economicRow(2, 20000, 10), $pending], 2))->toBe(['scores' => [1 => 650, 2 => 625], 'top_ids' => [1]]);
expect((new ProposalScoring)->rank([], 2))->toBe(['scores' => [], 'top_ids' => []]);
});
test('shared scoring refuses inconsistent projected inputs rather than inventing a result', function () {
$row = economicRow(1, 10000);
foreach ([[$row, $row], [[...$row, 'subtotal_minor' => 0]], [[...$row, 'evaluation_count' => 3]], [[...$row, 'weights' => ['price' => 100]]]] as $rows) {
expect(fn () => (new ProposalScoring)->rank($rows, 2))->toThrow(InvalidArgumentException::class);
}
expect(fn () => (new ProposalScoring)->rank([$row], 0))->toThrow(InvalidArgumentException::class);
});
+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();
}
});