Fix #12: roll back mutations when saving fails
This commit is contained in:
parent
2c5a8d969c
commit
d04ab6feaf
9 changed files with 677 additions and 184 deletions
|
|
@ -75,8 +75,6 @@ sorting, DNS refresh, movement, undo, and redo.
|
||||||
## Safety and known limitations
|
## Safety and known limitations
|
||||||
|
|
||||||
- Reloading discards unsaved in-memory state.
|
- Reloading discards unsaved in-memory state.
|
||||||
- A failed save can leave the interface changed while `/etc/hosts` remains
|
|
||||||
unchanged.
|
|
||||||
- Pre-edit Backups are not listed or restored by the TUI and have no retention
|
- Pre-edit Backups are not listed or restored by the TUI and have no retention
|
||||||
management. Manual recovery is documented in the user guide.
|
management. Manual recovery is documented in the user guide.
|
||||||
- Leaving Privileged Mode clears the application's session state but does not
|
- Leaving Privileged Mode clears the application's session state but does not
|
||||||
|
|
|
||||||
|
|
@ -75,8 +75,9 @@ Default Entries represent protected baseline localhost or broadcasthost
|
||||||
mappings. They cannot be changed, deleted, activated, deactivated, or moved.
|
mappings. They cannot be changed, deleted, activated, deactivated, or moved.
|
||||||
|
|
||||||
Successful mutations are saved to `/etc/hosts` immediately. Read the
|
Successful mutations are saved to `/etc/hosts` immediately. Read the
|
||||||
status message after every action. A message that reports a save failure means
|
status message after every action. If saving fails, the application restores
|
||||||
the interface may have changed while the system file did not.
|
the Host Entries, visible selection, and undo/redo history from before the
|
||||||
|
action.
|
||||||
|
|
||||||
## Use a DNS Entry
|
## Use a DNS Entry
|
||||||
|
|
||||||
|
|
@ -120,13 +121,9 @@ Adding, editing, deleting, moving, activating, deactivating, undoing, redoing,
|
||||||
and successfully refreshing DNS Entries save immediately. `Ctrl+S` explicitly
|
and successfully refreshing DNS Entries save immediately. `Ctrl+S` explicitly
|
||||||
saves the entire current in-memory Hosts File.
|
saves the entire current in-memory Hosts File.
|
||||||
|
|
||||||
If saving an undo or redo result fails, the application restores the prior
|
If an immediate save fails, the application restores the complete pre-action
|
||||||
in-memory state and undo/redo history. Reload with `Ctrl+R` to verify the
|
in-memory state, visible selection, and undo/redo history. A later mutation
|
||||||
on-disk state before attempting another change.
|
therefore cannot accidentally persist the failed change.
|
||||||
|
|
||||||
Other save failures can leave the interface changed while the file on disk is
|
|
||||||
unchanged. Reload with `Ctrl+R` to return the interface to the on-disk state
|
|
||||||
before attempting another change.
|
|
||||||
|
|
||||||
### Locate a Pre-edit Backup
|
### Locate a Pre-edit Backup
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ and safe file modifications with backup and validation.
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from copy import deepcopy
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
from .models import HostEntry, HostsFile
|
from .models import HostEntry, HostsFile
|
||||||
|
|
@ -23,6 +25,14 @@ from .commands import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MutationState:
|
||||||
|
"""Hosts File and history state from before a persisted mutation."""
|
||||||
|
|
||||||
|
hosts_file: HostsFile
|
||||||
|
undo_redo_history: UndoRedoHistory
|
||||||
|
|
||||||
|
|
||||||
class PermissionManager:
|
class PermissionManager:
|
||||||
"""
|
"""
|
||||||
Manages sudo permissions for hosts file editing.
|
Manages sudo permissions for hosts file editing.
|
||||||
|
|
@ -591,6 +601,22 @@ class HostsManager:
|
||||||
"""Get description of the operation that would be redone."""
|
"""Get description of the operation that would be redone."""
|
||||||
return self.undo_redo_history.get_redo_description()
|
return self.undo_redo_history.get_redo_description()
|
||||||
|
|
||||||
|
def capture_mutation_state(self, hosts_file: HostsFile) -> MutationState:
|
||||||
|
"""Capture the Hosts File and history before a persisted mutation."""
|
||||||
|
saved_hosts_file, saved_history = deepcopy((hosts_file, self.undo_redo_history))
|
||||||
|
return MutationState(saved_hosts_file, saved_history)
|
||||||
|
|
||||||
|
def save_mutation(
|
||||||
|
self, hosts_file: HostsFile, state: MutationState
|
||||||
|
) -> Tuple[bool, str, HostsFile]:
|
||||||
|
"""Save a mutation or restore its pre-action model and history."""
|
||||||
|
success, message = self.save_hosts_file(hosts_file)
|
||||||
|
if success:
|
||||||
|
return True, message, hosts_file
|
||||||
|
|
||||||
|
self.undo_redo_history = state.undo_redo_history
|
||||||
|
return False, message, state.hosts_file
|
||||||
|
|
||||||
def save_hosts_file(self, hosts_file: HostsFile) -> Tuple[bool, str]:
|
def save_hosts_file(self, hosts_file: HostsFile) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Save the hosts file to disk with sudo permissions.
|
Save the hosts file to disk with sudo permissions.
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ This module contains the main application class that orchestrates
|
||||||
all the handlers and provides the primary user interface.
|
all the handlers and provides the primary user interface.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import Callable
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from textual.app import App, ComposeResult, SuspendNotSupported
|
from textual.app import App, ComposeResult, SuspendNotSupported
|
||||||
from textual.containers import Horizontal, Vertical
|
from textual.containers import Horizontal, Vertical
|
||||||
|
|
@ -21,10 +21,9 @@ from textual.widgets import (
|
||||||
from textual.reactive import reactive
|
from textual.reactive import reactive
|
||||||
|
|
||||||
from ..core.parser import HostsParser
|
from ..core.parser import HostsParser
|
||||||
from ..core.models import HostsFile
|
from ..core.models import HostEntry, HostsFile
|
||||||
from ..core.commands import OperationResult
|
|
||||||
from ..core.config import Config
|
from ..core.config import Config
|
||||||
from ..core.manager import HostsManager
|
from ..core.manager import HostsManager, MutationState
|
||||||
from ..core.dns import DNSService
|
from ..core.dns import DNSService
|
||||||
from ..core.filters import EntryFilter, FilterOptions
|
from ..core.filters import EntryFilter, FilterOptions
|
||||||
from .config_modal import ConfigModal
|
from .config_modal import ConfigModal
|
||||||
|
|
@ -40,6 +39,14 @@ from .edit_handler import EditHandler
|
||||||
from .navigation_handler import NavigationHandler
|
from .navigation_handler import NavigationHandler
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MutationSnapshot:
|
||||||
|
"""TUI and core state that must survive a failed persistence attempt."""
|
||||||
|
|
||||||
|
manager_state: MutationState
|
||||||
|
selected_entry_index: int
|
||||||
|
|
||||||
|
|
||||||
class HostsManagerApp(App):
|
class HostsManagerApp(App):
|
||||||
"""
|
"""
|
||||||
Main application class for the hosts TUI manager.
|
Main application class for the hosts TUI manager.
|
||||||
|
|
@ -353,6 +360,44 @@ class HostsManagerApp(App):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def capture_mutation_state(self) -> MutationSnapshot:
|
||||||
|
"""Capture the model, selection, and history before a mutation."""
|
||||||
|
return MutationSnapshot(
|
||||||
|
manager_state=self.manager.capture_mutation_state(self.hosts_file),
|
||||||
|
selected_entry_index=self.selected_entry_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_mutation(self, snapshot: MutationSnapshot, action: str) -> bool:
|
||||||
|
"""Persist a mutation, restoring its complete pre-action state on failure."""
|
||||||
|
save_success, save_message, hosts_file = self.manager.save_mutation(
|
||||||
|
self.hosts_file, snapshot.manager_state
|
||||||
|
)
|
||||||
|
if save_success:
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.hosts_file = hosts_file
|
||||||
|
self.selected_entry_index = snapshot.selected_entry_index
|
||||||
|
|
||||||
|
self.table_handler.populate_entries_table()
|
||||||
|
self.table_handler.move_cursor_to_entry_index(self.selected_entry_index)
|
||||||
|
self.details_handler.update_entry_details()
|
||||||
|
self.update_status(
|
||||||
|
f"❌ {action} save failed; previous state restored: {save_message}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_pending_dns_entry(
|
||||||
|
self, entry_index: int, dns_name: str, hostnames: list[str]
|
||||||
|
) -> HostEntry | None:
|
||||||
|
"""Return the original DNS Entry if it is unchanged after an await."""
|
||||||
|
if not 0 <= entry_index < len(self.hosts_file.entries):
|
||||||
|
return None
|
||||||
|
|
||||||
|
entry = self.hosts_file.entries[entry_index]
|
||||||
|
if entry.dns_name != dns_name or entry.hostnames != hostnames:
|
||||||
|
return None
|
||||||
|
return entry
|
||||||
|
|
||||||
# Event handlers
|
# Event handlers
|
||||||
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
||||||
"""Handle row highlighting (cursor movement) in the DataTable."""
|
"""Handle row highlighting (cursor movement) in the DataTable."""
|
||||||
|
|
@ -624,14 +669,13 @@ class HostsManagerApp(App):
|
||||||
self.update_status("Entry creation cancelled")
|
self.update_status("Entry creation cancelled")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
snapshot = self.capture_mutation_state()
|
||||||
|
|
||||||
# Add the entry using the command-based manager method
|
# Add the entry using the command-based manager method
|
||||||
result = self.manager.execute_add_command(self.hosts_file, new_entry)
|
result = self.manager.execute_add_command(self.hosts_file, new_entry)
|
||||||
if result.success:
|
if result.success:
|
||||||
# Save the changes
|
# Save the changes
|
||||||
save_success, save_message = self.manager.save_hosts_file(
|
if self.save_mutation(snapshot, "Add"):
|
||||||
self.hosts_file
|
|
||||||
)
|
|
||||||
if save_success:
|
|
||||||
# Refresh the table
|
# Refresh the table
|
||||||
self.table_handler.populate_entries_table()
|
self.table_handler.populate_entries_table()
|
||||||
# Move cursor to the newly added entry (last entry)
|
# Move cursor to the newly added entry (last entry)
|
||||||
|
|
@ -649,8 +693,6 @@ class HostsManagerApp(App):
|
||||||
self.update_status(
|
self.update_status(
|
||||||
f"✅ {result.message} - Changes saved automatically"
|
f"✅ {result.message} - Changes saved automatically"
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
self.update_status(f"Entry added but save failed: {save_message}")
|
|
||||||
else:
|
else:
|
||||||
self.update_status(f"❌ {result.message}")
|
self.update_status(f"❌ {result.message}")
|
||||||
|
|
||||||
|
|
@ -682,16 +724,15 @@ class HostsManagerApp(App):
|
||||||
self.update_status("Entry deletion cancelled")
|
self.update_status("Entry deletion cancelled")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
snapshot = self.capture_mutation_state()
|
||||||
|
|
||||||
# Delete the entry using the command-based manager method
|
# Delete the entry using the command-based manager method
|
||||||
result = self.manager.execute_delete_command(
|
result = self.manager.execute_delete_command(
|
||||||
self.hosts_file, self.selected_entry_index
|
self.hosts_file, self.selected_entry_index
|
||||||
)
|
)
|
||||||
if result.success:
|
if result.success:
|
||||||
# Save the changes
|
# Save the changes
|
||||||
save_success, save_message = self.manager.save_hosts_file(
|
if self.save_mutation(snapshot, "Delete"):
|
||||||
self.hosts_file
|
|
||||||
)
|
|
||||||
if save_success:
|
|
||||||
# Adjust selected index if needed
|
# Adjust selected index if needed
|
||||||
if self.selected_entry_index >= len(self.hosts_file.entries):
|
if self.selected_entry_index >= len(self.hosts_file.entries):
|
||||||
self.selected_entry_index = max(
|
self.selected_entry_index = max(
|
||||||
|
|
@ -704,8 +745,6 @@ class HostsManagerApp(App):
|
||||||
self.update_status(
|
self.update_status(
|
||||||
f"✅ {result.message} - Changes saved automatically"
|
f"✅ {result.message} - Changes saved automatically"
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
self.update_status(f"Entry deleted but save failed: {save_message}")
|
|
||||||
else:
|
else:
|
||||||
self.update_status(f"❌ {result.message}")
|
self.update_status(f"❌ {result.message}")
|
||||||
|
|
||||||
|
|
@ -715,25 +754,6 @@ class HostsManagerApp(App):
|
||||||
"""Quit the application."""
|
"""Quit the application."""
|
||||||
self.navigation_handler.quit_application()
|
self.navigation_handler.quit_application()
|
||||||
|
|
||||||
def _save_history_operation(
|
|
||||||
self, action: str, rollback: Callable[[HostsFile], OperationResult]
|
|
||||||
) -> bool:
|
|
||||||
"""Save a history change or restore the previous state after a failure."""
|
|
||||||
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
|
|
||||||
if save_success:
|
|
||||||
return True
|
|
||||||
|
|
||||||
rollback_result = rollback(self.hosts_file)
|
|
||||||
if rollback_result.success:
|
|
||||||
self.update_status(
|
|
||||||
f"❌ {action} save failed; previous state restored: {save_message}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.update_status(
|
|
||||||
f"❌ {action} save failed and could not restore the previous state: {save_message}; {rollback_result.message}. Reload the Hosts File."
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def action_undo(self) -> None:
|
def action_undo(self) -> None:
|
||||||
"""Undo the last operation."""
|
"""Undo the last operation."""
|
||||||
if not self.edit_mode:
|
if not self.edit_mode:
|
||||||
|
|
@ -746,13 +766,12 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
# Get description before undoing
|
# Get description before undoing
|
||||||
description = self.manager.get_undo_description()
|
description = self.manager.get_undo_description()
|
||||||
|
snapshot = self.capture_mutation_state()
|
||||||
|
|
||||||
# Perform undo
|
# Perform undo
|
||||||
result = self.manager.undo_last_operation(self.hosts_file)
|
result = self.manager.undo_last_operation(self.hosts_file)
|
||||||
if result.success:
|
if result.success:
|
||||||
if not self._save_history_operation(
|
if not self.save_mutation(snapshot, "Undo"):
|
||||||
"Undo", self.manager.redo_last_operation
|
|
||||||
):
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Refresh the table and update UI
|
# Refresh the table and update UI
|
||||||
|
|
@ -774,13 +793,12 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
# Get description before redoing
|
# Get description before redoing
|
||||||
description = self.manager.get_redo_description()
|
description = self.manager.get_redo_description()
|
||||||
|
snapshot = self.capture_mutation_state()
|
||||||
|
|
||||||
# Perform redo
|
# Perform redo
|
||||||
result = self.manager.redo_last_operation(self.hosts_file)
|
result = self.manager.redo_last_operation(self.hosts_file)
|
||||||
if result.success:
|
if result.success:
|
||||||
if not self._save_history_operation(
|
if not self.save_mutation(snapshot, "Redo"):
|
||||||
"Redo", self.manager.undo_last_operation
|
|
||||||
):
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Refresh the table and update UI
|
# Refresh the table and update UI
|
||||||
|
|
@ -808,13 +826,6 @@ class HostsManagerApp(App):
|
||||||
self.update_status("No entries with DNS names found")
|
self.update_status("No entries with DNS names found")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Remember the currently selected entry before DNS update
|
|
||||||
current_entry = None
|
|
||||||
if self.hosts_file.entries and self.selected_entry_index < len(
|
|
||||||
self.hosts_file.entries
|
|
||||||
):
|
|
||||||
current_entry = self.hosts_file.entries[self.selected_entry_index]
|
|
||||||
|
|
||||||
async def refresh_dns():
|
async def refresh_dns():
|
||||||
try:
|
try:
|
||||||
# Extract DNS names (not hostnames!) from entries
|
# Extract DNS names (not hostnames!) from entries
|
||||||
|
|
@ -827,18 +838,31 @@ class HostsManagerApp(App):
|
||||||
resolved_count = 0
|
resolved_count = 0
|
||||||
failed_count = 0
|
failed_count = 0
|
||||||
|
|
||||||
# Resolve each DNS name and apply results back to entries
|
resolutions = [
|
||||||
for dns_name in dns_names:
|
(dns_name, await self.dns_service.resolve_entry_async(dns_name))
|
||||||
resolution = await self.dns_service.resolve_entry_async(dns_name)
|
for dns_name in dns_names
|
||||||
|
]
|
||||||
|
snapshot = self.capture_mutation_state()
|
||||||
|
current_entry = (
|
||||||
|
self.hosts_file.entries[self.selected_entry_index]
|
||||||
|
if 0 <= self.selected_entry_index < len(self.hosts_file.entries)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
current_dns_entries = self.hosts_file.get_dns_entries()
|
||||||
|
|
||||||
|
# Apply each DNS result back to the current entries.
|
||||||
|
for dns_name, resolution in resolutions:
|
||||||
# Find the corresponding entry and update it
|
# Find the corresponding entry and update it
|
||||||
for entry in dns_entries:
|
for entry in current_dns_entries:
|
||||||
if entry.dns_name == dns_name:
|
if entry.dns_name == dns_name:
|
||||||
# Apply resolution results to entry fields
|
# Apply resolution results to entry fields
|
||||||
entry.last_resolved = resolution.resolved_at
|
entry.last_resolved = resolution.resolved_at
|
||||||
entry.dns_resolution_status = resolution.status.value
|
entry.dns_resolution_status = resolution.status.value
|
||||||
|
|
||||||
if resolution.is_success():
|
if (
|
||||||
|
resolution.is_success()
|
||||||
|
and resolution.resolved_ip is not None
|
||||||
|
):
|
||||||
# Update both resolved_ip and ip_address for the hosts file
|
# Update both resolved_ip and ip_address for the hosts file
|
||||||
entry.ip_address = resolution.resolved_ip
|
entry.ip_address = resolution.resolved_ip
|
||||||
entry.resolved_ip = resolution.resolved_ip
|
entry.resolved_ip = resolution.resolved_ip
|
||||||
|
|
@ -849,13 +873,7 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
# Save hosts file with updated DNS information
|
# Save hosts file with updated DNS information
|
||||||
if resolved_count > 0 or failed_count > 0:
|
if resolved_count > 0 or failed_count > 0:
|
||||||
save_success, save_message = self.manager.save_hosts_file(
|
if not self.save_mutation(snapshot, "DNS refresh"):
|
||||||
self.hosts_file
|
|
||||||
)
|
|
||||||
if not save_success:
|
|
||||||
self.update_status(
|
|
||||||
f"❌ DNS resolution completed but save failed: {save_message}"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Update the UI and restore cursor position
|
# Update the UI and restore cursor position
|
||||||
|
|
@ -907,33 +925,36 @@ class HostsManagerApp(App):
|
||||||
self.update_status("❌ Selected entry has no DNS name to resolve")
|
self.update_status("❌ Selected entry has no DNS name to resolve")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Remember the currently selected entry before DNS update
|
dns_name = entry.dns_name
|
||||||
current_entry = entry
|
entry_hostnames = entry.hostnames.copy()
|
||||||
|
entry_index = self.selected_entry_index
|
||||||
|
|
||||||
async def update_single_dns():
|
async def update_single_dns():
|
||||||
try:
|
try:
|
||||||
dns_name = entry.dns_name
|
|
||||||
|
|
||||||
# Resolve the DNS name
|
# Resolve the DNS name
|
||||||
resolution = await self.dns_service.resolve_entry_async(dns_name)
|
resolution = await self.dns_service.resolve_entry_async(dns_name)
|
||||||
|
current_entry = self.get_pending_dns_entry(
|
||||||
|
entry_index, dns_name, entry_hostnames
|
||||||
|
)
|
||||||
|
if current_entry is None:
|
||||||
|
self.update_status(
|
||||||
|
f"❌ DNS entry changed before resolution completed: {dns_name}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
snapshot = self.capture_mutation_state()
|
||||||
|
|
||||||
# Apply resolution results to entry fields
|
# Apply resolution results to entry fields
|
||||||
entry.last_resolved = resolution.resolved_at
|
current_entry.last_resolved = resolution.resolved_at
|
||||||
entry.dns_resolution_status = resolution.status.value
|
current_entry.dns_resolution_status = resolution.status.value
|
||||||
|
|
||||||
if resolution.is_success():
|
if resolution.is_success() and resolution.resolved_ip is not None:
|
||||||
# Update both resolved_ip and ip_address for the hosts file
|
# Update both resolved_ip and ip_address for the hosts file
|
||||||
entry.ip_address = resolution.resolved_ip
|
current_entry.ip_address = resolution.resolved_ip
|
||||||
entry.resolved_ip = resolution.resolved_ip
|
current_entry.resolved_ip = resolution.resolved_ip
|
||||||
|
|
||||||
# Save hosts file with updated DNS information
|
# Save hosts file with updated DNS information
|
||||||
save_success, save_message = self.manager.save_hosts_file(
|
if not self.save_mutation(snapshot, "DNS refresh"):
|
||||||
self.hosts_file
|
|
||||||
)
|
|
||||||
if not save_success:
|
|
||||||
self.update_status(
|
|
||||||
f"❌ DNS resolution completed but save failed: {save_message}"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Update the UI and restore cursor position
|
# Update the UI and restore cursor position
|
||||||
|
|
@ -946,14 +967,13 @@ class HostsManagerApp(App):
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Resolution failed, save the status update
|
# Resolution failed, save the status update
|
||||||
save_success, save_message = self.manager.save_hosts_file(
|
if not self.save_mutation(snapshot, "DNS refresh"):
|
||||||
self.hosts_file
|
return
|
||||||
)
|
|
||||||
if save_success:
|
# Update the UI to show failed status and restore cursor position
|
||||||
# Update the UI to show failed status and restore cursor position
|
self.table_handler.populate_entries_table()
|
||||||
self.table_handler.populate_entries_table()
|
self.table_handler.restore_cursor_position(current_entry)
|
||||||
self.table_handler.restore_cursor_position(current_entry)
|
self.details_handler.update_entry_details()
|
||||||
self.details_handler.update_entry_details()
|
|
||||||
|
|
||||||
error_msg = resolution.error_message or "Unknown error"
|
error_msg = resolution.error_message or "Unknown error"
|
||||||
self.update_status(
|
self.update_status(
|
||||||
|
|
@ -1029,62 +1049,62 @@ class HostsManagerApp(App):
|
||||||
if not hasattr(entry, "dns_name") or not entry.dns_name:
|
if not hasattr(entry, "dns_name") or not entry.dns_name:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
dns_name = entry.dns_name
|
||||||
|
entry_hostnames = entry.hostnames.copy()
|
||||||
|
entry_index = next(
|
||||||
|
(
|
||||||
|
index
|
||||||
|
for index, candidate in enumerate(self.hosts_file.entries)
|
||||||
|
if candidate is entry
|
||||||
|
),
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
|
||||||
async def resolve_and_activate():
|
async def resolve_and_activate():
|
||||||
try:
|
try:
|
||||||
# Resolve the DNS name
|
# Resolve the DNS name
|
||||||
resolution = await self.dns_service.resolve_entry_async(entry.dns_name)
|
resolution = await self.dns_service.resolve_entry_async(dns_name)
|
||||||
|
current_entry = self.get_pending_dns_entry(
|
||||||
|
entry_index, dns_name, entry_hostnames
|
||||||
|
)
|
||||||
|
if current_entry is None:
|
||||||
|
self.update_status(
|
||||||
|
f"❌ DNS entry changed before resolution completed: {dns_name}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if resolution.is_success():
|
snapshot = self.capture_mutation_state()
|
||||||
# Find the entry in the hosts file and update it
|
|
||||||
for hosts_entry in self.hosts_file.entries:
|
|
||||||
if (
|
|
||||||
hasattr(hosts_entry, "dns_name")
|
|
||||||
and hosts_entry.dns_name == entry.dns_name
|
|
||||||
and hosts_entry.hostnames == entry.hostnames
|
|
||||||
):
|
|
||||||
# Update the entry with resolved IP
|
|
||||||
hosts_entry.ip_address = resolution.resolved_ip
|
|
||||||
hosts_entry.resolved_ip = resolution.resolved_ip
|
|
||||||
hosts_entry.last_resolved = resolution.resolved_at
|
|
||||||
hosts_entry.dns_resolution_status = resolution.status.value
|
|
||||||
hosts_entry.is_active = True # Activate the entry
|
|
||||||
|
|
||||||
# Save the updated hosts file
|
if resolution.is_success() and resolution.resolved_ip is not None:
|
||||||
save_success, save_message = self.manager.save_hosts_file(
|
# Update the entry with resolved IP
|
||||||
self.hosts_file
|
current_entry.ip_address = resolution.resolved_ip
|
||||||
)
|
current_entry.resolved_ip = resolution.resolved_ip
|
||||||
if save_success:
|
current_entry.last_resolved = resolution.resolved_at
|
||||||
# Update UI - use direct calls since we're in the same async context
|
current_entry.dns_resolution_status = resolution.status.value
|
||||||
self.table_handler.populate_entries_table()
|
current_entry.is_active = True # Activate the entry
|
||||||
self.details_handler.update_entry_details()
|
|
||||||
self.update_status(
|
# Save the updated hosts file
|
||||||
f"✅ DNS resolved: {entry.dns_name} → {resolution.resolved_ip} (entry activated)"
|
if self.save_mutation(snapshot, "DNS activation"):
|
||||||
)
|
# Update UI - use direct calls since we're in the same async context
|
||||||
else:
|
self.table_handler.populate_entries_table()
|
||||||
self.update_status(
|
self.details_handler.update_entry_details()
|
||||||
f"❌ DNS resolved but save failed: {save_message}"
|
self.update_status(
|
||||||
)
|
f"✅ DNS resolved: {dns_name} → {resolution.resolved_ip} (entry activated)"
|
||||||
break
|
)
|
||||||
else:
|
else:
|
||||||
# Resolution failed, update status but keep entry inactive
|
# Resolution failed, update status but keep entry inactive
|
||||||
for hosts_entry in self.hosts_file.entries:
|
current_entry.dns_resolution_status = resolution.status.value
|
||||||
if (
|
current_entry.last_resolved = resolution.resolved_at
|
||||||
hasattr(hosts_entry, "dns_name")
|
|
||||||
and hosts_entry.dns_name == entry.dns_name
|
if not self.save_mutation(snapshot, "DNS refresh"):
|
||||||
and hosts_entry.hostnames == entry.hostnames
|
return
|
||||||
):
|
|
||||||
hosts_entry.dns_resolution_status = resolution.status.value
|
|
||||||
hosts_entry.last_resolved = resolution.resolved_at
|
|
||||||
break
|
|
||||||
|
|
||||||
self.update_status(
|
self.update_status(
|
||||||
f"❌ DNS resolution failed for {entry.dns_name}: {resolution.error_message or 'Unknown error'}"
|
f"❌ DNS resolution failed for {dns_name}: {resolution.error_message or 'Unknown error'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.update_status(
|
self.update_status(f"❌ DNS resolution error for {dns_name}: {str(e)}")
|
||||||
f"❌ DNS resolution error for {entry.dns_name}: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start the resolution in background
|
# Start the resolution in background
|
||||||
self.run_worker(resolve_and_activate(), exclusive=False)
|
self.run_worker(resolve_and_activate(), exclusive=False)
|
||||||
|
|
|
||||||
|
|
@ -355,6 +355,7 @@ class EditHandler:
|
||||||
hostnames = [h.strip() for h in hostname_input.value.split(",") if h.strip()]
|
hostnames = [h.strip() for h in hostname_input.value.split(",") if h.strip()]
|
||||||
comment = comment_input.value.strip() or None
|
comment = comment_input.value.strip() or None
|
||||||
is_active = active_checkbox.value
|
is_active = active_checkbox.value
|
||||||
|
snapshot = self.app.capture_mutation_state()
|
||||||
|
|
||||||
# Update entry based on type
|
# Update entry based on type
|
||||||
if entry_type == "ip":
|
if entry_type == "ip":
|
||||||
|
|
@ -390,8 +391,7 @@ class EditHandler:
|
||||||
entry.is_active = is_active
|
entry.is_active = is_active
|
||||||
|
|
||||||
# Save to file
|
# Save to file
|
||||||
success, message = self.app.manager.save_hosts_file(self.app.hosts_file)
|
if self.app.save_mutation(snapshot, "Edit"):
|
||||||
if success:
|
|
||||||
# Update the table display
|
# Update the table display
|
||||||
self.app.table_handler.populate_entries_table()
|
self.app.table_handler.populate_entries_table()
|
||||||
# Restore cursor position
|
# Restore cursor position
|
||||||
|
|
@ -410,9 +410,7 @@ class EditHandler:
|
||||||
else:
|
else:
|
||||||
self.app.update_status("Entry saved successfully")
|
self.app.update_status("Entry saved successfully")
|
||||||
return True
|
return True
|
||||||
else:
|
return False
|
||||||
self.app.update_status(f"❌ Error saving entry: {message}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def navigate_to_next_field(self) -> None:
|
def navigate_to_next_field(self) -> None:
|
||||||
"""Move to the next field in edit mode."""
|
"""Move to the next field in edit mode."""
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class NavigationHandler:
|
||||||
|
|
||||||
# Remember current entry for cursor position restoration
|
# Remember current entry for cursor position restoration
|
||||||
current_entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
current_entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
||||||
|
snapshot = self.app.capture_mutation_state()
|
||||||
|
|
||||||
# Use command-based method for undo/redo support
|
# Use command-based method for undo/redo support
|
||||||
result = self.app.manager.execute_toggle_command(
|
result = self.app.manager.execute_toggle_command(
|
||||||
|
|
@ -36,10 +37,7 @@ class NavigationHandler:
|
||||||
)
|
)
|
||||||
if result.success:
|
if result.success:
|
||||||
# Auto-save the changes immediately
|
# Auto-save the changes immediately
|
||||||
save_success, save_message = self.app.manager.save_hosts_file(
|
if self.app.save_mutation(snapshot, "Toggle"):
|
||||||
self.app.hosts_file
|
|
||||||
)
|
|
||||||
if save_success:
|
|
||||||
self.app.table_handler.populate_entries_table()
|
self.app.table_handler.populate_entries_table()
|
||||||
# Restore cursor position to the same entry
|
# Restore cursor position to the same entry
|
||||||
self.app.set_timer(
|
self.app.set_timer(
|
||||||
|
|
@ -52,8 +50,6 @@ class NavigationHandler:
|
||||||
self.app.update_status(
|
self.app.update_status(
|
||||||
f"{result.message} - Changes saved automatically"
|
f"{result.message} - Changes saved automatically"
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
self.app.update_status(f"Entry toggled but save failed: {save_message}")
|
|
||||||
else:
|
else:
|
||||||
self.app.update_status(f"Error toggling entry: {result.message}")
|
self.app.update_status(f"Error toggling entry: {result.message}")
|
||||||
|
|
||||||
|
|
@ -69,16 +65,15 @@ class NavigationHandler:
|
||||||
self.app.update_status("No entries to move")
|
self.app.update_status("No entries to move")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
snapshot = self.app.capture_mutation_state()
|
||||||
|
|
||||||
# Use command-based method for undo/redo support
|
# Use command-based method for undo/redo support
|
||||||
result = self.app.manager.execute_move_command(
|
result = self.app.manager.execute_move_command(
|
||||||
self.app.hosts_file, self.app.selected_entry_index, "up"
|
self.app.hosts_file, self.app.selected_entry_index, "up"
|
||||||
)
|
)
|
||||||
if result.success:
|
if result.success:
|
||||||
# Auto-save the changes immediately
|
# Auto-save the changes immediately
|
||||||
save_success, save_message = self.app.manager.save_hosts_file(
|
if self.app.save_mutation(snapshot, "Move"):
|
||||||
self.app.hosts_file
|
|
||||||
)
|
|
||||||
if save_success:
|
|
||||||
# Update the selection index to follow the moved entry
|
# Update the selection index to follow the moved entry
|
||||||
if self.app.selected_entry_index > 0:
|
if self.app.selected_entry_index > 0:
|
||||||
self.app.selected_entry_index -= 1
|
self.app.selected_entry_index -= 1
|
||||||
|
|
@ -94,8 +89,6 @@ class NavigationHandler:
|
||||||
self.app.update_status(
|
self.app.update_status(
|
||||||
f"{result.message} - Changes saved automatically"
|
f"{result.message} - Changes saved automatically"
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
self.app.update_status(f"Entry moved but save failed: {save_message}")
|
|
||||||
else:
|
else:
|
||||||
self.app.update_status(f"Error moving entry: {result.message}")
|
self.app.update_status(f"Error moving entry: {result.message}")
|
||||||
|
|
||||||
|
|
@ -111,16 +104,15 @@ class NavigationHandler:
|
||||||
self.app.update_status("No entries to move")
|
self.app.update_status("No entries to move")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
snapshot = self.app.capture_mutation_state()
|
||||||
|
|
||||||
# Use command-based method for undo/redo support
|
# Use command-based method for undo/redo support
|
||||||
result = self.app.manager.execute_move_command(
|
result = self.app.manager.execute_move_command(
|
||||||
self.app.hosts_file, self.app.selected_entry_index, "down"
|
self.app.hosts_file, self.app.selected_entry_index, "down"
|
||||||
)
|
)
|
||||||
if result.success:
|
if result.success:
|
||||||
# Auto-save the changes immediately
|
# Auto-save the changes immediately
|
||||||
save_success, save_message = self.app.manager.save_hosts_file(
|
if self.app.save_mutation(snapshot, "Move"):
|
||||||
self.app.hosts_file
|
|
||||||
)
|
|
||||||
if save_success:
|
|
||||||
# Update the selection index to follow the moved entry
|
# Update the selection index to follow the moved entry
|
||||||
if self.app.selected_entry_index < len(self.app.hosts_file.entries) - 1:
|
if self.app.selected_entry_index < len(self.app.hosts_file.entries) - 1:
|
||||||
self.app.selected_entry_index += 1
|
self.app.selected_entry_index += 1
|
||||||
|
|
@ -136,8 +128,6 @@ class NavigationHandler:
|
||||||
self.app.update_status(
|
self.app.update_status(
|
||||||
f"{result.message} - Changes saved automatically"
|
f"{result.message} - Changes saved automatically"
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
self.app.update_status(f"Entry moved but save failed: {save_message}")
|
|
||||||
else:
|
else:
|
||||||
self.app.update_status(f"Error moving entry: {result.message}")
|
self.app.update_status(f"Error moving entry: {result.message}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ class TableHandler:
|
||||||
|
|
||||||
def actual_index_to_display_index(self, actual_index: int) -> int:
|
def actual_index_to_display_index(self, actual_index: int) -> int:
|
||||||
"""Convert an actual hosts file entry index to a display table index."""
|
"""Convert an actual hosts file entry index to a display table index."""
|
||||||
if actual_index >= len(self.app.hosts_file.entries):
|
if actual_index < 0 or actual_index >= len(self.app.hosts_file.entries):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
target_entry = self.app.hosts_file.entries[actual_index]
|
target_entry = self.app.hosts_file.entries[actual_index]
|
||||||
|
|
@ -136,6 +136,14 @@ class TableHandler:
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
def move_cursor_to_entry_index(self, actual_index: int) -> None:
|
||||||
|
"""Move the table cursor to an exact Hosts File entry index."""
|
||||||
|
table = self.app.query_one("#entries-table", DataTable)
|
||||||
|
display_index = self.actual_index_to_display_index(actual_index)
|
||||||
|
if table.row_count > 0 and display_index < table.row_count:
|
||||||
|
table.move_cursor(row=display_index)
|
||||||
|
table.focus()
|
||||||
|
|
||||||
def populate_entries_table(self) -> None:
|
def populate_entries_table(self) -> None:
|
||||||
"""Populate the left pane with hosts entries using DataTable."""
|
"""Populate the left pane with hosts entries using DataTable."""
|
||||||
table = self.app.query_one("#entries-table", DataTable)
|
table = self.app.query_one("#entries-table", DataTable)
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,31 @@ This module contains unit tests for the HostsManagerApp class,
|
||||||
validating application behavior, navigation, and user interactions.
|
validating application behavior, navigation, and user interactions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from unittest.mock import Mock, patch
|
from datetime import datetime
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
from hosts.tui.app import HostsManagerApp
|
from hosts.tui.app import HostsManagerApp
|
||||||
from hosts.core.models import HostEntry, HostsFile
|
from hosts.core.models import HostEntry, HostsFile
|
||||||
from hosts.core.parser import HostsParser
|
from hosts.core.parser import HostsParser
|
||||||
from hosts.core.config import Config
|
from hosts.core.config import Config
|
||||||
from hosts.core.manager import HostsManager
|
from hosts.core.dns import DNSResolution, DNSResolutionStatus
|
||||||
|
|
||||||
|
|
||||||
class TestHostsManagerApp:
|
class TestHostsManagerApp:
|
||||||
"""Test cases for the HostsManagerApp class."""
|
"""Test cases for the HostsManagerApp class."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def fail_saves(app: HostsManagerApp) -> None:
|
||||||
|
"""Configure a TUI app with an isolated failed-persistence boundary."""
|
||||||
|
app.manager.save_hosts_file = Mock(return_value=(False, "Permission denied"))
|
||||||
|
app.table_handler.populate_entries_table = Mock()
|
||||||
|
app.table_handler.move_cursor_to_entry_index = Mock()
|
||||||
|
app.details_handler.update_entry_details = Mock()
|
||||||
|
app.update_status = Mock()
|
||||||
|
|
||||||
def test_app_initialization(self):
|
def test_app_initialization(self):
|
||||||
"""Test application initialization."""
|
"""Test application initialization."""
|
||||||
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
|
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
|
||||||
|
|
@ -537,8 +549,7 @@ class TestHostsManagerApp:
|
||||||
app.update_status = Mock()
|
app.update_status = Mock()
|
||||||
|
|
||||||
serialized_files = []
|
serialized_files = []
|
||||||
app.manager = Mock(spec=HostsManager)
|
app.manager.enter_edit_mode = Mock(return_value=(True, "Edit mode enabled"))
|
||||||
app.manager.enter_edit_mode.return_value = (True, "Edit mode enabled")
|
|
||||||
|
|
||||||
def toggle_entry(hosts_file, index):
|
def toggle_entry(hosts_file, index):
|
||||||
hosts_file.toggle_entry(index)
|
hosts_file.toggle_entry(index)
|
||||||
|
|
@ -548,8 +559,8 @@ class TestHostsManagerApp:
|
||||||
serialized_files.append(HostsParser().serialize(hosts_file))
|
serialized_files.append(HostsParser().serialize(hosts_file))
|
||||||
return True, "Hosts file saved successfully"
|
return True, "Hosts file saved successfully"
|
||||||
|
|
||||||
app.manager.execute_toggle_command.side_effect = toggle_entry
|
app.manager.execute_toggle_command = Mock(side_effect=toggle_entry)
|
||||||
app.manager.save_hosts_file.side_effect = save_hosts_file
|
app.manager.save_hosts_file = Mock(side_effect=save_hosts_file)
|
||||||
|
|
||||||
app.action_sort_by_ip()
|
app.action_sort_by_ip()
|
||||||
assert app.edit_mode is False
|
assert app.edit_mode is False
|
||||||
|
|
@ -1138,20 +1149,16 @@ class TestHostsManagerApp:
|
||||||
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
|
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
|
||||||
app.hosts_file.add_entry(entry)
|
app.hosts_file.add_entry(entry)
|
||||||
app.manager.execute_toggle_command(app.hosts_file, 0)
|
app.manager.execute_toggle_command(app.hosts_file, 0)
|
||||||
app.manager.save_hosts_file = Mock(
|
self.fail_saves(app)
|
||||||
return_value=(False, "Permission denied")
|
|
||||||
)
|
|
||||||
app.table_handler.populate_entries_table = Mock()
|
|
||||||
app.details_handler.update_entry_details = Mock()
|
|
||||||
app.update_status = Mock()
|
|
||||||
|
|
||||||
app.action_undo()
|
app.action_undo()
|
||||||
|
|
||||||
assert entry.is_active is False
|
assert app.hosts_file.entries[0].is_active is False
|
||||||
assert app.manager.can_undo()
|
assert app.manager.can_undo()
|
||||||
assert not app.manager.can_redo()
|
assert not app.manager.can_redo()
|
||||||
app.table_handler.populate_entries_table.assert_not_called()
|
app.table_handler.populate_entries_table.assert_called_once()
|
||||||
app.details_handler.update_entry_details.assert_not_called()
|
app.table_handler.move_cursor_to_entry_index.assert_called_once_with(0)
|
||||||
|
app.details_handler.update_entry_details.assert_called_once()
|
||||||
app.update_status.assert_called_once_with(
|
app.update_status.assert_called_once_with(
|
||||||
"❌ Undo save failed; previous state restored: Permission denied"
|
"❌ Undo save failed; previous state restored: Permission denied"
|
||||||
)
|
)
|
||||||
|
|
@ -1190,22 +1197,449 @@ class TestHostsManagerApp:
|
||||||
app.hosts_file.add_entry(entry)
|
app.hosts_file.add_entry(entry)
|
||||||
app.manager.execute_toggle_command(app.hosts_file, 0)
|
app.manager.execute_toggle_command(app.hosts_file, 0)
|
||||||
app.manager.undo_last_operation(app.hosts_file)
|
app.manager.undo_last_operation(app.hosts_file)
|
||||||
|
self.fail_saves(app)
|
||||||
|
|
||||||
|
app.action_redo()
|
||||||
|
|
||||||
|
assert app.hosts_file.entries[0].is_active is True
|
||||||
|
assert not app.manager.can_undo()
|
||||||
|
assert app.manager.can_redo()
|
||||||
|
app.table_handler.populate_entries_table.assert_called_once()
|
||||||
|
app.table_handler.move_cursor_to_entry_index.assert_called_once_with(0)
|
||||||
|
app.details_handler.update_entry_details.assert_called_once()
|
||||||
|
app.update_status.assert_called_once_with(
|
||||||
|
"❌ Redo save failed; previous state restored: Permission denied"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_toggle_save_failure_restores_model_selection_and_history(self):
|
||||||
|
"""A failed toggle restores all user-visible and undoable state."""
|
||||||
|
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
|
||||||
|
app = HostsManagerApp()
|
||||||
|
app.edit_mode = True
|
||||||
|
app.manager.edit_mode = True
|
||||||
|
app.hosts_file = HostsFile(
|
||||||
|
entries=[
|
||||||
|
HostEntry(ip_address="192.0.2.1", hostnames=["one.test"]),
|
||||||
|
HostEntry(ip_address="192.0.2.2", hostnames=["two.test"]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
app.selected_entry_index = 0
|
||||||
|
app.manager.execute_toggle_command(app.hosts_file, 1)
|
||||||
|
app.manager.undo_last_operation(app.hosts_file)
|
||||||
|
self.fail_saves(app)
|
||||||
|
|
||||||
|
app.action_toggle_entry()
|
||||||
|
|
||||||
|
assert app.hosts_file.entries[0].is_active is True
|
||||||
|
assert app.selected_entry_index == 0
|
||||||
|
assert not app.manager.can_undo()
|
||||||
|
assert app.manager.can_redo()
|
||||||
|
app.update_status.assert_called_once_with(
|
||||||
|
"❌ Toggle save failed; previous state restored: Permission denied"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_move_save_failure_restores_entry_order_and_selection(self):
|
||||||
|
"""A failed move leaves the selected Host Entry in its original position."""
|
||||||
|
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
|
||||||
|
app = HostsManagerApp()
|
||||||
|
app.edit_mode = True
|
||||||
|
app.manager.edit_mode = True
|
||||||
|
app.hosts_file = HostsFile(
|
||||||
|
entries=[
|
||||||
|
HostEntry(ip_address="192.0.2.1", hostnames=["one.test"]),
|
||||||
|
HostEntry(ip_address="192.0.2.2", hostnames=["two.test"]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
app.selected_entry_index = 1
|
||||||
|
self.fail_saves(app)
|
||||||
|
|
||||||
|
app.action_move_entry_up()
|
||||||
|
|
||||||
|
assert [entry.hostnames[0] for entry in app.hosts_file.entries] == [
|
||||||
|
"one.test",
|
||||||
|
"two.test",
|
||||||
|
]
|
||||||
|
assert app.selected_entry_index == 1
|
||||||
|
assert not app.manager.can_undo()
|
||||||
|
app.update_status.assert_called_once_with(
|
||||||
|
"❌ Move save failed; previous state restored: Permission denied"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_add_save_failure_removes_entry_and_restores_history(self):
|
||||||
|
"""A failed add restores the prior entries and history."""
|
||||||
|
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
|
||||||
|
app = HostsManagerApp()
|
||||||
|
app.edit_mode = True
|
||||||
|
app.manager.edit_mode = True
|
||||||
|
existing = HostEntry(ip_address="192.0.2.1", hostnames=["one.test"])
|
||||||
|
added = HostEntry(ip_address="192.0.2.2", hostnames=["two.test"])
|
||||||
|
app.hosts_file = HostsFile(entries=[existing])
|
||||||
|
app.selected_entry_index = 0
|
||||||
|
self.fail_saves(app)
|
||||||
|
app.push_screen = Mock(
|
||||||
|
side_effect=lambda _screen, callback: callback(added)
|
||||||
|
)
|
||||||
|
|
||||||
|
app.action_add_entry()
|
||||||
|
|
||||||
|
assert [entry.hostnames[0] for entry in app.hosts_file.entries] == [
|
||||||
|
"one.test"
|
||||||
|
]
|
||||||
|
assert app.selected_entry_index == 0
|
||||||
|
assert not app.manager.can_undo()
|
||||||
|
app.update_status.assert_called_once_with(
|
||||||
|
"❌ Add save failed; previous state restored: Permission denied"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_later_mutation_does_not_persist_failed_add(self):
|
||||||
|
"""A later successful save excludes a previously failed addition."""
|
||||||
|
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
|
||||||
|
app = HostsManagerApp()
|
||||||
|
app.edit_mode = True
|
||||||
|
app.manager.edit_mode = True
|
||||||
|
existing = HostEntry(ip_address="192.0.2.1", hostnames=["one.test"])
|
||||||
|
added = HostEntry(ip_address="192.0.2.2", hostnames=["two.test"])
|
||||||
|
app.hosts_file = HostsFile(entries=[existing])
|
||||||
app.manager.save_hosts_file = Mock(
|
app.manager.save_hosts_file = Mock(
|
||||||
return_value=(False, "Permission denied")
|
return_value=(False, "Permission denied")
|
||||||
)
|
)
|
||||||
app.table_handler.populate_entries_table = Mock()
|
app.table_handler.populate_entries_table = Mock()
|
||||||
|
app.table_handler.move_cursor_to_entry_index = Mock()
|
||||||
|
app.table_handler.restore_cursor_position = Mock()
|
||||||
|
app.details_handler.update_entry_details = Mock()
|
||||||
|
app.update_status = Mock()
|
||||||
|
app.set_timer = Mock()
|
||||||
|
app.push_screen = Mock(
|
||||||
|
side_effect=lambda _screen, callback: callback(added)
|
||||||
|
)
|
||||||
|
app.action_add_entry()
|
||||||
|
|
||||||
|
persisted = []
|
||||||
|
|
||||||
|
def save_hosts_file(hosts_file):
|
||||||
|
persisted.append(HostsParser().serialize(hosts_file))
|
||||||
|
return True, "Hosts file saved"
|
||||||
|
|
||||||
|
app.manager.save_hosts_file = Mock(side_effect=save_hosts_file)
|
||||||
|
app.action_toggle_entry()
|
||||||
|
|
||||||
|
assert len(persisted) == 1
|
||||||
|
assert "one.test" in persisted[0]
|
||||||
|
assert "two.test" not in persisted[0]
|
||||||
|
|
||||||
|
def test_save_failure_restores_exact_duplicate_selection(self):
|
||||||
|
"""Rollback keeps the second of two identical Host Entries selected."""
|
||||||
|
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
|
||||||
|
app = HostsManagerApp()
|
||||||
|
app.edit_mode = True
|
||||||
|
app.manager.edit_mode = True
|
||||||
|
app.hosts_file = HostsFile(
|
||||||
|
entries=[
|
||||||
|
HostEntry(ip_address="192.0.2.1", hostnames=["same.test"]),
|
||||||
|
HostEntry(ip_address="192.0.2.1", hostnames=["same.test"]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
app.selected_entry_index = 1
|
||||||
|
app.manager.save_hosts_file = Mock(
|
||||||
|
return_value=(False, "Permission denied")
|
||||||
|
)
|
||||||
|
table = Mock(row_count=2)
|
||||||
|
app.query_one = Mock(return_value=table)
|
||||||
|
app.table_handler.populate_entries_table = Mock()
|
||||||
app.details_handler.update_entry_details = Mock()
|
app.details_handler.update_entry_details = Mock()
|
||||||
app.update_status = Mock()
|
app.update_status = Mock()
|
||||||
|
|
||||||
app.action_redo()
|
app.action_toggle_entry()
|
||||||
|
|
||||||
assert entry.is_active is True
|
assert app.selected_entry_index == 1
|
||||||
|
table.move_cursor.assert_called_once_with(row=1)
|
||||||
|
|
||||||
|
def test_delete_save_failure_restores_entry_and_selection(self):
|
||||||
|
"""A failed delete restores the removed Host Entry and selection."""
|
||||||
|
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
|
||||||
|
app = HostsManagerApp()
|
||||||
|
app.edit_mode = True
|
||||||
|
app.manager.edit_mode = True
|
||||||
|
app.hosts_file = HostsFile(
|
||||||
|
entries=[
|
||||||
|
HostEntry(ip_address="192.0.2.1", hostnames=["one.test"]),
|
||||||
|
HostEntry(ip_address="192.0.2.2", hostnames=["two.test"]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
app.selected_entry_index = 1
|
||||||
|
self.fail_saves(app)
|
||||||
|
app.push_screen = Mock(side_effect=lambda _screen, callback: callback(True))
|
||||||
|
|
||||||
|
app.action_delete_entry()
|
||||||
|
|
||||||
|
assert [entry.hostnames[0] for entry in app.hosts_file.entries] == [
|
||||||
|
"one.test",
|
||||||
|
"two.test",
|
||||||
|
]
|
||||||
|
assert app.selected_entry_index == 1
|
||||||
assert not app.manager.can_undo()
|
assert not app.manager.can_undo()
|
||||||
assert app.manager.can_redo()
|
|
||||||
app.table_handler.populate_entries_table.assert_not_called()
|
|
||||||
app.details_handler.update_entry_details.assert_not_called()
|
|
||||||
app.update_status.assert_called_once_with(
|
app.update_status.assert_called_once_with(
|
||||||
"❌ Redo save failed; previous state restored: Permission denied"
|
"❌ Delete save failed; previous state restored: Permission denied"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_entry_editor_save_failure_restores_all_entry_fields(self):
|
||||||
|
"""A failed editor save restores ordinary fields and DNS metadata."""
|
||||||
|
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
|
||||||
|
app = HostsManagerApp()
|
||||||
|
app.edit_mode = True
|
||||||
|
app.manager.edit_mode = True
|
||||||
|
resolved_at = datetime(2026, 9, 4, 10, 30)
|
||||||
|
app.hosts_file = HostsFile(
|
||||||
|
entries=[
|
||||||
|
HostEntry(
|
||||||
|
ip_address="192.0.2.1",
|
||||||
|
hostnames=["one.test", "alias.test"],
|
||||||
|
comment="original",
|
||||||
|
is_active=False,
|
||||||
|
dns_name="source.test",
|
||||||
|
resolved_ip="192.0.2.1",
|
||||||
|
last_resolved=resolved_at,
|
||||||
|
dns_resolution_status="resolved",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
app.selected_entry_index = 0
|
||||||
|
app.entry_edit_mode = True
|
||||||
|
app.manager.save_hosts_file = Mock(
|
||||||
|
return_value=(False, "Permission denied")
|
||||||
|
)
|
||||||
|
app.table_handler.populate_entries_table = Mock()
|
||||||
|
app.table_handler.move_cursor_to_entry_index = Mock()
|
||||||
|
app.update_status = Mock()
|
||||||
|
|
||||||
|
ip_input = Mock(value="198.51.100.8")
|
||||||
|
hostname_input = Mock(value="changed.test")
|
||||||
|
comment_input = Mock(value="changed")
|
||||||
|
active_checkbox = Mock(value=True)
|
||||||
|
dns_input = Mock(value="")
|
||||||
|
ip_radio = Mock(id="edit-ip-entry-radio", value=True)
|
||||||
|
dns_radio = Mock(id="edit-dns-entry-radio", value=False)
|
||||||
|
radio_set = Mock(pressed_button=ip_radio)
|
||||||
|
widgets = {
|
||||||
|
"#entry-details-display": Mock(),
|
||||||
|
"#entry-edit-form": Mock(),
|
||||||
|
"#ip-input": ip_input,
|
||||||
|
"#hostname-input": hostname_input,
|
||||||
|
"#comment-input": comment_input,
|
||||||
|
"#active-checkbox": active_checkbox,
|
||||||
|
"#dns-name-input": dns_input,
|
||||||
|
"#edit-entry-type-radio": radio_set,
|
||||||
|
"#edit-ip-entry-radio": ip_radio,
|
||||||
|
"#edit-dns-entry-radio": dns_radio,
|
||||||
|
"#edit-ip-section": Mock(),
|
||||||
|
"#edit-dns-section": Mock(),
|
||||||
|
}
|
||||||
|
app.query_one = Mock(side_effect=lambda selector, *_args: widgets[selector])
|
||||||
|
app.set_timer = Mock(side_effect=lambda _delay, callback: callback())
|
||||||
|
|
||||||
|
saved = app.edit_handler.validate_and_save_entry_changes()
|
||||||
|
|
||||||
|
entry = app.hosts_file.entries[0]
|
||||||
|
assert saved is False
|
||||||
|
assert entry.ip_address == "192.0.2.1"
|
||||||
|
assert entry.hostnames == ["one.test", "alias.test"]
|
||||||
|
assert entry.comment == "original"
|
||||||
|
assert entry.is_active is False
|
||||||
|
assert entry.dns_name == "source.test"
|
||||||
|
assert entry.resolved_ip == "192.0.2.1"
|
||||||
|
assert entry.last_resolved == resolved_at
|
||||||
|
assert entry.dns_resolution_status == "resolved"
|
||||||
|
assert ip_input.value == "192.0.2.1"
|
||||||
|
assert hostname_input.value == "one.test, alias.test"
|
||||||
|
assert comment_input.value == "original"
|
||||||
|
assert active_checkbox.value is False
|
||||||
|
assert dns_input.value == "source.test"
|
||||||
|
app.update_status.assert_called_once_with(
|
||||||
|
"❌ Edit save failed; previous state restored: Permission denied"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_batch_dns_save_failure_restores_mapping_and_metadata(self):
|
||||||
|
"""A failed batch DNS refresh restores every changed DNS field."""
|
||||||
|
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
|
||||||
|
app = HostsManagerApp()
|
||||||
|
app.edit_mode = True
|
||||||
|
app.manager.edit_mode = True
|
||||||
|
original_time = datetime(2026, 9, 3, 8, 0)
|
||||||
|
resolved_time = datetime(2026, 9, 4, 11, 0)
|
||||||
|
app.hosts_file = HostsFile(
|
||||||
|
entries=[
|
||||||
|
HostEntry(
|
||||||
|
ip_address="192.0.2.1",
|
||||||
|
hostnames=["one.test"],
|
||||||
|
dns_name="source.test",
|
||||||
|
resolved_ip="192.0.2.1",
|
||||||
|
last_resolved=original_time,
|
||||||
|
dns_resolution_status="match",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
app.selected_entry_index = 0
|
||||||
|
app.dns_service.resolve_entry_async = AsyncMock(
|
||||||
|
return_value=DNSResolution(
|
||||||
|
hostname="source.test",
|
||||||
|
resolved_ip="198.51.100.9",
|
||||||
|
status=DNSResolutionStatus.RESOLVED,
|
||||||
|
resolved_at=resolved_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.fail_saves(app)
|
||||||
|
workers = []
|
||||||
|
app.run_worker = Mock(
|
||||||
|
side_effect=lambda worker, **_kwargs: workers.append(worker)
|
||||||
|
)
|
||||||
|
|
||||||
|
app.action_refresh_dns()
|
||||||
|
await workers[0]
|
||||||
|
|
||||||
|
entry = app.hosts_file.entries[0]
|
||||||
|
assert entry.ip_address == "192.0.2.1"
|
||||||
|
assert entry.resolved_ip == "192.0.2.1"
|
||||||
|
assert entry.last_resolved == original_time
|
||||||
|
assert entry.dns_resolution_status == "match"
|
||||||
|
assert app.selected_entry_index == 0
|
||||||
|
app.update_status.assert_any_call(
|
||||||
|
"❌ DNS refresh save failed; previous state restored: Permission denied"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_single_dns_save_failure_restores_mapping_and_metadata(self):
|
||||||
|
"""A failed selected-entry DNS refresh restores every changed field."""
|
||||||
|
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
|
||||||
|
app = HostsManagerApp()
|
||||||
|
app.edit_mode = True
|
||||||
|
app.manager.edit_mode = True
|
||||||
|
original_time = datetime(2026, 9, 3, 8, 0)
|
||||||
|
app.hosts_file = HostsFile(
|
||||||
|
entries=[
|
||||||
|
HostEntry(
|
||||||
|
ip_address="192.0.2.1",
|
||||||
|
hostnames=["one.test"],
|
||||||
|
dns_name="source.test",
|
||||||
|
resolved_ip="192.0.2.1",
|
||||||
|
last_resolved=original_time,
|
||||||
|
dns_resolution_status="match",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
app.dns_service.resolve_entry_async = AsyncMock(
|
||||||
|
return_value=DNSResolution(
|
||||||
|
hostname="source.test",
|
||||||
|
resolved_ip="198.51.100.9",
|
||||||
|
status=DNSResolutionStatus.RESOLVED,
|
||||||
|
resolved_at=datetime(2026, 9, 4, 11, 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.fail_saves(app)
|
||||||
|
workers = []
|
||||||
|
app.run_worker = Mock(
|
||||||
|
side_effect=lambda worker, **_kwargs: workers.append(worker)
|
||||||
|
)
|
||||||
|
|
||||||
|
app.action_update_single_dns()
|
||||||
|
await workers[0]
|
||||||
|
|
||||||
|
entry = app.hosts_file.entries[0]
|
||||||
|
assert entry.ip_address == "192.0.2.1"
|
||||||
|
assert entry.resolved_ip == "192.0.2.1"
|
||||||
|
assert entry.last_resolved == original_time
|
||||||
|
assert entry.dns_resolution_status == "match"
|
||||||
|
app.update_status.assert_any_call(
|
||||||
|
"❌ DNS refresh save failed; previous state restored: Permission denied"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_single_dns_refresh_updates_selected_duplicate(self):
|
||||||
|
"""DNS refresh retains the selected entry identity across its await."""
|
||||||
|
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
|
||||||
|
app = HostsManagerApp()
|
||||||
|
app.edit_mode = True
|
||||||
|
app.manager.edit_mode = True
|
||||||
|
app.hosts_file = HostsFile(
|
||||||
|
entries=[
|
||||||
|
HostEntry(
|
||||||
|
ip_address="192.0.2.1",
|
||||||
|
hostnames=["same.test"],
|
||||||
|
dns_name="source.test",
|
||||||
|
),
|
||||||
|
HostEntry(
|
||||||
|
ip_address="192.0.2.2",
|
||||||
|
hostnames=["same.test"],
|
||||||
|
dns_name="source.test",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
app.selected_entry_index = 1
|
||||||
|
app.dns_service.resolve_entry_async = AsyncMock(
|
||||||
|
return_value=DNSResolution(
|
||||||
|
hostname="source.test",
|
||||||
|
resolved_ip="198.51.100.9",
|
||||||
|
status=DNSResolutionStatus.RESOLVED,
|
||||||
|
resolved_at=datetime(2026, 9, 4, 11, 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
app.manager.save_hosts_file = Mock(return_value=(True, "Saved"))
|
||||||
|
app.table_handler.populate_entries_table = Mock()
|
||||||
|
app.table_handler.restore_cursor_position = Mock()
|
||||||
|
app.details_handler.update_entry_details = Mock()
|
||||||
|
app.update_status = Mock()
|
||||||
|
workers = []
|
||||||
|
app.run_worker = Mock(
|
||||||
|
side_effect=lambda worker, **_kwargs: workers.append(worker)
|
||||||
|
)
|
||||||
|
|
||||||
|
app.action_update_single_dns()
|
||||||
|
await workers[0]
|
||||||
|
|
||||||
|
assert app.hosts_file.entries[0].ip_address == "192.0.2.1"
|
||||||
|
assert app.hosts_file.entries[1].ip_address == "198.51.100.9"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_new_entry_dns_save_failure_keeps_saved_placeholder(self):
|
||||||
|
"""Failed post-add DNS persistence keeps the already-saved DNS placeholder."""
|
||||||
|
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
|
||||||
|
app = HostsManagerApp()
|
||||||
|
app.edit_mode = True
|
||||||
|
app.manager.edit_mode = True
|
||||||
|
app.hosts_file = HostsFile()
|
||||||
|
entry = HostEntry(
|
||||||
|
ip_address="0.0.0.0",
|
||||||
|
hostnames=["one.test"],
|
||||||
|
is_active=False,
|
||||||
|
dns_name="source.test",
|
||||||
|
)
|
||||||
|
app.manager.execute_add_command(app.hosts_file, entry)
|
||||||
|
app.dns_service.resolve_entry_async = AsyncMock(
|
||||||
|
return_value=DNSResolution(
|
||||||
|
hostname="source.test",
|
||||||
|
resolved_ip="198.51.100.9",
|
||||||
|
status=DNSResolutionStatus.RESOLVED,
|
||||||
|
resolved_at=datetime(2026, 9, 4, 11, 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.fail_saves(app)
|
||||||
|
workers = []
|
||||||
|
app.run_worker = Mock(
|
||||||
|
side_effect=lambda worker, **_kwargs: workers.append(worker)
|
||||||
|
)
|
||||||
|
|
||||||
|
app._resolve_new_dns_entry(entry)
|
||||||
|
await workers[0]
|
||||||
|
|
||||||
|
restored = app.hosts_file.entries[0]
|
||||||
|
assert restored.ip_address == "0.0.0.0"
|
||||||
|
assert restored.resolved_ip is None
|
||||||
|
assert restored.last_resolved is None
|
||||||
|
assert restored.dns_resolution_status is None
|
||||||
|
assert restored.is_active is False
|
||||||
|
assert app.manager.can_undo()
|
||||||
|
app.update_status.assert_any_call(
|
||||||
|
"❌ DNS activation save failed; previous state restored: Permission denied"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_main_function(self):
|
def test_main_function(self):
|
||||||
|
|
|
||||||
|
|
@ -556,6 +556,28 @@ class TestHostsManager:
|
||||||
assert not success
|
assert not success
|
||||||
assert "No sudo permissions" in message
|
assert "No sudo permissions" in message
|
||||||
|
|
||||||
|
def test_save_mutation_failure_restores_hosts_file_and_history(self):
|
||||||
|
"""The manager owns rollback of model and undo/redo state."""
|
||||||
|
manager = HostsManager()
|
||||||
|
manager.edit_mode = True
|
||||||
|
hosts_file = HostsFile(
|
||||||
|
entries=[HostEntry("192.0.2.1", ["one.test"], is_active=True)]
|
||||||
|
)
|
||||||
|
manager.execute_toggle_command(hosts_file, 0)
|
||||||
|
manager.undo_last_operation(hosts_file)
|
||||||
|
state = manager.capture_mutation_state(hosts_file)
|
||||||
|
|
||||||
|
manager.execute_toggle_command(hosts_file, 0)
|
||||||
|
manager.save_hosts_file = Mock(return_value=(False, "Permission denied"))
|
||||||
|
|
||||||
|
success, message, restored_hosts_file = manager.save_mutation(hosts_file, state)
|
||||||
|
|
||||||
|
assert success is False
|
||||||
|
assert message == "Permission denied"
|
||||||
|
assert restored_hosts_file.entries[0].is_active is True
|
||||||
|
assert not manager.can_undo()
|
||||||
|
assert manager.can_redo()
|
||||||
|
|
||||||
@patch("subprocess.run")
|
@patch("subprocess.run")
|
||||||
def test_restore_backup_success(self, mock_run):
|
def test_restore_backup_success(self, mock_run):
|
||||||
"""Test restoring backup successfully."""
|
"""Test restoring backup successfully."""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue