Add unit tests for HostEntry and HostsFile models, and implement HostsParser tests

- Created comprehensive unit tests for HostEntry class, covering creation, validation, and conversion to/from hosts file lines.
- Developed unit tests for HostsFile class, including entry management, sorting, and retrieval of active/inactive entries.
- Implemented tests for HostsParser class, validating parsing and serialization of hosts files, handling comments, and file operations.
- Ensured coverage for edge cases such as empty files, invalid entries, and file permission checks.
This commit is contained in:
Philip Henning 2025-07-29 14:52:31 +02:00
parent 40a1e67949
commit 2decad8047
21 changed files with 1691 additions and 75 deletions

6
tests/__init__.py Normal file
View file

@ -0,0 +1,6 @@
"""
Test suite for the hosts TUI application.
This module contains unit tests, integration tests, and TUI tests
for validating the functionality of the hosts manager.
"""

Binary file not shown.

298
tests/test_models.py Normal file
View file

@ -0,0 +1,298 @@
"""
Tests for the hosts data models.
This module contains unit tests for the HostEntry and HostsFile classes,
validating their functionality and data integrity.
"""
import pytest
from hosts.core.models import HostEntry, HostsFile
class TestHostEntry:
"""Test cases for the HostEntry class."""
def test_host_entry_creation(self):
"""Test basic host entry creation."""
entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
assert entry.ip_address == "127.0.0.1"
assert entry.hostnames == ["localhost"]
assert entry.is_active is True
assert entry.comment is None
assert entry.dns_name is None
def test_host_entry_with_comment(self):
"""Test host entry creation with comment."""
entry = HostEntry(
ip_address="192.168.1.1",
hostnames=["router", "gateway"],
comment="Local router"
)
assert entry.comment == "Local router"
def test_host_entry_inactive(self):
"""Test inactive host entry creation."""
entry = HostEntry(
ip_address="10.0.0.1",
hostnames=["test.local"],
is_active=False
)
assert entry.is_active is False
def test_invalid_ip_address(self):
"""Test that invalid IP addresses raise ValueError."""
with pytest.raises(ValueError, match="Invalid IP address"):
HostEntry(ip_address="invalid.ip", hostnames=["test"])
def test_empty_hostnames(self):
"""Test that empty hostnames list raises ValueError."""
with pytest.raises(ValueError, match="At least one hostname is required"):
HostEntry(ip_address="127.0.0.1", hostnames=[])
def test_invalid_hostname(self):
"""Test that invalid hostnames raise ValueError."""
with pytest.raises(ValueError, match="Invalid hostname"):
HostEntry(ip_address="127.0.0.1", hostnames=["invalid..hostname"])
def test_ipv6_address(self):
"""Test IPv6 address support."""
entry = HostEntry(ip_address="::1", hostnames=["localhost"])
assert entry.ip_address == "::1"
def test_to_hosts_line_active(self):
"""Test conversion to hosts file line format for active entry."""
entry = HostEntry(
ip_address="127.0.0.1",
hostnames=["localhost", "local"],
comment="Loopback"
)
line = entry.to_hosts_line()
assert line == "127.0.0.1 localhost local # Loopback"
def test_to_hosts_line_inactive(self):
"""Test conversion to hosts file line format for inactive entry."""
entry = HostEntry(
ip_address="192.168.1.1",
hostnames=["router"],
is_active=False
)
line = entry.to_hosts_line()
assert line == "# 192.168.1.1 router"
def test_from_hosts_line_simple(self):
"""Test parsing simple hosts file line."""
line = "127.0.0.1 localhost"
entry = HostEntry.from_hosts_line(line)
assert entry is not None
assert entry.ip_address == "127.0.0.1"
assert entry.hostnames == ["localhost"]
assert entry.is_active is True
assert entry.comment is None
def test_from_hosts_line_with_comment(self):
"""Test parsing hosts file line with comment."""
line = "192.168.1.1 router gateway # Local network"
entry = HostEntry.from_hosts_line(line)
assert entry is not None
assert entry.ip_address == "192.168.1.1"
assert entry.hostnames == ["router", "gateway"]
assert entry.comment == "Local network"
def test_from_hosts_line_inactive(self):
"""Test parsing inactive hosts file line."""
line = "# 10.0.0.1 test.local"
entry = HostEntry.from_hosts_line(line)
assert entry is not None
assert entry.ip_address == "10.0.0.1"
assert entry.hostnames == ["test.local"]
assert entry.is_active is False
def test_from_hosts_line_empty(self):
"""Test parsing empty line returns None."""
assert HostEntry.from_hosts_line("") is None
assert HostEntry.from_hosts_line(" ") is None
def test_from_hosts_line_comment_only(self):
"""Test parsing comment-only line returns None."""
assert HostEntry.from_hosts_line("# This is just a comment") is None
def test_from_hosts_line_invalid(self):
"""Test parsing invalid line returns None."""
assert HostEntry.from_hosts_line("invalid line") is None
assert HostEntry.from_hosts_line("192.168.1.1") is None # No hostname
class TestHostsFile:
"""Test cases for the HostsFile class."""
def test_hosts_file_creation(self):
"""Test basic hosts file creation."""
hosts_file = HostsFile()
assert len(hosts_file.entries) == 0
assert len(hosts_file.header_comments) == 0
assert len(hosts_file.footer_comments) == 0
def test_add_entry(self):
"""Test adding entries to hosts file."""
hosts_file = HostsFile()
entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
hosts_file.add_entry(entry)
assert len(hosts_file.entries) == 1
assert hosts_file.entries[0] == entry
def test_add_invalid_entry(self):
"""Test that adding invalid entry raises ValueError."""
hosts_file = HostsFile()
with pytest.raises(ValueError):
# This will fail validation in add_entry
invalid_entry = HostEntry.__new__(HostEntry) # Bypass __init__
invalid_entry.ip_address = "invalid"
invalid_entry.hostnames = ["test"]
hosts_file.add_entry(invalid_entry)
def test_remove_entry(self):
"""Test removing entries from hosts file."""
hosts_file = HostsFile()
entry1 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
entry2 = HostEntry(ip_address="192.168.1.1", hostnames=["router"])
hosts_file.add_entry(entry1)
hosts_file.add_entry(entry2)
hosts_file.remove_entry(0)
assert len(hosts_file.entries) == 1
assert hosts_file.entries[0] == entry2
def test_remove_entry_invalid_index(self):
"""Test removing entry with invalid index does nothing."""
hosts_file = HostsFile()
entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
hosts_file.add_entry(entry)
hosts_file.remove_entry(10) # Invalid index
assert len(hosts_file.entries) == 1
def test_toggle_entry(self):
"""Test toggling entry active state."""
hosts_file = HostsFile()
entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
hosts_file.add_entry(entry)
assert entry.is_active is True
hosts_file.toggle_entry(0)
assert entry.is_active is False
hosts_file.toggle_entry(0)
assert entry.is_active is True
def test_get_active_entries(self):
"""Test getting only active entries."""
hosts_file = HostsFile()
active_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
inactive_entry = HostEntry(
ip_address="192.168.1.1",
hostnames=["router"],
is_active=False
)
hosts_file.add_entry(active_entry)
hosts_file.add_entry(inactive_entry)
active_entries = hosts_file.get_active_entries()
assert len(active_entries) == 1
assert active_entries[0] == active_entry
def test_get_inactive_entries(self):
"""Test getting only inactive entries."""
hosts_file = HostsFile()
active_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
inactive_entry = HostEntry(
ip_address="192.168.1.1",
hostnames=["router"],
is_active=False
)
hosts_file.add_entry(active_entry)
hosts_file.add_entry(inactive_entry)
inactive_entries = hosts_file.get_inactive_entries()
assert len(inactive_entries) == 1
assert inactive_entries[0] == inactive_entry
def test_sort_by_ip(self):
"""Test sorting entries by IP address."""
hosts_file = HostsFile()
entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["router"])
entry2 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
entry3 = HostEntry(ip_address="10.0.0.1", hostnames=["test"])
hosts_file.add_entry(entry1)
hosts_file.add_entry(entry2)
hosts_file.add_entry(entry3)
hosts_file.sort_by_ip()
assert hosts_file.entries[0].ip_address == "10.0.0.1"
assert hosts_file.entries[1].ip_address == "127.0.0.1"
assert hosts_file.entries[2].ip_address == "192.168.1.1"
def test_sort_by_hostname(self):
"""Test sorting entries by hostname."""
hosts_file = HostsFile()
entry1 = HostEntry(ip_address="127.0.0.1", hostnames=["zebra"])
entry2 = HostEntry(ip_address="192.168.1.1", hostnames=["alpha"])
entry3 = HostEntry(ip_address="10.0.0.1", hostnames=["beta"])
hosts_file.add_entry(entry1)
hosts_file.add_entry(entry2)
hosts_file.add_entry(entry3)
hosts_file.sort_by_hostname()
assert hosts_file.entries[0].hostnames[0] == "alpha"
assert hosts_file.entries[1].hostnames[0] == "beta"
assert hosts_file.entries[2].hostnames[0] == "zebra"
def test_find_entries_by_hostname(self):
"""Test finding entries by hostname."""
hosts_file = HostsFile()
entry1 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost", "local"])
entry2 = HostEntry(ip_address="192.168.1.1", hostnames=["router"])
entry3 = HostEntry(ip_address="10.0.0.1", hostnames=["test", "localhost"])
hosts_file.add_entry(entry1)
hosts_file.add_entry(entry2)
hosts_file.add_entry(entry3)
indices = hosts_file.find_entries_by_hostname("localhost")
assert indices == [0, 2]
indices = hosts_file.find_entries_by_hostname("router")
assert indices == [1]
indices = hosts_file.find_entries_by_hostname("nonexistent")
assert indices == []
def test_find_entries_by_ip(self):
"""Test finding entries by IP address."""
hosts_file = HostsFile()
entry1 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
entry2 = HostEntry(ip_address="192.168.1.1", hostnames=["router"])
entry3 = HostEntry(ip_address="127.0.0.1", hostnames=["local"])
hosts_file.add_entry(entry1)
hosts_file.add_entry(entry2)
hosts_file.add_entry(entry3)
indices = hosts_file.find_entries_by_ip("127.0.0.1")
assert indices == [0, 2]
indices = hosts_file.find_entries_by_ip("192.168.1.1")
assert indices == [1]
indices = hosts_file.find_entries_by_ip("10.0.0.1")
assert indices == []

353
tests/test_parser.py Normal file
View file

@ -0,0 +1,353 @@
"""
Tests for the hosts file parser.
This module contains unit tests for the HostsParser class,
validating file parsing and serialization functionality.
"""
import pytest
import tempfile
import os
from pathlib import Path
from hosts.core.parser import HostsParser
from hosts.core.models import HostEntry, HostsFile
class TestHostsParser:
"""Test cases for the HostsParser class."""
def test_parser_initialization(self):
"""Test parser initialization with default and custom paths."""
# Default path
parser = HostsParser()
assert str(parser.file_path) == "/etc/hosts"
# Custom path
custom_path = "/tmp/test_hosts"
parser = HostsParser(custom_path)
assert str(parser.file_path) == custom_path
def test_parse_simple_hosts_file(self):
"""Test parsing a simple hosts file."""
content = """127.0.0.1 localhost
192.168.1.1 router
"""
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
f.write(content)
f.flush()
parser = HostsParser(f.name)
hosts_file = parser.parse()
assert len(hosts_file.entries) == 2
# Check first entry
entry1 = hosts_file.entries[0]
assert entry1.ip_address == "127.0.0.1"
assert entry1.hostnames == ["localhost"]
assert entry1.is_active is True
assert entry1.comment is None
# Check second entry
entry2 = hosts_file.entries[1]
assert entry2.ip_address == "192.168.1.1"
assert entry2.hostnames == ["router"]
assert entry2.is_active is True
assert entry2.comment is None
os.unlink(f.name)
def test_parse_hosts_file_with_comments(self):
"""Test parsing hosts file with comments and inactive entries."""
content = """# This is a header comment
# Another header comment
127.0.0.1 localhost loopback # Loopback address
192.168.1.1 router gateway # Local router
# 10.0.0.1 test.local # Disabled test entry
# Footer comment
"""
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
f.write(content)
f.flush()
parser = HostsParser(f.name)
hosts_file = parser.parse()
# Check header comments
assert len(hosts_file.header_comments) == 2
assert hosts_file.header_comments[0] == "This is a header comment"
assert hosts_file.header_comments[1] == "Another header comment"
# Check entries
assert len(hosts_file.entries) == 3
# Active entry with comment
entry1 = hosts_file.entries[0]
assert entry1.ip_address == "127.0.0.1"
assert entry1.hostnames == ["localhost", "loopback"]
assert entry1.comment == "Loopback address"
assert entry1.is_active is True
# Another active entry
entry2 = hosts_file.entries[1]
assert entry2.ip_address == "192.168.1.1"
assert entry2.hostnames == ["router", "gateway"]
assert entry2.comment == "Local router"
assert entry2.is_active is True
# Inactive entry
entry3 = hosts_file.entries[2]
assert entry3.ip_address == "10.0.0.1"
assert entry3.hostnames == ["test.local"]
assert entry3.comment == "Disabled test entry"
assert entry3.is_active is False
# Check footer comments
assert len(hosts_file.footer_comments) == 1
assert hosts_file.footer_comments[0] == "Footer comment"
os.unlink(f.name)
def test_parse_empty_file(self):
"""Test parsing an empty hosts file."""
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
f.write("")
f.flush()
parser = HostsParser(f.name)
hosts_file = parser.parse()
assert len(hosts_file.entries) == 0
assert len(hosts_file.header_comments) == 0
assert len(hosts_file.footer_comments) == 0
os.unlink(f.name)
def test_parse_comments_only_file(self):
"""Test parsing a file with only comments."""
content = """# This is a comment
# Another comment
# Yet another comment
"""
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
f.write(content)
f.flush()
parser = HostsParser(f.name)
hosts_file = parser.parse()
assert len(hosts_file.entries) == 0
assert len(hosts_file.header_comments) == 3
assert hosts_file.header_comments[0] == "This is a comment"
assert hosts_file.header_comments[1] == "Another comment"
assert hosts_file.header_comments[2] == "Yet another comment"
os.unlink(f.name)
def test_parse_nonexistent_file(self):
"""Test parsing a nonexistent file raises FileNotFoundError."""
parser = HostsParser("/nonexistent/path/hosts")
with pytest.raises(FileNotFoundError):
parser.parse()
def test_serialize_simple_hosts_file(self):
"""Test serializing a simple hosts file."""
hosts_file = HostsFile()
entry1 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
entry2 = HostEntry(ip_address="192.168.1.1", hostnames=["router"])
hosts_file.add_entry(entry1)
hosts_file.add_entry(entry2)
parser = HostsParser()
content = parser.serialize(hosts_file)
expected = """127.0.0.1 localhost
192.168.1.1 router
"""
assert content == expected
def test_serialize_hosts_file_with_comments(self):
"""Test serializing hosts file with comments."""
hosts_file = HostsFile()
hosts_file.header_comments = ["Header comment 1", "Header comment 2"]
hosts_file.footer_comments = ["Footer comment"]
entry1 = HostEntry(
ip_address="127.0.0.1",
hostnames=["localhost"],
comment="Loopback"
)
entry2 = HostEntry(
ip_address="10.0.0.1",
hostnames=["test"],
is_active=False
)
hosts_file.add_entry(entry1)
hosts_file.add_entry(entry2)
parser = HostsParser()
content = parser.serialize(hosts_file)
expected = """# Header comment 1
# Header comment 2
127.0.0.1 localhost # Loopback
# 10.0.0.1 test
# Footer comment
"""
assert content == expected
def test_serialize_empty_hosts_file(self):
"""Test serializing an empty hosts file."""
hosts_file = HostsFile()
parser = HostsParser()
content = parser.serialize(hosts_file)
assert content == "\n"
def test_write_hosts_file(self):
"""Test writing hosts file to disk."""
hosts_file = HostsFile()
entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
hosts_file.add_entry(entry)
with tempfile.NamedTemporaryFile(delete=False) as f:
parser = HostsParser(f.name)
parser.write(hosts_file, backup=False)
# Read back and verify
with open(f.name, 'r') as read_file:
content = read_file.read()
assert content == "127.0.0.1 localhost\n"
os.unlink(f.name)
def test_write_hosts_file_with_backup(self):
"""Test writing hosts file with backup creation."""
# Create initial file
initial_content = "192.168.1.1 router\n"
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
f.write(initial_content)
f.flush()
# Create new hosts file to write
hosts_file = HostsFile()
entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
hosts_file.add_entry(entry)
parser = HostsParser(f.name)
parser.write(hosts_file, backup=True)
# Check that backup was created
backup_path = Path(f.name).with_suffix('.bak')
assert backup_path.exists()
# Check backup content
with open(backup_path, 'r') as backup_file:
backup_content = backup_file.read()
assert backup_content == initial_content
# Check new content
with open(f.name, 'r') as new_file:
new_content = new_file.read()
assert new_content == "127.0.0.1 localhost\n"
# Cleanup
os.unlink(backup_path)
os.unlink(f.name)
def test_validate_write_permissions(self):
"""Test write permission validation."""
# Test with a temporary file (should be writable)
with tempfile.NamedTemporaryFile() as f:
parser = HostsParser(f.name)
assert parser.validate_write_permissions() is True
# Test with a nonexistent file in /tmp (should be writable)
parser = HostsParser("/tmp/test_hosts_nonexistent")
assert parser.validate_write_permissions() is True
# Test with a path that likely doesn't have write permissions
parser = HostsParser("/root/test_hosts")
# This might be True if running as root, so we can't assert False
result = parser.validate_write_permissions()
assert isinstance(result, bool)
def test_get_file_info(self):
"""Test getting file information."""
content = "127.0.0.1 localhost\n"
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
f.write(content)
f.flush()
parser = HostsParser(f.name)
info = parser.get_file_info()
assert info['path'] == f.name
assert info['exists'] is True
assert info['readable'] is True
assert info['size'] == len(content)
assert info['modified'] is not None
assert isinstance(info['modified'], float)
os.unlink(f.name)
def test_get_file_info_nonexistent(self):
"""Test getting file information for nonexistent file."""
parser = HostsParser("/nonexistent/path")
info = parser.get_file_info()
assert info['path'] == "/nonexistent/path"
assert info['exists'] is False
assert info['readable'] is False
assert info['writable'] is False
assert info['size'] == 0
assert info['modified'] is None
def test_round_trip_parsing(self):
"""Test that parsing and serializing preserves content."""
original_content = """# System hosts file
# Do not edit manually
127.0.0.1 localhost loopback # Local loopback
::1 localhost # IPv6 loopback
192.168.1.1 router gateway # Local router
# 10.0.0.1 test.local # Test entry (disabled)
# End of file
"""
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
f.write(original_content)
f.flush()
# Parse and serialize
parser = HostsParser(f.name)
hosts_file = parser.parse()
# Write back and read
parser.write(hosts_file, backup=False)
with open(f.name, 'r') as read_file:
final_content = read_file.read()
# The content should be functionally equivalent
# (though formatting might differ slightly)
assert "127.0.0.1 localhost loopback # Local loopback" in final_content
assert "::1 localhost # IPv6 loopback" in final_content
assert "192.168.1.1 router gateway # Local router" in final_content
assert "# 10.0.0.1 test.local # Test entry (disabled)" in final_content
os.unlink(f.name)