Delegate privileged authentication to sudo PAM
This commit is contained in:
parent
7872991e0b
commit
026bbc4eff
8 changed files with 266 additions and 236 deletions
127
tests/test_app.py
Normal file
127
tests/test_app.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"""Tests for application-level authorization flow."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import Mock
|
||||
|
||||
from textual.app import SuspendNotSupported
|
||||
|
||||
from src.hosts.tui.app import HostsManagerApp
|
||||
|
||||
|
||||
@contextmanager
|
||||
def suspended_tui():
|
||||
"""Stand in for Textual's terminal suspension in action tests."""
|
||||
yield
|
||||
|
||||
|
||||
class TestPrivilegedModeAuthorization:
|
||||
"""Test the user-visible privileged-mode authorization flow."""
|
||||
|
||||
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")
|
||||
|
||||
def authorize_interactively():
|
||||
assert events == ["suspended"]
|
||||
events.append("authorized")
|
||||
return True, "Sudo access granted"
|
||||
|
||||
def finish_entering_edit_mode():
|
||||
assert events == ["suspended", "authorized", "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()
|
||||
|
||||
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"]
|
||||
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_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."
|
||||
)
|
||||
|
|
@ -24,9 +24,8 @@ class TestPermissionManager:
|
|||
assert not pm._sudo_validated
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_request_sudo_already_available(self, mock_run):
|
||||
"""Test requesting sudo when already available."""
|
||||
# Mock successful sudo -n true
|
||||
def test_request_sudo_uses_cached_authorization(self, mock_run):
|
||||
"""Test cached sudo authorization enters without an interaction."""
|
||||
mock_run.return_value = Mock(returncode=0)
|
||||
|
||||
pm = PermissionManager()
|
||||
|
|
@ -38,56 +37,62 @@ class TestPermissionManager:
|
|||
assert pm._sudo_validated
|
||||
|
||||
mock_run.assert_called_once_with(
|
||||
["sudo", "-n", "true"], capture_output=True, text=True, timeout=5
|
||||
["sudo", "-n", "-v"], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_request_sudo_prompt_success(self, mock_run):
|
||||
"""Test requesting sudo with password prompt success."""
|
||||
# First call (sudo -n true) fails, second call (sudo -S -v) succeeds
|
||||
mock_run.side_effect = [
|
||||
Mock(returncode=1), # sudo -n true fails
|
||||
Mock(returncode=0), # sudo -S -v succeeds
|
||||
]
|
||||
def test_request_sudo_interactively_uses_foreground_pam(self, mock_run):
|
||||
"""Test an uncached authorization delegates to foreground sudo."""
|
||||
mock_run.return_value = Mock(returncode=0)
|
||||
|
||||
pm = PermissionManager()
|
||||
success, message = pm.request_sudo("testpassword")
|
||||
success, message = pm.request_sudo(interactive=True)
|
||||
|
||||
assert success
|
||||
assert "access granted" in message
|
||||
assert pm.has_sudo
|
||||
assert pm._sudo_validated
|
||||
|
||||
assert mock_run.call_count == 2
|
||||
mock_run.assert_called_once_with(["sudo", "-v"])
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_request_sudo_no_password(self, mock_run):
|
||||
"""Test requesting sudo when no password is provided."""
|
||||
# sudo -n true fails (password needed)
|
||||
def test_request_sudo_requires_interaction_when_cache_unavailable(self, mock_run):
|
||||
"""Test an uncached authorization does not start PAM from the TUI."""
|
||||
mock_run.return_value = Mock(returncode=1)
|
||||
|
||||
pm = PermissionManager()
|
||||
success, message = pm.request_sudo()
|
||||
|
||||
assert not success
|
||||
assert "Password required" in message
|
||||
assert message == "Interactive authorization required"
|
||||
assert not pm.has_sudo
|
||||
assert not pm._sudo_validated
|
||||
mock_run.assert_called_once_with(
|
||||
["sudo", "-n", "-v"], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_request_sudo_interactively_denied(self, mock_run):
|
||||
"""Test a rejected PAM conversation keeps sudo unavailable."""
|
||||
mock_run.return_value = Mock(returncode=1)
|
||||
|
||||
pm = PermissionManager()
|
||||
success, message = pm.request_sudo(interactive=True)
|
||||
|
||||
assert not success
|
||||
assert message == "Authorization was not granted"
|
||||
assert not pm.has_sudo
|
||||
assert not pm._sudo_validated
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_request_sudo_denied(self, mock_run):
|
||||
"""Test requesting sudo when access is denied."""
|
||||
# Both calls fail
|
||||
mock_run.side_effect = [
|
||||
Mock(returncode=1), # sudo -n true fails
|
||||
Mock(returncode=1, stderr="access denied"), # sudo -S -v fails
|
||||
]
|
||||
def test_request_sudo_interactively_cancelled(self, mock_run):
|
||||
"""Test cancellation during PAM authorization keeps sudo unavailable."""
|
||||
mock_run.return_value = Mock(returncode=130)
|
||||
|
||||
pm = PermissionManager()
|
||||
success, message = pm.request_sudo("testpassword")
|
||||
success, message = pm.request_sudo(interactive=True)
|
||||
|
||||
assert not success
|
||||
assert "denied" in message
|
||||
assert message == "Authorization was not granted"
|
||||
assert not pm.has_sudo
|
||||
assert not pm._sudo_validated
|
||||
|
||||
|
|
@ -115,6 +120,18 @@ class TestPermissionManager:
|
|||
assert "Test error" in message
|
||||
assert not pm.has_sudo
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_request_sudo_reports_when_sudo_is_unavailable(self, mock_run):
|
||||
"""Test a missing sudo executable produces an actionable result."""
|
||||
mock_run.side_effect = FileNotFoundError()
|
||||
|
||||
pm = PermissionManager()
|
||||
success, message = pm.request_sudo()
|
||||
|
||||
assert not success
|
||||
assert message == "sudo is unavailable; remaining in Read-only Mode"
|
||||
assert not pm.has_sudo
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_validate_permissions_success(self, mock_run):
|
||||
"""Test validating permissions successfully."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue