]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
test: add a mock ModemManager for basic coverage of sd-networkd's integration 41402/head
authorLuca Boccassi <luca.boccassi@gmail.com>
Sat, 28 Mar 2026 14:40:11 +0000 (14:40 +0000)
committerLuca Boccassi <luca.boccassi@gmail.com>
Sat, 28 Mar 2026 15:19:21 +0000 (15:19 +0000)
Just the minimal setup and test case required to cover
https://github.com/systemd/systemd/issues/41389 for now, can be
expanded in the future

Boring boilerplate is bot-made, don't @ me

Co-developed-by: Claude Opus 4.6 noreply@anthropic.com
src/network/meson.build
src/network/test-modem-manager-mock.c [new file with mode: 0644]
test/integration-tests/TEST-85-NETWORK/meson.build
test/test-network/conf/25-wwan-ipv4v6.network [new file with mode: 0644]
test/test-network/conf/mock-modem-manager.conf [new file with mode: 0644]
test/test-network/systemd-networkd-tests.py

index 85b57669e460228c4e76cd644ff05e5cf22c48c3..00361a0017ed96bedde3d7ebd7b2ceb027cd8a06 100644 (file)
@@ -258,6 +258,12 @@ executables += [
         network_test_template + {
                 'sources' : files('test-networkd-util.c'),
         },
+        test_template + {
+                'sources' : files('test-modem-manager-mock.c'),
+                'conditions' : ['ENABLE_NETWORKD'],
+                'link_with' : [libshared],
+                'type' : 'manual',
+        },
         network_fuzz_template + {
                 'sources' : files('fuzz-netdev-parser.c'),
         },
diff --git a/src/network/test-modem-manager-mock.c b/src/network/test-modem-manager-mock.c
new file mode 100644 (file)
index 0000000..60f0dfa
--- /dev/null
@@ -0,0 +1,486 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+/*
+ * Minimal mock of ModemManager's D-Bus interface for testing systemd-networkd
+ * wwan/bearer support.
+ *
+ * Claims the org.freedesktop.ModemManager1 bus name and responds to:
+ * - GetManagedObjects on /org/freedesktop/ModemManager1
+ * - GetAll on /org/freedesktop/ModemManager1/Bearer/0
+ * - Simple.Connect on /org/freedesktop/ModemManager1/Modem/0
+ */
+
+#include <getopt.h>
+
+#include "sd-bus.h"
+#include "sd-daemon.h"
+#include "sd-event.h"
+
+#include "alloc-util.h"
+#include "build.h"
+#include "log.h"
+#include "main-func.h"
+#include "parse-util.h"
+#include "string-util.h"
+
+static char *arg_ifname = NULL;
+static char *arg_ipv4_address = NULL;
+static char *arg_ipv4_gateway = NULL;
+static uint32_t arg_ipv4_prefix = 24;
+static char *arg_ipv6_address = NULL;
+static char *arg_ipv6_gateway = NULL;
+static uint32_t arg_ipv6_prefix = 64;
+
+STATIC_DESTRUCTOR_REGISTER(arg_ifname, freep);
+STATIC_DESTRUCTOR_REGISTER(arg_ipv4_address, freep);
+STATIC_DESTRUCTOR_REGISTER(arg_ipv4_gateway, freep);
+STATIC_DESTRUCTOR_REGISTER(arg_ipv6_address, freep);
+STATIC_DESTRUCTOR_REGISTER(arg_ipv6_gateway, freep);
+
+/* ModemManager enum values */
+#define MM_BEARER_IP_METHOD_STATIC 2
+#define MM_MODEM_PORT_TYPE_NET     2
+#define MM_MODEM_STATE_CONNECTED   11
+
+static int append_bearer_properties(sd_bus_message *reply) {
+        int r;
+
+        /* a{sv} of bearer properties */
+        r = sd_bus_message_open_container(reply, 'a', "{sv}");
+        if (r < 0)
+                return r;
+
+        /* Interface */
+        r = sd_bus_message_append(reply, "{sv}", "Interface", "s", arg_ifname);
+        if (r < 0)
+                return r;
+
+        /* Connected */
+        r = sd_bus_message_append(reply, "{sv}", "Connected", "b", true);
+        if (r < 0)
+                return r;
+
+        /* Ip4Config: a{sv} */
+        if (arg_ipv4_address) {
+                r = sd_bus_message_open_container(reply, 'e', "sv");
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_append_basic(reply, 's', "Ip4Config");
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_open_container(reply, 'v', "a{sv}");
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_open_container(reply, 'a', "{sv}");
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_append(reply, "{sv}", "method", "u", (uint32_t) MM_BEARER_IP_METHOD_STATIC);
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_append(reply, "{sv}", "address", "s", arg_ipv4_address);
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_append(reply, "{sv}", "prefix", "u", arg_ipv4_prefix);
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_append(reply, "{sv}", "gateway", "s", arg_ipv4_gateway);
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_append(reply, "{sv}", "mtu", "u", (uint32_t) 1500);
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_close_container(reply); /* a{sv} */
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_close_container(reply); /* v */
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_close_container(reply); /* e */
+                if (r < 0)
+                        return r;
+        }
+
+        /* Ip6Config: a{sv} */
+        if (arg_ipv6_address) {
+                r = sd_bus_message_open_container(reply, 'e', "sv");
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_append_basic(reply, 's', "Ip6Config");
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_open_container(reply, 'v', "a{sv}");
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_open_container(reply, 'a', "{sv}");
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_append(reply, "{sv}", "method", "u", (uint32_t) MM_BEARER_IP_METHOD_STATIC);
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_append(reply, "{sv}", "address", "s", arg_ipv6_address);
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_append(reply, "{sv}", "prefix", "u", arg_ipv6_prefix);
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_append(reply, "{sv}", "gateway", "s", arg_ipv6_gateway);
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_append(reply, "{sv}", "mtu", "u", (uint32_t) 1500);
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_close_container(reply); /* a{sv} */
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_close_container(reply); /* v */
+                if (r < 0)
+                        return r;
+                r = sd_bus_message_close_container(reply); /* e */
+                if (r < 0)
+                        return r;
+        }
+
+        /* Properties: a{sv} with apn */
+        r = sd_bus_message_open_container(reply, 'e', "sv");
+        if (r < 0)
+                return r;
+        r = sd_bus_message_append_basic(reply, 's', "Properties");
+        if (r < 0)
+                return r;
+        r = sd_bus_message_open_container(reply, 'v', "a{sv}");
+        if (r < 0)
+                return r;
+        r = sd_bus_message_open_container(reply, 'a', "{sv}");
+        if (r < 0)
+                return r;
+        r = sd_bus_message_append(reply, "{sv}", "apn", "s", "internet.test");
+        if (r < 0)
+                return r;
+        r = sd_bus_message_close_container(reply); /* a{sv} */
+        if (r < 0)
+                return r;
+        r = sd_bus_message_close_container(reply); /* v */
+        if (r < 0)
+                return r;
+        r = sd_bus_message_close_container(reply); /* e */
+        if (r < 0)
+                return r;
+
+        r = sd_bus_message_close_container(reply); /* outer a{sv} */
+        if (r < 0)
+                return r;
+
+        return 0;
+}
+
+static int handle_get_managed_objects(sd_bus_message *msg, void *userdata, sd_bus_error *error) {
+        _cleanup_(sd_bus_message_unrefp) sd_bus_message *reply = NULL;
+        int r;
+
+        r = sd_bus_message_new_method_return(msg, &reply);
+        if (r < 0)
+                return r;
+
+        /* a{oa{sa{sv}}} */
+        r = sd_bus_message_open_container(reply, 'a', "{oa{sa{sv}}}");
+        if (r < 0)
+                return r;
+
+        /* Modem object */
+        r = sd_bus_message_open_container(reply, 'e', "oa{sa{sv}}");
+        if (r < 0)
+                return r;
+        r = sd_bus_message_append_basic(reply, 'o', "/org/freedesktop/ModemManager1/Modem/0");
+        if (r < 0)
+                return r;
+
+        /* Array of interfaces */
+        r = sd_bus_message_open_container(reply, 'a', "{sa{sv}}");
+        if (r < 0)
+                return r;
+
+        /* org.freedesktop.ModemManager1.Modem interface */
+        r = sd_bus_message_open_container(reply, 'e', "sa{sv}");
+        if (r < 0)
+                return r;
+        r = sd_bus_message_append_basic(reply, 's', "org.freedesktop.ModemManager1.Modem");
+        if (r < 0)
+                return r;
+
+        /* Modem properties: a{sv} */
+        r = sd_bus_message_open_container(reply, 'a', "{sv}");
+        if (r < 0)
+                return r;
+
+        /* Bearers: ao */
+        r = sd_bus_message_append(reply, "{sv}", "Bearers", "ao", 1, "/org/freedesktop/ModemManager1/Bearer/0");
+        if (r < 0)
+                return r;
+
+        /* State: i (CONNECTED) */
+        r = sd_bus_message_append(reply, "{sv}", "State", "i", (int32_t) MM_MODEM_STATE_CONNECTED);
+        if (r < 0)
+                return r;
+
+        /* StateFailedReason: u (NONE) */
+        r = sd_bus_message_append(reply, "{sv}", "StateFailedReason", "u", (uint32_t) 0);
+        if (r < 0)
+                return r;
+
+        /* Manufacturer */
+        r = sd_bus_message_append(reply, "{sv}", "Manufacturer", "s", "MockModem");
+        if (r < 0)
+                return r;
+
+        /* Model */
+        r = sd_bus_message_append(reply, "{sv}", "Model", "s", "Virtual");
+        if (r < 0)
+                return r;
+
+        /* Ports: a(su) — array of structs with port name and type */
+        r = sd_bus_message_open_container(reply, 'e', "sv");
+        if (r < 0)
+                return r;
+        r = sd_bus_message_append_basic(reply, 's', "Ports");
+        if (r < 0)
+                return r;
+        r = sd_bus_message_open_container(reply, 'v', "a(su)");
+        if (r < 0)
+                return r;
+        r = sd_bus_message_open_container(reply, 'a', "(su)");
+        if (r < 0)
+                return r;
+        r = sd_bus_message_append(reply, "(su)", arg_ifname, (uint32_t) MM_MODEM_PORT_TYPE_NET);
+        if (r < 0)
+                return r;
+        r = sd_bus_message_close_container(reply); /* a(su) */
+        if (r < 0)
+                return r;
+        r = sd_bus_message_close_container(reply); /* v */
+        if (r < 0)
+                return r;
+        r = sd_bus_message_close_container(reply); /* e */
+        if (r < 0)
+                return r;
+
+        r = sd_bus_message_close_container(reply); /* modem properties a{sv} */
+        if (r < 0)
+                return r;
+        r = sd_bus_message_close_container(reply); /* e sa{sv} */
+        if (r < 0)
+                return r;
+        r = sd_bus_message_close_container(reply); /* a{sa{sv}} */
+        if (r < 0)
+                return r;
+        r = sd_bus_message_close_container(reply); /* e oa{sa{sv}} */
+        if (r < 0)
+                return r;
+        r = sd_bus_message_close_container(reply); /* a{oa{sa{sv}}} */
+        if (r < 0)
+                return r;
+
+        r = sd_bus_send(NULL, reply, NULL);
+        if (r < 0)
+                return r;
+
+        return 1; /* handled */
+}
+
+static int handle_get_all(sd_bus_message *msg, void *userdata, sd_bus_error *error) {
+        _cleanup_(sd_bus_message_unrefp) sd_bus_message *reply = NULL;
+        int r;
+
+        r = sd_bus_message_new_method_return(msg, &reply);
+        if (r < 0)
+                return r;
+
+        /* bearer_get_all_handler() in networkd expects a leading interface name string
+         * before the a{sv} properties dict (it calls sd_bus_message_skip(message, "s")). */
+        r = sd_bus_message_append_basic(reply, 's', "org.freedesktop.ModemManager1.Bearer");
+        if (r < 0)
+                return r;
+
+        r = append_bearer_properties(reply);
+        if (r < 0)
+                return r;
+
+        r = sd_bus_send(NULL, reply, NULL);
+        if (r < 0)
+                return r;
+
+        return 1; /* handled */
+}
+
+static int handle_simple_connect(sd_bus_message *msg, void *userdata, sd_bus_error *error) {
+        _cleanup_(sd_bus_message_unrefp) sd_bus_message *reply = NULL;
+        int r;
+
+        /* Return the bearer path */
+        r = sd_bus_message_new_method_return(msg, &reply);
+        if (r < 0)
+                return r;
+
+        r = sd_bus_message_append(reply, "o", "/org/freedesktop/ModemManager1/Bearer/0");
+        if (r < 0)
+                return r;
+
+        r = sd_bus_send(NULL, reply, NULL);
+        if (r < 0)
+                return r;
+
+        return 1; /* handled */
+}
+
+static int filter_handler(sd_bus_message *m, void *userdata, sd_bus_error *error) {
+        const char *path, *interface, *member;
+        uint8_t type;
+
+        if (sd_bus_message_get_type(m, &type) < 0 || type != SD_BUS_MESSAGE_METHOD_CALL)
+                return 0;
+
+        path = sd_bus_message_get_path(m);
+        interface = sd_bus_message_get_interface(m);
+        member = sd_bus_message_get_member(m);
+
+        if (!path || !interface || !member)
+                return 0;
+
+        if (streq(path, "/org/freedesktop/ModemManager1") &&
+            streq(interface, "org.freedesktop.DBus.ObjectManager") &&
+            streq(member, "GetManagedObjects"))
+                return handle_get_managed_objects(m, userdata, error);
+
+        if (startswith(path, "/org/freedesktop/ModemManager1/Bearer/") &&
+            streq(interface, "org.freedesktop.DBus.Properties") &&
+            streq(member, "GetAll"))
+                return handle_get_all(m, userdata, error);
+
+        if (startswith(path, "/org/freedesktop/ModemManager1/Modem/") &&
+            streq(interface, "org.freedesktop.ModemManager1.Modem.Simple") &&
+            streq(member, "Connect"))
+                return handle_simple_connect(m, userdata, error);
+
+        return 0;
+}
+
+static int parse_argv(int argc, char *argv[]) {
+        enum {
+                ARG_IFNAME = 0x100,
+                ARG_IPV4_ADDRESS,
+                ARG_IPV4_GATEWAY,
+                ARG_IPV4_PREFIX,
+                ARG_IPV6_ADDRESS,
+                ARG_IPV6_GATEWAY,
+                ARG_IPV6_PREFIX,
+        };
+
+        static const struct option options[] = {
+                { "ifname",       required_argument, NULL, ARG_IFNAME       },
+                { "ipv4-address", required_argument, NULL, ARG_IPV4_ADDRESS },
+                { "ipv4-gateway", required_argument, NULL, ARG_IPV4_GATEWAY },
+                { "ipv4-prefix",  required_argument, NULL, ARG_IPV4_PREFIX  },
+                { "ipv6-address", required_argument, NULL, ARG_IPV6_ADDRESS },
+                { "ipv6-gateway", required_argument, NULL, ARG_IPV6_GATEWAY },
+                { "ipv6-prefix",  required_argument, NULL, ARG_IPV6_PREFIX  },
+                { "version",      no_argument,       NULL, 'v'              },
+                { "help",         no_argument,       NULL, 'h'              },
+                {}
+        };
+
+        int c, r;
+
+        while ((c = getopt_long(argc, argv, "vh", options, NULL)) >= 0)
+                switch (c) {
+                case ARG_IFNAME:
+                        if (free_and_strdup(&arg_ifname, optarg) < 0)
+                                return log_oom();
+                        break;
+                case ARG_IPV4_ADDRESS:
+                        if (free_and_strdup(&arg_ipv4_address, optarg) < 0)
+                                return log_oom();
+                        break;
+                case ARG_IPV4_GATEWAY:
+                        if (free_and_strdup(&arg_ipv4_gateway, optarg) < 0)
+                                return log_oom();
+                        break;
+                case ARG_IPV4_PREFIX:
+                        r = safe_atou32(optarg, &arg_ipv4_prefix);
+                        if (r < 0)
+                                return log_error_errno(r, "Failed to parse IPv4 prefix length: %m");
+                        break;
+                case ARG_IPV6_ADDRESS:
+                        if (free_and_strdup(&arg_ipv6_address, optarg) < 0)
+                                return log_oom();
+                        break;
+                case ARG_IPV6_GATEWAY:
+                        if (free_and_strdup(&arg_ipv6_gateway, optarg) < 0)
+                                return log_oom();
+                        break;
+                case ARG_IPV6_PREFIX:
+                        r = safe_atou32(optarg, &arg_ipv6_prefix);
+                        if (r < 0)
+                                return log_error_errno(r, "Failed to parse IPv6 prefix length: %m");
+                        break;
+                case 'v':
+                        return version();
+                case 'h':
+                        printf("Usage: %s [OPTIONS...]\n\n"
+                               "Mock ModemManager D-Bus service for testing.\n\n"
+                               "  --ifname=NAME          Interface name\n"
+                               "  --ipv4-address=ADDR    IPv4 address\n"
+                               "  --ipv4-gateway=ADDR    IPv4 gateway\n"
+                               "  --ipv4-prefix=LEN      IPv4 prefix length\n"
+                               "  --ipv6-address=ADDR    IPv6 address\n"
+                               "  --ipv6-gateway=ADDR    IPv6 gateway\n"
+                               "  --ipv6-prefix=LEN      IPv6 prefix length\n"
+                               "  -h, --help             Show this help\n"
+                               "  -v, --version          Show version\n",
+                               program_invocation_short_name);
+                        return 0;
+                default:
+                        return -EINVAL;
+                }
+
+        if (!arg_ifname)
+                return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "--ifname is required");
+
+        return 1; /* work to do */
+}
+
+static int run(int argc, char *argv[]) {
+        _cleanup_(sd_bus_flush_close_unrefp) sd_bus *bus = NULL;
+        _cleanup_(sd_event_unrefp) sd_event *event = NULL;
+        int r;
+
+        log_setup();
+
+        r = parse_argv(argc, argv);
+        if (r <= 0)
+                return r;
+
+        r = sd_event_new(&event);
+        if (r < 0)
+                return log_error_errno(r, "Failed to create event loop: %m");
+
+        r = sd_bus_open_system(&bus);
+        if (r < 0)
+                return log_error_errno(r, "Failed to connect to system bus: %m");
+
+        r = sd_bus_add_filter(bus, NULL, filter_handler, NULL);
+        if (r < 0)
+                return log_error_errno(r, "Failed to add filter: %m");
+
+        r = sd_bus_request_name(bus, "org.freedesktop.ModemManager1", 0);
+        if (r < 0)
+                return log_error_errno(r, "Failed to acquire bus name: %m");
+
+        r = sd_bus_attach_event(bus, event, 0);
+        if (r < 0)
+                return log_error_errno(r, "Failed to attach bus to event loop: %m");
+
+        (void) sd_notify(0, "READY=1");
+
+        return sd_event_loop(event);
+}
+
+DEFINE_MAIN_FUNCTION(run);
index 9e8534cff02e42f06ba076a6c95fb4c3367fbfaa..f708f05067b33799fcf968e6a00cfb8f00644b19 100644 (file)
@@ -23,6 +23,7 @@ foreach testcase : [
         'NetworkdIPv6PrefixTests',
         'NetworkdMTUTests',
         'NetworkdSysctlTest',
+        'NetworkdWWANTests',
 ]
         integration_tests += [
                 integration_test_template + {
diff --git a/test/test-network/conf/25-wwan-ipv4v6.network b/test/test-network/conf/25-wwan-ipv4v6.network
new file mode 100644 (file)
index 0000000..bf0a857
--- /dev/null
@@ -0,0 +1,12 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+[Match]
+Name=dummy98
+
+[Network]
+LLDP=no
+LinkLocalAddressing=no
+IPv6AcceptRA=no
+
+[MobileNetwork]
+APN=internet.test
+IPFamily=both
diff --git a/test/test-network/conf/mock-modem-manager.conf b/test/test-network/conf/mock-modem-manager.conf
new file mode 100644 (file)
index 0000000..0a762d7
--- /dev/null
@@ -0,0 +1,13 @@
+<!DOCTYPE busconfig PUBLIC
+ "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
+ "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
+<busconfig>
+  <!-- Allow root to own and interact with the mock ModemManager service -->
+  <policy user="root">
+    <allow own="org.freedesktop.ModemManager1"/>
+    <allow send_destination="org.freedesktop.ModemManager1"/>
+  </policy>
+  <policy context="default">
+    <allow send_destination="org.freedesktop.ModemManager1"/>
+  </policy>
+</busconfig>
index bab725bd23943ff81c2d9c31ebecdb10f1ce097b..03404e6cbeb41f611e3b3612aa8e2313b43128af 100755 (executable)
@@ -73,6 +73,7 @@ resolvectl_bin = shutil.which('resolvectl', path=which_paths)
 timedatectl_bin = shutil.which('timedatectl', path=which_paths)
 udevadm_bin = shutil.which('udevadm', path=which_paths)
 test_ndisc_send = None
+test_modem_manager_mock = None
 build_dir = None
 source_dir = None
 
@@ -973,6 +974,28 @@ def start_radvd(*additional_options, config_file):
 def stop_radvd():
     stop_by_pid_file(radvd_pid_file)
 
+def start_modem_manager_mock(*additional_options):
+    dbus_policy_src = os.path.join(networkd_ci_temp_dir, 'mock-modem-manager.conf')
+    cp(dbus_policy_src, '/etc/dbus-1/system.d/mock-modem-manager.conf')
+    check_output('systemctl reload dbus.service')
+
+    command = ' '.join([test_modem_manager_mock] + list(additional_options))
+    with open('/run/systemd/system/test-modem-manager-mock.service', mode='w', encoding='utf-8') as f:
+        f.write('[Unit]\n'
+                'Description=Mock ModemManager for networkd testing\n'
+                '[Service]\n'
+                'Type=notify\n'
+                'BusName=org.freedesktop.ModemManager1\n'
+                f'ExecStart={command}\n')
+    check_output('systemctl daemon-reload')
+    check_output('systemctl start test-modem-manager-mock.service')
+
+def stop_modem_manager_mock():
+    call('systemctl stop test-modem-manager-mock.service')
+    rm_f('/run/systemd/system/test-modem-manager-mock.service')
+    call('systemctl daemon-reload')
+    rm_f('/etc/dbus-1/system.d/mock-modem-manager.conf')
+
 def radvd_check_config(config_file):
     if not shutil.which('radvd'):
         print('radvd is not installed, assuming the config check failed')
@@ -1099,6 +1122,7 @@ def tear_down_common():
     stop_dnsmasq()
     stop_isc_dhcpd()
     stop_radvd()
+    stop_modem_manager_mock()
 
     # 2. remove modules
     call_quiet('rmmod netdevsim')
@@ -9567,6 +9591,54 @@ class NetworkdSysctlTest(unittest.TestCase, Utilities):
         self.assertNotIn("changed sysctl '/proc/sys/net/ipv6/conf/dummy98/max_addresses'", log)
         self.assertNotIn("Sysctl monitor BPF returned error", log)
 
+class NetworkdWWANTests(unittest.TestCase, Utilities):
+
+    def setUp(self):
+        setup_common()
+
+    def tearDown(self):
+        tear_down_common()
+
+    def test_wwan_ipv4v6_static(self):
+        """Test WWAN bearer with both IPv4 and IPv6 static configuration.
+
+        Regression test for https://github.com/systemd/systemd/issues/41389
+        """
+        if not os.path.exists(test_modem_manager_mock):
+            self.skipTest(f'{test_modem_manager_mock} does not exist.')
+
+        copy_network_unit('12-dummy.netdev', '25-wwan-ipv4v6.network')
+        try:
+            start_modem_manager_mock(
+                '--ifname', 'dummy98',
+                '--ipv4-address', '100.120.244.160',
+                '--ipv4-gateway', '100.120.244.161',
+                '--ipv4-prefix', '26',
+                '--ipv6-address', '2001:db8::1',
+                '--ipv6-gateway', '2001:db8::2',
+                '--ipv6-prefix', '64',
+            )
+        except (subprocess.CalledProcessError, PermissionError, OSError) as e:
+            self.skipTest(f'Failed to start mock ModemManager: {e}')
+        start_networkd()
+        self.wait_online('dummy98:routable')
+
+        output = check_output('ip -4 address show dev dummy98')
+        print(output)
+        self.assertIn('100.120.244.160/26', output)
+
+        output = check_output('ip -6 address show dev dummy98')
+        print(output)
+        self.assertIn('2001:db8::1/64', output)
+
+        output = check_output('ip -4 route show dev dummy98')
+        print(output)
+        self.assertIn('default via 100.120.244.161', output)
+
+        output = check_output('ip -6 route show dev dummy98')
+        print(output)
+        self.assertIn('default via 2001:db8::2', output)
+
 if __name__ == '__main__':
     parser = argparse.ArgumentParser()
     parser.add_argument('--build-dir', help='Path to build dir', dest='build_dir')
@@ -9626,6 +9698,11 @@ if __name__ == '__main__':
     else:
         test_ndisc_send = '/usr/lib/tests/test-ndisc-send'
 
+    if build_dir:
+        test_modem_manager_mock = os.path.normpath(os.path.join(build_dir, 'test-modem-manager-mock'))
+    else:
+        test_modem_manager_mock = '/usr/lib/systemd/tests/unit-tests/manual/test-modem-manager-mock'
+
     if asan_options:
         env.update({'ASAN_OPTIONS': asan_options})
     if lsan_options: