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);
+}