Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
86
tests/test_cli.py
Normal file
86
tests/test_cli.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from tai.cli import app
|
||||
from tai.ssh_client import SSHCommandResult
|
||||
|
||||
|
||||
def test_run_command_prints_scaffold_summary() -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"apache failed",
|
||||
"--host",
|
||||
"web01",
|
||||
"--port",
|
||||
"5566",
|
||||
"--no-probe",
|
||||
"--path",
|
||||
"/etc/apache2",
|
||||
"--jump-host",
|
||||
"bastion01",
|
||||
"--ignore-ssh-config",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "tai scaffold ready" in result.stdout
|
||||
assert "host=web01" in result.stdout
|
||||
assert "port=5566" in result.stdout
|
||||
|
||||
|
||||
def test_probe_success_prints_remote_output_by_default(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
async def fake_probe(self) -> SSHCommandResult: # type: ignore[no-untyped-def]
|
||||
return SSHCommandResult(
|
||||
command="uname -a",
|
||||
exit_code=0,
|
||||
stdout="Linux ssh 6.12.0",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr("tai.cli.SSHClient.probe", fake_probe)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"apache failed",
|
||||
"--host",
|
||||
"ssh.archflux.net",
|
||||
"--port",
|
||||
"5566",
|
||||
"--probe",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Probe succeeded" in result.stdout
|
||||
assert "Linux ssh 6.12.0" in result.stdout
|
||||
|
||||
|
||||
def test_probe_failure_returns_non_zero(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
async def fake_probe(self) -> SSHCommandResult: # type: ignore[no-untyped-def]
|
||||
return SSHCommandResult(
|
||||
command="uname -a",
|
||||
exit_code=255,
|
||||
stdout="",
|
||||
stderr="Permission denied (publickey,password).",
|
||||
)
|
||||
|
||||
monkeypatch.setattr("tai.cli.SSHClient.probe", fake_probe)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"apache failed",
|
||||
"--host",
|
||||
"ssh.archflux.net",
|
||||
"--port",
|
||||
"5566",
|
||||
"--probe",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Probe failed" in result.stdout
|
||||
65
tests/test_input_parser.py
Normal file
65
tests/test_input_parser.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tai.input_parser import InputValidationError, build_request
|
||||
|
||||
|
||||
def test_build_request_normalizes_values() -> None:
|
||||
req = build_request(
|
||||
issue=" apache fails to start ",
|
||||
host=" web01 ",
|
||||
port=5566,
|
||||
target_paths=["/etc/apache2", "~/logs"],
|
||||
identity_file="~/.ssh/id_ed25519",
|
||||
jump_host=" bastion01 ",
|
||||
ignore_ssh_config=True,
|
||||
)
|
||||
|
||||
assert req.issue == "apache fails to start"
|
||||
assert req.host == "web01"
|
||||
assert req.port == 5566
|
||||
assert req.target_paths[0] == Path("/etc/apache2")
|
||||
assert req.target_paths[1] == Path("~/logs").expanduser()
|
||||
assert req.identity_file == Path("~/.ssh/id_ed25519").expanduser()
|
||||
assert req.jump_host == "bastion01"
|
||||
assert req.ignore_ssh_config is True
|
||||
|
||||
|
||||
def test_build_request_rejects_empty_issue() -> None:
|
||||
with pytest.raises(InputValidationError):
|
||||
build_request(
|
||||
issue=" ",
|
||||
host="web01",
|
||||
port=22,
|
||||
target_paths=[],
|
||||
identity_file=None,
|
||||
jump_host=None,
|
||||
ignore_ssh_config=False,
|
||||
)
|
||||
|
||||
|
||||
def test_build_request_rejects_empty_host() -> None:
|
||||
with pytest.raises(InputValidationError):
|
||||
build_request(
|
||||
issue="apache down",
|
||||
host=" ",
|
||||
port=22,
|
||||
target_paths=[],
|
||||
identity_file=None,
|
||||
jump_host=None,
|
||||
ignore_ssh_config=False,
|
||||
)
|
||||
|
||||
|
||||
def test_build_request_rejects_invalid_port() -> None:
|
||||
with pytest.raises(InputValidationError):
|
||||
build_request(
|
||||
issue="apache down",
|
||||
host="web01",
|
||||
port=70000,
|
||||
target_paths=[],
|
||||
identity_file=None,
|
||||
jump_host=None,
|
||||
ignore_ssh_config=False,
|
||||
)
|
||||
93
tests/test_ssh_client.py
Normal file
93
tests/test_ssh_client.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tai.ssh_client import SSHClient, SSHCommandRejectedError, SSHConnectionConfig
|
||||
|
||||
|
||||
def _client(**kwargs: object) -> SSHClient:
|
||||
host = str(kwargs.get("host", "root@ssh.archflux.net"))
|
||||
port_value = kwargs.get("port", 22)
|
||||
if not isinstance(port_value, int):
|
||||
raise TypeError("port must be an int")
|
||||
port = port_value
|
||||
identity_file = kwargs.get("identity_file")
|
||||
jump_host = kwargs.get("jump_host")
|
||||
ignore_ssh_config = bool(kwargs.get("ignore_ssh_config", False))
|
||||
|
||||
if identity_file is not None and not isinstance(identity_file, Path):
|
||||
raise TypeError("identity_file must be a Path or None")
|
||||
|
||||
if jump_host is not None and not isinstance(jump_host, str):
|
||||
raise TypeError("jump_host must be a string or None")
|
||||
|
||||
return SSHClient(
|
||||
SSHConnectionConfig(
|
||||
host=host,
|
||||
port=port,
|
||||
identity_file=identity_file,
|
||||
jump_host=jump_host,
|
||||
ignore_ssh_config=ignore_ssh_config,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_summary_includes_expected_defaults() -> None:
|
||||
client = _client()
|
||||
text = client.summary()
|
||||
|
||||
assert "host=root@ssh.archflux.net" in text
|
||||
assert "port=22" in text
|
||||
assert "key=auto" in text
|
||||
assert "jump=none" in text
|
||||
assert "mode=use ssh config" in text
|
||||
|
||||
|
||||
def test_build_ssh_argv_respects_flags() -> None:
|
||||
client = _client(
|
||||
identity_file=Path("/root/.ssh/id_ed25519"),
|
||||
jump_host="bastion.archflux.net",
|
||||
ignore_ssh_config=True,
|
||||
)
|
||||
|
||||
argv = client.build_ssh_argv("uname -a")
|
||||
|
||||
assert argv[0] == "ssh"
|
||||
assert "-p" in argv
|
||||
assert "22" in argv
|
||||
assert "-F" in argv
|
||||
assert "/dev/null" in argv
|
||||
assert "-i" in argv
|
||||
assert "/root/.ssh/id_ed25519" in argv
|
||||
assert "-J" in argv
|
||||
assert "bastion.archflux.net" in argv
|
||||
assert argv[-2] == "root@ssh.archflux.net"
|
||||
assert argv[-1] == "uname -a"
|
||||
|
||||
|
||||
def test_rejects_destructive_or_shell_operator_commands() -> None:
|
||||
client = _client()
|
||||
|
||||
for command in ["rm -rf /tmp/x", "cat /etc/hosts | grep localhost", "uname -a; id"]:
|
||||
with pytest.raises(SSHCommandRejectedError):
|
||||
client.validate_read_only_command(command)
|
||||
|
||||
|
||||
def test_allows_expected_read_only_commands() -> None:
|
||||
client = _client()
|
||||
|
||||
for command in [
|
||||
"uname -a",
|
||||
"journalctl -n 100",
|
||||
"systemctl status apache2",
|
||||
"cat /etc/hosts",
|
||||
"ss -lntp",
|
||||
]:
|
||||
client.validate_read_only_command(command)
|
||||
|
||||
|
||||
def test_rejects_non_read_only_systemctl_subcommand() -> None:
|
||||
client = _client()
|
||||
|
||||
with pytest.raises(SSHCommandRejectedError):
|
||||
client.validate_read_only_command("systemctl restart apache2")
|
||||
Reference in New Issue
Block a user