Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 76 additions & 16 deletions src/shortcircuit/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,56 @@ class StateMapper(TypedDict):
TYPE_EVESCOUT: "Eve Scout",
}

MAPPER_TYPE_DESCRIPTIONS = {
TYPE_TRIPWIRE: "Corp/alliance scanning map — needs an account on a Tripwire server",
TYPE_EVESCOUT: "Public Thera and Turnur connections — no account needed",
}


class AddMapperTypeDialog(QtWidgets.QDialog):
"""
Type picker for the Add flow: each mapper kind with a one-line
description of what it is and what it needs, instead of a bare
dropdown of type names.
"""

def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Add mapper")
self.setMinimumWidth(440)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(QtWidgets.QLabel("What kind of mapper do you want to add?"))

self.list_types = QtWidgets.QListWidget()
self.list_types.setSpacing(2)
# Descriptions wrap rather than clip behind a horizontal scrollbar;
# how much fits on a line depends on the platform's font.
self.list_types.setWordWrap(True)
self.list_types.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
for type_key, label in MAPPER_TYPE_LABELS.items():
item = QtWidgets.QListWidgetItem()
item.setData(QtCore.Qt.ItemDataRole.UserRole, type_key)
description = MAPPER_TYPE_DESCRIPTIONS.get(type_key, "")
item.setText("{}\n {}".format(label, description) if description else label)
self.list_types.addItem(item)
self.list_types.setCurrentRow(0)
self.list_types.itemDoubleClicked.connect(lambda _item: self.accept())
layout.addWidget(self.list_types)

buttons = QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.StandardButton.Ok
| QtWidgets.QDialogButtonBox.StandardButton.Cancel
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)

def selected_type(self) -> Optional[str]:
item = self.list_types.currentItem()
if item is None:
return None
return item.data(QtCore.Qt.ItemDataRole.UserRole)


class TripwireDialog(QtWidgets.QDialog, Ui_TripwireDialog):
"""
Expand Down Expand Up @@ -88,7 +138,8 @@ class MappersDialog(QtWidgets.QDialog, Ui_MappersDialog):
COL_ENABLED = 0
COL_NAME = 1
COL_TYPE = 2
COL_URL = 3
COL_DETAILS = 3
COL_URL = 4

def __init__(
self,
Expand All @@ -101,14 +152,20 @@ def __init__(
self.configs: List[MapperConfig] = [MapperConfig(**c.to_dict()) for c in configs]
self.proxy = proxy or ""

self.tableWidget_mappers.setColumnCount(4)
# A config list, not a spreadsheet: no painted grid, zebra rows for
# scanning across, and no row numbers (they mean nothing here).
self.tableWidget_mappers.setShowGrid(False)
self.tableWidget_mappers.setAlternatingRowColors(True)
self.tableWidget_mappers.verticalHeader().setVisible(False)
self.tableWidget_mappers.setColumnCount(5)
self.tableWidget_mappers.setHorizontalHeaderLabels([
"Enabled", "Name", "Type", "URL",
"Enabled", "Name", "Type", "Details", "URL",
])
header = self.tableWidget_mappers.horizontalHeader()
header.setSectionResizeMode(self.COL_ENABLED, QtWidgets.QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(self.COL_NAME, QtWidgets.QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(self.COL_TYPE, QtWidgets.QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(self.COL_DETAILS, QtWidgets.QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(self.COL_URL, QtWidgets.QHeaderView.ResizeMode.Stretch)

self.pushButton_add.clicked.connect(self._on_add)
Expand Down Expand Up @@ -145,30 +202,33 @@ def _refresh_table(self):
type_item.setFlags(type_item.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable)
self.tableWidget_mappers.setItem(row, self.COL_TYPE, type_item)

details_item = QtWidgets.QTableWidgetItem(self._cfg_details(cfg))
details_item.setFlags(details_item.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable)
self.tableWidget_mappers.setItem(row, self.COL_DETAILS, details_item)

url_item = QtWidgets.QTableWidgetItem(cfg.url)
url_item.setFlags(url_item.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable)
self.tableWidget_mappers.setItem(row, self.COL_URL, url_item)

@staticmethod
def _cfg_details(cfg: MapperConfig) -> str:
"""Human-readable per-type summary; never the secret itself."""
if cfg.type == TYPE_TRIPWIRE:
return "user: {}".format(cfg.user) if cfg.user else "no account set"
if cfg.type == TYPE_EVESCOUT:
return "public"
return ""

def _set_enabled(self, row: int, checked: bool):
if 0 <= row < len(self.configs):
self.configs[row].enabled = checked

@QtCore.Slot()
def _on_add(self):
type_label, ok = QtWidgets.QInputDialog.getItem(
self,
"Add mapper",
"Type:",
list(MAPPER_TYPE_LABELS.values()),
0,
False,
)
if not ok:
picker = AddMapperTypeDialog(self)
if not picker.exec():
return
type_key = next(
(k for k, v in MAPPER_TYPE_LABELS.items() if v == type_label),
None,
)
type_key = picker.selected_type()
if type_key is None:
return

Expand Down
61 changes: 61 additions & 0 deletions src/shortcircuit/model/test_mappers_dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# test_mappers_dialog.py
"""The Mappers dialog's type picker and table."""

