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:
parent
0b7b52521f
commit
d5a143ee36
5 changed files with 206 additions and 15 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue