hubcio commented on code in PR #3733:
URL: https://github.com/apache/iggy/pull/3733#discussion_r3796053268
##########
foreign/cpp/include/iggy.hpp:
##########
@@ -370,4 +449,441 @@ class TopicOption final {
TopicOption() = delete;
};
+/**
+ * @brief Exception thrown when an Iggy client operation fails.
+ */
+class IggyException : public std::runtime_error {
+ public:
+ explicit IggyException(const char *message) : std::runtime_error(message)
{}
+ explicit IggyException(const std::string &message) :
std::runtime_error(message) {}
+};
+
+/**
+ * @brief Owning client connection to an Apache Iggy server.
+ *
+ * Create instances with Builder or FromConnectionString(). The client owns a
+ * handle to the underlying Rust client. Destroying the C++ object releases
that
+ * handle, but does not stop heartbeat processing started by Connect().
+ *
+ * Builder initializes a TCP client. To use QUIC, HTTP, or WebSocket, create
the
+ * client with FromConnectionString().
+ *
+ * @code{.cpp}
+ * auto client = iggy::IggyBlockingClient::Builder()
+ * .WithServerAddress("127.0.0.1:8090")
+ * .Build();
+ * client.Connect();
+ * client.Login("iggy", "iggy");
+ * client.Shutdown();
+ * @endcode
+ */
+class IggyBlockingClient final {
Review Comment:
as it stands the class can't do any actual work - the only operations are
connect/login/logout, `client_` is private with no accessor, so no stream,
topic, message or user call is reachable. it's also never instantiated by the
test suite: all three builder tests throw inside `Build()` before construction,
so the move ctor, move assign, destructor and all five methods have zero
executed coverage. either add a handle accessor plus lifetime coverage, or keep
the type out of the public header until it wraps real operations.
##########
foreign/cpp/include/iggy.hpp:
##########
@@ -370,4 +449,441 @@ class TopicOption final {
TopicOption() = delete;
};
+/**
+ * @brief Exception thrown when an Iggy client operation fails.
+ */
+class IggyException : public std::runtime_error {
+ public:
+ explicit IggyException(const char *message) : std::runtime_error(message)
{}
+ explicit IggyException(const std::string &message) :
std::runtime_error(message) {}
+};
+
+/**
+ * @brief Owning client connection to an Apache Iggy server.
+ *
+ * Create instances with Builder or FromConnectionString(). The client owns a
+ * handle to the underlying Rust client. Destroying the C++ object releases
that
+ * handle, but does not stop heartbeat processing started by Connect().
+ *
+ * Builder initializes a TCP client. To use QUIC, HTTP, or WebSocket, create
the
+ * client with FromConnectionString().
+ *
+ * @code{.cpp}
+ * auto client = iggy::IggyBlockingClient::Builder()
+ * .WithServerAddress("127.0.0.1:8090")
+ * .Build();
+ * client.Connect();
+ * client.Login("iggy", "iggy");
+ * client.Shutdown();
+ * @endcode
+ */
+class IggyBlockingClient final {
+ public:
+ class Builder;
+
+ /** @brief IggyBlockingClient is move-only. */
+ IggyBlockingClient(const IggyBlockingClient &) = delete;
+ IggyBlockingClient &operator=(const IggyBlockingClient &) = delete;
+
+ /**
+ * @brief Transfers ownership of a client.
+ * @param other Client whose connection ownership is transferred.
+ *
+ * The moved-from client may be destroyed or assigned a new value, but must
+ * not be used for client operations.
+ */
+ IggyBlockingClient(IggyBlockingClient &&other) noexcept;
+
+ /**
+ * @brief Replaces this client by taking ownership from another client.
+ * @param other Client whose connection ownership is transferred.
+ * @return Reference to this client.
+ *
+ * Any Rust client handle currently owned by this object is released first.
+ * Call Shutdown() before replacing a connected client. The moved-from
+ * client must not be used for client operations.
+ */
+ IggyBlockingClient &operator=(IggyBlockingClient &&other) noexcept;
+
+ /**
+ * @brief Releases the handle to the underlying Rust client.
+ *
+ * Destruction does not stop the heartbeat task started by Connect(). For
Review Comment:
also at lines 466, 614-615 and 632-634.
these lifecycle notes don't match the sdk: dropping the client aborts the
heartbeat task (`impl Drop for IggyClient` in core/sdk/src/clients/client.rs),
and `connect()` early-returns when a live handle exists, so repeated
`Connect()` doesn't stack tasks and disconnect/reconnect cycles are fine - the
suite's own `DisconnectThenReconnectWithoutRelogin` exercises exactly that
cycle. the only true part is that `Disconnect()` keeps the existing task
pinging, which with auto-login configured can silently reconnect - that's the
note worth keeping.
##########
foreign/cpp/src/lib.rs:
##########
@@ -372,14 +372,58 @@ mod ffi {
streams: Vec<StreamPermissionEntry>,
}
+ 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,
Review Comment:
stringly-typed discriminant with two spellings for one state (`""` from
aggregate init, `"disabled"` from the builder). a shared `#[repr(u8)] enum
AutoLoginKind { Disabled = 0, UsernamePassword, PersonalAccessToken }`
collapses that, drops three heap strings from the config, and gives C++ named
constants; `Disabled = 0` keeps zero-init working.
##########
foreign/cpp/src/client.cpp:
##########
@@ -0,0 +1,216 @@
+/*
+ * 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() {
+ try {
+ client_->connect();
Review Comment:
all five methods deref `client_` unguarded; after a move it's nullptr and
cxx passes it as a rust `&Client`, which is UB rather than a throw - and
callers have no way to check (no `operator bool`, no accessor). a private
`Handle()` that throws when null and routes all five turns use-after-move into
a clean `IggyException`.
##########
foreign/cpp/tests/e2e/client.cpp:
##########
@@ -146,6 +184,1387 @@ 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<std::string>(created_user.username), username);
+ EXPECT_EQ(static_cast<std::string>(fetched_user.username), username);
+ }
+}
+
+TEST_F(LowLevelE2E_Client, CreateUserBeforeLoginThrows) {
+ RecordProperty("description", "Rejects user creation without an active
authenticated session.");
+ iggy::ffi::Client *client = GetLoggedOutClient();
+ iggy::ffi::Client *root = GetLoggedInClient();
+ const std::string before_login_username = GetRandomName(50);
+ const std::string logged_out_username = GetRandomName(50);
+ const std::string disconnected_username = GetRandomName(50);
+
+ ASSERT_THROW(client->create_user(before_login_username, "secret123", 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<std::string>(shortest_user.username),
shortest_username);
+ EXPECT_EQ(static_cast<std::string>(longest_user.username),
longest_username);
+ EXPECT_EQ(fetched_shortest.id, shortest_user.id);
+ EXPECT_EQ(fetched_longest.id, longest_user.id);
+}
+
+TEST_F(LowLevelE2E_Client, CreateUserWithPasswordOutsideLengthBoundsThrows) {
+ RecordProperty("description", "Rejects 2-byte and 101-byte passwords
without creating users.");
+ iggy::ffi::Client *client = GetLoggedInClient();
+ const std::string short_username = GetRandomName(50);
+ const std::string long_username = GetRandomName(50);
+ const std::string short_password(2, 'a');
+ const std::string long_password(101, 'a');
+ ASSERT_EQ(short_password.size(), 2u);
+ ASSERT_EQ(long_password.size(), 101u);
+
+ ASSERT_THROW(client->create_user(short_username, short_password, 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<std::uint8_t>::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<std::string>(created_user.username), username);
+ EXPECT_EQ(static_cast<std::string>(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", "server_default", 0,
"server_default", {});
+ denied_topic =
root_client->create_topic(make_numeric_identifier(denied_stream.id),
denied_topic_name, 1,
+ "none", "server_default", 0,
"server_default", {});
+ });
+
+ iggy::ffi::Permissions permissions{};
+ iggy::ffi::StreamPermissionEntry stream_permissions{};
+ stream_permissions.stream_id = allowed_stream.id;
+ iggy::ffi::TopicPermissionEntry topic_permissions{};
+ topic_permissions.topic_id = allowed_topic.id;
+ topic_permissions.permissions.read_topic = true;
+
stream_permissions.permissions.topics.push_back(std::move(topic_permissions));
+ permissions.streams.push_back(std::move(stream_permissions));
+ ASSERT_NO_THROW({ CreateUser(root_client, username, "secret123", 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<std::string>(fetched_topic.name),
allowed_topic_name);
+ ASSERT_THROW(
+ user_client->get_topic(make_numeric_identifier(denied_stream.id),
make_numeric_identifier(denied_topic.id)),
+ std::exception);
+}
+
+TEST_F(LowLevelE2E_Client,
CreateUserRejectsDuplicateStreamPermissionIdsWithoutCreatingUser) {
+ RecordProperty("description",
+ "Attempts to create a user with two permission entries for
stream ID 42, then verifies creation "
+ "fails and no user is stored.");
+ iggy::ffi::Client *client = GetLoggedInClient();
+ const std::string username = GetRandomName(50);
+ iggy::ffi::Permissions permissions{};
+ iggy::ffi::StreamPermissionEntry first_stream{};
+ iggy::ffi::StreamPermissionEntry second_stream{};
+ first_stream.stream_id = 42;
+ second_stream.stream_id = 42;
+ permissions.streams.push_back(std::move(first_stream));
+ permissions.streams.push_back(std::move(second_stream));
+
+ ASSERT_THROW(client->create_user(username, "secret123", 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<std::string>(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), true,
replacement, true, 2), std::exception);
+ ASSERT_NO_THROW(client->connect());
+ ASSERT_THROW(client->update_user(make_string_identifier(username), true,
replacement, true, 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), true,
replacement, true, 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), true,
replacement, true, 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<std::string>(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<std::uint32_t>::max();
+
+ ASSERT_THROW(client->update_user(make_string_identifier(unknown_username),
true, proposed_username, true, 2),
+ std::exception);
+ ASSERT_THROW(client->update_user(make_numeric_identifier(unknown_id),
true, GetRandomName(50), true, 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),
true, replacement, true, 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<std::string>(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),
true, replacement, true, 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<std::string>(by_id.username), replacement);
+ EXPECT_EQ(static_cast<std::string>(by_name.username), replacement);
+ EXPECT_EQ(by_id.status, 1u);
+ EXPECT_EQ(by_name.status, 1u);
+}
+
+TEST_F(LowLevelE2E_Client,
UpdateUserAllowsUsernameAndStatusToBeUpdatedIndependently) {
+ RecordProperty("description", "Updates either username or status without
changing the other field.");
+ iggy::ffi::Client *client = GetLoggedInClient();
+ const std::string username = GetRandomName(50);
+ const std::string replacement = GetRandomName(50);
+ iggy::ffi::UserInfoDetails created{};
+ ASSERT_NO_THROW({ created = CreateUser(client, username, "secret123", 1);
});
+
+ ASSERT_NO_THROW(client->update_user(make_numeric_identifier(created.id),
false, "", true, 2));
+ iggy::ffi::UserInfoDetails status_updated{};
+ ASSERT_NO_THROW({ status_updated =
client->get_user(make_numeric_identifier(created.id)); });
+ EXPECT_EQ(static_cast<std::string>(status_updated.username), username);
+ EXPECT_EQ(status_updated.status, 2u);
+
+ ASSERT_NO_THROW(client->update_user(make_numeric_identifier(created.id),
true, replacement, false, 0));
+ 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 username_updated{};
+ ASSERT_NO_THROW({ username_updated =
client->get_user(make_numeric_identifier(created.id)); });
+ EXPECT_EQ(static_cast<std::string>(username_updated.username),
replacement);
+ EXPECT_EQ(username_updated.status, 2u);
+}
+
+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);
Review Comment:
`resize(50)` pads with `\0`, so this tests a random 8..50 char name plus nul
bytes instead of the 50-byte boundary the test name claims - only 1 in 43 draws
is a clean 50. `resize(50, 'a')` plus `ASSERT_EQ(second_replacement.size(),
50u)`, like line 266 does. the `ASSERT_NE` on the next line compares a 3-byte
vs 50-byte string, always true, can be dropped.
##########
foreign/cpp/include/iggy.hpp:
##########
@@ -370,4 +449,441 @@ class TopicOption final {
TopicOption() = delete;
Review Comment:
the PascalCase rename skipped this class - `segment_size`, `enforce_fsync`,
`messages_required_to_save`, `size_of_messages_required_to_save` and
`preallocate_segments` are still snake_case while every other option type in
the header moved, so the public header ships two naming conventions for the
same kind of type.
##########
foreign/cpp/src/client.cpp:
##########
@@ -0,0 +1,216 @@
+/*
+ * 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() {
+ try {
+ client_->connect();
+ } catch (const std::exception &error) {
+ throw IggyException(error.what());
+ }
+}
+
+void IggyBlockingClient::Disconnect() {
+ try {
+ client_->disconnect();
+ } catch (const std::exception &error) {
+ throw IggyException(error.what());
+ }
+}
+
+void IggyBlockingClient::Shutdown() {
+ try {
+ client_->shutdown();
+ } catch (const std::exception &error) {
+ throw IggyException(error.what());
+ }
+}
+
+LoginInfo IggyBlockingClient::Login(std::string username, std::string
password) {
+ try {
+ return client_->login_user(std::move(username), std::move(password));
+ } catch (const std::exception &error) {
+ throw IggyException(error.what());
+ }
+}
+
+void IggyBlockingClient::Logout() {
+ 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 {
Review Comment:
`delete_connection` can only return `Ok(())`, and a rust panic aborts
instead of throwing (cxx wraps every extern "Rust" shim in `prevent_unwind`),
so this catch and the five like it in the tests are dead. changing the bridge
fn to return unit instead of `Result<()>` removes them all and is
source-compatible for callers.
##########
foreign/cpp/src/client.cpp:
##########
@@ -0,0 +1,216 @@
+/*
+ * 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) {
Review Comment:
same five-line catch/rethrow block appears 7 times in this file. a small
rethrow helper would collapse them, though seven explicit blocks arguably read
fine - take it or leave it. worth doing only if combined with the null-guarding
`Handle()` so one wrapper does both.
##########
foreign/cpp/src/client.rs:
##########
@@ -75,37 +77,86 @@ 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() {
Review Comment:
gating on `is_empty()` makes empty strings silently mean unset, which
contradicts the documented contract: `WithServerAddress("").Build()` quietly
falls back to 127.0.0.1:8090 even though the doxygen says build throws on an
invalid address (an unset env var ends up talking to localhost instead of
failing loudly), `WithTlsDomain("")` / `WithTlsCaFile("")` slip past the
tls-requires-tls check below, and `WithPersonalAccessToken("")` arms auto-login
with credentials that can only fail later at `Connect()`. rejecting empty
explicitly (or has_* flags like the other optionals) closes all three.
##########
foreign/cpp/src/lib.rs:
##########
@@ -372,14 +372,58 @@ mod ffi {
streams: Vec<StreamPermissionEntry>,
}
+ struct UserInfo {
+ id: u32,
+ created_at: u64,
+ status: u8,
Review Comment:
also on `UserInfoDetails` and the `create_user`/`update_user` params.
`status: u8` means magic `1`/`2` at every call site (the e2e file has over a
hundred of them), and C++ is the only sdk without a named type - java, C#, go,
python and node all expose a `UserStatus` enum. a shared `#[repr(u8)] enum
UserStatus { Active = 1, Inactive = 2 }` in the bridge gives C++ named
constants exactly like the existing `HeaderKind` pattern: the field stays `u8`,
no rust match change, no wire change.
##########
foreign/cpp/src/client.cpp:
##########
@@ -0,0 +1,216 @@
+/*
+ * 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() {
+ try {
+ client_->connect();
+ } catch (const std::exception &error) {
+ throw IggyException(error.what());
+ }
+}
+
+void IggyBlockingClient::Disconnect() {
+ try {
+ client_->disconnect();
+ } catch (const std::exception &error) {
+ throw IggyException(error.what());
+ }
+}
+
+void IggyBlockingClient::Shutdown() {
+ try {
+ client_->shutdown();
+ } catch (const std::exception &error) {
+ throw IggyException(error.what());
+ }
+}
+
+LoginInfo IggyBlockingClient::Login(std::string username, std::string
password) {
+ try {
+ return client_->login_user(std::move(username), std::move(password));
+ } catch (const std::exception &error) {
+ throw IggyException(error.what());
+ }
+}
+
+void IggyBlockingClient::Logout() {
+ 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)
{
+ reconnection_max_retries_ = retries;
+ return *this;
+}
+
+IggyBlockingClient::Builder
&IggyBlockingClient::Builder::WithoutReconnectionLimit() {
+ reconnection_max_retries_.reset();
+ return *this;
+}
+
+IggyBlockingClient::Builder
&IggyBlockingClient::Builder::WithReconnectionInterval(std::chrono::microseconds
interval) {
+ if (interval.count() < 0) {
+ throw IggyException("Reconnection interval cannot be negative");
+ }
+ reconnection_interval_micros_ =
static_cast<std::uint64_t>(interval.count());
+ return *this;
+}
+
+IggyBlockingClient::Builder
&IggyBlockingClient::Builder::WithReestablishAfter(std::chrono::microseconds
duration) {
+ if (duration.count() < 0) {
+ throw IggyException("Reestablish duration cannot be negative");
+ }
+ reestablish_after_micros_ = static_cast<std::uint64_t>(duration.count());
+ return *this;
+}
+
+IggyBlockingClient::Builder &IggyBlockingClient::Builder::WithTlsEnabled(bool
enabled) {
+ tls_enabled_ = enabled;
+ return *this;
+}
+
+IggyBlockingClient::Builder
&IggyBlockingClient::Builder::WithTlsDomain(std::string domain) {
+ 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.has_reconnection_max_retries =
reconnection_max_retries_.has_value();
+ config.reconnection_max_retries =
reconnection_max_retries_.value_or(0);
+ config.has_reconnection_interval =
reconnection_interval_micros_.has_value();
+ config.reconnection_interval_micros =
reconnection_interval_micros_.value_or(0);
+ config.has_reestablish_after =
reestablish_after_micros_.has_value();
+ config.reestablish_after_micros =
reestablish_after_micros_.value_or(0);
+ config.tls_enabled = tls_enabled_;
+ config.tls_domain = tls_domain_;
+ config.tls_ca_file = tls_ca_file_;
+ config.has_tls_validate_certificate =
tls_validate_certificate_.has_value();
+ config.tls_validate_certificate =
tls_validate_certificate_.value_or(false);
+ config.no_delay = no_delay_;
+ client =
ffi::new_connection(std::move(config));
+ if (client == nullptr) {
Review Comment:
`new_connection` either throws or returns a non-null pointer
(`Box::into_raw`), the move ctor is noexcept, and the private ctor already
throws this exact message - so the null check and the catch-block rollback are
unreachable. `return
IggyBlockingClient(ffi::new_connection(std::move(config)));` inside the try,
mirroring `FromConnectionString`, saves ~10 lines and the duplicated literal.
##########
foreign/cpp/tests/e2e/test_helpers.hpp:
##########
@@ -222,13 +245,61 @@ class E2ETestFixture : public ::testing::Test {
}
}
+ void CleanupUsers() {
Review Comment:
these two are the 5th and 6th copy of the same connect-as-root-and-loop body
(streams and consumer groups each have a normal + best-effort pair too). a
`RunAsRoot(fn)` plus a noexcept best-effort variant collapses all six to
one-liners, ~70 lines less, and one root login per teardown instead of up to
three - each login is an argon2 hash server-side, so it adds up across the
suite. one constraint: keep the consumer-groups -> streams -> users order.
##########
foreign/cpp/tests/e2e/client.cpp:
##########
@@ -146,6 +184,1387 @@ 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<std::string>(created_user.username), username);
+ EXPECT_EQ(static_cast<std::string>(fetched_user.username), username);
+ }
+}
+
+TEST_F(LowLevelE2E_Client, CreateUserBeforeLoginThrows) {
+ RecordProperty("description", "Rejects user creation without an active
authenticated session.");
+ iggy::ffi::Client *client = GetLoggedOutClient();
+ iggy::ffi::Client *root = GetLoggedInClient();
+ const std::string before_login_username = GetRandomName(50);
+ const std::string logged_out_username = GetRandomName(50);
+ const std::string disconnected_username = GetRandomName(50);
+
+ ASSERT_THROW(client->create_user(before_login_username, "secret123", 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<std::string>(shortest_user.username),
shortest_username);
+ EXPECT_EQ(static_cast<std::string>(longest_user.username),
longest_username);
+ EXPECT_EQ(fetched_shortest.id, shortest_user.id);
+ EXPECT_EQ(fetched_longest.id, longest_user.id);
+}
+
+TEST_F(LowLevelE2E_Client, CreateUserWithPasswordOutsideLengthBoundsThrows) {
+ RecordProperty("description", "Rejects 2-byte and 101-byte passwords
without creating users.");
+ iggy::ffi::Client *client = GetLoggedInClient();
+ const std::string short_username = GetRandomName(50);
+ const std::string long_username = GetRandomName(50);
+ const std::string short_password(2, 'a');
+ const std::string long_password(101, 'a');
+ ASSERT_EQ(short_password.size(), 2u);
+ ASSERT_EQ(long_password.size(), 101u);
+
+ ASSERT_THROW(client->create_user(short_username, short_password, 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<std::uint8_t>::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<std::string>(created_user.username), username);
+ EXPECT_EQ(static_cast<std::string>(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", "server_default", 0,
"server_default", {});
+ denied_topic =
root_client->create_topic(make_numeric_identifier(denied_stream.id),
denied_topic_name, 1,
+ "none", "server_default", 0,
"server_default", {});
+ });
+
+ iggy::ffi::Permissions permissions{};
+ iggy::ffi::StreamPermissionEntry stream_permissions{};
+ stream_permissions.stream_id = allowed_stream.id;
+ iggy::ffi::TopicPermissionEntry topic_permissions{};
+ topic_permissions.topic_id = allowed_topic.id;
+ topic_permissions.permissions.read_topic = true;
+
stream_permissions.permissions.topics.push_back(std::move(topic_permissions));
+ permissions.streams.push_back(std::move(stream_permissions));
+ ASSERT_NO_THROW({ CreateUser(root_client, username, "secret123", 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<std::string>(fetched_topic.name),
allowed_topic_name);
+ ASSERT_THROW(
+ user_client->get_topic(make_numeric_identifier(denied_stream.id),
make_numeric_identifier(denied_topic.id)),
+ std::exception);
+}
+
+TEST_F(LowLevelE2E_Client,
CreateUserRejectsDuplicateStreamPermissionIdsWithoutCreatingUser) {
+ RecordProperty("description",
+ "Attempts to create a user with two permission entries for
stream ID 42, then verifies creation "
+ "fails and no user is stored.");
+ iggy::ffi::Client *client = GetLoggedInClient();
+ const std::string username = GetRandomName(50);
+ iggy::ffi::Permissions permissions{};
+ iggy::ffi::StreamPermissionEntry first_stream{};
+ iggy::ffi::StreamPermissionEntry second_stream{};
+ first_stream.stream_id = 42;
+ second_stream.stream_id = 42;
+ permissions.streams.push_back(std::move(first_stream));
+ permissions.streams.push_back(std::move(second_stream));
+
+ ASSERT_THROW(client->create_user(username, "secret123", 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<std::string>(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), true,
replacement, true, 2), std::exception);
+ ASSERT_NO_THROW(client->connect());
+ ASSERT_THROW(client->update_user(make_string_identifier(username), true,
replacement, true, 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), true,
replacement, true, 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), true,
replacement, true, 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<std::string>(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<std::uint32_t>::max();
+
+ ASSERT_THROW(client->update_user(make_string_identifier(unknown_username),
true, proposed_username, true, 2),
+ std::exception);
+ ASSERT_THROW(client->update_user(make_numeric_identifier(unknown_id),
true, GetRandomName(50), true, 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),
true, replacement, true, 2));
+ const auto tracked_user = std::find(tracked_user_names_.begin(),
tracked_user_names_.end(), username);
+ ASSERT_NE(tracked_user, tracked_user_names_.end());
Review Comment:
this find/assert/if/assign block repeats 9 times (also at 716, 749, 775,
781, 830, 911, 951, 1019), and the trailing `if` is dead in each - `ASSERT_NE`
already returns on failure. a `RenameTrackedUser(from, to)` helper next to
`ForgetUser` saves ~40 lines and lets the fixture members stay private (the
other four members widened to protected aren't touched by any test).
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]