Выполнение шага 5:

• Updated Plan
  └ Продолжаю итерацию 5 как отдельный рабочий срез поверх текущих SSH credentials.
    □ Добавить модель/миграцию/repository для host_scans и покрыть repository тестами
    □ Добавить SSH command runner и LinuxHostScanner с whitelist read-only команд
    □ Подключить controller/routes/UI для запуска deep scan и просмотра результата
    □ Прогнать проверки и зафиксировать ограничения Docker-верификации
This commit is contained in:
2026-05-29 17:34:27 +08:00
parent 3981ffdf5e
commit dc3a466944
15 changed files with 837 additions and 6 deletions
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Domovoy\Tests\Repositories;
use Domovoy\Models\Credential;
use Domovoy\Repositories\CredentialRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\TestCase;
final class CredentialRepositoryTest extends TestCase
{
public function testFindByIdAndDeleteScopeCredentialsToDevice(): void
{
$pdo = new FakeCredentialPdo();
$repository = new CredentialRepository($pdo);
$credential = new Credential();
$credential->deviceId = 7;
$credential->name = 'main';
$credential->username = 'root';
$credential->encryptedSecret = 'encrypted';
$repository->save($credential);
self::assertSame('root', $repository->findById(1)?->username);
$repository->deleteForDevice(1, 99);
self::assertCount(1, $pdo->rows);
$repository->deleteForDevice(1, 7);
self::assertSame([], $pdo->rows);
}
public function testUpdateTestResultPersistsStatusAndTimestamp(): void
{
$pdo = new FakeCredentialPdo();
$repository = new CredentialRepository($pdo);
$credential = new Credential();
$credential->deviceId = 7;
$credential->name = 'main';
$credential->username = 'root';
$credential->encryptedSecret = 'encrypted';
$repository->save($credential);
$repository->updateTestResult(1, 'ok');
self::assertSame('ok', $pdo->rows[1]['last_test_status']);
self::assertNotNull($pdo->rows[1]['last_test_at']);
}
}
final class FakeCredentialPdo 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 FakeCredentialStatement($this, $query);
}
public function lastInsertId(?string $name = null): string|false
{
return (string)$this->lastId;
}
}
final class FakeCredentialStatement extends PDOStatement
{
/** @var array<string, mixed>|false */
private array|false $result = false;
public function __construct(
private FakeCredentialPdo $pdo,
private string $query
) {
}
public function execute(?array $params = null): bool
{
$params ??= [];
if (str_contains($this->query, 'SELECT * FROM credentials WHERE id = :id')) {
$this->result = $this->pdo->rows[(int)$params['id']] ?? false;
return true;
}
if (str_contains($this->query, 'INSERT INTO credentials')) {
$this->pdo->lastId++;
$params['id'] = $this->pdo->lastId;
$params['created_at'] ??= '2026-05-29 00:00:00';
$params['updated_at'] ??= '2026-05-29 00:00:00';
$this->pdo->rows[$this->pdo->lastId] = $params;
return true;
}
if (str_contains($this->query, 'DELETE FROM credentials WHERE id = :id AND device_id = :device_id')) {
$id = (int)$params['id'];
if (($this->pdo->rows[$id]['device_id'] ?? null) === $params['device_id']) {
unset($this->pdo->rows[$id]);
}
return true;
}
if (str_contains($this->query, 'UPDATE credentials SET last_test_status')) {
$id = (int)$params['id'];
$this->pdo->rows[$id]['last_test_status'] = $params['last_test_status'];
$this->pdo->rows[$id]['last_test_at'] = $params['last_test_at'];
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;
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Domovoy\Tests\Repositories;
use Domovoy\Models\HostScan;
use Domovoy\Repositories\HostScanRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\TestCase;
final class HostScanRepositoryTest extends TestCase
{
public function testSaveAndFindByIdRoundTripsSummary(): void
{
$pdo = new FakeHostScanPdo();
$repository = new HostScanRepository($pdo);
$scan = new HostScan();
$scan->deviceId = 5;
$scan->credentialId = 9;
$scan->status = 'done';
$scan->summary = ['hostname' => 'server-1'];
$scan->rawPath = 'storage/scans/test.json';
$scan->startedAt = new \DateTimeImmutable('2026-05-29 10:00:00');
$scan->finishedAt = new \DateTimeImmutable('2026-05-29 10:00:05');
$repository->save($scan);
$loaded = $repository->findById(1);
self::assertSame(1, $scan->id);
self::assertSame(['hostname' => 'server-1'], $loaded?->summary);
self::assertSame('done', $loaded?->status);
}
}
final class FakeHostScanPdo 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 FakeHostScanStatement($this, $query);
}
public function lastInsertId(?string $name = null): string|false
{
return (string)$this->lastId;
}
}
final class FakeHostScanStatement extends PDOStatement
{
/** @var array<string, mixed>|false */
private array|false $result = false;
public function __construct(
private FakeHostScanPdo $pdo,
private string $query
) {
}
public function execute(?array $params = null): bool
{
$params ??= [];
if (str_contains($this->query, 'INSERT INTO host_scans')) {
$this->pdo->lastId++;
$params['id'] = $this->pdo->lastId;
$params['created_at'] ??= '2026-05-29 00:00:00';
$this->pdo->rows[$this->pdo->lastId] = $params;
return true;
}
if (str_contains($this->query, 'SELECT * FROM host_scans WHERE id = :id')) {
$this->result = $this->pdo->rows[(int)$params['id']] ?? false;
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;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Domovoy\Tests\Security;
use Domovoy\Services\Security\CredentialVault;
use PHPUnit\Framework\TestCase;
final class CredentialVaultTest extends TestCase
{
public function testEncryptsAndDecryptsSecretWithoutStoringPlaintext(): void
{
$vault = new CredentialVault('test-key-material');
$encrypted = $vault->encrypt('secret-password');
self::assertNotSame('secret-password', $encrypted);
self::assertSame('secret-password', $vault->decrypt($encrypted));
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Domovoy\Tests\Services;
use Domovoy\Models\Credential;
use Domovoy\Models\Device;
use Domovoy\Services\HostScan\CommandWhitelist;
use Domovoy\Services\HostScan\LinuxHostScanner;
use PHPUnit\Framework\TestCase;
final class LinuxHostScannerTest extends TestCase
{
public function testCollectsReadOnlyCommandsAndWritesRawJson(): void
{
$storagePath = sys_get_temp_dir() . '/domovoy-host-scan-test-' . uniqid();
mkdir($storagePath, 0777, true);
$runner = new FakeCommandRunner([
'hostnamectl --static' => ['exit_code' => 0, 'stdout' => "server-1\n", 'stderr' => ''],
'cat /etc/os-release' => ['exit_code' => 0, 'stdout' => "PRETTY_NAME=\"Debian GNU/Linux 12\"\n", 'stderr' => ''],
'uname -r' => ['exit_code' => 0, 'stdout' => "6.1.0\n", 'stderr' => ''],
'df -h --output=source,size,used,avail,pcent,target' => ['exit_code' => 0, 'stdout' => "Filesystem Size Used Avail Use% Mounted on\n/dev/sda1 20G 10G 10G 50% /\n", 'stderr' => ''],
'systemctl --failed --no-pager --plain' => ['exit_code' => 0, 'stdout' => "0 loaded units listed.\n", 'stderr' => ''],
'crontab -l' => ['exit_code' => 1, 'stdout' => '', 'stderr' => 'no crontab for root'],
]);
$scanner = new LinuxHostScanner($runner, new CommandWhitelist(), $storagePath);
$device = new Device();
$device->id = 5;
$device->primaryIp = '192.168.1.10';
$credential = new Credential();
$credential->id = 9;
$result = $scanner->scan($device, $credential);
self::assertSame('server-1', $result['summary']['hostname']);
self::assertSame('Debian GNU/Linux 12', $result['summary']['os']);
self::assertSame('6.1.0', $result['summary']['kernel']);
self::assertFileExists($result['raw_path']);
self::assertStringContainsString('hostnamectl', file_get_contents($result['raw_path']));
}
}
final class FakeCommandRunner
{
/** @param array<string, array{exit_code: int, stdout: string, stderr: string}> $responses */
public function __construct(private array $responses)
{
}
/** @return array{exit_code: int, stdout: string, stderr: string} */
public function run(Credential $credential, string $host, array $command): array
{
$key = implode(' ', $command);
return $this->responses[$key] ?? ['exit_code' => 127, 'stdout' => '', 'stderr' => 'missing fake response'];
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Domovoy\Tests\Services;
use Domovoy\Models\Credential;
use Domovoy\Services\Security\CredentialVault;
use Domovoy\Services\Ssh\SshCredentialTester;
use PHPUnit\Framework\TestCase;
final class SshCredentialTesterTest extends TestCase
{
public function testTestsPasswordCredentialWithDecryptedSecret(): void
{
$vault = new CredentialVault('test-key-material');
$credential = new Credential();
$credential->username = 'root';
$credential->authMethod = 'password';
$credential->encryptedSecret = $vault->encrypt('secret-password');
$tester = new SshCredentialTester($vault, function (string $host, int $port, int $timeout) {
self::assertSame('192.168.1.10', $host);
self::assertSame(22, $port);
self::assertSame(5, $timeout);
return new FakeSshClient();
}, ['connect_timeout' => 5]);
$result = $tester->test($credential, '192.168.1.10');
self::assertSame('ok', $result['status']);
self::assertSame('Подключение успешно', $result['message']);
self::assertSame('root', FakeSshClient::$lastUsername);
self::assertSame('secret-password', FakeSshClient::$lastPassword);
}
}
final class FakeSshClient
{
public static ?string $lastUsername = null;
public static ?string $lastPassword = null;
public function login(string $username, string $password): bool
{
self::$lastUsername = $username;
self::$lastPassword = $password;
return true;
}
}