*/ private array $leases = []; /** @param array $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 */ public array $files = []; /** @var list> */ public array $releases = []; /** @var list> */ public array $repositories = []; /** @var list */ public array $readRefs = []; public string $artifactSha = ''; public int $artifactSize = 128; public int $hashCalls = 0; public int $releaseListCalls = 0; public bool $throwOnCommit = false; /** @param array $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 $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 */ 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' => '

README

', ]; $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, 'raw/branch/main/install.sh')); assertTrue(str_contains($installation, 'ECHT INSTALLIEREN')); assertTrue(str_contains($installation, '40-stelligen Commit')); assertTrue(str_contains($installation, '/var/backups/netbox-plugin-store/')); assertTrue(str_contains($installation, 'Dry-Run: Nein')); assertTrue(str_contains($installation, 'Release-Artefakte')); assertTrue(str_contains($installation, 'configuration.py must contain exactly one static PLUGINS assignment')); assertTrue(str_contains($installation, 'Socket-Prüfung meldet')); assertTrue(str_contains($installation, 'python -m py_compile')); 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, 'control_socket_disable = True')); assertTrue(str_contains($installation, 'http://127.0.0.1:8001/login/')); assertTrue(str_contains($installation, 'netbox-store-agent.socket')); assertTrue(str_contains($installation, 'netbox-store-agent capabilities')); $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\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), '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);