refactor(monitoring): N2 - tach monitoring_tab.py, CanonicalAuditLogger, MonitoringQueryService, go circular import, sandbox matrix
- ui/monitoring_tab.py (1546 dong) tach thanh presentation/monitoring/** (container + 7 tab/card + shared helper), ui/monitoring_tab.py con lai re-export shim de app.py khong doi. - infrastructure/telemetry/audit_logger.py: CanonicalAuditLogger, core/audit_log.py thanh wrapper mong, tuong thich nguoc 100% voi schema .jsonl cu. - application/monitoring/monitoring_query_service.py: MonitoringQueryService read-only, filter/sort/pagination, khong import PySide6. - Go circular import model_pricing<->usage_tracker va agent_security<-> agent_security_alert (core/agent_security_types.py moi). - infrastructure/sandbox/sandbox_capabilities.py: SandboxCapabilityMatrix theo OS (Windows/Linux/macOS), chua dau noi vao core/sandbox_manager.py. - conftest.py: sua loi checkout khong ten cowork_local khien pytest import nham thu muc khac. - 77 test moi, 167/167 pass. QA da xac nhan UI/business logic khong doi (xem evidence/report/unified_report.html). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
86c27e2e79
commit
40b12ecb15
@@ -0,0 +1,75 @@
|
||||
"""Task 4b — agent_security <-> agent_security_alert circular dependency is gone.
|
||||
|
||||
Before this fix, ``agent_security_alert.py`` imported ``SecurityVerdict`` from
|
||||
``agent_security.py`` at module level (for the ``notify_admin`` type
|
||||
annotation), while ``agent_security.py`` deferred-imported
|
||||
``agent_security_alert.notify_admin`` inside ``enforce_prompt``/
|
||||
``enforce_command`` — an architectural cycle only avoided at runtime by
|
||||
pushing that second import inside a function body.
|
||||
|
||||
``SecurityVerdict``/``SecurityBlocked`` now live in the dependency-free leaf
|
||||
module ``agent_security_types.py``. ``agent_security_alert.py`` imports the
|
||||
type from there instead of from ``agent_security.py``, which lets
|
||||
``agent_security.py`` import ``agent_security_alert.notify_admin`` at module
|
||||
top level with no cycle.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.core import (
|
||||
agent_security,
|
||||
agent_security_alert,
|
||||
agent_security_types,
|
||||
)
|
||||
|
||||
|
||||
def test_shared_types_live_in_the_leaf_module() -> None:
|
||||
assert agent_security.SecurityVerdict is agent_security_types.SecurityVerdict
|
||||
assert agent_security.SecurityBlocked is agent_security_types.SecurityBlocked
|
||||
assert agent_security_alert.SecurityVerdict is agent_security_types.SecurityVerdict
|
||||
|
||||
|
||||
def test_agent_security_alert_no_longer_imports_agent_security() -> None:
|
||||
assert "agent_security" not in agent_security_alert.__dict__
|
||||
|
||||
|
||||
def test_notify_admin_imported_at_module_top_level_in_agent_security() -> None:
|
||||
assert agent_security.notify_admin is agent_security_alert.notify_admin
|
||||
|
||||
|
||||
def test_enforce_command_still_blocks_and_alerts_like_before(monkeypatch) -> None:
|
||||
class _FakeProvider:
|
||||
def chat(self, messages, tools=None):
|
||||
return {"content": '{"allowed": false, "reason": "destructive"}'}
|
||||
|
||||
class _Config:
|
||||
data = {"agent_security": {"enabled": True, "validate_commands": True,
|
||||
"command_ai_check": True}}
|
||||
ms365 = {}
|
||||
|
||||
@property
|
||||
def agent_security(self):
|
||||
return self.data["agent_security"]
|
||||
|
||||
notify_calls = []
|
||||
record_calls = []
|
||||
monkeypatch.setattr(agent_security, "notify_admin",
|
||||
lambda config, verdict, detail="": notify_calls.append((verdict, detail)))
|
||||
from cowork_local.core import audit_log
|
||||
monkeypatch.setattr(audit_log, "record",
|
||||
lambda *a, **k: record_calls.append((a, k)))
|
||||
|
||||
emitted = []
|
||||
raised = False
|
||||
try:
|
||||
agent_security.enforce_command(
|
||||
_FakeProvider(), "run_command", {"command": "rm -rf /"}, _Config(),
|
||||
emit=emitted.append,
|
||||
)
|
||||
except agent_security.SecurityBlocked as exc:
|
||||
raised = True
|
||||
assert exc.verdict.layer == "command"
|
||||
|
||||
assert raised is True
|
||||
assert notify_calls
|
||||
assert record_calls
|
||||
assert emitted and emitted[0]["type"] == "notice"
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Task 2 — CanonicalAuditLogger.
|
||||
|
||||
Verifies: (1) the infrastructure class itself round-trips events correctly
|
||||
and mirrors to a shared dir, (2) ``core/audit_log.py``'s wrapper functions
|
||||
still behave exactly as before (same signatures, same dict schema, same
|
||||
never-raise guarantee), and (3) old-format raw dicts (as written by the
|
||||
pre-refactor ``core/audit_log.py``) still load correctly for backward
|
||||
compatibility.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from cowork_local.core import audit_log
|
||||
from cowork_local.infrastructure.telemetry.audit_logger import (
|
||||
KIND_MCP_CALL,
|
||||
KIND_SECURITY_BLOCK,
|
||||
CanonicalAuditEvent,
|
||||
CanonicalAuditLogger,
|
||||
)
|
||||
|
||||
|
||||
def test_record_and_load_round_trip(tmp_path) -> None:
|
||||
logger = CanonicalAuditLogger(tmp_path)
|
||||
logger.set_identity("alice", "machine-1", role="admin")
|
||||
logger.record(KIND_SECURITY_BLOCK, "run_command", False, detail="blocked it")
|
||||
|
||||
events = logger.load_events()
|
||||
assert len(events) == 1
|
||||
e = events[0]
|
||||
assert e.kind == KIND_SECURITY_BLOCK
|
||||
assert e.name == "run_command"
|
||||
assert e.ok is False
|
||||
assert e.detail == "blocked it"
|
||||
assert e.account == "alice"
|
||||
assert e.machine == "machine-1"
|
||||
assert e.role == "admin"
|
||||
|
||||
|
||||
def test_load_events_filters_by_kind(tmp_path) -> None:
|
||||
logger = CanonicalAuditLogger(tmp_path)
|
||||
logger.record(KIND_SECURITY_BLOCK, "a", False)
|
||||
logger.record(KIND_MCP_CALL, "b", True)
|
||||
|
||||
only_mcp = logger.load_events(kind=KIND_MCP_CALL)
|
||||
assert [e.name for e in only_mcp] == ["b"]
|
||||
|
||||
|
||||
def test_shared_dir_mirroring(tmp_path) -> None:
|
||||
shared = tmp_path / "shared"
|
||||
logger = CanonicalAuditLogger(tmp_path / "audit")
|
||||
logger.set_identity("bob", "machine-2", shared_dir=str(shared))
|
||||
logger.record(KIND_MCP_CALL, "tool_x", True)
|
||||
|
||||
mirrored_files = list((shared / "telemetry" / "audit").glob("machine-2-*.jsonl"))
|
||||
assert len(mirrored_files) == 1
|
||||
|
||||
|
||||
def test_from_dict_is_tolerant_of_old_partial_rows() -> None:
|
||||
old_row = {"ts": "2024-01-01T00:00:00", "kind": "tool_call", "name": "x", "ok": True}
|
||||
event = CanonicalAuditEvent.from_dict(old_row)
|
||||
assert event.detail == ""
|
||||
assert event.account == ""
|
||||
|
||||
|
||||
def test_record_never_raises_on_bad_directory(tmp_path) -> None:
|
||||
bad_dir = tmp_path / "some_file.txt"
|
||||
bad_dir.write_text("not a directory")
|
||||
logger = CanonicalAuditLogger(bad_dir / "audit")
|
||||
logger.record(KIND_SECURITY_BLOCK, "x", False) # must not raise
|
||||
|
||||
|
||||
def test_core_audit_log_wrapper_same_schema_as_before(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(audit_log, "AUDIT_DIR", tmp_path)
|
||||
monkeypatch.setattr(audit_log, "_logger",
|
||||
audit_log.CanonicalAuditLogger(tmp_path))
|
||||
audit_log.set_identity("carol", "machine-3", role="user")
|
||||
audit_log.record("permission", "install_package", True, detail="ok", agent_role="cowork")
|
||||
|
||||
events = audit_log.load_events()
|
||||
assert len(events) == 1
|
||||
e = events[0]
|
||||
assert set(e.keys()) == {"ts", "kind", "agent_role", "name", "ok", "detail",
|
||||
"account", "role", "machine"}
|
||||
assert e["kind"] == "permission"
|
||||
assert e["name"] == "install_package"
|
||||
assert e["ok"] is True
|
||||
assert e["agent_role"] == "cowork"
|
||||
assert e["account"] == "carol"
|
||||
|
||||
# Raw file on disk still uses the exact pre-refactor schema/keys.
|
||||
raw_line = next((tmp_path).glob("*.jsonl")).read_text(encoding="utf-8").splitlines()[0]
|
||||
raw = json.loads(raw_line)
|
||||
assert list(raw.keys()) == ["ts", "kind", "agent_role", "name", "ok", "detail",
|
||||
"account", "role", "machine"]
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Task 4a — model_pricing <-> usage_tracker circular dependency is gone.
|
||||
|
||||
Before this fix, ``model_pricing.turn_cost_usd`` deferred-imported
|
||||
``usage_tracker`` for its flat fallback rates, while ``usage_tracker.set_budget``
|
||||
deferred-imported ``model_pricing`` for currency conversion — a real
|
||||
architectural cycle, only avoided at runtime by pushing both imports inside
|
||||
function bodies. Now ``model_pricing`` is a leaf module (it owns its own
|
||||
fallback rates) and ``usage_tracker`` imports it at module top level.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import sys
|
||||
|
||||
from cowork_local.config import AppConfig, DEFAULT_CONFIG
|
||||
from cowork_local.core import model_pricing, usage_tracker
|
||||
|
||||
|
||||
def test_model_pricing_does_not_depend_on_usage_tracker_module_level() -> None:
|
||||
assert "usage_tracker" not in model_pricing.__dict__
|
||||
assert "usage_tracker" not in getattr(model_pricing, "__all__", [])
|
||||
|
||||
|
||||
def test_usage_tracker_imports_model_pricing_at_top_level() -> None:
|
||||
assert usage_tracker.mp is model_pricing
|
||||
|
||||
|
||||
def test_turn_cost_usd_fallback_matches_pre_refactor_default_rates(tmp_path) -> None:
|
||||
config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "config.json")
|
||||
# No matching row in the price table and no override in config.data["usage"]
|
||||
# -> falls back to the flat rates that used to live in
|
||||
# usage_tracker.DEFAULT_PRICING (0.5 in / 1.5 out USD per 1M tokens).
|
||||
cost = model_pricing.turn_cost_usd("some-unknown-model", 1_000_000, 1_000_000, config)
|
||||
assert cost == 0.5 + 1.5
|
||||
|
||||
|
||||
def test_turn_cost_usd_honours_usage_override_like_before(tmp_path) -> None:
|
||||
config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "config.json")
|
||||
config.data.setdefault("usage", {})["price_per_mtok_in_usd"] = 2.0
|
||||
config.data["usage"]["price_per_mtok_out_usd"] = 4.0
|
||||
cost = model_pricing.turn_cost_usd("some-unknown-model", 1_000_000, 1_000_000, config)
|
||||
assert cost == 2.0 + 4.0
|
||||
|
||||
|
||||
def test_set_budget_still_converts_via_model_pricing(tmp_path) -> None:
|
||||
config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "config.json")
|
||||
usage_tracker.set_budget(config, 100.0, currency="USD")
|
||||
status = usage_tracker.budget_status(config)
|
||||
assert status is not None
|
||||
assert status["amount_usd"] == 100.0
|
||||
|
||||
|
||||
def test_no_import_time_cycle_when_loaded_fresh() -> None:
|
||||
for name in ("cowork_local.core.model_pricing", "cowork_local.core.usage_tracker"):
|
||||
sys.modules.pop(name, None)
|
||||
import importlib
|
||||
|
||||
mp = importlib.import_module("cowork_local.core.model_pricing")
|
||||
ut = importlib.import_module("cowork_local.core.usage_tracker")
|
||||
assert ut.mp is mp
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Task 1 (sub-step 6d) — AgentStatusTab widget smoke test."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
|
||||
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
|
||||
|
||||
from cowork_local.config import AppConfig, DEFAULT_CONFIG
|
||||
from cowork_local.presentation.monitoring.tabs.agent_status_tab import AgentStatusTab
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
class _FakeCowork:
|
||||
def active_workers(self):
|
||||
return [1, 2]
|
||||
|
||||
|
||||
class _FakeTaskScheduler:
|
||||
def running_count(self):
|
||||
return 3
|
||||
|
||||
|
||||
class _FakeCtx:
|
||||
def __init__(self, tmp_path):
|
||||
self.config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "c.json")
|
||||
self.config.data["agent_security"]["enabled"] = True
|
||||
|
||||
|
||||
def test_agent_status_tab_has_no_search_or_detail(qapp, tmp_path) -> None:
|
||||
tab = AgentStatusTab(_FakeCtx(tmp_path), on_refresh_all=lambda: None)
|
||||
assert not hasattr(tab, "filter_edit")
|
||||
assert not hasattr(tab, "detail_panel")
|
||||
|
||||
|
||||
def test_refresh_populates_six_rows_with_live_counts(qapp, tmp_path) -> None:
|
||||
tab = AgentStatusTab(_FakeCtx(tmp_path), on_refresh_all=lambda: None,
|
||||
cowork=_FakeCowork(), task_scheduler=_FakeTaskScheduler())
|
||||
tab.refresh()
|
||||
assert tab.table.rowCount() == 6
|
||||
assert tab.table.item(0, 0).text() # Cowork row has a label
|
||||
assert tab.table.cellWidget(0, 1) is not None # badge pill widget
|
||||
|
||||
|
||||
def test_retranslate_does_not_raise(qapp, tmp_path) -> None:
|
||||
tab = AgentStatusTab(_FakeCtx(tmp_path), on_refresh_all=lambda: None)
|
||||
tab.retranslate()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Task 1 (sub-step 6c) — SecurityEventsTab / McpTab / ActionLogsTab.
|
||||
|
||||
Widget smoke tests against the offscreen QPA platform (see
|
||||
test_monitoring_event_widgets.py for why no pytest-qt is needed). Verifies
|
||||
each tab wires build_filter_scaffold correctly, exposes the attributes the
|
||||
container's retranslate loop needs, and forwards set_events to its table.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
|
||||
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
|
||||
|
||||
from cowork_local.presentation.monitoring.tabs.action_logs_tab import ActionLogsTab
|
||||
from cowork_local.presentation.monitoring.tabs.mcp_tab import McpTab
|
||||
from cowork_local.presentation.monitoring.tabs.security_events_tab import SecurityEventsTab
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cls,expected_title_key", [
|
||||
(SecurityEventsTab, "monitoring.security_events_title"),
|
||||
(McpTab, "monitoring.mcp_history_title"),
|
||||
(ActionLogsTab, "monitoring.action_logs_title"),
|
||||
])
|
||||
def test_tab_exposes_container_facing_attributes(qapp, cls, expected_title_key) -> None:
|
||||
refresh_calls = []
|
||||
tab = cls(ctx=None, on_refresh_all=lambda: refresh_calls.append(1))
|
||||
assert tab.title_key == expected_title_key
|
||||
assert tab.filter_edit is not None
|
||||
assert tab.detail_panel is not None
|
||||
|
||||
tab.title_refresh_btn.click()
|
||||
assert refresh_calls == [1]
|
||||
|
||||
|
||||
def test_set_events_forwards_to_table(qapp) -> None:
|
||||
tab = SecurityEventsTab(ctx=None, on_refresh_all=lambda: None)
|
||||
tab.set_events([{"ts": "2026-01-01T00:00:00", "kind": "security_block", "name": "x",
|
||||
"ok": False, "detail": "d", "account": "a", "machine": "m"}])
|
||||
assert tab.table.rowCount() == 1
|
||||
|
||||
|
||||
def test_retranslate_does_not_raise(qapp) -> None:
|
||||
tab = McpTab(ctx=None, on_refresh_all=lambda: None)
|
||||
tab.retranslate() # must not raise
|
||||
|
||||
|
||||
def test_ai_filter_noop_when_search_box_empty(qapp) -> None:
|
||||
tab = ActionLogsTab(ctx=None, on_refresh_all=lambda: None)
|
||||
tab.ai_filter_btn.click() # empty search text -> start_ai_filter no-ops, must not raise
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Task 1 (sub-step 6b) — EventTable / EventDetailPanel widget smoke tests.
|
||||
|
||||
These instantiate real PySide6 widgets against the offscreen QPA platform
|
||||
(no display needed, no pytest-qt dependency — a plain QApplication instance
|
||||
is enough to construct/query widgets, only an actual event loop would need
|
||||
more). Verifies the extraction into presentation/monitoring/shared/ wires up
|
||||
without error and preserves the pre-refactor row/column behaviour.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
|
||||
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
|
||||
|
||||
from cowork_local.presentation.monitoring.shared.event_detail_panel import EventDetailPanel
|
||||
from cowork_local.presentation.monitoring.shared.event_table import EventTable
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
def _sample_event(**overrides):
|
||||
ev = {"ts": "2026-05-25T15:03:00", "kind": "security_block", "name": "run_command",
|
||||
"ok": False, "detail": "blocked it", "account": "alice", "machine": "m1",
|
||||
"agent_role": "cowork"}
|
||||
ev.update(overrides)
|
||||
return ev
|
||||
|
||||
|
||||
def test_event_table_security_events_hides_result_column(qapp) -> None:
|
||||
table = EventTable(show_result=False)
|
||||
table.retranslate()
|
||||
assert table.columnCount() == 6
|
||||
|
||||
|
||||
def test_event_table_generic_shows_result_column(qapp) -> None:
|
||||
table = EventTable(show_result=True)
|
||||
table.retranslate()
|
||||
assert table.columnCount() == 7
|
||||
|
||||
|
||||
def test_set_events_populates_rows_newest_first(qapp) -> None:
|
||||
table = EventTable(show_result=True)
|
||||
table.set_events([
|
||||
_sample_event(ts="2026-05-25T10:00:00", name="first"),
|
||||
_sample_event(ts="2026-05-25T12:00:00", name="second"),
|
||||
])
|
||||
assert table.rowCount() == 2
|
||||
assert table.item(0, 4).text() == "second"
|
||||
assert table.item(1, 4).text() == "first"
|
||||
|
||||
|
||||
def test_event_at_row_round_trips_full_event(qapp) -> None:
|
||||
table = EventTable(show_result=False)
|
||||
ev = _sample_event()
|
||||
table.set_events([ev])
|
||||
assert table.event_at_row(0) == ev
|
||||
|
||||
|
||||
def test_apply_filter_hides_non_matching_rows(qapp) -> None:
|
||||
table = EventTable(show_result=True)
|
||||
table.set_events([_sample_event(name="run_command"), _sample_event(name="fetch_url")])
|
||||
table.apply_filter("fetch")
|
||||
hidden = [table.isRowHidden(r) for r in range(table.rowCount())]
|
||||
assert hidden.count(True) == 1
|
||||
|
||||
|
||||
def test_detail_panel_shows_event_without_error(qapp) -> None:
|
||||
panel = EventDetailPanel()
|
||||
panel.retranslate()
|
||||
panel.show_event(_sample_event(), 0)
|
||||
assert panel._detail_text == "blocked it"
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Task 1 (sub-step 6f) — OverviewTab widget smoke test."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
import time
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
|
||||
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
|
||||
|
||||
from cowork_local.config import AppConfig, DEFAULT_CONFIG
|
||||
from cowork_local.presentation.monitoring.tabs.overview_tab import OverviewTab
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
class _FakeCtx:
|
||||
def __init__(self, tmp_path):
|
||||
self.config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "c.json")
|
||||
self.started_at = time.time() - 30
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
def cowork_output_dir(self):
|
||||
return "."
|
||||
|
||||
|
||||
def _make_tab(tmp_path):
|
||||
return OverviewTab(
|
||||
_FakeCtx(tmp_path), on_status_message=lambda _m: None,
|
||||
on_settings_changed=lambda: None, on_view_all_action_logs=lambda: None,
|
||||
action_logs_tab_visible=True)
|
||||
|
||||
|
||||
def test_construction_and_retranslate(qapp, tmp_path) -> None:
|
||||
tab = _make_tab(tmp_path)
|
||||
tab.retranslate() # must not raise
|
||||
|
||||
|
||||
def test_refresh_with_no_events_shows_no_activity_text(qapp, tmp_path) -> None:
|
||||
tab = _make_tab(tmp_path)
|
||||
tab.retranslate()
|
||||
tab.refresh(events=[])
|
||||
assert tab.activity_lbl.text() != ""
|
||||
|
||||
|
||||
def test_refresh_with_events_renders_activity_lines(qapp, tmp_path) -> None:
|
||||
tab = _make_tab(tmp_path)
|
||||
tab.retranslate()
|
||||
tab.refresh(events=[
|
||||
{"ts": "2026-01-01T00:00:00", "kind": "tool_call", "name": "run_command", "ok": True},
|
||||
{"ts": "2026-01-01T00:00:05", "kind": "security_block", "name": "blocked", "ok": False},
|
||||
])
|
||||
assert "run_command" in tab.activity_lbl.text() or "blocked" in tab.activity_lbl.text()
|
||||
|
||||
|
||||
def test_view_all_button_invokes_callback(qapp, tmp_path) -> None:
|
||||
calls = []
|
||||
tab = OverviewTab(
|
||||
_FakeCtx(tmp_path), on_status_message=lambda _m: None,
|
||||
on_settings_changed=lambda: None, on_view_all_action_logs=lambda: calls.append(1),
|
||||
action_logs_tab_visible=True)
|
||||
tab.view_all_btn.click()
|
||||
assert calls == [1]
|
||||
|
||||
|
||||
def test_audit_group_visibility_follows_constructor_flag(qapp, tmp_path) -> None:
|
||||
hidden_tab = OverviewTab(
|
||||
_FakeCtx(tmp_path), on_status_message=lambda _m: None,
|
||||
on_settings_changed=lambda: None, on_view_all_action_logs=lambda: None,
|
||||
action_logs_tab_visible=False)
|
||||
assert hidden_tab.audit_group.isHidden() is True
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Task 1 (sub-step 6f, follow-up) — PricingPanel, split out of overview_tab.py
|
||||
to keep that file under the 400-line quality rule."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
|
||||
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
|
||||
|
||||
from cowork_local.config import AppConfig, DEFAULT_CONFIG
|
||||
from cowork_local.presentation.monitoring.tabs.pricing_panel import PricingPanel
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
class _FakeCtx:
|
||||
def __init__(self, tmp_path):
|
||||
self.config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "c.json")
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_construction_and_retranslate(qapp, tmp_path) -> None:
|
||||
panel = PricingPanel(_FakeCtx(tmp_path), on_status_message=lambda _m: None)
|
||||
panel.retranslate()
|
||||
|
||||
|
||||
def test_add_and_delete_pricing_row_round_trip(qapp, tmp_path, monkeypatch) -> None:
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
ctx = _FakeCtx(tmp_path)
|
||||
panel = PricingPanel(ctx, on_status_message=lambda _m: None)
|
||||
monkeypatch.setattr(QInputDialog, "getText", staticmethod(lambda *a, **k: ("gpt-test", True)))
|
||||
panel._add_pricing_row()
|
||||
assert panel.table.rowCount() == 1
|
||||
assert panel.table.item(0, 0).text() == "gpt-test"
|
||||
|
||||
panel.table.selectRow(0)
|
||||
panel._delete_pricing_row()
|
||||
assert panel.table.rowCount() == 0
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Task 3 — MonitoringQueryService: read-only filter/sort/pagination over
|
||||
audit events, fully testable without file I/O (InMemoryAuditEventRepository)
|
||||
and with a real CanonicalAuditLogger wired through CanonicalAuditEventRepository.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.application.monitoring.dto.audit_event_dto import AuditEventDTO
|
||||
from cowork_local.application.monitoring.monitoring_query_service import (
|
||||
MonitoringQueryService,
|
||||
)
|
||||
from cowork_local.application.monitoring.repository.audit_event_repository import (
|
||||
CanonicalAuditEventRepository,
|
||||
InMemoryAuditEventRepository,
|
||||
)
|
||||
from cowork_local.infrastructure.telemetry.audit_logger import CanonicalAuditLogger
|
||||
|
||||
|
||||
def _event(ts, kind="tool_call", name="x", ok=True, detail="") -> AuditEventDTO:
|
||||
return AuditEventDTO(ts=ts, kind=kind, name=name, ok=ok, detail=detail)
|
||||
|
||||
|
||||
def test_query_filters_by_kind() -> None:
|
||||
repo = InMemoryAuditEventRepository([
|
||||
_event("2026-01-01T00:00:00", kind="mcp_call", name="a"),
|
||||
_event("2026-01-01T00:00:01", kind="security_block", name="b"),
|
||||
])
|
||||
service = MonitoringQueryService(repo)
|
||||
page = service.query(kind="mcp_call")
|
||||
assert [e.name for e in page.items] == ["a"]
|
||||
|
||||
|
||||
def test_query_filters_by_ok_and_text() -> None:
|
||||
repo = InMemoryAuditEventRepository([
|
||||
_event("2026-01-01T00:00:00", name="run_command", ok=False, detail="blocked"),
|
||||
_event("2026-01-01T00:00:01", name="run_command", ok=True, detail="fine"),
|
||||
_event("2026-01-01T00:00:02", name="fetch_url", ok=False, detail="blocked"),
|
||||
])
|
||||
service = MonitoringQueryService(repo)
|
||||
page = service.query(ok=False, text="run_command")
|
||||
assert len(page.items) == 1
|
||||
assert page.items[0].detail == "blocked"
|
||||
|
||||
|
||||
def test_query_sorts_newest_first_by_default() -> None:
|
||||
repo = InMemoryAuditEventRepository([
|
||||
_event("2026-01-01T00:00:00", name="first"),
|
||||
_event("2026-01-02T00:00:00", name="second"),
|
||||
])
|
||||
service = MonitoringQueryService(repo)
|
||||
page = service.query()
|
||||
assert [e.name for e in page.items] == ["second", "first"]
|
||||
|
||||
|
||||
def test_query_paginates() -> None:
|
||||
events = [_event(f"2026-01-{i:02d}T00:00:00", name=str(i)) for i in range(1, 11)]
|
||||
repo = InMemoryAuditEventRepository(events)
|
||||
service = MonitoringQueryService(repo)
|
||||
|
||||
page1 = service.query(sort_by="ts", descending=False, page=1, page_size=4)
|
||||
page2 = service.query(sort_by="ts", descending=False, page=2, page_size=4)
|
||||
|
||||
assert page1.total == 10
|
||||
assert [e.name for e in page1.items] == ["1", "2", "3", "4"]
|
||||
assert [e.name for e in page2.items] == ["5", "6", "7", "8"]
|
||||
assert page1.has_more is True
|
||||
|
||||
|
||||
def test_large_page_size_returns_everything_matching_current_ui_behaviour() -> None:
|
||||
events = [_event(f"2026-01-{i:02d}T00:00:00", name=str(i)) for i in range(1, 6)]
|
||||
repo = InMemoryAuditEventRepository(events)
|
||||
service = MonitoringQueryService(repo)
|
||||
page = service.query(page_size=10_000)
|
||||
assert len(page.items) == 5
|
||||
assert page.has_more is False
|
||||
|
||||
|
||||
def test_repository_is_read_only_no_pyside6_import() -> None:
|
||||
import cowork_local.application.monitoring.monitoring_query_service as mod
|
||||
import cowork_local.application.monitoring.repository.audit_event_repository as repo_mod
|
||||
assert "PySide6" not in mod.__dict__
|
||||
assert "PySide6" not in repo_mod.__dict__
|
||||
assert not hasattr(mod.MonitoringQueryService, "record")
|
||||
|
||||
|
||||
def test_canonical_repository_wires_to_real_logger(tmp_path) -> None:
|
||||
logger = CanonicalAuditLogger(tmp_path)
|
||||
logger.record("security_block", "run_command", False, detail="nope")
|
||||
logger.record("mcp_call", "search", True)
|
||||
|
||||
repo = CanonicalAuditEventRepository(logger)
|
||||
service = MonitoringQueryService(repo)
|
||||
|
||||
page = service.query(kind="security_block")
|
||||
assert len(page.items) == 1
|
||||
assert page.items[0].name == "run_command"
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Task 1 (sub-step 6e) — SandboxDetailsCard / PermissionsCard.
|
||||
|
||||
Verifies the Permissions card is nested INSIDE the Sandbox card's fold
|
||||
(matching the pre-refactor layout exactly) and that refresh()/retranslate()
|
||||
compute the same values the original MonitoringTab methods did.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
|
||||
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
|
||||
|
||||
from cowork_local.config import AppConfig, DEFAULT_CONFIG
|
||||
from cowork_local.presentation.monitoring.tabs.sandbox_tab import SandboxDetailsCard
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
class _FakeCtx:
|
||||
def __init__(self, tmp_path, block_network=False):
|
||||
self.config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "c.json")
|
||||
self.config.data["agent_security"]["block_network"] = block_network
|
||||
self.config.data["agent_security"]["resource_limit_cpu_percent"] = 50
|
||||
import time
|
||||
self.started_at = time.time() - 65 # ~1m5s uptime
|
||||
|
||||
|
||||
def test_permissions_card_nested_inside_sandbox_fold(qapp, tmp_path) -> None:
|
||||
card = SandboxDetailsCard(_FakeCtx(tmp_path), on_settings_changed=lambda: None)
|
||||
assert card.permissions_card.parent() is card._detail
|
||||
|
||||
|
||||
def test_refresh_computes_uptime_and_resource_limits(qapp, tmp_path) -> None:
|
||||
card = SandboxDetailsCard(_FakeCtx(tmp_path), on_settings_changed=lambda: None)
|
||||
card.retranslate()
|
||||
card.refresh()
|
||||
assert "CPU 50%" in card.limits_lbl.text()
|
||||
assert "m" in card.uptime_val.text()
|
||||
|
||||
|
||||
def test_refresh_reflects_network_blocked_on_both_cards(qapp, tmp_path) -> None:
|
||||
card = SandboxDetailsCard(_FakeCtx(tmp_path, block_network=True), on_settings_changed=lambda: None)
|
||||
card.retranslate()
|
||||
card.refresh()
|
||||
assert card.net_val.objectName() == "badgeWarn"
|
||||
assert card.permissions_card.network_val.objectName() == "badgeWarn"
|
||||
|
||||
|
||||
def test_refresh_reflects_network_allowed(qapp, tmp_path) -> None:
|
||||
card = SandboxDetailsCard(_FakeCtx(tmp_path, block_network=False), on_settings_changed=lambda: None)
|
||||
card.retranslate()
|
||||
card.refresh()
|
||||
assert card.net_val.objectName() == "badgeSuccess"
|
||||
assert card.permissions_card.network_val.objectName() == "badgeSuccess"
|
||||
|
||||
|
||||
def test_fold_starts_collapsed(qapp, tmp_path) -> None:
|
||||
# isVisible() alone can't tell (it also depends on ancestors actually
|
||||
# being shown on screen, which nothing here is) — isHidden() reflects
|
||||
# the explicit setVisible(False) call regardless of the parent chain.
|
||||
card = SandboxDetailsCard(_FakeCtx(tmp_path), on_settings_changed=lambda: None)
|
||||
assert card._detail.isHidden() is True
|
||||
card.more_btn.setChecked(True)
|
||||
assert card._detail.isHidden() is False
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Task 1 (sub-step 6a) — presentation/monitoring/shared helpers.
|
||||
|
||||
Only the pure-Python functions are covered here (no PySide6 widget
|
||||
instantiation needed, no pytest-qt required). Values are asserted against
|
||||
what ``ui/monitoring_tab.py``'s original, now-removed private functions
|
||||
produced, so this doubles as a characterization test proving the extraction
|
||||
didn't change output.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.presentation.monitoring.shared import badges, formatters
|
||||
|
||||
|
||||
def test_fmt_bytes() -> None:
|
||||
assert formatters.fmt_bytes(500) == "500 B"
|
||||
assert formatters.fmt_bytes(2048) == "2 KB"
|
||||
|
||||
|
||||
def test_fmt_event_time_and_full_and_relative_are_unparsable_safe() -> None:
|
||||
assert formatters.fmt_event_time("not-a-timestamp") == "not-a-timestamp"
|
||||
assert formatters.fmt_event_time_full("not-a-timestamp") == "not-a-timestamp"
|
||||
assert formatters.relative_time("not-a-timestamp") == ""
|
||||
|
||||
|
||||
def test_fmt_event_time_formats_valid_iso_timestamp() -> None:
|
||||
assert formatters.fmt_event_time("2026-05-25T15:03:00") == "25/05 15:03"
|
||||
# The separator is computed via datetime.strftime with a literal
|
||||
# non-ASCII character in the format string, same as the pre-refactor
|
||||
# ui/monitoring_tab.py code — on a Windows box whose locale ANSI codepage
|
||||
# has no direct mapping for U+00B7 (MIDDLE DOT), strftime's encode/decode
|
||||
# round trip through that codepage can substitute a different but
|
||||
# visually similar character (observed: U+30FB on a ja_JP/cp932 locale).
|
||||
# That behavior is unchanged by this refactor either way, so the test
|
||||
# computes the expected separator the exact same way the implementation
|
||||
# does, rather than assuming byte 0xB7 survives on every locale.
|
||||
import datetime as _dt
|
||||
expected_full = _dt.datetime(2026, 5, 25, 15, 3, 7).strftime(
|
||||
f"%d/%m/%Y {formatters._MIDDLE_DOT} %H:%M:%S")
|
||||
assert formatters.fmt_event_time_full("2026-05-25T15:03:07") == expected_full
|
||||
|
||||
|
||||
def test_event_id_shape() -> None:
|
||||
assert formatters.event_id("2026-05-25T15:03:00", 7) == "evt_202605251503_007"
|
||||
|
||||
|
||||
def test_agent_initials() -> None:
|
||||
assert formatters.agent_initials("Cowork Agent") == "CA"
|
||||
assert formatters.agent_initials("graphrag") == "G"
|
||||
|
||||
|
||||
def test_agent_avatar_colour_identity_mapping() -> None:
|
||||
assert formatters.agent_avatar_colour("Security Agent") == "#D13438"
|
||||
assert formatters.agent_avatar_colour("Cowork Agent") == "#0078D4"
|
||||
assert formatters.agent_avatar_colour("unknown-agent") == "#0078D4"
|
||||
|
||||
|
||||
def test_action_label_falls_back_to_raw_name_when_unmapped() -> None:
|
||||
assert badges.action_label("some_custom_tool") == "some_custom_tool"
|
||||
|
||||
|
||||
def test_status_info_security_block_unmapped_defaults_to_blocked() -> None:
|
||||
tone, key = badges.status_info("some_new_rule", kind="security_block", ok=False)
|
||||
assert (tone, key) == ("badgePurple", "monitoring.status_blocked")
|
||||
|
||||
|
||||
def test_status_info_mcp_call_falls_back_to_ok_flag() -> None:
|
||||
assert badges.status_info("search", kind="mcp_call", ok=True) == ("badgeSuccess", "monitoring.status_ok")
|
||||
assert badges.status_info("search", kind="mcp_call", ok=False) == ("badgeDanger", "monitoring.status_failed")
|
||||
|
||||
|
||||
def test_severity_info_dangerous_command_is_critical() -> None:
|
||||
assert badges.severity_info("run_command") == ("badgeDanger", "monitoring.severity_critical")
|
||||
|
||||
|
||||
def test_agent_badge_name_identity_mapping() -> None:
|
||||
assert badges.agent_badge_name("Security Agent") == "badgeDanger"
|
||||
assert badges.agent_badge_name("graphrag") == "badgePurple"
|
||||
assert badges.agent_badge_name("unknown") == "badge"
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Task 1 (sub-step 6g) — the new presentation/monitoring/monitoring_tab.py
|
||||
container. Builds a real MonitoringTab against a real AppConfig/AppContext
|
||||
(same convention as tests/routing/test_service.py: real config/context, only
|
||||
the true external collaborators — here, none — get a fake), and verifies the
|
||||
public API app.py depends on is intact: constructor signature,
|
||||
``status_message`` signal, ``select_subtab``, ``nav_subtabs``,
|
||||
``hide_tab_bar``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
|
||||
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
|
||||
|
||||
from cowork_local.config import AppConfig, DEFAULT_CONFIG
|
||||
from cowork_local.presentation.monitoring.monitoring_tab import MonitoringTab
|
||||
from cowork_local.state import AppContext
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ctx(tmp_path):
|
||||
config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "config.json")
|
||||
return AppContext(config)
|
||||
|
||||
|
||||
def test_constructor_accepts_apps_py_call_signature(qapp, ctx) -> None:
|
||||
# app.py:499-502 calls MonitoringTab(self.ctx, cowork=..., structure=...,
|
||||
# task_scheduler=...) — all optional besides ctx.
|
||||
tab = MonitoringTab(ctx)
|
||||
assert tab.tabs.count() >= 1
|
||||
|
||||
|
||||
def test_status_message_signal_exists(qapp, ctx) -> None:
|
||||
tab = MonitoringTab(ctx)
|
||||
received = []
|
||||
tab.status_message.connect(received.append)
|
||||
tab.status_message.emit("hello")
|
||||
assert received == ["hello"]
|
||||
|
||||
|
||||
def test_select_subtab_and_nav_subtabs(qapp, ctx) -> None:
|
||||
tab = MonitoringTab(ctx)
|
||||
subtabs = tab.nav_subtabs()
|
||||
assert len(subtabs) == tab.tabs.count()
|
||||
tab.select_subtab(1)
|
||||
assert tab.tabs.currentIndex() == 1
|
||||
|
||||
|
||||
def test_hide_tab_bar_does_not_raise(qapp, ctx) -> None:
|
||||
tab = MonitoringTab(ctx)
|
||||
tab.hide_tab_bar()
|
||||
|
||||
|
||||
def test_full_refresh_populates_event_tabs_via_query_service(qapp, ctx, monkeypatch) -> None:
|
||||
from cowork_local.core import audit_log
|
||||
|
||||
audit_dir = ctx.config.path.parent / "audit"
|
||||
monkeypatch.setattr(audit_log, "AUDIT_DIR", audit_dir)
|
||||
monkeypatch.setattr(audit_log, "_logger", audit_log.CanonicalAuditLogger(audit_dir))
|
||||
audit_log.record("security_block", "run_command", False, detail="blocked")
|
||||
audit_log.record("mcp_call", "search", True)
|
||||
audit_log.record("tool_call", "read_file", True)
|
||||
|
||||
tab = MonitoringTab(ctx, cowork=None, structure=None, task_scheduler=None)
|
||||
tab.refresh()
|
||||
|
||||
assert tab.security_tab.table.rowCount() == 1
|
||||
assert tab.mcp_tab.table.rowCount() == 1
|
||||
assert tab.action_tab.table.rowCount() == 3
|
||||
|
||||
|
||||
def test_retranslate_does_not_raise(qapp, ctx) -> None:
|
||||
tab = MonitoringTab(ctx)
|
||||
tab._retranslate()
|
||||
|
||||
|
||||
def test_settings_changed_callback_is_the_container_full_refresh(qapp, ctx) -> None:
|
||||
# Editing Settings from either the Sandbox or the nested Permissions
|
||||
# card's "Edit" button used to call the same
|
||||
# MonitoringTab._open_settings_and_refresh -> self.refresh(); the
|
||||
# container now wires both cards' on_settings_changed to its own
|
||||
# bound refresh method, so this equality is the direct replacement
|
||||
# for that identity.
|
||||
tab = MonitoringTab(ctx)
|
||||
assert tab.overview_tab.sandbox_card._on_settings_changed == tab.refresh
|
||||
assert tab.overview_tab.sandbox_card.permissions_card._on_settings_changed == tab.refresh
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Task 5 — Sandbox Capability Matrix: pure OS/risk-tier policy, no execution,
|
||||
no PySide6, no dependency on core/sandbox_manager.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.infrastructure.sandbox import sandbox_capabilities as sc
|
||||
|
||||
|
||||
def test_detect_os_from_injected_platform_name() -> None:
|
||||
assert sc.detect_os("win32") == sc.WINDOWS
|
||||
assert sc.detect_os("linux") == sc.LINUX
|
||||
assert sc.detect_os("darwin") == sc.MACOS
|
||||
assert sc.detect_os("some-other-os") == sc.UNKNOWN
|
||||
|
||||
|
||||
def test_windows_matches_todays_real_backends() -> None:
|
||||
matrix = sc.SandboxCapabilityMatrix(operating_system=sc.WINDOWS)
|
||||
names = {b.name for b in matrix.available_backends()}
|
||||
assert names == {"direct", "integrity_job_wfp", "appcontainer", "windows_sandbox"}
|
||||
|
||||
|
||||
def test_windows_routing_matches_core_sandbox_manager_today() -> None:
|
||||
matrix = sc.SandboxCapabilityMatrix(operating_system=sc.WINDOWS)
|
||||
assert matrix.select_backend(sc.SAFE) == "integrity_job_wfp"
|
||||
assert matrix.select_backend(sc.MODERATE) == "integrity_job_wfp"
|
||||
assert matrix.select_backend(sc.HIGH) == "appcontainer"
|
||||
assert matrix.select_backend(sc.CRITICAL) == "windows_sandbox"
|
||||
|
||||
|
||||
def test_linux_has_no_real_backend_yet() -> None:
|
||||
matrix = sc.SandboxCapabilityMatrix(operating_system=sc.LINUX)
|
||||
available = {b.name for b in matrix.available_backends()}
|
||||
assert available == {"direct"} # namespaces_bubblewrap declared but not implemented
|
||||
assert matrix.select_backend(sc.SAFE) == "direct" # SAFE/MODERATE only ever wanted direct
|
||||
# HIGH's preferred backend (namespaces_bubblewrap) isn't implemented, and
|
||||
# HIGH's routing table names "blocked" as the explicit next preference
|
||||
# (not a silent fallback to unisolated "direct") — a HIGH-risk command
|
||||
# must never quietly downgrade to no isolation just because the real
|
||||
# sandbox backend is missing on this OS.
|
||||
assert matrix.select_backend(sc.HIGH) == "blocked"
|
||||
assert matrix.select_backend(sc.CRITICAL) == "blocked"
|
||||
|
||||
|
||||
def test_macos_has_no_real_backend_yet() -> None:
|
||||
matrix = sc.SandboxCapabilityMatrix(operating_system=sc.MACOS)
|
||||
available = {b.name for b in matrix.available_backends()}
|
||||
assert available == {"direct"}
|
||||
|
||||
|
||||
def test_disallowing_direct_fallback_blocks_instead() -> None:
|
||||
# LINUX's own HIGH routing already names "blocked" explicitly, so it
|
||||
# doesn't exercise the allow_direct_fallback branch. Register a profile
|
||||
# whose HIGH tier names only an unavailable backend (no explicit
|
||||
# "direct"/"blocked" entry) to exercise the bottom-of-select_backend
|
||||
# fallback path directly.
|
||||
os_name = "test-os-fallback"
|
||||
profile = sc.OsSandboxProfile(
|
||||
operating_system=os_name,
|
||||
backends=(sc.SandboxBackend("direct", "none", True),),
|
||||
routing={sc.SAFE: ("direct",), sc.MODERATE: ("direct",),
|
||||
sc.HIGH: ("not_yet_implemented",), sc.CRITICAL: ("not_yet_implemented",)},
|
||||
)
|
||||
sc.register_profile(profile)
|
||||
try:
|
||||
allowed = sc.SandboxCapabilityMatrix(operating_system=os_name, allow_direct_fallback=True)
|
||||
disallowed = sc.SandboxCapabilityMatrix(operating_system=os_name, allow_direct_fallback=False)
|
||||
assert allowed.select_backend(sc.HIGH) == "direct"
|
||||
assert disallowed.select_backend(sc.HIGH) == "blocked"
|
||||
# CRITICAL never falls back to direct even when allowed.
|
||||
assert allowed.select_backend(sc.CRITICAL) == "blocked"
|
||||
finally:
|
||||
del sc._PROFILES[os_name]
|
||||
|
||||
|
||||
def test_unknown_os_always_blocks() -> None:
|
||||
matrix = sc.SandboxCapabilityMatrix(operating_system=sc.UNKNOWN)
|
||||
assert matrix.available_backends() == ()
|
||||
for tier in (sc.SAFE, sc.MODERATE, sc.HIGH, sc.CRITICAL):
|
||||
assert matrix.select_backend(tier) == "blocked"
|
||||
|
||||
|
||||
def test_registering_a_brand_new_os_requires_no_class_changes() -> None:
|
||||
freebsd = "freebsd"
|
||||
profile = sc.OsSandboxProfile(
|
||||
operating_system=freebsd,
|
||||
backends=(sc.SandboxBackend("direct", "none", True),),
|
||||
routing={sc.SAFE: ("direct",), sc.MODERATE: ("direct",),
|
||||
sc.HIGH: ("blocked",), sc.CRITICAL: ("blocked",)},
|
||||
)
|
||||
sc.register_profile(profile)
|
||||
try:
|
||||
matrix = sc.SandboxCapabilityMatrix(operating_system=freebsd)
|
||||
assert matrix.select_backend(sc.SAFE) == "direct"
|
||||
assert matrix.select_backend(sc.HIGH) == "blocked"
|
||||
finally:
|
||||
del sc._PROFILES[freebsd] # don't leak state into other tests
|
||||
|
||||
|
||||
def test_no_pyside6_or_subprocess_dependency() -> None:
|
||||
assert "PySide6" not in sc.__dict__
|
||||
assert "subprocess" not in sc.__dict__
|
||||
Reference in New Issue
Block a user