- Sim facade (bound as `sim`): start(tag) arms the sim, dispatching 14a/15693 by tag type; stop(), push(tag) -> tag.sync(), frames -> decoded trace - SimResource: opens SimSession on load, closes on exit; a missing device is non-fatal - one connection mode per workspace (reader OR sim) — the second is refused (serial contention) - sim.push added to the write effect patterns (withheld on autoreplay) Arming stays explicit. Hardware-free tests via injected sessions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
136 lines
4.6 KiB
Python
136 lines
4.6 KiB
Python
"""Tests for the pm3py pyws plugin (P1: reader resource + namespace + effects). Hardware-free."""
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from pyws import ResourceStatus, WorkspacePlugin
|
|
|
|
from pm3py.pyws_plugin import Pm3pyPlugin, ReaderResource, Sim, SimResource
|
|
|
|
|
|
def _run(coro):
|
|
# match pm3py's suite convention — a shared loop that isn't closed between tests
|
|
# (asyncio.run() closes the loop and poisons every test that runs after this file)
|
|
return asyncio.get_event_loop().run_until_complete(coro)
|
|
|
|
|
|
def test_plugin_is_a_workspace_plugin():
|
|
assert issubclass(Pm3pyPlugin, WorkspacePlugin)
|
|
assert Pm3pyPlugin.name == "pm3py"
|
|
|
|
|
|
def test_entry_point_registered():
|
|
# pyws discovers plugins from this group; requires an (editable) install of pm3py
|
|
from importlib.metadata import entry_points
|
|
|
|
eps = entry_points()
|
|
group = eps.select(group="pyws.plugins") if hasattr(eps, "select") else eps.get("pyws.plugins", [])
|
|
assert "pm3py" in {ep.name for ep in group}
|
|
|
|
|
|
def test_namespace_injects_tag_classes():
|
|
ns = Pm3pyPlugin().namespace(session=None)
|
|
assert "SimSession" in ns
|
|
assert "NTAG213" in ns and callable(ns["NTAG213"])
|
|
assert "Proxmark3" in ns
|
|
# a constructed tag really is a pm3py transponder
|
|
tag = ns["NTAG213"]()
|
|
assert hasattr(tag, "uid")
|
|
|
|
|
|
def test_effects_cover_reader_and_sim():
|
|
eff = Pm3pyPlugin.effects
|
|
assert any("wrbl" in p for p in eff["write"])
|
|
assert any("scan" in p for p in eff["read"])
|
|
assert "sim.start" in eff["sim"]
|
|
assert any(p == "*.sync" for p in eff["write"]) # tag.sync() is a write
|
|
|
|
|
|
def test_effects_classify_nested_pm3py_calls():
|
|
# end-to-end: the declared patterns must actually classify pm3py's nested API
|
|
from pyws.effects import EffectResolver, classify_statement
|
|
|
|
r = EffectResolver()
|
|
r.add_plugin(Pm3pyPlugin())
|
|
assert classify_statement("reader.hf.mfu.wrbl(4, b'aa')", {}, r) == "write"
|
|
assert classify_statement("reader.hf.iso14a.scan()", {}, r) == "read"
|
|
assert classify_statement("authorized_tag.sync()", {}, r) == "write"
|
|
# a pure model edit is unmatched -> unknown -> replays under safe-by-default
|
|
assert classify_statement("authorized_tag.set_page(4, b'aa')", {}, r) == "unknown"
|
|
|
|
|
|
def test_reader_resource_connects_and_disconnects():
|
|
mock = AsyncMock()
|
|
r = ReaderResource("reader", client=mock)
|
|
_run(r.start())
|
|
assert r.status is ResourceStatus.CONNECTED
|
|
assert r.obj is mock
|
|
mock.connect.assert_awaited_once()
|
|
_run(r.stop())
|
|
mock.disconnect.assert_awaited_once()
|
|
assert r.status is ResourceStatus.DISCONNECTED
|
|
|
|
|
|
def test_reader_resource_no_device_is_nonfatal(monkeypatch):
|
|
import pm3py.core.client as client
|
|
|
|
monkeypatch.setattr(client, "find_proxmark_port", lambda: None)
|
|
r = ReaderResource("reader") # no client, no port -> Proxmark3(None) raises -> disconnected
|
|
_run(r.start())
|
|
assert r.status is ResourceStatus.DISCONNECTED
|
|
assert r.obj is None
|
|
|
|
|
|
def test_create_resource_builds_reader_and_sim():
|
|
# fresh plugin instances → each mode is available
|
|
assert isinstance(Pm3pyPlugin().create_resource("reader", {}, session=None), ReaderResource)
|
|
assert isinstance(Pm3pyPlugin().create_resource("sim", {}, session=None), SimResource)
|
|
|
|
|
|
def test_one_mode_per_workspace():
|
|
plug = Pm3pyPlugin()
|
|
assert isinstance(plug.create_resource("reader", {}, None), ReaderResource)
|
|
# a second, different-mode connection resource is refused (serial contention)
|
|
assert plug.create_resource("sim", {}, None) is None
|
|
|
|
|
|
def test_sim_resource_opens_and_closes():
|
|
fake = MagicMock()
|
|
r = SimResource("sim", session=fake)
|
|
_run(r.start())
|
|
assert r.status is ResourceStatus.CONNECTED
|
|
assert isinstance(r.obj, Sim) and r.obj.session is fake
|
|
_run(r.stop())
|
|
fake.close.assert_called_once()
|
|
|
|
|
|
def test_sim_resource_no_device_is_nonfatal():
|
|
r = SimResource("sim", config={"port": "/dev/pyws-no-such-device"})
|
|
_run(r.start())
|
|
assert r.status is ResourceStatus.DISCONNECTED
|
|
assert r.obj is None
|
|
|
|
|
|
def test_sim_start_dispatches_by_protocol():
|
|
from pm3py.sim import NTAG213, Tag15693
|
|
|
|
sess = MagicMock()
|
|
sim = Sim(sess)
|
|
sim.start(NTAG213()) # ISO 14443-A
|
|
sess.start_14a.assert_called_once()
|
|
sess.start_15693.assert_not_called()
|
|
sim.start(Tag15693()) # ISO 15693
|
|
sess.start_15693.assert_called_once()
|
|
|
|
|
|
def test_sim_push_calls_tag_sync():
|
|
sim = Sim(MagicMock())
|
|
tag = MagicMock()
|
|
sim.push(tag)
|
|
tag.sync.assert_called_once()
|
|
|
|
|
|
def test_sim_effect_patterns():
|
|
eff = Pm3pyPlugin.effects
|
|
assert "sim.start" in eff["sim"] and "sim.stop" in eff["sim"]
|
|
assert "sim.push" in eff["write"]
|