commit13
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user