feat: Add sudo authentication screen and update authorization flow

- Introduced a new module for terminal presentation during sudo authentication.
- Updated the application to display a temporary authentication notice.
- Enhanced tests to cover the new sudo authentication flow and its edge cases.
This commit is contained in:
Philip Henning 2026-09-04 20:02:46 +02:00
parent 0b7b52521f
commit d5a143ee36
5 changed files with 206 additions and 15 deletions

View file

@ -1,6 +1,9 @@
"""Tests for application-level authorization flow."""
from contextlib import contextmanager
from io import StringIO
import os
import signal
from unittest.mock import Mock, patch
import pytest
@ -13,6 +16,10 @@ from src.hosts.tui.app import HostsManagerApp
from src.hosts.tui.custom_footer import CustomFooter
from src.hosts.tui.filter_modal import FilterModal
from src.hosts.tui.help_modal import HelpModal
from src.hosts.tui.privilege_prompt import (
render_sudo_authentication_notice,
sudo_authentication_screen,
)
@contextmanager
@ -242,6 +249,53 @@ async def test_small_viewport_blocks_hidden_mutation_shortcuts():
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 = []
@ -252,13 +306,26 @@ class TestPrivilegedModeAuthorization:
yield
events.append("restored")
def authorize_interactively():
@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", "authorized", "restored"]
assert events == [
"suspended",
"notice shown",
"authorized",
"notice dismissed",
"restored",
]
events.append("edit mode enabled")
return True, "Edit mode enabled"
@ -273,14 +340,24 @@ class TestPrivilegedModeAuthorization:
app.suspend = Mock(return_value=suspended_tui())
app.update_status = Mock()
app.action_toggle_edit_mode()
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", "authorized", "restored", "edit mode enabled"]
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):
@ -333,6 +410,34 @@ class TestPrivilegedModeAuthorization:
"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()