diff --git a/.github/workflows/run-opensips-tests.yml b/.github/workflows/run-opensips-tests.yml index 8463f08..37518a7 100644 --- a/.github/workflows/run-opensips-tests.yml +++ b/.github/workflows/run-opensips-tests.yml @@ -5,6 +5,115 @@ on: push: pull_request: +# Previously this delegated to the tests repo's reusable workflow: +# uses: OpenSIPS/sipssert-opensips-tests/.github/workflows/run-tests.yml@main +# That workflow checks out `${{ github.repository }}` as the tests dir, which +# resolves to THIS framework repo when called from here -- so it looked for a +# non-existent `tests/run.yml` and the matrix step died (red since Feb 2026). +# Instead, run the conformance suite inline: install the framework from THIS +# checkout (so a PR's framework code is exercised) and pull the scenarios from +# the tests repo explicitly. + +env: + TESTS_REPO: OpenSIPS/sipssert-opensips-tests + jobs: - opensips: - uses: OpenSIPS/sipssert-opensips-tests/.github/workflows/run-tests.yml@main + setup-matrix: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - name: Checkout SIPssert (this repo) + uses: actions/checkout@v6 + with: + path: sipssert + + - name: Checkout Tests repo + uses: actions/checkout@v6 + with: + repository: ${{ env.TESTS_REPO }} + path: tests + + - name: Read and parse YAML file + id: set-matrix + run: | + MATRIX=$(python3 sipssert/.github/actions/Set_Matrix/read_matrix.py tests/run.yml) + echo "matrix=${MATRIX}" >> "$GITHUB_OUTPUT" + shell: bash + + test: + needs: setup-matrix + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.setup-matrix.outputs.matrix) }} + runs-on: ${{ matrix.os }} + steps: + - name: Checkout SIPssert (this repo) + uses: actions/checkout@v6 + with: + path: sipssert + + - name: Install SIPssert requirements + run: | + sudo apt update && sudo apt install -y tcpdump + sudo chmod +s "$(which tcpdump)" + shell: bash + + - name: Install SIPssert framework (from this checkout) + run: | + cd sipssert + python3 setup.py install --user clean + shell: bash + + - name: Checkout Tests repo + uses: actions/checkout@v6 + with: + repository: ${{ env.TESTS_REPO }} + path: tests + + - name: Run Test + uses: ./sipssert/.github/actions/Run_Tests + with: + tests: ${{ matrix.tests }} + + aggregate: + needs: test + runs-on: ubuntu-latest + if: always() + steps: + - name: Checkout Tests repo + uses: actions/checkout@v6 + with: + repository: ${{ env.TESTS_REPO }} + path: tests + + - name: Download All Artifacts + uses: actions/download-artifact@v8 + with: + path: junit-reports + + - name: Aggregate JUnit Reports + run: | + pip install junitparser + python tests/.github/scripts/aggregate_junit_reports.py + env: + REPORTS_DIR: junit-reports + OUTPUT_FILE: junit-final-report.xml + + - name: Upload JUnit XML + uses: actions/upload-artifact@v7 + with: + name: junit-final-report + path: junit-final-report.xml + + - name: Print tests summary + uses: mikepenz/action-junit-report@v6 + id: summary + if: success() || failure() + with: + check_name: 'SIPssert tests report' + report_paths: junit-final-report.xml + annotate_only: true + fail_on_failure: true + detailed_summary: true + include_passed: true diff --git a/sipssert/tasks/rtpengine.py b/sipssert/tasks/rtpengine.py new file mode 100644 index 0000000..227170c --- /dev/null +++ b/sipssert/tasks/rtpengine.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python +## +## This file is part of the SIPssert Testing Framework project +## Copyright (C) 2026 OpenSIPS Solutions +## +## This program is free software: you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation, either version 3 of the License, or +## (at your option) any later version. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with this program. If not, see . +## + +"""RTPengine runner class""" + +import shlex + +from sipssert.task import Task + + +class RTPEngineTask(Task): + + """RTPengine class""" + + default_daemon = True + default_stop_timeout = 3 + default_image = "debian:trixie-slim" + + def __init__(self, test_dir, config): + + super().__init__(test_dir, config) + self.entrypoint = config.get("entrypoint", "sh") + + self.ip = config.get("ip", "127.0.0.1") + # `ng_port` is the rtpengine control (ng) port. Note we deliberately do + # NOT alias the base `port`/`ports` keys here: those are reserved by the + # base Task for container port *publishing* and a string value there + # (e.g. "22222") would crash get_ports(); the ng port is reached over + # the scenario bridge and never published. + self.ng_port = config.get("ng_port", "22222") + self.interface = config.get("interface", self.ip) + self.table = config.get("table", "-1") + self.install = config.get("install", True) + self.packages = self.normalize_list(config.get("packages", [ + "rtpengine-daemon", + "iproute2", + ])) + self.options = config.get("options", []) + + if "healthcheck" not in config: + self.healthcheck = { + "test": f"ss -lnu | grep -q :{self.ng_port}", + "interval": 2000000000, + "timeout": 2000000000, + "retries": 120, + } + + def get_task_args(self): + return [ + "-c", + " && ".join(self.get_setup_commands() + [self.get_run_command()]), + ] + + def get_setup_commands(self): + if not self.install: + return [] + + packages = " ".join(shlex.quote(str(pkg)) for pkg in self.packages) + return [ + "echo 'exit 101' > /usr/sbin/policy-rc.d", + "chmod +x /usr/sbin/policy-rc.d", + "apt-get update", + "DEBIAN_FRONTEND=noninteractive apt-get install -y " + f"--no-install-recommends {packages}", + ] + + def get_run_command(self): + args = [ + "exec", + "rtpengine", + "--table=" + str(self.table), + "--interface=" + str(self.interface), + "--listen-ng=" + str(self.ip) + ":" + str(self.ng_port), + "--foreground", + "--log-stderr", + ] + args += self.normalize_options(self.options) + return " ".join(shlex.quote(str(arg)) for arg in args) + + def normalize_options(self, options): + return self.normalize_list(options) + + def normalize_list(self, options): + if options is None: + return [] + if isinstance(options, str): + return shlex.split(options) + return [str(option) for option in options] + + +# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/tests/test_opensips_mi_task.py b/tests/test_opensips_mi_task.py new file mode 100644 index 0000000..53174f8 --- /dev/null +++ b/tests/test_opensips_mi_task.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python +"""Regression tests pinning that the opensips-mi task can run an arbitrary +python script with forwarded arguments (via the base task `args:` support). + +This is what lets the rtp_relay ng_checker run as `type: opensips-mi` instead of +a bespoke generic task. +""" + +import importlib +import unittest + + +def make(config=None): + cfg = {"name": "ng checker"} + if config: + cfg.update(config) + mod = importlib.import_module("sipssert.tasks.opensips-mi") + return mod.OpenSIPSMITask("/test", cfg) + + +class TestOpenSIPSMIScript(unittest.TestCase): + + def test_script_relative_is_mounted_under_home(self): + t = make({"script": "scripts/ng_checker.py"}) + self.assertEqual(t.get_task_args(), ["/home/scripts/ng_checker.py"]) + + def test_script_absolute_is_left_untouched(self): + t = make({"script": "/abs/checker.py"}) + self.assertEqual(t.get_task_args(), ["/abs/checker.py"]) + + def test_script_args_are_forwarded_after_script(self): + t = make({ + "script": "scripts/ng_checker.py", + "args": ["/caps/ng.pcap", "savpf", "--ng-port", "22222"], + }) + self.assertEqual( + t.get_args(), + ["/home/scripts/ng_checker.py", + "/caps/ng.pcap", "savpf", "--ng-port", "22222"], + ) + + def test_args_with_special_chars_pass_through_as_argv(self): + # args are argv elements (no shell), so special chars survive verbatim + t = make({"script": "scripts/c.py", "args": ["a b", "x\\y", "--re=$x"]}) + self.assertEqual( + t.get_args(), + ["/home/scripts/c.py", "a b", "x\\y", "--re=$x"], + ) + + def test_integer_args_are_stringified(self): + t = make({"script": "scripts/c.py", "args": [22222]}) + self.assertEqual(t.get_args(), ["/home/scripts/c.py", "22222"]) + + def test_no_script_keeps_mi_cli_fallback(self): + t = make({"mi_ip": "192.168.52.1", "mi_port": 8888}) + self.assertEqual( + t.get_task_args(), + ["-t", "http", "-i", "192.168.52.1", "-p", "8888"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rtpengine_task.py b/tests/test_rtpengine_task.py new file mode 100644 index 0000000..d4884f4 --- /dev/null +++ b/tests/test_rtpengine_task.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python +"""Unit tests for the reusable rtpengine task (sipssert.tasks.rtpengine).""" + +import importlib +import unittest + + +def make(config=None): + cfg = {"name": "rtpengine"} + if config: + cfg.update(config) + mod = importlib.import_module("sipssert.tasks.rtpengine") + return mod.RTPEngineTask("/test", cfg) + + +class TestRTPEngineDefaults(unittest.TestCase): + + def test_class_resolves_from_type(self): + # the framework derives the class name from the task `type`: the + # normalized class name must be "rtpenginetask" so `type: rtpengine` + # selects it. + mod = importlib.import_module("sipssert.tasks.rtpengine") + cls = mod.RTPEngineTask + normalized = "".join(c for c in "rtpengine" if c.isalnum()) + "task" + self.assertEqual(cls.__name__.lower(), normalized) + + def test_defaults(self): + t = make() + self.assertTrue(t.daemon) # media proxy runs as a daemon + self.assertEqual(t.stop_timeout, 3) + self.assertEqual(t.image, "debian:trixie-slim") + self.assertEqual(t.entrypoint, "sh") + self.assertEqual(str(t.ng_port), "22222") + self.assertEqual(t.interface, t.ip) # interface defaults to ip + self.assertEqual(t.ip, "127.0.0.1") + self.assertEqual(str(t.table), "-1") + + def test_default_healthcheck_gates_on_ng_socket(self): + t = make({"ng_port": 23456}) + self.assertEqual(t.healthcheck["test"], "ss -lnu | grep -q :23456") + self.assertEqual(t.healthcheck["retries"], 120) + + def test_custom_healthcheck_is_preserved(self): + hc = {"test": "true", "retries": 1} + t = make({"healthcheck": hc}) + self.assertEqual(t.healthcheck, hc) + + +class TestRTPEngineRunCommand(unittest.TestCase): + + def test_run_command_has_required_flags(self): + t = make({"ip": "192.168.52.4", "ng_port": "22222"}) + run = t.get_run_command() + self.assertIn("exec rtpengine", run) + self.assertIn("--table=-1", run) + self.assertIn("--interface=192.168.52.4", run) + self.assertIn("--listen-ng=192.168.52.4:22222", run) + self.assertIn("--foreground", run) + self.assertIn("--log-stderr", run) + + def test_ng_port_is_the_only_knob(self): + t = make({"ng_port": "30000"}) + self.assertEqual(str(t.ng_port), "30000") + self.assertIn(":30000", t.get_run_command()) + + def test_base_port_is_not_aliased_to_ng_port(self): + # `port` belongs to the base Task (container port publishing) and must + # NOT silently retarget the ng control port. + t = make({"port": 30000}) + self.assertEqual(str(t.ng_port), "22222") + self.assertIn(":22222", t.get_run_command()) + + def test_custom_interface_and_table(self): + t = make({"ip": "10.0.0.1", "interface": "eth0/10.0.0.1", + "table": "0"}) + run = t.get_run_command() + self.assertIn("--interface=eth0/10.0.0.1", run) + self.assertIn("--table=0", run) + + def test_options_as_list_are_appended(self): + t = make({"options": ["--delete-delay=0", "--no-fallback"]}) + run = t.get_run_command() + self.assertIn("--delete-delay=0", run) + self.assertIn("--no-fallback", run) + + def test_options_as_string_are_split(self): + t = make({"options": "--delete-delay=0 --no-fallback"}) + run = t.get_run_command() + self.assertIn("--delete-delay=0", run) + self.assertIn("--no-fallback", run) + + +class TestRTPEngineSetup(unittest.TestCase): + + def test_install_commands_present_by_default(self): + cmds = make().get_setup_commands() + joined = " && ".join(cmds) + self.assertIn("policy-rc.d", joined) + self.assertIn("apt-get update", joined) + self.assertIn("apt-get install", joined) + self.assertIn("rtpengine-daemon", joined) + self.assertIn("iproute2", joined) + + def test_install_disabled_skips_apt(self): + self.assertEqual(make({"install": False}).get_setup_commands(), []) + + def test_custom_packages(self): + cmds = make({"packages": ["rtpengine-daemon", "tcpdump"]}).get_setup_commands() + joined = " ".join(cmds) + self.assertIn("tcpdump", joined) + self.assertNotIn("iproute2", joined) + + def test_task_args_chain_setup_then_run(self): + args = make().get_task_args() + self.assertEqual(args[0], "-c") + self.assertEqual(len(args), 2) + # the run command is the last link of the && chain + self.assertTrue(args[1].rstrip().endswith("--log-stderr")) + self.assertIn("apt-get install", args[1]) + self.assertLess(args[1].index("apt-get install"), args[1].index("exec rtpengine")) + + def test_task_args_when_install_disabled_is_only_run(self): + args = make({"install": False}).get_task_args() + self.assertEqual(args[0], "-c") + self.assertNotIn("apt-get", args[1]) + self.assertIn("exec rtpengine", args[1]) + + +class TestRTPEngineAdversarial(unittest.TestCase): + + def test_normalize_list_none_empty_str(self): + t = make() + self.assertEqual(t.normalize_list(None), []) + self.assertEqual(t.normalize_list(""), []) + self.assertEqual(t.normalize_list([]), []) + + def test_normalize_list_mixed_types(self): + t = make() + self.assertEqual(t.normalize_list(["a", 1]), ["a", "1"]) + + def test_package_with_space_is_quoted(self): + # a value containing a space must survive as a single shell token + cmds = make({"packages": ["weird pkg"]}).get_setup_commands() + install = [c for c in cmds if "install" in c][0] + self.assertIn("'weird pkg'", install) + + def test_option_with_backslash_is_quoted(self): + t = make({"options": ["--cfg=a\\b"]}) + run = t.get_run_command() + # the backslash token must not be word-split away + self.assertIn("a\\b", run) + self.assertIn("--cfg=a\\b", "".join(t.normalize_options(["--cfg=a\\b"]))) + + def test_option_with_special_chars_quoted(self): + t = make({"options": ["--x=$(echo hi)"]}) + run = t.get_run_command() + # must be quoted so the subshell is not evaluated by the container shell + self.assertIn("'--x=$(echo hi)'", run) + + +if __name__ == "__main__": + unittest.main()