Files
trailofbits-dropkit/tests/test_ssh_config.py
T
2026-01-29 21:53:58 -05:00

986 lines
30 KiB
Python

"""Comprehensive tests for SSH config management."""
from pathlib import Path
import pytest
from dropkit.ssh_config import (
add_ssh_host,
get_ssh_host_ip,
remove_known_hosts_entry,
remove_ssh_host,
)
@pytest.fixture
def temp_ssh_dir(tmp_path):
"""Create a temporary SSH directory for testing."""
ssh_dir = tmp_path / ".ssh"
ssh_dir.mkdir(mode=0o700)
yield ssh_dir
# Cleanup is automatic with tmp_path
@pytest.fixture
def temp_config(temp_ssh_dir):
"""Create a temporary SSH config file path."""
config_path = temp_ssh_dir / "config"
return str(config_path)
class TestAddSSHHost:
"""Tests for add_ssh_host function."""
def test_empty_file(self, temp_config):
"""Test adding host to empty SSH config file."""
# Create empty file
Path(temp_config).touch(mode=0o600)
add_ssh_host(temp_config, "myhost", "192.168.1.1", "ubuntu")
content = Path(temp_config).read_text()
assert "Host myhost" in content
assert "HostName 192.168.1.1" in content
assert "User ubuntu" in content
def test_non_existent_file(self, temp_config):
"""Test creating new file and directory if they don't exist."""
# Ensure file doesn't exist
assert not Path(temp_config).exists()
add_ssh_host(temp_config, "myhost", "192.168.1.1", "ubuntu", "~/.ssh/id_ed25519")
assert Path(temp_config).exists()
content = Path(temp_config).read_text()
assert "Host myhost" in content
assert "IdentityFile ~/.ssh/id_ed25519" in content
def test_simple_addition(self, temp_config):
"""Test adding host to config with existing unrelated hosts."""
# Create config with existing host
existing = """Host other-server
HostName 10.0.0.1
User admin
Host another-host
HostName 10.0.0.2
User root
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "myhost", "192.168.1.1", "ubuntu")
content = Path(temp_config).read_text()
assert "Host other-server" in content
assert "Host another-host" in content
assert "Host myhost" in content
# Count "Host " with space to avoid matching "HostName"
assert content.count("Host ") == 3
def test_update_existing_host(self, temp_config):
"""Test replacing existing host entry without duplication."""
# Create config with existing host
existing = """Host myhost
HostName 10.0.0.1
User old_user
Host other-host
HostName 10.0.0.2
User admin
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "myhost", "192.168.1.1", "new_user")
content = Path(temp_config).read_text()
assert content.count("Host myhost") == 1 # Should not duplicate
assert "HostName 192.168.1.1" in content
assert "User new_user" in content
assert "old_user" not in content
assert "10.0.0.1" not in content
assert "Host other-host" in content # Other hosts preserved
def test_host_with_similar_names(self, temp_config):
"""Test that similar host names don't interfere."""
existing = """Host myhost
HostName 10.0.0.1
User user1
Host myhost-dev
HostName 10.0.0.2
User user2
Host myhost-prod
HostName 10.0.0.3
User user3
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "myhost", "192.168.1.1", "newuser")
content = Path(temp_config).read_text()
assert "Host myhost-dev" in content
assert "10.0.0.2" in content
assert "Host myhost-prod" in content
assert "10.0.0.3" in content
# myhost should be updated
lines = content.split("\n")
myhost_line = [i for i, line in enumerate(lines) if line.strip() == "Host myhost"][-1]
# Check that the next few lines after myhost contain the new values
section = "\n".join(lines[myhost_line : myhost_line + 5])
assert "192.168.1.1" in section
assert "newuser" in section
def test_host_as_substring(self, temp_config):
"""Test that substring hosts are handled correctly."""
existing = """Host prod-server
HostName 10.0.0.1
User admin
Host prod
HostName 10.0.0.2
User root
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "prod", "192.168.1.1", "ubuntu")
content = Path(temp_config).read_text()
assert "Host prod-server" in content
assert "10.0.0.1" in content
# prod should be updated
assert content.count("Host prod\n") >= 1
def test_multiple_blank_lines(self, temp_config):
"""Test handling config with various blank lines."""
existing = """Host server1
HostName 10.0.0.1
User admin
Host server2
HostName 10.0.0.2
User root
Host server3
HostName 10.0.0.3
User ubuntu
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "newhost", "192.168.1.1", "user")
content = Path(temp_config).read_text()
assert "Host server1" in content
assert "Host server2" in content
assert "Host server3" in content
assert "Host newhost" in content
def test_comments_in_config(self, temp_config):
"""Test preserving comments in config."""
existing = """# This is a comment
Host server1
HostName 10.0.0.1
User admin
# Another comment
# Global settings
Host server2
HostName 10.0.0.2
User root
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "newhost", "192.168.1.1", "user")
content = Path(temp_config).read_text()
assert "# This is a comment" in content
assert "# Another comment" in content
assert "# Global settings" in content
def test_mixed_indentation(self, temp_config):
"""Test handling tabs and spaces."""
existing = """Host server1
HostName 10.0.0.1
User admin
Host server2
HostName 10.0.0.2
User root
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "newhost", "192.168.1.1", "user")
content = Path(temp_config).read_text()
# Original hosts should be preserved
assert "Host server1" in content
assert "Host server2" in content
# New host should be added
assert "Host newhost" in content
def test_host_at_beginning(self, temp_config):
"""Test updating a host that's the first entry."""
existing = """Host first-host
HostName 10.0.0.1
User admin
Host second-host
HostName 10.0.0.2
User root
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "first-host", "192.168.1.1", "newuser")
content = Path(temp_config).read_text()
assert content.count("Host first-host") == 1
assert "192.168.1.1" in content
assert "10.0.0.1" not in content
assert "Host second-host" in content
def test_host_at_end(self, temp_config):
"""Test updating a host that's the last entry."""
existing = """Host first-host
HostName 10.0.0.1
User admin
Host last-host
HostName 10.0.0.2
User root
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "last-host", "192.168.1.1", "newuser")
content = Path(temp_config).read_text()
assert "Host first-host" in content
assert content.count("Host last-host") == 1
assert "192.168.1.1" in content
assert "10.0.0.2" not in content
def test_host_in_middle(self, temp_config):
"""Test updating a host between others."""
existing = """Host first-host
HostName 10.0.0.1
User admin
Host middle-host
HostName 10.0.0.2
User root
Host last-host
HostName 10.0.0.3
User ubuntu
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "middle-host", "192.168.1.1", "newuser")
content = Path(temp_config).read_text()
assert "Host first-host" in content
assert "Host last-host" in content
assert content.count("Host middle-host") == 1
assert "192.168.1.1" in content
assert "10.0.0.2" not in content
def test_complex_host_entries(self, temp_config):
"""Test host with many configuration options."""
existing = """Host complex-host
HostName 10.0.0.1
User admin
Port 2222
ForwardAgent yes
ProxyJump bastion
IdentityFile ~/.ssh/custom_key
StrictHostKeyChecking no
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "complex-host", "192.168.1.1", "newuser", "~/.ssh/new_key")
content = Path(temp_config).read_text()
assert content.count("Host complex-host") == 1
assert "192.168.1.1" in content
assert "newuser" in content
# Old complex options should be replaced
assert "Port 2222" not in content
def test_host_with_extra_spaces(self, temp_config):
"""Test host declaration with multiple spaces."""
existing = """Host server-with-spaces
HostName 10.0.0.1
User admin
"""
Path(temp_config).write_text(existing)
# This won't match because our check is exact
add_ssh_host(temp_config, "server-with-spaces", "192.168.1.1", "newuser")
content = Path(temp_config).read_text()
# Should add a new entry since "Host server-with-spaces" != "Host server-with-spaces"
assert "Host server-with-spaces" in content
def test_host_patterns(self, temp_config):
"""Test existing wildcard hosts."""
existing = """Host *.example.com
User admin
IdentityFile ~/.ssh/example_key
Host myserver
HostName 10.0.0.1
User root
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "newhost", "192.168.1.1", "ubuntu")
content = Path(temp_config).read_text()
assert "Host *.example.com" in content
assert "Host myserver" in content
assert "Host newhost" in content
def test_no_trailing_newline(self, temp_config):
"""Test config file without final newline."""
existing = """Host myhost
HostName 10.0.0.1
User admin""" # No trailing newline
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "newhost", "192.168.1.1", "ubuntu")
content = Path(temp_config).read_text()
assert "Host myhost" in content
assert "Host newhost" in content
# Verify Host newhost is on its own line (not concatenated to previous line)
lines = content.split("\n")
host_lines = [line for line in lines if "Host newhost" in line]
assert len(host_lines) == 1
assert host_lines[0].strip() == "Host newhost"
def test_permissions_preserved(self, temp_config):
"""Test that file permissions are set correctly."""
Path(temp_config).touch(mode=0o644)
add_ssh_host(temp_config, "myhost", "192.168.1.1", "ubuntu")
# File should now have 0600 permissions
mode = Path(temp_config).stat().st_mode & 0o777
assert mode == 0o600
def test_backup_created(self, temp_config):
"""Test that backup file is created."""
existing = """Host oldhost
HostName 10.0.0.1
User admin
"""
Path(temp_config).write_text(existing)
Path(temp_config).chmod(0o600)
add_ssh_host(temp_config, "newhost", "192.168.1.1", "ubuntu")
backup_path = Path(temp_config).parent / "config.bak"
assert backup_path.exists()
backup_content = backup_path.read_text()
assert "Host oldhost" in backup_content
assert "Host newhost" not in backup_content
# Check backup has same permissions
backup_mode = backup_path.stat().st_mode & 0o777
assert backup_mode == 0o600
class TestRemoveSSHHost:
"""Tests for remove_ssh_host function."""
def test_non_existent_file(self, temp_config):
"""Test removing from non-existent config."""
assert not Path(temp_config).exists()
result = remove_ssh_host(temp_config, "myhost")
assert result is False
def test_host_doesnt_exist(self, temp_config):
"""Test removing host that's not in config."""
existing = """Host server1
HostName 10.0.0.1
User admin
"""
Path(temp_config).write_text(existing)
result = remove_ssh_host(temp_config, "nonexistent")
assert result is False
content = Path(temp_config).read_text()
assert "Host server1" in content # Should be unchanged
def test_remove_first_host(self, temp_config):
"""Test removing the first host entry."""
existing = """Host first-host
HostName 10.0.0.1
User admin
Host second-host
HostName 10.0.0.2
User root
Host third-host
HostName 10.0.0.3
User ubuntu
"""
Path(temp_config).write_text(existing)
result = remove_ssh_host(temp_config, "first-host")
assert result is True
content = Path(temp_config).read_text()
assert "Host first-host" not in content
assert "Host second-host" in content
assert "Host third-host" in content
def test_remove_last_host(self, temp_config):
"""Test removing the last host entry."""
existing = """Host first-host
HostName 10.0.0.1
User admin
Host second-host
HostName 10.0.0.2
User root
Host last-host
HostName 10.0.0.3
User ubuntu
"""
Path(temp_config).write_text(existing)
result = remove_ssh_host(temp_config, "last-host")
assert result is True
content = Path(temp_config).read_text()
assert "Host first-host" in content
assert "Host second-host" in content
assert "Host last-host" not in content
def test_remove_middle_host(self, temp_config):
"""Test removing a host between others."""
existing = """Host first-host
HostName 10.0.0.1
User admin
Host middle-host
HostName 10.0.0.2
User root
Host last-host
HostName 10.0.0.3
User ubuntu
"""
Path(temp_config).write_text(existing)
result = remove_ssh_host(temp_config, "middle-host")
assert result is True
content = Path(temp_config).read_text()
assert "Host first-host" in content
assert "Host middle-host" not in content
assert "Host last-host" in content
def test_remove_only_host(self, temp_config):
"""Test removing the only host in config."""
existing = """Host only-host
HostName 10.0.0.1
User admin
"""
Path(temp_config).write_text(existing)
result = remove_ssh_host(temp_config, "only-host")
assert result is True
content = Path(temp_config).read_text()
assert "Host only-host" not in content
# File should be essentially empty or just whitespace
assert content.strip() == "" or "Host" not in content
def test_remove_similar_host_names(self, temp_config):
"""Test removing specific host when similar names exist."""
existing = """Host myhost
HostName 10.0.0.1
User user1
Host myhost-dev
HostName 10.0.0.2
User user2
Host myhost-prod
HostName 10.0.0.3
User user3
"""
Path(temp_config).write_text(existing)
result = remove_ssh_host(temp_config, "myhost")
assert result is True
content = Path(temp_config).read_text()
assert "Host myhost\n" not in content
assert "10.0.0.1" not in content
assert "Host myhost-dev" in content
assert "10.0.0.2" in content
assert "Host myhost-prod" in content
assert "10.0.0.3" in content
def test_remove_host_with_many_options(self, temp_config):
"""Test removing host with many configuration lines."""
existing = """Host simple-host
HostName 10.0.0.1
User admin
Host complex-host
HostName 10.0.0.2
User root
Port 2222
ForwardAgent yes
ProxyJump bastion
IdentityFile ~/.ssh/custom_key
StrictHostKeyChecking no
LocalForward 8080 localhost:80
Host another-host
HostName 10.0.0.3
User ubuntu
"""
Path(temp_config).write_text(existing)
result = remove_ssh_host(temp_config, "complex-host")
assert result is True
content = Path(temp_config).read_text()
assert "Host simple-host" in content
assert "Host complex-host" not in content
assert "Port 2222" not in content
assert "ProxyJump bastion" not in content
assert "Host another-host" in content
def test_backup_created_on_remove(self, temp_config):
"""Test that backup is created when removing host."""
existing = """Host myhost
HostName 10.0.0.1
User admin
Host otherhost
HostName 10.0.0.2
User root
"""
Path(temp_config).write_text(existing)
Path(temp_config).chmod(0o600)
remove_ssh_host(temp_config, "myhost")
backup_path = Path(temp_config).parent / "config.bak"
assert backup_path.exists()
backup_content = backup_path.read_text()
assert "Host myhost" in backup_content
assert "Host otherhost" in backup_content
# Check backup has same permissions
backup_mode = backup_path.stat().st_mode & 0o777
assert backup_mode == 0o600
def test_remove_preserves_comments(self, temp_config):
"""Test that comments are preserved when removing host."""
existing = """# Global comment
Host keephost
HostName 10.0.0.1
User admin
# This host will be removed
Host removehost
HostName 10.0.0.2
User root
# Another comment
Host anotherhost
HostName 10.0.0.3
User ubuntu
"""
Path(temp_config).write_text(existing)
result = remove_ssh_host(temp_config, "removehost")
assert result is True
content = Path(temp_config).read_text()
assert "# Global comment" in content
assert "# Another comment" in content
assert "Host keephost" in content
assert "Host removehost" not in content
assert "Host anotherhost" in content
class TestGetSSHHostIP:
"""Tests for get_ssh_host_ip function."""
def test_valid_host(self, temp_config):
"""Test getting IP for existing host."""
existing = """Host myhost
HostName 192.168.1.100
User ubuntu
"""
Path(temp_config).write_text(existing)
result = get_ssh_host_ip(temp_config, "myhost")
assert result == "192.168.1.100"
def test_host_not_found(self, temp_config):
"""Test getting IP for non-existent host."""
existing = """Host otherhost
HostName 192.168.1.100
User ubuntu
"""
Path(temp_config).write_text(existing)
result = get_ssh_host_ip(temp_config, "myhost")
assert result is None
def test_missing_hostname_field(self, temp_config):
"""Test host entry without HostName field."""
existing = """Host myhost
User ubuntu
IdentityFile ~/.ssh/id_rsa
"""
Path(temp_config).write_text(existing)
result = get_ssh_host_ip(temp_config, "myhost")
assert result is None
def test_non_existent_file(self, temp_config):
"""Test with non-existent config file."""
result = get_ssh_host_ip(temp_config, "myhost")
assert result is None
def test_tailscale_ip(self, temp_config):
"""Test getting Tailscale IP address."""
existing = """Host dropkit.myhost
HostName 100.80.123.45
User ubuntu
ForwardAgent yes
"""
Path(temp_config).write_text(existing)
result = get_ssh_host_ip(temp_config, "dropkit.myhost")
assert result == "100.80.123.45"
def test_multiple_hosts(self, temp_config):
"""Test getting IP from config with multiple hosts."""
existing = """Host firsthost
HostName 10.0.0.1
User admin
Host targethost
HostName 192.168.1.50
User ubuntu
Host thirdhost
HostName 10.0.0.3
User root
"""
Path(temp_config).write_text(existing)
result = get_ssh_host_ip(temp_config, "targethost")
assert result == "192.168.1.50"
def test_host_with_extra_whitespace(self, temp_config):
"""Test HostName with extra whitespace."""
existing = """Host myhost
HostName 192.168.1.100
User ubuntu
"""
Path(temp_config).write_text(existing)
result = get_ssh_host_ip(temp_config, "myhost")
assert result == "192.168.1.100"
def test_multiple_hosts_on_same_line(self, temp_config):
"""Test host with multiple aliases on same line."""
existing = """Host myhost myalias anotherhost
HostName 192.168.1.100
User ubuntu
"""
Path(temp_config).write_text(existing)
# Should work for any of the aliases
assert get_ssh_host_ip(temp_config, "myhost") == "192.168.1.100"
assert get_ssh_host_ip(temp_config, "myalias") == "192.168.1.100"
assert get_ssh_host_ip(temp_config, "anotherhost") == "192.168.1.100"
def test_hostname_vs_host(self, temp_config):
"""Test that we distinguish between Host directive and HostName option."""
existing = """Host realhost
HostName 192.168.1.100
User ubuntu
Host anotherreal
HostName 10.0.0.1
User admin
"""
Path(temp_config).write_text(existing)
# Should not find HostName as a host alias
assert get_ssh_host_ip(temp_config, "192.168.1.100") is None
# Should find the actual hosts
assert get_ssh_host_ip(temp_config, "realhost") == "192.168.1.100"
class TestRemoveKnownHostsEntry:
"""Tests for remove_known_hosts_entry function."""
@pytest.fixture
def temp_known_hosts(self, temp_ssh_dir):
"""Create a temporary known_hosts file path."""
known_hosts_path = temp_ssh_dir / "known_hosts"
return str(known_hosts_path)
def test_non_existent_file(self, temp_known_hosts):
"""Test removing from non-existent known_hosts."""
assert not Path(temp_known_hosts).exists()
result = remove_known_hosts_entry(temp_known_hosts, ["myhost"])
assert result == 0
def test_no_matching_entries(self, temp_known_hosts):
"""Test removing hostname that's not in known_hosts."""
existing = """otherhost ssh-ed25519 AAAA...
192.168.1.1 ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(temp_known_hosts, ["myhost"])
assert result == 0
content = Path(temp_known_hosts).read_text()
assert "otherhost" in content
assert "192.168.1.1" in content
def test_remove_single_hostname(self, temp_known_hosts):
"""Test removing a single hostname entry."""
existing = """myhost ssh-ed25519 AAAA...
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(temp_known_hosts, ["myhost"])
assert result == 1
content = Path(temp_known_hosts).read_text()
assert "myhost" not in content
assert "otherhost" in content
def test_remove_ip_address(self, temp_known_hosts):
"""Test removing an IP address entry."""
existing = """192.168.1.100 ssh-ed25519 AAAA...
10.0.0.1 ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(temp_known_hosts, ["192.168.1.100"])
assert result == 1
content = Path(temp_known_hosts).read_text()
assert "192.168.1.100" not in content
assert "10.0.0.1" in content
def test_remove_multiple_entries(self, temp_known_hosts):
"""Test removing hostname and IP address together."""
existing = """tobcloud.myhost ssh-ed25519 AAAA...
100.80.123.45 ssh-ed25519 AAAA...
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(temp_known_hosts, ["tobcloud.myhost", "100.80.123.45"])
assert result == 2
content = Path(temp_known_hosts).read_text()
assert "tobcloud.myhost" not in content
assert "100.80.123.45" not in content
assert "otherhost" in content
def test_comma_separated_hostnames(self, temp_known_hosts):
"""Test removing entry with comma-separated hostnames."""
existing = """myhost,192.168.1.100 ssh-ed25519 AAAA...
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
# Should match by hostname
result = remove_known_hosts_entry(temp_known_hosts, ["myhost"])
assert result == 1
content = Path(temp_known_hosts).read_text()
assert "myhost" not in content
assert "192.168.1.100" not in content # Whole line removed
assert "otherhost" in content
def test_comma_separated_match_by_ip(self, temp_known_hosts):
"""Test removing entry by IP when comma-separated."""
existing = """myhost,192.168.1.100 ssh-ed25519 AAAA...
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
# Should match by IP
result = remove_known_hosts_entry(temp_known_hosts, ["192.168.1.100"])
assert result == 1
content = Path(temp_known_hosts).read_text()
assert "myhost" not in content
assert "192.168.1.100" not in content
assert "otherhost" in content
def test_bracketed_entry(self, temp_known_hosts):
"""Test removing bracketed entry like [hostname]:port."""
existing = """[myhost]:2222 ssh-ed25519 AAAA...
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(temp_known_hosts, ["myhost"])
assert result == 1
content = Path(temp_known_hosts).read_text()
assert "[myhost]:2222" not in content
assert "otherhost" in content
def test_bracketed_ip_entry(self, temp_known_hosts):
"""Test removing bracketed IP entry like [192.168.1.1]:2222."""
existing = """[192.168.1.100]:2222 ssh-ed25519 AAAA...
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(temp_known_hosts, ["192.168.1.100"])
assert result == 1
content = Path(temp_known_hosts).read_text()
assert "192.168.1.100" not in content
assert "otherhost" in content
def test_hashed_entries_preserved(self, temp_known_hosts):
"""Test that hashed entries (|1|...) are preserved."""
existing = """|1|abc123...= ssh-ed25519 AAAA...
myhost ssh-rsa AAAA...
|1|def456...= ssh-ed25519 AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(temp_known_hosts, ["myhost"])
assert result == 1
content = Path(temp_known_hosts).read_text()
assert "|1|abc123" in content
assert "|1|def456" in content
assert "myhost" not in content
def test_comments_preserved(self, temp_known_hosts):
"""Test that comments are preserved."""
existing = """# This is a comment
myhost ssh-ed25519 AAAA...
# Another comment
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(temp_known_hosts, ["myhost"])
assert result == 1
content = Path(temp_known_hosts).read_text()
assert "# This is a comment" in content
assert "# Another comment" in content
assert "myhost" not in content
assert "otherhost" in content
def test_empty_lines_preserved(self, temp_known_hosts):
"""Test that empty lines are preserved."""
existing = """myhost ssh-ed25519 AAAA...
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(temp_known_hosts, ["myhost"])
assert result == 1
content = Path(temp_known_hosts).read_text()
assert "myhost" not in content
assert "otherhost" in content
# Should preserve structure
assert "\n\n" in content or content.count("\n") >= 2
def test_case_insensitive_matching(self, temp_known_hosts):
"""Test that hostname matching is case-insensitive."""
existing = """MyHost ssh-ed25519 AAAA...
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(temp_known_hosts, ["myhost"])
assert result == 1
content = Path(temp_known_hosts).read_text()
assert "MyHost" not in content
assert "otherhost" in content
def test_backup_created(self, temp_known_hosts):
"""Test that backup file is created."""
existing = """myhost ssh-ed25519 AAAA...
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
Path(temp_known_hosts).chmod(0o600)
remove_known_hosts_entry(temp_known_hosts, ["myhost"])
backup_path = Path(temp_known_hosts).parent / "known_hosts.bak"
assert backup_path.exists()
backup_content = backup_path.read_text()
assert "myhost" in backup_content
assert "otherhost" in backup_content
# Check backup has same permissions
backup_mode = backup_path.stat().st_mode & 0o777
assert backup_mode == 0o600
def test_remove_all_entries(self, temp_known_hosts):
"""Test removing all entries from known_hosts."""
existing = """myhost ssh-ed25519 AAAA...
192.168.1.100 ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(temp_known_hosts, ["myhost", "192.168.1.100"])
assert result == 2
content = Path(temp_known_hosts).read_text()
assert content.strip() == ""
def test_tobcloud_hostname_format(self, temp_known_hosts):
"""Test removing tobcloud-style hostname."""
existing = """tobcloud.my-droplet ssh-ed25519 AAAA...
100.80.123.45 ssh-ed25519 AAAA...
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(
temp_known_hosts, ["tobcloud.my-droplet", "100.80.123.45"]
)
assert result == 2
content = Path(temp_known_hosts).read_text()
assert "tobcloud.my-droplet" not in content
assert "100.80.123.45" not in content
assert "otherhost" in content