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
30 changes: 30 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
11 changes: 10 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,13 @@ reversec = "src/reversec"
pysolar = "src/pysolar"

[tool.setuptools.dynamic]
version = { attr = "drozer.__version__" }
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"]
72 changes: 72 additions & 0 deletions tests/test_android.py
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions tests/test_fs.py
Original file line number Diff line number Diff line change
@@ -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""
35 changes: 35 additions & 0 deletions tests/test_list.py
Original file line number Diff line number Diff line change
@@ -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([])) == []
27 changes: 27 additions & 0 deletions tests/test_text.py
Original file line number Diff line number Diff line change
@@ -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)
47 changes: 47 additions & 0 deletions tests/test_util.py
Original file line number Diff line number Diff line change
@@ -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)