diff --git a/.gitguardian.yaml b/.gitguardian.yaml new file mode 100644 index 0000000..e81116e --- /dev/null +++ b/.gitguardian.yaml @@ -0,0 +1,6 @@ +version: 2 +ignore-paths: + - tests/ +ignored-detectors: + - name: "Generic Password" + - name: "Generic High Entropy Secret" diff --git a/src/portkeydrop/dialogs/site_manager.py b/src/portkeydrop/dialogs/site_manager.py index c0f5aed..e39f723 100644 --- a/src/portkeydrop/dialogs/site_manager.py +++ b/src/portkeydrop/dialogs/site_manager.py @@ -70,7 +70,15 @@ def _build_ui(self) -> None: ctrl.SetName(ctrl_name) setattr(self, attr_name, ctrl) grid.Add(lbl, 0, wx.ALIGN_CENTER_VERTICAL) - if attr_name == "key_path_text": + if attr_name == "password_text": + row = wx.BoxSizer(wx.HORIZONTAL) + row.Add(ctrl, 1, wx.EXPAND) + self.show_password_btn = wx.Button(self, label="S&how") + self.show_password_btn.SetName("Show password") + self.show_password_btn.Bind(wx.EVT_BUTTON, self._on_toggle_password) + row.Add(self.show_password_btn, 0, wx.LEFT, 4) + grid.Add(row, 1, wx.EXPAND) + elif attr_name == "key_path_text": row = wx.BoxSizer(wx.HORIZONTAL) row.Add(ctrl, 1, wx.EXPAND) browse_btn = wx.Button(self, label="&Browse...") @@ -146,6 +154,26 @@ def _on_remove(self, event: wx.CommandEvent) -> None: self._selected_site = None self._refresh_site_list() + def _on_toggle_password(self, event: wx.CommandEvent) -> None: + """Toggle password field between masked and plain text.""" + current_value = self.password_text.GetValue() + is_masked = bool(self.password_text.GetWindowStyle() & wx.TE_PASSWORD) + sizer_item = self.password_text.GetContainingSizer() + new_style = 0 if is_masked else wx.TE_PASSWORD + new_ctrl = wx.TextCtrl(self, style=new_style) + new_ctrl.SetName("Password") + new_ctrl.SetValue(current_value) + + if sizer_item: + sizer_item.Replace(self.password_text, new_ctrl) + + self.password_text.Destroy() + self.password_text = new_ctrl + self.show_password_btn.SetLabel("H&ide" if is_masked else "S&how") + self.show_password_btn.SetName("Hide password" if is_masked else "Show password") + self.Layout() + new_ctrl.SetFocus() + def _on_save(self, event: wx.CommandEvent) -> None: if not self._selected_site: return diff --git a/tests/test_site_manager_dialog.py b/tests/test_site_manager_dialog.py new file mode 100644 index 0000000..1649b91 --- /dev/null +++ b/tests/test_site_manager_dialog.py @@ -0,0 +1,279 @@ +"""Tests for SiteManagerDialog, including show/hide password toggle.""" + +from __future__ import annotations + +import importlib +import sys +import types +from unittest.mock import MagicMock +import pytest + + +# --------------------------------------------------------------------------- +# Minimal wx stub for SiteManagerDialog +# --------------------------------------------------------------------------- + + +class _Window: + def __init__(self, parent=None, **_kw): + pass + + def Bind(self, *a, **kw): + pass + + def SetName(self, *a): + pass + + def SetSizer(self, *a): + pass + + def Layout(self): + pass + + def SetFocus(self): + pass + + def Destroy(self): + pass + + def GetContainingSizer(self): + return _Sizer() + + def GetWindowStyle(self): + return 0 + + def GetValue(self): + return "" + + def SetValue(self, v): + self._value = v + + def SetLabel(self, v): + pass + + +class _Dialog(_Window): + def __init__(self, parent=None, title="", size=None, style=0, **_kw): + pass + + def EndModal(self, r): + pass + + +class _Sizer: + def GetItem(self, ctrl): + return self + + def Replace(self, old, new): + pass + + def Add(self, *a, **kw): + pass + + +class _TextCtrl(_Window): + def __init__(self, parent=None, style=0, **_kw): + self._style = style + self._value = "" + + def GetWindowStyle(self): + return self._style + + def GetValue(self): + return self._value + + def SetValue(self, v): + self._value = v + + def GetContainingSizer(self): + return _Sizer() + + def Destroy(self): + pass + + +class _Button(_Window): + def __init__(self, parent=None, label="", **_kw): + self._label = label + self._name = "" + + def SetLabel(self, v): + self._label = v + + def SetName(self, v): + self._name = v + + +class _Choice(_Window): + def __init__(self, *a, choices=None, **kw): + self._choices = choices or [] + + def SetSelection(self, i): + pass + + def GetStringSelection(self): + return self._choices[0] if self._choices else "" + + +class _StaticText(_Window): + pass + + +class _FlexGridSizer(_Sizer): + def __init__(self, *a, **kw): + pass + + def AddGrowableCol(self, *a): + pass + + def Add(self, *a, **kw): + pass + + +class _BoxSizer(_Sizer): + def __init__(self, *a, **kw): + pass + + def Add(self, *a, **kw): + pass + + +class _ListBox(_Window): + def __init__(self, *a, **kw): + self._items = [] + + def Clear(self): + self._items = [] + + def Append(self, label, data=None): + self._items.append((label, data)) + + def GetSelection(self): + return -1 + + def GetClientData(self, i): + return None + + def Bind(self, *a, **kw): + pass + + +def _make_fake_wx(): + wx = types.ModuleType("wx") + wx.Dialog = _Dialog + wx.Frame = _Window + wx.Panel = _Window + wx.TextCtrl = _TextCtrl + wx.Button = _Button + wx.Choice = _Choice + wx.StaticText = _StaticText + wx.FlexGridSizer = _FlexGridSizer + wx.BoxSizer = _BoxSizer + wx.ListBox = _ListBox + wx.StaticBox = _Window + wx.StaticBoxSizer = _BoxSizer + wx.FileDialog = _Dialog + wx.OK = 5100 + wx.ID_OK = 5100 + wx.CANCEL = 5101 + wx.ALIGN_CENTER_VERTICAL = 1 + wx.ALIGN_RIGHT = 2 + wx.LEFT = 4 + wx.RIGHT = 8 + wx.TOP = 16 + wx.BOTTOM = 32 + wx.EXPAND = 64 + wx.ALL = 128 + wx.VERTICAL = 256 + wx.HORIZONTAL = 512 + wx.TE_PASSWORD = 64 + wx.DEFAULT_DIALOG_STYLE = 128 + wx.RESIZE_BORDER = 256 + wx.FD_OPEN = 512 + wx.FD_FILE_MUST_EXIST = 1024 + wx.NOT_FOUND = -1 + wx.EVT_BUTTON = object() + wx.EVT_LISTBOX = object() + wx.MessageBox = MagicMock(return_value=wx.OK) + return wx + + +@pytest.fixture +def dialog_module(monkeypatch): + fake_wx = _make_fake_wx() + monkeypatch.setitem(sys.modules, "wx", fake_wx) + sys.modules.pop("portkeydrop.dialogs.site_manager", None) + mod = importlib.import_module("portkeydrop.dialogs.site_manager") + return mod, fake_wx + + +def _make_dialog(mod, fake_wx, masked=True): + site_manager = MagicMock() + site_manager.sites = [] + dlg = object.__new__(mod.SiteManagerDialog) + dlg._site_manager = site_manager + dlg._selected_site = None + dlg._connect_requested = False + + # Password ctrl + pw = _TextCtrl(style=fake_wx.TE_PASSWORD if masked else 0) + pw.SetValue("secret123") + dlg.password_text = pw + + dlg.show_password_btn = _Button() + dlg.Layout = MagicMock() + return dlg + + +class TestTogglePassword: + def test_show_reveals_password(self, dialog_module): + mod, fake_wx = dialog_module + dlg = _make_dialog(mod, fake_wx, masked=True) + + mod.SiteManagerDialog._on_toggle_password(dlg, MagicMock()) + + # New ctrl is plain text (no TE_PASSWORD) + assert dlg.password_text.GetWindowStyle() == 0 + # Value preserved + assert dlg.password_text.GetValue() == "secret123" + # Button updated + assert dlg.show_password_btn._label == "H&ide" + assert dlg.show_password_btn._name == "Hide password" + dlg.Layout.assert_called_once() + + def test_hide_masks_password(self, dialog_module): + mod, fake_wx = dialog_module + dlg = _make_dialog(mod, fake_wx, masked=False) + dlg.password_text.SetValue("secret123") + + mod.SiteManagerDialog._on_toggle_password(dlg, MagicMock()) + + assert dlg.password_text.GetWindowStyle() == fake_wx.TE_PASSWORD + assert dlg.show_password_btn._label == "S&how" + assert dlg.show_password_btn._name == "Show password" + + def test_value_preserved_on_toggle(self, dialog_module): + mod, fake_wx = dialog_module + dlg = _make_dialog(mod, fake_wx, masked=True) + dlg.password_text.SetValue("mypassword") + + mod.SiteManagerDialog._on_toggle_password(dlg, MagicMock()) + + assert dlg.password_text.GetValue() == "mypassword" + + +class TestSiteManagerDialogInit: + def test_init_creates_show_password_btn(self, dialog_module): + """__init__ should wire up the Show password button.""" + from portkeydrop.sites import SiteManager + + mod, fake_wx = dialog_module + site_manager = MagicMock(spec=SiteManager) + site_manager.sites = [] + + dlg = mod.SiteManagerDialog(None, site_manager) + + assert hasattr(dlg, "show_password_btn") + assert dlg.show_password_btn._label == "S&how" + assert dlg.show_password_btn._name == "Show password" + assert hasattr(dlg, "password_text")