"""Tests for application-level authorization flow.""" from contextlib import contextmanager import asyncio from io import StringIO import os import signal from unittest.mock import Mock, patch import pytest from rich.cells import cell_len from textual.app import SuspendNotSupported from textual.widgets import Button, Input, RadioButton, Static from src.hosts.core.filters import FilterOptions from src.hosts.core.models import HostEntry, HostsFile from src.hosts.tui.app import HostsManagerApp from src.hosts.tui.custom_footer import CustomFooter from src.hosts.tui.backup_restore_modal import BackupRestoreModal from src.hosts.tui.filter_modal import FilterModal from src.hosts.tui.help_modal import HelpModal from src.hosts.tui.add_entry_modal import AddEntryModal from src.hosts.tui.privilege_prompt import ( render_sudo_authentication_notice, sudo_authentication_screen, ) @contextmanager def suspended_tui(): """Stand in for Textual's terminal suspension in action tests.""" yield def app_with_filterable_entries() -> HostsManagerApp: """Create an app whose filter workflow cannot access the system Hosts File.""" app = HostsManagerApp() app.hosts_file = HostsFile( entries=[ HostEntry(ip_address="192.0.2.1", hostnames=["active.test"]), HostEntry( ip_address="192.0.2.2", hostnames=["inactive.test"], is_active=False, ), ] ) app.load_hosts_file = Mock() return app @pytest.mark.asyncio async def test_filter_shortcut_opens_modal_and_applies_the_selected_filter(): """The filter shortcut opens a modal whose Apply action changes the view state.""" app = app_with_filterable_entries() async with app.run_test() as pilot: footer = app.query_one("#custom-footer", CustomFooter) footer_text = footer.query_one("#footer-right", Static).render() assert "ctrl+f Filters" in str(footer_text) app.action_help() await pilot.pause() assert isinstance(app.screen, HelpModal) assert app.screen.query_one("#help-close") is not None await pilot.press("escape") await pilot.pause() await pilot.press("ctrl+f") assert isinstance(app.screen, FilterModal) app.screen.query_one("#status-active", RadioButton).value = True await pilot.pause() await pilot.click("#apply") await pilot.pause() assert app.current_filter_options.active_only assert not app.current_filter_options.show_inactive assert [entry.hostnames[0] for entry in app.get_visible_entries()] == [ "active.test" ] @pytest.mark.asyncio async def test_filter_shortcut_does_not_stack_filter_modals(): """Repeated filter shortcuts keep the existing filter modal in focus.""" app = app_with_filterable_entries() async with app.run_test() as pilot: await pilot.press("ctrl+f") await pilot.pause() await pilot.press("ctrl+f") await pilot.press("ctrl+f") await pilot.pause() assert isinstance(app.screen, FilterModal) assert sum(isinstance(screen, FilterModal) for screen in app.screen_stack) == 1 @pytest.mark.asyncio async def test_new_from_selected_prefills_the_highlighted_visible_entry(): """Shift+N snapshots the highlighted visible Host Entry into the Add form.""" app = app_with_filterable_entries() source = app.hosts_file.entries[1] source.hostnames = ["inactive.test", "alias.test"] source.comment = "Copied comment" source.is_active = False app.edit_mode = True app.sort_column = "hostname" async with app.run_test(size=(120, 40)) as pilot: app.table_handler.populate_entries_table() app.table_handler.move_cursor_to_entry_index(1) await pilot.pause() await pilot.press("shift+n") await pilot.pause() assert isinstance(app.screen, AddEntryModal) assert app.screen.query_one("#hostnames-input", Input).value == ( "inactive.test, alias.test" ) assert app.screen.query_one("#ip-address-input", Input).value == "192.0.2.2" assert app.screen.query_one("#comment-input", Input).value == "Copied comment" assert not app.screen.query_one("#active-checkbox").value assert "Based on: inactive.test" in str( app.screen.query_one("#entry-source", Static).render() ) assert app.focused is app.screen.query_one("#hostnames-input", Input) assert source.hostnames == ["inactive.test", "alias.test"] assert source.comment == "Copied comment" assert not source.is_active @pytest.mark.asyncio async def test_new_from_selected_requires_a_visible_host_entry(): """Shift+N does not reuse a stale selection when filters hide every entry.""" app = app_with_filterable_entries() app.edit_mode = True app.current_filter_options.search_term = "not-a-match" async with app.run_test(size=(120, 40)) as pilot: app.table_handler.populate_entries_table() await pilot.press("shift+n") await pilot.pause() assert not isinstance(app.screen, AddEntryModal) assert "No Host Entry selected; use n to add a blank Host Entry." in str( app.query_one("#message-rail", Static).render() ) @pytest.mark.asyncio async def test_new_from_selected_is_available_at_the_minimum_viewport(): """Shift+N keeps the selected-entry add workflow reachable at 100×30.""" app = app_with_filterable_entries() app.edit_mode = True async with app.run_test(size=(100, 30)) as pilot: app.table_handler.populate_entries_table() await pilot.press("shift+n") await pilot.pause() assert isinstance(app.screen, AddEntryModal) assert app.focused is app.screen.query_one("#hostnames-input", Input) await pilot.press("escape") await pilot.pause() assert not isinstance(app.screen, AddEntryModal) @pytest.mark.asyncio async def test_help_overlay_closes_to_the_control_that_opened_it(): """Help is an overlay and returns keyboard focus to its opener.""" app = app_with_filterable_entries() async with app.run_test(size=(120, 40)) as pilot: search = app.query_one("#search-input", Input) search.focus() app.action_help() await pilot.pause() assert isinstance(app.screen, HelpModal) await pilot.press("escape") await pilot.pause() assert app.focused is search @pytest.mark.asyncio async def test_filter_modal_tabs_through_controls_in_visual_order(): """The modal starts at Presets and tabs through the visible controls in order.""" app = app_with_filterable_entries() async with app.run_test() as pilot: await pilot.press("ctrl+f") await pilot.pause() assert app.focused is app.screen.query_one("#preset-select") for expected_id in ( "load-preset", "save-preset", "delete-preset", "status-filter-type", "type-filter-type", "resolution-filter-type", "search-term", "search-hostnames", "search-comments", "search-ips", "search-case-sensitive", "cancel", "reset", "apply", ): await pilot.press("tab") assert app.focused is app.screen.query_one(f"#{expected_id}") @pytest.mark.asyncio async def test_filter_reset_clears_filters_and_cancel_preserves_applied_filters(): """Reset clears the form on Apply, while Cancel leaves the applied filter alone.""" app = app_with_filterable_entries() app.current_filter_options = FilterOptions( active_only=True, show_inactive=False, ) async with app.run_test() as pilot: await pilot.press("ctrl+f") await pilot.click("#reset") await pilot.click("#apply") await pilot.pause() assert app.current_filter_options.is_empty() await pilot.press("ctrl+f") assert isinstance(app.screen, FilterModal) app.screen.query_one("#status-active", RadioButton).value = True await pilot.pause() await pilot.press("escape") await pilot.pause() assert app.current_filter_options.is_empty() assert [entry.hostnames[0] for entry in app.get_visible_entries()] == [ "active.test", "inactive.test", ] @pytest.mark.asyncio async def test_workspace_uses_read_only_detail_rows_and_hides_irrelevant_dns_fields(): """An IP Host Entry has compact text details instead of disabled controls.""" app = app_with_filterable_entries() async with app.run_test(size=(120, 40)) as pilot: app.table_handler.populate_entries_table() app.details_handler.update_entry_details() await pilot.pause() details = app.query_one("#entry-details-display") assert not list(details.query(Input)) assert "192.0.2.1" in str(app.query_one("#details-ip-input", Static).render()) assert "Active" in str( app.query_one("#details-active-checkbox", Static).render() ) assert app.query_one("#details-dns-rows").has_class("hidden") @pytest.mark.asyncio async def test_workspace_exposes_dns_and_protection_details_and_durable_state(): """DNS and Default Entry states remain explicit independently of colour.""" app = app_with_filterable_entries() app.hosts_file.entries[0].dns_name = "active.example" app.hosts_file.entries[0].dns_resolution_status = "resolved" app.hosts_file.entries[0].resolved_ip = "192.0.2.1" app.current_filter_options.active_only = True app.current_filter_options.show_inactive = False async with app.run_test(size=(100, 30)) as pilot: app.table_handler.populate_entries_table() app.details_handler.update_entry_details() app._update_footer_status() await pilot.pause() assert not app.query_one("#details-dns-rows").has_class("hidden") assert "Resolved" in str( app.query_one("#details-dns-status-input", Static).render() ) footer = app.query_one("#custom-footer", CustomFooter) assert "Filters: 1" in footer._status_text assert "READ-ONLY" in footer._status_text assert app.has_class("compact-layout") @pytest.mark.asyncio async def test_small_viewport_replaces_workspace_with_an_explicit_safe_message(): """A viewport below the contract cannot expose clipped mutating controls.""" app = app_with_filterable_entries() async with app.run_test(size=(80, 24)) as pilot: await pilot.pause() assert not app.query_one("#workspace").display minimum_size = app.query_one("#minimum-size", Static) assert "requires at least 100 columns × 30 rows" in str(minimum_size.render()) @pytest.mark.asyncio async def test_small_viewport_blocks_hidden_mutation_shortcuts(): """A minimum-size presentation cannot open a hidden editor or privilege flow.""" app = app_with_filterable_entries() app.push_screen = Mock() app.manager = Mock() async with app.run_test(size=(80, 24)) as pilot: await pilot.press("n", "ctrl+e") await pilot.pause() app.push_screen.assert_not_called() app.manager.enter_edit_mode.assert_not_called() @pytest.mark.asyncio async def test_restore_opens_a_side_by_side_preview_with_exact_paths(tmp_path): """The recovery workflow makes its before-and-after files reviewable.""" app = app_with_filterable_entries() backup_path = tmp_path / "hosts.backup" hosts_file_path = tmp_path / "hosts" backup_path.write_text("127.0.0.1 restored.test\n") hosts_file_path.write_text("127.0.0.1 current.test\n") app.edit_mode = True app.manager = Mock() app.manager.backup_path = backup_path app.manager.parser.file_path = hosts_file_path async with app.run_test(size=(120, 40)) as pilot: app.action_restore_backup() await pilot.pause() await pilot.pause() assert isinstance(app.screen, BackupRestoreModal) assert str(backup_path) in str( app.screen.query_one("#backup-restored-path", Static).render() ) assert str(hosts_file_path) in str( app.screen.query_one("#backup-current-path", Static).render() ) diff = str(app.screen.query_one("#backup-diff", Static).render()) assert "current.test" in diff assert "restored.test" in diff @pytest.mark.asyncio async def test_restore_side_by_side_divider_uses_terminal_cell_widths(tmp_path): """Tabs in Hosts File lines cannot move the right-hand file between rows.""" app = app_with_filterable_entries() backup_path = tmp_path / "hosts.backup" hosts_file_path = tmp_path / "hosts" hosts_file_path.write_text( "127.0.0.1\tlocalhost\n" "255.255.255.255\tbroadcasthost\n" "::1\tlocalhost\n" "192.0.2.10\tcurrent.test\n" ) backup_path.write_text( "127.0.0.1\tlocalhost\n" "255.255.255.255\tbroadcasthost\n" "::1\tlocalhost\n" "192.0.2.10\trestored.test\n" ) app.edit_mode = True app.manager = Mock() app.manager.backup_path = backup_path app.manager.parser.file_path = hosts_file_path async with app.run_test(size=(160, 40)) as pilot: app.action_restore_backup() await pilot.pause() await pilot.pause() modal = app.screen assert isinstance(modal, BackupRestoreModal) lines = [ line for line in modal._render_diff().plain.splitlines() if " │ " in line ] divider_columns = { cell_len(line[: line.index("│")].expandtabs(8)) for line in lines } assert "\t" not in "\n".join(lines) assert len(divider_columns) == 1 @pytest.mark.asyncio async def test_restore_preview_disables_a_no_change_operation(tmp_path): """A no-op restore cannot perform an unnecessary privileged write.""" app = app_with_filterable_entries() backup_path = tmp_path / "hosts.backup" hosts_file_path = tmp_path / "hosts" contents = "127.0.0.1 localhost\n" backup_path.write_text(contents) hosts_file_path.write_text(contents) app.edit_mode = True app.manager = Mock() app.manager.backup_path = backup_path app.manager.parser.file_path = hosts_file_path async with app.run_test(size=(120, 40)) as pilot: app.action_restore_backup() await pilot.pause() await pilot.pause() modal = app.screen assert isinstance(modal, BackupRestoreModal) assert modal.query_one("#restore-button", Button).disabled assert "would make no changes" in str( modal.query_one("#backup-restore-status", Static).render() ) @pytest.mark.asyncio async def test_restore_preview_fills_width_and_routes_navigation_from_cancel(tmp_path): """The safe initial focus does not prevent full mode or two-axis scrolling.""" app = app_with_filterable_entries() backup_path = tmp_path / "hosts.backup" hosts_file_path = tmp_path / "hosts" current_lines = [ f"192.0.2.{index} host-{index}.test " + "x" * 120 for index in range(1, 81) ] restored_lines = current_lines.copy() restored_lines[39] = "192.0.2.40 restored.test " + "y" * 120 hosts_file_path.write_text("\n".join(current_lines) + "\n") backup_path.write_text("\n".join(restored_lines) + "\n") app.edit_mode = True app.manager = Mock() app.manager.backup_path = backup_path app.manager.parser.file_path = hosts_file_path async with app.run_test(size=(160, 40)) as pilot: app.action_restore_backup() await pilot.pause() await pilot.pause() modal = app.screen assert isinstance(modal, BackupRestoreModal) container = modal.query_one("#backup-restore-container") assert container.outer_size.width == 156 changed_diff = str(modal.query_one("#backup-diff", Static).render()) await pilot.press("f") await pilot.pause() full_diff = str(modal.query_one("#backup-diff", Static).render()) assert full_diff != changed_diff assert "host-1.test" in full_diff scroller = modal.query_one("#backup-diff-scroll") assert app.focused is modal.query_one("#cancel-button") await pilot.press("down", "right") await pilot.pause() assert scroller.scroll_y > 0 assert scroller.scroll_x > 0 class TestPrivilegedModeAuthorization: """Test the user-visible privileged-mode authorization flow.""" def test_sudo_notice_has_a_framed_and_a_narrow_terminal_variant(self): """The notice keeps its safety language when there is no room for a frame.""" framed = render_sudo_authentication_notice(width=80, color=False) narrow = render_sudo_authentication_notice(width=40, color=False) for notice in (framed, narrow): assert "PRIVILEGED MODE REQUESTED" in notice assert "sudo authentication required" in notice assert "does not collect or store your credentials" in notice assert "Ctrl+C to cancel and remain in Read-only Mode" in notice assert "+--------------------------------------------------+" in framed assert "+--------------------------------------------------+" not in narrow def test_sudo_notice_uses_the_alternate_screen_for_the_entire_conversation(self): """The shell viewport is restored after sudo/PAM is finished.""" output = StringIO() with sudo_authentication_screen(stream=output, width=80): output.write("sudo output\n") rendered = output.getvalue() assert rendered.startswith("\x1b[?1049h") assert "PRIVILEGED MODE REQUESTED" in rendered assert "sudo output" in rendered assert rendered.endswith("\x1b[0m\x1b[?1049l") def test_sudo_screen_turns_sigint_into_a_catchable_keyboard_interrupt(self): """Asyncio's SIGINT handler must not cancel the Textual main task.""" output = StringIO() previous_handler = signal.getsignal(signal.SIGINT) runner_handler_called = False def runner_handler(_signum, _frame): nonlocal runner_handler_called runner_handler_called = True signal.signal(signal.SIGINT, runner_handler) try: with pytest.raises(KeyboardInterrupt): with sudo_authentication_screen(stream=output, width=80): os.kill(os.getpid(), signal.SIGINT) finally: signal.signal(signal.SIGINT, previous_handler) assert not runner_handler_called def test_interactive_authorization_suspends_and_restores_the_tui(self): """Test foreground PAM authorization runs while Textual is suspended.""" events = [] @contextmanager def suspended_tui(): events.append("suspended") yield events.append("restored") @contextmanager def authentication_screen(): assert events == ["suspended"] events.append("notice shown") yield events.append("notice dismissed") def authorize_interactively(): assert events == ["suspended", "notice shown"] events.append("authorized") return True, "Sudo access granted" def finish_entering_edit_mode(): assert events == [ "suspended", "notice shown", "authorized", "notice dismissed", "restored", ] events.append("edit mode enabled") return True, "Edit mode enabled" app = HostsManagerApp() app.manager = Mock() app.manager.enter_edit_mode.return_value = ( False, "Interactive authorization required", ) app.manager.authorize_interactively.side_effect = authorize_interactively app.manager.finish_entering_edit_mode.side_effect = finish_entering_edit_mode app.suspend = Mock(return_value=suspended_tui()) app.update_status = Mock() with patch( "src.hosts.tui.app.sudo_authentication_screen", authentication_screen ): app.action_toggle_edit_mode() assert app.edit_mode app.suspend.assert_called_once_with() app.manager.enter_edit_mode.assert_called_once_with() app.manager.authorize_interactively.assert_called_once_with() app.manager.finish_entering_edit_mode.assert_called_once_with() assert events == [ "suspended", "notice shown", "authorized", "notice dismissed", "restored", "edit mode enabled", ] app.update_status.assert_called_once_with("Edit mode enabled") def test_rejected_authorization_keeps_the_app_read_only(self): """Test a rejected PAM conversation reports the required message.""" app = HostsManagerApp() app.manager = Mock() app.manager.enter_edit_mode.return_value = ( False, "Interactive authorization required", ) app.manager.authorize_interactively.return_value = ( False, "Authorization was not granted", ) app.suspend = Mock(return_value=suspended_tui()) app.update_status = Mock() app.action_toggle_edit_mode() assert not app.edit_mode app.update_status.assert_called_once_with( "Authorization was not granted; remaining in Read-only Mode." ) def test_interrupted_authorization_keeps_the_app_read_only(self): """Test interruption during PAM authorization restores read-only mode.""" events = [] @contextmanager def suspended_tui(): events.append("suspended") yield events.append("restored") app = HostsManagerApp() app.manager = Mock() app.manager.enter_edit_mode.return_value = ( False, "Interactive authorization required", ) app.manager.authorize_interactively.side_effect = KeyboardInterrupt() app.suspend = Mock(return_value=suspended_tui()) app.update_status = Mock() app.action_toggle_edit_mode() assert not app.edit_mode assert events == ["suspended", "restored"] app.update_status.assert_called_once_with( "Authorization was not granted; remaining in Read-only Mode." ) def test_interrupt_while_dismissing_the_sudo_notice_keeps_the_app_running(self): """An interrupt during terminal cleanup must not escape and quit the app.""" app = HostsManagerApp() app.manager = Mock() app.manager.enter_edit_mode.return_value = ( False, "Interactive authorization required", ) app.manager.authorize_interactively.return_value = ( False, "Authorization was not granted", ) app.suspend = Mock(return_value=suspended_tui()) app.update_status = Mock() @contextmanager def interrupted_notice(): yield raise KeyboardInterrupt with patch("src.hosts.tui.app.sudo_authentication_screen", interrupted_notice): app.action_toggle_edit_mode() assert not app.edit_mode app.update_status.assert_called_once_with( "Authorization was not granted; remaining in Read-only Mode." ) def test_unsupported_suspension_keeps_the_app_read_only(self): """Test the user receives an actionable suspension failure message.""" app = HostsManagerApp() app.manager = Mock() app.manager.enter_edit_mode.return_value = ( False, "Interactive authorization required", ) app.suspend = Mock(side_effect=SuspendNotSupported()) app.update_status = Mock() app.action_toggle_edit_mode() assert not app.edit_mode app.update_status.assert_called_once_with( "Interactive authorization requires terminal suspension; remaining in Read-only Mode." ) @patch("src.hosts.core.manager.subprocess.run") def test_leaving_privileged_mode_preserves_the_sudo_timestamp(self, mock_run): """Test Ctrl+E clears app state without invalidating sudo globally.""" app = HostsManagerApp() app.edit_mode = True app.manager.edit_mode = True app.manager.permission_manager.has_sudo = True app.manager.permission_manager._sudo_validated = True app.update_status = Mock() app.action_toggle_edit_mode() assert not app.edit_mode assert not app.manager.edit_mode assert not app.manager.permission_manager.has_sudo assert not app.manager.permission_manager._sudo_validated mock_run.assert_not_called() app.update_status.assert_called_once_with("Edit mode disabled") class TestPreEditBackupRestoration: """Test the user-reachable Pre-edit Backup recovery flow.""" def test_restore_action_confirms_paths_then_reloads_after_success(self, tmp_path): app = HostsManagerApp() app.edit_mode = True app.manager = Mock() backup_path = tmp_path / "hosts.backup.123" hosts_file_path = tmp_path / "hosts" backup_path.write_text("127.0.0.1 restored.test\n") hosts_file_path.write_text("127.0.0.1 current.test\n") app.manager.backup_path = backup_path app.manager.parser.file_path = hosts_file_path app.manager.restore_backup.return_value = (True, "Backup restored successfully") app.push_screen = Mock() app.load_hosts_file = Mock() app.update_status = Mock() app.run_worker = Mock(side_effect=lambda coroutine, **_: asyncio.run(coroutine)) app.action_restore_backup() modal, callback = app.push_screen.call_args.args assert modal.preview.backup_path == backup_path assert modal.preview.hosts_file_path == hosts_file_path callback(True) app.manager.restore_backup.assert_called_once_with( modal.preview.backup_digest, modal.preview.hosts_file_digest ) app.load_hosts_file.assert_called_once_with() assert app.update_status.call_args.args == ( f"Pre-edit Backup restored from {backup_path} to {hosts_file_path}", ) def test_failed_restore_keeps_interface_state_and_reports_paths(self, tmp_path): app = HostsManagerApp() app.edit_mode = True app.manager = Mock() backup_path = tmp_path / "hosts.backup.123" hosts_file_path = tmp_path / "hosts" backup_path.write_text("127.0.0.1 restored.test\n") hosts_file_path.write_text("127.0.0.1 current.test\n") app.manager.backup_path = backup_path app.manager.parser.file_path = hosts_file_path app.manager.restore_backup.return_value = (False, "Permission denied") app.push_screen = Mock() app.load_hosts_file = Mock() app.update_status = Mock() app.run_worker = Mock(side_effect=lambda coroutine, **_: asyncio.run(coroutine)) app.action_restore_backup() _, callback = app.push_screen.call_args.args callback(True) app.load_hosts_file.assert_not_called() assert app.update_status.call_args.args == ( f"Failed to restore Pre-edit Backup from {backup_path} to {hosts_file_path}: Permission denied. " "The Hosts File and interface are unchanged. Check write access and try again.", ) def test_restore_is_unavailable_while_the_entry_editor_has_unsaved_state(self): app = HostsManagerApp() app.entry_edit_mode = True app.manager = Mock() app.push_screen = Mock() app.update_status = Mock() app.action_restore_backup() app.push_screen.assert_not_called() app.manager.restore_backup.assert_not_called() app.update_status.assert_called_once_with( "Finish or cancel the Entry Editor before restoring the Pre-edit Backup." )