diff --git a/PLAN.md b/PLAN.md
index 6c4f51f..c1295ce 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -202,6 +202,11 @@
---
+## Итерация 4. SSH-доступы к устройствам
+
+Цель: хранить SSH-доступы к устройству, не показывать секреты в UI
+и уметь проверить подключение.
+
Что делаем:
- Миграция credentials
- Credentials CRUD (только SSH для MVP)
@@ -215,14 +220,16 @@
migrations/...CreateCredentials.php
app/Controllers/CredentialController.php
app/Services/Security/CredentialVault.php
- app/Services/HostScan/SshClientFactory.php
+ app/Services/Ssh/SshCredentialTester.php
app/Repositories/CredentialRepository.php
- templates/credentials/_form.php
- templates/credentials/_test_result.php
templates/devices/show.php (добавить блок доступов)
.env.example (добавить ENCRYPTION_KEY)
+ tests/Security/CredentialVaultTest.php
+ tests/Repositories/CredentialRepositoryTest.php
+ tests/Services/SshCredentialTesterTest.php
Проверка:
+ docker compose exec app php vendor/bin/phinx migrate
# Добавить SSH-доступ к устройству
# Нажать "Тест" — увидеть результат подключения
# Секрет не отображается в UI открытым текстом
diff --git a/app/Controllers/CredentialController.php b/app/Controllers/CredentialController.php
new file mode 100644
index 0000000..605bf85
--- /dev/null
+++ b/app/Controllers/CredentialController.php
@@ -0,0 +1,97 @@
+deviceService->getDevice($deviceId) === null) {
+ $response->getBody()->write('Device not found');
+ return $response->withStatus(404);
+ }
+
+ $data = $request->getParsedBody();
+ $authMethod = ($data['auth_method'] ?? 'password') === 'private_key' ? 'private_key' : 'password';
+ $credential = new Credential();
+ $credential->deviceId = $deviceId;
+ $credential->type = 'ssh';
+ $credential->name = trim($data['name'] ?? '') ?: 'SSH';
+ $credential->username = trim($data['username'] ?? '');
+ $credential->port = max(1, min(65535, (int)($data['port'] ?? 22)));
+ $credential->authMethod = $authMethod;
+
+ if ($authMethod === 'private_key') {
+ $privateKey = trim($data['private_key'] ?? '');
+ if ($privateKey !== '') {
+ $credential->encryptedPrivateKey = $this->vault->encrypt($privateKey);
+ }
+ } else {
+ $secret = (string)($data['secret'] ?? '');
+ if ($secret !== '') {
+ $credential->encryptedSecret = $this->vault->encrypt($secret);
+ }
+ }
+
+ if ($credential->username === '') {
+ return $response
+ ->withHeader('Location', '/devices/' . $deviceId)
+ ->withStatus(302);
+ }
+
+ $this->credentialRepository->save($credential);
+
+ return $response
+ ->withHeader('Location', '/devices/' . $deviceId)
+ ->withStatus(302);
+ }
+
+ public function test(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+ {
+ $deviceId = (int)$args['deviceId'];
+ $credential = $this->credentialRepository->findById((int)$args['id']);
+ $device = $this->deviceService->getDevice($deviceId);
+
+ if ($credential === null || $device === null || $credential->deviceId !== $deviceId) {
+ $response->getBody()->write('Credential not found');
+ return $response->withStatus(404);
+ }
+
+ $host = $device->primaryIp ?: ($device->hostname ?? '');
+ $result = $this->sshCredentialTester->test($credential, $host);
+ $this->credentialRepository->updateTestResult((int)$credential->id, $result['status']);
+
+ return $response
+ ->withHeader('Location', '/devices/' . $deviceId)
+ ->withStatus(302);
+ }
+
+ public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+ {
+ $deviceId = (int)$args['deviceId'];
+ $this->credentialRepository->deleteForDevice((int)$args['id'], $deviceId);
+
+ return $response
+ ->withHeader('Location', '/devices/' . $deviceId)
+ ->withStatus(302);
+ }
+}
diff --git a/app/Controllers/DeviceController.php b/app/Controllers/DeviceController.php
index 4284f97..b5d2b65 100644
--- a/app/Controllers/DeviceController.php
+++ b/app/Controllers/DeviceController.php
@@ -12,13 +12,16 @@ class DeviceController
{
private \Domovoy\Services\Inventory\DeviceService $deviceService;
private \Domovoy\Repositories\DiscoveredHostRepository $discoveredHostRepository;
+ private \Domovoy\Repositories\CredentialRepository $credentialRepository;
public function __construct(
\Domovoy\Services\Inventory\DeviceService $deviceService,
- \Domovoy\Repositories\DiscoveredHostRepository $discoveredHostRepository
+ \Domovoy\Repositories\DiscoveredHostRepository $discoveredHostRepository,
+ \Domovoy\Repositories\CredentialRepository $credentialRepository
) {
$this->deviceService = $deviceService;
$this->discoveredHostRepository = $discoveredHostRepository;
+ $this->credentialRepository = $credentialRepository;
}
public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
@@ -82,6 +85,7 @@ class DeviceController
$username = $_SESSION['username'] ?? 'User';
$types = Device::$types;
$importances = Device::$importances;
+ $credentials = $this->credentialRepository->findByDevice($id);
require dirname(__DIR__, 2) . '/templates/devices/show.php';
$body = ob_get_clean();
$response->getBody()->write($body);
diff --git a/app/Models/Credential.php b/app/Models/Credential.php
new file mode 100644
index 0000000..0a7d93a
--- /dev/null
+++ b/app/Models/Credential.php
@@ -0,0 +1,43 @@
+id = (int)$data['id'];
+ $obj->deviceId = (int)$data['device_id'];
+ $obj->type = $data['type'];
+ $obj->name = $data['name'];
+ $obj->username = $data['username'];
+ $obj->port = (int)$data['port'];
+ $obj->authMethod = $data['auth_method'];
+ $obj->encryptedSecret = $data['encrypted_secret'] ?? null;
+ $obj->encryptedPrivateKey = $data['encrypted_private_key'] ?? null;
+ $obj->publicKeyFingerprint = $data['public_key_fingerprint'] ?? null;
+ $obj->lastTestStatus = $data['last_test_status'] ?? null;
+ $obj->lastTestAt = $data['last_test_at'] !== null ? new \DateTimeImmutable($data['last_test_at']) : null;
+ $obj->createdAt = new \DateTimeImmutable($data['created_at']);
+ $obj->updatedAt = new \DateTimeImmutable($data['updated_at']);
+ return $obj;
+ }
+}
diff --git a/app/Repositories/CredentialRepository.php b/app/Repositories/CredentialRepository.php
new file mode 100644
index 0000000..031856d
--- /dev/null
+++ b/app/Repositories/CredentialRepository.php
@@ -0,0 +1,89 @@
+pdo->prepare('SELECT * FROM credentials WHERE device_id = :device_id ORDER BY name ASC');
+ $stmt->execute(['device_id' => $deviceId]);
+ $results = [];
+ while ($row = $stmt->fetch()) {
+ $results[] = Credential::fromArray($row);
+ }
+ return $results;
+ }
+
+ public function findById(int $id): ?Credential
+ {
+ $stmt = $this->pdo->prepare('SELECT * FROM credentials WHERE id = :id');
+ $stmt->execute(['id' => $id]);
+ $row = $stmt->fetch();
+ return $row ? Credential::fromArray($row) : null;
+ }
+
+ public function save(Credential $credential): void
+ {
+ $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s');
+ $stmt = $this->pdo->prepare(
+ 'INSERT INTO credentials
+ (device_id, type, name, username, port, auth_method, encrypted_secret,
+ encrypted_private_key, public_key_fingerprint, last_test_status,
+ last_test_at, created_at, updated_at)
+ VALUES
+ (:device_id, :type, :name, :username, :port, :auth_method, :encrypted_secret,
+ :encrypted_private_key, :public_key_fingerprint, :last_test_status,
+ :last_test_at, :created_at, :updated_at)'
+ );
+ $stmt->execute([
+ 'device_id' => $credential->deviceId,
+ 'type' => $credential->type,
+ 'name' => $credential->name,
+ 'username' => $credential->username,
+ 'port' => $credential->port,
+ 'auth_method' => $credential->authMethod,
+ 'encrypted_secret' => $credential->encryptedSecret,
+ 'encrypted_private_key' => $credential->encryptedPrivateKey,
+ 'public_key_fingerprint' => $credential->publicKeyFingerprint,
+ 'last_test_status' => $credential->lastTestStatus,
+ 'last_test_at' => $credential->lastTestAt?->format('Y-m-d H:i:s'),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ $credential->id = (int)$this->pdo->lastInsertId();
+ }
+
+ public function updateTestResult(int $id, string $status): void
+ {
+ $stmt = $this->pdo->prepare(
+ 'UPDATE credentials SET last_test_status = :last_test_status,
+ last_test_at = :last_test_at, updated_at = :updated_at WHERE id = :id'
+ );
+ $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s');
+ $stmt->execute([
+ 'id' => $id,
+ 'last_test_status' => $status,
+ 'last_test_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ public function deleteForDevice(int $id, int $deviceId): void
+ {
+ $stmt = $this->pdo->prepare('DELETE FROM credentials WHERE id = :id AND device_id = :device_id');
+ $stmt->execute([
+ 'id' => $id,
+ 'device_id' => $deviceId,
+ ]);
+ }
+}
diff --git a/app/Services/Security/CredentialVault.php b/app/Services/Security/CredentialVault.php
new file mode 100644
index 0000000..42fca9b
--- /dev/null
+++ b/app/Services/Security/CredentialVault.php
@@ -0,0 +1,31 @@
+password = $keyMaterial;
+ }
+
+ public function encrypt(string $plainText): string
+ {
+ return Crypto::encryptWithPassword($plainText, $this->password);
+ }
+
+ public function decrypt(string $cipherText): string
+ {
+ return Crypto::decryptWithPassword($cipherText, $this->password);
+ }
+}
diff --git a/app/Services/Ssh/SshCredentialTester.php b/app/Services/Ssh/SshCredentialTester.php
new file mode 100644
index 0000000..9bd7627
--- /dev/null
+++ b/app/Services/Ssh/SshCredentialTester.php
@@ -0,0 +1,71 @@
+ $settings
+ */
+ public function __construct(
+ private CredentialVault $vault,
+ ?callable $clientFactory = null,
+ private array $settings = []
+ ) {
+ $this->clientFactory = $clientFactory ?? static fn (string $host, int $port, int $timeout): SSH2 => new SSH2($host, $port, $timeout);
+ }
+
+ /** @return array{status: string, message: string} */
+ public function test(Credential $credential, string $host): array
+ {
+ if ($host === '') {
+ return ['status' => 'failed', 'message' => 'У устройства не указан IP или hostname'];
+ }
+
+ try {
+ $client = ($this->clientFactory)(
+ $host,
+ $credential->port,
+ (int)($this->settings['connect_timeout'] ?? 5)
+ );
+
+ $auth = $this->buildAuth($credential);
+ $ok = $client->login($credential->username, $auth);
+
+ if ($ok) {
+ return ['status' => 'ok', 'message' => 'Подключение успешно'];
+ }
+
+ return ['status' => 'failed', 'message' => 'SSH-аутентификация не прошла'];
+ } catch (\Throwable $e) {
+ return ['status' => 'failed', 'message' => 'Ошибка SSH: ' . $e->getMessage()];
+ }
+ }
+
+ private function buildAuth(Credential $credential): mixed
+ {
+ if ($credential->authMethod === 'private_key') {
+ if ($credential->encryptedPrivateKey === null) {
+ throw new \RuntimeException('Private key is empty');
+ }
+ return PublicKeyLoader::loadPrivateKey($this->vault->decrypt($credential->encryptedPrivateKey));
+ }
+
+ if ($credential->encryptedSecret === null) {
+ throw new \RuntimeException('Password is empty');
+ }
+
+ return $this->vault->decrypt($credential->encryptedSecret);
+ }
+}
diff --git a/db/migrations/20250526000007_create_credentials.php b/db/migrations/20250526000007_create_credentials.php
new file mode 100644
index 0000000..1c5a589
--- /dev/null
+++ b/db/migrations/20250526000007_create_credentials.php
@@ -0,0 +1,29 @@
+table('credentials');
+ $table
+ ->addColumn('device_id', 'integer', ['null' => false])
+ ->addColumn('type', 'string', ['limit' => 30, 'null' => false, 'default' => 'ssh'])
+ ->addColumn('name', 'string', ['limit' => 255, 'null' => false])
+ ->addColumn('username', 'string', ['limit' => 255, 'null' => false])
+ ->addColumn('port', 'integer', ['null' => false, 'default' => 22])
+ ->addColumn('auth_method', 'string', ['limit' => 30, 'null' => false, 'default' => 'password'])
+ ->addColumn('encrypted_secret', 'text', ['null' => true])
+ ->addColumn('encrypted_private_key', 'text', ['null' => true])
+ ->addColumn('public_key_fingerprint', 'string', ['limit' => 255, 'null' => true])
+ ->addColumn('last_test_status', 'string', ['limit' => 30, 'null' => true])
+ ->addColumn('last_test_at', 'datetime', ['null' => true])
+ ->addColumn('created_at', 'datetime', ['null' => false, 'default' => 'CURRENT_TIMESTAMP'])
+ ->addColumn('updated_at', 'datetime', ['null' => false, 'default' => 'CURRENT_TIMESTAMP'])
+ ->addIndex(['device_id'])
+ ->create();
+ }
+}
diff --git a/public/index.php b/public/index.php
index 70b433a..6aefeae 100644
--- a/public/index.php
+++ b/public/index.php
@@ -71,6 +71,9 @@ $containerBuilder->addDefinitions([
\Domovoy\Repositories\DiscoveredHostRepository::class => function ($c) {
return new \Domovoy\Repositories\DiscoveredHostRepository($c->get(PDO::class));
},
+ \Domovoy\Repositories\CredentialRepository::class => function ($c) {
+ return new \Domovoy\Repositories\CredentialRepository($c->get(PDO::class));
+ },
\Domovoy\Repositories\AuditLogRepository::class => function ($c) {
return new \Domovoy\Repositories\AuditLogRepository($c->get(PDO::class));
},
@@ -108,6 +111,16 @@ $containerBuilder->addDefinitions([
\Domovoy\Services\AuthService::class => function ($c) {
return new \Domovoy\Services\AuthService($c->get(\Domovoy\Repositories\UserRepository::class));
},
+ \Domovoy\Services\Security\CredentialVault::class => function ($c) {
+ return new \Domovoy\Services\Security\CredentialVault($c->get('settings')['app']['encryption_key']);
+ },
+ \Domovoy\Services\Ssh\SshCredentialTester::class => function ($c) {
+ return new \Domovoy\Services\Ssh\SshCredentialTester(
+ $c->get(\Domovoy\Services\Security\CredentialVault::class),
+ null,
+ $c->get('settings')['ssh']
+ );
+ },
// Controllers
\Domovoy\Controllers\AuthController::class => function ($c) {
return new \Domovoy\Controllers\AuthController($c->get(\Domovoy\Services\AuthService::class));
@@ -149,7 +162,16 @@ $containerBuilder->addDefinitions([
\Domovoy\Controllers\DeviceController::class => function ($c) {
return new \Domovoy\Controllers\DeviceController(
$c->get(\Domovoy\Services\Inventory\DeviceService::class),
- $c->get(\Domovoy\Repositories\DiscoveredHostRepository::class)
+ $c->get(\Domovoy\Repositories\DiscoveredHostRepository::class),
+ $c->get(\Domovoy\Repositories\CredentialRepository::class)
+ );
+ },
+ \Domovoy\Controllers\CredentialController::class => function ($c) {
+ return new \Domovoy\Controllers\CredentialController(
+ $c->get(\Domovoy\Services\Inventory\DeviceService::class),
+ $c->get(\Domovoy\Repositories\CredentialRepository::class),
+ $c->get(\Domovoy\Services\Security\CredentialVault::class),
+ $c->get(\Domovoy\Services\Ssh\SshCredentialTester::class)
);
},
\Domovoy\Controllers\DiscoveriesController::class => function ($c) {
@@ -211,6 +233,9 @@ $app->group('', function (\Slim\Routing\RouteCollectorProxy $group) {
$group->post('/devices/{id}/update', [\Domovoy\Controllers\DeviceController::class, 'update'])->setName('devices.update');
$group->post('/devices/{id}/delete', [\Domovoy\Controllers\DeviceController::class, 'delete'])->setName('devices.delete');
$group->post('/devices/from-host', [\Domovoy\Controllers\DeviceController::class, 'createFromHost'])->setName('devices.from_host');
+ $group->post('/devices/{id}/credentials', [\Domovoy\Controllers\CredentialController::class, 'create'])->setName('credentials.create');
+ $group->post('/devices/{deviceId}/credentials/{id}/test', [\Domovoy\Controllers\CredentialController::class, 'test'])->setName('credentials.test');
+ $group->post('/devices/{deviceId}/credentials/{id}/delete', [\Domovoy\Controllers\CredentialController::class, 'delete'])->setName('credentials.delete');
});
$app->run();
diff --git a/templates/devices/show.php b/templates/devices/show.php
index b9d4b0f..925e72c 100644
--- a/templates/devices/show.php
+++ b/templates/devices/show.php
@@ -9,7 +9,7 @@
-
+
| Тип | = htmlspecialchars($device->type) ?> |
@@ -31,5 +31,91 @@
+
+
+
SSH-доступы
+
+
+
+
+
+
+ | Название |
+ Пользователь |
+ Порт |
+ Метод |
+ Последний тест |
+ Действия |
+
+
+
+
+ | Доступы пока не добавлены |
+
+
+
+ | = htmlspecialchars($credential->name) ?> |
+ = htmlspecialchars($credential->username) ?> |
+ = (int)$credential->port ?> |
+ = $credential->authMethod === 'private_key' ? 'ключ' : 'пароль' ?> |
+
+ lastTestStatus === null): ?>
+ не проверялся
+
+
+ = htmlspecialchars($credential->lastTestStatus) ?>
+
+ lastTestAt !== null): ?>
+ = $credential->lastTestAt->format('Y-m-d H:i') ?>
+
+
+ |
+
+
+
+ |
+
+
+
+
+
+
+
+
diff --git a/tests/Repositories/CredentialRepositoryTest.php b/tests/Repositories/CredentialRepositoryTest.php
new file mode 100644
index 0000000..4fe1d07
--- /dev/null
+++ b/tests/Repositories/CredentialRepositoryTest.php
@@ -0,0 +1,127 @@
+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
> */
+ 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|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;
+ }
+}
diff --git a/tests/Repositories/HostScanRepositoryTest.php b/tests/Repositories/HostScanRepositoryTest.php
new file mode 100644
index 0000000..959e4cd
--- /dev/null
+++ b/tests/Repositories/HostScanRepositoryTest.php
@@ -0,0 +1,94 @@
+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> */
+ 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|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;
+ }
+}
diff --git a/tests/Security/CredentialVaultTest.php b/tests/Security/CredentialVaultTest.php
new file mode 100644
index 0000000..c7705d6
--- /dev/null
+++ b/tests/Security/CredentialVaultTest.php
@@ -0,0 +1,21 @@
+encrypt('secret-password');
+
+ self::assertNotSame('secret-password', $encrypted);
+ self::assertSame('secret-password', $vault->decrypt($encrypted));
+ }
+}
diff --git a/tests/Services/LinuxHostScannerTest.php b/tests/Services/LinuxHostScannerTest.php
new file mode 100644
index 0000000..50ad00b
--- /dev/null
+++ b/tests/Services/LinuxHostScannerTest.php
@@ -0,0 +1,58 @@
+ ['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 $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'];
+ }
+}
diff --git a/tests/Services/SshCredentialTesterTest.php b/tests/Services/SshCredentialTesterTest.php
new file mode 100644
index 0000000..ac68796
--- /dev/null
+++ b/tests/Services/SshCredentialTesterTest.php
@@ -0,0 +1,49 @@
+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;
+ }
+}