You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iggy.apache.org by ri...@apache.org on 2026/08/24 17:31:16 UTC

(iggy) branch master updated: feat(cpp): add functions related to user management, and initial high-level client (#3733)

This is an automated email from the ASF dual-hosted git repository.

slbotbm pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new f8311d651 feat(cpp): add functions related to user management, and initial high-level client (#3733)
f8311d651 is described below

commit f8311d651d219999855490922a48f0b4f60aa66f
Author: Rimuksh Kansal <ni...@gmail.com>
AuthorDate: Tue Aug 25 02:31:10 2026 +0900

    feat(cpp): add functions related to user management, and initial high-level client (#3733)
---
 .github/actions/cpp-bazel/pre-merge/action.yml     |   25 +-
 .github/config/components.yml                      |    2 +-
 .../features/step_definitions/background_steps.cpp |    5 +-
 .../features/step_definitions/messaging_steps.cpp  |   18 +-
 foreign/cpp/.bazelrc                               |    5 +-
 foreign/cpp/include/iggy.hpp                       |  917 ++++++++---
 foreign/cpp/src/client.cpp                         |  200 +++
 foreign/cpp/src/client.rs                          |  196 ++-
 foreign/cpp/src/lib.rs                             |   89 +-
 foreign/cpp/src/type_conversion.rs                 |  146 +-
 foreign/cpp/tests/e2e/client.cpp                   | 1606 +++++++++++++++++++-
 foreign/cpp/tests/e2e/test_helpers.hpp             |  202 +--
 foreign/cpp/tests/e2e/topic.cpp                    |   10 +-
 foreign/cpp/tests/unit/unit_tests.cpp              |  169 +-
 14 files changed, 3157 insertions(+), 433 deletions(-)

diff --git a/.github/actions/cpp-bazel/pre-merge/action.yml b/.github/actions/cpp-bazel/pre-merge/action.yml
index 102a9302c..0cd50e62f 100644
--- a/.github/actions/cpp-bazel/pre-merge/action.yml
+++ b/.github/actions/cpp-bazel/pre-merge/action.yml
@@ -20,7 +20,7 @@ description: C++ pre-merge testing github iggy actions
 
 inputs:
   task:
-    description: "Task to run (lint, build, test, e2e)"
+    description: "Task to run (lint, build, test)"
     required: true
 
 runs:
@@ -72,33 +72,26 @@ runs:
         cd foreign/cpp
         bazel build --config=ci //:iggy-cpp
 
-    - name: Test
+    - name: Setup server for tests
       if: inputs.task == 'test'
-      shell: bash
-      run: |
-        cd foreign/cpp
-        bazel test --config=ci //:unit
-
-    - name: Setup server for e2e tests
-      if: inputs.task == 'e2e'
       uses: ./.github/actions/utils/server-start
       with:
         cargo-bin: iggy-server
 
-    - name: Run e2e tests
-      if: inputs.task == 'e2e'
+    - name: Test
+      if: inputs.task == 'test'
       shell: bash
       run: |
         cd foreign/cpp
-        bazel test --config=ci //:e2e
+        bazel test --config=ci //...
 
-    - name: Stop server after e2e tests
-      if: always() && inputs.task == 'e2e'
+    - name: Stop server after tests
+      if: always() && inputs.task == 'test'
       uses: ./.github/actions/utils/server-stop
 
     - name: Validate task
-      if: inputs.task != 'lint' && inputs.task != 'build' && inputs.task != 'test' && inputs.task != 'e2e'
+      if: inputs.task != 'lint' && inputs.task != 'build' && inputs.task != 'test'
       shell: bash
       run: |
-        echo "::error::Unsupported task '${{ inputs.task }}'. Expected one of: lint, build, test, e2e."
+        echo "::error::Unsupported task '${{ inputs.task }}'. Expected one of: lint, build, test."
         exit 1
diff --git a/.github/config/components.yml b/.github/config/components.yml
index 3ddd7520b..307edd535 100644
--- a/.github/config/components.yml
+++ b/.github/config/components.yml
@@ -294,7 +294,7 @@ components:
       - "ci-infrastructure"
     paths:
       - "foreign/cpp/**"
-    tasks: ["lint", "build", "test", "e2e"]
+    tasks: ["lint", "build", "test"]
 
   # Shared BDD infrastructure that affects all BDD tests
   bdd-infrastructure:
diff --git a/bdd/cpp/features/step_definitions/background_steps.cpp b/bdd/cpp/features/step_definitions/background_steps.cpp
index 5100fde08..c4f593f59 100644
--- a/bdd/cpp/features/step_definitions/background_steps.cpp
+++ b/bdd/cpp/features/step_definitions/background_steps.cpp
@@ -28,6 +28,7 @@
 
 #include <cstdlib>
 #include <string>
+#include <utility>
 
 #include "world.hpp"
 
@@ -44,7 +45,9 @@ GIVEN("^I have a running Iggy server$") {
     // Empty address makes the SDK fall back to its default TCP endpoint; in CI the address
     // is supplied via IGGY_TCP_ADDRESS (e.g. iggy-server:8090).
     const std::string address = env_or("IGGY_TCP_ADDRESS", "");
-    iggy::ffi::Client *client = iggy::ffi::new_connection(address);
+    iggy::ffi::IggyClientConfig config{};
+    config.server_address     = address;
+    iggy::ffi::Client *client = iggy::ffi::new_connection(std::move(config));
     ASSERT_NE(client, nullptr);
     context->client = client;
     context->client->connect();
diff --git a/bdd/cpp/features/step_definitions/messaging_steps.cpp b/bdd/cpp/features/step_definitions/messaging_steps.cpp
index 6be384e28..90ee3ba7e 100644
--- a/bdd/cpp/features/step_definitions/messaging_steps.cpp
+++ b/bdd/cpp/features/step_definitions/messaging_steps.cpp
@@ -72,15 +72,15 @@ WHEN("^I create a topic with name \"([^\"]{1,255})\" in stream ([0-9]+) with ([0
     // Drive the SDK through the typed helpers in iggy.hpp rather than raw strings. Options that
     // still take plain strings below (partitioning kind, consumer kind) mark where the C++ SDK
     // does not yet expose a typed wrapper.
-    const auto compression    = iggy::CompressionAlgorithm::none();
-    const auto message_expiry = iggy::Expiry::never_expire();
-    const auto max_topic_size = iggy::MaxTopicSize::server_default();
+    const auto compression    = iggy::CompressionAlgorithm::None();
+    const auto message_expiry = iggy::Expiry::NeverExpire();
+    const auto max_topic_size = iggy::MaxTopicSize::ServerDefault();
 
     context->client->create_topic(bdd::make_numeric_identifier(static_cast<std::uint32_t>(stream_id)), topic_name,
                                   static_cast<std::uint32_t>(partitions_count),
-                                  std::string(compression.compression_algorithm_value()),
-                                  std::string(message_expiry.expiry_kind()), message_expiry.expiry_value(),
-                                  std::string(max_topic_size.max_topic_size()), {});
+                                  std::string(compression.CompressionAlgorithmValue()),
+                                  std::string(message_expiry.ExpiryKind()), message_expiry.ExpiryValue(),
+                                  std::string(max_topic_size.MaxTopicSizeValue()), {});
 }
 
 THEN("^the topic should be created successfully$") {
@@ -146,12 +146,12 @@ WHEN("^I poll messages from stream ([0-9]+), topic ([0-9]+), partition ([0-9]+)
     REGEX_PARAM(int, offset);
     cucumber::ScenarioScope<bdd::GlobalContext> context;
 
-    const auto polling_strategy = iggy::PollingStrategy::offset(static_cast<std::uint64_t>(offset));
+    const auto polling_strategy = iggy::PollingStrategy::Offset(static_cast<std::uint64_t>(offset));
     const auto polled           = context->client->poll_messages(
         bdd::make_numeric_identifier(static_cast<std::uint32_t>(stream_id)),
         bdd::make_numeric_identifier(static_cast<std::uint32_t>(topic_id)), static_cast<std::uint32_t>(partition_id),
-        "consumer", bdd::make_numeric_identifier(1), std::string(polling_strategy.polling_strategy_kind()),
-        polling_strategy.polling_strategy_value(), 100, false);
+        "consumer", bdd::make_numeric_identifier(1), std::string(polling_strategy.PollingStrategyKind()),
+        polling_strategy.PollingStrategyValue(), 100, false);
 
     context->polled.count = polled.count;
     context->polled.offsets.clear();
diff --git a/foreign/cpp/.bazelrc b/foreign/cpp/.bazelrc
index 08372bd13..880712c20 100644
--- a/foreign/cpp/.bazelrc
+++ b/foreign/cpp/.bazelrc
@@ -20,6 +20,10 @@
 # Enable BzlMod
 common --enable_bzlmod
 
+# Default test behavior
+test --test_output=errors
+test --cache_test_results=no
+
 # Debug configuration
 build:debug --compilation_mode=dbg
 build:debug --copt=-g3
@@ -44,5 +48,4 @@ build:ci --config=release
 build:ci --lockfile_mode=error
 
 test:ci --lockfile_mode=error
-test:ci --test_output=errors
 test:ci --test_summary=detailed
diff --git a/foreign/cpp/include/iggy.hpp b/foreign/cpp/include/iggy.hpp
index 62362fb96..593815299 100644
--- a/foreign/cpp/include/iggy.hpp
+++ b/foreign/cpp/include/iggy.hpp
@@ -20,8 +20,15 @@
 #pragma once
 
 #include <cstddef>
+/**
+ * @file iggy.hpp
+ * @brief Public C++ API for the Apache Iggy client.
+ */
+
+#include <chrono>
 #include <cstdint>
 #include <limits>
+#include <optional>
 #include <stdexcept>
 #include <string>
 #include <string_view>
@@ -31,23 +38,18 @@
 
 namespace iggy {
 
-/// Exception raised by the C++ client when an operation fails.
-class IggyException : public std::runtime_error {
-  public:
-    explicit IggyException(const char *message) : std::runtime_error(message) {}
-    explicit IggyException(const std::string &message) : std::runtime_error(message) {}
-};
+using LoginInfo = ffi::LoginInfo;
 
 namespace detail {
 
-/// Internal helper used by string-backed option types in this header.
+/** @brief Internal base for string-backed option types. */
 template <typename Tag>
 class StringTag {
   protected:
     explicit StringTag(std::string value) : value_(std::move(value)) {}
     ~StringTag() = default;
 
-    std::string_view value() const { return value_; }
+    std::string_view Value() const { return value_; }
 
   private:
     std::string value_;
@@ -55,162 +57,220 @@ class StringTag {
 
 }  // namespace detail
 
-/// Compression setting for `Client::create_topic(...)`.
-///
-/// Use this type to choose whether messages in a topic are stored as-is or compressed with gzip.
-///
-/// Internally, this value is passed across the Rust FFI as a string.
-/// Values outside the supported set are rejected by the Rust client.
+/**
+ * @brief Compression algorithm used for topic messages.
+ *
+ * Selects whether messages in a topic are stored as-is or compressed with
+ * gzip.
+ *
+ * @note The value is passed across the Rust FFI as a string. The Rust client
+ *       rejects unsupported values.
+ */
 class CompressionAlgorithm final : private detail::StringTag<CompressionAlgorithm> {
   public:
-    /// Store messages without compression.
-    static CompressionAlgorithm none() { return CompressionAlgorithm("none"); }
-    /// Compress messages with gzip.
-    static CompressionAlgorithm gzip() { return CompressionAlgorithm("gzip"); }
+    /** @brief Returns the uncompressed storage option. */
+    static CompressionAlgorithm None() { return CompressionAlgorithm("none"); }
+
+    /** @brief Returns the gzip compression option. */
+    static CompressionAlgorithm Gzip() { return CompressionAlgorithm("gzip"); }
 
-    std::string_view compression_algorithm_value() const { return value(); }
+    /**
+     * @brief Returns the value passed to the client implementation.
+     * @return Compression algorithm name.
+     */
+    std::string_view CompressionAlgorithmValue() const { return Value(); }
 
   private:
     explicit CompressionAlgorithm(std::string algorithm)
         : detail::StringTag<CompressionAlgorithm>(std::move(algorithm)) {}
 };
 
-/// Compression setting for `Client::snapshot(...)`.
-///
-/// Use this type to choose how snapshot data is compressed in the generated archive.
-///
-/// Internally, this value is passed across the Rust FFI as a string.
-/// Values outside the supported set are rejected by the Rust client.
+/**
+ * @brief Compression algorithm used for system snapshot archives.
+ *
+ * Selects how snapshot data is compressed in the generated archive.
+ *
+ * @note The value is passed across the Rust FFI as a string. The Rust client
+ *       rejects unsupported values.
+ */
 class SnapshotCompression final : private detail::StringTag<SnapshotCompression> {
   public:
-    /// Store snapshot files without compression.
-    static SnapshotCompression stored() { return SnapshotCompression("stored"); }
-    /// Use standard deflate compression.
-    static SnapshotCompression deflated() { return SnapshotCompression("deflated"); }
-    /// Use bzip2 for a higher compression ratio at the cost of slower processing.
-    static SnapshotCompression bzip2() { return SnapshotCompression("bzip2"); }
-    /// Use Zstandard for fast compression and decompression.
-    static SnapshotCompression zstd() { return SnapshotCompression("zstd"); }
-    /// Use LZMA for high compression, especially for larger files.
-    static SnapshotCompression lzma() { return SnapshotCompression("lzma"); }
-    /// Use XZ, which is similar to LZMA and often faster to decompress.
-    static SnapshotCompression xz() { return SnapshotCompression("xz"); }
-
-    std::string_view snapshot_compression_value() const { return value(); }
+    /** @brief Returns the uncompressed storage option. */
+    static SnapshotCompression Stored() { return SnapshotCompression("stored"); }
+
+    /** @brief Returns the Deflate compression option. */
+    static SnapshotCompression Deflated() { return SnapshotCompression("deflated"); }
+
+    /** @brief Uses bzip2 for better compression with slower processing. */
+    static SnapshotCompression Bzip2() { return SnapshotCompression("bzip2"); }
+
+    /** @brief Uses Zstandard for fast compression and decompression. */
+    static SnapshotCompression Zstd() { return SnapshotCompression("zstd"); }
+
+    /** @brief Uses LZMA for high compression, especially for larger files. */
+    static SnapshotCompression Lzma() { return SnapshotCompression("lzma"); }
+
+    /** @brief Uses XZ for LZMA-like compression with faster decompression. */
+    static SnapshotCompression Xz() { return SnapshotCompression("xz"); }
+
+    /**
+     * @brief Returns the value passed to the client implementation.
+     * @return Snapshot compression algorithm name.
+     */
+    std::string_view SnapshotCompressionValue() const { return Value(); }
 
   private:
     explicit SnapshotCompression(std::string snapshot_compression)
         : detail::StringTag<SnapshotCompression>(std::move(snapshot_compression)) {}
 };
 
-/// System snapshot selector for `Client::snapshot(...)`.
-///
-/// Use this type to choose which snapshot data the server should include.
-///
-/// Internally, each selected value is passed across the Rust FFI as a string.
-/// Values outside the supported set are rejected by the Rust client.
+/**
+ * @brief Selects data to include in a system snapshot.
+ *
+ * @note Each selected value is passed across the Rust FFI as a string. The
+ *       Rust client rejects unsupported values.
+ */
 class SystemSnapshotType final : private detail::StringTag<SystemSnapshotType> {
   public:
-    /// Include an overview of the filesystem structure.
-    static SystemSnapshotType filesystem_overview() { return SystemSnapshotType("filesystem_overview"); }
-    /// Include the list of currently running processes.
-    static SystemSnapshotType process_list() { return SystemSnapshotType("process_list"); }
-    /// Include CPU, memory, and other system resource usage statistics.
-    static SystemSnapshotType resource_usage() { return SystemSnapshotType("resource_usage"); }
-    /// Include the test snapshot used for development and testing.
-    static SystemSnapshotType test() { return SystemSnapshotType("test"); }
-    /// Include server logs from the configured logging directory.
-    static SystemSnapshotType server_logs() { return SystemSnapshotType("server_logs"); }
-    /// Include the server configuration.
-    static SystemSnapshotType server_config() { return SystemSnapshotType("server_config"); }
-    /// Include all available snapshot types.
-    static SystemSnapshotType all() { return SystemSnapshotType("all"); }
-
-    std::string_view snapshot_type_value() const { return value(); }
+    /** @brief Includes an overview of the file-system structure. */
+    static SystemSnapshotType FilesystemOverview() { return SystemSnapshotType("filesystem_overview"); }
+
+    /** @brief Includes currently running processes. */
+    static SystemSnapshotType ProcessList() { return SystemSnapshotType("process_list"); }
+
+    /** @brief Includes CPU, memory, and other resource usage statistics. */
+    static SystemSnapshotType ResourceUsage() { return SystemSnapshotType("resource_usage"); }
+
+    /** @brief Includes the test snapshot used for development and testing. */
+    static SystemSnapshotType Test() { return SystemSnapshotType("test"); }
+
+    /** @brief Includes server logs from the configured logging directory. */
+    static SystemSnapshotType ServerLogs() { return SystemSnapshotType("server_logs"); }
+
+    /** @brief Includes server configuration. */
+    static SystemSnapshotType ServerConfig() { return SystemSnapshotType("server_config"); }
+
+    /** @brief Includes all available snapshot data. */
+    static SystemSnapshotType All() { return SystemSnapshotType("all"); }
+
+    /**
+     * @brief Returns the value passed to the client implementation.
+     * @return System snapshot type name.
+     */
+    std::string_view SnapshotTypeValue() const { return Value(); }
 
   private:
     explicit SystemSnapshotType(std::string snapshot_type)
         : detail::StringTag<SystemSnapshotType>(std::move(snapshot_type)) {}
 };
 
-/// Maximum retained size for a topic.
-///
-/// Use this type to choose whether a topic uses the server default limit, no limit, or an
-/// explicit byte limit.
-///
-/// Internally, this value is passed across the Rust FFI as a string.
-/// Values outside the supported set are rejected by the Rust client.
-/// In addition to `server_default` and `unlimited`, the Rust parser also accepts
-/// numeric size strings. A value of `0` is treated as `server_default`, and a
-/// value of `std::numeric_limits<std::uint64_t>::max()` is treated as `unlimited`.
+/**
+ * @brief Maximum retained size of a topic.
+ *
+ * A topic may use the server default, have no size limit, or use an explicit
+ * byte limit.
+ *
+ * @note The value is passed across the Rust FFI as a string. The Rust parser
+ *       accepts server_default, unlimited, and decimal byte counts. Zero maps
+ *       to server_default, and std::numeric_limits<std::uint64_t>::max() maps
+ *       to unlimited. The Rust client rejects unsupported values.
+ */
 class MaxTopicSize final : private detail::StringTag<MaxTopicSize> {
   public:
-    /// Use the server's default maximum topic size.
-    static MaxTopicSize server_default() { return MaxTopicSize("server_default"); }
-    /// Disable the maximum topic size limit.
-    static MaxTopicSize unlimited() { return MaxTopicSize("unlimited"); }
-    /// Set an explicit maximum topic size in bytes.
-    ///
-    /// A value of `0` is treated as `server_default()`. A value of
-    /// `std::numeric_limits<std::uint64_t>::max()` is treated as `unlimited()`.
-    /// The configured limit cannot be lower than the server's segment size.
-    static MaxTopicSize from_bytes(std::uint64_t bytes) {
+    /** @brief Returns the server-default size option. */
+    static MaxTopicSize ServerDefault() { return MaxTopicSize("server_default"); }
+
+    /** @brief Returns the unlimited size option. */
+    static MaxTopicSize Unlimited() { return MaxTopicSize("unlimited"); }
+
+    /**
+     * @brief Creates an explicit topic size limit.
+     * @param bytes Maximum topic size in bytes.
+     * @return Server-default size for zero, unlimited size for
+     *         std::numeric_limits<std::uint64_t>::max(), or the requested limit.
+     * @note The configured limit cannot be smaller than the server segment size.
+     */
+    static MaxTopicSize FromBytes(std::uint64_t bytes) {
         if (bytes == 0) {
-            return server_default();
+            return ServerDefault();
         }
         if (bytes == std::numeric_limits<std::uint64_t>::max()) {
-            return unlimited();
+            return Unlimited();
         }
         return MaxTopicSize(std::to_string(bytes));
     }
 
-    std::string_view max_topic_size() const { return value(); }
+    /**
+     * @brief Returns the value passed to the client implementation.
+     * @return Topic size option or decimal byte count.
+     */
+    std::string_view MaxTopicSizeValue() const { return Value(); }
 
   private:
     explicit MaxTopicSize(std::string max_topic_size) : detail::StringTag<MaxTopicSize>(std::move(max_topic_size)) {}
 };
 
 // TODO(slbotbm): Add rust bindings for Identifier that will use IdKind
-/// Describes whether an identifier is numeric or string-based.
-///
-/// Use this type to describe how an identifier is encoded.
-///
-/// This type is reserved for future identifier bindings and is not currently passed through the
-/// Rust FFI.
+/**
+ * @brief Identifies whether an identifier is numeric or text-based.
+ *
+ * @note Reserved for future identifier bindings and not currently passed
+ *       across the Rust FFI.
+ */
 class IdKind final : private detail::StringTag<IdKind> {
   public:
-    /// A numeric identifier represented as a 32-bit integer.
-    static IdKind numeric() { return IdKind("numeric"); }
-    /// A string identifier represented by its text value.
-    static IdKind string() { return IdKind("string"); }
+    /** @brief Uses a numeric identifier represented as a 32-bit integer. */
+    static IdKind Numeric() { return IdKind("numeric"); }
 
-    std::string_view id_kind_value() const { return value(); }
+    /** @brief Uses a string identifier represented by its text value. */
+    static IdKind String() { return IdKind("string"); }
+
+    /**
+     * @brief Returns the value passed to the client implementation.
+     * @return Identifier kind name.
+     */
+    std::string_view IdKindValue() const { return Value(); }
 
   private:
     explicit IdKind(std::string id_kind) : detail::StringTag<IdKind>(std::move(id_kind)) {}
 };
 
-/// Message expiry policy for `Client::create_topic(...)`.
-///
-/// Use this type to choose how long messages in a topic are retained.
-///
-/// `expiry_kind()` returns the selected mode. `expiry_value()` returns the associated payload:
-/// the duration for `duration(micros)`, `0` for `server_default()`, or
-/// `std::numeric_limits<std::uint64_t>::max()` for `never_expire()`.
-///
-/// Internally, this value is passed across the Rust FFI as a kind/value pair.
-/// Unsupported kinds are rejected by the Rust client.
+/**
+ * @brief Message retention policy for a topic.
+ *
+ * @note The expiry kind and value are passed across the Rust FFI as a pair.
+ *       The Rust client rejects unsupported kinds.
+ */
 class Expiry final {
   public:
-    /// Use the server's default message expiry policy.
-    static Expiry server_default() { return Expiry("server_default", 0); }
-    /// Keep messages until they are removed for some other reason, such as topic deletion.
-    static Expiry never_expire() { return Expiry("never_expire", std::numeric_limits<std::uint64_t>::max()); }
-    /// Expire messages after the given number of microseconds.
-    static Expiry duration(std::uint64_t micros) { return Expiry("duration", micros); }
-
-    std::string_view expiry_kind() const { return expiry_kind_; }
-    std::uint64_t expiry_value() const { return expiry_value_; }
+    /** @brief Returns the server-default expiry policy. */
+    static Expiry ServerDefault() { return Expiry("server_default", 0); }
+
+    /**
+     * @brief Keeps messages until another operation removes them, such as
+     *        topic deletion.
+     */
+    static Expiry NeverExpire() { return Expiry("never_expire", std::numeric_limits<std::uint64_t>::max()); }
+
+    /**
+     * @brief Creates a time-based expiry policy.
+     * @param micros Message lifetime in microseconds.
+     * @return Time-based expiry policy.
+     */
+    static Expiry Duration(std::uint64_t micros) { return Expiry("duration", micros); }
+
+    /**
+     * @brief Returns the expiry policy kind.
+     * @return One of server_default, never_expire, or duration.
+     */
+    std::string_view ExpiryKind() const { return expiry_kind_; }
+
+    /**
+     * @brief Returns the value associated with the expiry policy.
+     * @return Duration in microseconds for Duration(), zero for ServerDefault(),
+     *         or std::numeric_limits<std::uint64_t>::max() for NeverExpire().
+     */
+    std::uint64_t ExpiryValue() const { return expiry_value_; }
 
   private:
     explicit Expiry(std::string expiry_kind, std::uint64_t expiry_value)
@@ -220,32 +280,51 @@ class Expiry final {
     std::uint64_t expiry_value_;
 };
 
-/// Starting position for `Client::poll_messages(...)`.
-///
-/// Use this type to choose where the server should begin reading messages.
-///
-/// `polling_strategy_kind()` returns the selected mode. `polling_strategy_value()` returns the
-/// associated offset or timestamp for the parameterized modes and `0` for the others.
-///
-/// Internally, this value is passed across the Rust FFI as a kind/value pair.
-/// Unsupported kinds are rejected by the Rust client.
+/**
+ * @brief Starting position for polling messages.
+ *
+ * @note The strategy kind and value are passed across the Rust FFI as a pair.
+ *       The Rust client rejects unsupported kinds.
+ */
 class PollingStrategy final {
   public:
-    /// Start polling from a specific message offset.
-    static PollingStrategy offset(std::uint64_t value) { return PollingStrategy("offset", value); }
-    /// Start polling from a specific timestamp.
-    static PollingStrategy timestamp(std::uint64_t value) { return PollingStrategy("timestamp", value); }
-    /// Start polling from the first message in the partition.
-    static PollingStrategy first() { return PollingStrategy("first", 0); }
-    /// Start polling from the last message currently available in the partition.
-    static PollingStrategy last() { return PollingStrategy("last", 0); }
-    /// Start polling from the next message after the stored consumer offset.
-    ///
-    /// This is typically used with automatic offset commits enabled.
-    static PollingStrategy next() { return PollingStrategy("next", 0); }
-
-    std::string_view polling_strategy_kind() const { return polling_strategy_kind_; }
-    std::uint64_t polling_strategy_value() const { return polling_strategy_value_; }
+    /**
+     * @brief Starts polling at a message offset.
+     * @param value Message offset.
+     * @return Offset-based polling strategy.
+     */
+    static PollingStrategy Offset(std::uint64_t value) { return PollingStrategy("offset", value); }
+
+    /**
+     * @brief Starts polling at a timestamp.
+     * @param value Timestamp value expected by the Iggy protocol.
+     * @return Timestamp-based polling strategy.
+     */
+    static PollingStrategy Timestamp(std::uint64_t value) { return PollingStrategy("timestamp", value); }
+
+    /** @brief Starts polling with the first message in the partition. */
+    static PollingStrategy First() { return PollingStrategy("first", 0); }
+
+    /** @brief Starts polling with the last available message in the partition. */
+    static PollingStrategy Last() { return PollingStrategy("last", 0); }
+
+    /**
+     * @brief Returns a strategy that starts after the stored consumer offset.
+     * @note Typically used with automatic offset commits enabled.
+     */
+    static PollingStrategy Next() { return PollingStrategy("next", 0); }
+
+    /**
+     * @brief Returns the polling strategy kind.
+     * @return One of offset, timestamp, first, last, or next.
+     */
+    std::string_view PollingStrategyKind() const { return polling_strategy_kind_; }
+
+    /**
+     * @brief Returns the value associated with the polling strategy.
+     * @return Offset or timestamp for parameterized strategies; otherwise zero.
+     */
+    std::uint64_t PollingStrategyValue() const { return polling_strategy_value_; }
 
   private:
     explicit PollingStrategy(std::string kind, std::uint64_t value)
@@ -261,9 +340,9 @@ namespace detail {
 /// a big-endian host produces the same block as a little-endian one.
 template <typename Value>
 rust::Vec<std::uint8_t> to_little_endian_bytes(const Value value) {
-    rust::Vec<std::uint8_t> bytes;
+    rust::Vec<std::uint8_t> bytes{};
     bytes.reserve(sizeof(Value));
-    for (std::size_t index = 0; index < sizeof(Value); ++index) {
+    for (std::size_t index{}; index < sizeof(Value); ++index) {
         bytes.push_back(static_cast<std::uint8_t>((value >> (index * 8)) & 0xFF));
     }
 
@@ -271,14 +350,14 @@ rust::Vec<std::uint8_t> to_little_endian_bytes(const Value value) {
 }
 
 inline rust::Vec<std::uint8_t> to_bool_bytes(const bool value) {
-    rust::Vec<std::uint8_t> bytes;
+    rust::Vec<std::uint8_t> bytes{};
     bytes.push_back(static_cast<std::uint8_t>(value ? 1 : 0));
 
     return bytes;
 }
 
 inline rust::Vec<std::uint8_t> to_key_bytes(const std::string_view key) {
-    rust::Vec<std::uint8_t> bytes;
+    rust::Vec<std::uint8_t> bytes{};
     bytes.reserve(key.size());
     for (const char character : key) {
         bytes.push_back(static_cast<std::uint8_t>(character));
@@ -288,7 +367,7 @@ inline rust::Vec<std::uint8_t> to_key_bytes(const std::string_view key) {
 }
 
 inline iggy::ffi::HeaderField to_header_field(const iggy::ffi::HeaderKind kind, rust::Vec<std::uint8_t> value) {
-    iggy::ffi::HeaderField field;
+    iggy::ffi::HeaderField field{};
     field.kind  = static_cast<std::uint8_t>(kind);
     field.value = std::move(value);
 
@@ -299,7 +378,7 @@ inline iggy::ffi::HeaderField to_header_field(const iggy::ffi::HeaderKind kind,
 inline iggy::ffi::HeaderEntry to_option_entry(const std::string_view key,
                                               const iggy::ffi::HeaderKind value_kind,
                                               rust::Vec<std::uint8_t> value) {
-    iggy::ffi::HeaderEntry entry;
+    iggy::ffi::HeaderEntry entry{};
     entry.key   = to_header_field(iggy::ffi::HeaderKind::String, to_key_bytes(key));
     entry.value = to_header_field(value_kind, std::move(value));
 
@@ -308,60 +387,87 @@ inline iggy::ffi::HeaderEntry to_option_entry(const std::string_view key,
 
 }  // namespace detail
 
-/// Topic option entries for the trailing options block of
-/// `Client::create_topic(...)`.
-///
-/// Each factory names one key of the server's topic option catalog and encodes
-/// its value under that key's catalog kind. Both halves matter: the server
-/// refuses a key it does not list, and refuses a listed key whose value arrives
-/// under a different kind than the catalog gives it.
-/// `Client::describe_options("topic")` enumerates the catalog with each key's
-/// kind and default, which is the discovery path over the binary transports:
-/// they carry back an error code without naming the refused key.
-///
-/// These keys are create-time only. `Client::update_topic(...)` refuses every
-/// one of them by name: they describe how a topic's partitions were laid down,
-/// so changing one mid-life would leave earlier segments built to the old value.
+/**
+ * @brief Creates topic option entries for `Client::create_topic(...)`.
+ *
+ * Each factory encodes one key from the server's topic option catalog using
+ * that key's required value kind. The server rejects unknown keys and values
+ * encoded with a different kind.
+ *
+ * Use `Client::describe_options("topic")` to retrieve the supported keys,
+ * value kinds, and defaults. Binary transport errors do not identify a rejected
+ * key, so the catalog is the discovery mechanism for those transports.
+ *
+ * @note These options are accepted only during topic creation.
+ *       `Client::update_topic(...)` rejects them because they define how
+ *       partition storage is created. Changing them later could leave existing
+ *       and new segments with different storage settings.
+ */
 class TopicOption final {
   public:
-    /// Set the size at which this topic's segments rotate.
-    ///
-    /// Must be a multiple of 512 bytes, at least 1 MiB, and no larger than the
-    /// server's segment ceiling.
-    static iggy::ffi::HeaderEntry segment_size(const std::uint64_t bytes) {
+    /**
+     * @brief Set the size at which this topic's segments rotate.
+     *
+     * Must be a multiple of 512 bytes, at least 1 MiB, and no larger than the
+     * server's segment ceiling.
+     *
+     * @param bytes Segment size in bytes.
+     * @return Encoded topic option entry.
+     */
+    static iggy::ffi::HeaderEntry SegmentSize(const std::uint64_t bytes) {
         return detail::to_option_entry("segment_size", iggy::ffi::HeaderKind::Uint64,
                                        detail::to_little_endian_bytes(bytes));
     }
 
-    /// Choose whether writes to this topic's partitions are fsynced.
-    static iggy::ffi::HeaderEntry enforce_fsync(const bool enabled) {
+    /**
+     * @brief Choose whether writes to this topic's partitions are fsynced.
+     *
+     * @param enabled Whether partition writes are fsynced.
+     * @return Encoded topic option entry.
+     */
+    static iggy::ffi::HeaderEntry EnforceFsync(const bool enabled) {
         return detail::to_option_entry("enforce_fsync", iggy::ffi::HeaderKind::Bool, detail::to_bool_bytes(enabled));
     }
 
-    /// Flush the journal once it holds this many messages.
-    ///
-    /// Must be non-zero. Paired with
-    /// `size_of_messages_required_to_save(bytes)`: whichever threshold trips
-    /// first flushes.
-    static iggy::ffi::HeaderEntry messages_required_to_save(const std::uint32_t messages) {
+    /**
+     * @brief Flush the journal once it holds this many messages.
+     *
+     * Must be non-zero. Paired with
+     * `SizeOfMessagesRequiredToSave(bytes)`: whichever threshold trips
+     * first flushes.
+     *
+     * @param messages Message count at which to flush the journal.
+     * @return Encoded topic option entry.
+     */
+    static iggy::ffi::HeaderEntry MessagesRequiredToSave(const std::uint32_t messages) {
         return detail::to_option_entry("messages_required_to_save", iggy::ffi::HeaderKind::Uint32,
                                        detail::to_little_endian_bytes(messages));
     }
 
-    /// Flush the journal once it holds this many bytes.
-    ///
-    /// Capped at 1 GiB: a threshold above the largest a segment may be never
-    /// trips, and the journal does not survive a crash.
-    static iggy::ffi::HeaderEntry size_of_messages_required_to_save(const std::uint64_t bytes) {
+    /**
+     * @brief Flush the journal once it holds this many bytes.
+     *
+     * Capped at 1 GiB: a threshold above the largest a segment may be never
+     * trips, and the journal does not survive a crash.
+     *
+     * @param bytes Byte count at which to flush the journal.
+     * @return Encoded topic option entry.
+     */
+    static iggy::ffi::HeaderEntry SizeOfMessagesRequiredToSave(const std::uint64_t bytes) {
         return detail::to_option_entry("size_of_messages_required_to_save", iggy::ffi::HeaderKind::Uint64,
                                        detail::to_little_endian_bytes(bytes));
     }
 
-    /// Choose whether a segment's bytes are reserved on disk when it is created.
-    ///
-    /// Reserves `segment_size * partitions_count` up front, which the server
-    /// caps at 64 GiB per topic.
-    static iggy::ffi::HeaderEntry preallocate_segments(const bool enabled) {
+    /**
+     * @brief Choose whether a segment's bytes are reserved on disk when it is created.
+     *
+     * Reserves `segment_size * partitions_count` up front, which the server
+     * caps at 64 GiB per topic.
+     *
+     * @param enabled Whether to reserve segment storage on disk.
+     * @return Encoded topic option entry.
+     */
+    static iggy::ffi::HeaderEntry PreallocateSegments(const bool enabled) {
         return detail::to_option_entry("preallocate_segments", iggy::ffi::HeaderKind::Bool,
                                        detail::to_bool_bytes(enabled));
     }
@@ -370,4 +476,453 @@ class TopicOption final {
     TopicOption() = delete;
 };
 
+/**
+ * @brief Exception thrown when an Iggy client operation fails.
+ */
+class IggyException : public std::runtime_error {
+  public:
+    explicit IggyException(const char *message) : std::runtime_error(message) {}
+    explicit IggyException(const std::string &message) : std::runtime_error(message) {}
+};
+
+/**
+ * @brief Owning client connection to an Apache Iggy server.
+ *
+ * Create instances with Builder or FromConnectionString(). The client owns a
+ * handle to the underlying Rust client. Destroying the C++ object releases that
+ * handle. The Rust client aborts its heartbeat task when it is dropped.
+ *
+ * Builder initializes a TCP client. To use QUIC, HTTP, or WebSocket, create the
+ * client with FromConnectionString().
+ *
+ * @code{.cpp}
+ * auto client{iggy::IggyBlockingClient::Builder()
+ *                 .WithServerAddress("127.0.0.1:8090")
+ *                 .Build()};
+ * client.Connect();
+ * client.Login("iggy", "iggy");
+ * client.Shutdown();
+ * @endcode
+ */
+class IggyBlockingClient final {
+  public:
+    class Builder;
+
+    /** @brief IggyBlockingClient is move-only. */
+    IggyBlockingClient(const IggyBlockingClient &)            = delete;
+    IggyBlockingClient &operator=(const IggyBlockingClient &) = delete;
+
+    /**
+     * @brief Transfers ownership of a client.
+     * @param other Client whose connection ownership is transferred.
+     *
+     * The moved-from client may be destroyed or assigned a new value, but must
+     * not be used for client operations.
+     */
+    IggyBlockingClient(IggyBlockingClient &&other) noexcept;
+
+    /**
+     * @brief Replaces this client by taking ownership from another client.
+     * @param other Client whose connection ownership is transferred.
+     * @return Reference to this client.
+     *
+     * Any Rust client handle currently owned by this object is released first.
+     * Call Shutdown() before replacing a connected client. The moved-from
+     * client must not be used for client operations.
+     */
+    IggyBlockingClient &operator=(IggyBlockingClient &&other) noexcept;
+
+    /**
+     * @brief Releases the handle to the underlying Rust client.
+     *
+     * Dropping the underlying Rust client aborts its heartbeat task. Cleanup
+     * errors cannot be reported from the destructor.
+     */
+    ~IggyBlockingClient();
+
+    /**
+     * @brief Creates a client from an Iggy connection string.
+     *
+     * Connection strings use one of these forms:
+     *
+     * - `iggy://<credentials>@<host>:<port>[?<options>]` for TCP.
+     * - `iggy+tcp://<credentials>@<host>:<port>[?<options>]` for TCP.
+     * - `iggy+quic://<credentials>@<host>:<port>[?<options>]` for QUIC.
+     * - `iggy+http://<credentials>@<host>:<port>[?<options>]` for HTTP.
+     * - `iggy+ws://<credentials>@<host>:<port>[?<options>]` for WebSocket.
+     *
+     * Credentials are either `<username>:<password>` or a personal access
+     * token. Multiple query parameters are separated with `&`.
+     *
+     * Connection string examples:
+     *
+     * - Username and password:
+     *   `iggy+tcp://iggy:iggy@127.0.0.1:8090`
+     * - Personal access token:
+     *   `iggy+tcp://iggypat-1234567890abcdef@127.0.0.1:8090`
+     * - TCP with TLS:
+     *   `iggy+tcp://iggy:iggy@localhost:8090?tls=true&tls_domain=localhost`
+     *
+     * TCP accepts these query parameters:
+     *
+     * - `tls=<bool>`
+     * - `tls_domain=<string>`
+     * - `tls_ca_file=<path>`
+     * - `reconnection_retries=<uint32|unlimited>`
+     * - `reconnection_interval=<duration>`
+     * - `reestablish_after=<duration>`
+     * - `heartbeat_interval=<duration>`
+     * - `nodelay=<bool>`
+     *
+     * QUIC accepts these query parameters:
+     *
+     * - `response_buffer_size=<uint64>`
+     * - `max_concurrent_bidi_streams=<uint64>`
+     * - `datagram_send_buffer_size=<uint64>`
+     * - `initial_mtu=<uint16>`
+     * - `send_window=<uint64>`
+     * - `receive_window=<uint64>`
+     * - `keep_alive_interval=<uint64>`
+     * - `max_idle_timeout=<uint64>`
+     * - `validate_certificate=<bool>`
+     * - `heartbeat_interval=<duration>`
+     * - `reconnection_max_retries=<uint32|unlimited>`
+     * - `reconnection_interval=<duration>`
+     * - `reconnection_reestablish_after=<duration>`
+     *
+     * HTTP accepts these query parameters:
+     *
+     * - `heartbeat_interval=<duration>`
+     * - `retries=<uint32>`
+     *
+     * WebSocket accepts these query parameters:
+     *
+     * - `heartbeat_interval=<duration>`
+     * - `reconnection_retries=<uint32|unlimited>`
+     * - `reconnection_interval=<duration>`
+     * - `reestablish_after=<duration>`
+     * - `read_buffer_size=<unsigned integer>`
+     * - `write_buffer_size=<unsigned integer>`
+     * - `max_write_buffer_size=<unsigned integer>`
+     * - `max_message_size=<unsigned integer>`
+     * - `max_frame_size=<unsigned integer>`
+     * - `accept_unmasked_frames=<bool>`
+     * - `tls=<bool>`
+     * - `tls_domain=<string>`
+     * - `tls_ca_file=<path>`
+     * - `tls_validate_certificate=<bool>`
+     *
+     * Durations use Iggy duration syntax, such as `500ms`, `5s`, or `1min`.
+     * Boolean values are `true` or `false`.
+     *
+     * Credentials embedded in the connection string configure automatic login
+     * for Connect() and later reconnections. This method parses configuration
+     * but does not establish a network connection.
+     *
+     * @param connection_string Connection string containing client configuration.
+     * @return Configured, disconnected client.
+     * @throws IggyException if the connection string is invalid or the client
+     *         cannot be created.
+     */
+    static IggyBlockingClient FromConnectionString(std::string connection_string);
+
+    /**
+     * @brief Connects to the configured Iggy server.
+     *
+     * Establishes the configured transport connection and starts heartbeat
+     * processing. If automatic login was configured, authentication is also
+     * performed.
+     *
+     * @note HTTP is stateless; connecting initializes heartbeat processing but
+     *       does not open a persistent transport connection.
+     * @note Repeated calls do not start additional heartbeat tasks. An existing
+     *       heartbeat task is reused while it is still running.
+     * @note The default reconnection limit is unlimited. If the server remains
+     *       unavailable, this method keeps retrying and blocks the caller. Use
+     *       WithReconnectionMaxRetries() to bound the wait.
+     * @throws IggyException if automatic authentication fails, or if a finite
+     *         reconnection limit is configured and exhausted.
+     */
+    void Connect();
+
+    /**
+     * @brief Disconnects from the configured Iggy server.
+     *
+     * Disconnect is temporary. It drops the active transport connection and
+     * changes the client state to disconnected, but keeps the client reusable.
+     * Call Connect() to establish a new connection. Configured automatic login
+     * is applied when reconnecting.
+     *
+     * @note Disconnect() does not stop the existing heartbeat task. With
+     *       automatic login configured, a heartbeat may reconnect and
+     *       authenticate the client in the background.
+     * @note The HTTP transport is stateless and treats this operation as a
+     *       no-op.
+     * @throws IggyException if the client cannot disconnect cleanly.
+     * @see Shutdown()
+     */
+    void Disconnect();
+
+    /**
+     * @brief Shuts down the client and its background tasks.
+     *
+     * Shutdown is terminal for stateful transports. It gracefully closes the
+     * active transport where supported, releases transport resources, and
+     * changes the client state to shutdown. Binary operations then fail with a
+     * client-shutdown error. The background heartbeat task stops when it next
+     * observes that error. Create a new client instead of reusing a shut-down
+     * client.
+     *
+     * @note The HTTP transport is stateless and treats this operation as a
+     *       no-op.
+     * @throws IggyException if shutdown fails.
+     * @see Disconnect()
+     */
+    void Shutdown();
+
+    /**
+     * @brief Authenticates with a username and password.
+     *
+     * For TCP, QUIC, and WebSocket, call Connect() first. A successful login
+     * leaves the transport connected and marks the session authenticated. For
+     * HTTP, the returned access token is stored by the client and used for
+     * subsequent authenticated requests.
+     *
+     * @param username Iggy user name.
+     * @param password Iggy user password.
+     * @return Information about the authenticated session.
+     * @throws IggyException if authentication fails.
+     */
+    LoginInfo Login(std::string username, std::string password);
+
+    /**
+     * @brief Ends the current authenticated session.
+     *
+     * Logout does not disconnect the transport. For binary transports, the
+     * client returns to the connected but unauthenticated state. For HTTP, the
+     * stored access token is cleared after the server accepts the logout.
+     * Protected operations require another successful Login() or an automatic
+     * login during reconnection.
+     *
+     * @throws IggyException if logout fails.
+     * @see Disconnect()
+     */
+    void Logout();
+
+  private:
+    explicit IggyBlockingClient(ffi::Client *client);
+
+    template <typename Operation>
+    static decltype(auto) RethrowAsIggyException(Operation &&operation) {
+        try {
+            return std::forward<Operation>(operation)();
+        } catch (const std::exception &error) {
+            throw IggyException(error.what());
+        }
+    }
+
+    ffi::Client *Handle() const;
+    void Reset() noexcept;
+
+    ffi::Client *client_;
+};
+
+/**
+ * @brief Fluent builder for IggyBlockingClient.
+ *
+ * The builder creates TCP clients only. Use
+ * IggyBlockingClient::FromConnectionString() to select another transport.
+ * Configuration methods return the builder by reference and may be chained.
+ * Unless documented otherwise, settings are validated and applied by Build().
+ */
+class IggyBlockingClient::Builder final {
+  public:
+    /**
+     * @brief Creates a builder with the default TCP endpoint, 127.0.0.1:8090.
+     *
+     * Automatic login and TLS are disabled. Reconnection is enabled with
+     * unlimited retries, a one-second retry interval, and a five-second delay
+     * before reestablishing a previously working connection. The heartbeat
+     * interval is five seconds. TCP_NODELAY is disabled. Build() always returns
+     * a disconnected client.
+     */
+    Builder();
+
+    /**
+     * @brief Sets the TCP server address.
+     *
+     * The address is trimmed and validated during Build(). Host names, IPv4,
+     * and bracketed IPv6 are accepted. A non-zero port is required.
+     *
+     * @param server_address Server address in host:port form.
+     * @return Reference to this builder.
+     * @throws IggyException if @p server_address is empty.
+     * @note Build() throws IggyException if the address is invalid.
+     */
+    Builder &WithServerAddress(std::string server_address);
+
+    /**
+     * @brief Enables automatic authentication with user credentials.
+     *
+     * The credentials are used whenever Connect() establishes a connection,
+     * including reconnections. This replaces a previously configured personal
+     * access token.
+     *
+     * @param username Iggy user name.
+     * @param password Iggy user password.
+     * @return Reference to this builder.
+     * @throws IggyException if either credential is empty.
+     * @see IggyBlockingClient::Connect()
+     * @see IggyBlockingClient::Login()
+     */
+    Builder &WithAutoLogin(std::string username, std::string password);
+
+    /**
+     * @brief Enables automatic authentication with a personal access token.
+     *
+     * The token is used whenever Connect() establishes a connection, including
+     * reconnections. This replaces previously configured username and password
+     * credentials.
+     *
+     * @param token Personal access token.
+     * @return Reference to this builder.
+     * @throws IggyException if the token is empty.
+     * @see IggyBlockingClient::Connect()
+     */
+    Builder &WithPersonalAccessToken(std::string token);
+
+    /**
+     * @brief Sets the maximum number of reconnection attempts.
+     *
+     * Reconnection is enabled by default. A value of zero disables retries
+     * after the initial connection attempt. This replaces a previous call to
+     * WithoutReconnectionLimit().
+     *
+     * @param retries Maximum number of attempts.
+     * @return Reference to this builder.
+     */
+    Builder &WithReconnectionMaxRetries(std::uint32_t retries);
+
+    /**
+     * @brief Removes the limit on reconnection attempts.
+     *
+     * This is the default and replaces a previous finite retry limit.
+     *
+     * @return Reference to this builder.
+     */
+    Builder &WithoutReconnectionLimit();
+
+    /**
+     * @brief Sets the delay between reconnection attempts.
+     *
+     * The default interval is one second. This interval applies between failed
+     * connection attempts.
+     *
+     * @param interval Non-negative reconnection interval.
+     * @return Reference to this builder.
+     * @throws IggyException if @p interval is negative.
+     */
+    Builder &WithReconnectionInterval(std::chrono::microseconds interval);
+
+    /**
+     * @brief Sets the delay before restoring a lost established connection.
+     *
+     * The default delay is five seconds. This cooldown is distinct from the
+     * interval between failed connection attempts.
+     *
+     * @param duration Non-negative delay.
+     * @return Reference to this builder.
+     * @throws IggyException if @p duration is negative.
+     */
+    Builder &WithReestablishAfter(std::chrono::microseconds duration);
+
+    /**
+     * @brief Enables or disables TLS.
+     *
+     * TLS is disabled by default. TLS domain, CA file, and certificate
+     * validation settings require TLS to be enabled.
+     *
+     * @param enabled Whether TLS is enabled.
+     * @return Reference to this builder.
+     */
+    Builder &WithTlsEnabled(bool enabled = true);
+
+    /**
+     * @brief Sets the domain used for TLS server-name verification.
+     *
+     * When omitted, the domain is derived from the configured server address.
+     * Build() throws IggyException if this is set while TLS is disabled.
+     *
+     * @param domain TLS domain name.
+     * @return Reference to this builder.
+     * @throws IggyException if @p domain is empty.
+     */
+    Builder &WithTlsDomain(std::string domain);
+
+    /**
+     * @brief Sets the certificate-authority file used by TLS.
+     *
+     * When omitted, system root certificates are used. This setting has no
+     * effect unless TLS is enabled. Build() throws IggyException if a path is
+     * set while TLS is disabled.
+     *
+     * @param path Path to a PEM-encoded certificate-authority file.
+     * @return Reference to this builder.
+     * @throws IggyException if @p path is empty.
+     */
+    Builder &WithTlsCaFile(std::string path);
+
+    /**
+     * @brief Enables or disables TLS certificate validation.
+     *
+     * Certificate validation is enabled by default. Disabling it accepts
+     * certificates without verifying their trust chain or server identity and
+     * should be limited to controlled development environments. This setting
+     * requires TLS; Build() throws IggyException if certificate validation is
+     * configured while TLS is disabled.
+     *
+     * @param enabled Whether the server certificate is validated.
+     * @return Reference to this builder.
+     */
+    Builder &WithTlsCertificateValidation(bool enabled = true);
+
+    /**
+     * @brief Enables TCP_NODELAY on the client socket.
+     *
+     * TCP_NODELAY disables Nagle's algorithm to reduce latency for small
+     * writes, potentially increasing packet count. It is disabled by default.
+     *
+     * @return Reference to this builder.
+     */
+    Builder &WithNoDelay();
+
+    /**
+     * @brief Builds an owning Iggy blocking client.
+     *
+     * Build() validates the TCP configuration and creates an independent
+     * client. The builder is not consumed and may be reused. The returned
+     * client is always disconnected; call IggyBlockingClient::Connect()
+     * explicitly before using operations that require a connection.
+     *
+     * @return Configured client.
+     * @throws IggyException if validation or client creation fails.
+     */
+    IggyBlockingClient Build() const;
+
+  private:
+    std::string server_address_{};
+    ffi::AutoLoginKind auto_login_kind_{ffi::AutoLoginKind::Disabled};
+    std::string auto_login_username_{};
+    std::string auto_login_password_{};
+    std::string personal_access_token_{};
+    std::optional<std::uint32_t> reconnection_max_retries_{};
+    std::optional<std::uint64_t> reconnection_interval_micros_{};
+    std::optional<std::uint64_t> reestablish_after_micros_{};
+    bool tls_enabled_{};
+    std::string tls_domain_{};
+    std::string tls_ca_file_{};
+    std::optional<bool> tls_validate_certificate_{};
+    bool no_delay_{};
+};
+
 }  // namespace iggy
diff --git a/foreign/cpp/src/client.cpp b/foreign/cpp/src/client.cpp
new file mode 100644
index 000000000..f81e7956c
--- /dev/null
+++ b/foreign/cpp/src/client.cpp
@@ -0,0 +1,200 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include "iggy.hpp"
+
+namespace iggy {
+
+IggyBlockingClient::IggyBlockingClient(IggyBlockingClient &&other) noexcept
+    : client_(std::exchange(other.client_, nullptr)) {}
+
+IggyBlockingClient &IggyBlockingClient::operator=(IggyBlockingClient &&other) noexcept {
+    if (this != &other) {
+        Reset();
+        client_ = std::exchange(other.client_, nullptr);
+    }
+    return *this;
+}
+
+IggyBlockingClient::~IggyBlockingClient() {
+    Reset();
+}
+
+IggyBlockingClient IggyBlockingClient::FromConnectionString(std::string connection_string) {
+    return RethrowAsIggyException(
+        [&connection_string] { return IggyBlockingClient(ffi::from_connection_string(connection_string)); });
+}
+
+void IggyBlockingClient::Connect() {
+    RethrowAsIggyException([this] { Handle()->connect(); });
+}
+
+void IggyBlockingClient::Disconnect() {
+    RethrowAsIggyException([this] { Handle()->disconnect(); });
+}
+
+void IggyBlockingClient::Shutdown() {
+    RethrowAsIggyException([this] { Handle()->shutdown(); });
+}
+
+LoginInfo IggyBlockingClient::Login(std::string username, std::string password) {
+    return RethrowAsIggyException(
+        [this, &username, &password] { return Handle()->login_user(std::move(username), std::move(password)); });
+}
+
+void IggyBlockingClient::Logout() {
+    RethrowAsIggyException([this] { Handle()->logout_user(); });
+}
+
+IggyBlockingClient::IggyBlockingClient(ffi::Client *client) : client_(client) {
+    if (client_ == nullptr) {
+        throw IggyException("Could not create Iggy client");
+    }
+}
+
+ffi::Client *IggyBlockingClient::Handle() const {
+    if (client_ == nullptr) {
+        throw IggyException("Cannot use a moved-from IggyBlockingClient");
+    }
+    return client_;
+}
+
+void IggyBlockingClient::Reset() noexcept {
+    if (client_ == nullptr) {
+        return;
+    }
+
+    ffi::Client *client{std::exchange(client_, nullptr)};
+    ffi::delete_client(client);
+}
+
+IggyBlockingClient::Builder::Builder() = default;
+
+IggyBlockingClient::Builder &IggyBlockingClient::Builder::WithServerAddress(std::string server_address) {
+    if (server_address.empty()) {
+        throw IggyException("Server address cannot be empty");
+    }
+    server_address_ = std::move(server_address);
+    return *this;
+}
+
+IggyBlockingClient::Builder &IggyBlockingClient::Builder::WithAutoLogin(std::string username, std::string password) {
+    if (username.empty() || password.empty()) {
+        throw IggyException("Automatic login username and password cannot be empty");
+    }
+    auto_login_kind_     = ffi::AutoLoginKind::UsernamePassword;
+    auto_login_username_ = std::move(username);
+    auto_login_password_ = std::move(password);
+    personal_access_token_.clear();
+    return *this;
+}
+
+IggyBlockingClient::Builder &IggyBlockingClient::Builder::WithPersonalAccessToken(std::string token) {
+    if (token.empty()) {
+        throw IggyException("Personal access token cannot be empty");
+    }
+    auto_login_kind_       = ffi::AutoLoginKind::PersonalAccessToken;
+    personal_access_token_ = std::move(token);
+    auto_login_username_.clear();
+    auto_login_password_.clear();
+    return *this;
+}
+
+IggyBlockingClient::Builder &IggyBlockingClient::Builder::WithReconnectionMaxRetries(std::uint32_t retries) {
+    reconnection_max_retries_ = retries;
+    return *this;
+}
+
+IggyBlockingClient::Builder &IggyBlockingClient::Builder::WithoutReconnectionLimit() {
+    reconnection_max_retries_.reset();
+    return *this;
+}
+
+IggyBlockingClient::Builder &IggyBlockingClient::Builder::WithReconnectionInterval(std::chrono::microseconds interval) {
+    if (interval.count() < 0) {
+        throw IggyException("Reconnection interval cannot be negative");
+    }
+    reconnection_interval_micros_ = static_cast<std::uint64_t>(interval.count());
+    return *this;
+}
+
+IggyBlockingClient::Builder &IggyBlockingClient::Builder::WithReestablishAfter(std::chrono::microseconds duration) {
+    if (duration.count() < 0) {
+        throw IggyException("Reestablish duration cannot be negative");
+    }
+    reestablish_after_micros_ = static_cast<std::uint64_t>(duration.count());
+    return *this;
+}
+
+IggyBlockingClient::Builder &IggyBlockingClient::Builder::WithTlsEnabled(bool enabled) {
+    tls_enabled_ = enabled;
+    return *this;
+}
+
+IggyBlockingClient::Builder &IggyBlockingClient::Builder::WithTlsDomain(std::string domain) {
+    if (domain.empty()) {
+        throw IggyException("TLS domain cannot be empty");
+    }
+    tls_domain_ = std::move(domain);
+    return *this;
+}
+
+IggyBlockingClient::Builder &IggyBlockingClient::Builder::WithTlsCaFile(std::string path) {
+    if (path.empty()) {
+        throw IggyException("TLS CA file cannot be empty");
+    }
+    tls_ca_file_ = std::move(path);
+    return *this;
+}
+
+IggyBlockingClient::Builder &IggyBlockingClient::Builder::WithTlsCertificateValidation(bool enabled) {
+    tls_validate_certificate_ = enabled;
+    return *this;
+}
+
+IggyBlockingClient::Builder &IggyBlockingClient::Builder::WithNoDelay() {
+    no_delay_ = true;
+    return *this;
+}
+
+IggyBlockingClient IggyBlockingClient::Builder::Build() const {
+    return IggyBlockingClient::RethrowAsIggyException([this] {
+        ffi::IggyClientConfig config{};
+        config.server_address               = server_address_;
+        config.auto_login_kind              = auto_login_kind_;
+        config.username                     = auto_login_username_;
+        config.password                     = auto_login_password_;
+        config.personal_access_token        = personal_access_token_;
+        config.has_reconnection_max_retries = reconnection_max_retries_.has_value();
+        config.reconnection_max_retries     = reconnection_max_retries_.value_or(0);
+        config.has_reconnection_interval    = reconnection_interval_micros_.has_value();
+        config.reconnection_interval_micros = reconnection_interval_micros_.value_or(0);
+        config.has_reestablish_after        = reestablish_after_micros_.has_value();
+        config.reestablish_after_micros     = reestablish_after_micros_.value_or(0);
+        config.tls_enabled                  = tls_enabled_;
+        config.tls_domain                   = tls_domain_;
+        config.tls_ca_file                  = tls_ca_file_;
+        config.has_tls_validate_certificate = tls_validate_certificate_.has_value();
+        config.tls_validate_certificate     = tls_validate_certificate_.value_or(false);
+        config.no_delay                     = no_delay_;
+        return IggyBlockingClient(ffi::new_connection(std::move(config)));
+    });
+}
+
+}  // namespace iggy
diff --git a/foreign/cpp/src/client.rs b/foreign/cpp/src/client.rs
index 0206aebd4..a03e3d39e 100644
--- a/foreign/cpp/src/client.rs
+++ b/foreign/cpp/src/client.rs
@@ -18,17 +18,19 @@
 use crate::{RUNTIME, ffi};
 use bytes::Bytes;
 use iggy::prelude::{
-    Client as IggyConnectionClient, ClusterClient,
+    AutoLogin as RustAutoLogin, Client as IggyConnectionClient, ClusterClient,
     CompressionAlgorithm as RustCompressionAlgorithm, Consumer, ConsumerGroupClient,
     ConsumerOffsetClient, Identifier as RustIdentifier, IggyClient as RustIggyClient,
-    IggyClientBuilder as RustIggyClientBuilder, IggyExpiry as RustIggyExpiry, IggyMessage,
-    IggyTimestamp, MaxTopicSize as RustMaxTopicSize, MessageClient,
-    OptionsScope as RustOptionsScope, PartitionClient, Partitioning,
+    IggyClientBuilder as RustIggyClientBuilder, IggyDuration as RustIggyDuration,
+    IggyExpiry as RustIggyExpiry, IggyMessage, IggyTimestamp, MaxTopicSize as RustMaxTopicSize,
+    MessageClient, OptionsScope as RustOptionsScope, PartitionClient, Partitioning,
     Permissions as RustPermissions, PollingStrategy, SegmentClient,
     SnapshotCompression as RustSnapshotCompression, StreamClient, StreamUpdateOptions,
     SystemClient as RustSystemClient, SystemSnapshotType as RustSystemSnapshotType, TopicClient,
-    TopicCreateOptions, TopicUpdateOptions, UserClient,
+    TopicCreateOptions, TopicUpdateOptions, UserClient, UserStatus as RustUserStatus,
+    UserUpdateOptions,
 };
+use iggy_common::Credentials as RustCredentials;
 use std::collections::HashSet;
 use std::convert::TryFrom;
 use std::str::FromStr;
@@ -75,23 +77,71 @@ pub struct Client {
 ///   (use-after-free).
 /// - This function does not provide synchronisation. The pointer must not be used concurrently
 ///   from multiple threads unless the caller serialises access externally.
-pub fn new_connection(connection_string: String) -> Result<*mut Client, String> {
-    let connection_str = connection_string.as_str();
-    let client = match connection_str {
-        "" => RustIggyClientBuilder::new()
-            .with_tcp()
-            .build()
-            .map_err(|error| format!("Could not build default connection: {error}"))?,
-        s if s.starts_with("iggy://") || s.starts_with("iggy+") => {
-            RustIggyClient::from_connection_string(s)
-                .map_err(|error| format!("Could not parse connection string '{s}': {error}"))?
+pub fn new_connection(config: ffi::IggyClientConfig) -> Result<*mut Client, String> {
+    let mut builder = RustIggyClientBuilder::new().with_tcp();
+    if !config.server_address.is_empty() {
+        builder = builder.with_server_address(config.server_address);
+    }
+    match config.auto_login_kind {
+        ffi::AutoLoginKind::Disabled => {}
+        ffi::AutoLoginKind::UsernamePassword => {
+            builder = builder.with_auto_sign_in(RustAutoLogin::Enabled(
+                RustCredentials::UsernamePassword(config.username, config.password.into()),
+            ));
+        }
+        ffi::AutoLoginKind::PersonalAccessToken => {
+            builder = builder.with_auto_sign_in(RustAutoLogin::Enabled(
+                RustCredentials::PersonalAccessToken(config.personal_access_token.into()),
+            ));
         }
-        s => RustIggyClientBuilder::new()
-            .with_tcp()
-            .with_server_address(connection_string.clone())
-            .build()
-            .map_err(|error| format!("Could not build connection for address '{s}': {error}"))?,
-    };
+        _ => return Err("Unsupported automatic login kind".to_owned()),
+    }
+    builder = builder.with_reconnection_max_retries(
+        config
+            .has_reconnection_max_retries
+            .then_some(config.reconnection_max_retries),
+    );
+    if config.has_reconnection_interval {
+        builder = builder.with_reconnection_interval(RustIggyDuration::from(
+            config.reconnection_interval_micros,
+        ));
+    }
+    if config.has_reestablish_after {
+        builder =
+            builder.with_reestablish_after(RustIggyDuration::from(config.reestablish_after_micros));
+    }
+    if !config.tls_enabled
+        && (!config.tls_domain.is_empty()
+            || !config.tls_ca_file.is_empty()
+            || config.has_tls_validate_certificate)
+    {
+        return Err("TLS settings require TLS to be enabled".to_owned());
+    }
+    builder = builder.with_tls_enabled(config.tls_enabled);
+    if !config.tls_domain.is_empty() {
+        builder = builder.with_tls_domain(config.tls_domain);
+    }
+    if !config.tls_ca_file.is_empty() {
+        builder = builder.with_tls_ca_file(config.tls_ca_file);
+    }
+    if config.has_tls_validate_certificate {
+        builder = builder.with_tls_validate_certificate(config.tls_validate_certificate);
+    }
+    if config.no_delay {
+        builder = builder.with_no_delay();
+    }
+    let client = builder
+        .build()
+        .map_err(|error| format!("Could not build configured connection: {error}"))?;
+
+    Ok(Box::into_raw(Box::new(Client {
+        inner: Arc::new(client),
+    })))
+}
+
+pub fn from_connection_string(connection_string: String) -> Result<*mut Client, String> {
+    let client = RustIggyClient::from_connection_string(&connection_string)
+        .map_err(|error| format!("Could not parse connection string: {error}"))?;
 
     Ok(Box::into_raw(Box::new(Client {
         inner: Arc::new(client),
@@ -99,13 +149,14 @@ pub fn new_connection(connection_string: String) -> Result<*mut Client, String>
 }
 
 impl Client {
-    pub fn login_user(&self, username: String, password: String) -> Result<(), String> {
+    pub fn login_user(&self, username: String, password: String) -> Result<ffi::LoginInfo, String> {
         RUNTIME.block_on(async {
-            self.inner
+            let identity = self
+                .inner
                 .login_user(&username, &password)
                 .await
                 .map_err(|error| format!("Could not login user '{username}': {error}"))?;
-            Ok(())
+            Ok(ffi::LoginInfo::from(identity))
         })
     }
 
@@ -1137,6 +1188,99 @@ impl Client {
         })
     }
 
+    pub fn get_user(&self, user_id: ffi::Identifier) -> Result<ffi::UserInfoDetails, String> {
+        let rust_user_id = RustIdentifier::try_from(user_id)
+            .map_err(|error| format!("Could not get user: invalid user identifier: {error}"))?;
+
+        RUNTIME.block_on(async {
+            let user = self
+                .inner
+                .get_user(&rust_user_id)
+                .await
+                .map_err(|error| format!("Could not get user '{rust_user_id}': {error}"))?;
+            ffi::UserInfoDetails::try_from(user)
+                .map_err(|error| format!("Could not get user '{rust_user_id}': {error}"))
+        })
+    }
+
+    pub fn get_users(&self) -> Result<Vec<ffi::UserInfo>, String> {
+        RUNTIME.block_on(async {
+            let users = self
+                .inner
+                .get_users()
+                .await
+                .map_err(|error| format!("Could not get users: {error}"))?;
+            Ok(users.into_iter().map(ffi::UserInfo::from).collect())
+        })
+    }
+
+    pub fn create_user(
+        &self,
+        username: String,
+        password: String,
+        status: ffi::UserStatus,
+        has_permissions: bool,
+        permissions: ffi::Permissions,
+    ) -> Result<ffi::UserInfoDetails, String> {
+        let rust_status = RustUserStatus::try_from(status)
+            .map_err(|error| format!("Could not create user '{username}': {error}"))?;
+        let rust_permissions = has_permissions
+            .then(|| RustPermissions::try_from(permissions))
+            .transpose()
+            .map_err(|error| format!("Could not create user '{username}': {error}"))?;
+
+        RUNTIME.block_on(async {
+            let user = self
+                .inner
+                .create_user(&username, &password, rust_status, rust_permissions)
+                .await
+                .map_err(|error| format!("Could not create user '{username}': {error}"))?;
+            Ok(ffi::UserInfoDetails::from(user))
+        })
+    }
+
+    pub fn delete_user(&self, user_id: ffi::Identifier) -> Result<(), String> {
+        let rust_user_id = RustIdentifier::try_from(user_id)
+            .map_err(|error| format!("Could not delete user: invalid user identifier: {error}"))?;
+
+        RUNTIME.block_on(async {
+            self.inner
+                .delete_user(&rust_user_id)
+                .await
+                .map_err(|error| format!("Could not delete user '{rust_user_id}': {error}"))?;
+            Ok(())
+        })
+    }
+
+    pub fn update_user(
+        &self,
+        user_id: ffi::Identifier,
+        has_username: bool,
+        username: String,
+        has_status: bool,
+        status: ffi::UserStatus,
+    ) -> Result<(), String> {
+        let rust_user_id = RustIdentifier::try_from(user_id)
+            .map_err(|error| format!("Could not update user: invalid user identifier: {error}"))?;
+        let rust_status = has_status
+            .then(|| RustUserStatus::try_from(status))
+            .transpose()
+            .map_err(|error| format!("Could not update user '{rust_user_id}': {error}"))?;
+
+        RUNTIME.block_on(async {
+            self.inner
+                .update_user(
+                    &rust_user_id,
+                    has_username.then_some(username.as_str()),
+                    rust_status,
+                    &UserUpdateOptions::default(),
+                )
+                .await
+                .map_err(|error| format!("Could not update user '{rust_user_id}': {error}"))?;
+            Ok(())
+        })
+    }
+
     pub fn update_permissions(
         &self,
         user_id: ffi::Identifier,
@@ -1197,12 +1341,10 @@ impl Client {
     }
 }
 
-pub unsafe fn delete_connection(client: *mut Client) -> Result<(), String> {
+pub unsafe fn delete_connection(client: *mut Client) {
     if !client.is_null() {
         unsafe {
             drop(Box::from_raw(client));
         }
     }
-
-    Ok(())
 }
diff --git a/foreign/cpp/src/lib.rs b/foreign/cpp/src/lib.rs
index 3f7eda58d..496b0728f 100644
--- a/foreign/cpp/src/lib.rs
+++ b/foreign/cpp/src/lib.rs
@@ -22,7 +22,7 @@ mod messages;
 mod producer;
 mod type_conversion;
 
-use client::{Client, delete_connection as delete_client, new_connection};
+use client::{Client, delete_connection as delete_client, from_connection_string, new_connection};
 use consumer::Consumer;
 use messages::make_message;
 use producer::Producer;
@@ -372,14 +372,71 @@ mod ffi {
         streams: Vec<StreamPermissionEntry>,
     }
 
+    #[repr(u8)]
+    enum UserStatus {
+        Active = 1,
+        Inactive = 2,
+    }
+
+    struct UserInfo {
+        id: u32,
+        created_at: u64,
+        status: UserStatus,
+        username: String,
+    }
+
+    struct UserInfoDetails {
+        id: u32,
+        created_at: u64,
+        status: UserStatus,
+        username: String,
+        has_permissions: bool,
+        permissions: Permissions,
+    }
+
+    struct LoginInfo {
+        user_id: u32,
+        has_access_token: bool,
+        access_token: String,
+        access_token_expiry: u64,
+    }
+
+    #[repr(u8)]
+    enum AutoLoginKind {
+        Disabled = 0,
+        UsernamePassword,
+        PersonalAccessToken,
+    }
+
+    struct IggyClientConfig {
+        server_address: String,
+        auto_login_kind: AutoLoginKind,
+        username: String,
+        password: String,
+        personal_access_token: String,
+        has_reconnection_max_retries: bool,
+        reconnection_max_retries: u32,
+        has_reconnection_interval: bool,
+        reconnection_interval_micros: u64,
+        has_reestablish_after: bool,
+        reestablish_after_micros: u64,
+        tls_enabled: bool,
+        tls_domain: String,
+        tls_ca_file: String,
+        has_tls_validate_certificate: bool,
+        tls_validate_certificate: bool,
+        no_delay: bool,
+    }
+
     extern "Rust" {
         type Client;
         type Consumer;
         type Producer;
 
         // Client functions
-        fn new_connection(connection_string: String) -> Result<*mut Client>;
-        fn login_user(self: &Client, username: String, password: String) -> Result<()>;
+        fn new_connection(config: IggyClientConfig) -> Result<*mut Client>;
+        fn from_connection_string(connection_string: String) -> Result<*mut Client>;
+        fn login_user(self: &Client, username: String, password: String) -> Result<LoginInfo>;
         fn logout_user(self: &Client) -> Result<()>;
         fn connect(self: &Client) -> Result<()>;
         fn create_stream(self: &Client, stream_name: String) -> Result<StreamDetails>;
@@ -553,11 +610,25 @@ mod ffi {
             partition_id: u32,
             segments_count: u32,
         ) -> Result<()>;
-        // fn get_user(self: &Client, user_id: Identifier) -> Result<()>;
-        // fn get_users(self: &Client) -> Result<()>;
-        // fn create_user(self: &Client, username: String, password: String, status: u8) -> Result<()>;
-        // fn delete_user(self: &Client, user_id: Identifier) -> Result<()>;
-        // fn update_user(self: &Client, user_id: Identifier, username: String, status: u8) -> Result<()>;
+        fn get_user(self: &Client, user_id: Identifier) -> Result<UserInfoDetails>;
+        fn get_users(self: &Client) -> Result<Vec<UserInfo>>;
+        fn create_user(
+            self: &Client,
+            username: String,
+            password: String,
+            status: UserStatus,
+            has_permissions: bool,
+            permissions: Permissions,
+        ) -> Result<UserInfoDetails>;
+        fn delete_user(self: &Client, user_id: Identifier) -> Result<()>;
+        fn update_user(
+            self: &Client,
+            user_id: Identifier,
+            has_username: bool,
+            username: String,
+            has_status: bool,
+            status: UserStatus,
+        ) -> Result<()>;
         fn update_permissions(
             self: &Client,
             user_id: Identifier,
@@ -580,7 +651,7 @@ mod ffi {
         // fn delete_personal_access_token(self: &Client, name: String) -> Result<()>;
         // fn login_with_personal_access_token(self: &Client, token: String) -> Result<IdentityInfo>;
 
-        unsafe fn delete_client(client: *mut Client) -> Result<()>;
+        unsafe fn delete_client(client: *mut Client);
 
         // Identifier functions
         fn set_string(self: &mut Identifier, id: String) -> Result<()>;
diff --git a/foreign/cpp/src/type_conversion.rs b/foreign/cpp/src/type_conversion.rs
index e2c2866df..83d4c52b6 100644
--- a/foreign/cpp/src/type_conversion.rs
+++ b/foreign/cpp/src/type_conversion.rs
@@ -34,9 +34,10 @@ use iggy_common::{
     ConsumerGroup as RustConsumerGroup, ConsumerGroupInfo as RustConsumerGroupInfo,
     ConsumerGroupMember as RustConsumerGroupMember, ConsumerOffsetInfo as RustConsumerOffsetInfo,
     GlobalPermissions as RustGlobalPermissions, HeaderEntry as RustHeaderEntry,
-    HeaderField as RustHeaderField, HeaderKind as RustHeaderKind, Permissions as RustPermissions,
-    Stats as RustStats, StreamPermissions as RustStreamPermissions,
+    HeaderField as RustHeaderField, HeaderKind as RustHeaderKind, IdentityInfo as RustIdentityInfo,
+    Permissions as RustPermissions, Stats as RustStats, StreamPermissions as RustStreamPermissions,
     TopicPermissions as RustTopicPermissions, TransportEndpoints as RustTransportEndpoints,
+    UserInfo as RustUserInfo, UserInfoDetails as RustUserInfoDetails, UserStatus as RustUserStatus,
 };
 use std::collections::BTreeMap;
 
@@ -128,6 +129,80 @@ impl TryFrom<Option<RustClientInfoDetails>> for ffi::ClientInfoDetails {
     }
 }
 
+impl From<RustIdentityInfo> for ffi::LoginInfo {
+    fn from(identity: RustIdentityInfo) -> Self {
+        let has_access_token = identity.access_token.is_some();
+        let (access_token, access_token_expiry) = identity
+            .access_token
+            .map(|token| (token.token, token.expiry))
+            .unwrap_or_default();
+
+        ffi::LoginInfo {
+            user_id: identity.user_id,
+            has_access_token,
+            access_token,
+            access_token_expiry,
+        }
+    }
+}
+
+impl From<RustUserInfo> for ffi::UserInfo {
+    fn from(user: RustUserInfo) -> Self {
+        ffi::UserInfo {
+            id: user.id,
+            created_at: user.created_at.as_micros(),
+            status: ffi::UserStatus::from(user.status),
+            username: user.username,
+        }
+    }
+}
+
+impl From<RustUserInfoDetails> for ffi::UserInfoDetails {
+    fn from(user: RustUserInfoDetails) -> Self {
+        let has_permissions = user.permissions.is_some();
+        ffi::UserInfoDetails {
+            id: user.id,
+            created_at: user.created_at.as_micros(),
+            status: ffi::UserStatus::from(user.status),
+            username: user.username,
+            has_permissions,
+            permissions: ffi::Permissions::from(user.permissions.unwrap_or_default()),
+        }
+    }
+}
+
+impl From<RustUserStatus> for ffi::UserStatus {
+    fn from(status: RustUserStatus) -> Self {
+        match status {
+            RustUserStatus::Active => ffi::UserStatus::Active,
+            RustUserStatus::Inactive => ffi::UserStatus::Inactive,
+        }
+    }
+}
+
+impl TryFrom<ffi::UserStatus> for RustUserStatus {
+    type Error = String;
+
+    fn try_from(status: ffi::UserStatus) -> Result<Self, Self::Error> {
+        match status {
+            ffi::UserStatus::Active => Ok(RustUserStatus::Active),
+            ffi::UserStatus::Inactive => Ok(RustUserStatus::Inactive),
+            _ => Err("invalid user status".to_owned()),
+        }
+    }
+}
+
+impl TryFrom<Option<RustUserInfoDetails>> for ffi::UserInfoDetails {
+    type Error = String;
+
+    fn try_from(user: Option<RustUserInfoDetails>) -> Result<Self, Self::Error> {
+        match user {
+            Some(user) => Ok(ffi::UserInfoDetails::from(user)),
+            None => Err("user not found".to_string()),
+        }
+    }
+}
+
 impl TryFrom<Option<RustConsumerOffsetInfo>> for ffi::ConsumerOffsetInfo {
     type Error = String;
 
@@ -233,6 +308,73 @@ impl From<RustClusterMetadata> for ffi::ClusterMetadata {
     }
 }
 
+impl From<RustGlobalPermissions> for ffi::GlobalPermissions {
+    fn from(permissions: RustGlobalPermissions) -> Self {
+        ffi::GlobalPermissions {
+            manage_servers: permissions.manage_servers,
+            read_servers: permissions.read_servers,
+            manage_users: permissions.manage_users,
+            read_users: permissions.read_users,
+            manage_streams: permissions.manage_streams,
+            read_streams: permissions.read_streams,
+            manage_topics: permissions.manage_topics,
+            read_topics: permissions.read_topics,
+            poll_messages: permissions.poll_messages,
+            send_messages: permissions.send_messages,
+        }
+    }
+}
+
+impl From<RustTopicPermissions> for ffi::TopicPermissions {
+    fn from(permissions: RustTopicPermissions) -> Self {
+        ffi::TopicPermissions {
+            manage_topic: permissions.manage_topic,
+            read_topic: permissions.read_topic,
+            poll_messages: permissions.poll_messages,
+            send_messages: permissions.send_messages,
+        }
+    }
+}
+
+impl From<RustStreamPermissions> for ffi::StreamPermissions {
+    fn from(permissions: RustStreamPermissions) -> Self {
+        ffi::StreamPermissions {
+            manage_stream: permissions.manage_stream,
+            read_stream: permissions.read_stream,
+            manage_topics: permissions.manage_topics,
+            read_topics: permissions.read_topics,
+            poll_messages: permissions.poll_messages,
+            send_messages: permissions.send_messages,
+            topics: permissions
+                .topics
+                .unwrap_or_default()
+                .into_iter()
+                .map(|(topic_id, permissions)| ffi::TopicPermissionEntry {
+                    topic_id: topic_id as u32,
+                    permissions: ffi::TopicPermissions::from(permissions),
+                })
+                .collect(),
+        }
+    }
+}
+
+impl From<RustPermissions> for ffi::Permissions {
+    fn from(permissions: RustPermissions) -> Self {
+        ffi::Permissions {
+            global: ffi::GlobalPermissions::from(permissions.global),
+            streams: permissions
+                .streams
+                .unwrap_or_default()
+                .into_iter()
+                .map(|(stream_id, permissions)| ffi::StreamPermissionEntry {
+                    stream_id: stream_id as u32,
+                    permissions: ffi::StreamPermissions::from(permissions),
+                })
+                .collect(),
+        }
+    }
+}
+
 impl From<ffi::GlobalPermissions> for RustGlobalPermissions {
     fn from(permissions: ffi::GlobalPermissions) -> Self {
         RustGlobalPermissions {
diff --git a/foreign/cpp/tests/e2e/client.cpp b/foreign/cpp/tests/e2e/client.cpp
index 8e5d370d3..5fde59871 100644
--- a/foreign/cpp/tests/e2e/client.cpp
+++ b/foreign/cpp/tests/e2e/client.cpp
@@ -24,6 +24,7 @@
 #include <chrono>
 #include <cstddef>
 #include <cstdint>
+#include <limits>
 #include <string>
 #include <thread>
 #include <unordered_set>
@@ -36,28 +37,60 @@
 class LowLevelE2E_Client : public E2ETestFixture {};
 
 TEST_F(LowLevelE2E_Client, ConnectAndLogin) {
-    RecordProperty("description", "Connects and logs in successfully using each supported connection string format.");
+    RecordProperty("description",
+                   "Connects and returns matching login information using binary connection string formats.");
+    constexpr std::uint32_t root_user_id   = 0;
     const std::string username             = "iggy";
     const std::string password             = "iggy";
     const std::string connection_strings[] = {
         "iggy://iggy:iggy@127.0.0.1:8090",
         "iggy+tcp://iggy:iggy@127.0.0.1:8090",
-        "iggy+http://iggy:iggy@127.0.0.1:3000",
         "",
     };
 
     for (const std::string &connection_string : connection_strings) {
         SCOPED_TRACE(connection_string);
         iggy::ffi::Client *client = nullptr;
-        ASSERT_NO_THROW({ client = iggy::ffi::new_connection(connection_string); });
+        ASSERT_NO_THROW({
+            client = connection_string.empty() ? iggy::ffi::new_connection({})
+                                               : iggy::ffi::from_connection_string(connection_string);
+        });
         ASSERT_NE(client, nullptr);
         TrackClient(client);
 
+        iggy::ffi::LoginInfo login_info{};
+        iggy::ffi::ClientInfoDetails me{};
         ASSERT_NO_THROW(client->connect());
-        ASSERT_NO_THROW(client->login_user(username, password));
+        ASSERT_NO_THROW({ login_info = client->login_user(username, password); });
+        ASSERT_NO_THROW({ me = client->get_me(); });
+
+        EXPECT_EQ(login_info.user_id, root_user_id);
+        EXPECT_TRUE(me.has_user_id);
+        EXPECT_EQ(login_info.user_id, me.user_id);
+        EXPECT_FALSE(login_info.has_access_token);
+        EXPECT_TRUE(static_cast<std::string>(login_info.access_token).empty());
+        EXPECT_EQ(login_info.access_token_expiry, 0u);
     }
 }
 
+TEST_F(LowLevelE2E_Client, HttpLoginReturnsAccessToken) {
+    RecordProperty("description", "Returns root user information and an access token after HTTP login.");
+    constexpr std::uint32_t root_user_id = 0;
+    iggy::ffi::Client *client            = nullptr;
+    ASSERT_NO_THROW({ client = iggy::ffi::from_connection_string("iggy+http://iggy:iggy@127.0.0.1:3000"); });
+    ASSERT_NE(client, nullptr);
+    TrackClient(client);
+
+    iggy::ffi::LoginInfo login_info{};
+    ASSERT_NO_THROW(client->connect());
+    ASSERT_NO_THROW({ login_info = client->login_user("iggy", "iggy"); });
+
+    EXPECT_EQ(login_info.user_id, root_user_id);
+    EXPECT_TRUE(login_info.has_access_token);
+    EXPECT_FALSE(static_cast<std::string>(login_info.access_token).empty());
+    EXPECT_NE(login_info.access_token_expiry, 0u);
+}
+
 TEST_F(LowLevelE2E_Client, NewConnectionWithMalformedConnectionStringsThrow) {
     RecordProperty("description", "Rejects malformed connection strings when creating a new client connection.");
     const std::string malformed_connection_strings[] = {
@@ -68,14 +101,14 @@ TEST_F(LowLevelE2E_Client, NewConnectionWithMalformedConnectionStringsThrow) {
 
     for (const std::string &connection_string : malformed_connection_strings) {
         SCOPED_TRACE(connection_string);
-        ASSERT_THROW({ iggy::ffi::new_connection(connection_string); }, std::exception);
+        ASSERT_THROW({ iggy::ffi::from_connection_string(connection_string); }, std::exception);
     }
 }
 
 TEST_F(LowLevelE2E_Client, LoginWithInvalidCredentialsThrows) {
     RecordProperty("description", "Throws when authentication uses invalid credentials after connecting.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
     TrackClient(client);
 
@@ -86,7 +119,7 @@ TEST_F(LowLevelE2E_Client, LoginWithInvalidCredentialsThrows) {
 TEST_F(LowLevelE2E_Client, LoginTwiceWithDifferentCredentials) {
     RecordProperty("description", "Rejects a second login attempt that switches to invalid credentials.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
     TrackClient(client);
 
@@ -99,7 +132,7 @@ TEST_F(LowLevelE2E_Client, LogoutWithoutLogin) {
     RecordProperty("description",
                    "Rejects logout before authentication, both before and after connect, then succeeds after login.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
     TrackClient(client);
 
@@ -113,21 +146,26 @@ TEST_F(LowLevelE2E_Client, LogoutWithoutLogin) {
 }
 
 TEST_F(LowLevelE2E_Client, ReloginOnSameClientAfterLogout) {
-    RecordProperty("description", "Allows reauthenticating on the same connected client after a successful logout.");
+    RecordProperty("description", "Returns the same user identity when reauthenticating after a successful logout.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
     TrackClient(client);
 
+    iggy::ffi::LoginInfo first_login{};
+    iggy::ffi::LoginInfo second_login{};
     iggy::ffi::ClientInfoDetails first_me{};
     iggy::ffi::ClientInfoDetails second_me{};
     ASSERT_NO_THROW(client->connect());
-    ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
+    ASSERT_NO_THROW({ first_login = client->login_user("iggy", "iggy"); });
     ASSERT_NO_THROW({ first_me = client->get_me(); });
     ASSERT_NO_THROW(client->logout_user());
-    ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
+    ASSERT_NO_THROW({ second_login = client->login_user("iggy", "iggy"); });
     ASSERT_NO_THROW({ second_me = client->get_me(); });
 
+    EXPECT_EQ(first_login.user_id, first_me.user_id);
+    EXPECT_EQ(second_login.user_id, second_me.user_id);
+    EXPECT_EQ(second_login.user_id, first_login.user_id);
     EXPECT_EQ(second_me.client_id, first_me.client_id);
     EXPECT_EQ(second_me.user_id, first_me.user_id);
 }
@@ -136,7 +174,7 @@ TEST_F(LowLevelE2E_Client, LogoutErrorsWhenCalledMoreThanOnce) {
     RecordProperty("description",
                    "Rejects repeated logout calls once the authenticated session has already logged out.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
     TrackClient(client);
 
@@ -146,6 +184,1445 @@ TEST_F(LowLevelE2E_Client, LogoutErrorsWhenCalledMoreThanOnce) {
     ASSERT_THROW(client->logout_user(), std::exception);
 }
 
+TEST_F(LowLevelE2E_Client, CreateUserWithUsernameOutsideLengthBoundsThrows) {
+    RecordProperty("description", "Rejects 2-byte and 51-byte usernames over TCP without creating users.");
+    iggy::ffi::Client *client = GetLoggedInClient();
+    const std::string too_short_username(2, 'a');
+    const std::string too_long_username(51, 'a');
+    const std::string usernames[] = {too_short_username, too_long_username};
+
+    ASSERT_EQ(too_short_username.size(), 2u);
+    ASSERT_EQ(too_long_username.size(), 51u);
+    for (const auto &username : usernames) {
+        SCOPED_TRACE(username.size());
+        ASSERT_THROW(
+            client->create_user(username, "secret123", iggy::ffi::UserStatus::Active, false, iggy::ffi::Permissions{}),
+            std::exception);
+        ASSERT_THROW(client->get_user(make_string_identifier(username)), std::exception);
+    }
+}
+
+TEST_F(LowLevelE2E_Client, CreateUserAcceptsNonAsciiAndNonAlphabeticUsernames) {
+    RecordProperty("description",
+                   "Creates and retrieves usernames containing punctuation, multilingual UTF-8, and emoji over TCP.");
+    iggy::ffi::Client *client     = GetLoggedInClient();
+    const std::string suffix      = GetRandomName(12);
+    const std::string usernames[] = {
+        "!@#_" + suffix, "ユーザー_" + suffix, "用户_" + suffix, "नाम_" + suffix, "사용자_" + suffix, "😀🚀_" + suffix,
+    };
+
+    for (const auto &username : usernames) {
+        SCOPED_TRACE(username);
+        ASSERT_LE(username.size(), 50u);
+
+        iggy::ffi::UserInfoDetails created_user{};
+        iggy::ffi::UserInfoDetails fetched_user{};
+        ASSERT_NO_THROW({ created_user = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
+        ASSERT_NO_THROW({ fetched_user = client->get_user(make_string_identifier(username)); });
+
+        EXPECT_EQ(fetched_user.id, created_user.id);
+        EXPECT_EQ(static_cast<std::string>(created_user.username), username);
+        EXPECT_EQ(static_cast<std::string>(fetched_user.username), username);
+    }
+}
+
+TEST_F(LowLevelE2E_Client, CreateUserBeforeLoginThrows) {
+    RecordProperty("description", "Rejects user creation without an active authenticated session.");
+    iggy::ffi::Client *client               = GetLoggedOutClient();
+    iggy::ffi::Client *root                 = GetLoggedInClient();
+    const std::string before_login_username = GetRandomName(50);
+    const std::string logged_out_username   = GetRandomName(50);
+    const std::string disconnected_username = GetRandomName(50);
+
+    ASSERT_THROW(client->create_user(before_login_username, "secret123", iggy::ffi::UserStatus::Active, false,
+                                     iggy::ffi::Permissions{}),
+                 std::exception);
+    ASSERT_NO_THROW(client->connect());
+    ASSERT_THROW(client->create_user(before_login_username, "secret123", iggy::ffi::UserStatus::Active, false,
+                                     iggy::ffi::Permissions{}),
+                 std::exception);
+
+    ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
+    ASSERT_NO_THROW(client->logout_user());
+    ASSERT_THROW(client->create_user(logged_out_username, "secret123", iggy::ffi::UserStatus::Active, false,
+                                     iggy::ffi::Permissions{}),
+                 std::exception);
+
+    ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
+    ASSERT_NO_THROW(client->disconnect());
+    ASSERT_THROW(client->create_user(disconnected_username, "secret123", iggy::ffi::UserStatus::Active, false,
+                                     iggy::ffi::Permissions{}),
+                 std::exception);
+
+    ASSERT_THROW(root->get_user(make_string_identifier(before_login_username)), std::exception);
+    ASSERT_THROW(root->get_user(make_string_identifier(logged_out_username)), std::exception);
+    ASSERT_THROW(root->get_user(make_string_identifier(disconnected_username)), std::exception);
+}
+
+TEST_F(LowLevelE2E_Client, CreateUserAcceptsUsernameAndPasswordLengthBounds) {
+    RecordProperty("description",
+                   "Creates users with shortest and longest ASCII usernames and passwords that can authenticate.");
+    iggy::ffi::Client *root_client     = GetLoggedInClient();
+    iggy::ffi::Client *shortest_client = GetLoggedOutClient();
+    iggy::ffi::Client *longest_client  = GetLoggedOutClient();
+    std::string shortest_username      = GetRandomName(3);
+    std::string longest_username       = GetRandomName(50);
+    const std::string shortest_password(3, 'a');
+    const std::string longest_password(100, 'a');
+    longest_username.resize(50, 'a');
+    ASSERT_EQ(shortest_username.size(), 3u);
+    ASSERT_EQ(longest_username.size(), 50u);
+    ASSERT_EQ(shortest_password.size(), 3u);
+    ASSERT_EQ(longest_password.size(), 100u);
+
+    iggy::ffi::UserInfoDetails shortest_user{};
+    iggy::ffi::UserInfoDetails longest_user{};
+    iggy::ffi::UserInfoDetails fetched_shortest{};
+    iggy::ffi::UserInfoDetails fetched_longest{};
+    ASSERT_NO_THROW({
+        shortest_user = CreateUser(root_client, shortest_username, shortest_password, iggy::ffi::UserStatus::Active);
+    });
+    ASSERT_NO_THROW(
+        { longest_user = CreateUser(root_client, longest_username, longest_password, iggy::ffi::UserStatus::Active); });
+    ASSERT_NO_THROW({ fetched_shortest = root_client->get_user(make_string_identifier(shortest_username)); });
+    ASSERT_NO_THROW({ fetched_longest = root_client->get_user(make_string_identifier(longest_username)); });
+    ASSERT_NO_THROW(shortest_client->connect());
+    ASSERT_NO_THROW(longest_client->connect());
+    ASSERT_NO_THROW(shortest_client->login_user(shortest_username, shortest_password));
+    ASSERT_NO_THROW(longest_client->login_user(longest_username, longest_password));
+
+    EXPECT_EQ(static_cast<std::string>(shortest_user.username), shortest_username);
+    EXPECT_EQ(static_cast<std::string>(longest_user.username), longest_username);
+    EXPECT_EQ(fetched_shortest.id, shortest_user.id);
+    EXPECT_EQ(fetched_longest.id, longest_user.id);
+}
+
+TEST_F(LowLevelE2E_Client, CreateUserWithPasswordOutsideLengthBoundsThrows) {
+    RecordProperty("description", "Rejects 2-byte and 101-byte passwords without creating users.");
+    iggy::ffi::Client *client        = GetLoggedInClient();
+    const std::string short_username = GetRandomName(50);
+    const std::string long_username  = GetRandomName(50);
+    const std::string short_password(2, 'a');
+    const std::string long_password(101, 'a');
+    ASSERT_EQ(short_password.size(), 2u);
+    ASSERT_EQ(long_password.size(), 101u);
+
+    ASSERT_THROW(client->create_user(short_username, short_password, iggy::ffi::UserStatus::Active, false,
+                                     iggy::ffi::Permissions{}),
+                 std::exception);
+    ASSERT_THROW(client->create_user(long_username, long_password, iggy::ffi::UserStatus::Active, false,
+                                     iggy::ffi::Permissions{}),
+                 std::exception);
+    ASSERT_THROW(client->get_user(make_string_identifier(short_username)), std::exception);
+    ASSERT_THROW(client->get_user(make_string_identifier(long_username)), std::exception);
+}
+
+TEST_F(LowLevelE2E_Client, CreateUserWithInvalidStatusThrows) {
+    RecordProperty("description", "Rejects invalid status codes before creating users.");
+    iggy::ffi::Client *client              = GetLoggedInClient();
+    const iggy::ffi::UserStatus statuses[] = {
+        static_cast<iggy::ffi::UserStatus>(0),
+        static_cast<iggy::ffi::UserStatus>(3),
+        static_cast<iggy::ffi::UserStatus>(std::numeric_limits<std::uint8_t>::max()),
+    };
+
+    for (const auto status : statuses) {
+        const std::string username = GetRandomName(50);
+        SCOPED_TRACE(static_cast<std::uint8_t>(status));
+        ASSERT_THROW(client->create_user(username, "secret123", status, false, iggy::ffi::Permissions{}),
+                     std::exception);
+        ASSERT_THROW(client->get_user(make_string_identifier(username)), std::exception);
+    }
+}
+
+TEST_F(LowLevelE2E_Client, CreateUserReturnsCreatedActiveUserDetails) {
+    RecordProperty("description", "Returns and persists active user details.");
+    iggy::ffi::Client *client  = GetLoggedInClient();
+    const std::string username = GetRandomName(50);
+    iggy::ffi::UserInfoDetails created_user{};
+    iggy::ffi::UserInfoDetails fetched_user{};
+    ASSERT_NO_THROW({ created_user = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
+    ASSERT_NO_THROW({ fetched_user = client->get_user(make_string_identifier(username)); });
+
+    EXPECT_EQ(fetched_user.id, created_user.id);
+    EXPECT_EQ(static_cast<std::string>(created_user.username), username);
+    EXPECT_EQ(static_cast<std::string>(fetched_user.username), username);
+    EXPECT_EQ(created_user.status, iggy::ffi::UserStatus::Active);
+    EXPECT_EQ(fetched_user.status, iggy::ffi::UserStatus::Active);
+}
+
+TEST_F(LowLevelE2E_Client, CreateUserRejectsDuplicateUsernameWithoutChangingOriginal) {
+    RecordProperty("description", "Rejects duplicate usernames without changing the existing user.");
+    iggy::ffi::Client *root_client = GetLoggedInClient();
+    iggy::ffi::Client *user_client = GetLoggedOutClient();
+    const std::string username     = GetRandomName(50);
+    const std::string password     = "original-secret";
+    iggy::ffi::UserInfoDetails original{};
+    ASSERT_NO_THROW({ original = CreateUser(root_client, username, password, iggy::ffi::UserStatus::Active); });
+
+    ASSERT_THROW(root_client->create_user(username, "replacement-secret", iggy::ffi::UserStatus::Inactive, false,
+                                          iggy::ffi::Permissions{}),
+                 std::exception);
+
+    iggy::ffi::UserInfoDetails fetched{};
+    ASSERT_NO_THROW({ fetched = root_client->get_user(make_string_identifier(username)); });
+    EXPECT_EQ(fetched.id, original.id);
+    EXPECT_EQ(fetched.status, iggy::ffi::UserStatus::Active);
+    ASSERT_NO_THROW(user_client->connect());
+    ASSERT_NO_THROW(user_client->login_user(username, password));
+    iggy::ffi::Client *replacement_client = GetLoggedOutClient();
+    ASSERT_NO_THROW(replacement_client->connect());
+    ASSERT_THROW(replacement_client->login_user(username, "replacement-secret"), std::exception);
+}
+
+TEST_F(LowLevelE2E_Client, CreateUserPreservesNestedPermissionsInCreateAndGetResponses) {
+    RecordProperty("description",
+                   "Creates a user with global and per-resource permissions, then verifies create_user and get_user "
+                   "return the same flags and numeric stream/topic IDs.");
+    iggy::ffi::Client *client  = GetLoggedInClient();
+    const std::string username = GetRandomName(50);
+    iggy::ffi::Permissions permissions{};
+    permissions.global.manage_servers = true;
+    permissions.global.read_users     = true;
+    permissions.global.manage_streams = true;
+    permissions.global.read_topics    = true;
+    permissions.global.send_messages  = true;
+
+    iggy::ffi::StreamPermissionEntry first_stream{};
+    first_stream.stream_id                 = 42;
+    first_stream.permissions.manage_stream = true;
+    first_stream.permissions.read_topics   = true;
+    first_stream.permissions.send_messages = true;
+    iggy::ffi::TopicPermissionEntry first_topic{};
+    first_topic.topic_id                  = 7;
+    first_topic.permissions.manage_topic  = true;
+    first_topic.permissions.poll_messages = true;
+    iggy::ffi::TopicPermissionEntry second_topic{};
+    second_topic.topic_id                  = 9;
+    second_topic.permissions.read_topic    = true;
+    second_topic.permissions.send_messages = true;
+    first_stream.permissions.topics.push_back(std::move(first_topic));
+    first_stream.permissions.topics.push_back(std::move(second_topic));
+
+    iggy::ffi::StreamPermissionEntry second_stream{};
+    second_stream.stream_id                 = 84;
+    second_stream.permissions.read_stream   = true;
+    second_stream.permissions.manage_topics = true;
+    second_stream.permissions.poll_messages = true;
+    iggy::ffi::TopicPermissionEntry third_topic{};
+    third_topic.topic_id               = 3;
+    third_topic.permissions.read_topic = true;
+    second_stream.permissions.topics.push_back(std::move(third_topic));
+    permissions.streams.push_back(std::move(first_stream));
+    permissions.streams.push_back(std::move(second_stream));
+
+    iggy::ffi::UserInfoDetails created{};
+    iggy::ffi::UserInfoDetails fetched{};
+    ASSERT_NO_THROW({
+        created =
+            CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active, true, std::move(permissions));
+    });
+    ASSERT_NO_THROW({ fetched = client->get_user(make_string_identifier(username)); });
+    for (const auto *user : {&created, &fetched}) {
+        EXPECT_TRUE(user->permissions.global.manage_servers);
+        EXPECT_FALSE(user->permissions.global.read_servers);
+        EXPECT_FALSE(user->permissions.global.manage_users);
+        EXPECT_TRUE(user->permissions.global.read_users);
+        EXPECT_TRUE(user->permissions.global.manage_streams);
+        EXPECT_FALSE(user->permissions.global.read_streams);
+        EXPECT_FALSE(user->permissions.global.manage_topics);
+        EXPECT_TRUE(user->permissions.global.read_topics);
+        EXPECT_FALSE(user->permissions.global.poll_messages);
+        EXPECT_TRUE(user->permissions.global.send_messages);
+        ASSERT_EQ(user->permissions.streams.size(), 2u);
+
+        const iggy::ffi::StreamPermissionEntry *stream_42 = nullptr;
+        const iggy::ffi::StreamPermissionEntry *stream_84 = nullptr;
+        for (const auto &stream : user->permissions.streams) {
+            if (stream.stream_id == 42) {
+                stream_42 = &stream;
+            }
+            if (stream.stream_id == 84) {
+                stream_84 = &stream;
+            }
+        }
+        ASSERT_NE(stream_42, nullptr);
+        ASSERT_NE(stream_84, nullptr);
+        EXPECT_TRUE(stream_42->permissions.manage_stream);
+        EXPECT_FALSE(stream_42->permissions.read_stream);
+        EXPECT_FALSE(stream_42->permissions.manage_topics);
+        EXPECT_TRUE(stream_42->permissions.read_topics);
+        EXPECT_FALSE(stream_42->permissions.poll_messages);
+        EXPECT_TRUE(stream_42->permissions.send_messages);
+        ASSERT_EQ(stream_42->permissions.topics.size(), 2u);
+        const iggy::ffi::TopicPermissionEntry *topic_7 = nullptr;
+        const iggy::ffi::TopicPermissionEntry *topic_9 = nullptr;
+        for (const auto &topic : stream_42->permissions.topics) {
+            if (topic.topic_id == 7) {
+                topic_7 = &topic;
+            }
+            if (topic.topic_id == 9) {
+                topic_9 = &topic;
+            }
+        }
+        ASSERT_NE(topic_7, nullptr);
+        ASSERT_NE(topic_9, nullptr);
+        EXPECT_TRUE(topic_7->permissions.manage_topic);
+        EXPECT_FALSE(topic_7->permissions.read_topic);
+        EXPECT_TRUE(topic_7->permissions.poll_messages);
+        EXPECT_FALSE(topic_7->permissions.send_messages);
+        EXPECT_FALSE(topic_9->permissions.manage_topic);
+        EXPECT_TRUE(topic_9->permissions.read_topic);
+        EXPECT_FALSE(topic_9->permissions.poll_messages);
+        EXPECT_TRUE(topic_9->permissions.send_messages);
+        EXPECT_FALSE(stream_84->permissions.manage_stream);
+        EXPECT_TRUE(stream_84->permissions.read_stream);
+        EXPECT_TRUE(stream_84->permissions.manage_topics);
+        EXPECT_FALSE(stream_84->permissions.read_topics);
+        EXPECT_TRUE(stream_84->permissions.poll_messages);
+        EXPECT_FALSE(stream_84->permissions.send_messages);
+        ASSERT_EQ(stream_84->permissions.topics.size(), 1u);
+        EXPECT_EQ(stream_84->permissions.topics[0].topic_id, 3u);
+        EXPECT_FALSE(stream_84->permissions.topics[0].permissions.manage_topic);
+        EXPECT_TRUE(stream_84->permissions.topics[0].permissions.read_topic);
+        EXPECT_FALSE(stream_84->permissions.topics[0].permissions.poll_messages);
+        EXPECT_FALSE(stream_84->permissions.topics[0].permissions.send_messages);
+    }
+}
+
+TEST_F(LowLevelE2E_Client, CreatedUserCanReadOnlyTopicGrantedByPermissions) {
+    RecordProperty("description",
+                   "Creates a user with read access to one topic, then verifies that topic can be fetched and a topic "
+                   "in another stream is denied.");
+    iggy::ffi::Client *root_client        = GetLoggedInClient();
+    iggy::ffi::Client *user_client        = GetLoggedOutClient();
+    const std::string allowed_stream_name = GetRandomName();
+    const std::string denied_stream_name  = GetRandomName();
+    const std::string allowed_topic_name  = GetRandomName();
+    const std::string denied_topic_name   = GetRandomName();
+    const std::string username            = GetRandomName(50);
+
+    iggy::ffi::StreamDetails allowed_stream{};
+    iggy::ffi::StreamDetails denied_stream{};
+    ASSERT_NO_THROW({ allowed_stream = root_client->create_stream(allowed_stream_name); });
+    TrackStream(allowed_stream_name);
+    ASSERT_NO_THROW({ denied_stream = root_client->create_stream(denied_stream_name); });
+    TrackStream(denied_stream_name);
+
+    iggy::ffi::TopicDetails allowed_topic{};
+    iggy::ffi::TopicDetails denied_topic{};
+    ASSERT_NO_THROW({
+        allowed_topic = root_client->create_topic(make_numeric_identifier(allowed_stream.id), allowed_topic_name, 1,
+                                                  "none", "server_default", 0, "server_default", {});
+        denied_topic  = root_client->create_topic(make_numeric_identifier(denied_stream.id), denied_topic_name, 1,
+                                                  "none", "server_default", 0, "server_default", {});
+    });
+
+    iggy::ffi::Permissions permissions{};
+    iggy::ffi::StreamPermissionEntry stream_permissions{};
+    stream_permissions.stream_id = allowed_stream.id;
+    iggy::ffi::TopicPermissionEntry topic_permissions{};
+    topic_permissions.topic_id               = allowed_topic.id;
+    topic_permissions.permissions.read_topic = true;
+    stream_permissions.permissions.topics.push_back(std::move(topic_permissions));
+    permissions.streams.push_back(std::move(stream_permissions));
+    ASSERT_NO_THROW({
+        CreateUser(root_client, username, "secret123", iggy::ffi::UserStatus::Active, true, std::move(permissions));
+    });
+    ASSERT_NO_THROW(user_client->connect());
+    ASSERT_NO_THROW(user_client->login_user(username, "secret123"));
+
+    iggy::ffi::TopicDetails fetched_topic{};
+    ASSERT_NO_THROW({
+        fetched_topic = user_client->get_topic(make_numeric_identifier(allowed_stream.id),
+                                               make_numeric_identifier(allowed_topic.id));
+    });
+    EXPECT_EQ(fetched_topic.id, allowed_topic.id);
+    EXPECT_EQ(static_cast<std::string>(fetched_topic.name), allowed_topic_name);
+    ASSERT_THROW(
+        user_client->get_topic(make_numeric_identifier(denied_stream.id), make_numeric_identifier(denied_topic.id)),
+        std::exception);
+}
+
+TEST_F(LowLevelE2E_Client, CreateUserRejectsDuplicateStreamPermissionIdsWithoutCreatingUser) {
+    RecordProperty("description",
+                   "Attempts to create a user with two permission entries for stream ID 42, then verifies creation "
+                   "fails and no user is stored.");
+    iggy::ffi::Client *client  = GetLoggedInClient();
+    const std::string username = GetRandomName(50);
+    iggy::ffi::Permissions permissions{};
+    iggy::ffi::StreamPermissionEntry first_stream{};
+    iggy::ffi::StreamPermissionEntry second_stream{};
+    first_stream.stream_id  = 42;
+    second_stream.stream_id = 42;
+    permissions.streams.push_back(std::move(first_stream));
+    permissions.streams.push_back(std::move(second_stream));
+
+    ASSERT_THROW(
+        client->create_user(username, "secret123", iggy::ffi::UserStatus::Active, true, std::move(permissions)),
+        std::exception);
+    ASSERT_THROW(client->get_user(make_string_identifier(username)), std::exception);
+}
+
+TEST_F(LowLevelE2E_Client, CreateUserRejectsDuplicateTopicPermissionIdsWithoutCreatingUser) {
+    RecordProperty("description",
+                   "Attempts to create a user with two permission entries for topic ID 7 in the same stream, then "
+                   "verifies creation fails and no user is stored.");
+    iggy::ffi::Client *client  = GetLoggedInClient();
+    const std::string username = GetRandomName(50);
+    iggy::ffi::Permissions permissions{};
+    iggy::ffi::StreamPermissionEntry stream{};
+    stream.stream_id = 42;
+    iggy::ffi::TopicPermissionEntry first_topic{};
+    iggy::ffi::TopicPermissionEntry second_topic{};
+    first_topic.topic_id  = 7;
+    second_topic.topic_id = 7;
+    stream.permissions.topics.push_back(std::move(first_topic));
+    stream.permissions.topics.push_back(std::move(second_topic));
+    permissions.streams.push_back(std::move(stream));
+
+    ASSERT_THROW(
+        client->create_user(username, "secret123", iggy::ffi::UserStatus::Active, true, std::move(permissions)),
+        std::exception);
+    ASSERT_THROW(client->get_user(make_string_identifier(username)), std::exception);
+}
+
+TEST_F(LowLevelE2E_Client, ReadUsersPermissionDoesNotAllowCreateUser) {
+    RecordProperty("description", "Rejects user creation by a user with read_users but not manage_users.");
+    iggy::ffi::Client *root_client = GetLoggedInClient();
+    iggy::ffi::Client *user_client = GetLoggedOutClient();
+    const std::string username     = GetRandomName(50);
+    const std::string target       = GetRandomName(50);
+    iggy::ffi::Permissions permissions{};
+    permissions.global.read_users = true;
+    ASSERT_NO_THROW({
+        CreateUser(root_client, username, "secret123", iggy::ffi::UserStatus::Active, true, std::move(permissions));
+    });
+    ASSERT_NO_THROW(user_client->connect());
+    ASSERT_NO_THROW(user_client->login_user(username, "secret123"));
+
+    ASSERT_THROW(
+        user_client->create_user(target, "secret123", iggy::ffi::UserStatus::Active, false, iggy::ffi::Permissions{}),
+        std::exception);
+    ASSERT_THROW(root_client->get_user(make_string_identifier(target)), std::exception);
+}
+
+TEST_F(LowLevelE2E_Client, ManageUsersPermissionAllowsGrantingAdditionalPermissions) {
+    RecordProperty("description", "Allows a user manager to grant a child a permission the manager does not have.");
+    iggy::ffi::Client *root_client     = GetLoggedInClient();
+    iggy::ffi::Client *manager_client  = GetLoggedOutClient();
+    iggy::ffi::Client *child_client    = GetLoggedOutClient();
+    const std::string manager_username = GetRandomName(50);
+    const std::string child_username   = GetRandomName(50);
+    const std::string denied_stream    = GetRandomName();
+    const std::string child_stream     = GetRandomName();
+    iggy::ffi::Permissions manager_permissions{};
+    manager_permissions.global.manage_users   = true;
+    manager_permissions.global.manage_streams = false;
+    ASSERT_NO_THROW({
+        CreateUser(root_client, manager_username, "secret123", iggy::ffi::UserStatus::Active, true,
+                   std::move(manager_permissions));
+    });
+    ASSERT_NO_THROW(manager_client->connect());
+    ASSERT_NO_THROW(manager_client->login_user(manager_username, "secret123"));
+    ASSERT_THROW(manager_client->create_stream(denied_stream), std::exception);
+
+    iggy::ffi::Permissions child_permissions{};
+    child_permissions.global.manage_streams = true;
+    iggy::ffi::UserInfoDetails child{};
+    ASSERT_NO_THROW({
+        child = CreateUser(manager_client, child_username, "child-secret", iggy::ffi::UserStatus::Active, true,
+                           std::move(child_permissions));
+    });
+    EXPECT_EQ(static_cast<std::string>(child.username), child_username);
+    EXPECT_EQ(child.status, iggy::ffi::UserStatus::Active);
+    iggy::ffi::UserInfoDetails fetched{};
+    ASSERT_NO_THROW({ fetched = root_client->get_user(make_string_identifier(child_username)); });
+    EXPECT_EQ(fetched.id, child.id);
+    EXPECT_TRUE(fetched.permissions.global.manage_streams);
+
+    ASSERT_NO_THROW(child_client->connect());
+    ASSERT_NO_THROW(child_client->login_user(child_username, "child-secret"));
+    ASSERT_NO_THROW(child_client->create_stream(child_stream));
+    TrackStream(child_stream);
+}
+
+TEST_F(LowLevelE2E_Client, CreatedActiveUserAuthenticatesOnlyWithSuppliedPassword) {
+    RecordProperty("description", "Authenticates an active user only with its supplied password.");
+    iggy::ffi::Client *root_client  = GetLoggedInClient();
+    iggy::ffi::Client *valid_client = GetLoggedOutClient();
+    iggy::ffi::Client *wrong_client = GetLoggedOutClient();
+    const std::string username      = GetRandomName(50);
+    const std::string password      = "known-secret";
+    ASSERT_NO_THROW({ CreateUser(root_client, username, password, iggy::ffi::UserStatus::Active); });
+    ASSERT_NO_THROW(valid_client->connect());
+    ASSERT_NO_THROW(wrong_client->connect());
+    ASSERT_NO_THROW(valid_client->login_user(username, password));
+    ASSERT_THROW(wrong_client->login_user(username, "other-secret"), std::exception);
+}
+
+TEST_F(LowLevelE2E_Client, CreatedInactiveUserCannotAuthenticate) {
+    RecordProperty("description", "Persists inactive users but rejects authentication for them.");
+    iggy::ffi::Client *root_client = GetLoggedInClient();
+    iggy::ffi::Client *user_client = GetLoggedOutClient();
+    const std::string username     = GetRandomName(50);
+    const std::string password     = "inactive-secret";
+    iggy::ffi::UserInfoDetails created{};
+    iggy::ffi::UserInfoDetails fetched{};
+    ASSERT_NO_THROW({ created = CreateUser(root_client, username, password, iggy::ffi::UserStatus::Inactive); });
+    ASSERT_NO_THROW({ fetched = root_client->get_user(make_string_identifier(username)); });
+    EXPECT_EQ(created.status, iggy::ffi::UserStatus::Inactive);
+    EXPECT_EQ(fetched.status, iggy::ffi::UserStatus::Inactive);
+    ASSERT_NO_THROW(user_client->connect());
+    ASSERT_THROW(user_client->login_user(username, password), std::exception);
+}
+
+TEST_F(LowLevelE2E_Client, UpdateUserRejectsUnauthenticatedClientWithoutChangingTarget) {
+    RecordProperty("description", "Rejects user updates without an active authenticated session.");
+    iggy::ffi::Client *root_client = GetLoggedInClient();
+    const std::string username     = GetRandomName(50);
+    const std::string replacement  = GetRandomName(50);
+    iggy::ffi::UserInfoDetails created{};
+    ASSERT_NO_THROW({ created = CreateUser(root_client, username, "secret123", iggy::ffi::UserStatus::Active); });
+
+    iggy::ffi::Client *client = GetLoggedOutClient();
+    ASSERT_THROW(
+        client->update_user(make_string_identifier(username), true, replacement, true, iggy::ffi::UserStatus::Inactive),
+        std::exception);
+    ASSERT_NO_THROW(client->connect());
+    ASSERT_THROW(
+        client->update_user(make_string_identifier(username), true, replacement, true, iggy::ffi::UserStatus::Inactive),
+        std::exception);
+    ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
+    ASSERT_NO_THROW(client->logout_user());
+    ASSERT_THROW(
+        client->update_user(make_string_identifier(username), true, replacement, true, iggy::ffi::UserStatus::Inactive),
+        std::exception);
+    ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
+    ASSERT_NO_THROW(client->disconnect());
+    ASSERT_THROW(
+        client->update_user(make_string_identifier(username), true, replacement, true, iggy::ffi::UserStatus::Inactive),
+        std::exception);
+
+    iggy::ffi::UserInfoDetails fetched{};
+    ASSERT_NO_THROW({ fetched = root_client->get_user(make_string_identifier(username)); });
+    EXPECT_EQ(fetched.id, created.id);
+    EXPECT_EQ(static_cast<std::string>(fetched.username), username);
+    EXPECT_EQ(fetched.status, iggy::ffi::UserStatus::Active);
+}
+
+TEST_F(LowLevelE2E_Client, UpdateUserRejectsUnknownUsernameAndNumericId) {
+    RecordProperty("description", "Rejects updates for unknown username and numeric identifiers.");
+    iggy::ffi::Client *client           = GetLoggedInClient();
+    const std::string unknown_username  = GetRandomName(50);
+    const std::string proposed_username = GetRandomName(50);
+    const auto unknown_id               = std::numeric_limits<std::uint32_t>::max();
+
+    ASSERT_THROW(client->update_user(make_string_identifier(unknown_username), true, proposed_username, true,
+                                     iggy::ffi::UserStatus::Inactive),
+                 std::exception);
+    ASSERT_THROW(client->update_user(make_numeric_identifier(unknown_id), true, GetRandomName(50), true,
+                                     iggy::ffi::UserStatus::Inactive),
+                 std::exception);
+    ASSERT_THROW(client->get_user(make_string_identifier(proposed_username)), std::exception);
+}
+
+TEST_F(LowLevelE2E_Client, UpdateUserByUsernameChangesUsernameAndStatus) {
+    RecordProperty("description", "Updates a user by username and changes both username and status.");
+    iggy::ffi::Client *client     = GetLoggedInClient();
+    const std::string username    = GetRandomName(50);
+    const std::string replacement = GetRandomName(50);
+    iggy::ffi::UserInfoDetails created{};
+    ASSERT_NO_THROW({ created = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
+    ASSERT_NO_THROW(client->update_user(make_string_identifier(username), true, replacement, true,
+                                        iggy::ffi::UserStatus::Inactive));
+    RenameTrackedUser(username, replacement);
+
+    ASSERT_THROW(client->get_user(make_string_identifier(username)), std::exception);
+    iggy::ffi::UserInfoDetails fetched{};
+    ASSERT_NO_THROW({ fetched = client->get_user(make_string_identifier(replacement)); });
+    EXPECT_EQ(fetched.id, created.id);
+    EXPECT_EQ(static_cast<std::string>(fetched.username), replacement);
+    EXPECT_EQ(fetched.status, iggy::ffi::UserStatus::Inactive);
+}
+
+TEST_F(LowLevelE2E_Client, UpdateUserByNumericIdChangesUsernameAndStatus) {
+    RecordProperty("description", "Updates a user by numeric ID and changes both username and status.");
+    iggy::ffi::Client *client     = GetLoggedInClient();
+    const std::string username    = GetRandomName(50);
+    const std::string replacement = GetRandomName(50);
+    iggy::ffi::UserInfoDetails created{};
+    ASSERT_NO_THROW({ created = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Inactive); });
+    ASSERT_NO_THROW(client->update_user(make_numeric_identifier(created.id), true, replacement, true,
+                                        iggy::ffi::UserStatus::Active));
+    RenameTrackedUser(username, replacement);
+
+    iggy::ffi::UserInfoDetails by_id{};
+    iggy::ffi::UserInfoDetails by_name{};
+    ASSERT_NO_THROW({ by_id = client->get_user(make_numeric_identifier(created.id)); });
+    ASSERT_NO_THROW({ by_name = client->get_user(make_string_identifier(replacement)); });
+    EXPECT_EQ(by_id.id, created.id);
+    EXPECT_EQ(by_name.id, created.id);
+    EXPECT_EQ(static_cast<std::string>(by_id.username), replacement);
+    EXPECT_EQ(static_cast<std::string>(by_name.username), replacement);
+    EXPECT_EQ(by_id.status, iggy::ffi::UserStatus::Active);
+    EXPECT_EQ(by_name.status, iggy::ffi::UserStatus::Active);
+}
+
+TEST_F(LowLevelE2E_Client, UpdateUserAllowsUsernameAndStatusToBeUpdatedIndependently) {
+    RecordProperty("description", "Updates either username or status without changing the other field.");
+    iggy::ffi::Client *client     = GetLoggedInClient();
+    const std::string username    = GetRandomName(50);
+    const std::string replacement = GetRandomName(50);
+    iggy::ffi::UserInfoDetails created{};
+    ASSERT_NO_THROW({ created = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
+
+    ASSERT_NO_THROW(
+        client->update_user(make_numeric_identifier(created.id), false, "", true, iggy::ffi::UserStatus::Inactive));
+    iggy::ffi::UserInfoDetails status_updated{};
+    ASSERT_NO_THROW({ status_updated = client->get_user(make_numeric_identifier(created.id)); });
+    EXPECT_EQ(static_cast<std::string>(status_updated.username), username);
+    EXPECT_EQ(status_updated.status, iggy::ffi::UserStatus::Inactive);
+
+    ASSERT_NO_THROW(client->update_user(make_numeric_identifier(created.id), true, replacement, false,
+                                        iggy::ffi::UserStatus::Active));
+    RenameTrackedUser(username, replacement);
+
+    iggy::ffi::UserInfoDetails username_updated{};
+    ASSERT_NO_THROW({ username_updated = client->get_user(make_numeric_identifier(created.id)); });
+    EXPECT_EQ(static_cast<std::string>(username_updated.username), replacement);
+    EXPECT_EQ(username_updated.status, iggy::ffi::UserStatus::Inactive);
+}
+
+TEST_F(LowLevelE2E_Client, UpdateUserAcceptsUsernameLengthBounds) {
+    RecordProperty("description", "Accepts exact three-byte and fifty-byte username boundaries.");
+    iggy::ffi::Client *client           = GetLoggedInClient();
+    const std::string first_username    = GetRandomName(50);
+    const std::string second_username   = GetRandomName(50);
+    const std::string first_replacement = GetRandomName(3);
+    std::string second_replacement      = GetRandomName(50);
+    second_replacement.resize(50, 'a');
+    ASSERT_EQ(second_replacement.size(), 50u);
+    iggy::ffi::UserInfoDetails first{};
+    iggy::ffi::UserInfoDetails second{};
+    ASSERT_NO_THROW({ first = CreateUser(client, first_username, "secret123", iggy::ffi::UserStatus::Active); });
+    ASSERT_NO_THROW({ second = CreateUser(client, second_username, "secret123", iggy::ffi::UserStatus::Active); });
+    ASSERT_NO_THROW(client->update_user(make_string_identifier(first_username), true, first_replacement, true,
+                                        iggy::ffi::UserStatus::Active));
+    RenameTrackedUser(first_username, first_replacement);
+    ASSERT_NO_THROW(client->update_user(make_string_identifier(second_username), true, second_replacement, true,
+                                        iggy::ffi::UserStatus::Active));
+    RenameTrackedUser(second_username, second_replacement);
+
+    iggy::ffi::UserInfoDetails fetched_first{};
+    iggy::ffi::UserInfoDetails fetched_second{};
+    ASSERT_NO_THROW({ fetched_first = client->get_user(make_string_identifier(first_replacement)); });
+    ASSERT_NO_THROW({ fetched_second = client->get_user(make_string_identifier(second_replacement)); });
+    EXPECT_EQ(fetched_first.id, first.id);
+    EXPECT_EQ(fetched_second.id, second.id);
+    EXPECT_EQ(static_cast<std::string>(fetched_first.username), first_replacement);
+    EXPECT_EQ(static_cast<std::string>(fetched_second.username), second_replacement);
+}
+
+TEST_F(LowLevelE2E_Client, UpdateUserRejectsUsernameOutsideLengthBounds) {
+    RecordProperty("description", "Rejects username sizes outside the SDK and server limits.");
+    iggy::ffi::Client *client = GetLoggedInClient();
+    const std::string source  = GetRandomName(50);
+    iggy::ffi::UserInfoDetails created{};
+    ASSERT_NO_THROW({ created = CreateUser(client, source, "secret123", iggy::ffi::UserStatus::Active); });
+    const std::string invalid_usernames[] = {
+        "", "a", "aa", std::string(51, 'c'), std::string(255, 'd'), std::string(256, 'e')};
+
+    for (const auto &replacement : invalid_usernames) {
+        ASSERT_THROW(
+            client->update_user(make_string_identifier(source), true, replacement, true, iggy::ffi::UserStatus::Active),
+            std::exception);
+    }
+    iggy::ffi::UserInfoDetails fetched{};
+    ASSERT_NO_THROW({ fetched = client->get_user(make_string_identifier(source)); });
+    EXPECT_EQ(fetched.id, created.id);
+    EXPECT_EQ(fetched.status, iggy::ffi::UserStatus::Active);
+    EXPECT_EQ(static_cast<std::string>(fetched.username), source);
+}
+
+TEST_F(LowLevelE2E_Client, UpdateUserAcceptsNonAsciiAndNonAlphabeticUsername) {
+    RecordProperty("description", "Accepts representative non-ASCII and non-alphabetic usernames.");
+    iggy::ffi::Client *client        = GetLoggedInClient();
+    const std::string suffix         = GetRandomName(8);
+    const std::string sources[]      = {GetRandomName(50), GetRandomName(50), GetRandomName(50)};
+    const std::string replacements[] = {"!@#_" + suffix, "ユーザー_" + suffix, "😀🚀_" + suffix};
+    ASSERT_LE(replacements[0].size(), 50u);
+    ASSERT_LE(replacements[1].size(), 50u);
+    ASSERT_LE(replacements[2].size(), 50u);
+    for (std::size_t index = 0; index < 3; ++index) {
+        iggy::ffi::UserInfoDetails created{};
+        ASSERT_NO_THROW({ created = CreateUser(client, sources[index], "secret123", iggy::ffi::UserStatus::Active); });
+        ASSERT_NO_THROW(client->update_user(make_string_identifier(sources[index]), true, replacements[index], true,
+                                            iggy::ffi::UserStatus::Active));
+        RenameTrackedUser(sources[index], replacements[index]);
+        iggy::ffi::UserInfoDetails fetched{};
+        ASSERT_NO_THROW({ fetched = client->get_user(make_string_identifier(replacements[index])); });
+        EXPECT_EQ(fetched.id, created.id);
+        EXPECT_EQ(static_cast<std::string>(fetched.username), replacements[index]);
+    }
+}
+
+TEST_F(LowLevelE2E_Client, UpdateUserRejectsInvalidStatusWithoutRenamingTarget) {
+    RecordProperty("description", "Rejects invalid status codes atomically with username changes.");
+    iggy::ffi::Client *client  = GetLoggedInClient();
+    const std::string username = GetRandomName(50);
+    iggy::ffi::UserInfoDetails created{};
+    ASSERT_NO_THROW({ created = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
+    const iggy::ffi::UserStatus statuses[] = {
+        static_cast<iggy::ffi::UserStatus>(0),
+        static_cast<iggy::ffi::UserStatus>(3),
+        static_cast<iggy::ffi::UserStatus>(std::numeric_limits<std::uint8_t>::max()),
+    };
+    for (const auto status : statuses) {
+        const std::string replacement = GetRandomName(50);
+        ASSERT_THROW(client->update_user(make_string_identifier(username), true, replacement, true, status),
+                     std::exception);
+        ASSERT_THROW(client->get_user(make_string_identifier(replacement)), std::exception);
+    }
+    iggy::ffi::UserInfoDetails fetched{};
+    ASSERT_NO_THROW({ fetched = client->get_user(make_string_identifier(username)); });
+    EXPECT_EQ(fetched.id, created.id);
+    EXPECT_EQ(fetched.status, iggy::ffi::UserStatus::Active);
+    EXPECT_EQ(static_cast<std::string>(fetched.username), username);
+}
+
+TEST_F(LowLevelE2E_Client, UpdateUserRejectsDuplicateUsernameWithoutChangingStatus) {
+    RecordProperty("description", "Rejects duplicate usernames without partially applying the status update.");
+    iggy::ffi::Client *client           = GetLoggedInClient();
+    const std::string target_username   = GetRandomName(50);
+    const std::string conflict_username = GetRandomName(50);
+    iggy::ffi::UserInfoDetails target{};
+    iggy::ffi::UserInfoDetails conflict{};
+    ASSERT_NO_THROW({ target = CreateUser(client, target_username, "secret123", iggy::ffi::UserStatus::Active); });
+    ASSERT_NO_THROW(
+        { conflict = CreateUser(client, conflict_username, "secret123", iggy::ffi::UserStatus::Inactive); });
+    ASSERT_THROW(client->update_user(make_string_identifier(target_username), true, conflict_username, true,
+                                     iggy::ffi::UserStatus::Inactive),
+                 std::exception);
+    iggy::ffi::UserInfoDetails fetched_target{};
+    iggy::ffi::UserInfoDetails fetched_conflict{};
+    ASSERT_NO_THROW({ fetched_target = client->get_user(make_string_identifier(target_username)); });
+    ASSERT_NO_THROW({ fetched_conflict = client->get_user(make_string_identifier(conflict_username)); });
+    EXPECT_EQ(fetched_target.id, target.id);
+    EXPECT_EQ(fetched_conflict.id, conflict.id);
+    EXPECT_EQ(fetched_target.status, iggy::ffi::UserStatus::Active);
+    EXPECT_EQ(fetched_conflict.status, iggy::ffi::UserStatus::Inactive);
+}
+
+TEST_F(LowLevelE2E_Client, UpdateUserAllowsCurrentUsernameWhileChangingStatus) {
+    RecordProperty("description", "Allows a same-username update that changes status.");
+    iggy::ffi::Client *client  = GetLoggedInClient();
+    const std::string username = GetRandomName(50);
+    iggy::ffi::UserInfoDetails created{};
+    ASSERT_NO_THROW({ created = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
+    ASSERT_NO_THROW(
+        client->update_user(make_string_identifier(username), true, username, true, iggy::ffi::UserStatus::Inactive));
+    iggy::ffi::UserInfoDetails fetched{};
+    ASSERT_NO_THROW({ fetched = client->get_user(make_string_identifier(username)); });
+    EXPECT_EQ(fetched.id, created.id);
+    EXPECT_EQ(fetched.status, iggy::ffi::UserStatus::Inactive);
+    EXPECT_EQ(static_cast<std::string>(fetched.username), username);
+}
+
+TEST_F(LowLevelE2E_Client, UpdateUserPreservesPasswordPermissionsAndCreationData) {
+    RecordProperty("description", "Preserves password, permissions, ID, and creation timestamp after a rename.");
+    iggy::ffi::Client *root_client  = GetLoggedInClient();
+    iggy::ffi::Client *valid_client = GetLoggedOutClient();
+    iggy::ffi::Client *old_client   = GetLoggedOutClient();
+    const std::string username      = GetRandomName(50);
+    const std::string replacement   = GetRandomName(50);
+    iggy::ffi::Permissions permissions{};
+    permissions.global.read_users    = true;
+    permissions.global.read_streams  = true;
+    permissions.global.send_messages = true;
+    iggy::ffi::UserInfoDetails created{};
+    ASSERT_NO_THROW({
+        created = CreateUser(root_client, username, "known-secret", iggy::ffi::UserStatus::Active, true,
+                             std::move(permissions));
+    });
+    ASSERT_NO_THROW(root_client->update_user(make_string_identifier(username), true, replacement, true,
+                                             iggy::ffi::UserStatus::Active));
+    RenameTrackedUser(username, replacement);
+    iggy::ffi::UserInfoDetails fetched{};
+    ASSERT_NO_THROW({ fetched = root_client->get_user(make_string_identifier(replacement)); });
+    EXPECT_EQ(fetched.id, created.id);
+    EXPECT_EQ(fetched.created_at, created.created_at);
+    EXPECT_TRUE(fetched.has_permissions);
+    EXPECT_TRUE(fetched.permissions.global.read_users);
+    EXPECT_TRUE(fetched.permissions.global.read_streams);
+    EXPECT_TRUE(fetched.permissions.global.send_messages);
+    ASSERT_NO_THROW(valid_client->connect());
+    ASSERT_NO_THROW(valid_client->login_user(replacement, "known-secret"));
+    ASSERT_NO_THROW(old_client->connect());
+    ASSERT_THROW(old_client->login_user(username, "known-secret"), std::exception);
+}
+
+TEST_F(LowLevelE2E_Client, UpdateUserToInactiveBlocksFreshLoginUntilReactivated) {
+    RecordProperty("description", "Blocks fresh login while inactive and restores it when active.");
+    iggy::ffi::Client *root_client = GetLoggedInClient();
+    iggy::ffi::Client *user_client = GetLoggedOutClient();
+    const std::string username     = GetRandomName(50);
+    ASSERT_NO_THROW({ CreateUser(root_client, username, "known-secret", iggy::ffi::UserStatus::Active); });
+    ASSERT_NO_THROW(root_client->update_user(make_string_identifier(username), true, username, true,
+                                             iggy::ffi::UserStatus::Inactive));
+    ASSERT_NO_THROW(user_client->connect());
+    ASSERT_THROW(user_client->login_user(username, "known-secret"), std::exception);
+    ASSERT_NO_THROW(root_client->update_user(make_string_identifier(username), true, username, true,
+                                             iggy::ffi::UserStatus::Active));
+    ASSERT_NO_THROW(user_client->login_user(username, "known-secret"));
+}
+
+TEST_F(LowLevelE2E_Client, UpdateUserRenameMakesOldUsernameReusable) {
+    RecordProperty("description", "Releases the old username for a new user after a successful rename.");
+    iggy::ffi::Client *client      = GetLoggedInClient();
+    const std::string old_username = GetRandomName(50);
+    const std::string new_username = GetRandomName(50);
+    iggy::ffi::UserInfoDetails first{};
+    ASSERT_NO_THROW({ first = CreateUser(client, old_username, "secret123", iggy::ffi::UserStatus::Active); });
+    ASSERT_NO_THROW(client->update_user(make_string_identifier(old_username), true, new_username, true,
+                                        iggy::ffi::UserStatus::Active));
+    RenameTrackedUser(old_username, new_username);
+    iggy::ffi::UserInfoDetails second{};
+    ASSERT_NO_THROW({ second = CreateUser(client, old_username, "secret123", iggy::ffi::UserStatus::Active); });
+    EXPECT_EQ(static_cast<std::string>(client->get_user(make_string_identifier(new_username)).username), new_username);
+    EXPECT_EQ(static_cast<std::string>(client->get_user(make_string_identifier(old_username)).username), old_username);
+}
+
+TEST_F(LowLevelE2E_Client, UpdateUserRejectsRenameToRootUsername) {
+    RecordProperty("description", "Rejects changing a non-root user's username to root's username.");
+    iggy::ffi::Client *client  = GetLoggedInClient();
+    const std::string username = GetRandomName(50);
+    iggy::ffi::UserInfoDetails target{};
+    ASSERT_NO_THROW({ target = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
+    ASSERT_THROW(
+        client->update_user(make_string_identifier(username), true, "iggy", true, iggy::ffi::UserStatus::Inactive),
+        std::exception);
+    iggy::ffi::UserInfoDetails root{};
+    iggy::ffi::UserInfoDetails fetched{};
+    ASSERT_NO_THROW({ root = client->get_user(make_string_identifier("iggy")); });
+    ASSERT_NO_THROW({ fetched = client->get_user(make_string_identifier(username)); });
+    EXPECT_EQ(root.id, 0u);
+    EXPECT_EQ(static_cast<std::string>(root.username), "iggy");
+    EXPECT_EQ(root.status, iggy::ffi::UserStatus::Active);
+    EXPECT_EQ(fetched.id, target.id);
+    EXPECT_EQ(fetched.status, iggy::ffi::UserStatus::Active);
+    EXPECT_EQ(static_cast<std::string>(fetched.username), username);
+}
+
+TEST_F(LowLevelE2E_Client, ReadUsersPermissionDoesNotAllowUpdateUser) {
+    RecordProperty("description", "Rejects updates from a user with read_users but not manage_users.");
+    iggy::ffi::Client *root_client    = GetLoggedInClient();
+    iggy::ffi::Client *caller_client  = GetLoggedOutClient();
+    const std::string caller_username = GetRandomName(50);
+    const std::string target_username = GetRandomName(50);
+    iggy::ffi::Permissions permissions{};
+    permissions.global.read_users = true;
+    ASSERT_NO_THROW({
+        CreateUser(root_client, caller_username, "secret123", iggy::ffi::UserStatus::Active, true,
+                   std::move(permissions));
+    });
+    iggy::ffi::UserInfoDetails target{};
+    ASSERT_NO_THROW({ target = CreateUser(root_client, target_username, "secret123", iggy::ffi::UserStatus::Active); });
+    ASSERT_NO_THROW(caller_client->connect());
+    ASSERT_NO_THROW(caller_client->login_user(caller_username, "secret123"));
+    ASSERT_THROW(caller_client->update_user(make_string_identifier(target_username), true, GetRandomName(50), true,
+                                            iggy::ffi::UserStatus::Inactive),
+                 std::exception);
+    ASSERT_THROW(caller_client->update_user(make_string_identifier(caller_username), true, GetRandomName(50), true,
+                                            iggy::ffi::UserStatus::Inactive),
+                 std::exception);
+    iggy::ffi::UserInfoDetails fetched{};
+    ASSERT_NO_THROW({ fetched = root_client->get_user(make_string_identifier(target_username)); });
+    EXPECT_EQ(fetched.id, target.id);
+    EXPECT_EQ(fetched.status, iggy::ffi::UserStatus::Active);
+}
+
+TEST_F(LowLevelE2E_Client, ManageUsersPermissionAllowsUpdateWithoutReadUsers) {
+    RecordProperty("description", "Allows updates from a manager without read_users permission.");
+    iggy::ffi::Client *root_client     = GetLoggedInClient();
+    iggy::ffi::Client *manager_client  = GetLoggedOutClient();
+    const std::string manager_username = GetRandomName(50);
+    const std::string target_username  = GetRandomName(50);
+    const std::string replacement      = GetRandomName(50);
+    iggy::ffi::Permissions permissions{};
+    permissions.global.manage_users = true;
+    ASSERT_NO_THROW({
+        CreateUser(root_client, manager_username, "secret123", iggy::ffi::UserStatus::Active, true,
+                   std::move(permissions));
+    });
+    iggy::ffi::UserInfoDetails target{};
+    ASSERT_NO_THROW({ target = CreateUser(root_client, target_username, "secret123", iggy::ffi::UserStatus::Active); });
+    ASSERT_NO_THROW(manager_client->connect());
+    ASSERT_NO_THROW(manager_client->login_user(manager_username, "secret123"));
+    ASSERT_NO_THROW(manager_client->update_user(make_string_identifier(target_username), true, replacement, true,
+                                                iggy::ffi::UserStatus::Inactive));
+    RenameTrackedUser(target_username, replacement);
+    iggy::ffi::UserInfoDetails fetched{};
+    ASSERT_NO_THROW({ fetched = root_client->get_user(make_string_identifier(replacement)); });
+    EXPECT_EQ(fetched.id, target.id);
+    EXPECT_EQ(fetched.status, iggy::ffi::UserStatus::Inactive);
+    EXPECT_EQ(static_cast<std::string>(fetched.username), replacement);
+}
+
+TEST_F(LowLevelE2E_Client, GetUserBeforeLoginThrows) {
+    RecordProperty("description", "Rejects user lookup without an active authenticated session.");
+    iggy::ffi::Client *client = GetLoggedOutClient();
+
+    ASSERT_THROW(client->get_user(make_string_identifier("iggy")), std::exception);
+    ASSERT_NO_THROW(client->connect());
+    ASSERT_THROW(client->get_user(make_string_identifier("iggy")), std::exception);
+    ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
+    ASSERT_NO_THROW(client->logout_user());
+    ASSERT_THROW(client->get_user(make_string_identifier("iggy")), std::exception);
+    ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
+    ASSERT_NO_THROW(client->disconnect());
+    ASSERT_THROW(client->get_user(make_string_identifier("iggy")), std::exception);
+}
+
+TEST_F(LowLevelE2E_Client, GetUserByUsernameReturnsRootDetails) {
+    RecordProperty("description", "Returns deterministic root details for a username lookup.");
+    iggy::ffi::Client *client = GetLoggedInClient();
+
+    iggy::ffi::UserInfoDetails user{};
+    ASSERT_NO_THROW({ user = client->get_user(make_string_identifier("iggy")); });
+
+    EXPECT_EQ(user.id, 0u);
+    EXPECT_EQ(static_cast<std::string>(user.username), "iggy");
+    EXPECT_EQ(user.status, iggy::ffi::UserStatus::Active);
+}
+
+TEST_F(LowLevelE2E_Client, GetUserByNumericIdMatchesUsernameLookup) {
+    RecordProperty("description", "Returns equivalent root details for username and numeric identifiers.");
+    iggy::ffi::Client *client = GetLoggedInClient();
+
+    iggy::ffi::UserInfoDetails by_username{};
+    iggy::ffi::UserInfoDetails by_id{};
+    ASSERT_NO_THROW({ by_username = client->get_user(make_string_identifier("iggy")); });
+    ASSERT_NO_THROW({ by_id = client->get_user(make_numeric_identifier(0)); });
+
+    EXPECT_EQ(by_id.id, by_username.id);
+    EXPECT_EQ(by_id.status, by_username.status);
+    EXPECT_EQ(static_cast<std::string>(by_id.username), static_cast<std::string>(by_username.username));
+    EXPECT_EQ(by_id.permissions.global.manage_servers, by_username.permissions.global.manage_servers);
+    EXPECT_EQ(by_id.permissions.global.read_servers, by_username.permissions.global.read_servers);
+    EXPECT_EQ(by_id.permissions.global.manage_users, by_username.permissions.global.manage_users);
+    EXPECT_EQ(by_id.permissions.global.read_users, by_username.permissions.global.read_users);
+    EXPECT_EQ(by_id.permissions.global.manage_streams, by_username.permissions.global.manage_streams);
+    EXPECT_EQ(by_id.permissions.global.read_streams, by_username.permissions.global.read_streams);
+    EXPECT_EQ(by_id.permissions.global.manage_topics, by_username.permissions.global.manage_topics);
+    EXPECT_EQ(by_id.permissions.global.read_topics, by_username.permissions.global.read_topics);
+    EXPECT_EQ(by_id.permissions.global.poll_messages, by_username.permissions.global.poll_messages);
+    EXPECT_EQ(by_id.permissions.global.send_messages, by_username.permissions.global.send_messages);
+    EXPECT_EQ(by_id.permissions.streams.size(), by_username.permissions.streams.size());
+}
+
+TEST_F(LowLevelE2E_Client, GetUserWithUnknownIdentifierThrows) {
+    RecordProperty("description", "Rejects lookups for unknown username and numeric identifiers.");
+    iggy::ffi::Client *client      = GetLoggedInClient();
+    const std::string unknown_user = GetRandomName(50);
+
+    ASSERT_THROW(client->get_user(make_string_identifier(unknown_user)), std::exception);
+    ASSERT_THROW(client->get_user(make_numeric_identifier(std::numeric_limits<std::uint32_t>::max())), std::exception);
+}
+
+TEST_F(LowLevelE2E_Client, GetUsersBeforeLoginThrows) {
+    RecordProperty("description", "Rejects listing users without an active authenticated session.");
+    iggy::ffi::Client *client = GetLoggedOutClient();
+
+    ASSERT_THROW(client->get_users(), std::exception);
+    ASSERT_NO_THROW(client->connect());
+    ASSERT_THROW(client->get_users(), std::exception);
+    ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
+    ASSERT_NO_THROW(client->logout_user());
+    ASSERT_THROW(client->get_users(), std::exception);
+    ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
+    ASSERT_NO_THROW(client->disconnect());
+    ASSERT_THROW(client->get_users(), std::exception);
+}
+
+TEST_F(LowLevelE2E_Client, GetUserAllowsSelfLookupWithoutReadUsersPermission) {
+    RecordProperty("description", "Allows a user without read_users permission to read its own details.");
+    iggy::ffi::Client *root_client = GetLoggedInClient();
+    const std::string username     = GetRandomName(50);
+    iggy::ffi::Permissions permissions{};
+    permissions.global.read_users = false;
+    iggy::ffi::UserInfoDetails created_user{};
+    ASSERT_NO_THROW({
+        created_user =
+            CreateUser(root_client, username, "secret123", iggy::ffi::UserStatus::Active, true, std::move(permissions));
+    });
+
+    iggy::ffi::Client *user_client = GetLoggedOutClient();
+    ASSERT_NO_THROW(user_client->connect());
+    ASSERT_NO_THROW(user_client->login_user(username, "secret123"));
+
+    iggy::ffi::UserInfoDetails by_username{};
+    iggy::ffi::UserInfoDetails by_id{};
+    ASSERT_NO_THROW({ by_username = user_client->get_user(make_string_identifier(username)); });
+    ASSERT_NO_THROW({ by_id = user_client->get_user(make_numeric_identifier(created_user.id)); });
+
+    EXPECT_EQ(by_username.id, created_user.id);
+    EXPECT_EQ(by_id.id, created_user.id);
+    EXPECT_EQ(static_cast<std::string>(by_username.username), username);
+    EXPECT_EQ(static_cast<std::string>(by_id.username), username);
+    EXPECT_EQ(by_username.status, iggy::ffi::UserStatus::Active);
+    EXPECT_EQ(by_id.status, iggy::ffi::UserStatus::Active);
+    EXPECT_FALSE(by_username.permissions.global.read_users);
+    EXPECT_FALSE(by_id.permissions.global.read_users);
+}
+
+TEST_F(LowLevelE2E_Client, UserWithoutReadUsersPermissionCannotQueryOtherUsers) {
+    RecordProperty("description", "Rejects other-user lookup and listing without read_users permission.");
+    iggy::ffi::Client *root_client = GetLoggedInClient();
+    const std::string username     = GetRandomName(50);
+    iggy::ffi::Permissions permissions{};
+    permissions.global.read_users = false;
+    ASSERT_NO_THROW({
+        CreateUser(root_client, username, "secret123", iggy::ffi::UserStatus::Active, true, std::move(permissions));
+    });
+
+    iggy::ffi::Client *user_client = GetLoggedOutClient();
+    ASSERT_NO_THROW(user_client->connect());
+    ASSERT_NO_THROW(user_client->login_user(username, "secret123"));
+
+    ASSERT_THROW(user_client->get_user(make_numeric_identifier(0)), std::exception);
+    ASSERT_THROW(user_client->get_users(), std::exception);
+}
+
+TEST_F(LowLevelE2E_Client, ReadUsersPermissionAllowsUserQueries) {
+    RecordProperty("description", "Allows user lookup and listing with only read_users permission.");
+    iggy::ffi::Client *root_client = GetLoggedInClient();
+    const std::string username     = GetRandomName(50);
+    iggy::ffi::Permissions permissions{};
+    permissions.global.read_users = true;
+    ASSERT_NO_THROW({
+        CreateUser(root_client, username, "secret123", iggy::ffi::UserStatus::Active, true, std::move(permissions));
+    });
+
+    iggy::ffi::Client *user_client = GetLoggedOutClient();
+    ASSERT_NO_THROW(user_client->connect());
+    ASSERT_NO_THROW(user_client->login_user(username, "secret123"));
+
+    iggy::ffi::UserInfoDetails root{};
+    rust::Vec<iggy::ffi::UserInfo> users;
+    ASSERT_NO_THROW({ root = user_client->get_user(make_numeric_identifier(0)); });
+    ASSERT_NO_THROW({ users = user_client->get_users(); });
+
+    EXPECT_EQ(root.id, 0u);
+    EXPECT_EQ(static_cast<std::string>(root.username), "iggy");
+    bool found_self = false;
+    for (const auto &user : users) {
+        if (static_cast<std::string>(user.username) == username) {
+            found_self = true;
+        }
+    }
+    EXPECT_TRUE(found_self);
+}
+
+TEST_F(LowLevelE2E_Client, ManageUsersPermissionImpliesReadUsers) {
+    RecordProperty("description", "Allows user lookup and listing when manage_users is set without read_users.");
+    iggy::ffi::Client *root_client = GetLoggedInClient();
+    const std::string username     = GetRandomName(50);
+    iggy::ffi::Permissions permissions{};
+    permissions.global.manage_users = true;
+    permissions.global.read_users   = false;
+    ASSERT_NO_THROW({
+        CreateUser(root_client, username, "secret123", iggy::ffi::UserStatus::Active, true, std::move(permissions));
+    });
+
+    iggy::ffi::Client *user_client = GetLoggedOutClient();
+    ASSERT_NO_THROW(user_client->connect());
+    ASSERT_NO_THROW(user_client->login_user(username, "secret123"));
+
+    iggy::ffi::UserInfoDetails root{};
+    rust::Vec<iggy::ffi::UserInfo> users;
+    ASSERT_NO_THROW({ root = user_client->get_user(make_numeric_identifier(0)); });
+    ASSERT_NO_THROW({ users = user_client->get_users(); });
+
+    EXPECT_EQ(root.id, 0u);
+    EXPECT_EQ(static_cast<std::string>(root.username), "iggy");
+    bool found_self = false;
+    for (const auto &user : users) {
+        if (static_cast<std::string>(user.username) == username) {
+            found_self = true;
+        }
+    }
+    EXPECT_TRUE(found_self);
+}
+
+TEST_F(LowLevelE2E_Client, GetUsersContainsCreatedUser) {
+    RecordProperty("description", "Lists a created user with the input username and status.");
+    iggy::ffi::Client *client  = GetLoggedInClient();
+    const std::string username = GetRandomName(50);
+    iggy::ffi::UserInfoDetails created_user{};
+    ASSERT_NO_THROW({ created_user = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
+
+    rust::Vec<iggy::ffi::UserInfo> users;
+    ASSERT_NO_THROW({ users = client->get_users(); });
+
+    bool found_user = false;
+    for (const auto &user : users) {
+        if (user.id == created_user.id) {
+            found_user = true;
+            EXPECT_EQ(static_cast<std::string>(user.username), username);
+            EXPECT_EQ(user.status, iggy::ffi::UserStatus::Active);
+        }
+    }
+    EXPECT_TRUE(found_user);
+}
+
+TEST_F(LowLevelE2E_Client, GetUsersReturnsAllCreatedUsersOrderedById) {
+    RecordProperty("description", "Returns every created user without pagination, ordered by ID over TCP.");
+    iggy::ffi::Client *client = GetLoggedInClient();
+    rust::Vec<iggy::ffi::UserInfo> users_before;
+    ASSERT_NO_THROW({ users_before = client->get_users(); });
+
+    const std::string first_username  = GetRandomName(50);
+    const std::string second_username = GetRandomName(50);
+    const std::string third_username  = GetRandomName(50);
+    iggy::ffi::UserInfoDetails first_user{};
+    iggy::ffi::UserInfoDetails second_user{};
+    iggy::ffi::UserInfoDetails third_user{};
+    ASSERT_NO_THROW({ first_user = CreateUser(client, first_username, "secret123", iggy::ffi::UserStatus::Active); });
+    ASSERT_NO_THROW({ second_user = CreateUser(client, second_username, "secret123", iggy::ffi::UserStatus::Active); });
+    ASSERT_NO_THROW({ third_user = CreateUser(client, third_username, "secret123", iggy::ffi::UserStatus::Active); });
+
+    rust::Vec<iggy::ffi::UserInfo> users_after;
+    ASSERT_NO_THROW({ users_after = client->get_users(); });
+    ASSERT_EQ(users_after.size(), users_before.size() + 3);
+
+    bool found_first  = false;
+    bool found_second = false;
+    bool found_third  = false;
+    for (std::size_t index = 0; index < users_after.size(); ++index) {
+        if (index > 0) {
+            EXPECT_LT(users_after[index - 1].id, users_after[index].id);
+        }
+        const auto &user           = users_after[index];
+        const std::string username = static_cast<std::string>(user.username);
+        if (user.id == first_user.id && username == first_username) {
+            found_first = true;
+        }
+        if (user.id == second_user.id && username == second_username) {
+            found_second = true;
+        }
+        if (user.id == third_user.id && username == third_username) {
+            found_third = true;
+        }
+    }
+    EXPECT_TRUE(found_first);
+    EXPECT_TRUE(found_second);
+    EXPECT_TRUE(found_third);
+}
+
+TEST_F(LowLevelE2E_Client, GetUsersMatchesGetUserDetails) {
+    RecordProperty("description", "Returns consistent ID, username, and status from user query APIs.");
+    iggy::ffi::Client *client  = GetLoggedInClient();
+    const std::string username = GetRandomName(50);
+    iggy::ffi::UserInfoDetails created_user{};
+    ASSERT_NO_THROW({ created_user = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
+
+    iggy::ffi::UserInfoDetails user_details{};
+    rust::Vec<iggy::ffi::UserInfo> users;
+    ASSERT_NO_THROW({ user_details = client->get_user(make_string_identifier(username)); });
+    ASSERT_NO_THROW({ users = client->get_users(); });
+
+    EXPECT_EQ(user_details.id, created_user.id);
+    EXPECT_EQ(static_cast<std::string>(user_details.username), username);
+    EXPECT_EQ(user_details.status, iggy::ffi::UserStatus::Active);
+    bool found_user = false;
+    for (const auto &user : users) {
+        if (user.id == user_details.id) {
+            found_user = true;
+            EXPECT_EQ(static_cast<std::string>(user.username), username);
+            EXPECT_EQ(user.status, iggy::ffi::UserStatus::Active);
+        }
+    }
+    EXPECT_TRUE(found_user);
+}
+
+TEST_F(LowLevelE2E_Client, DeletedUserDisappearsFromGetUserAndGetUsers) {
+    RecordProperty("description", "Removes a deleted user from detail and list queries.");
+    iggy::ffi::Client *client  = GetLoggedInClient();
+    const std::string username = GetRandomName(50);
+    iggy::ffi::UserInfoDetails created_user{};
+    ASSERT_NO_THROW({ created_user = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
+    ASSERT_NO_THROW(client->delete_user(make_string_identifier(username)));
+    ForgetUser(username);
+
+    ASSERT_THROW(client->get_user(make_string_identifier(username)), std::exception);
+    rust::Vec<iggy::ffi::UserInfo> users;
+    ASSERT_NO_THROW({ users = client->get_users(); });
+
+    for (const auto &user : users) {
+        EXPECT_FALSE(user.id == created_user.id && static_cast<std::string>(user.username) == username);
+    }
+}
+
+TEST_F(LowLevelE2E_Client, DeleteUserRejectsUnauthenticatedClientWithoutDeletingTarget) {
+    RecordProperty("description", "Rejects user deletion without an active authenticated session.");
+    iggy::ffi::Client *root_client = GetLoggedInClient();
+    iggy::ffi::Client *client      = GetLoggedOutClient();
+    const std::string username     = GetRandomName(50);
+    iggy::ffi::UserInfoDetails created_user{};
+    ASSERT_NO_THROW({ created_user = CreateUser(root_client, username, "secret123", iggy::ffi::UserStatus::Active); });
+
+    ASSERT_THROW(client->delete_user(make_string_identifier(username)), std::exception);
+    ASSERT_NO_THROW(client->connect());
+    ASSERT_THROW(client->delete_user(make_string_identifier(username)), std::exception);
+    ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
+    ASSERT_NO_THROW(client->logout_user());
+    ASSERT_THROW(client->delete_user(make_string_identifier(username)), std::exception);
+    ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
+    ASSERT_NO_THROW(client->disconnect());
+    ASSERT_THROW(client->delete_user(make_string_identifier(username)), std::exception);
+
+    iggy::ffi::UserInfoDetails fetched_user{};
+    ASSERT_NO_THROW({ fetched_user = root_client->get_user(make_string_identifier(username)); });
+    EXPECT_EQ(fetched_user.id, created_user.id);
+}
+
+TEST_F(LowLevelE2E_Client, DeleteUserRejectsUnknownUsernameAndNumericId) {
+    RecordProperty("description", "Rejects deletion of unknown string and numeric user identifiers.");
+    iggy::ffi::Client *client          = GetLoggedInClient();
+    const std::string unknown_user     = GetRandomName(50);
+    constexpr std::uint32_t unknown_id = std::numeric_limits<std::uint32_t>::max();
+
+    ASSERT_THROW(client->delete_user(make_string_identifier(unknown_user)), std::exception);
+    ASSERT_THROW(client->delete_user(make_numeric_identifier(unknown_id)), std::exception);
+}
+
+TEST_F(LowLevelE2E_Client, DeleteUserRejectsRootWithoutChangingRoot) {
+    RecordProperty("description", "Rejects root deletion by username and numeric ID without changing root.");
+    iggy::ffi::Client *client = GetLoggedInClient();
+    iggy::ffi::UserInfoDetails before_by_username{};
+    iggy::ffi::UserInfoDetails before_by_id{};
+    ASSERT_NO_THROW({ before_by_username = client->get_user(make_string_identifier("iggy")); });
+    ASSERT_NO_THROW({ before_by_id = client->get_user(make_numeric_identifier(0)); });
+    ASSERT_EQ(before_by_username.id, 0u);
+    ASSERT_EQ(before_by_id.id, 0u);
+
+    ASSERT_THROW(client->delete_user(make_string_identifier("iggy")), std::exception);
+    ASSERT_THROW(client->delete_user(make_numeric_identifier(0)), std::exception);
+
+    iggy::ffi::UserInfoDetails after_by_username{};
+    iggy::ffi::UserInfoDetails after_by_id{};
+    ASSERT_NO_THROW({ after_by_username = client->get_user(make_string_identifier("iggy")); });
+    ASSERT_NO_THROW({ after_by_id = client->get_user(make_numeric_identifier(0)); });
+    for (const auto *root : {&after_by_username, &after_by_id}) {
+        EXPECT_EQ(root->id, 0u);
+        EXPECT_EQ(static_cast<std::string>(root->username), "iggy");
+        EXPECT_EQ(root->status, iggy::ffi::UserStatus::Active);
+    }
+}
+
+TEST_F(LowLevelE2E_Client, DeleteUserByNumericIdRemovesTarget) {
+    RecordProperty("description", "Deletes a user selected by its numeric ID.");
+    iggy::ffi::Client *client  = GetLoggedInClient();
+    const std::string username = GetRandomName(50);
+    iggy::ffi::UserInfoDetails created_user{};
+    ASSERT_NO_THROW({ created_user = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
+
+    ASSERT_NO_THROW(client->delete_user(make_numeric_identifier(created_user.id)));
+    ForgetUser(username);
+
+    ASSERT_THROW(client->get_user(make_string_identifier(username)), std::exception);
+    rust::Vec<iggy::ffi::UserInfo> users;
+    ASSERT_NO_THROW({ users = client->get_users(); });
+    for (const auto &user : users) {
+        EXPECT_FALSE(user.id == created_user.id && static_cast<std::string>(user.username) == username);
+    }
+}
+
+TEST_F(LowLevelE2E_Client, DeleteUserRejectsRepeatedDeletion) {
+    RecordProperty("description", "Rejects deleting the same user twice.");
+    iggy::ffi::Client *client  = GetLoggedInClient();
+    const std::string username = GetRandomName(50);
+    iggy::ffi::UserInfoDetails created_user{};
+    ASSERT_NO_THROW({ created_user = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
+
+    ASSERT_NO_THROW(client->delete_user(make_string_identifier(username)));
+    ForgetUser(username);
+    ASSERT_THROW(client->delete_user(make_string_identifier(username)), std::exception);
+
+    rust::Vec<iggy::ffi::UserInfo> users;
+    ASSERT_NO_THROW({ users = client->get_users(); });
+    for (const auto &user : users) {
+        EXPECT_FALSE(user.id == created_user.id && static_cast<std::string>(user.username) == username);
+    }
+}
+
+TEST_F(LowLevelE2E_Client, ReadUsersPermissionDoesNotAllowDeleteUser) {
+    RecordProperty("description", "Rejects other-user and self deletion with read_users but not manage_users.");
+    iggy::ffi::Client *root_client   = GetLoggedInClient();
+    iggy::ffi::Client *caller_client = GetLoggedOutClient();
+    const std::string caller_name    = GetRandomName(50);
+    const std::string target_name    = GetRandomName(50);
+    iggy::ffi::Permissions permissions{};
+    permissions.global.read_users   = true;
+    permissions.global.manage_users = false;
+    iggy::ffi::UserInfoDetails caller{};
+    iggy::ffi::UserInfoDetails target{};
+    ASSERT_NO_THROW({
+        caller = CreateUser(root_client, caller_name, "caller-secret", iggy::ffi::UserStatus::Active, true,
+                            std::move(permissions));
+    });
+    ASSERT_NO_THROW({ target = CreateUser(root_client, target_name, "target-secret", iggy::ffi::UserStatus::Active); });
+    ASSERT_NO_THROW(caller_client->connect());
+    ASSERT_NO_THROW(caller_client->login_user(caller_name, "caller-secret"));
+
+    ASSERT_THROW(caller_client->delete_user(make_string_identifier(target_name)), std::exception);
+    ASSERT_THROW(caller_client->delete_user(make_string_identifier(caller_name)), std::exception);
+
+    iggy::ffi::UserInfoDetails fetched_caller{};
+    iggy::ffi::UserInfoDetails fetched_target{};
+    ASSERT_NO_THROW({ fetched_caller = root_client->get_user(make_string_identifier(caller_name)); });
+    ASSERT_NO_THROW({ fetched_target = root_client->get_user(make_string_identifier(target_name)); });
+    EXPECT_EQ(fetched_caller.id, caller.id);
+    EXPECT_EQ(fetched_target.id, target.id);
+}
+
+TEST_F(LowLevelE2E_Client, ManageUsersPermissionAllowsDeleteUser) {
+    RecordProperty("description", "Allows deletion with manage_users even when read_users is false.");
+    iggy::ffi::Client *root_client    = GetLoggedInClient();
+    iggy::ffi::Client *manager_client = GetLoggedOutClient();
+    const std::string manager_name    = GetRandomName(50);
+    const std::string target_name     = GetRandomName(50);
+    iggy::ffi::Permissions permissions{};
+    permissions.global.manage_users = true;
+    permissions.global.read_users   = false;
+    iggy::ffi::UserInfoDetails target{};
+    ASSERT_NO_THROW({
+        CreateUser(root_client, manager_name, "manager-secret", iggy::ffi::UserStatus::Active, true,
+                   std::move(permissions));
+    });
+    ASSERT_NO_THROW({ target = CreateUser(root_client, target_name, "target-secret", iggy::ffi::UserStatus::Active); });
+    ASSERT_NO_THROW(manager_client->connect());
+    ASSERT_NO_THROW(manager_client->login_user(manager_name, "manager-secret"));
+
+    ASSERT_NO_THROW(manager_client->delete_user(make_string_identifier(target_name)));
+    ForgetUser(target_name);
+
+    ASSERT_THROW(root_client->get_user(make_string_identifier(target_name)), std::exception);
+    rust::Vec<iggy::ffi::UserInfo> users;
+    ASSERT_NO_THROW({ users = root_client->get_users(); });
+    for (const auto &user : users) {
+        EXPECT_FALSE(user.id == target.id && static_cast<std::string>(user.username) == target_name);
+    }
+}
+
+TEST_F(LowLevelE2E_Client, DeleteUserRemovesOnlyTheTarget) {
+    RecordProperty("description", "Deletes only the selected user and leaves another user usable.");
+    iggy::ffi::Client *root_client     = GetLoggedInClient();
+    iggy::ffi::Client *survivor_client = GetLoggedOutClient();
+    const std::string target_name      = GetRandomName(50);
+    const std::string survivor_name    = GetRandomName(50);
+    iggy::ffi::UserInfoDetails target{};
+    iggy::ffi::UserInfoDetails survivor{};
+    ASSERT_NO_THROW({ target = CreateUser(root_client, target_name, "target-secret", iggy::ffi::UserStatus::Active); });
+    ASSERT_NO_THROW(
+        { survivor = CreateUser(root_client, survivor_name, "survivor-secret", iggy::ffi::UserStatus::Active); });
+
+    ASSERT_NO_THROW(root_client->delete_user(make_numeric_identifier(target.id)));
+    ForgetUser(target_name);
+
+    ASSERT_THROW(root_client->get_user(make_string_identifier(target_name)), std::exception);
+    rust::Vec<iggy::ffi::UserInfo> users;
+    ASSERT_NO_THROW({ users = root_client->get_users(); });
+    for (const auto &user : users) {
+        EXPECT_FALSE(user.id == target.id && static_cast<std::string>(user.username) == target_name);
+    }
+
+    iggy::ffi::UserInfoDetails fetched_survivor{};
+    ASSERT_NO_THROW({ fetched_survivor = root_client->get_user(make_string_identifier(survivor_name)); });
+    EXPECT_EQ(fetched_survivor.id, survivor.id);
+    EXPECT_EQ(static_cast<std::string>(fetched_survivor.username), survivor_name);
+    EXPECT_EQ(fetched_survivor.status, survivor.status);
+    ASSERT_NO_THROW(survivor_client->connect());
+    ASSERT_NO_THROW(survivor_client->login_user(survivor_name, "survivor-secret"));
+}
+
+TEST_F(LowLevelE2E_Client, DeleteInactiveUserSucceeds) {
+    RecordProperty("description", "Deletes an inactive user by username.");
+    iggy::ffi::Client *client  = GetLoggedInClient();
+    const std::string username = GetRandomName(50);
+    iggy::ffi::UserInfoDetails created_user{};
+    ASSERT_NO_THROW({ created_user = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Inactive); });
+
+    ASSERT_NO_THROW(client->delete_user(make_string_identifier(username)));
+    ForgetUser(username);
+
+    ASSERT_THROW(client->get_user(make_string_identifier(username)), std::exception);
+    rust::Vec<iggy::ffi::UserInfo> users;
+    ASSERT_NO_THROW({ users = client->get_users(); });
+    for (const auto &user : users) {
+        EXPECT_FALSE(user.id == created_user.id && static_cast<std::string>(user.username) == username);
+    }
+}
+
+TEST_F(LowLevelE2E_Client, DeleteUserRevokesExistingSessions) {
+    RecordProperty("description", "Revokes an existing session and prevents fresh login after user deletion.");
+    iggy::ffi::Client *root_client   = GetLoggedInClient();
+    iggy::ffi::Client *target_client = GetLoggedOutClient();
+    iggy::ffi::Client *fresh_client  = GetLoggedOutClient();
+    const std::string username       = GetRandomName(50);
+    const std::string password       = "target-secret";
+    iggy::ffi::Permissions permissions{};
+    permissions.global.read_servers = true;
+    ASSERT_NO_THROW(
+        { CreateUser(root_client, username, password, iggy::ffi::UserStatus::Active, true, std::move(permissions)); });
+    ASSERT_NO_THROW(target_client->connect());
+    ASSERT_NO_THROW(target_client->login_user(username, password));
+    iggy::ffi::Stats stats{};
+    ASSERT_NO_THROW({ stats = target_client->get_stats(); });
+
+    ASSERT_NO_THROW(root_client->delete_user(make_string_identifier(username)));
+    ForgetUser(username);
+
+    ASSERT_THROW(target_client->get_stats(), std::exception);
+    ASSERT_NO_THROW(fresh_client->connect());
+    ASSERT_THROW(fresh_client->login_user(username, password), std::exception);
+}
+
+TEST_F(LowLevelE2E_Client, DeletedUsernameCanBeRecreatedWithoutOldCredentialsOrPermissions) {
+    RecordProperty("description", "Recreates a deleted username without retaining old credentials or permissions.");
+    iggy::ffi::Client *root_client         = GetLoggedInClient();
+    iggy::ffi::Client *new_password_client = GetLoggedOutClient();
+    iggy::ffi::Client *old_password_client = GetLoggedOutClient();
+    const std::string username             = GetRandomName(50);
+    const std::string old_password         = "old-secret";
+    const std::string new_password         = "new-secret";
+    iggy::ffi::Permissions permissions{};
+    permissions.global.read_servers = true;
+    ASSERT_NO_THROW({
+        CreateUser(root_client, username, old_password, iggy::ffi::UserStatus::Active, true, std::move(permissions));
+    });
+
+    ASSERT_NO_THROW(root_client->delete_user(make_string_identifier(username)));
+    ForgetUser(username);
+
+    iggy::ffi::UserInfoDetails replacement{};
+    ASSERT_NO_THROW({ replacement = CreateUser(root_client, username, new_password, iggy::ffi::UserStatus::Active); });
+    EXPECT_EQ(static_cast<std::string>(replacement.username), username);
+    EXPECT_FALSE(replacement.has_permissions);
+    EXPECT_FALSE(replacement.permissions.global.read_servers);
+    EXPECT_TRUE(replacement.permissions.streams.empty());
+
+    iggy::ffi::UserInfoDetails fetched_replacement{};
+    ASSERT_NO_THROW({ fetched_replacement = root_client->get_user(make_string_identifier(username)); });
+    EXPECT_EQ(fetched_replacement.id, replacement.id);
+
+    ASSERT_NO_THROW(new_password_client->connect());
+    ASSERT_NO_THROW(new_password_client->login_user(username, new_password));
+    ASSERT_NO_THROW(old_password_client->connect());
+    ASSERT_THROW(old_password_client->login_user(username, old_password), std::exception);
+}
+
 TEST_F(LowLevelE2E_Client, ChangePasswordBeforeLoginThrows) {
     RecordProperty("description",
                    "Rejects change_password before connect, after connect but before login, and after disconnect.");
@@ -201,6 +1678,58 @@ TEST_F(LowLevelE2E_Client, ChangePasswordForWrongUserThrows) {
     ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
 }
 
+TEST_F(LowLevelE2E_Client, UserWithoutManageUsersCannotChangeAnotherUsersPassword) {
+    RecordProperty("description",
+                   "Rejects another user's password change without manage_users and preserves the old credentials.");
+    iggy::ffi::Client *root_client    = GetLoggedInClient();
+    iggy::ffi::Client *actor_client   = GetLoggedOutClient();
+    iggy::ffi::Client *target_client  = GetLoggedOutClient();
+    const std::string actor_name      = GetRandomName(50);
+    const std::string target_name     = GetRandomName(50);
+    const std::string target_password = "target-secret";
+    const std::string new_password    = "replacement-secret";
+    ASSERT_NO_THROW({ CreateUser(root_client, actor_name, "actor-secret", iggy::ffi::UserStatus::Active); });
+    iggy::ffi::UserInfoDetails target{};
+    ASSERT_NO_THROW({ target = CreateUser(root_client, target_name, target_password, iggy::ffi::UserStatus::Active); });
+    ASSERT_NO_THROW(actor_client->connect());
+    ASSERT_NO_THROW(actor_client->login_user(actor_name, "actor-secret"));
+
+    ASSERT_THROW(actor_client->change_password(make_numeric_identifier(target.id), target_password, new_password),
+                 std::exception);
+
+    ASSERT_NO_THROW(target_client->connect());
+    ASSERT_NO_THROW(target_client->login_user(target_name, target_password));
+}
+
+TEST_F(LowLevelE2E_Client, ManageUsersPermissionAllowsChangingAnotherUsersPassword) {
+    RecordProperty("description", "Allows a user with manage_users to change another user's password.");
+    iggy::ffi::Client *root_client         = GetLoggedInClient();
+    iggy::ffi::Client *manager_client      = GetLoggedOutClient();
+    iggy::ffi::Client *old_password_client = GetLoggedOutClient();
+    iggy::ffi::Client *new_password_client = GetLoggedOutClient();
+    const std::string manager_name         = GetRandomName(50);
+    const std::string target_name          = GetRandomName(50);
+    const std::string target_password      = "target-secret";
+    const std::string new_password         = "replacement-secret";
+    iggy::ffi::Permissions permissions{};
+    permissions.global.manage_users = true;
+    ASSERT_NO_THROW({
+        CreateUser(root_client, manager_name, "manager-secret", iggy::ffi::UserStatus::Active, true,
+                   std::move(permissions));
+    });
+    iggy::ffi::UserInfoDetails target{};
+    ASSERT_NO_THROW({ target = CreateUser(root_client, target_name, target_password, iggy::ffi::UserStatus::Active); });
+    ASSERT_NO_THROW(manager_client->connect());
+    ASSERT_NO_THROW(manager_client->login_user(manager_name, "manager-secret"));
+
+    ASSERT_NO_THROW(manager_client->change_password(make_numeric_identifier(target.id), target_password, new_password));
+
+    ASSERT_NO_THROW(old_password_client->connect());
+    ASSERT_THROW(old_password_client->login_user(target_name, target_password), std::exception);
+    ASSERT_NO_THROW(new_password_client->connect());
+    ASSERT_NO_THROW(new_password_client->login_user(target_name, new_password));
+}
+
 TEST_F(LowLevelE2E_Client, ChangePasswordUpdatesCredentialsAndCanBeRestored) {
     RecordProperty("description",
                    "Changes the password for the current user, updates login behavior, and restores the original "
@@ -230,12 +1759,12 @@ TEST_F(LowLevelE2E_Client, ChangePasswordUpdatesCredentialsAndCanBeRestored) {
 TEST_F(LowLevelE2E_Client, DeleteWhileUnauthenticatedAfterFailedLogin) {
     RecordProperty("description", "Allows client cleanup after a failed login leaves the connection unauthenticated.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
 
     ASSERT_NO_THROW(client->connect());
     ASSERT_THROW(client->login_user("biggy", "biggy"), std::exception);
-    ASSERT_NO_THROW(iggy::ffi::delete_client(client));
+    iggy::ffi::delete_client(client);
     client = nullptr;
 }
 
@@ -243,7 +1772,7 @@ TEST_F(LowLevelE2E_Client, ConnectLoginThenDisconnect) {
     RecordProperty("description",
                    "Connects, logs in, disconnects successfully, and rejects authenticated operations afterward.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
     TrackClient(client);
 
@@ -256,7 +1785,7 @@ TEST_F(LowLevelE2E_Client, ConnectLoginThenDisconnect) {
 TEST_F(LowLevelE2E_Client, DisconnectWithoutConnect) {
     RecordProperty("description", "Allows disconnect to be called on a client that was never explicitly connected.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
     TrackClient(client);
 
@@ -266,7 +1795,7 @@ TEST_F(LowLevelE2E_Client, DisconnectWithoutConnect) {
 TEST_F(LowLevelE2E_Client, DisconnectWithoutLogin) {
     RecordProperty("description", "Allows disconnect after connect even when no user has authenticated.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
     TrackClient(client);
 
@@ -279,7 +1808,7 @@ TEST_F(LowLevelE2E_Client, DisconnectThenReconnectWithoutRelogin) {
     RecordProperty("description",
                    "Requires logging in again after a disconnect and reconnect before authenticated operations work.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
     TrackClient(client);
 
@@ -293,7 +1822,7 @@ TEST_F(LowLevelE2E_Client, DisconnectThenReconnectWithoutRelogin) {
 TEST_F(LowLevelE2E_Client, DisconnectAfterFailedLogin) {
     RecordProperty("description", "Allows disconnect after a failed login attempt leaves the client unauthenticated.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
     TrackClient(client);
 
@@ -307,7 +1836,7 @@ TEST_F(LowLevelE2E_Client, ConnectLoginThenShutdown) {
     RecordProperty("description",
                    "Connects, logs in, shuts down successfully, and rejects further operations afterward.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
     TrackClient(client);
 
@@ -322,7 +1851,7 @@ TEST_F(LowLevelE2E_Client, ConnectLoginThenShutdown) {
 TEST_F(LowLevelE2E_Client, ShutdownWithoutConnect) {
     RecordProperty("description", "Allows shutdown to be called on a client that was never explicitly connected.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
     TrackClient(client);
 
@@ -332,7 +1861,7 @@ TEST_F(LowLevelE2E_Client, ShutdownWithoutConnect) {
 TEST_F(LowLevelE2E_Client, ShutdownWithoutLogin) {
     RecordProperty("description", "Allows shutdown after connect even when no user has authenticated.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
     TrackClient(client);
 
@@ -344,7 +1873,7 @@ TEST_F(LowLevelE2E_Client, ShutdownWithoutLogin) {
 TEST_F(LowLevelE2E_Client, ShutdownAfterFailedLogin) {
     RecordProperty("description", "Allows shutdown after a failed login attempt leaves the client unauthenticated.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
     TrackClient(client);
 
@@ -357,7 +1886,7 @@ TEST_F(LowLevelE2E_Client, ShutdownAfterFailedLogin) {
 TEST_F(LowLevelE2E_Client, RepeatedShutdownCallsHaveStableBehavior) {
     RecordProperty("description", "Keeps repeated shutdown calls stable across duplicate invocations.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
     TrackClient(client);
 
@@ -371,7 +1900,7 @@ TEST_F(LowLevelE2E_Client, RepeatedShutdownCallsHaveStableBehavior) {
 TEST_F(LowLevelE2E_Client, ShutdownThenConnectThrows) {
     RecordProperty("description", "Rejects reconnecting a client after shutdown transitions it to a terminal state.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
     TrackClient(client);
 
@@ -385,7 +1914,7 @@ TEST_F(LowLevelE2E_Client, ShutdownThenLoginThrows) {
     RecordProperty("description",
                    "Rejects logging in again after shutdown, even when login would normally auto-connect.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
     TrackClient(client);
 
@@ -479,7 +2008,7 @@ TEST_F(LowLevelE2E_Client, GetClientsReflectsLoggedOutSessionAsUnauthenticated)
 TEST_F(LowLevelE2E_Client, LoginWithoutConnect) {
     RecordProperty("description", "Supports login without an explicit prior connect call.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
     TrackClient(client);
 
@@ -489,23 +2018,23 @@ TEST_F(LowLevelE2E_Client, LoginWithoutConnect) {
 TEST_F(LowLevelE2E_Client, ConnectWithoutLoginThenDelete) {
     RecordProperty("description", "Allows connecting without logging in and then deleting the client.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
 
     ASSERT_NO_THROW(client->connect());
-    ASSERT_NO_THROW(iggy::ffi::delete_client(client));
+    iggy::ffi::delete_client(client);
     client = nullptr;
 }
 
 TEST_F(LowLevelE2E_Client, DeleteWithoutDisconnect) {
     RecordProperty("description", "Allows deleting a connected and authenticated client without disconnecting first.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
 
     ASSERT_NO_THROW(client->connect());
     ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
-    ASSERT_NO_THROW(iggy::ffi::delete_client(client));
+    iggy::ffi::delete_client(client);
     client = nullptr;
 }
 
@@ -513,23 +2042,23 @@ TEST_F(LowLevelE2E_Client, RepeatedClientMethodCallsHaveStableBehavior) {
     RecordProperty("description",
                    "Keeps repeated connect, login, and delete calls stable across duplicate invocations.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
 
     ASSERT_NO_THROW(client->connect());
     ASSERT_NO_THROW(client->connect());
     ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
     ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
-    ASSERT_NO_THROW(iggy::ffi::delete_client(client));
+    iggy::ffi::delete_client(client);
     client = nullptr;
 
-    ASSERT_NO_THROW(iggy::ffi::delete_client(client));
+    iggy::ffi::delete_client(client);
 }
 
 TEST_F(LowLevelE2E_Client, RepeatedDisconnectCallsHaveStableBehavior) {
     RecordProperty("description", "Keeps repeated disconnect calls stable across duplicate invocations.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+    ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
     ASSERT_NE(client, nullptr);
     TrackClient(client);
 
@@ -543,7 +2072,7 @@ TEST_F(LowLevelE2E_Client, RepeatedDisconnectCallsHaveStableBehavior) {
 TEST_F(LowLevelE2E_Client, DeleteNullConnectionIsNoop) {
     RecordProperty("description", "Treats deleting a null client pointer as a no-op.");
     iggy::ffi::Client *client = nullptr;
-    ASSERT_NO_THROW(iggy::ffi::delete_client(client));
+    iggy::ffi::delete_client(client);
 }
 
 TEST_F(LowLevelE2E_Client, GetStatsBeforeLoginThrows) {
@@ -1654,7 +3183,8 @@ TEST_F(LowLevelE2E_Client, HeartbeatIntervalReturnsConfiguredValueFromConnection
                    "Returns the configured heartbeat interval in microseconds from the connection string.");
     constexpr std::uint64_t configured_heartbeat_micros = 10'000'000ull;
     iggy::ffi::Client *client                           = nullptr;
-    ASSERT_NO_THROW({ client = iggy::ffi::new_connection("iggy://iggy:iggy@127.0.0.1:8090?heartbeat_interval=10s"); });
+    ASSERT_NO_THROW(
+        { client = iggy::ffi::from_connection_string("iggy://iggy:iggy@127.0.0.1:8090?heartbeat_interval=10s"); });
     ASSERT_NE(client, nullptr);
     TrackClient(client);
 
diff --git a/foreign/cpp/tests/e2e/test_helpers.hpp b/foreign/cpp/tests/e2e/test_helpers.hpp
index de81c9c7b..22a4b142a 100644
--- a/foreign/cpp/tests/e2e/test_helpers.hpp
+++ b/foreign/cpp/tests/e2e/test_helpers.hpp
@@ -22,6 +22,7 @@
 #include <algorithm>
 #include <cstdint>
 #include <initializer_list>
+#include <memory>
 #include <random>
 #include <string>
 #include <utility>
@@ -118,7 +119,7 @@ class E2ETestFixture : public ::testing::Test {
 
     iggy::ffi::Client *GetLoggedOutClient() {
         iggy::ffi::Client *client = nullptr;
-        EXPECT_NO_THROW({ client = iggy::ffi::new_connection(""); });
+        EXPECT_NO_THROW({ client = iggy::ffi::new_connection({}); });
         EXPECT_NE(client, nullptr);
         if (client == nullptr) {
             return nullptr;
@@ -140,10 +141,15 @@ class E2ETestFixture : public ::testing::Test {
         return client;
     }
 
-    std::string GetRandomName() {
+    std::string GetRandomName(const std::size_t max_length = 255) {
+        if (max_length == 0) {
+            return {};
+        }
+
         static constexpr char alphabet[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
         static thread_local std::mt19937 generator(std::random_device{}());
-        std::uniform_int_distribution<std::size_t> length_distribution(8, 255);
+        const std::size_t min_length = std::min<std::size_t>(8, max_length);
+        std::uniform_int_distribution<std::size_t> length_distribution(min_length, max_length);
         std::uniform_int_distribution<std::size_t> distribution(0, sizeof(alphabet) - 2);
         const std::size_t length = length_distribution(generator);
 
@@ -157,6 +163,28 @@ class E2ETestFixture : public ::testing::Test {
         return name;
     }
 
+    iggy::ffi::UserInfoDetails CreateUser(iggy::ffi::Client *client,
+                                          const std::string &username,
+                                          const std::string &password,
+                                          const iggy::ffi::UserStatus status,
+                                          const bool has_permissions         = false,
+                                          iggy::ffi::Permissions permissions = {}) {
+        auto user = client->create_user(username, password, status, has_permissions, std::move(permissions));
+        tracked_user_names_.push_back(username);
+        return user;
+    }
+
+    void ForgetUser(const std::string &username) {
+        tracked_user_names_.erase(std::remove(tracked_user_names_.begin(), tracked_user_names_.end(), username),
+                                  tracked_user_names_.end());
+    }
+
+    void RenameTrackedUser(const std::string &from, const std::string &to) {
+        const auto tracked_user = std::find(tracked_user_names_.begin(), tracked_user_names_.end(), from);
+        ASSERT_NE(tracked_user, tracked_user_names_.end());
+        *tracked_user = to;
+    }
+
     void TrackStream(const std::string &stream_name) { tracked_stream_names_.push_back(stream_name); }
     void TrackStream(const std::uint32_t stream_id) { tracked_stream_ids_.push_back(stream_id); }
     void TrackConsumerGroup(const std::string &stream_name,
@@ -199,19 +227,31 @@ class E2ETestFixture : public ::testing::Test {
         iggy::ffi::Client *client_to_delete = client;
         client                              = nullptr;
         ForgetClient(client_to_delete);
-        EXPECT_NO_THROW(iggy::ffi::delete_client(client_to_delete));
+        iggy::ffi::delete_client(client_to_delete);
     }
 
     void Cleanup() {
-        CleanupConsumerGroups();
-        CleanupStreams();
+        if (HasTrackedResources()) {
+            RunAsRoot([this](iggy::ffi::Client *client) {
+                CleanupConsumerGroups(client);
+                CleanupStreams(client);
+                CleanupUsers(client);
+            });
+        }
         CleanupClients();
     }
 
   private:
+    using ClientPtr = std::unique_ptr<iggy::ffi::Client, decltype(&iggy::ffi::delete_client)>;
+
     void CleanupBestEffort() noexcept {
-        CleanupConsumerGroupsBestEffort();
-        CleanupStreamsBestEffort();
+        if (HasTrackedResources()) {
+            RunAsRootBestEffort([this](iggy::ffi::Client *client) {
+                CleanupConsumerGroupsBestEffort(client);
+                CleanupStreamsBestEffort(client);
+                CleanupUsersBestEffort(client);
+            });
+        }
         CleanupClientsBestEffort();
     }
 
@@ -222,119 +262,101 @@ class E2ETestFixture : public ::testing::Test {
         }
     }
 
-    void CleanupStreams() {
-        if (tracked_stream_names_.empty() && tracked_stream_ids_.empty()) {
-            return;
+    void CleanupUsers(iggy::ffi::Client *client) {
+        for (const auto &username : tracked_user_names_) {
+            EXPECT_NO_THROW(client->delete_user(make_string_identifier(username)));
         }
+        tracked_user_names_.clear();
+    }
 
-        iggy::ffi::Client *cleanup_client = nullptr;
-        EXPECT_NO_THROW({ cleanup_client = iggy::ffi::new_connection(""); });
-        EXPECT_NE(cleanup_client, nullptr);
-        if (cleanup_client != nullptr) {
-            EXPECT_NO_THROW(cleanup_client->connect());
-            EXPECT_NO_THROW(cleanup_client->login_user("iggy", "iggy"));
-            for (const auto &stream_name : tracked_stream_names_) {
-                EXPECT_NO_THROW(cleanup_client->delete_stream(make_string_identifier(stream_name)));
-            }
-            for (const auto stream_id : tracked_stream_ids_) {
-                EXPECT_NO_THROW(cleanup_client->delete_stream(make_numeric_identifier(stream_id)));
+    void CleanupUsersBestEffort(iggy::ffi::Client *client) noexcept {
+        try {
+            for (const auto &username : tracked_user_names_) {
+                client->delete_user(make_string_identifier(username));
             }
-            EXPECT_NO_THROW(iggy::ffi::delete_client(cleanup_client));
+        } catch (...) {
         }
 
-        tracked_stream_names_.clear();
-        tracked_stream_ids_.clear();
+        tracked_user_names_.clear();
     }
 
-    void CleanupConsumerGroups() {
-        if (tracked_consumer_groups_.empty()) {
-            return;
+    void CleanupStreams(iggy::ffi::Client *client) {
+        for (const auto &stream_name : tracked_stream_names_) {
+            EXPECT_NO_THROW(client->delete_stream(make_string_identifier(stream_name)));
         }
+        for (const auto stream_id : tracked_stream_ids_) {
+            EXPECT_NO_THROW(client->delete_stream(make_numeric_identifier(stream_id)));
+        }
+        tracked_stream_names_.clear();
+        tracked_stream_ids_.clear();
+    }
 
-        iggy::ffi::Client *cleanup_client = nullptr;
-        EXPECT_NO_THROW({ cleanup_client = iggy::ffi::new_connection(""); });
-        EXPECT_NE(cleanup_client, nullptr);
-        if (cleanup_client != nullptr) {
-            EXPECT_NO_THROW(cleanup_client->connect());
-            EXPECT_NO_THROW(cleanup_client->login_user("iggy", "iggy"));
-            for (const auto &group : tracked_consumer_groups_) {
-                EXPECT_NO_THROW(cleanup_client->delete_consumer_group(make_string_identifier(group.stream_name),
-                                                                      make_string_identifier(group.topic_name),
-                                                                      make_string_identifier(group.group_name)));
+    void CleanupStreamsBestEffort(iggy::ffi::Client *client) noexcept {
+        try {
+            for (const auto &stream_name : tracked_stream_names_) {
+                client->delete_stream(make_string_identifier(stream_name));
             }
-            EXPECT_NO_THROW(iggy::ffi::delete_client(cleanup_client));
+            for (const auto stream_id : tracked_stream_ids_) {
+                client->delete_stream(make_numeric_identifier(stream_id));
+            }
+        } catch (...) {
         }
 
-        tracked_consumer_groups_.clear();
+        tracked_stream_names_.clear();
+        tracked_stream_ids_.clear();
     }
 
-    void CleanupStreamsBestEffort() noexcept {
-        if (tracked_stream_names_.empty() && tracked_stream_ids_.empty()) {
-            return;
+    void CleanupConsumerGroups(iggy::ffi::Client *client) {
+        for (const auto &group : tracked_consumer_groups_) {
+            EXPECT_NO_THROW(client->delete_consumer_group(make_string_identifier(group.stream_name),
+                                                          make_string_identifier(group.topic_name),
+                                                          make_string_identifier(group.group_name)));
         }
+        tracked_consumer_groups_.clear();
+    }
 
-        iggy::ffi::Client *cleanup_client = nullptr;
+    void CleanupConsumerGroupsBestEffort(iggy::ffi::Client *client) noexcept {
         try {
-            cleanup_client = iggy::ffi::new_connection("");
-            if (cleanup_client != nullptr) {
-                cleanup_client->connect();
-                cleanup_client->login_user("iggy", "iggy");
-                for (const auto &stream_name : tracked_stream_names_) {
-                    cleanup_client->delete_stream(make_string_identifier(stream_name));
-                }
-                for (const auto stream_id : tracked_stream_ids_) {
-                    cleanup_client->delete_stream(make_numeric_identifier(stream_id));
-                }
+            for (const auto &group : tracked_consumer_groups_) {
+                client->delete_consumer_group(make_string_identifier(group.stream_name),
+                                              make_string_identifier(group.topic_name),
+                                              make_string_identifier(group.group_name));
             }
         } catch (...) {
         }
 
-        if (cleanup_client != nullptr) {
-            try {
-                iggy::ffi::delete_client(cleanup_client);
-            } catch (...) {
-            }
-        }
-
-        tracked_stream_names_.clear();
-        tracked_stream_ids_.clear();
+        tracked_consumer_groups_.clear();
     }
 
-    void CleanupConsumerGroupsBestEffort() noexcept {
-        if (tracked_consumer_groups_.empty()) {
-            return;
-        }
+    template <typename Cleanup>
+    void RunAsRoot(Cleanup &&cleanup) {
+        ClientPtr cleanup_client{nullptr, iggy::ffi::delete_client};
+        ASSERT_NO_THROW(cleanup_client.reset(iggy::ffi::new_connection({})));
+        ASSERT_NE(cleanup_client, nullptr);
+        ASSERT_NO_THROW(cleanup_client->connect());
+        ASSERT_NO_THROW(cleanup_client->login_user("iggy", "iggy"));
+        cleanup(cleanup_client.get());
+    }
 
-        iggy::ffi::Client *cleanup_client = nullptr;
+    template <typename Cleanup>
+    void RunAsRootBestEffort(Cleanup &&cleanup) noexcept {
+        ClientPtr cleanup_client{nullptr, iggy::ffi::delete_client};
         try {
-            cleanup_client = iggy::ffi::new_connection("");
+            cleanup_client.reset(iggy::ffi::new_connection({}));
             if (cleanup_client != nullptr) {
                 cleanup_client->connect();
                 cleanup_client->login_user("iggy", "iggy");
-                for (const auto &group : tracked_consumer_groups_) {
-                    cleanup_client->delete_consumer_group(make_string_identifier(group.stream_name),
-                                                          make_string_identifier(group.topic_name),
-                                                          make_string_identifier(group.group_name));
-                }
+                cleanup(cleanup_client.get());
             }
         } catch (...) {
         }
-
-        if (cleanup_client != nullptr) {
-            try {
-                iggy::ffi::delete_client(cleanup_client);
-            } catch (...) {
-            }
-        }
-
-        tracked_consumer_groups_.clear();
     }
 
     void CleanupClients() {
         for (iggy::ffi::Client *&client : clients_) {
             iggy::ffi::Client *client_to_delete = client;
             client                              = nullptr;
-            EXPECT_NO_THROW(iggy::ffi::delete_client(client_to_delete));
+            iggy::ffi::delete_client(client_to_delete);
         }
         clients_.clear();
     }
@@ -343,15 +365,19 @@ class E2ETestFixture : public ::testing::Test {
         for (iggy::ffi::Client *&client : clients_) {
             iggy::ffi::Client *client_to_delete = client;
             client                              = nullptr;
-            try {
-                iggy::ffi::delete_client(client_to_delete);
-            } catch (...) {
-            }
+            iggy::ffi::delete_client(client_to_delete);
         }
         clients_.clear();
     }
 
+    bool HasTrackedResources() const {
+        return !tracked_consumer_groups_.empty() || !tracked_stream_names_.empty() || !tracked_stream_ids_.empty() ||
+               !tracked_user_names_.empty();
+    }
+
+  private:
     std::vector<iggy::ffi::Client *> clients_;
+    std::vector<std::string> tracked_user_names_;
     std::vector<std::string> tracked_stream_names_;
     std::vector<std::uint32_t> tracked_stream_ids_;
     std::vector<TrackedConsumerGroup> tracked_consumer_groups_;
diff --git a/foreign/cpp/tests/e2e/topic.cpp b/foreign/cpp/tests/e2e/topic.cpp
index 3c863a185..5bc3bdaa4 100644
--- a/foreign/cpp/tests/e2e/topic.cpp
+++ b/foreign/cpp/tests/e2e/topic.cpp
@@ -290,11 +290,11 @@ TEST_F(LowLevelE2E_Topic, CreateTopicWithTypedOptionHelpersReportsThemAsExplicit
     constexpr std::uint64_t size_of_messages_required_to_save = 2ULL * 1024ULL * 1024ULL;
 
     rust::Vec<iggy::ffi::HeaderEntry> options;
-    options.push_back(iggy::TopicOption::segment_size(segment_size_bytes));
-    options.push_back(iggy::TopicOption::enforce_fsync(true));
-    options.push_back(iggy::TopicOption::messages_required_to_save(messages_required_to_save));
-    options.push_back(iggy::TopicOption::size_of_messages_required_to_save(size_of_messages_required_to_save));
-    options.push_back(iggy::TopicOption::preallocate_segments(false));
+    options.push_back(iggy::TopicOption::SegmentSize(segment_size_bytes));
+    options.push_back(iggy::TopicOption::EnforceFsync(true));
+    options.push_back(iggy::TopicOption::MessagesRequiredToSave(messages_required_to_save));
+    options.push_back(iggy::TopicOption::SizeOfMessagesRequiredToSave(size_of_messages_required_to_save));
+    options.push_back(iggy::TopicOption::PreallocateSegments(false));
 
     ASSERT_NO_THROW(client->create_topic(make_string_identifier(stream_name), topic_name, 1, "none", "server_default",
                                          0, "server_default", std::move(options)));
diff --git a/foreign/cpp/tests/unit/unit_tests.cpp b/foreign/cpp/tests/unit/unit_tests.cpp
index 6f4bc7590..18db58f65 100644
--- a/foreign/cpp/tests/unit/unit_tests.cpp
+++ b/foreign/cpp/tests/unit/unit_tests.cpp
@@ -19,6 +19,7 @@
 
 #include <cstdint>
 #include <string>
+#include <utility>
 #include <vector>
 
 #include <gtest/gtest.h>
@@ -42,80 +43,81 @@ constexpr std::uint8_t kind_code(const iggy::ffi::HeaderKind kind) {
 }  // namespace
 
 TEST(CompressionAlgorithmTest, ReturnsExpectedValues) {
-    EXPECT_EQ(iggy::CompressionAlgorithm::none().compression_algorithm_value(), "none");
-    EXPECT_EQ(iggy::CompressionAlgorithm::gzip().compression_algorithm_value(), "gzip");
+    EXPECT_EQ(iggy::CompressionAlgorithm::None().CompressionAlgorithmValue(), "none");
+    EXPECT_EQ(iggy::CompressionAlgorithm::Gzip().CompressionAlgorithmValue(), "gzip");
 }
 
 TEST(SnapshotCompressionTest, ReturnsExpectedValues) {
-    EXPECT_EQ(iggy::SnapshotCompression::stored().snapshot_compression_value(), "stored");
-    EXPECT_EQ(iggy::SnapshotCompression::deflated().snapshot_compression_value(), "deflated");
-    EXPECT_EQ(iggy::SnapshotCompression::bzip2().snapshot_compression_value(), "bzip2");
-    EXPECT_EQ(iggy::SnapshotCompression::zstd().snapshot_compression_value(), "zstd");
-    EXPECT_EQ(iggy::SnapshotCompression::lzma().snapshot_compression_value(), "lzma");
-    EXPECT_EQ(iggy::SnapshotCompression::xz().snapshot_compression_value(), "xz");
+    EXPECT_EQ(iggy::SnapshotCompression::Stored().SnapshotCompressionValue(), "stored");
+    EXPECT_EQ(iggy::SnapshotCompression::Deflated().SnapshotCompressionValue(), "deflated");
+    EXPECT_EQ(iggy::SnapshotCompression::Bzip2().SnapshotCompressionValue(), "bzip2");
+    EXPECT_EQ(iggy::SnapshotCompression::Zstd().SnapshotCompressionValue(), "zstd");
+    EXPECT_EQ(iggy::SnapshotCompression::Lzma().SnapshotCompressionValue(), "lzma");
+    EXPECT_EQ(iggy::SnapshotCompression::Xz().SnapshotCompressionValue(), "xz");
 }
 
 TEST(SystemSnapshotTypeTest, ReturnsExpectedValues) {
-    EXPECT_EQ(iggy::SystemSnapshotType::filesystem_overview().snapshot_type_value(), "filesystem_overview");
-    EXPECT_EQ(iggy::SystemSnapshotType::process_list().snapshot_type_value(), "process_list");
-    EXPECT_EQ(iggy::SystemSnapshotType::resource_usage().snapshot_type_value(), "resource_usage");
-    EXPECT_EQ(iggy::SystemSnapshotType::test().snapshot_type_value(), "test");
-    EXPECT_EQ(iggy::SystemSnapshotType::server_logs().snapshot_type_value(), "server_logs");
-    EXPECT_EQ(iggy::SystemSnapshotType::server_config().snapshot_type_value(), "server_config");
-    EXPECT_EQ(iggy::SystemSnapshotType::all().snapshot_type_value(), "all");
+    EXPECT_EQ(iggy::SystemSnapshotType::FilesystemOverview().SnapshotTypeValue(), "filesystem_overview");
+    EXPECT_EQ(iggy::SystemSnapshotType::ProcessList().SnapshotTypeValue(), "process_list");
+    EXPECT_EQ(iggy::SystemSnapshotType::ResourceUsage().SnapshotTypeValue(), "resource_usage");
+    EXPECT_EQ(iggy::SystemSnapshotType::Test().SnapshotTypeValue(), "test");
+    EXPECT_EQ(iggy::SystemSnapshotType::ServerLogs().SnapshotTypeValue(), "server_logs");
+    EXPECT_EQ(iggy::SystemSnapshotType::ServerConfig().SnapshotTypeValue(), "server_config");
+    EXPECT_EQ(iggy::SystemSnapshotType::All().SnapshotTypeValue(), "all");
 }
 
 TEST(IdKindTest, ReturnsExpectedValues) {
-    EXPECT_EQ(iggy::IdKind::numeric().id_kind_value(), "numeric");
-    EXPECT_EQ(iggy::IdKind::string().id_kind_value(), "string");
+    EXPECT_EQ(iggy::IdKind::Numeric().IdKindValue(), "numeric");
+    EXPECT_EQ(iggy::IdKind::String().IdKindValue(), "string");
 }
 
 TEST(MaxTopicSizeTest, ReturnsExpectedValues) {
-    EXPECT_EQ(iggy::MaxTopicSize::server_default().max_topic_size(), "server_default");
-    EXPECT_EQ(iggy::MaxTopicSize::unlimited().max_topic_size(), "unlimited");
-    EXPECT_EQ(iggy::MaxTopicSize::from_bytes(0).max_topic_size(), "server_default");
-    EXPECT_EQ(iggy::MaxTopicSize::from_bytes(std::numeric_limits<std::uint64_t>::max()).max_topic_size(), "unlimited");
-    EXPECT_EQ(iggy::MaxTopicSize::from_bytes(1024).max_topic_size(), "1024");
+    EXPECT_EQ(iggy::MaxTopicSize::ServerDefault().MaxTopicSizeValue(), "server_default");
+    EXPECT_EQ(iggy::MaxTopicSize::Unlimited().MaxTopicSizeValue(), "unlimited");
+    EXPECT_EQ(iggy::MaxTopicSize::FromBytes(0).MaxTopicSizeValue(), "server_default");
+    EXPECT_EQ(iggy::MaxTopicSize::FromBytes(std::numeric_limits<std::uint64_t>::max()).MaxTopicSizeValue(),
+              "unlimited");
+    EXPECT_EQ(iggy::MaxTopicSize::FromBytes(1024).MaxTopicSizeValue(), "1024");
 }
 
 TEST(PollingStrategyTest, ReturnsExpectedKindAndValue) {
-    const auto offset = iggy::PollingStrategy::offset(7);
-    EXPECT_EQ(offset.polling_strategy_kind(), "offset");
-    EXPECT_EQ(offset.polling_strategy_value(), 7u);
+    const auto offset = iggy::PollingStrategy::Offset(7);
+    EXPECT_EQ(offset.PollingStrategyKind(), "offset");
+    EXPECT_EQ(offset.PollingStrategyValue(), 7u);
 
-    const auto timestamp = iggy::PollingStrategy::timestamp(42);
-    EXPECT_EQ(timestamp.polling_strategy_kind(), "timestamp");
-    EXPECT_EQ(timestamp.polling_strategy_value(), 42u);
+    const auto timestamp = iggy::PollingStrategy::Timestamp(42);
+    EXPECT_EQ(timestamp.PollingStrategyKind(), "timestamp");
+    EXPECT_EQ(timestamp.PollingStrategyValue(), 42u);
 
-    const auto first = iggy::PollingStrategy::first();
-    EXPECT_EQ(first.polling_strategy_kind(), "first");
-    EXPECT_EQ(first.polling_strategy_value(), 0u);
+    const auto first = iggy::PollingStrategy::First();
+    EXPECT_EQ(first.PollingStrategyKind(), "first");
+    EXPECT_EQ(first.PollingStrategyValue(), 0u);
 
-    const auto last = iggy::PollingStrategy::last();
-    EXPECT_EQ(last.polling_strategy_kind(), "last");
-    EXPECT_EQ(last.polling_strategy_value(), 0u);
+    const auto last = iggy::PollingStrategy::Last();
+    EXPECT_EQ(last.PollingStrategyKind(), "last");
+    EXPECT_EQ(last.PollingStrategyValue(), 0u);
 
-    const auto next = iggy::PollingStrategy::next();
-    EXPECT_EQ(next.polling_strategy_kind(), "next");
-    EXPECT_EQ(next.polling_strategy_value(), 0u);
+    const auto next = iggy::PollingStrategy::Next();
+    EXPECT_EQ(next.PollingStrategyKind(), "next");
+    EXPECT_EQ(next.PollingStrategyValue(), 0u);
 }
 
 TEST(ExpiryTest, ReturnsExpectedKindAndValue) {
-    const auto server_default = iggy::Expiry::server_default();
-    EXPECT_EQ(server_default.expiry_kind(), "server_default");
-    EXPECT_EQ(server_default.expiry_value(), static_cast<std::uint64_t>(0));
+    const auto server_default = iggy::Expiry::ServerDefault();
+    EXPECT_EQ(server_default.ExpiryKind(), "server_default");
+    EXPECT_EQ(server_default.ExpiryValue(), static_cast<std::uint64_t>(0));
 
-    const auto never_expire = iggy::Expiry::never_expire();
-    EXPECT_EQ(never_expire.expiry_kind(), "never_expire");
-    EXPECT_EQ(never_expire.expiry_value(), std::numeric_limits<std::uint64_t>::max());
+    const auto never_expire = iggy::Expiry::NeverExpire();
+    EXPECT_EQ(never_expire.ExpiryKind(), "never_expire");
+    EXPECT_EQ(never_expire.ExpiryValue(), std::numeric_limits<std::uint64_t>::max());
 
-    const auto duration = iggy::Expiry::duration(15);
-    EXPECT_EQ(duration.expiry_kind(), "duration");
-    EXPECT_EQ(duration.expiry_value(), static_cast<std::uint64_t>(15));
+    const auto duration = iggy::Expiry::Duration(15);
+    EXPECT_EQ(duration.ExpiryKind(), "duration");
+    EXPECT_EQ(duration.ExpiryValue(), static_cast<std::uint64_t>(15));
 }
 
 TEST(TopicOptionTest, SegmentSizeEncodesLittleEndianUint64) {
-    const auto option = iggy::TopicOption::segment_size(0x0102030405060708ULL);
+    const auto option = iggy::TopicOption::SegmentSize(0x0102030405060708ULL);
 
     EXPECT_EQ(option.key.kind, kind_code(iggy::ffi::HeaderKind::String));
     EXPECT_EQ(option_key(option), "segment_size");
@@ -124,14 +126,14 @@ TEST(TopicOptionTest, SegmentSizeEncodesLittleEndianUint64) {
 }
 
 TEST(TopicOptionTest, EnforceFsyncEncodesSingleBoolByte) {
-    const auto enabled = iggy::TopicOption::enforce_fsync(true);
+    const auto enabled = iggy::TopicOption::EnforceFsync(true);
 
     EXPECT_EQ(enabled.key.kind, kind_code(iggy::ffi::HeaderKind::String));
     EXPECT_EQ(option_key(enabled), "enforce_fsync");
     EXPECT_EQ(enabled.value.kind, kind_code(iggy::ffi::HeaderKind::Bool));
     EXPECT_EQ(option_value_bytes(enabled), (std::vector<std::uint8_t>{1}));
 
-    const auto disabled = iggy::TopicOption::enforce_fsync(false);
+    const auto disabled = iggy::TopicOption::EnforceFsync(false);
 
     EXPECT_EQ(option_key(disabled), "enforce_fsync");
     EXPECT_EQ(disabled.value.kind, kind_code(iggy::ffi::HeaderKind::Bool));
@@ -139,7 +141,7 @@ TEST(TopicOptionTest, EnforceFsyncEncodesSingleBoolByte) {
 }
 
 TEST(TopicOptionTest, MessagesRequiredToSaveEncodesLittleEndianUint32) {
-    const auto option = iggy::TopicOption::messages_required_to_save(0x01020304U);
+    const auto option = iggy::TopicOption::MessagesRequiredToSave(0x01020304U);
 
     EXPECT_EQ(option.key.kind, kind_code(iggy::ffi::HeaderKind::String));
     EXPECT_EQ(option_key(option), "messages_required_to_save");
@@ -148,7 +150,7 @@ TEST(TopicOptionTest, MessagesRequiredToSaveEncodesLittleEndianUint32) {
 }
 
 TEST(TopicOptionTest, SizeOfMessagesRequiredToSaveEncodesLittleEndianUint64) {
-    const auto option = iggy::TopicOption::size_of_messages_required_to_save(1024ULL * 1024ULL);
+    const auto option = iggy::TopicOption::SizeOfMessagesRequiredToSave(1024ULL * 1024ULL);
 
     EXPECT_EQ(option.key.kind, kind_code(iggy::ffi::HeaderKind::String));
     EXPECT_EQ(option_key(option), "size_of_messages_required_to_save");
@@ -157,25 +159,25 @@ TEST(TopicOptionTest, SizeOfMessagesRequiredToSaveEncodesLittleEndianUint64) {
 }
 
 TEST(TopicOptionTest, PreallocateSegmentsEncodesSingleBoolByte) {
-    const auto enabled = iggy::TopicOption::preallocate_segments(true);
+    const auto enabled = iggy::TopicOption::PreallocateSegments(true);
 
     EXPECT_EQ(enabled.key.kind, kind_code(iggy::ffi::HeaderKind::String));
     EXPECT_EQ(option_key(enabled), "preallocate_segments");
     EXPECT_EQ(enabled.value.kind, kind_code(iggy::ffi::HeaderKind::Bool));
     EXPECT_EQ(option_value_bytes(enabled), (std::vector<std::uint8_t>{1}));
 
-    const auto disabled = iggy::TopicOption::preallocate_segments(false);
+    const auto disabled = iggy::TopicOption::PreallocateSegments(false);
 
     EXPECT_EQ(option_value_bytes(disabled), (std::vector<std::uint8_t>{0}));
 }
 
 TEST(TopicOptionTest, MaximumValuesFillEveryValueByte) {
-    const auto segment_size = iggy::TopicOption::segment_size(std::numeric_limits<std::uint64_t>::max());
+    const auto segment_size = iggy::TopicOption::SegmentSize(std::numeric_limits<std::uint64_t>::max());
     EXPECT_EQ(option_value_bytes(segment_size),
               (std::vector<std::uint8_t>{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}));
 
     const auto messages_required_to_save =
-        iggy::TopicOption::messages_required_to_save(std::numeric_limits<std::uint32_t>::max());
+        iggy::TopicOption::MessagesRequiredToSave(std::numeric_limits<std::uint32_t>::max());
     EXPECT_EQ(option_value_bytes(messages_required_to_save), (std::vector<std::uint8_t>{0xFF, 0xFF, 0xFF, 0xFF}));
 }
 
@@ -187,3 +189,60 @@ TEST(IggyExceptionTest, StoresMessage) {
     const iggy::IggyException from_string(message);
     EXPECT_EQ(std::string(from_string.what()), message);
 }
+
+TEST(IggyBlockingClientTest, MovedFromOperationsThrow) {
+    auto client   = iggy::IggyBlockingClient::Builder().Build();
+    auto moved_to = std::move(client);
+    (void)moved_to;
+
+    EXPECT_THROW(client.Connect(), iggy::IggyException);
+    EXPECT_THROW(client.Disconnect(), iggy::IggyException);
+    EXPECT_THROW(client.Shutdown(), iggy::IggyException);
+    EXPECT_THROW(client.Login("iggy", "iggy"), iggy::IggyException);
+    EXPECT_THROW(client.Logout(), iggy::IggyException);
+}
+
+TEST(AutoLoginKindTest, HasStableDiscriminantsAndZeroInitializedDefault) {
+    EXPECT_EQ(static_cast<std::uint8_t>(iggy::ffi::AutoLoginKind::Disabled), 0u);
+    EXPECT_EQ(static_cast<std::uint8_t>(iggy::ffi::AutoLoginKind::UsernamePassword), 1u);
+    EXPECT_EQ(static_cast<std::uint8_t>(iggy::ffi::AutoLoginKind::PersonalAccessToken), 2u);
+
+    const iggy::ffi::IggyClientConfig config{};
+    EXPECT_EQ(config.auto_login_kind, iggy::ffi::AutoLoginKind::Disabled);
+}
+
+TEST(IggyBlockingClientBuilderTest, BuildsWithEachAutoLoginKind) {
+    EXPECT_NO_THROW((void)iggy::IggyBlockingClient::Builder().Build());
+    EXPECT_NO_THROW((void)iggy::IggyBlockingClient::Builder().WithAutoLogin("iggy", "iggy").Build());
+    EXPECT_NO_THROW((void)iggy::IggyBlockingClient::Builder().WithPersonalAccessToken("token").Build());
+}
+
+TEST(IggyBlockingClientBuilderTest, RejectsEmptyServerAddress) {
+    EXPECT_THROW((void)iggy::IggyBlockingClient::Builder().WithServerAddress(""), iggy::IggyException);
+}
+
+TEST(IggyBlockingClientBuilderTest, RejectsEmptyAutoLoginCredentials) {
+    EXPECT_THROW((void)iggy::IggyBlockingClient::Builder().WithAutoLogin("", "password"), iggy::IggyException);
+    EXPECT_THROW((void)iggy::IggyBlockingClient::Builder().WithAutoLogin("username", ""), iggy::IggyException);
+}
+
+TEST(IggyBlockingClientBuilderTest, RejectsEmptyPersonalAccessToken) {
+    EXPECT_THROW((void)iggy::IggyBlockingClient::Builder().WithPersonalAccessToken(""), iggy::IggyException);
+}
+
+TEST(IggyBlockingClientBuilderTest, RejectsTlsDomainWhenTlsIsDisabled) {
+    EXPECT_THROW((void)iggy::IggyBlockingClient::Builder().WithTlsDomain("localhost").Build(), iggy::IggyException);
+}
+
+TEST(IggyBlockingClientBuilderTest, RejectsTlsCaFileWhenTlsIsDisabled) {
+    EXPECT_THROW((void)iggy::IggyBlockingClient::Builder().WithTlsCaFile("ca.pem").Build(), iggy::IggyException);
+}
+
+TEST(IggyBlockingClientBuilderTest, RejectsTlsValidationWhenTlsIsDisabled) {
+    EXPECT_THROW((void)iggy::IggyBlockingClient::Builder().WithTlsCertificateValidation().Build(), iggy::IggyException);
+}
+
+TEST(IggyBlockingClientBuilderTest, RejectsEmptyTlsSettings) {
+    EXPECT_THROW((void)iggy::IggyBlockingClient::Builder().WithTlsDomain(""), iggy::IggyException);
+    EXPECT_THROW((void)iggy::IggyBlockingClient::Builder().WithTlsCaFile(""), iggy::IggyException);
+}