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