Stabilize discovery workflow

This commit is contained in:
2026-05-29 17:16:46 +08:00
parent 15772bc17e
commit 3981ffdf5e
40 changed files with 2039 additions and 211 deletions
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Domovoy\Tests\Controllers;
use Domovoy\Controllers\NetworkRangeController;
use Domovoy\Repositories\NetworkRangeRepository;
use PHPUnit\Framework\TestCase;
final class NetworkRangeControllerTest extends TestCase
{
public function testPrivateIpv4RangesAreAllowedAndPublicRangesAreRejected(): void
{
$controller = new NetworkRangeController(new NoopNetworkRangeRepository());
$method = new \ReflectionMethod(NetworkRangeController::class, 'isValidCidr');
$method->setAccessible(true);
self::assertTrue($method->invoke($controller, '10.0.0.0/8'));
self::assertTrue($method->invoke($controller, '172.16.0.0/12'));
self::assertTrue($method->invoke($controller, '192.168.1.1/32'));
self::assertTrue($method->invoke($controller, '169.254.10.20/32'));
self::assertTrue($method->invoke($controller, '127.0.0.1/32'));
self::assertFalse($method->invoke($controller, '8.8.8.8/32'));
self::assertFalse($method->invoke($controller, '1.1.1.0/24'));
}
}
final class NoopNetworkRangeRepository extends NetworkRangeRepository
{
public function __construct()
{
}
}
+152
View File
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace Domovoy\Tests\Discovery;
use Domovoy\Models\DiscoveredHost;
use Domovoy\Models\NetworkRange;
use Domovoy\Repositories\DiscoveredHostRepository;
use Domovoy\Services\Discovery\ArpTableReader;
use Domovoy\Services\Discovery\HostFingerprintService;
use Domovoy\Services\Discovery\NetworkScanner;
use Domovoy\Services\Discovery\PingScanner;
use Domovoy\Services\Discovery\TcpPortScanner;
use PHPUnit\Framework\TestCase;
final class NetworkScannerTest extends TestCase
{
public function testScanEnumeratesUsableHostsForThirtyBitCidr(): void
{
$repository = new InMemoryDiscoveredHostRepository();
$scanner = new NetworkScanner(
new AlwaysAlivePingScanner(),
new EmptyTcpPortScanner(),
new EmptyArpTableReader(),
new StableFingerprintService(),
$repository
);
$range = new NetworkRange();
$range->cidr = '192.168.1.0/30';
$hosts = $scanner->scan($range, 123);
self::assertSame(['192.168.1.1', '192.168.1.2'], array_map(
static fn (DiscoveredHost $host): string => $host->ipAddress,
$hosts
));
self::assertSame([123, 123], array_map(
static fn (DiscoveredHost $host): ?int => $host->scanJobId,
$repository->saved
));
}
public function testScanRejectsRangesLargerThanTwentyFourForMvp(): void
{
$scanner = new NetworkScanner(
new AlwaysAlivePingScanner(),
new EmptyTcpPortScanner(),
new EmptyArpTableReader(),
new StableFingerprintService(),
new InMemoryDiscoveredHostRepository()
);
$range = new NetworkRange();
$range->cidr = '192.168.0.0/23';
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('larger than /24');
$scanner->scan($range, 321);
}
public function testResumableScanStartsAtSavedIndexAndReportsProgress(): void
{
$repository = new InMemoryDiscoveredHostRepository();
$scanner = new NetworkScanner(
new AlwaysAlivePingScanner(),
new EmptyTcpPortScanner(),
new EmptyArpTableReader(),
new StableFingerprintService(),
$repository
);
$range = new NetworkRange();
$range->cidr = '192.168.1.0/30';
$progress = [];
$result = $scanner->scanResumable(
$range,
456,
1,
static fn (): bool => false,
static function (array $state) use (&$progress): void {
$progress[] = $state;
}
);
self::assertSame(['192.168.1.2'], array_map(
static fn (DiscoveredHost $host): string => $host->ipAddress,
$result['hosts']
));
self::assertTrue($result['completed']);
self::assertSame(2, $result['next_ip_index']);
self::assertSame(2, $result['total_ips']);
self::assertSame(1, $result['scanned_count']);
self::assertSame(1, $progress[0]['next_ip_index']);
self::assertSame(2, $progress[1]['next_ip_index']);
}
}
final class AlwaysAlivePingScanner extends PingScanner
{
public function ping(string $ip, int $timeoutMs = 500): bool
{
return true;
}
}
final class EmptyTcpPortScanner extends TcpPortScanner
{
public function scan(string $ip, array $ports, int $timeoutMs = 200, int $maxConcurrency = 50): array
{
return [];
}
}
final class EmptyArpTableReader extends ArpTableReader
{
public function read(): array
{
return [];
}
}
final class StableFingerprintService extends HostFingerprintService
{
public function guessVendor(array $openPorts, ?string $hostname = null): ?string
{
return null;
}
public function calculateConfidence(int $openPortCount, bool $hasHostname): int
{
return 50;
}
}
final class InMemoryDiscoveredHostRepository extends DiscoveredHostRepository
{
/** @var DiscoveredHost[] */
public array $saved = [];
public function __construct()
{
}
public function save(DiscoveredHost $host): void
{
$this->saved[] = $host;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Domovoy\Tests\Discovery;
use Domovoy\Services\Discovery\TcpPortScanner;
use PHPUnit\Framework\TestCase;
final class TcpPortScannerTest extends TestCase
{
public function testTimedOutConnectionIsNotReportedAsOpen(): void
{
$scanner = new TcpPortScanner();
$openPorts = $scanner->scan('10.255.255.1', [9100], 50, 1);
self::assertSame([], $openPorts);
}
}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Domovoy\Tests\Inventory;
use Domovoy\Models\Device;
use Domovoy\Models\DiscoveredHost;
use Domovoy\Repositories\DeviceRepository;
use Domovoy\Repositories\DiscoveredHostRepository;
use Domovoy\Services\Inventory\DeviceService;
use PHPUnit\Framework\TestCase;
final class DeviceServiceMergeTest extends TestCase
{
public function testMergeDiscoveredHostLinksHostAndFillsBlankDeviceFields(): void
{
$device = new Device();
$device->id = 5;
$device->name = 'server';
$host = new DiscoveredHost();
$host->id = 9;
$host->ipAddress = '192.168.1.50';
$host->macAddress = 'aa:bb:cc:dd:ee:ff';
$host->hostname = 'server.local';
$host->vendor = 'Intel';
$devices = new MergeDeviceRepository($device);
$hosts = new MergeHostRepository($host);
$service = new DeviceService($devices, $hosts);
$service->mergeDiscoveredHost(9, 5);
self::assertSame('192.168.1.50', $device->primaryIp);
self::assertSame('aa:bb:cc:dd:ee:ff', $device->macAddress);
self::assertSame('server.local', $device->hostname);
self::assertSame('Intel', $device->vendor);
self::assertSame('merged', $host->status);
self::assertSame('5', $host->matchedDeviceId);
self::assertSame($device, $devices->savedDevice);
self::assertSame($host, $hosts->savedHost);
}
}
final class MergeDeviceRepository extends DeviceRepository
{
public ?Device $savedDevice = null;
public function __construct(private Device $device)
{
}
public function findById(int $id): ?Device
{
return $id === $this->device->id ? $this->device : null;
}
public function save(Device $device): void
{
$this->savedDevice = $device;
}
}
final class MergeHostRepository extends DiscoveredHostRepository
{
public ?DiscoveredHost $savedHost = null;
public function __construct(private DiscoveredHost $host)
{
}
public function findById(int $id): ?DiscoveredHost
{
return $id === $this->host->id ? $this->host : null;
}
public function save(DiscoveredHost $host): void
{
$this->savedHost = $host;
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Domovoy\Tests\Inventory;
use Domovoy\Models\Device;
use Domovoy\Models\DiscoveredHost;
use Domovoy\Repositories\DeviceRepository;
use Domovoy\Services\Inventory\MergeSuggestionService;
use PHPUnit\Framework\TestCase;
final class MergeSuggestionServiceTest extends TestCase
{
public function testSuggestsDeviceByIpWhenMacAndHostnameDoNotMatch(): void
{
$device = new Device();
$device->id = 10;
$device->name = 'router';
$device->primaryIp = '192.168.1.1';
$repository = new FakeDeviceRepository(ipMatch: $device);
$service = new MergeSuggestionService($repository);
$host = new DiscoveredHost();
$host->ipAddress = '192.168.1.1';
$suggestions = $service->findSuggestions($host);
self::assertSame($device, $suggestions[0]['device']);
self::assertSame(40, $suggestions[0]['confidence']);
self::assertSame('IP адрес совпадает', $suggestions[0]['reason']);
}
}
final class FakeDeviceRepository extends DeviceRepository
{
public function __construct(
private ?Device $macMatch = null,
private ?Device $nameMatch = null,
private ?Device $ipMatch = null
) {
}
public function findByMac(string $macAddress): ?Device
{
return $this->macMatch;
}
public function findByName(string $name): ?Device
{
return $this->nameMatch;
}
public function findByIp(string $ipAddress): ?Device
{
return $this->ipMatch;
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace Domovoy\Tests\Jobs;
use Domovoy\Models\NetworkRange;
use Domovoy\Models\ScanJob;
use Domovoy\Repositories\NetworkRangeRepository;
use Domovoy\Repositories\ScanJobRepository;
use Domovoy\Services\Discovery\NetworkScanner;
use Domovoy\Services\Jobs\ScanJobRunner;
use PHPUnit\Framework\TestCase;
final class ScanJobRunnerTest extends TestCase
{
public function testNetworkDiscoveryJobScansItsNetworkRange(): void
{
$job = new ScanJob();
$job->id = 77;
$job->type = 'network_discovery';
$job->status = 'pending';
$job->networkRangeId = 5;
$range = new NetworkRange();
$range->id = 5;
$range->cidr = '192.168.1.1/32';
$jobs = new InMemoryScanJobRepository([$job]);
$ranges = new InMemoryNetworkRangeLookup([5 => $range]);
$scanner = new RecordingNetworkScanner();
$runner = new ScanJobRunner($jobs, $scanner, $ranges);
$runner->runNext();
self::assertSame('done', $job->status);
self::assertSame($range, $scanner->scannedRange);
self::assertSame(77, $scanner->scanJobId);
}
public function testPausedDiscoveryJobKeepsProgressAndDoesNotBecomeDone(): void
{
$job = new ScanJob();
$job->id = 88;
$job->type = 'network_discovery';
$job->status = 'pending';
$job->networkRangeId = 5;
$job->resultJson = json_encode([
'next_ip_index' => 1,
'hosts_found' => 2,
]);
$range = new NetworkRange();
$range->id = 5;
$range->cidr = '192.168.1.0/30';
$jobs = new InMemoryScanJobRepository([$job]);
$ranges = new InMemoryNetworkRangeLookup([5 => $range]);
$scanner = new RecordingNetworkScanner();
$scanner->result = [
'hosts' => [],
'completed' => false,
'next_ip_index' => 2,
'total_ips' => 2,
'scanned_count' => 1,
'hosts_found' => 2,
];
$jobs->statusAfterFirstProgressCheck = 'paused';
$runner = new ScanJobRunner($jobs, $scanner, $ranges);
$runner->runNext();
self::assertSame('paused', $job->status);
self::assertNull($job->finishedAt);
self::assertSame(1, $scanner->startIndex);
self::assertSame(2, json_decode($job->resultJson ?? '{}', true)['next_ip_index']);
}
}
final class InMemoryScanJobRepository extends ScanJobRepository
{
public ?string $statusAfterFirstProgressCheck = null;
private int $findByIdCalls = 0;
/** @param ScanJob[] $pending */
public function __construct(private array $pending)
{
}
public function findPending(): array
{
return $this->pending;
}
public function save(ScanJob $job): void
{
}
public function findById(int $id): ?ScanJob
{
$this->findByIdCalls++;
if ($this->statusAfterFirstProgressCheck !== null && $this->findByIdCalls > 1) {
$this->pending[0]->status = $this->statusAfterFirstProgressCheck;
}
return $this->pending[0] ?? null;
}
}
final class InMemoryNetworkRangeLookup extends NetworkRangeRepository
{
/** @param array<int, NetworkRange> $ranges */
public function __construct(private array $ranges)
{
}
public function findById(int $id): ?NetworkRange
{
return $this->ranges[$id] ?? null;
}
}
final class RecordingNetworkScanner extends NetworkScanner
{
public ?NetworkRange $scannedRange = null;
public ?int $scanJobId = null;
public ?int $startIndex = null;
public array $result = [
'hosts' => [],
'completed' => true,
'next_ip_index' => 1,
'total_ips' => 1,
'scanned_count' => 1,
'hosts_found' => 0,
];
public function __construct()
{
}
public function scan(NetworkRange $range, ?int $scanJobId = null): array
{
$this->scannedRange = $range;
$this->scanJobId = $scanJobId;
return [];
}
public function scanResumable(
NetworkRange $range,
int $scanJobId,
int $startIndex,
callable $shouldStop,
callable $onProgress
): array {
$this->scannedRange = $range;
$this->scanJobId = $scanJobId;
$this->startIndex = $startIndex;
$onProgress($this->result);
return $this->result;
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Domovoy\Tests\Repositories;
use Domovoy\Repositories\DiscoveredHostRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\TestCase;
final class DiscoveredHostFilterTest extends TestCase
{
public function testFindFilteredAppliesRangeScanJobAndStatusFilters(): void
{
$pdo = new FilterCapturePdo();
$repository = new DiscoveredHostRepository($pdo);
$repository->findFiltered(7, 3, 'new', 50);
self::assertStringContainsString('LEFT JOIN scan_jobs', $pdo->query);
self::assertStringContainsString('LEFT JOIN network_ranges', $pdo->query);
self::assertStringContainsString('LEFT JOIN devices', $pdo->query);
self::assertStringContainsString('sj.network_range_id = :range_id', $pdo->query);
self::assertStringContainsString('dh.scan_job_id = :scan_job_id', $pdo->query);
self::assertStringContainsString('dh.status = :status', $pdo->query);
self::assertSame(7, $pdo->bound['range_id']);
self::assertSame(3, $pdo->bound['scan_job_id']);
self::assertSame('new', $pdo->bound['status']);
self::assertSame(50, $pdo->bound['limit']);
}
public function testFindFilteredSortsByIpByDefault(): void
{
$pdo = new FilterCapturePdo();
$repository = new DiscoveredHostRepository($pdo);
$repository->findFiltered(null, null, 'all', 50);
self::assertStringContainsString('ORDER BY INET_ATON(dh.ip_address) ASC', $pdo->query);
}
public function testFindFilteredAppliesAllowedSortColumnAndDirection(): void
{
$pdo = new FilterCapturePdo();
$repository = new DiscoveredHostRepository($pdo);
$repository->findFiltered(null, null, 'all', 50, 'hostname', 'desc');
self::assertStringContainsString('ORDER BY dh.hostname DESC', $pdo->query);
}
}
final class FilterCapturePdo extends PDO
{
public string $query = '';
/** @var array<string, mixed> */
public array $bound = [];
public function __construct()
{
}
public function prepare(string $query, array $options = []): PDOStatement|false
{
$this->query = $query;
return new FilterCaptureStatement($this);
}
}
final class FilterCaptureStatement extends PDOStatement
{
public function __construct(private FilterCapturePdo $pdo)
{
}
public function bindValue(string|int $param, mixed $value, int $type = PDO::PARAM_STR): bool
{
$this->pdo->bound[ltrim((string)$param, ':')] = $value;
return true;
}
public function execute(?array $params = null): bool
{
foreach (($params ?? []) as $key => $value) {
$this->pdo->bound[ltrim((string)$key, ':')] = $value;
}
return true;
}
public function fetch(int $mode = PDO::FETCH_DEFAULT, int $cursorOrientation = PDO::FETCH_ORI_NEXT, int $cursorOffset = 0): mixed
{
return false;
}
}
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace Domovoy\Tests\Repositories;
use Domovoy\Models\DiscoveredHost;
use Domovoy\Repositories\DiscoveredHostRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\TestCase;
final class DiscoveredHostRepositoryTest extends TestCase
{
public function testSaveUpdatesExistingNewHostByMacInsteadOfCreatingDuplicate(): void
{
$pdo = new FakeDiscoveredHostPdo();
$repository = new DiscoveredHostRepository($pdo);
$first = new DiscoveredHost();
$first->scanJobId = 1;
$first->ipAddress = '192.168.1.10';
$first->macAddress = 'AA:BB:CC:DD:EE:FF';
$first->hostname = 'old-host';
$first->openPorts = [22];
$repository->save($first);
$second = new DiscoveredHost();
$second->scanJobId = 2;
$second->ipAddress = '192.168.1.20';
$second->macAddress = 'aa:bb:cc:dd:ee:ff';
$second->hostname = 'new-host';
$second->openPorts = [22, 80];
$repository->save($second);
self::assertCount(1, $pdo->rows);
self::assertSame(1, $first->id);
self::assertSame(1, $second->id);
self::assertSame(2, $pdo->rows[1]['scan_job_id']);
self::assertSame('192.168.1.20', $pdo->rows[1]['ip_address']);
self::assertSame('new-host', $pdo->rows[1]['hostname']);
self::assertSame('[22,80]', $pdo->rows[1]['open_ports_json']);
}
}
final class FakeDiscoveredHostPdo extends PDO
{
/** @var array<int, array<string, mixed>> */
public array $rows = [];
public int $lastId = 0;
public function __construct()
{
}
public function prepare(string $query, array $options = []): PDOStatement|false
{
return new FakeDiscoveredHostStatement($this, $query);
}
public function lastInsertId(?string $name = null): string|false
{
return (string)$this->lastId;
}
}
final class FakeDiscoveredHostStatement extends PDOStatement
{
/** @var array<string, mixed>|false */
private array|false $result = false;
public function __construct(
private FakeDiscoveredHostPdo $pdo,
private string $query
) {
}
public function execute(?array $params = null): bool
{
$params ??= [];
if (str_contains($this->query, 'SELECT * FROM discovered_hosts WHERE id = :id')) {
$this->result = $this->pdo->rows[(int)$params['id']] ?? false;
return true;
}
if (str_contains($this->query, 'WHERE mac_address = :mac_address')) {
$this->result = false;
foreach ($this->pdo->rows as $row) {
if ($row['mac_address'] === $params['mac_address'] && $row['status'] === 'new') {
$this->result = $row;
break;
}
}
return true;
}
if (str_contains($this->query, 'WHERE ip_address = :ip_address')) {
$this->result = false;
foreach ($this->pdo->rows as $row) {
if ($row['ip_address'] === $params['ip_address'] && $row['status'] === 'new') {
$this->result = $row;
break;
}
}
return true;
}
if (str_contains($this->query, 'INSERT INTO discovered_hosts')) {
$this->pdo->lastId++;
$params['id'] = $this->pdo->lastId;
$this->pdo->rows[$this->pdo->lastId] = $params;
return true;
}
if (str_contains($this->query, 'UPDATE discovered_hosts SET')) {
$id = (int)$params['id'];
$this->pdo->rows[$id] = array_replace($this->pdo->rows[$id], $params);
return true;
}
throw new \RuntimeException('Unexpected query: ' . $this->query);
}
public function fetch(int $mode = PDO::FETCH_DEFAULT, int $cursorOrientation = PDO::FETCH_ORI_NEXT, int $cursorOffset = 0): mixed
{
return $this->result;
}
}