865 lines
40 KiB
PHP
865 lines
40 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use NetBoxStore\Config;
|
|
use NetBoxStore\Database\JsonStoreRepository;
|
|
use NetBoxStore\Database\CallbackLease;
|
|
use NetBoxStore\Database\ExclusiveLease;
|
|
use NetBoxStore\Database\State;
|
|
use NetBoxStore\Database\StoreRepository;
|
|
use NetBoxStore\Domain\Approval;
|
|
use NetBoxStore\Domain\Catalog;
|
|
use NetBoxStore\Http\Application;
|
|
use NetBoxStore\Http\Request;
|
|
use NetBoxStore\Http\View;
|
|
use NetBoxStore\Security\Auth;
|
|
use NetBoxStore\Security\HttpClient;
|
|
use NetBoxStore\Security\SsrfGuard;
|
|
use NetBoxStore\Support;
|
|
use NetBoxStore\Sync\Adapter\SourceAdapter;
|
|
use NetBoxStore\Sync\Adapter\ForgejoAdapter;
|
|
use NetBoxStore\Sync\Adapter\GitHubAdapter;
|
|
use NetBoxStore\Sync\Discovery;
|
|
use NetBoxStore\Sync\ReadmeRenderer;
|
|
use NetBoxStore\Sync\SyncService;
|
|
use NetBoxStore\Sync\SyncBudget;
|
|
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
|
|
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
|
|
if ((error_reporting() & $severity) === 0) {
|
|
return false;
|
|
}
|
|
throw new ErrorException($message, 0, $severity, $file, $line);
|
|
});
|
|
|
|
final class MemoryRepository implements StoreRepository
|
|
{
|
|
public int $transactions = 0;
|
|
/** @var array<string,true> */
|
|
private array $leases = [];
|
|
|
|
/** @param array<string,mixed> $state */
|
|
public function __construct(public array $state)
|
|
{
|
|
State::validate($this->state);
|
|
}
|
|
|
|
public function initialize(): void
|
|
{
|
|
}
|
|
|
|
public function read(): array
|
|
{
|
|
return $this->state;
|
|
}
|
|
|
|
public function acquireLease(string $name): ?ExclusiveLease
|
|
{
|
|
if (isset($this->leases[$name])) {
|
|
return null;
|
|
}
|
|
$this->leases[$name] = true;
|
|
return new CallbackLease(function () use ($name): void {
|
|
unset($this->leases[$name]);
|
|
});
|
|
}
|
|
|
|
public function transaction(callable $callback): mixed
|
|
{
|
|
$this->transactions++;
|
|
$draft = $this->state;
|
|
$result = $callback($draft);
|
|
State::validate($draft);
|
|
$this->state = $draft;
|
|
return $result;
|
|
}
|
|
}
|
|
|
|
final class FakeAdapter implements SourceAdapter
|
|
{
|
|
/** @var array<string,string> */
|
|
public array $files = [];
|
|
/** @var list<array<string,mixed>> */
|
|
public array $releases = [];
|
|
/** @var list<array<string,mixed>> */
|
|
public array $repositories = [];
|
|
/** @var list<string> */
|
|
public array $readRefs = [];
|
|
public string $artifactSha = '';
|
|
public int $artifactSize = 128;
|
|
public int $hashCalls = 0;
|
|
public int $releaseListCalls = 0;
|
|
public bool $throwOnCommit = false;
|
|
|
|
/** @param array<string,mixed> $repository */
|
|
public function __construct(public array $repository)
|
|
{
|
|
}
|
|
|
|
public function listRepositories(): array
|
|
{
|
|
return $this->repositories !== [] ? $this->repositories : [$this->repository];
|
|
}
|
|
|
|
public function getCommitSha(array $repository, ?string $ref = null): string
|
|
{
|
|
if ($this->throwOnCommit) {
|
|
throw new RuntimeException('simulated inspection failure');
|
|
}
|
|
return (string) $this->repository['commitSha'];
|
|
}
|
|
|
|
public function fetchText(array $repository, string $path, string $commitSha): ?string
|
|
{
|
|
$this->readRefs[] = $commitSha;
|
|
if ($commitSha !== $this->repository['commitSha']) {
|
|
throw new RuntimeException('unpinned read');
|
|
}
|
|
return $this->files[$path] ?? null;
|
|
}
|
|
|
|
public function listTree(array $repository, string $commitSha): array
|
|
{
|
|
$this->readRefs[] = $commitSha;
|
|
return array_keys($this->files);
|
|
}
|
|
|
|
public function rawFileUrl(array $repository, string $path, string $commitSha): string
|
|
{
|
|
$this->readRefs[] = $commitSha;
|
|
return 'https://git.mrblake.cc/' . $repository['fullName'] . '/raw/commit/' . $commitSha . '/' . $path;
|
|
}
|
|
|
|
public function listReleases(array $repository): array
|
|
{
|
|
$this->releaseListCalls++;
|
|
return $this->releases;
|
|
}
|
|
|
|
public function sourceArchiveUrl(array $repository, string $commitSha): ?string
|
|
{
|
|
return 'https://git.mrblake.cc/' . $repository['fullName'] . '/archive/' . $commitSha . '.tar.gz';
|
|
}
|
|
|
|
public function hashArtifact(string $url, string $expectedSha256 = ''): array
|
|
{
|
|
$this->hashCalls++;
|
|
return ['sha256' => $this->artifactSha, 'artifactSize' => $this->artifactSize];
|
|
}
|
|
}
|
|
|
|
/** @var array<string,Closure> $tests */
|
|
$tests = [];
|
|
function test(string $name, Closure $test): void
|
|
{
|
|
global $tests;
|
|
$tests[$name] = $test;
|
|
}
|
|
|
|
function assertTrue(bool $condition, string $message = 'assertTrue failed'): void
|
|
{
|
|
if (!$condition) {
|
|
throw new RuntimeException($message);
|
|
}
|
|
}
|
|
|
|
function assertSame(mixed $expected, mixed $actual, string $message = ''): void
|
|
{
|
|
if ($expected !== $actual) {
|
|
throw new RuntimeException(($message !== '' ? $message . ': ' : '') . 'expected ' . var_export($expected, true) . ', got ' . var_export($actual, true));
|
|
}
|
|
}
|
|
|
|
function assertThrows(Closure $callback, string $contains = ''): void
|
|
{
|
|
try {
|
|
$callback();
|
|
} catch (Throwable $exception) {
|
|
if ($contains !== '' && !str_contains($exception->getMessage(), $contains)) {
|
|
throw new RuntimeException('Exception did not contain expected text: ' . $exception->getMessage());
|
|
}
|
|
return;
|
|
}
|
|
throw new RuntimeException('Expected exception was not thrown.');
|
|
}
|
|
|
|
function configureEnvironment(string $root, ?string $jsonPath = null): Config
|
|
{
|
|
$values = [
|
|
'APP_ENV' => 'test',
|
|
'STORE_PUBLIC_URL' => 'http://localhost:3000',
|
|
'STORE_TRUST_PROXY' => 'false',
|
|
'STORE_DB_DRIVER' => 'json',
|
|
'STORE_JSON_PATH' => $jsonPath ?? ($root . '/data/test-store.json'),
|
|
'STORE_ALLOWED_SOURCE_HOSTS' => 'git.mrblake.cc,api.github.com,github.com,raw.githubusercontent.com,127.0.0.1',
|
|
'STORE_ALLOW_PRIVATE_NETWORKS' => 'false',
|
|
'STORE_ADMIN_USERNAME' => '',
|
|
'STORE_ADMIN_PASSWORD_HASH' => '',
|
|
'STORE_SESSION_SECRET' => '',
|
|
'STORE_DEFAULT_BASE_URL' => 'https://git.mrblake.cc',
|
|
'STORE_DEFAULT_API_URL' => 'https://git.mrblake.cc/api/v1',
|
|
'STORE_DEFAULT_OWNER' => 'MrBlake',
|
|
'STORE_DEFAULT_PROVIDER' => 'forgejo',
|
|
];
|
|
foreach ($values as $key => $value) {
|
|
putenv($key . '=' . $value);
|
|
$_ENV[$key] = $value;
|
|
}
|
|
putenv('STORE_COOKIE_SECURE');
|
|
unset($_ENV['STORE_COOKIE_SECURE']);
|
|
return Config::load($root);
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
function approvedFixture(): array
|
|
{
|
|
$state = State::empty();
|
|
$source = [
|
|
'id' => 'source-1', 'slug' => 'mrblake', 'name' => 'MrBlake', 'provider' => 'forgejo',
|
|
'baseUrl' => 'https://git.mrblake.cc', 'apiUrl' => 'https://git.mrblake.cc/api/v1',
|
|
'owner' => 'MrBlake', 'ownerKind' => 'user', 'tokenEnv' => '', 'topic' => 'netbox-plugin',
|
|
'status' => 'approved', 'active' => true, 'includeForks' => false, 'includeArchived' => false,
|
|
'autoApprovePlugins' => false,
|
|
];
|
|
$plugin = [
|
|
'id' => 'plugin-1', 'sourceId' => 'source-1', 'externalId' => '101', 'slug' => 'demo-plugin',
|
|
'name' => 'Demo Plugin', 'summary' => 'Ein Testplugin', 'description' => 'Beschreibung',
|
|
'repositoryOwner' => 'MrBlake', 'repositoryName' => 'netbox-demo',
|
|
'repositoryUrl' => 'https://git.mrblake.cc/MrBlake/netbox-demo', 'packageName' => 'netbox-demo',
|
|
'importName' => 'netbox_demo', 'minNetboxVersion' => '4.6.5', 'maxNetboxVersion' => '4.6.8',
|
|
'status' => 'approved', 'active' => true, 'archived' => false, 'license' => 'MIT',
|
|
'commitSha' => str_repeat('a', 40), 'readmeHtml' => '<p>README</p>',
|
|
];
|
|
$release = [
|
|
'id' => 'release-1', 'pluginId' => 'plugin-1', 'externalId' => '501', 'version' => '1.2.3',
|
|
'title' => '1.2.3', 'downloadUrl' => 'https://git.mrblake.cc/assets/netbox_demo-1.2.3-py3-none-any.whl',
|
|
'releaseUrl' => 'https://git.mrblake.cc/releases/1', 'sha256' => str_repeat('b', 64),
|
|
'artifactSize' => 12_345, 'commitSha' => str_repeat('a', 40), 'artifactKind' => 'wheel',
|
|
'artifactFilename' => 'netbox_demo-1.2.3-py3-none-any.whl',
|
|
'minNetboxVersion' => '4.6.5', 'maxNetboxVersion' => '4.6.8', 'publishedAt' => '2026-08-20T10:00:00Z',
|
|
'draft' => false, 'withdrawn' => false, 'status' => 'pending',
|
|
'approvedAt' => null, 'approvedBy' => null, 'approvedPayloadSha256' => '',
|
|
];
|
|
$state['sources'][] = $source;
|
|
$state['plugins'][] = $plugin;
|
|
$state['releases'][] = $release;
|
|
Approval::approve($state, 'releases', 'release-1', 'test-admin');
|
|
return $state;
|
|
}
|
|
|
|
function fakeRepository(): array
|
|
{
|
|
return [
|
|
'externalId' => '101', 'owner' => 'MrBlake', 'name' => 'netbox-demo', 'fullName' => 'MrBlake/netbox-demo',
|
|
'htmlUrl' => 'https://git.mrblake.cc/MrBlake/netbox-demo', 'defaultBranch' => 'main',
|
|
'description' => 'Remote description', 'homepageUrl' => '', 'topics' => [], 'archived' => false,
|
|
'fork' => false, 'empty' => false, 'commitSha' => str_repeat('c', 40),
|
|
];
|
|
}
|
|
|
|
function candidatePyproject(): string
|
|
{
|
|
return <<<'TOML'
|
|
[project]
|
|
name = "netbox-demo"
|
|
version = "1.2.3"
|
|
description = "Remote summary"
|
|
dependencies = ["netbox>=4.6.5,<=4.6.8"]
|
|
|
|
[project.entry-points."netbox.plugins"]
|
|
demo = "netbox_demo"
|
|
TOML;
|
|
}
|
|
|
|
$storeRoot = dirname(__DIR__);
|
|
$config = configureEnvironment($storeRoot);
|
|
$guard = new SsrfGuard($config);
|
|
$http = new HttpClient($config, $guard);
|
|
|
|
test('versions use a strict PEP 440 subset', static function (): void {
|
|
assertSame('1.2.3rc1', Support::safeVersion('v1.2.3RC1'));
|
|
assertSame('0.0.0+build.abcdef12', Support::safeVersion('0.0.0+build.abcdef12'));
|
|
assertSame('', Support::safeVersion('1.0-foo'));
|
|
assertSame('', Support::safeVersion('01.0'));
|
|
assertSame('', Support::safeVersion('1.0+local-build'));
|
|
assertSame('', Support::safeVersion('release-foo'));
|
|
});
|
|
|
|
test('plugin validation matches strict client limits', static function (): void {
|
|
$plugin = approvedFixture()['plugins'][0];
|
|
assertSame([], Approval::pluginErrors($plugin));
|
|
$plugin['slug'] = str_repeat('a', 65);
|
|
assertTrue(Approval::pluginErrors($plugin) !== []);
|
|
$plugin = approvedFixture()['plugins'][0];
|
|
$plugin['packageName'] = 'bad-';
|
|
assertTrue(Approval::pluginErrors($plugin) !== []);
|
|
$plugin = approvedFixture()['plugins'][0];
|
|
$plugin['importName'] = 'nested.module';
|
|
assertTrue(Approval::pluginErrors($plugin) !== []);
|
|
});
|
|
|
|
test('release approval is payload-bound and rejects invalid or duplicate versions', static function (): void {
|
|
$state = approvedFixture();
|
|
assertTrue(Approval::current($state['plugins'][0], $state['releases'][0]));
|
|
$state['releases'][0]['artifactSize']++;
|
|
assertTrue(!Approval::current($state['plugins'][0], $state['releases'][0]), 'changed size must invalidate payload');
|
|
|
|
$invalid = approvedFixture();
|
|
$invalid['releases'][0]['status'] = 'pending';
|
|
$invalid['releases'][0]['approvedPayloadSha256'] = '';
|
|
$invalid['releases'][0]['version'] = 'release-foo';
|
|
assertThrows(static function () use (&$invalid): void { Approval::approve($invalid, 'releases', 'release-1', 'admin'); }, 'Release-Version');
|
|
|
|
$sourceArchive = approvedFixture();
|
|
$sourceArchive['releases'][0]['status'] = 'pending';
|
|
$sourceArchive['releases'][0]['approvedPayloadSha256'] = '';
|
|
$sourceArchive['releases'][0]['artifactKind'] = 'source';
|
|
assertThrows(static function () use (&$sourceArchive): void { Approval::approve($sourceArchive, 'releases', 'release-1', 'admin'); }, 'Wheel');
|
|
|
|
$duplicate = approvedFixture();
|
|
$second = $duplicate['releases'][0];
|
|
$second['id'] = 'release-2';
|
|
$second['externalId'] = '502';
|
|
$second['status'] = 'pending';
|
|
$second['approvedPayloadSha256'] = '';
|
|
$duplicate['releases'][] = $second;
|
|
assertThrows(static function () use (&$duplicate): void { Approval::approve($duplicate, 'releases', 'release-2', 'admin'); }, 'bereits');
|
|
});
|
|
|
|
test('release approval matches Host-Agent commit, Wheel identity and catalog bounds', static function (): void {
|
|
$invalidCommit = approvedFixture();
|
|
$invalidCommit['releases'][0]['status'] = 'pending';
|
|
$invalidCommit['releases'][0]['approvedPayloadSha256'] = '';
|
|
$invalidCommit['releases'][0]['commitSha'] = str_repeat('c', 64);
|
|
assertThrows(static function () use (&$invalidCommit): void {
|
|
Approval::approve($invalidCommit, 'releases', 'release-1', 'admin');
|
|
}, '40-stellig');
|
|
|
|
$unsafeFilename = approvedFixture();
|
|
$unsafeFilename['releases'][0]['status'] = 'pending';
|
|
$unsafeFilename['releases'][0]['approvedPayloadSha256'] = '';
|
|
$unsafeFilename['releases'][0]['downloadUrl'] = 'https://git.mrblake.cc/assets/not-a-wheel.whl';
|
|
assertThrows(static function () use (&$unsafeFilename): void {
|
|
Approval::approve($unsafeFilename, 'releases', 'release-1', 'admin');
|
|
}, 'Wheel-Dateinamen');
|
|
|
|
$wrongDistribution = approvedFixture();
|
|
$wrongDistribution['releases'][0]['status'] = 'pending';
|
|
$wrongDistribution['releases'][0]['approvedPayloadSha256'] = '';
|
|
$wrongDistribution['releases'][0]['downloadUrl'] = 'https://git.mrblake.cc/assets/other_plugin-1.2.3-py3-none-any.whl';
|
|
assertThrows(static function () use (&$wrongDistribution): void {
|
|
Approval::approve($wrongDistribution, 'releases', 'release-1', 'admin');
|
|
}, 'Distribution');
|
|
|
|
$wrongVersion = approvedFixture();
|
|
$wrongVersion['releases'][0]['status'] = 'pending';
|
|
$wrongVersion['releases'][0]['approvedPayloadSha256'] = '';
|
|
$wrongVersion['releases'][0]['downloadUrl'] = 'https://git.mrblake.cc/assets/netbox_demo-2.0.0-py3-none-any.whl';
|
|
assertThrows(static function () use (&$wrongVersion): void {
|
|
Approval::approve($wrongVersion, 'releases', 'release-1', 'admin');
|
|
}, 'Wheel-Version');
|
|
|
|
$bounded = approvedFixture();
|
|
$base = $bounded['releases'][0];
|
|
for ($number = 2; $number <= 1_000; $number++) {
|
|
$release = $base;
|
|
$release['id'] = 'release-' . $number;
|
|
$release['externalId'] = 'external-' . $number;
|
|
$release['version'] = '1.2.' . $number;
|
|
$release['downloadUrl'] = 'https://git.mrblake.cc/assets/netbox_demo-1.2.' . $number . '-py3-none-any.whl';
|
|
$release['approvedPayloadSha256'] = Approval::payloadHash($bounded['plugins'][0], $release);
|
|
$bounded['releases'][] = $release;
|
|
}
|
|
$candidate = $base;
|
|
$candidate['id'] = 'release-1001';
|
|
$candidate['externalId'] = 'external-1001';
|
|
$candidate['version'] = '2.0.0';
|
|
$candidate['downloadUrl'] = 'https://git.mrblake.cc/assets/netbox_demo-2.0.0-py3-none-any.whl';
|
|
$candidate['status'] = 'pending';
|
|
$candidate['approvedPayloadSha256'] = '';
|
|
$bounded['releases'][] = $candidate;
|
|
assertThrows(static function () use (&$bounded): void {
|
|
Approval::approve($bounded, 'releases', 'release-1001', 'admin');
|
|
}, '1.000');
|
|
|
|
$last = array_key_last($bounded['releases']);
|
|
$bounded['releases'][$last]['status'] = 'approved';
|
|
$bounded['releases'][$last]['approvedPayloadSha256'] = Approval::payloadHash($bounded['plugins'][0], $bounded['releases'][$last]);
|
|
assertSame(1_000, count(Catalog::releases($bounded, $bounded['plugins'][0])));
|
|
});
|
|
|
|
test('release discovery considers Wheel assets only', static function (): void {
|
|
$rank = new ReflectionMethod(ForgejoAdapter::class, 'assetRank');
|
|
assertSame(99, $rank->invoke(null, ['name' => 'plugin.tar.gz']));
|
|
assertSame(99, $rank->invoke(null, ['name' => 'plugin.whl.asc']));
|
|
assertSame(0, $rank->invoke(null, ['name' => 'plugin-1.0-py3-none-any.whl']));
|
|
assertSame(1, $rank->invoke(null, ['name' => 'plugin-1.0-cp312-linux_x86_64.whl']));
|
|
});
|
|
|
|
test('Forgejo and GitHub account repository and release pages before accumulation', static function () use ($config, $guard): void {
|
|
$cases = [
|
|
[ForgejoAdapter::class, 'accountRepositoryPage', 'repository', [
|
|
'baseUrl' => 'https://git.mrblake.cc', 'apiUrl' => 'https://git.mrblake.cc/api/v1',
|
|
'owner' => 'MrBlake', 'ownerKind' => 'user', 'tokenEnv' => '',
|
|
]],
|
|
[ForgejoAdapter::class, 'accountReleasePage', 'release', [
|
|
'baseUrl' => 'https://git.mrblake.cc', 'apiUrl' => 'https://git.mrblake.cc/api/v1',
|
|
'owner' => 'MrBlake', 'ownerKind' => 'user', 'tokenEnv' => '',
|
|
]],
|
|
[GitHubAdapter::class, 'accountRepositoryPage', 'repository', [
|
|
'baseUrl' => 'https://github.com', 'apiUrl' => 'https://api.github.com',
|
|
'owner' => 'MrBlake', 'ownerKind' => 'user', 'tokenEnv' => '',
|
|
]],
|
|
[GitHubAdapter::class, 'accountReleasePage', 'release', [
|
|
'baseUrl' => 'https://github.com', 'apiUrl' => 'https://api.github.com',
|
|
'owner' => 'MrBlake', 'ownerKind' => 'user', 'tokenEnv' => '',
|
|
]],
|
|
];
|
|
foreach ($cases as [$adapterClass, $methodName, $kind, $source]) {
|
|
$caseHttp = new HttpClient($config, $guard);
|
|
$budget = new SyncBudget(60, 100, 1_000_000, 1, 1);
|
|
$caseHttp->beginBudget($budget);
|
|
try {
|
|
$adapter = new $adapterClass($source, $config, $caseHttp);
|
|
$method = new ReflectionMethod($adapterClass, $methodName);
|
|
assertThrows(static function () use ($method, $adapter): void {
|
|
$method->invoke($adapter, [['id' => 1], ['id' => 2]]);
|
|
}, $kind . ' limit');
|
|
$usage = $budget->usage();
|
|
assertSame(2, $usage[$kind === 'repository' ? 'repositoriesCounted' : 'releasesCounted']);
|
|
} finally {
|
|
$caseHttp->endBudget($budget);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('catalog and release-detail API keep the exact client contract', static function () use ($config, $guard, $http): void {
|
|
$state = approvedFixture();
|
|
$releaseKeys = [
|
|
'version', 'download_url', 'sha256', 'artifact_size', 'artifact_kind', 'artifact_filename', 'commit_sha', 'min_netbox_version',
|
|
'max_netbox_version', 'published_at', 'approved', 'status', 'immutable', 'approved_payload_sha256',
|
|
];
|
|
$pluginKeys = [
|
|
'api_version', 'slug', 'name', 'summary', 'description', 'repository_url', 'latest_version',
|
|
'package_name', 'import_name', 'min_netbox_version', 'max_netbox_version', 'approved', 'status', 'releases',
|
|
];
|
|
$serialized = Catalog::serializePlugin($state, $state['plugins'][0]);
|
|
assertSame($pluginKeys, array_keys($serialized));
|
|
assertSame($releaseKeys, array_keys($serialized['releases'][0]));
|
|
assertSame('1.2.3', $serialized['latest_version']);
|
|
|
|
$repository = new MemoryRepository($state);
|
|
$sync = new SyncService($repository, $config, $http, $guard);
|
|
$app = new Application($config, $repository, new Auth($config, $repository), $sync, $guard);
|
|
$response = $app->handle(new Request('GET', '/api/v1/plugins/demo-plugin/releases/1.2.3', [], [], [], '127.0.0.1'));
|
|
assertSame(200, $response->status);
|
|
assertSame('no-store', $response->headers['Cache-Control']);
|
|
assertSame($releaseKeys, array_keys(json_decode($response->body, true, 512, JSON_THROW_ON_ERROR)));
|
|
|
|
$state['releases'] = [];
|
|
assertSame(null, Catalog::serializePlugin($state, $state['plugins'][0])['latest_version']);
|
|
});
|
|
|
|
test('catalog defensively filters stale, withdrawn and incomplete entries', static function (): void {
|
|
$state = approvedFixture();
|
|
$state['releases'][0]['withdrawn'] = true;
|
|
assertSame([], Catalog::releases($state, $state['plugins'][0]));
|
|
$state = approvedFixture();
|
|
$state['plugins'][0]['maxNetboxVersion'] = '';
|
|
assertSame([], Catalog::approvedPlugins($state));
|
|
});
|
|
|
|
test('public and admin templates render safely with complete artifact evidence', static function () use ($config): void {
|
|
$state = approvedFixture();
|
|
$plugin = $state['plugins'][0];
|
|
$plugin['source'] = $state['sources'][0];
|
|
$plugin['latestRelease'] = $state['releases'][0];
|
|
$release = $state['releases'][0];
|
|
$release['plugin'] = $state['plugins'][0];
|
|
$view = new View($config);
|
|
$common = ['currentPath' => '/', 'adminEnabled' => false, 'adminUser' => ''];
|
|
$home = $view->render('home', $common + [
|
|
'title' => 'Store', 'plugins' => [$plugin], 'sources' => $state['sources'], 'query' => '',
|
|
'selectedSource' => '', 'selectedNetboxVersion' => '', 'invalidVersion' => false,
|
|
'count' => 1, 'totalCount' => 1, 'page' => 1, 'pages' => 1,
|
|
]);
|
|
assertTrue(str_contains($home, 'Demo Plugin'));
|
|
$detail = $view->render('plugin', $common + [
|
|
'title' => 'Demo', 'plugin' => $state['plugins'][0], 'source' => $state['sources'][0], 'releases' => $state['releases'],
|
|
]);
|
|
assertTrue(str_contains($detail, 'Wheel'));
|
|
$installation = $view->render('installation', $common + ['title' => 'Installation']);
|
|
assertTrue(str_contains($installation, 'git+https://git.mrblake.cc/MrBlake/Netbox-Store.git'));
|
|
assertTrue(str_contains($installation, 'https://netbox.mrblake.cc'));
|
|
$admin = $view->render('admin/dashboard', [
|
|
'title' => 'Admin', 'currentPath' => '/admin', 'adminEnabled' => true, 'adminUser' => 'admin',
|
|
'csrf' => 'safe-token', 'ok' => '', 'error' => '', 'sources' => $state['sources'],
|
|
'plugins' => [$plugin], 'releases' => [$release], 'runs' => [], 'audits' => [],
|
|
]);
|
|
assertTrue(str_contains($admin, $release['downloadUrl']));
|
|
assertTrue(str_contains($admin, $release['sha256']));
|
|
assertTrue(str_contains($admin, '4.6.5'));
|
|
});
|
|
|
|
test('README rendering strips HTML and pins relative links to the commit', static function (): void {
|
|
$repository = fakeRepository();
|
|
$adapter = new FakeAdapter($repository);
|
|
$html = (new ReadmeRenderer())->render(
|
|
"# Demo\n\n<script>alert(1)</script>\n\n[Handbuch](../manual.md)  [Unsicher](javascript:alert(1))",
|
|
$adapter,
|
|
$repository,
|
|
'docs/README.md',
|
|
$repository['commitSha'],
|
|
);
|
|
assertTrue(!str_contains(strtolower($html), '<script'));
|
|
assertTrue(!str_contains(strtolower($html), 'javascript:'));
|
|
assertTrue(str_contains($html, '/raw/commit/' . $repository['commitSha'] . '/manual.md'));
|
|
assertTrue(str_contains($html, '/raw/commit/' . $repository['commitSha'] . '/docs/images/logo.png'));
|
|
assertTrue(str_contains($html, 'referrerpolicy="no-referrer"'));
|
|
});
|
|
|
|
test('discovery resolves dynamic setuptools version and pins every read', static function (): void {
|
|
$repository = fakeRepository();
|
|
$adapter = new FakeAdapter($repository);
|
|
$adapter->files = [
|
|
'pyproject.toml' => <<<'TOML'
|
|
[project]
|
|
name = "netbox-slm"
|
|
dynamic = ["version"]
|
|
dependencies = ["netbox>=4.6.5,<=4.6.8"]
|
|
[project.entry-points."netbox.plugins"]
|
|
slm = "netbox_slm"
|
|
[tool.setuptools.dynamic]
|
|
version = {attr = "netbox_slm.__version__"}
|
|
TOML,
|
|
'netbox_slm/__init__.py' => "__version__ = '1.13.0'\n",
|
|
'README.md' => '# SLM',
|
|
];
|
|
$result = (new Discovery())->discover($adapter, $repository, 'netbox-plugin');
|
|
assertSame('1.13.0', $result['version']);
|
|
assertSame('netbox_slm', $result['importName']);
|
|
assertTrue($adapter->readRefs !== []);
|
|
assertTrue(count(array_unique($adapter->readRefs)) === 1 && $adapter->readRefs[0] === $repository['commitSha']);
|
|
});
|
|
|
|
test('manifest schema version is ignored for compatibility-list manifests', static function (): void {
|
|
$repository = fakeRepository();
|
|
$adapter = new FakeAdapter($repository);
|
|
$adapter->files = [
|
|
'netbox-plugin.json' => json_encode(['version' => '0.1', 'compatibility' => [['netbox' => '4.5']]], JSON_THROW_ON_ERROR),
|
|
'setup.py' => "# netbox\nname = 'netbox-topology'\nversion = '4.5.1'\n",
|
|
'README.md' => '# Topology',
|
|
];
|
|
$result = (new Discovery())->discover($adapter, $repository, 'netbox-plugin');
|
|
assertSame('4.5.1', $result['version']);
|
|
});
|
|
|
|
test('HTTP authorization stays on the exact API origin and private literals fail closed', static function () use ($http, $guard): void {
|
|
$method = new ReflectionMethod(HttpClient::class, 'filterSensitiveHeaders');
|
|
$headers = ['Accept: application/json', 'Authorization: token very-secret'];
|
|
$same = $method->invoke($http, $headers, 'https://git.mrblake.cc/api/v1/repos', 'https://git.mrblake.cc');
|
|
$redirected = $method->invoke($http, $headers, 'https://github.com/assets/file.whl', 'https://git.mrblake.cc');
|
|
assertSame($headers, $same);
|
|
assertTrue(!array_filter($redirected, static fn (string $header): bool => str_starts_with(strtolower($header), 'authorization:')));
|
|
assertThrows(static function () use ($guard): void { $guard->assertConfiguredUrl('https://127.0.0.1/internal'); }, 'private');
|
|
});
|
|
|
|
test('default-source bootstrap does not write on the second call', static function () use ($config, $guard, $http): void {
|
|
$repository = new MemoryRepository(State::empty());
|
|
$service = new SyncService($repository, $config, $http, $guard);
|
|
assertTrue($service->ensureDefaultSource()['created']);
|
|
assertSame(1, $repository->transactions);
|
|
assertTrue(!$service->ensureDefaultSource()['created']);
|
|
assertSame(1, $repository->transactions, 'second bootstrap should be read-only');
|
|
});
|
|
|
|
test('JSON datastore transactions remain valid and atomic', static function () use ($storeRoot): void {
|
|
$directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'netbox-store-test-' . bin2hex(random_bytes(5));
|
|
$path = $directory . DIRECTORY_SEPARATOR . 'store.json';
|
|
$repository = new JsonStoreRepository($path, 1_024);
|
|
$repository->initialize();
|
|
$repository->transaction(static function (array &$state): void {
|
|
$state['auditLog'][] = ['id' => 'one'];
|
|
});
|
|
$decoded = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
|
|
assertSame('one', $decoded['auditLog'][0]['id']);
|
|
assertTrue(!glob($directory . DIRECTORY_SEPARATOR . '*.tmp-*'));
|
|
$before = hash_file('sha256', $path);
|
|
assertThrows(static function () use ($repository): void {
|
|
$repository->transaction(static function (array &$state): void {
|
|
$state['auditLog'][] = ['id' => 'oversized', 'details' => str_repeat('x', 2_000)];
|
|
});
|
|
}, 'safety limit');
|
|
assertSame($before, hash_file('sha256', $path), 'oversized draft must leave old datastore intact');
|
|
$otherRepository = new JsonStoreRepository($path, 1_024);
|
|
$firstLease = $repository->acquireLease('sync-source:one');
|
|
assertTrue($firstLease instanceof ExclusiveLease);
|
|
assertSame(null, $otherRepository->acquireLease('sync-source:one'), 'second process lease must fail immediately');
|
|
$firstLease->release();
|
|
$recoveredLease = $otherRepository->acquireLease('sync-source:one');
|
|
assertTrue($recoveredLease instanceof ExclusiveLease);
|
|
$recoveredLease->release();
|
|
@unlink($path);
|
|
@unlink($path . '.lock');
|
|
foreach (glob($path . '.lease-*.lock') ?: [] as $leasePath) {
|
|
@unlink($leasePath);
|
|
}
|
|
@rmdir($directory);
|
|
});
|
|
|
|
test('sync budgets enforce aggregate counters and deadline', static function (): void {
|
|
$requests = new SyncBudget(60, 1, 1_000, 10, 10);
|
|
$requests->consumeRequest();
|
|
assertThrows(static function () use ($requests): void { $requests->consumeRequest(); }, 'request limit');
|
|
|
|
$bytes = new SyncBudget(60, 10, 10, 10, 10);
|
|
assertTrue($bytes->tryConsumeBytes(6));
|
|
assertTrue(!$bytes->tryConsumeBytes(5));
|
|
assertThrows(static function () use ($bytes): void { $bytes->assertWithinLimits(); }, 'byte limit');
|
|
|
|
$repositories = new SyncBudget(60, 10, 1_000, 1, 10);
|
|
assertThrows(static function () use ($repositories): void { $repositories->consumeRepositories(2); }, 'repository limit');
|
|
assertSame(2, $repositories->usage()['repositoriesCounted']);
|
|
|
|
$releases = new SyncBudget(60, 10, 1_000, 10, 1);
|
|
assertThrows(static function () use ($releases): void { $releases->consumeReleases(2); }, 'release limit');
|
|
|
|
$deadline = new SyncBudget(0, 10, 1_000, 10, 10);
|
|
assertThrows(static function () use ($deadline): void { $deadline->checkpoint(); }, 'deadline');
|
|
});
|
|
|
|
test('sync service holds an exclusive lease independent of stale run age', static function () use ($config, $guard, $http): void {
|
|
$state = State::empty();
|
|
$state['sources'][] = [
|
|
'id' => 'source-sync', 'slug' => 'mrblake', 'name' => 'MrBlake', 'provider' => 'forgejo',
|
|
'baseUrl' => 'https://git.mrblake.cc', 'apiUrl' => 'https://git.mrblake.cc/api/v1',
|
|
'owner' => 'MrBlake', 'ownerKind' => 'user', 'tokenEnv' => '', 'topic' => 'netbox-plugin',
|
|
'status' => 'approved', 'active' => true, 'includeForks' => false, 'includeArchived' => false,
|
|
'autoApprovePlugins' => false,
|
|
];
|
|
$state['syncRuns'][] = [
|
|
'id' => 'old-run', 'sourceId' => 'source-sync', 'trigger' => 'command', 'status' => 'running',
|
|
'startedAt' => gmdate('Y-m-d\TH:i:s\Z', time() - 7 * 3600), 'finishedAt' => null, 'errors' => [],
|
|
];
|
|
$repository = new MemoryRepository($state);
|
|
$adapter = new FakeAdapter(fakeRepository() + ['empty' => true]);
|
|
$service = new SyncService($repository, $config, $http, $guard, adapterFactory: static fn (array $source): SourceAdapter => $adapter);
|
|
$held = $repository->acquireLease('sync-source:source-sync');
|
|
assertTrue($held instanceof ExclusiveLease);
|
|
assertThrows(static function () use ($service): void { $service->syncSource('source-sync'); }, 'already running');
|
|
assertSame('running', $repository->read()['syncRuns'][0]['status'], 'age must never bypass a held lease');
|
|
$held->release();
|
|
assertSame('success', $service->syncSource('source-sync')['status']);
|
|
assertSame('failed', $repository->read()['syncRuns'][0]['status'], 'orphaned run is recovered only after the lease is available');
|
|
});
|
|
|
|
test('sync service enforces aggregate repository and release limits', static function () use ($storeRoot, $guard, $http): void {
|
|
$source = [
|
|
'id' => 'source-budget', 'slug' => 'budget', 'name' => 'Budget', 'provider' => 'forgejo',
|
|
'baseUrl' => 'https://git.mrblake.cc', 'apiUrl' => 'https://git.mrblake.cc/api/v1',
|
|
'owner' => 'MrBlake', 'ownerKind' => 'user', 'tokenEnv' => '', 'topic' => 'netbox-plugin',
|
|
'status' => 'approved', 'active' => true, 'includeForks' => false, 'includeArchived' => false,
|
|
'autoApprovePlugins' => false,
|
|
];
|
|
try {
|
|
putenv('STORE_SYNC_MAX_REPOSITORIES=1');
|
|
putenv('STORE_SYNC_MAX_RELEASES=1');
|
|
$limitedConfig = Config::load($storeRoot);
|
|
|
|
$repositoryState = State::empty();
|
|
$repositoryState['sources'][] = $source;
|
|
$repositoryStore = new MemoryRepository($repositoryState);
|
|
$repositoryAdapter = new FakeAdapter(fakeRepository());
|
|
$secondRepository = fakeRepository();
|
|
$secondRepository['externalId'] = '102';
|
|
$secondRepository['name'] = 'netbox-demo-two';
|
|
$secondRepository['fullName'] = 'MrBlake/netbox-demo-two';
|
|
$secondRepository['htmlUrl'] .= '-two';
|
|
$repositoryAdapter->repositories = [fakeRepository(), $secondRepository];
|
|
$service = new SyncService($repositoryStore, $limitedConfig, $http, $guard, adapterFactory: static fn (array $item): SourceAdapter => $repositoryAdapter);
|
|
$run = $service->syncSource('source-budget');
|
|
assertSame('failed', $run['status']);
|
|
assertSame(2, $run['repositoriesCounted']);
|
|
assertSame([], $repositoryStore->read()['plugins']);
|
|
|
|
$releaseState = State::empty();
|
|
$releaseState['sources'][] = $source;
|
|
$releaseStore = new MemoryRepository($releaseState);
|
|
$releaseAdapter = new FakeAdapter(fakeRepository());
|
|
$releaseAdapter->files = ['pyproject.toml' => candidatePyproject(), 'README.md' => '# Demo'];
|
|
$releaseAdapter->releases = [
|
|
['externalId' => 'one', 'version' => '1.0.0'],
|
|
['externalId' => 'two', 'version' => '2.0.0'],
|
|
];
|
|
$service = new SyncService($releaseStore, $limitedConfig, $http, $guard, adapterFactory: static fn (array $item): SourceAdapter => $releaseAdapter);
|
|
$run = $service->syncSource('source-budget');
|
|
assertSame('failed', $run['status']);
|
|
assertSame(2, $run['releasesCounted']);
|
|
assertSame([], $releaseStore->read()['releases']);
|
|
} finally {
|
|
putenv('STORE_SYNC_MAX_REPOSITORIES=2000');
|
|
putenv('STORE_SYNC_MAX_RELEASES=1000');
|
|
}
|
|
});
|
|
|
|
test('private GitHub repositories sync metadata but never release assets', static function () use ($config, $guard, $http): void {
|
|
$state = State::empty();
|
|
$state['sources'][] = [
|
|
'id' => 'source-github', 'slug' => 'github', 'name' => 'GitHub', 'provider' => 'github',
|
|
'baseUrl' => 'https://github.com', 'apiUrl' => 'https://api.github.com',
|
|
'owner' => 'PrivateOrg', 'ownerKind' => 'organization', 'tokenEnv' => 'GITHUB_TOKEN', 'topic' => 'netbox-plugin',
|
|
'status' => 'approved', 'active' => true, 'includeForks' => false, 'includeArchived' => false,
|
|
'autoApprovePlugins' => false,
|
|
];
|
|
$repository = new MemoryRepository($state);
|
|
$upstream = fakeRepository();
|
|
$upstream['private'] = true;
|
|
$upstream['htmlUrl'] = 'https://github.com/PrivateOrg/netbox-demo';
|
|
$upstream['owner'] = 'PrivateOrg';
|
|
$upstream['fullName'] = 'PrivateOrg/netbox-demo';
|
|
$adapter = new FakeAdapter($upstream);
|
|
$adapter->files = ['pyproject.toml' => candidatePyproject(), 'README.md' => '# Private demo'];
|
|
$adapter->releases = [['externalId' => 'must-not-be-read', 'version' => '1.2.3']];
|
|
$service = new SyncService($repository, $config, $http, $guard, adapterFactory: static fn (array $source): SourceAdapter => $adapter);
|
|
$run = $service->syncSource('source-github');
|
|
assertSame('partial', $run['status']);
|
|
assertSame(0, $adapter->releaseListCalls);
|
|
assertSame(1, count($repository->read()['plugins']));
|
|
assertSame([], $repository->read()['releases']);
|
|
assertTrue(str_contains(json_encode($run['errors'], JSON_THROW_ON_ERROR), 'Private GitHub'));
|
|
$plugin = $repository->read()['plugins'][0];
|
|
$plugin['source'] = $repository->read()['sources'][0];
|
|
$dashboard = (new View($config))->render('admin/dashboard', [
|
|
'title' => 'Admin', 'currentPath' => '/admin', 'adminEnabled' => true, 'adminUser' => 'admin',
|
|
'csrf' => 'token', 'ok' => '', 'error' => '', 'sources' => $repository->read()['sources'],
|
|
'plugins' => [$plugin], 'releases' => [], 'runs' => [$run], 'audits' => [],
|
|
]);
|
|
assertTrue(str_contains($dashboard, 'Private GitHub'));
|
|
});
|
|
|
|
test('forwarded client IP is accepted only from an exact trusted proxy', static function () use ($storeRoot): void {
|
|
$server = $_SERVER;
|
|
$get = $_GET;
|
|
$post = $_POST;
|
|
try {
|
|
putenv('STORE_TRUST_PROXY=true');
|
|
putenv('STORE_TRUSTED_PROXY_IPS=127.0.0.1');
|
|
$_SERVER = ['REQUEST_URI' => '/', 'REQUEST_METHOD' => 'GET', 'REMOTE_ADDR' => '203.0.113.10', 'HTTP_X_FORWARDED_FOR' => '198.51.100.20'];
|
|
$_GET = $_POST = [];
|
|
$untrusted = Request::fromGlobals(Config::load($storeRoot));
|
|
assertSame('203.0.113.10', $untrusted->ip);
|
|
|
|
putenv('STORE_TRUSTED_PROXY_IPS=203.0.113.10');
|
|
$trusted = Request::fromGlobals(Config::load($storeRoot));
|
|
assertSame('198.51.100.20', $trusted->ip);
|
|
} finally {
|
|
$_SERVER = $server;
|
|
$_GET = $get;
|
|
$_POST = $post;
|
|
putenv('STORE_TRUST_PROXY=false');
|
|
putenv('STORE_TRUSTED_PROXY_IPS=127.0.0.1,::1');
|
|
}
|
|
});
|
|
|
|
test('sync preserves overrides, rehashes replacements, withdraws removals and archives non-candidates', static function () use ($config, $guard, $http): void {
|
|
$state = State::empty();
|
|
$state['sources'][] = [
|
|
'id' => 'source-sync', 'slug' => 'mrblake', 'name' => 'MrBlake', 'provider' => 'forgejo',
|
|
'baseUrl' => 'https://git.mrblake.cc', 'apiUrl' => 'https://git.mrblake.cc/api/v1',
|
|
'owner' => 'MrBlake', 'ownerKind' => 'user', 'tokenEnv' => '', 'topic' => 'netbox-plugin',
|
|
'status' => 'approved', 'active' => true, 'includeForks' => false, 'includeArchived' => false,
|
|
'autoApprovePlugins' => false,
|
|
];
|
|
$repository = new MemoryRepository($state);
|
|
$adapter = new FakeAdapter(fakeRepository());
|
|
$adapter->files = ['pyproject.toml' => candidatePyproject(), 'README.md' => '# Demo'];
|
|
$adapter->artifactSha = str_repeat('1', 64);
|
|
$adapter->releases = [[
|
|
'externalId' => 'release-upstream', 'version' => '1.2.3', 'title' => '1.2.3',
|
|
'releaseUrl' => 'https://git.mrblake.cc/MrBlake/netbox-demo/releases/1',
|
|
'downloadUrl' => 'https://git.mrblake.cc/assets/netbox_demo-1.2.3-py3-none-any.whl',
|
|
'expectedSha256' => '', 'commitSha' => str_repeat('c', 40),
|
|
'prerelease' => false, 'draft' => false, 'changelog' => '', 'publishedAt' => '2026-08-20T10:00:00Z',
|
|
]];
|
|
$service = new SyncService($repository, $config, $http, $guard, adapterFactory: static fn (array $source): SourceAdapter => $adapter);
|
|
assertSame('success', $service->syncSource('source-sync')['status']);
|
|
assertSame(1, $adapter->hashCalls);
|
|
|
|
$repository->transaction(static function (array &$draft): void {
|
|
$plugin = &$draft['plugins'][0];
|
|
$plugin['name'] = 'Admin Name';
|
|
$plugin['summary'] = 'Admin Summary';
|
|
$plugin['description'] = 'Admin Description';
|
|
$plugin['packageName'] = 'admin-package';
|
|
$plugin['importName'] = 'admin_plugin';
|
|
$plugin['minNetboxVersion'] = '4.6.5';
|
|
$plugin['maxNetboxVersion'] = '4.6.8';
|
|
$plugin['metadataOverrides'] = array_intersect_key($plugin, array_flip(['name', 'summary', 'description', 'packageName', 'importName', 'minNetboxVersion', 'maxNetboxVersion']));
|
|
$draft['releases'][0]['downloadUrl'] = 'https://git.mrblake.cc/assets/admin_package-1.2.3-py3-none-any.whl';
|
|
Approval::approve($draft, 'plugins', $plugin['id'], 'admin');
|
|
Approval::approve($draft, 'releases', $draft['releases'][0]['id'], 'admin');
|
|
});
|
|
$adapter->artifactSha = str_repeat('2', 64);
|
|
assertSame('success', $service->syncSource('source-sync')['status']);
|
|
$afterReplacement = $repository->read();
|
|
assertSame('Admin Name', $afterReplacement['plugins'][0]['name']);
|
|
assertSame('admin-package', $afterReplacement['plugins'][0]['packageName']);
|
|
assertSame(str_repeat('2', 64), $afterReplacement['releases'][0]['sha256']);
|
|
assertSame('pending', $afterReplacement['releases'][0]['status']);
|
|
assertSame(2, $adapter->hashCalls, 'same URL must be fetched and hashed again');
|
|
|
|
$repository->transaction(static function (array &$draft): void {
|
|
$draft['plugins'][0]['packageName'] = 'netbox-demo';
|
|
$draft['plugins'][0]['importName'] = 'netbox_demo';
|
|
$draft['plugins'][0]['metadataOverrides'] = [];
|
|
Approval::approve($draft, 'releases', $draft['releases'][0]['id'], 'admin');
|
|
});
|
|
$adapter->files['pyproject.toml'] = str_replace(
|
|
['name = "netbox-demo"', 'demo = "netbox_demo"'],
|
|
['name = "netbox-demo-next"', 'demo = "netbox_next"'],
|
|
candidatePyproject(),
|
|
);
|
|
$service->syncSource('source-sync');
|
|
$changedMetadata = $repository->read();
|
|
assertSame('pending', $changedMetadata['plugins'][0]['status'], 'upstream install metadata must reset plugin approval');
|
|
assertSame('pending', $changedMetadata['releases'][0]['status']);
|
|
|
|
$repository->transaction(static function (array &$draft): void {
|
|
$draft['releases'][0]['downloadUrl'] = 'https://git.mrblake.cc/assets/netbox_demo_next-1.2.3-py3-none-any.whl';
|
|
Approval::approve($draft, 'plugins', $draft['plugins'][0]['id'], 'admin');
|
|
Approval::approve($draft, 'releases', $draft['releases'][0]['id'], 'admin');
|
|
});
|
|
$adapter->releases = [];
|
|
$service->syncSource('source-sync');
|
|
$fallbackState = $repository->read();
|
|
$withdrawn = $fallbackState['releases'][0];
|
|
assertTrue($withdrawn['withdrawn']);
|
|
assertSame('pending', $withdrawn['status']);
|
|
assertSame('source_archive', $fallbackState['releases'][1]['artifactKind']);
|
|
assertSame('source:' . str_repeat('c', 40), $fallbackState['releases'][1]['externalId']);
|
|
|
|
$adapter->files = [];
|
|
$service->syncSource('source-sync');
|
|
$final = $repository->read();
|
|
assertTrue($final['plugins'][0]['archived']);
|
|
assertSame('pending', $final['plugins'][0]['status']);
|
|
assertSame([], Catalog::approvedPlugins($final));
|
|
});
|
|
|
|
$failures = 0;
|
|
$started = microtime(true);
|
|
foreach ($tests as $name => $testCase) {
|
|
try {
|
|
$testCase();
|
|
fwrite(STDOUT, "PASS {$name}\n");
|
|
} catch (Throwable $exception) {
|
|
$failures++;
|
|
fwrite(STDERR, "FAIL {$name}\n {$exception->getMessage()}\n");
|
|
}
|
|
}
|
|
$duration = number_format(microtime(true) - $started, 2);
|
|
fwrite($failures === 0 ? STDOUT : STDERR, sprintf("\n%d test(s), %d failure(s), %ss\n", count($tests), $failures, $duration));
|
|
exit($failures === 0 ? 0 : 1);
|