Files
Netbox-Store/store/tests/run.php
T
MrBlake a4cecec7b9
CI / php-store (push) Waiting to run
CI / python-components (push) Waiting to run
Document real host agent installations
2026-08-24 22:50:48 +02:00

877 lines
41 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, 'https://git.mrblake.cc/MrBlake/Netbox-Store/archive/main.tar.gz'));
assertTrue(str_contains($installation, "sed -i '/^[[:space:]]*netbox-plugin-store"));
assertTrue(str_contains($installation, 'sudo apt install -y git'));
assertTrue(str_contains($installation, 'https://netbox.mrblake.cc'));
assertTrue(str_contains($installation, 'API_TOKEN_PEPPERS'));
assertTrue(str_contains($installation, '/opt/netbox/netbox/generate_secret_key.py'));
assertTrue(str_contains($installation, 'python -m py_compile'));
assertTrue(str_contains($installation, 'control_socket_disable = True'));
assertTrue(str_contains($installation, 'http://127.0.0.1:8001/login/'));
assertTrue(str_contains($installation, 'mrblake-netbox-store-agent @ https://git.mrblake.cc'));
assertTrue(str_contains($installation, 'netbox-store-agent.socket'));
assertTrue(str_contains($installation, 'dry_run = false'));
assertTrue(str_contains($installation, '"execution_mode": "agent"'));
assertTrue(str_contains($installation, '"default_dry_run": False'));
$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) ![Logo](images/logo.png) [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);