]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
vmspawn: add integration test for QMP client library
authorChristian Brauner <brauner@kernel.org>
Mon, 30 Mar 2026 20:17:59 +0000 (22:17 +0200)
committerChristian Brauner <brauner@kernel.org>
Wed, 15 Apr 2026 08:14:48 +0000 (10:14 +0200)
Test the QMP client library using a mock QMP server over a socketpair:

- test_qmp_client_basic: Verifies full handshake, query-status with
  response parsing, stop/cont commands, and asynchronous STOP event
  delivery via the sd-event I/O callback
- test_qmp_client_eof: Verifies that the client properly detects
  server disconnection (EOF) and returns a disconnect error

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
src/test/meson.build
src/test/test-qmp-client.c [new file with mode: 0644]

index fb0aac27f88644f3128346d4a6873340e6a20d68..5fcf007f703429cc4b6dc70aab06979ed40b3ca6 100644 (file)
@@ -431,6 +431,9 @@ executables += [
         test_template + {
                 'sources' : files('test-progress-bar.c'),
         },
+        test_template + {
+                'sources' : files('test-qmp-client.c'),
+        },
         test_template + {
                 'sources' : files('test-qrcode-util.c'),
                 'dependencies' : libdl,
diff --git a/src/test/test-qmp-client.c b/src/test/test-qmp-client.c
new file mode 100644 (file)
index 0000000..9bfe487
--- /dev/null
@@ -0,0 +1,516 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+#include <fcntl.h>
+#include <signal.h>
+#include <sys/socket.h>
+
+#include "sd-event.h"
+#include "sd-json.h"
+
+#include "errno-util.h"
+#include "fd-util.h"
+#include "io-util.h"
+#include "pidref.h"
+#include "process-util.h"
+#include "qmp-client.h"
+#include "socket-util.h"
+#include "string-util.h"
+#include "tests.h"
+
+/* Mock QMP server: runs in the child process of a fork, communicates via one end of a socketpair. */
+
+static void mock_qmp_write_json(int fd, sd_json_variant *v) {
+        _cleanup_free_ char *s = NULL;
+
+        ASSERT_OK(sd_json_variant_format(v, 0, &s));
+        ASSERT_NOT_NULL(strextend(&s, "\r\n"));
+        ASSERT_OK(loop_write(fd, s, SIZE_MAX));
+}
+
+static void mock_qmp_write_literal(int fd, const char *msg) {
+        ASSERT_OK(loop_write(fd, msg, SIZE_MAX));
+        ASSERT_OK(loop_write(fd, "\r\n", 2));
+}
+
+/* Read a command from the QMP client, verify it contains the expected command name, extract the id,
+ * and send a reply with that id. If reply_data is NULL, an empty return object is sent. */
+static void mock_qmp_expect_and_reply(int fd, const char *expected_command, sd_json_variant *reply_data) {
+        _cleanup_free_ char *buf = NULL;
+        _cleanup_(sd_json_variant_unrefp) sd_json_variant *cmd = NULL, *reply_obj = NULL, *response = NULL;
+
+        buf = ASSERT_NOT_NULL(new(char, 4096));
+
+        ssize_t n = read(fd, buf, 4095);
+        assert_se(n > 0);
+        buf[n] = '\0';
+
+        ASSERT_OK(sd_json_parse(buf, 0, &cmd, NULL, NULL));
+
+        sd_json_variant *execute = ASSERT_NOT_NULL(sd_json_variant_by_key(cmd, "execute"));
+        ASSERT_STREQ(sd_json_variant_string(execute), expected_command);
+
+        sd_json_variant *id = ASSERT_NOT_NULL(sd_json_variant_by_key(cmd, "id"));
+
+        if (!reply_data)
+                ASSERT_OK(sd_json_variant_new_object(&reply_obj, NULL, 0));
+
+        ASSERT_OK(sd_json_buildo(
+                        &response,
+                        SD_JSON_BUILD_PAIR("return", SD_JSON_BUILD_VARIANT(reply_data ?: reply_obj)),
+                        SD_JSON_BUILD_PAIR("id", SD_JSON_BUILD_VARIANT(id))));
+
+        mock_qmp_write_json(fd, response);
+}
+
+static _noreturn_ void mock_qmp_server(int fd) {
+        _cleanup_(sd_json_variant_unrefp) sd_json_variant *status_return = NULL;
+
+        /* Send QMP greeting */
+        mock_qmp_write_literal(fd,
+                "{\"QMP\": {\"version\": {\"qemu\": {\"micro\": 0, \"minor\": 2, \"major\": 9}}, \"capabilities\": [\"oob\"]}}");
+
+        /* Accept qmp_capabilities */
+        mock_qmp_expect_and_reply(fd, "qmp_capabilities", NULL);
+
+        /* Accept query-status, reply with running state */
+        ASSERT_OK(sd_json_buildo(
+                        &status_return,
+                        SD_JSON_BUILD_PAIR_BOOLEAN("running", true),
+                        SD_JSON_BUILD_PAIR_STRING("status", "running")));
+        mock_qmp_expect_and_reply(fd, "query-status", status_return);
+
+        /* Accept stop */
+        mock_qmp_expect_and_reply(fd, "stop", NULL);
+
+        /* Send a STOP event */
+        mock_qmp_write_literal(fd,
+                "{\"event\": \"STOP\", \"timestamp\": {\"seconds\": 1234, \"microseconds\": 5678}}");
+
+        /* Accept cont */
+        mock_qmp_expect_and_reply(fd, "cont", NULL);
+
+        /* Close to trigger EOF */
+        safe_close(fd);
+        _exit(EXIT_SUCCESS);
+}
+
+/* Test helper: tracks an async QMP command result and signals completion. */
+typedef struct {
+        sd_json_variant *result;
+        char *error_desc;
+        int error;
+        bool done;
+} QmpTestResult;
+
+static int on_test_result(
+                QmpClient *client,
+                sd_json_variant *result,
+                const char *error_desc,
+                int error,
+                void *userdata) {
+
+        QmpTestResult *t = ASSERT_PTR(userdata);
+
+        t->error = error;
+        if (result)
+                t->result = sd_json_variant_ref(result);
+        if (error_desc)
+                t->error_desc = strdup(error_desc);
+        t->done = true;
+        return 0;
+}
+
+/* Run the event loop until the test result callback fires. */
+static void qmp_test_wait(sd_event *event, QmpTestResult *t) {
+        assert(event);
+        assert(t);
+
+        while (!t->done)
+                ASSERT_OK(sd_event_run(event, UINT64_MAX));
+}
+
+static void qmp_test_result_done(QmpTestResult *t) {
+        assert(t);
+
+        sd_json_variant_unref(t->result);
+        free(t->error_desc);
+        *t = (QmpTestResult) {};
+}
+
+static int test_event_callback(
+                QmpClient *client,
+                const char *event,
+                sd_json_variant *data,
+                void *userdata) {
+
+        bool *event_received = ASSERT_PTR(userdata);
+
+        /* We may also receive a synthetic SHUTDOWN event when the mock server closes the connection;
+         * only validate the STOP event we actually care about. */
+        if (streq(event, "STOP"))
+                *event_received = true;
+
+        return 0;
+}
+
+TEST(qmp_client_basic) {
+        _cleanup_(qmp_client_unrefp) QmpClient *client = NULL;
+        _cleanup_(sd_event_unrefp) sd_event *event = NULL;
+        _cleanup_(pidref_done) PidRef pid = PIDREF_NULL;
+        QmpTestResult t = {};
+        sd_json_variant *running, *status;
+        int qmp_fds[2];
+        int r;
+
+        ASSERT_OK(sd_event_new(&event));
+
+        ASSERT_OK_ERRNO(socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0, qmp_fds));
+
+        r = pidref_safe_fork("(mock-qmp)", FORK_DEATHSIG_SIGKILL|FORK_LOG, &pid);
+        ASSERT_OK(r);
+
+        if (r == 0) {
+                safe_close(qmp_fds[0]);
+                mock_qmp_server(qmp_fds[1]);
+        }
+
+        safe_close(qmp_fds[1]);
+
+        /* Connect then attach to event loop — handshake completes transparently
+         * inside the first call()/invoke(). */
+        ASSERT_OK(qmp_client_connect_fd(&client, qmp_fds[0]));
+        ASSERT_OK(qmp_client_attach_event(client, event, SD_EVENT_PRIORITY_NORMAL));
+
+        /* Set event callback to catch STOP event during cont */
+        bool event_received = false;
+        qmp_client_bind_event(client, test_event_callback, &event_received);
+
+        /* Execute query-status */
+        ASSERT_OK(qmp_client_invoke(client, "query-status", NULL, on_test_result, &t));
+        qmp_test_wait(event, &t);
+        ASSERT_EQ(t.error, 0);
+        ASSERT_NOT_NULL(t.result);
+
+        running = ASSERT_NOT_NULL(sd_json_variant_by_key(t.result, "running"));
+        ASSERT_TRUE(sd_json_variant_boolean(running));
+
+        status = ASSERT_NOT_NULL(sd_json_variant_by_key(t.result, "status"));
+        ASSERT_STREQ(sd_json_variant_string(status), "running");
+
+        qmp_test_result_done(&t);
+
+        /* Execute stop */
+        ASSERT_OK(qmp_client_invoke(client, "stop", NULL, on_test_result, &t));
+        qmp_test_wait(event, &t);
+        ASSERT_EQ(t.error, 0);
+        qmp_test_result_done(&t);
+
+        /* Execute cont -- the STOP event should be dispatched by the IO callback */
+        ASSERT_OK(qmp_client_invoke(client, "cont", NULL, on_test_result, &t));
+        qmp_test_wait(event, &t);
+        ASSERT_EQ(t.error, 0);
+        qmp_test_result_done(&t);
+
+        /* Verify the STOP event was received */
+        ASSERT_TRUE(event_received);
+
+        /* Wait for child and verify clean exit */
+        siginfo_t si = {};
+        ASSERT_OK(pidref_wait_for_terminate(&pid, &si));
+        ASSERT_EQ(si.si_code, CLD_EXITED);
+        ASSERT_EQ(si.si_status, EXIT_SUCCESS);
+}
+
+TEST(qmp_client_eof) {
+        _cleanup_(qmp_client_unrefp) QmpClient *client = NULL;
+        _cleanup_(sd_event_unrefp) sd_event *event = NULL;
+        _cleanup_(pidref_done) PidRef pid = PIDREF_NULL;
+        QmpTestResult t = {};
+        int qmp_fds[2];
+        int r;
+
+        ASSERT_OK(sd_event_new(&event));
+        ASSERT_OK_ERRNO(socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0, qmp_fds));
+
+        r = pidref_safe_fork("(mock-qmp-eof)", FORK_DEATHSIG_SIGKILL|FORK_LOG, &pid);
+        ASSERT_OK(r);
+
+        if (r == 0) {
+                safe_close(qmp_fds[0]);
+
+                /* Send greeting and accept capabilities, then die */
+                mock_qmp_write_literal(qmp_fds[1],
+                        "{\"QMP\": {\"version\": {\"qemu\": {\"micro\": 0, \"minor\": 0, \"major\": 9}}, \"capabilities\": []}}");
+
+                mock_qmp_expect_and_reply(qmp_fds[1], "qmp_capabilities", NULL);
+
+                /* Close immediately to trigger EOF */
+                safe_close(qmp_fds[1]);
+                _exit(EXIT_SUCCESS);
+        }
+
+        safe_close(qmp_fds[1]);
+
+        ASSERT_OK(qmp_client_connect_fd(&client, qmp_fds[0]));
+        ASSERT_OK(qmp_client_attach_event(client, event, SD_EVENT_PRIORITY_NORMAL));
+
+        /* Executing a command should fail with a disconnect error because the server
+         * closed. The handshake may succeed or fail inside invoke() — either way the
+         * invoke itself or the async callback should report a disconnect. */
+        r = qmp_client_invoke(client, "query-status", NULL, on_test_result, &t);
+        if (r < 0)
+                ASSERT_TRUE(ERRNO_IS_NEG_DISCONNECT(r));
+        else {
+                qmp_test_wait(event, &t);
+                ASSERT_TRUE(ERRNO_IS_NEG_DISCONNECT(t.error));
+                qmp_test_result_done(&t);
+        }
+
+        siginfo_t si = {};
+        ASSERT_OK(pidref_wait_for_terminate(&pid, &si));
+        ASSERT_EQ(si.si_code, CLD_EXITED);
+        ASSERT_EQ(si.si_status, EXIT_SUCCESS);
+}
+
+/* Read one QMP command from fd (one recvmsg, expecting it fits in the buffer for typical
+ * test commands). Returns the number of SCM_RIGHTS fds that arrived attached to the read,
+ * stores the first received fd in *ret_received_fd (or -EBADF if none) and closes any extras,
+ * and parses the JSON into *ret_cmd. */
+static size_t mock_qmp_recv_command(int fd, sd_json_variant **ret_cmd, int *ret_received_fd) {
+        char buf[4096];
+        char ctrl[CMSG_SPACE(sizeof(int) * 4)];
+        struct iovec iov = { .iov_base = buf, .iov_len = sizeof(buf) - 1 };
+        struct msghdr mh = {
+                .msg_iov = &iov, .msg_iovlen = 1,
+                .msg_control = ctrl, .msg_controllen = sizeof(ctrl),
+        };
+        size_t n_fds = 0;
+        int received_fd = -EBADF;
+
+        ssize_t n = recvmsg(fd, &mh, MSG_CMSG_CLOEXEC);
+        assert_se(n > 0);
+        buf[n] = '\0';
+
+        struct cmsghdr *cmsg;
+        CMSG_FOREACH(cmsg, &mh) {
+                if (cmsg->cmsg_level != SOL_SOCKET || cmsg->cmsg_type != SCM_RIGHTS)
+                        continue;
+                size_t k = (cmsg->cmsg_len - CMSG_LEN(0)) / sizeof(int);
+                int *fds = (int*) CMSG_DATA(cmsg);
+                for (size_t i = 0; i < k; i++) {
+                        if (received_fd < 0)
+                                received_fd = fds[i];
+                        else
+                                safe_close(fds[i]);
+                }
+                n_fds += k;
+        }
+
+        ASSERT_OK(sd_json_parse(buf, 0, ret_cmd, NULL, NULL));
+
+        if (ret_received_fd)
+                *ret_received_fd = received_fd;
+        else if (received_fd >= 0)
+                safe_close(received_fd);
+
+        return n_fds;
+}
+
+/* Mock QMP server for the fd-on-first-invoke regression. Drives the wire dance:
+ *   greeting → (recv qmp_capabilities, expect 0 fds) → reply →
+ *   (recv add-fd, expect exactly 1 fd) → reply
+ * Asserts the cmsg fd counts directly so a regression flips the child to
+ * exit_failure and the parent test fails on the wait-for-terminate. */
+static _noreturn_ void mock_qmp_server_fd_first(int fd) {
+        _cleanup_(sd_json_variant_unrefp) sd_json_variant *cap_cmd = NULL,
+                                                          *addfd_cmd = NULL,
+                                                          *cap_reply = NULL,
+                                                          *addfd_return = NULL,
+                                                          *addfd_reply = NULL;
+        size_t n_fds;
+        int received_fd = -EBADF;
+
+        /* Greeting */
+        mock_qmp_write_literal(fd,
+                "{\"QMP\": {\"version\": {\"qemu\": {\"micro\": 0, \"minor\": 0, \"major\": 9}}, \"capabilities\": []}}");
+
+        /* Receive qmp_capabilities — must arrive with NO fds attached. */
+        n_fds = mock_qmp_recv_command(fd, &cap_cmd, /* ret_received_fd= */ NULL);
+        ASSERT_EQ(n_fds, (size_t) 0);
+        ASSERT_STREQ(sd_json_variant_string(sd_json_variant_by_key(cap_cmd, "execute")), "qmp_capabilities");
+
+        sd_json_variant *cap_id = ASSERT_NOT_NULL(sd_json_variant_by_key(cap_cmd, "id"));
+        ASSERT_OK(sd_json_buildo(
+                        &cap_reply,
+                        SD_JSON_BUILD_PAIR("return", SD_JSON_BUILD_EMPTY_OBJECT),
+                        SD_JSON_BUILD_PAIR("id", SD_JSON_BUILD_VARIANT(cap_id))));
+        mock_qmp_write_json(fd, cap_reply);
+
+        /* Receive add-fd — must arrive with EXACTLY ONE fd attached. */
+        n_fds = mock_qmp_recv_command(fd, &addfd_cmd, &received_fd);
+        ASSERT_EQ(n_fds, (size_t) 1);
+        ASSERT_TRUE(received_fd >= 0);
+        ASSERT_STREQ(sd_json_variant_string(sd_json_variant_by_key(addfd_cmd, "execute")), "add-fd");
+        safe_close(received_fd);
+
+        sd_json_variant *addfd_id = ASSERT_NOT_NULL(sd_json_variant_by_key(addfd_cmd, "id"));
+        ASSERT_OK(sd_json_buildo(
+                        &addfd_return,
+                        SD_JSON_BUILD_PAIR_UNSIGNED("fdset-id", 0),
+                        SD_JSON_BUILD_PAIR_UNSIGNED("fd", 42)));
+        ASSERT_OK(sd_json_buildo(
+                        &addfd_reply,
+                        SD_JSON_BUILD_PAIR("return", SD_JSON_BUILD_VARIANT(addfd_return)),
+                        SD_JSON_BUILD_PAIR("id", SD_JSON_BUILD_VARIANT(addfd_id))));
+        mock_qmp_write_json(fd, addfd_reply);
+
+        safe_close(fd);
+        _exit(EXIT_SUCCESS);
+}
+
+/* Regression: pass an fd in the very first qmp_client_invoke() against a fresh client
+ * (lazy-bootstrap state, handshake not yet done). The previous push_fd+invoke split would
+ * stage the fd on the stream BEFORE qmp_client_ensure_running() drove the handshake; the
+ * handshake's qmp_capabilities enqueue would then steal the staged fd onto its own
+ * sendmsg. The new QmpClientArgs API stages fds inside invoke AFTER ensure_running, so
+ * the fd lands on add-fd's sendmsg as it should. */
+TEST(qmp_client_first_invoke_with_fd) {
+        _cleanup_(qmp_client_unrefp) QmpClient *client = NULL;
+        _cleanup_(sd_event_unrefp) sd_event *event = NULL;
+        _cleanup_(pidref_done) PidRef pid = PIDREF_NULL;
+        _cleanup_(sd_json_variant_unrefp) sd_json_variant *args = NULL;
+        _cleanup_close_ int fd_to_pass = -EBADF;
+        QmpTestResult t = {};
+        int qmp_fds[2];
+        int r;
+
+        ASSERT_OK(sd_event_new(&event));
+        ASSERT_OK_ERRNO(socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0, qmp_fds));
+
+        r = pidref_safe_fork("(mock-qmp-fd-first)", FORK_DEATHSIG_SIGKILL|FORK_LOG, &pid);
+        ASSERT_OK(r);
+
+        if (r == 0) {
+                safe_close(qmp_fds[0]);
+                mock_qmp_server_fd_first(qmp_fds[1]);
+        }
+
+        safe_close(qmp_fds[1]);
+
+        /* Open a real fd to pass — /dev/null is universally available. */
+        fd_to_pass = open("/dev/null", O_RDWR|O_CLOEXEC);
+        ASSERT_OK(fd_to_pass);
+
+        ASSERT_OK(qmp_client_connect_fd(&client, qmp_fds[0]));
+        ASSERT_OK(qmp_client_attach_event(client, event, SD_EVENT_PRIORITY_NORMAL));
+
+        /* Build add-fd args. The fdset-id value is irrelevant — the mock server only
+         * cares that the fd arrived with the correct sendmsg. */
+        ASSERT_OK(sd_json_buildo(&args, SD_JSON_BUILD_PAIR_UNSIGNED("fdset-id", 0)));
+
+        /* THIS is the previously-broken pattern: very first invoke against the client,
+         * carrying an fd, with the handshake still pending. */
+        ASSERT_OK(qmp_client_invoke(client, "add-fd",
+                                    QMP_CLIENT_ARGS_FD(args, TAKE_FD(fd_to_pass)),
+                                    on_test_result, &t));
+
+        qmp_test_wait(event, &t);
+        ASSERT_EQ(t.error, 0);
+        ASSERT_NOT_NULL(t.result);
+        qmp_test_result_done(&t);
+
+        /* Wait for the mock server child. If it received fds in the wrong order it
+         * exited via the test-assertion failure path and si.si_status will be non-zero. */
+        siginfo_t si = {};
+        ASSERT_OK(pidref_wait_for_terminate(&pid, &si));
+        ASSERT_EQ(si.si_code, CLD_EXITED);
+        ASSERT_EQ(si.si_status, EXIT_SUCCESS);
+}
+
+/* Regression: when qmp_client_invoke() fails before stage_fds runs (e.g.
+ * ensure_running() returns -ENOTCONN because the peer closed mid-handshake), the
+ * caller-supplied fds — already TAKE_FD()'d through QMP_CLIENT_ARGS_FD() — must be
+ * closed inside invoke. Otherwise they leak. */
+TEST(qmp_client_invoke_failure_closes_fds) {
+        _cleanup_(qmp_client_unrefp) QmpClient *client = NULL;
+        _cleanup_(sd_json_variant_unrefp) sd_json_variant *args = NULL;
+        _cleanup_close_ int fd_to_pass = -EBADF;
+        QmpTestResult t = {};
+        int qmp_fds[2];
+        int saved_fd_value;
+
+        ASSERT_OK_ERRNO(socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0, qmp_fds));
+
+        /* Close the peer end immediately so ensure_running()'s read sees EOF and
+         * the client transitions straight to DISCONNECTED inside the first invoke. */
+        safe_close(qmp_fds[1]);
+
+        ASSERT_OK(qmp_client_connect_fd(&client, qmp_fds[0]));
+        /* Deliberately do NOT attach to an event loop — invoke uses ensure_running()'s
+         * synchronous process+wait pump for the handshake. */
+
+        fd_to_pass = open("/dev/null", O_RDWR|O_CLOEXEC);
+        ASSERT_OK(fd_to_pass);
+        saved_fd_value = fd_to_pass;   /* remember the int value for the closed-check */
+
+        ASSERT_OK(sd_json_buildo(&args, SD_JSON_BUILD_PAIR_UNSIGNED("fdset-id", 0)));
+
+        /* invoke must fail because the peer is gone. The TAKE_FD inside the macro
+         * has already zeroed our local fd_to_pass; if invoke leaked the fd here,
+         * the fd would stay open in our process. */
+        int r = qmp_client_invoke(client, "add-fd",
+                                  QMP_CLIENT_ARGS_FD(args, TAKE_FD(fd_to_pass)),
+                                  on_test_result, &t);
+        ASSERT_TRUE(r < 0);
+        ASSERT_TRUE(ERRNO_IS_NEG_DISCONNECT(r));
+
+        /* fd_to_pass should now be -EBADF (TAKE_FD'd) and the underlying kernel fd
+         * should have been closed by the qmp_client_args_close_fds cleanup in
+         * qmp_client_invoke(). fcntl on the old int returns EBADF only if the slot
+         * is genuinely free. */
+        ASSERT_EQ(fd_to_pass, -EBADF);
+        ASSERT_EQ(fcntl(saved_fd_value, F_GETFD), -1);
+        ASSERT_EQ(errno, EBADF);
+}
+
+TEST(qmp_schema_has_member) {
+        _cleanup_(sd_json_variant_unrefp) sd_json_variant *schema = NULL;
+
+        /* QEMU introspection uses opaque numeric type ids ("0", "1", ...) — only member names are
+         * the actual QAPI strings. Verify we walk all object entries and find the member by name. */
+        ASSERT_OK(sd_json_build(&schema,
+                SD_JSON_BUILD_ARRAY(
+                        SD_JSON_BUILD_OBJECT(
+                                SD_JSON_BUILD_PAIR_STRING("name", "0"),
+                                SD_JSON_BUILD_PAIR_STRING("meta-type", "object"),
+                                SD_JSON_BUILD_PAIR("members", SD_JSON_BUILD_ARRAY(
+                                        SD_JSON_BUILD_OBJECT(
+                                                SD_JSON_BUILD_PAIR_STRING("name", "offset"),
+                                                SD_JSON_BUILD_PAIR_STRING("type", "int"))))),
+                        SD_JSON_BUILD_OBJECT(
+                                SD_JSON_BUILD_PAIR_STRING("name", "SomeEnum"),
+                                SD_JSON_BUILD_PAIR_STRING("meta-type", "enum")),
+                        SD_JSON_BUILD_OBJECT(
+                                SD_JSON_BUILD_PAIR_STRING("name", "1"),
+                                SD_JSON_BUILD_PAIR_STRING("meta-type", "object"),
+                                SD_JSON_BUILD_PAIR("members", SD_JSON_BUILD_ARRAY(
+                                        SD_JSON_BUILD_OBJECT(
+                                                SD_JSON_BUILD_PAIR_STRING("name", "lazy-refcounts"),
+                                                SD_JSON_BUILD_PAIR_STRING("type", "bool")),
+                                        SD_JSON_BUILD_OBJECT(
+                                                SD_JSON_BUILD_PAIR_STRING("name", "discard-no-unref"),
+                                                SD_JSON_BUILD_PAIR_STRING("type", "bool"))))))));
+
+        ASSERT_TRUE(qmp_schema_has_member(schema, "discard-no-unref"));
+        ASSERT_TRUE(qmp_schema_has_member(schema, "offset"));
+        ASSERT_FALSE(qmp_schema_has_member(schema, "definitely-not-a-real-field"));
+        ASSERT_FALSE(qmp_schema_has_member(NULL, "discard-no-unref"));
+}
+
+static int intro(void) {
+        /* Ignore SIGPIPE so that write() to a closed socket returns EPIPE instead of killing us */
+        ASSERT_TRUE(signal(SIGPIPE, SIG_IGN) != SIG_ERR);
+        return 0;
+}
+
+DEFINE_TEST_MAIN_FULL(LOG_DEBUG, intro, NULL);