diff --git a/bdd/cpp/features/step_definitions/background_steps.cpp b/bdd/cpp/features/step_definitions/background_steps.cpp index 5100fde086..c4f593f596 100644 --- a/bdd/cpp/features/step_definitions/background_steps.cpp +++ b/bdd/cpp/features/step_definitions/background_steps.cpp @@ -28,6 +28,7 @@ #include #include +#include #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 df4afc7db6..cb0622fc49 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(stream_id)), topic_name, static_cast(partitions_count), - std::string(compression.compression_algorithm_value()), 0, - std::string(message_expiry.expiry_kind()), message_expiry.expiry_value(), - std::string(max_topic_size.max_topic_size())); + std::string(compression.CompressionAlgorithmValue()), 0, + 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 context; - const auto polling_strategy = iggy::PollingStrategy::offset(static_cast(offset)); + const auto polling_strategy = iggy::PollingStrategy::Offset(static_cast(offset)); const auto polled = context->client->poll_messages( bdd::make_numeric_identifier(static_cast(stream_id)), bdd::make_numeric_identifier(static_cast(topic_id)), static_cast(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 08372bd139..880712c20b 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 d874e010de..c6bb6740e0 100644 --- a/foreign/cpp/include/iggy.hpp +++ b/foreign/cpp/include/iggy.hpp @@ -19,32 +19,36 @@ #pragma once +/** + * @file iggy.hpp + * @brief Public C++ API for the Apache Iggy client. + */ + +#include #include #include +#include #include #include #include #include +#include "lib.rs.h" + 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 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_; @@ -52,162 +56,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 { 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"); } - std::string_view compression_algorithm_value() const { return value(); } + /** @brief Returns the gzip compression option. */ + static CompressionAlgorithm Gzip() { return CompressionAlgorithm("gzip"); } + + /** + * @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(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 { 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(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 { 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(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::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::max() maps + * to unlimited. The Rust client rejects unsupported values. + */ class MaxTopicSize final : private detail::StringTag { 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::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::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::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(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 { 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"); } + + /** @brief Uses a string identifier represented by its text value. */ + static IdKind String() { return IdKind("string"); } - std::string_view id_kind_value() const { return value(); } + /** + * @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(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::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::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::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::max() for NeverExpire(). + */ + std::uint64_t ExpiryValue() const { return expiry_value_; } private: explicit Expiry(std::string expiry_kind, std::uint64_t expiry_value) @@ -217,32 +279,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) @@ -252,4 +333,425 @@ class PollingStrategy final { std::uint64_t polling_strategy_value_; }; +/** + * @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 the + * underlying Rust-backed connection and releases it when destroyed. + * + * 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"); + * @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 client currently owned by this object is released first. The + * moved-from client must not be used for client operations. + */ + IggyBlockingClient &operator=(IggyBlockingClient &&other) noexcept; + + /** + * @brief Releases the underlying Rust client. + * + * Cleanup errors cannot be reported from the destructor. Call Shutdown() + * explicitly when graceful transport shutdown must be observed. + */ + ~IggyBlockingClient(); + + /** + * @brief Creates a client from an Iggy connection string. + * + * Connection strings use one of these forms: + * + * - `iggy://@:[?]` for TCP. + * - `iggy+tcp://@:[?]` for TCP. + * - `iggy+quic://@:[?]` for QUIC. + * - `iggy+http://@:[?]` for HTTP. + * - `iggy+ws://@:[?]` for WebSocket. + * + * Credentials are either `:` 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=` + * - `tls_domain=` + * - `tls_ca_file=` + * - `reconnection_retries=` + * - `reconnection_interval=` + * - `reestablish_after=` + * - `heartbeat_interval=` + * - `nodelay=` + * + * QUIC accepts these query parameters: + * + * - `response_buffer_size=` + * - `max_concurrent_bidi_streams=` + * - `datagram_send_buffer_size=` + * - `initial_mtu=` + * - `send_window=` + * - `receive_window=` + * - `keep_alive_interval=` + * - `max_idle_timeout=` + * - `validate_certificate=` + * - `heartbeat_interval=` + * - `reconnection_max_retries=` + * - `reconnection_interval=` + * - `reconnection_reestablish_after=` + * + * HTTP accepts these query parameters: + * + * - `heartbeat_interval=` + * - `retries=` + * + * WebSocket accepts these query parameters: + * + * - `heartbeat_interval=` + * - `reconnection_retries=` + * - `reconnection_interval=` + * - `reestablish_after=` + * - `read_buffer_size=` + * - `write_buffer_size=` + * - `max_write_buffer_size=` + * - `max_message_size=` + * - `max_frame_size=` + * - `accept_unmasked_frames=` + * - `tls=` + * - `tls_domain=` + * - `tls_ca_file=` + * - `tls_validate_certificate=` + * + * 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. Calling this on an already connected client has no effect. + * + * @note HTTP is stateless; connecting initializes heartbeat processing but + * does not open a persistent transport connection. + * @throws IggyException if the connection or automatic authentication + * fails. + */ + void Connect() const; + + /** + * @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 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() const; + + /** + * @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, which also causes the background heartbeat task to + * stop. 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() const; + + /** + * @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) const; + + /** + * @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() const; + + private: + explicit IggyBlockingClient(ffi::Client *client); + + 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. + * @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. + * @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. + * @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 are applied only when TLS is 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 empty, the domain is derived from the configured server address. + * This setting has no effect unless TLS is enabled. + * + * @param domain TLS domain name. + * @return Reference to this builder. + */ + 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. + * + * @param path Path to a PEM-encoded certificate-authority file. + * @return Reference to this builder. + */ + 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 + * has no effect unless TLS is enabled. + * + * @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_; + std::string auto_login_kind_ = "disabled"; + std::string auto_login_username_; + std::string auto_login_password_; + std::string personal_access_token_; + bool set_reconnection_max_retries_ = false; + std::optional reconnection_max_retries_; + std::optional reconnection_interval_micros_; + std::optional reestablish_after_micros_; + std::optional tls_enabled_; + std::string tls_domain_; + std::string tls_ca_file_; + std::optional tls_validate_certificate_; + bool no_delay_ = false; +}; + } // namespace iggy diff --git a/foreign/cpp/src/client.cpp b/foreign/cpp/src/client.cpp new file mode 100644 index 0000000000..a5696f5899 --- /dev/null +++ b/foreign/cpp/src/client.cpp @@ -0,0 +1,220 @@ +/* + * 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) { + try { + return IggyBlockingClient(ffi::from_connection_string(connection_string)); + } catch (const std::exception &error) { + throw IggyException(error.what()); + } +} + +void IggyBlockingClient::Connect() const { + try { + client_->connect(); + } catch (const std::exception &error) { + throw IggyException(error.what()); + } +} + +void IggyBlockingClient::Disconnect() const { + try { + client_->disconnect(); + } catch (const std::exception &error) { + throw IggyException(error.what()); + } +} + +void IggyBlockingClient::Shutdown() const { + try { + client_->shutdown(); + } catch (const std::exception &error) { + throw IggyException(error.what()); + } +} + +LoginInfo IggyBlockingClient::Login(std::string username, std::string password) const { + try { + return client_->login_user(std::move(username), std::move(password)); + } catch (const std::exception &error) { + throw IggyException(error.what()); + } +} + +void IggyBlockingClient::Logout() const { + try { + client_->logout_user(); + } catch (const std::exception &error) { + throw IggyException(error.what()); + } +} + +IggyBlockingClient::IggyBlockingClient(ffi::Client *client) : client_(client) { + if (client_ == nullptr) { + throw IggyException("Could not create Iggy client"); + } +} + +void IggyBlockingClient::Reset() noexcept { + if (client_ == nullptr) { + return; + } + + ffi::Client *client = std::exchange(client_, nullptr); + try { + ffi::delete_client(client); + } catch (...) { + } +} + +IggyBlockingClient::Builder::Builder() = default; + +IggyBlockingClient::Builder &IggyBlockingClient::Builder::WithServerAddress(std::string server_address) { + server_address_ = std::move(server_address); + return *this; +} + +IggyBlockingClient::Builder &IggyBlockingClient::Builder::WithAutoLogin(std::string username, std::string password) { + auto_login_kind_ = "username_password"; + 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) { + auto_login_kind_ = "personal_access_token"; + 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) { + set_reconnection_max_retries_ = true; + reconnection_max_retries_ = retries; + return *this; +} + +IggyBlockingClient::Builder &IggyBlockingClient::Builder::WithoutReconnectionLimit() { + set_reconnection_max_retries_ = true; + 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(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(duration.count()); + return *this; +} + +IggyBlockingClient::Builder &IggyBlockingClient::Builder::WithTlsEnabled(bool enabled) { + tls_enabled_ = enabled; + return *this; +} + +IggyBlockingClient::Builder &IggyBlockingClient::Builder::WithTlsDomain(std::string domain) { + tls_domain_ = std::move(domain); + return *this; +} + +IggyBlockingClient::Builder &IggyBlockingClient::Builder::WithTlsCaFile(std::string path) { + 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 { + ffi::Client *client = nullptr; + try { + 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.set_reconnection_max_retries = set_reconnection_max_retries_; + 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.has_tls_enabled = tls_enabled_.has_value(); + config.tls_enabled = tls_enabled_.value_or(false); + 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_; + client = ffi::new_connection(std::move(config)); + if (client == nullptr) { + throw IggyException("Could not create Iggy client"); + } + return IggyBlockingClient(client); + } catch (const std::exception &error) { + if (client != nullptr) { + try { + ffi::delete_client(client); + } catch (...) { + } + } + throw IggyException(error.what()); + } +} + +} // namespace iggy diff --git a/foreign/cpp/src/client.rs b/foreign/cpp/src/client.rs index a701812005..0adddbdd0f 100644 --- a/foreign/cpp/src/client.rs +++ b/foreign/cpp/src/client.rs @@ -18,15 +18,17 @@ 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, PartitionClient, Partitioning, - Permissions as RustPermissions, PollingStrategy, SegmentClient, - SnapshotCompression as RustSnapshotCompression, StreamClient, SystemClient as RustSystemClient, - SystemSnapshotType as RustSystemSnapshotType, TopicClient, UserClient, + IggyClientBuilder as RustIggyClientBuilder, IggyDuration as RustIggyDuration, + IggyExpiry as RustIggyExpiry, IggyMessage, IggyTimestamp, MaxTopicSize as RustMaxTopicSize, + MessageClient, PartitionClient, Partitioning, Permissions as RustPermissions, PollingStrategy, + SegmentClient, SnapshotCompression as RustSnapshotCompression, StreamClient, + SystemClient as RustSystemClient, SystemSnapshotType as RustSystemSnapshotType, TopicClient, + UserClient, UserStatus as RustUserStatus, }; +use iggy_common::Credentials as RustCredentials; use std::collections::HashSet; use std::convert::TryFrom; use std::str::FromStr; @@ -73,23 +75,70 @@ 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.as_str() { + "" | "disabled" => {} + "username_password" => { + builder = builder.with_auto_sign_in(RustAutoLogin::Enabled( + RustCredentials::UsernamePassword(config.username, config.password.into()), + )); + } + "personal_access_token" => { + 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()), + } + if config.set_reconnection_max_retries { + 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.has_tls_enabled { + builder = builder.with_tls_enabled(config.tls_enabled); + if 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), @@ -97,13 +146,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 { 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)) }) } @@ -1085,6 +1135,90 @@ impl Client { }) } + pub fn get_user(&self, user_id: ffi::Identifier) -> Result { + 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, 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: u8, + has_permissions: bool, + permissions: ffi::Permissions, + ) -> Result { + let rust_status = RustUserStatus::from_code(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, + username: String, + status: u8, + ) -> 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 = RustUserStatus::from_code(status) + .map_err(|error| format!("Could not update user '{rust_user_id}': {error}"))?; + + RUNTIME.block_on(async { + self.inner + .update_user(&rust_user_id, Some(&username), Some(rust_status)) + .await + .map_err(|error| format!("Could not update user '{rust_user_id}': {error}"))?; + Ok(()) + }) + } + pub fn update_permissions( &self, user_id: ffi::Identifier, diff --git a/foreign/cpp/src/lib.rs b/foreign/cpp/src/lib.rs index 0c871e9dd5..052939d165 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; @@ -316,14 +316,60 @@ mod ffi { streams: Vec, } + struct UserInfo { + id: u32, + created_at: u64, + status: u8, + username: String, + } + + struct UserInfoDetails { + id: u32, + created_at: u64, + status: u8, + username: String, + has_permissions: bool, + permissions: Permissions, + } + + struct LoginInfo { + user_id: u32, + has_access_token: bool, + access_token: String, + access_token_expiry: u64, + } + + struct IggyClientConfig { + server_address: String, + auto_login_kind: String, + username: String, + password: String, + personal_access_token: String, + set_reconnection_max_retries: bool, + 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, + has_tls_enabled: bool, + 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; fn logout_user(self: &Client) -> Result<()>; fn connect(self: &Client) -> Result<()>; fn create_stream(self: &Client, stream_name: String) -> Result; @@ -493,11 +539,23 @@ 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; + fn get_users(self: &Client) -> Result>; + fn create_user( + self: &Client, + username: String, + password: String, + status: u8, + has_permissions: bool, + permissions: Permissions, + ) -> Result; + fn delete_user(self: &Client, user_id: Identifier) -> Result<()>; + fn update_user( + self: &Client, + user_id: Identifier, + username: String, + status: u8, + ) -> Result<()>; fn update_permissions( self: &Client, user_id: Identifier, diff --git a/foreign/cpp/src/type_conversion.rs b/foreign/cpp/src/type_conversion.rs index 8a47f7c402..ebb2266f93 100644 --- a/foreign/cpp/src/type_conversion.rs +++ b/foreign/cpp/src/type_conversion.rs @@ -31,9 +31,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, }; use std::collections::BTreeMap; @@ -125,6 +126,59 @@ impl TryFrom> for ffi::ClientInfoDetails { } } +impl From 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 for ffi::UserInfo { + fn from(user: RustUserInfo) -> Self { + ffi::UserInfo { + id: user.id, + created_at: user.created_at.as_micros(), + status: user.status.as_code(), + username: user.username, + } + } +} + +impl From 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: user.status.as_code(), + username: user.username, + has_permissions, + permissions: ffi::Permissions::from(user.permissions.unwrap_or_default()), + } + } +} + +impl TryFrom> for ffi::UserInfoDetails { + type Error = String; + + fn try_from(user: Option) -> Result { + match user { + Some(user) => Ok(ffi::UserInfoDetails::from(user)), + None => Err("user not found".to_string()), + } + } +} + impl TryFrom> for ffi::ConsumerOffsetInfo { type Error = String; @@ -230,6 +284,73 @@ impl From for ffi::ClusterMetadata { } } +impl From 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 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 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 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 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 64405fe92b..0c1397ad9e 100644 --- a/foreign/cpp/tests/e2e/client.cpp +++ b/foreign/cpp/tests/e2e/client.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -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(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(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,1355 @@ 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", 1, 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", 1); }); + ASSERT_NO_THROW({ fetched_user = client->get_user(make_string_identifier(username)); }); + + EXPECT_EQ(fetched_user.id, created_user.id); + EXPECT_EQ(static_cast(created_user.username), username); + EXPECT_EQ(static_cast(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", 1, false, iggy::ffi::Permissions{}), + std::exception); + ASSERT_NO_THROW(client->connect()); + ASSERT_THROW(client->create_user(before_login_username, "secret123", 1, 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", 1, 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", 1, 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, 1); }); + ASSERT_NO_THROW({ longest_user = CreateUser(root_client, longest_username, longest_password, 1); }); + 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(shortest_user.username), shortest_username); + EXPECT_EQ(static_cast(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, 1, false, iggy::ffi::Permissions{}), + std::exception); + ASSERT_THROW(client->create_user(long_username, long_password, 1, 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 std::uint8_t statuses[] = {0, 3, std::numeric_limits::max()}; + + for (const std::uint8_t status : statuses) { + const std::string username = GetRandomName(50); + SCOPED_TRACE(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", 1); }); + ASSERT_NO_THROW({ fetched_user = client->get_user(make_string_identifier(username)); }); + + EXPECT_EQ(fetched_user.id, created_user.id); + EXPECT_EQ(static_cast(created_user.username), username); + EXPECT_EQ(static_cast(fetched_user.username), username); + EXPECT_EQ(created_user.status, 1u); + EXPECT_EQ(fetched_user.status, 1u); +} + +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, 1); }); + + ASSERT_THROW(root_client->create_user(username, "replacement-secret", 2, 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, 1u); + 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", 1, 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", 0, "server_default", 0, "server_default"); + denied_topic = root_client->create_topic(make_numeric_identifier(denied_stream.id), denied_topic_name, 1, + "none", 0, "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", 1, 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(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", 1, 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", 1, 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", 1, 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", 1, 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", 1, 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", 1, true, std::move(child_permissions)); }); + EXPECT_EQ(static_cast(child.username), child_username); + EXPECT_EQ(child.status, 1u); + 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, 1); }); + 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, 2); }); + ASSERT_NO_THROW({ fetched = root_client->get_user(make_string_identifier(username)); }); + EXPECT_EQ(created.status, 2u); + EXPECT_EQ(fetched.status, 2u); + 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", 1); }); + + iggy::ffi::Client *client = GetLoggedOutClient(); + ASSERT_THROW(client->update_user(make_string_identifier(username), replacement, 2), std::exception); + ASSERT_NO_THROW(client->connect()); + ASSERT_THROW(client->update_user(make_string_identifier(username), replacement, 2), 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), replacement, 2), std::exception); + ASSERT_NO_THROW(client->login_user("iggy", "iggy")); + ASSERT_NO_THROW(client->disconnect()); + ASSERT_THROW(client->update_user(make_string_identifier(username), replacement, 2), 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(fetched.username), username); + EXPECT_EQ(fetched.status, 1u); +} + +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::max(); + + ASSERT_THROW(client->update_user(make_string_identifier(unknown_username), proposed_username, 2), std::exception); + ASSERT_THROW(client->update_user(make_numeric_identifier(unknown_id), GetRandomName(50), 2), 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", 1); }); + ASSERT_NO_THROW(client->update_user(make_string_identifier(username), replacement, 2)); + const auto tracked_user = std::find(tracked_user_names_.begin(), tracked_user_names_.end(), username); + ASSERT_NE(tracked_user, tracked_user_names_.end()); + if (tracked_user != tracked_user_names_.end()) { + *tracked_user = 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(fetched.username), replacement); + EXPECT_EQ(fetched.status, 2u); +} + +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", 2); }); + ASSERT_NO_THROW(client->update_user(make_numeric_identifier(created.id), replacement, 1)); + const auto tracked_user = std::find(tracked_user_names_.begin(), tracked_user_names_.end(), username); + ASSERT_NE(tracked_user, tracked_user_names_.end()); + if (tracked_user != tracked_user_names_.end()) { + *tracked_user = 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(by_id.username), replacement); + EXPECT_EQ(static_cast(by_name.username), replacement); + EXPECT_EQ(by_id.status, 1u); + EXPECT_EQ(by_name.status, 1u); +} + +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); + ASSERT_NE(first_replacement, second_replacement); + iggy::ffi::UserInfoDetails first{}; + iggy::ffi::UserInfoDetails second{}; + ASSERT_NO_THROW({ first = CreateUser(client, first_username, "secret123", 1); }); + ASSERT_NO_THROW({ second = CreateUser(client, second_username, "secret123", 1); }); + ASSERT_NO_THROW(client->update_user(make_string_identifier(first_username), first_replacement, 1)); + const auto first_tracked_user = std::find(tracked_user_names_.begin(), tracked_user_names_.end(), first_username); + ASSERT_NE(first_tracked_user, tracked_user_names_.end()); + if (first_tracked_user != tracked_user_names_.end()) { + *first_tracked_user = first_replacement; + } + ASSERT_NO_THROW(client->update_user(make_string_identifier(second_username), second_replacement, 1)); + const auto second_tracked_user = std::find(tracked_user_names_.begin(), tracked_user_names_.end(), second_username); + ASSERT_NE(second_tracked_user, tracked_user_names_.end()); + if (second_tracked_user != tracked_user_names_.end()) { + *second_tracked_user = 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(fetched_first.username), first_replacement); + EXPECT_EQ(static_cast(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", 1); }); + 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), replacement, 1), 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, 1u); + EXPECT_EQ(static_cast(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", 1); }); + ASSERT_NO_THROW(client->update_user(make_string_identifier(sources[index]), replacements[index], 1)); + const auto tracked_user = std::find(tracked_user_names_.begin(), tracked_user_names_.end(), sources[index]); + ASSERT_NE(tracked_user, tracked_user_names_.end()); + if (tracked_user != tracked_user_names_.end()) { + *tracked_user = 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(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", 1); }); + const std::uint8_t statuses[] = {0, 3, std::numeric_limits::max()}; + for (const auto status : statuses) { + const std::string replacement = GetRandomName(50); + ASSERT_THROW(client->update_user(make_string_identifier(username), replacement, 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, 1u); + EXPECT_EQ(static_cast(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", 1); }); + ASSERT_NO_THROW({ conflict = CreateUser(client, conflict_username, "secret123", 2); }); + ASSERT_THROW(client->update_user(make_string_identifier(target_username), conflict_username, 2), 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, 1u); + EXPECT_EQ(fetched_conflict.status, 2u); +} + +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", 1); }); + ASSERT_NO_THROW(client->update_user(make_string_identifier(username), username, 2)); + 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, 2u); + EXPECT_EQ(static_cast(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", 1, true, std::move(permissions)); }); + ASSERT_NO_THROW(root_client->update_user(make_string_identifier(username), replacement, 1)); + const auto tracked_user = std::find(tracked_user_names_.begin(), tracked_user_names_.end(), username); + ASSERT_NE(tracked_user, tracked_user_names_.end()); + if (tracked_user != tracked_user_names_.end()) { + *tracked_user = 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", 1); }); + ASSERT_NO_THROW(root_client->update_user(make_string_identifier(username), username, 2)); + 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), username, 1)); + 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", 1); }); + ASSERT_NO_THROW(client->update_user(make_string_identifier(old_username), new_username, 1)); + const auto tracked_user = std::find(tracked_user_names_.begin(), tracked_user_names_.end(), old_username); + ASSERT_NE(tracked_user, tracked_user_names_.end()); + if (tracked_user != tracked_user_names_.end()) { + *tracked_user = new_username; + } + iggy::ffi::UserInfoDetails second{}; + ASSERT_NO_THROW({ second = CreateUser(client, old_username, "secret123", 1); }); + EXPECT_EQ(static_cast(client->get_user(make_string_identifier(new_username)).username), new_username); + EXPECT_EQ(static_cast(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", 1); }); + ASSERT_THROW(client->update_user(make_string_identifier(username), "iggy", 2), 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(root.username), "iggy"); + EXPECT_EQ(root.status, 1u); + EXPECT_EQ(fetched.id, target.id); + EXPECT_EQ(fetched.status, 1u); + EXPECT_EQ(static_cast(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", 1, true, std::move(permissions)); }); + iggy::ffi::UserInfoDetails target{}; + ASSERT_NO_THROW({ target = CreateUser(root_client, target_username, "secret123", 1); }); + 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), GetRandomName(50), 2), + std::exception); + ASSERT_THROW(caller_client->update_user(make_string_identifier(caller_username), GetRandomName(50), 2), + 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, 1u); +} + +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", 1, true, std::move(permissions)); }); + iggy::ffi::UserInfoDetails target{}; + ASSERT_NO_THROW({ target = CreateUser(root_client, target_username, "secret123", 1); }); + 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), replacement, 2)); + const auto tracked_user = std::find(tracked_user_names_.begin(), tracked_user_names_.end(), target_username); + ASSERT_NE(tracked_user, tracked_user_names_.end()); + if (tracked_user != tracked_user_names_.end()) { + *tracked_user = 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, 2u); + EXPECT_EQ(static_cast(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(user.username), "iggy"); + EXPECT_EQ(user.status, 1u); +} + +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(by_id.username), static_cast(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::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", 1, 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(by_username.username), username); + EXPECT_EQ(static_cast(by_id.username), username); + EXPECT_EQ(by_username.status, 1u); + EXPECT_EQ(by_id.status, 1u); + 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", 1, 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", 1, 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 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(root.username), "iggy"); + bool found_self = false; + for (const auto &user : users) { + if (static_cast(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", 1, 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 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(root.username), "iggy"); + bool found_self = false; + for (const auto &user : users) { + if (static_cast(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", 1); }); + + rust::Vec 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(user.username), username); + EXPECT_EQ(user.status, 1u); + } + } + 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 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", 1); }); + ASSERT_NO_THROW({ second_user = CreateUser(client, second_username, "secret123", 1); }); + ASSERT_NO_THROW({ third_user = CreateUser(client, third_username, "secret123", 1); }); + + rust::Vec 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(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", 1); }); + + iggy::ffi::UserInfoDetails user_details{}; + rust::Vec 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(user_details.username), username); + EXPECT_EQ(user_details.status, 1u); + bool found_user = false; + for (const auto &user : users) { + if (user.id == user_details.id) { + found_user = true; + EXPECT_EQ(static_cast(user.username), username); + EXPECT_EQ(user.status, 1u); + } + } + 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", 1); }); + 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 users; + ASSERT_NO_THROW({ users = client->get_users(); }); + + for (const auto &user : users) { + EXPECT_FALSE(user.id == created_user.id && static_cast(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", 1); }); + + 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::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(root->username), "iggy"); + EXPECT_EQ(root->status, 1u); + } +} + +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", 1); }); + + 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 users; + ASSERT_NO_THROW({ users = client->get_users(); }); + for (const auto &user : users) { + EXPECT_FALSE(user.id == created_user.id && static_cast(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", 1); }); + + 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 users; + ASSERT_NO_THROW({ users = client->get_users(); }); + for (const auto &user : users) { + EXPECT_FALSE(user.id == created_user.id && static_cast(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", 1, true, std::move(permissions)); }); + ASSERT_NO_THROW({ target = CreateUser(root_client, target_name, "target-secret", 1); }); + 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", 1, true, std::move(permissions)); }); + ASSERT_NO_THROW({ target = CreateUser(root_client, target_name, "target-secret", 1); }); + 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 users; + ASSERT_NO_THROW({ users = root_client->get_users(); }); + for (const auto &user : users) { + EXPECT_FALSE(user.id == target.id && static_cast(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", 1); }); + ASSERT_NO_THROW({ survivor = CreateUser(root_client, survivor_name, "survivor-secret", 1); }); + + 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 users; + ASSERT_NO_THROW({ users = root_client->get_users(); }); + for (const auto &user : users) { + EXPECT_FALSE(user.id == target.id && static_cast(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(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", 2); }); + + 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 users; + ASSERT_NO_THROW({ users = client->get_users(); }); + for (const auto &user : users) { + EXPECT_FALSE(user.id == created_user.id && static_cast(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, 1, 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, 1, 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, 1); }); + EXPECT_EQ(static_cast(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 +1588,55 @@ 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", 1); }); + iggy::ffi::UserInfoDetails target{}; + ASSERT_NO_THROW({ target = CreateUser(root_client, target_name, target_password, 1); }); + 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", 1, true, std::move(permissions)); }); + iggy::ffi::UserInfoDetails target{}; + ASSERT_NO_THROW({ target = CreateUser(root_client, target_name, target_password, 1); }); + 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,7 +1666,7 @@ 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()); @@ -243,7 +1679,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 +1692,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 +1702,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 +1715,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 +1729,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 +1743,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 +1758,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 +1768,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 +1780,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 +1793,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 +1807,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 +1821,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); @@ -487,7 +1923,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); @@ -497,7 +1933,7 @@ 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()); @@ -508,7 +1944,7 @@ TEST_F(LowLevelE2E_Client, ConnectWithoutLoginThenDelete) { 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()); @@ -521,7 +1957,7 @@ 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()); @@ -537,7 +1973,7 @@ TEST_F(LowLevelE2E_Client, RepeatedClientMethodCallsHaveStableBehavior) { 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); @@ -1649,7 +3085,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 de81c9c7b4..a15e1a2506 100644 --- a/foreign/cpp/tests/e2e/test_helpers.hpp +++ b/foreign/cpp/tests/e2e/test_helpers.hpp @@ -118,7 +118,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 +140,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 length_distribution(8, 255); + const std::size_t min_length = std::min(8, max_length); + std::uniform_int_distribution length_distribution(min_length, max_length); std::uniform_int_distribution distribution(0, sizeof(alphabet) - 2); const std::size_t length = length_distribution(generator); @@ -157,6 +162,22 @@ class E2ETestFixture : public ::testing::Test { return name; } + iggy::ffi::UserInfoDetails CreateUser(iggy::ffi::Client *client, + const std::string &username, + const std::string &password, + const std::uint8_t 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 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, @@ -206,6 +227,7 @@ class E2ETestFixture : public ::testing::Test { CleanupConsumerGroups(); CleanupStreams(); CleanupClients(); + CleanupUsers(); } private: @@ -213,6 +235,7 @@ class E2ETestFixture : public ::testing::Test { CleanupConsumerGroupsBestEffort(); CleanupStreamsBestEffort(); CleanupClientsBestEffort(); + CleanupUsersBestEffort(); } void ForgetClient(iggy::ffi::Client *client) { @@ -222,13 +245,61 @@ class E2ETestFixture : public ::testing::Test { } } + void CleanupUsers() { + if (tracked_user_names_.empty()) { + return; + } + + 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 &username : tracked_user_names_) { + EXPECT_NO_THROW(cleanup_client->delete_user(make_string_identifier(username))); + } + EXPECT_NO_THROW(iggy::ffi::delete_client(cleanup_client)); + } + + tracked_user_names_.clear(); + } + + void CleanupUsersBestEffort() noexcept { + if (tracked_user_names_.empty()) { + return; + } + + iggy::ffi::Client *cleanup_client = nullptr; + try { + cleanup_client = iggy::ffi::new_connection({}); + if (cleanup_client != nullptr) { + cleanup_client->connect(); + cleanup_client->login_user("iggy", "iggy"); + for (const auto &username : tracked_user_names_) { + cleanup_client->delete_user(make_string_identifier(username)); + } + } + } catch (...) { + } + + if (cleanup_client != nullptr) { + try { + iggy::ffi::delete_client(cleanup_client); + } catch (...) { + } + } + + tracked_user_names_.clear(); + } + void CleanupStreams() { if (tracked_stream_names_.empty() && tracked_stream_ids_.empty()) { return; } iggy::ffi::Client *cleanup_client = nullptr; - EXPECT_NO_THROW({ cleanup_client = iggy::ffi::new_connection(""); }); + EXPECT_NO_THROW({ cleanup_client = iggy::ffi::new_connection({}); }); EXPECT_NE(cleanup_client, nullptr); if (cleanup_client != nullptr) { EXPECT_NO_THROW(cleanup_client->connect()); @@ -252,7 +323,7 @@ class E2ETestFixture : public ::testing::Test { } iggy::ffi::Client *cleanup_client = nullptr; - EXPECT_NO_THROW({ cleanup_client = iggy::ffi::new_connection(""); }); + EXPECT_NO_THROW({ cleanup_client = iggy::ffi::new_connection({}); }); EXPECT_NE(cleanup_client, nullptr); if (cleanup_client != nullptr) { EXPECT_NO_THROW(cleanup_client->connect()); @@ -275,7 +346,7 @@ class E2ETestFixture : public ::testing::Test { iggy::ffi::Client *cleanup_client = nullptr; try { - cleanup_client = iggy::ffi::new_connection(""); + cleanup_client = iggy::ffi::new_connection({}); if (cleanup_client != nullptr) { cleanup_client->connect(); cleanup_client->login_user("iggy", "iggy"); @@ -307,7 +378,7 @@ class E2ETestFixture : public ::testing::Test { iggy::ffi::Client *cleanup_client = nullptr; try { - cleanup_client = iggy::ffi::new_connection(""); + cleanup_client = iggy::ffi::new_connection({}); if (cleanup_client != nullptr) { cleanup_client->connect(); cleanup_client->login_user("iggy", "iggy"); @@ -351,7 +422,9 @@ class E2ETestFixture : public ::testing::Test { clients_.clear(); } + protected: std::vector clients_; + std::vector tracked_user_names_; std::vector tracked_stream_names_; std::vector tracked_stream_ids_; std::vector tracked_consumer_groups_; diff --git a/foreign/cpp/tests/unit/unit_tests.cpp b/foreign/cpp/tests/unit/unit_tests.cpp index 8eaac4e857..1dfbcba514 100644 --- a/foreign/cpp/tests/unit/unit_tests.cpp +++ b/foreign/cpp/tests/unit/unit_tests.cpp @@ -24,76 +24,77 @@ #include "iggy.hpp" 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::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::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(0)); + const auto server_default = iggy::Expiry::ServerDefault(); + EXPECT_EQ(server_default.ExpiryKind(), "server_default"); + EXPECT_EQ(server_default.ExpiryValue(), static_cast(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::max()); + const auto never_expire = iggy::Expiry::NeverExpire(); + EXPECT_EQ(never_expire.ExpiryKind(), "never_expire"); + EXPECT_EQ(never_expire.ExpiryValue(), std::numeric_limits::max()); - const auto duration = iggy::Expiry::duration(15); - EXPECT_EQ(duration.expiry_kind(), "duration"); - EXPECT_EQ(duration.expiry_value(), static_cast(15)); + const auto duration = iggy::Expiry::Duration(15); + EXPECT_EQ(duration.ExpiryKind(), "duration"); + EXPECT_EQ(duration.ExpiryValue(), static_cast(15)); } TEST(IggyExceptionTest, StoresMessage) {