diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..173eeaec --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,30 @@ +name: tests + +on: + push: + pull_request: + +jobs: + pytest: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install pytest + run: python -m pip install --upgrade pip pytest + + - name: Run unit tests + # The suite imports only dependency-free host-side modules and resolves + # them via pythonpath = ["src"] (see pyproject.toml), so the full + # package install is not required to run it. + run: pytest diff --git a/pyproject.toml b/pyproject.toml index fe951add..90b2587a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,4 +45,13 @@ reversec = "src/reversec" pysolar = "src/pysolar" [tool.setuptools.dynamic] -version = { attr = "drozer.__version__" } \ No newline at end of file +version = { attr = "drozer.__version__" } + +[project.optional-dependencies] +test = ["pytest"] + +[tool.pytest.ini_options] +# The package sources live under src/; expose them so the unit tests can be +# run directly (without an editable install) via `pytest`. +pythonpath = ["src"] +testpaths = ["tests"] \ No newline at end of file diff --git a/tests/test_android.py b/tests/test_android.py new file mode 100644 index 00000000..90c785e1 --- /dev/null +++ b/tests/test_android.py @@ -0,0 +1,72 @@ +"""Unit tests for drozer.android.Intent (host-side logic only). + +These cover the pure-Python behaviour of Intent that does not require a live +Android session: validity checks, default attributes and flag combination. +""" + +import argparse + +from drozer.android import Intent + + +class TestIntentValidity: + + def test_empty_intent_is_invalid(self): + # An Intent must carry at least an action or a component. + assert Intent().isValid() is False + + def test_action_makes_intent_valid(self): + assert Intent(action="android.intent.action.VIEW").isValid() is True + + def test_component_makes_intent_valid(self): + assert Intent(component=["com.example", "com.example.Activity"]).isValid() is True + + +class TestIntentDefaults: + + def test_attributes_default_to_none(self): + intent = Intent() + assert intent.action is None + assert intent.category is None + assert intent.component is None + assert intent.data_uri is None + assert intent.extras is None + assert intent.flags is None + assert intent.mimetype is None + + +class TestIntentFlags: + + # __build_flags is name-mangled; access it directly to unit-test the pure + # flag-combination logic without needing a Java context. + def _build(self, flags): + return Intent()._Intent__build_flags(flags) + + def test_single_named_flag(self): + assert self._build(["ACTIVITY_NEW_TASK"]) == 0x10000000 + + def test_named_flags_are_ored_together(self): + assert self._build(["ACTIVITY_NEW_TASK", "ACTIVITY_CLEAR_TOP"]) == 0x14000000 + + def test_hexadecimal_flag(self): + assert self._build(["0x10000000"]) == 0x10000000 + + def test_empty_flag_list_is_zero(self): + assert self._build([]) == 0x00000000 + + +class TestIntentFromParser: + + def test_builds_intent_from_namespace(self): + namespace = argparse.Namespace( + action="android.intent.action.MAIN", + category=None, + component=None, + data_uri=None, + extras=[], + flags=[], + mimetype=None, + ) + intent = Intent.fromParser(namespace) + assert intent.action == "android.intent.action.MAIN" + assert intent.isValid() is True diff --git a/tests/test_fs.py b/tests/test_fs.py new file mode 100644 index 00000000..629fea13 --- /dev/null +++ b/tests/test_fs.py @@ -0,0 +1,28 @@ +"""Unit tests for reversec.common.fs.""" + +from reversec.common import fs + + +class TestReadWrite: + + def test_write_then_read_round_trips(self, tmp_path): + target = str(tmp_path / "data.bin") + written = fs.write(target, b"hello world") + assert written == len(b"hello world") + assert fs.read(target) == b"hello world" + + def test_read_missing_file_returns_none(self, tmp_path): + assert fs.read(str(tmp_path / "does-not-exist")) is None + + def test_read_as_text(self, tmp_path): + target = str(tmp_path / "data.txt") + fs.write(target, b"plain text") + assert fs.read(target, "r") == "plain text" + + +class TestTouch: + + def test_touch_creates_empty_file(self, tmp_path): + target = str(tmp_path / "touched") + fs.touch(target) + assert fs.read(target) == b"" diff --git a/tests/test_list.py b/tests/test_list.py new file mode 100644 index 00000000..149d388d --- /dev/null +++ b/tests/test_list.py @@ -0,0 +1,35 @@ +"""Unit tests for reversec.common.list.""" + +from reversec.common.list import chunk, flatten + + +class TestChunk: + + def test_splits_into_even_chunks(self): + assert list(chunk([1, 2, 3, 4], 2)) == [[1, 2], [3, 4]] + + def test_final_chunk_may_be_short(self): + assert list(chunk([1, 2, 3, 4, 5], 2)) == [[1, 2], [3, 4], [5]] + + def test_empty_list_yields_nothing(self): + assert list(chunk([], 2)) == [] + + def test_chunk_larger_than_list(self): + assert list(chunk([1, 2], 10)) == [[1, 2]] + + +class TestFlatten: + + def test_flattens_nested_lists(self): + assert list(flatten([1, [2, 3], [4, [5, 6]]])) == [1, 2, 3, 4, 5, 6] + + def test_flattens_tuples(self): + assert list(flatten([1, (2, 3)])) == [1, 2, 3] + + def test_strings_are_not_flattened(self): + # Strings are iterable but must be treated as scalar values, otherwise + # they would be exploded into individual characters. + assert list(flatten(["ab", ["cd"]])) == ["ab", "cd"] + + def test_empty_list(self): + assert list(flatten([])) == [] diff --git a/tests/test_text.py b/tests/test_text.py new file mode 100644 index 00000000..8a42027c --- /dev/null +++ b/tests/test_text.py @@ -0,0 +1,27 @@ +"""Unit tests for reversec.common.text.""" + +from reversec.common.text import indent, wrap + + +class TestIndent: + + def test_prefixes_every_line(self): + assert indent("a\nb\nc", " ") == " a\n b\n c" + + def test_single_line(self): + assert indent("hello", ">> ") == ">> hello" + + def test_empty_string_still_prefixed(self): + assert indent("", ">") == ">" + + +class TestWrap: + + def test_text_shorter_than_width_is_unchanged(self): + assert wrap("one two three", 1000) == "one two three" + + def test_wraps_on_word_boundaries(self): + assert wrap("aaa bbb ccc", 5) == "aaa\nbbb\nccc" + + def test_existing_newlines_are_preserved(self): + assert "\n" in wrap("first line\nsecond line", 1000) diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 00000000..18c6fe9f --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,47 @@ +"""Unit tests for drozer.util.""" + +import argparse + +import pytest + +from drozer.util import DefaultHost, DefaultPort, StoreZeroOrTwo, parse_server + + +class TestParseServer: + + def test_none_uses_defaults(self): + host, port = parse_server(None) + # DefaultHost ("localhost") is resolved to an address by parse_server. + assert port == DefaultPort + assert host # a non-empty resolved address + + def test_host_only_uses_default_port(self): + assert parse_server("127.0.0.1") == ("127.0.0.1", DefaultPort) + + def test_host_and_port(self): + assert parse_server("10.0.0.5:9999") == ("10.0.0.5", 9999) + + def test_port_is_returned_as_int(self): + _, port = parse_server("127.0.0.1:1234") + assert isinstance(port, int) and port == 1234 + + +class TestStoreZeroOrTwo: + + def _action(self): + return StoreZeroOrTwo(option_strings=["--ssl"], dest="ssl") + + def test_accepts_zero_arguments(self): + action, namespace = self._action(), argparse.Namespace() + action(None, namespace, [], None) + assert namespace.ssl == [] + + def test_accepts_two_arguments(self): + action, namespace = self._action(), argparse.Namespace() + action(None, namespace, ["cert.pem", "key.pem"], None) + assert namespace.ssl == ["cert.pem", "key.pem"] + + def test_rejects_one_argument(self): + action, namespace = self._action(), argparse.Namespace() + with pytest.raises(argparse.ArgumentTypeError): + action(None, namespace, ["cert.pem"], None)