import os

# The suite is headless: select the Qt platform plugin before Qt is imported.
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

import pytest
from PySide6 import QtWidgets

from shortcircuit.model.mapper_config import TYPE_EVESCOUT, TYPE_TRIPWIRE, MapperConfig


@pytest.fixture(scope="session")
def qapp():
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
yield app


def test_picker_lists_every_type_with_a_description(qapp):
from shortcircuit.app import MAPPER_TYPE_DESCRIPTIONS, MAPPER_TYPE_LABELS, AddMapperTypeDialog

picker = AddMapperTypeDialog()
rows = [picker.list_types.item(i) for i in range(picker.list_types.count())]
assert len(rows) == len(MAPPER_TYPE_LABELS)
for type_key in MAPPER_TYPE_LABELS:
assert MAPPER_TYPE_DESCRIPTIONS.get(type_key), "every type needs a description"
assert picker.selected_type() == list(MAPPER_TYPE_LABELS)[0]
picker.list_types.setCurrentRow(1)
assert picker.selected_type() == list(MAPPER_TYPE_LABELS)[1]


def test_details_column_never_shows_secrets(qapp):
from shortcircuit.app import MappersDialog

tripwire = MapperConfig(type=TYPE_TRIPWIRE, name="Corp", url="https://tw", user="pilot", password="hunter2")
no_account = MapperConfig(type=TYPE_TRIPWIRE, name="Empty", url="https://tw")
scout = MapperConfig(type=TYPE_EVESCOUT, name="Eve Scout")
assert MappersDialog._cfg_details(tripwire) == "user: pilot"
assert MappersDialog._cfg_details(no_account) == "no account set"
assert MappersDialog._cfg_details(scout) == "public"
for cfg in (tripwire, no_account, scout):
assert "hunter2" not in MappersDialog._cfg_details(cfg)


def test_table_has_details_column_and_no_row_numbers(qapp):
from shortcircuit.app import MappersDialog

configs = [
MapperConfig(type=TYPE_TRIPWIRE, name="Corp", url="https://tw", user="pilot", password="x"),
MapperConfig(type=TYPE_EVESCOUT, name="Eve Scout", url="https://scout"),
]
dialog = MappersDialog(configs, proxy="")
table = dialog.tableWidget_mappers
headers = [table.horizontalHeaderItem(c).text() for c in range(table.columnCount())]
assert headers == ["Enabled", "Name", "Type", "Details", "URL"]
assert table.item(0, MappersDialog.COL_DETAILS).text() == "user: pilot"
assert table.item(1, MappersDialog.COL_URL).text() == "https://scout"
assert not table.showGrid()
assert not table.verticalHeader().isVisible()