blob: a88def7225913c9ff8fa85aad15b43ebdf56e39e [file]
/*
* 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.
*/
// TODO(slbotbm): Add tests for store_consumer_offset, get_consumer_offset, and delete_consumer_offset functions
// attached to client after implementing consumer group functions
// TODO(slbotbm): Add tests for update_permissions after creating create_user, get_user, etc. functions
#include <algorithm>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <string>
#include <thread>
#include <unordered_set>
#include <gtest/gtest.h>
#include "lib.rs.h"
#include "tests/e2e/test_helpers.hpp"
class LowLevelE2E_Client : public E2ETestFixture {};
TEST_F(LowLevelE2E_Client, ConnectAndLogin) {
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",
"",
};
for (const std::string &connection_string : connection_strings) {
SCOPED_TRACE(connection_string);
iggy::ffi::Client *client = nullptr;
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({ login_info = client->login_user(username, password); });
ASSERT_NO_THROW({ me = client->get_me(); });
EXPECT_EQ(login_info.user_id, root_user_id);
EXPECT_TRUE(me.has_user_id);
EXPECT_EQ(login_info.user_id, me.user_id);
EXPECT_FALSE(login_info.has_access_token);
EXPECT_TRUE(static_cast<std::string>(login_info.access_token).empty());
EXPECT_EQ(login_info.access_token_expiry, 0u);
}
}
TEST_F(LowLevelE2E_Client, HttpLoginReturnsAccessToken) {
RecordProperty("description", "Returns root user information and an access token after HTTP login.");
constexpr std::uint32_t root_user_id = 0;
iggy::ffi::Client *client = nullptr;
ASSERT_NO_THROW({ client = iggy::ffi::from_connection_string("iggy+http://iggy:iggy@127.0.0.1:3000"); });
ASSERT_NE(client, nullptr);
TrackClient(client);
iggy::ffi::LoginInfo login_info{};
ASSERT_NO_THROW(client->connect());
ASSERT_NO_THROW({ login_info = client->login_user("iggy", "iggy"); });
EXPECT_EQ(login_info.user_id, root_user_id);
EXPECT_TRUE(login_info.has_access_token);
EXPECT_FALSE(static_cast<std::string>(login_info.access_token).empty());
EXPECT_NE(login_info.access_token_expiry, 0u);
}
TEST_F(LowLevelE2E_Client, NewConnectionWithMalformedConnectionStringsThrow) {
RecordProperty("description", "Rejects malformed connection strings when creating a new client connection.");
const std::string malformed_connection_strings[] = {
"iggy+invalid://iggy:iggy@127.0.0.1:8090", "iggy+tcp://iggy:iggy@:8090", "iggy+tcp://iggy:iggy@127.0.0.1",
"iggy+tcp://iggy:iggy@127.0.0.1:abc", "iggy+tcp://:iggy@127.0.0.1:8090", "iggy+tcp://iggy:@127.0.0.1:8090",
"iggy+tcp://iggy:iggy127.0.0.1:8090", "not-a-connection-string", "iggy://iggy:iggy@",
};
for (const std::string &connection_string : malformed_connection_strings) {
SCOPED_TRACE(connection_string);
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_NE(client, nullptr);
TrackClient(client);
ASSERT_NO_THROW(client->connect());
ASSERT_THROW(client->login_user("biggy", "biggy"), std::exception);
}
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_NE(client, nullptr);
TrackClient(client);
ASSERT_NO_THROW(client->connect());
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_THROW(client->login_user("biggy", "biggy"), std::exception);
}
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_NE(client, nullptr);
TrackClient(client);
ASSERT_THROW(client->logout_user(), std::exception);
ASSERT_NO_THROW(client->connect());
ASSERT_THROW(client->logout_user(), std::exception);
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->logout_user());
}
TEST_F(LowLevelE2E_Client, ReloginOnSameClientAfterLogout) {
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_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({ first_login = client->login_user("iggy", "iggy"); });
ASSERT_NO_THROW({ first_me = client->get_me(); });
ASSERT_NO_THROW(client->logout_user());
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);
}
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_NE(client, nullptr);
TrackClient(client);
ASSERT_NO_THROW(client->connect());
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->logout_user());
ASSERT_THROW(client->logout_user(), std::exception);
}
TEST_F(LowLevelE2E_Client, CreateUserWithUsernameOutsideLengthBoundsThrows) {
RecordProperty("description", "Rejects 2-byte and 51-byte usernames over TCP without creating users.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string too_short_username(2, 'a');
const std::string too_long_username(51, 'a');
const std::string usernames[] = {too_short_username, too_long_username};
ASSERT_EQ(too_short_username.size(), 2u);
ASSERT_EQ(too_long_username.size(), 51u);
for (const auto &username : usernames) {
SCOPED_TRACE(username.size());
ASSERT_THROW(
client->create_user(username, "secret123", iggy::ffi::UserStatus::Active, false, iggy::ffi::Permissions{}),
std::exception);
ASSERT_THROW(client->get_user(make_string_identifier(username)), std::exception);
}
}
TEST_F(LowLevelE2E_Client, CreateUserAcceptsNonAsciiAndNonAlphabeticUsernames) {
RecordProperty("description",
"Creates and retrieves usernames containing punctuation, multilingual UTF-8, and emoji over TCP.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string suffix = GetRandomName(12);
const std::string usernames[] = {
"!@#_" + suffix, "ユーザー_" + suffix, "用户_" + suffix, "नाम_" + suffix, "사용자_" + suffix, "😀🚀_" + suffix,
};
for (const auto &username : usernames) {
SCOPED_TRACE(username);
ASSERT_LE(username.size(), 50u);
iggy::ffi::UserInfoDetails created_user{};
iggy::ffi::UserInfoDetails fetched_user{};
ASSERT_NO_THROW({ created_user = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW({ fetched_user = client->get_user(make_string_identifier(username)); });
EXPECT_EQ(fetched_user.id, created_user.id);
EXPECT_EQ(static_cast<std::string>(created_user.username), username);
EXPECT_EQ(static_cast<std::string>(fetched_user.username), username);
}
}
TEST_F(LowLevelE2E_Client, CreateUserBeforeLoginThrows) {
RecordProperty("description", "Rejects user creation without an active authenticated session.");
iggy::ffi::Client *client = GetLoggedOutClient();
iggy::ffi::Client *root = GetLoggedInClient();
const std::string before_login_username = GetRandomName(50);
const std::string logged_out_username = GetRandomName(50);
const std::string disconnected_username = GetRandomName(50);
ASSERT_THROW(client->create_user(before_login_username, "secret123", iggy::ffi::UserStatus::Active, false,
iggy::ffi::Permissions{}),
std::exception);
ASSERT_NO_THROW(client->connect());
ASSERT_THROW(client->create_user(before_login_username, "secret123", iggy::ffi::UserStatus::Active, false,
iggy::ffi::Permissions{}),
std::exception);
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->logout_user());
ASSERT_THROW(client->create_user(logged_out_username, "secret123", iggy::ffi::UserStatus::Active, false,
iggy::ffi::Permissions{}),
std::exception);
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->disconnect());
ASSERT_THROW(client->create_user(disconnected_username, "secret123", iggy::ffi::UserStatus::Active, false,
iggy::ffi::Permissions{}),
std::exception);
ASSERT_THROW(root->get_user(make_string_identifier(before_login_username)), std::exception);
ASSERT_THROW(root->get_user(make_string_identifier(logged_out_username)), std::exception);
ASSERT_THROW(root->get_user(make_string_identifier(disconnected_username)), std::exception);
}
TEST_F(LowLevelE2E_Client, CreateUserAcceptsUsernameAndPasswordLengthBounds) {
RecordProperty("description",
"Creates users with shortest and longest ASCII usernames and passwords that can authenticate.");
iggy::ffi::Client *root_client = GetLoggedInClient();
iggy::ffi::Client *shortest_client = GetLoggedOutClient();
iggy::ffi::Client *longest_client = GetLoggedOutClient();
std::string shortest_username = GetRandomName(3);
std::string longest_username = GetRandomName(50);
const std::string shortest_password(3, 'a');
const std::string longest_password(100, 'a');
longest_username.resize(50, 'a');
ASSERT_EQ(shortest_username.size(), 3u);
ASSERT_EQ(longest_username.size(), 50u);
ASSERT_EQ(shortest_password.size(), 3u);
ASSERT_EQ(longest_password.size(), 100u);
iggy::ffi::UserInfoDetails shortest_user{};
iggy::ffi::UserInfoDetails longest_user{};
iggy::ffi::UserInfoDetails fetched_shortest{};
iggy::ffi::UserInfoDetails fetched_longest{};
ASSERT_NO_THROW({
shortest_user = CreateUser(root_client, shortest_username, shortest_password, iggy::ffi::UserStatus::Active);
});
ASSERT_NO_THROW(
{ longest_user = CreateUser(root_client, longest_username, longest_password, iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW({ fetched_shortest = root_client->get_user(make_string_identifier(shortest_username)); });
ASSERT_NO_THROW({ fetched_longest = root_client->get_user(make_string_identifier(longest_username)); });
ASSERT_NO_THROW(shortest_client->connect());
ASSERT_NO_THROW(longest_client->connect());
ASSERT_NO_THROW(shortest_client->login_user(shortest_username, shortest_password));
ASSERT_NO_THROW(longest_client->login_user(longest_username, longest_password));
EXPECT_EQ(static_cast<std::string>(shortest_user.username), shortest_username);
EXPECT_EQ(static_cast<std::string>(longest_user.username), longest_username);
EXPECT_EQ(fetched_shortest.id, shortest_user.id);
EXPECT_EQ(fetched_longest.id, longest_user.id);
}
TEST_F(LowLevelE2E_Client, CreateUserWithPasswordOutsideLengthBoundsThrows) {
RecordProperty("description", "Rejects 2-byte and 101-byte passwords without creating users.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string short_username = GetRandomName(50);
const std::string long_username = GetRandomName(50);
const std::string short_password(2, 'a');
const std::string long_password(101, 'a');
ASSERT_EQ(short_password.size(), 2u);
ASSERT_EQ(long_password.size(), 101u);
ASSERT_THROW(client->create_user(short_username, short_password, iggy::ffi::UserStatus::Active, false,
iggy::ffi::Permissions{}),
std::exception);
ASSERT_THROW(client->create_user(long_username, long_password, iggy::ffi::UserStatus::Active, false,
iggy::ffi::Permissions{}),
std::exception);
ASSERT_THROW(client->get_user(make_string_identifier(short_username)), std::exception);
ASSERT_THROW(client->get_user(make_string_identifier(long_username)), std::exception);
}
TEST_F(LowLevelE2E_Client, CreateUserWithInvalidStatusThrows) {
RecordProperty("description", "Rejects invalid status codes before creating users.");
iggy::ffi::Client *client = GetLoggedInClient();
const iggy::ffi::UserStatus statuses[] = {
static_cast<iggy::ffi::UserStatus>(0),
static_cast<iggy::ffi::UserStatus>(3),
static_cast<iggy::ffi::UserStatus>(std::numeric_limits<std::uint8_t>::max()),
};
for (const auto status : statuses) {
const std::string username = GetRandomName(50);
SCOPED_TRACE(static_cast<std::uint8_t>(status));
ASSERT_THROW(client->create_user(username, "secret123", status, false, iggy::ffi::Permissions{}),
std::exception);
ASSERT_THROW(client->get_user(make_string_identifier(username)), std::exception);
}
}
TEST_F(LowLevelE2E_Client, CreateUserReturnsCreatedActiveUserDetails) {
RecordProperty("description", "Returns and persists active user details.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string username = GetRandomName(50);
iggy::ffi::UserInfoDetails created_user{};
iggy::ffi::UserInfoDetails fetched_user{};
ASSERT_NO_THROW({ created_user = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW({ fetched_user = client->get_user(make_string_identifier(username)); });
EXPECT_EQ(fetched_user.id, created_user.id);
EXPECT_EQ(static_cast<std::string>(created_user.username), username);
EXPECT_EQ(static_cast<std::string>(fetched_user.username), username);
EXPECT_EQ(created_user.status, iggy::ffi::UserStatus::Active);
EXPECT_EQ(fetched_user.status, iggy::ffi::UserStatus::Active);
}
TEST_F(LowLevelE2E_Client, CreateUserRejectsDuplicateUsernameWithoutChangingOriginal) {
RecordProperty("description", "Rejects duplicate usernames without changing the existing user.");
iggy::ffi::Client *root_client = GetLoggedInClient();
iggy::ffi::Client *user_client = GetLoggedOutClient();
const std::string username = GetRandomName(50);
const std::string password = "original-secret";
iggy::ffi::UserInfoDetails original{};
ASSERT_NO_THROW({ original = CreateUser(root_client, username, password, iggy::ffi::UserStatus::Active); });
ASSERT_THROW(root_client->create_user(username, "replacement-secret", iggy::ffi::UserStatus::Inactive, false,
iggy::ffi::Permissions{}),
std::exception);
iggy::ffi::UserInfoDetails fetched{};
ASSERT_NO_THROW({ fetched = root_client->get_user(make_string_identifier(username)); });
EXPECT_EQ(fetched.id, original.id);
EXPECT_EQ(fetched.status, iggy::ffi::UserStatus::Active);
ASSERT_NO_THROW(user_client->connect());
ASSERT_NO_THROW(user_client->login_user(username, password));
iggy::ffi::Client *replacement_client = GetLoggedOutClient();
ASSERT_NO_THROW(replacement_client->connect());
ASSERT_THROW(replacement_client->login_user(username, "replacement-secret"), std::exception);
}
TEST_F(LowLevelE2E_Client, CreateUserPreservesNestedPermissionsInCreateAndGetResponses) {
RecordProperty("description",
"Creates a user with global and per-resource permissions, then verifies create_user and get_user "
"return the same flags and numeric stream/topic IDs.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string username = GetRandomName(50);
iggy::ffi::Permissions permissions{};
permissions.global.manage_servers = true;
permissions.global.read_users = true;
permissions.global.manage_streams = true;
permissions.global.read_topics = true;
permissions.global.send_messages = true;
iggy::ffi::StreamPermissionEntry first_stream{};
first_stream.stream_id = 42;
first_stream.permissions.manage_stream = true;
first_stream.permissions.read_topics = true;
first_stream.permissions.send_messages = true;
iggy::ffi::TopicPermissionEntry first_topic{};
first_topic.topic_id = 7;
first_topic.permissions.manage_topic = true;
first_topic.permissions.poll_messages = true;
iggy::ffi::TopicPermissionEntry second_topic{};
second_topic.topic_id = 9;
second_topic.permissions.read_topic = true;
second_topic.permissions.send_messages = true;
first_stream.permissions.topics.push_back(std::move(first_topic));
first_stream.permissions.topics.push_back(std::move(second_topic));
iggy::ffi::StreamPermissionEntry second_stream{};
second_stream.stream_id = 84;
second_stream.permissions.read_stream = true;
second_stream.permissions.manage_topics = true;
second_stream.permissions.poll_messages = true;
iggy::ffi::TopicPermissionEntry third_topic{};
third_topic.topic_id = 3;
third_topic.permissions.read_topic = true;
second_stream.permissions.topics.push_back(std::move(third_topic));
permissions.streams.push_back(std::move(first_stream));
permissions.streams.push_back(std::move(second_stream));
iggy::ffi::UserInfoDetails created{};
iggy::ffi::UserInfoDetails fetched{};
ASSERT_NO_THROW({
created =
CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active, true, std::move(permissions));
});
ASSERT_NO_THROW({ fetched = client->get_user(make_string_identifier(username)); });
for (const auto *user : {&created, &fetched}) {
EXPECT_TRUE(user->permissions.global.manage_servers);
EXPECT_FALSE(user->permissions.global.read_servers);
EXPECT_FALSE(user->permissions.global.manage_users);
EXPECT_TRUE(user->permissions.global.read_users);
EXPECT_TRUE(user->permissions.global.manage_streams);
EXPECT_FALSE(user->permissions.global.read_streams);
EXPECT_FALSE(user->permissions.global.manage_topics);
EXPECT_TRUE(user->permissions.global.read_topics);
EXPECT_FALSE(user->permissions.global.poll_messages);
EXPECT_TRUE(user->permissions.global.send_messages);
ASSERT_EQ(user->permissions.streams.size(), 2u);
const iggy::ffi::StreamPermissionEntry *stream_42 = nullptr;
const iggy::ffi::StreamPermissionEntry *stream_84 = nullptr;
for (const auto &stream : user->permissions.streams) {
if (stream.stream_id == 42) {
stream_42 = &stream;
}
if (stream.stream_id == 84) {
stream_84 = &stream;
}
}
ASSERT_NE(stream_42, nullptr);
ASSERT_NE(stream_84, nullptr);
EXPECT_TRUE(stream_42->permissions.manage_stream);
EXPECT_FALSE(stream_42->permissions.read_stream);
EXPECT_FALSE(stream_42->permissions.manage_topics);
EXPECT_TRUE(stream_42->permissions.read_topics);
EXPECT_FALSE(stream_42->permissions.poll_messages);
EXPECT_TRUE(stream_42->permissions.send_messages);
ASSERT_EQ(stream_42->permissions.topics.size(), 2u);
const iggy::ffi::TopicPermissionEntry *topic_7 = nullptr;
const iggy::ffi::TopicPermissionEntry *topic_9 = nullptr;
for (const auto &topic : stream_42->permissions.topics) {
if (topic.topic_id == 7) {
topic_7 = &topic;
}
if (topic.topic_id == 9) {
topic_9 = &topic;
}
}
ASSERT_NE(topic_7, nullptr);
ASSERT_NE(topic_9, nullptr);
EXPECT_TRUE(topic_7->permissions.manage_topic);
EXPECT_FALSE(topic_7->permissions.read_topic);
EXPECT_TRUE(topic_7->permissions.poll_messages);
EXPECT_FALSE(topic_7->permissions.send_messages);
EXPECT_FALSE(topic_9->permissions.manage_topic);
EXPECT_TRUE(topic_9->permissions.read_topic);
EXPECT_FALSE(topic_9->permissions.poll_messages);
EXPECT_TRUE(topic_9->permissions.send_messages);
EXPECT_FALSE(stream_84->permissions.manage_stream);
EXPECT_TRUE(stream_84->permissions.read_stream);
EXPECT_TRUE(stream_84->permissions.manage_topics);
EXPECT_FALSE(stream_84->permissions.read_topics);
EXPECT_TRUE(stream_84->permissions.poll_messages);
EXPECT_FALSE(stream_84->permissions.send_messages);
ASSERT_EQ(stream_84->permissions.topics.size(), 1u);
EXPECT_EQ(stream_84->permissions.topics[0].topic_id, 3u);
EXPECT_FALSE(stream_84->permissions.topics[0].permissions.manage_topic);
EXPECT_TRUE(stream_84->permissions.topics[0].permissions.read_topic);
EXPECT_FALSE(stream_84->permissions.topics[0].permissions.poll_messages);
EXPECT_FALSE(stream_84->permissions.topics[0].permissions.send_messages);
}
}
TEST_F(LowLevelE2E_Client, CreatedUserCanReadOnlyTopicGrantedByPermissions) {
RecordProperty("description",
"Creates a user with read access to one topic, then verifies that topic can be fetched and a topic "
"in another stream is denied.");
iggy::ffi::Client *root_client = GetLoggedInClient();
iggy::ffi::Client *user_client = GetLoggedOutClient();
const std::string allowed_stream_name = GetRandomName();
const std::string denied_stream_name = GetRandomName();
const std::string allowed_topic_name = GetRandomName();
const std::string denied_topic_name = GetRandomName();
const std::string username = GetRandomName(50);
iggy::ffi::StreamDetails allowed_stream{};
iggy::ffi::StreamDetails denied_stream{};
ASSERT_NO_THROW({ allowed_stream = root_client->create_stream(allowed_stream_name); });
TrackStream(allowed_stream_name);
ASSERT_NO_THROW({ denied_stream = root_client->create_stream(denied_stream_name); });
TrackStream(denied_stream_name);
iggy::ffi::TopicDetails allowed_topic{};
iggy::ffi::TopicDetails denied_topic{};
ASSERT_NO_THROW({
allowed_topic =
root_client->create_topic(make_numeric_identifier(allowed_stream.id), allowed_topic_name,
make_topic_create_options(1, "none", "server_default", 0, "server_default"));
denied_topic =
root_client->create_topic(make_numeric_identifier(denied_stream.id), denied_topic_name,
make_topic_create_options(1, "none", "server_default", 0, "server_default"));
});
iggy::ffi::Permissions permissions{};
iggy::ffi::StreamPermissionEntry stream_permissions{};
stream_permissions.stream_id = allowed_stream.id;
iggy::ffi::TopicPermissionEntry topic_permissions{};
topic_permissions.topic_id = allowed_topic.id;
topic_permissions.permissions.read_topic = true;
stream_permissions.permissions.topics.push_back(std::move(topic_permissions));
permissions.streams.push_back(std::move(stream_permissions));
ASSERT_NO_THROW({
CreateUser(root_client, username, "secret123", iggy::ffi::UserStatus::Active, true, std::move(permissions));
});
ASSERT_NO_THROW(user_client->connect());
ASSERT_NO_THROW(user_client->login_user(username, "secret123"));
iggy::ffi::TopicDetails fetched_topic{};
ASSERT_NO_THROW({
fetched_topic = user_client->get_topic(make_numeric_identifier(allowed_stream.id),
make_numeric_identifier(allowed_topic.id));
});
EXPECT_EQ(fetched_topic.id, allowed_topic.id);
EXPECT_EQ(static_cast<std::string>(fetched_topic.name), allowed_topic_name);
ASSERT_THROW(
user_client->get_topic(make_numeric_identifier(denied_stream.id), make_numeric_identifier(denied_topic.id)),
std::exception);
}
TEST_F(LowLevelE2E_Client, CreateUserRejectsDuplicateStreamPermissionIdsWithoutCreatingUser) {
RecordProperty("description",
"Attempts to create a user with two permission entries for stream ID 42, then verifies creation "
"fails and no user is stored.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string username = GetRandomName(50);
iggy::ffi::Permissions permissions{};
iggy::ffi::StreamPermissionEntry first_stream{};
iggy::ffi::StreamPermissionEntry second_stream{};
first_stream.stream_id = 42;
second_stream.stream_id = 42;
permissions.streams.push_back(std::move(first_stream));
permissions.streams.push_back(std::move(second_stream));
ASSERT_THROW(
client->create_user(username, "secret123", iggy::ffi::UserStatus::Active, true, std::move(permissions)),
std::exception);
ASSERT_THROW(client->get_user(make_string_identifier(username)), std::exception);
}
TEST_F(LowLevelE2E_Client, CreateUserRejectsDuplicateTopicPermissionIdsWithoutCreatingUser) {
RecordProperty("description",
"Attempts to create a user with two permission entries for topic ID 7 in the same stream, then "
"verifies creation fails and no user is stored.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string username = GetRandomName(50);
iggy::ffi::Permissions permissions{};
iggy::ffi::StreamPermissionEntry stream{};
stream.stream_id = 42;
iggy::ffi::TopicPermissionEntry first_topic{};
iggy::ffi::TopicPermissionEntry second_topic{};
first_topic.topic_id = 7;
second_topic.topic_id = 7;
stream.permissions.topics.push_back(std::move(first_topic));
stream.permissions.topics.push_back(std::move(second_topic));
permissions.streams.push_back(std::move(stream));
ASSERT_THROW(
client->create_user(username, "secret123", iggy::ffi::UserStatus::Active, true, std::move(permissions)),
std::exception);
ASSERT_THROW(client->get_user(make_string_identifier(username)), std::exception);
}
TEST_F(LowLevelE2E_Client, ReadUsersPermissionDoesNotAllowCreateUser) {
RecordProperty("description", "Rejects user creation by a user with read_users but not manage_users.");
iggy::ffi::Client *root_client = GetLoggedInClient();
iggy::ffi::Client *user_client = GetLoggedOutClient();
const std::string username = GetRandomName(50);
const std::string target = GetRandomName(50);
iggy::ffi::Permissions permissions{};
permissions.global.read_users = true;
ASSERT_NO_THROW({
CreateUser(root_client, username, "secret123", iggy::ffi::UserStatus::Active, true, std::move(permissions));
});
ASSERT_NO_THROW(user_client->connect());
ASSERT_NO_THROW(user_client->login_user(username, "secret123"));
ASSERT_THROW(
user_client->create_user(target, "secret123", iggy::ffi::UserStatus::Active, false, iggy::ffi::Permissions{}),
std::exception);
ASSERT_THROW(root_client->get_user(make_string_identifier(target)), std::exception);
}
TEST_F(LowLevelE2E_Client, ManageUsersPermissionAllowsGrantingAdditionalPermissions) {
RecordProperty("description", "Allows a user manager to grant a child a permission the manager does not have.");
iggy::ffi::Client *root_client = GetLoggedInClient();
iggy::ffi::Client *manager_client = GetLoggedOutClient();
iggy::ffi::Client *child_client = GetLoggedOutClient();
const std::string manager_username = GetRandomName(50);
const std::string child_username = GetRandomName(50);
const std::string denied_stream = GetRandomName();
const std::string child_stream = GetRandomName();
iggy::ffi::Permissions manager_permissions{};
manager_permissions.global.manage_users = true;
manager_permissions.global.manage_streams = false;
ASSERT_NO_THROW({
CreateUser(root_client, manager_username, "secret123", iggy::ffi::UserStatus::Active, true,
std::move(manager_permissions));
});
ASSERT_NO_THROW(manager_client->connect());
ASSERT_NO_THROW(manager_client->login_user(manager_username, "secret123"));
ASSERT_THROW(manager_client->create_stream(denied_stream), std::exception);
iggy::ffi::Permissions child_permissions{};
child_permissions.global.manage_streams = true;
iggy::ffi::UserInfoDetails child{};
ASSERT_NO_THROW({
child = CreateUser(manager_client, child_username, "child-secret", iggy::ffi::UserStatus::Active, true,
std::move(child_permissions));
});
EXPECT_EQ(static_cast<std::string>(child.username), child_username);
EXPECT_EQ(child.status, iggy::ffi::UserStatus::Active);
iggy::ffi::UserInfoDetails fetched{};
ASSERT_NO_THROW({ fetched = root_client->get_user(make_string_identifier(child_username)); });
EXPECT_EQ(fetched.id, child.id);
EXPECT_TRUE(fetched.permissions.global.manage_streams);
ASSERT_NO_THROW(child_client->connect());
ASSERT_NO_THROW(child_client->login_user(child_username, "child-secret"));
ASSERT_NO_THROW(child_client->create_stream(child_stream));
TrackStream(child_stream);
}
TEST_F(LowLevelE2E_Client, CreatedActiveUserAuthenticatesOnlyWithSuppliedPassword) {
RecordProperty("description", "Authenticates an active user only with its supplied password.");
iggy::ffi::Client *root_client = GetLoggedInClient();
iggy::ffi::Client *valid_client = GetLoggedOutClient();
iggy::ffi::Client *wrong_client = GetLoggedOutClient();
const std::string username = GetRandomName(50);
const std::string password = "known-secret";
ASSERT_NO_THROW({ CreateUser(root_client, username, password, iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW(valid_client->connect());
ASSERT_NO_THROW(wrong_client->connect());
ASSERT_NO_THROW(valid_client->login_user(username, password));
ASSERT_THROW(wrong_client->login_user(username, "other-secret"), std::exception);
}
TEST_F(LowLevelE2E_Client, CreatedInactiveUserCannotAuthenticate) {
RecordProperty("description", "Persists inactive users but rejects authentication for them.");
iggy::ffi::Client *root_client = GetLoggedInClient();
iggy::ffi::Client *user_client = GetLoggedOutClient();
const std::string username = GetRandomName(50);
const std::string password = "inactive-secret";
iggy::ffi::UserInfoDetails created{};
iggy::ffi::UserInfoDetails fetched{};
ASSERT_NO_THROW({ created = CreateUser(root_client, username, password, iggy::ffi::UserStatus::Inactive); });
ASSERT_NO_THROW({ fetched = root_client->get_user(make_string_identifier(username)); });
EXPECT_EQ(created.status, iggy::ffi::UserStatus::Inactive);
EXPECT_EQ(fetched.status, iggy::ffi::UserStatus::Inactive);
ASSERT_NO_THROW(user_client->connect());
ASSERT_THROW(user_client->login_user(username, password), std::exception);
}
TEST_F(LowLevelE2E_Client, UpdateUserRejectsUnauthenticatedClientWithoutChangingTarget) {
RecordProperty("description", "Rejects user updates without an active authenticated session.");
iggy::ffi::Client *root_client = GetLoggedInClient();
const std::string username = GetRandomName(50);
const std::string replacement = GetRandomName(50);
iggy::ffi::UserInfoDetails created{};
ASSERT_NO_THROW({ created = CreateUser(root_client, username, "secret123", iggy::ffi::UserStatus::Active); });
iggy::ffi::Client *client = GetLoggedOutClient();
ASSERT_THROW(
client->update_user(make_string_identifier(username), true, replacement, true, iggy::ffi::UserStatus::Inactive),
std::exception);
ASSERT_NO_THROW(client->connect());
ASSERT_THROW(
client->update_user(make_string_identifier(username), true, replacement, true, iggy::ffi::UserStatus::Inactive),
std::exception);
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->logout_user());
ASSERT_THROW(
client->update_user(make_string_identifier(username), true, replacement, true, iggy::ffi::UserStatus::Inactive),
std::exception);
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->disconnect());
ASSERT_THROW(
client->update_user(make_string_identifier(username), true, replacement, true, iggy::ffi::UserStatus::Inactive),
std::exception);
iggy::ffi::UserInfoDetails fetched{};
ASSERT_NO_THROW({ fetched = root_client->get_user(make_string_identifier(username)); });
EXPECT_EQ(fetched.id, created.id);
EXPECT_EQ(static_cast<std::string>(fetched.username), username);
EXPECT_EQ(fetched.status, iggy::ffi::UserStatus::Active);
}
TEST_F(LowLevelE2E_Client, UpdateUserRejectsUnknownUsernameAndNumericId) {
RecordProperty("description", "Rejects updates for unknown username and numeric identifiers.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string unknown_username = GetRandomName(50);
const std::string proposed_username = GetRandomName(50);
const auto unknown_id = std::numeric_limits<std::uint32_t>::max();
ASSERT_THROW(client->update_user(make_string_identifier(unknown_username), true, proposed_username, true,
iggy::ffi::UserStatus::Inactive),
std::exception);
ASSERT_THROW(client->update_user(make_numeric_identifier(unknown_id), true, GetRandomName(50), true,
iggy::ffi::UserStatus::Inactive),
std::exception);
ASSERT_THROW(client->get_user(make_string_identifier(proposed_username)), std::exception);
}
TEST_F(LowLevelE2E_Client, UpdateUserByUsernameChangesUsernameAndStatus) {
RecordProperty("description", "Updates a user by username and changes both username and status.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string username = GetRandomName(50);
const std::string replacement = GetRandomName(50);
iggy::ffi::UserInfoDetails created{};
ASSERT_NO_THROW({ created = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW(client->update_user(make_string_identifier(username), true, replacement, true,
iggy::ffi::UserStatus::Inactive));
RenameTrackedUser(username, replacement);
ASSERT_THROW(client->get_user(make_string_identifier(username)), std::exception);
iggy::ffi::UserInfoDetails fetched{};
ASSERT_NO_THROW({ fetched = client->get_user(make_string_identifier(replacement)); });
EXPECT_EQ(fetched.id, created.id);
EXPECT_EQ(static_cast<std::string>(fetched.username), replacement);
EXPECT_EQ(fetched.status, iggy::ffi::UserStatus::Inactive);
}
TEST_F(LowLevelE2E_Client, UpdateUserByNumericIdChangesUsernameAndStatus) {
RecordProperty("description", "Updates a user by numeric ID and changes both username and status.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string username = GetRandomName(50);
const std::string replacement = GetRandomName(50);
iggy::ffi::UserInfoDetails created{};
ASSERT_NO_THROW({ created = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Inactive); });
ASSERT_NO_THROW(client->update_user(make_numeric_identifier(created.id), true, replacement, true,
iggy::ffi::UserStatus::Active));
RenameTrackedUser(username, replacement);
iggy::ffi::UserInfoDetails by_id{};
iggy::ffi::UserInfoDetails by_name{};
ASSERT_NO_THROW({ by_id = client->get_user(make_numeric_identifier(created.id)); });
ASSERT_NO_THROW({ by_name = client->get_user(make_string_identifier(replacement)); });
EXPECT_EQ(by_id.id, created.id);
EXPECT_EQ(by_name.id, created.id);
EXPECT_EQ(static_cast<std::string>(by_id.username), replacement);
EXPECT_EQ(static_cast<std::string>(by_name.username), replacement);
EXPECT_EQ(by_id.status, iggy::ffi::UserStatus::Active);
EXPECT_EQ(by_name.status, iggy::ffi::UserStatus::Active);
}
TEST_F(LowLevelE2E_Client, UpdateUserAllowsUsernameAndStatusToBeUpdatedIndependently) {
RecordProperty("description", "Updates either username or status without changing the other field.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string username = GetRandomName(50);
const std::string replacement = GetRandomName(50);
iggy::ffi::UserInfoDetails created{};
ASSERT_NO_THROW({ created = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW(
client->update_user(make_numeric_identifier(created.id), false, "", true, iggy::ffi::UserStatus::Inactive));
iggy::ffi::UserInfoDetails status_updated{};
ASSERT_NO_THROW({ status_updated = client->get_user(make_numeric_identifier(created.id)); });
EXPECT_EQ(static_cast<std::string>(status_updated.username), username);
EXPECT_EQ(status_updated.status, iggy::ffi::UserStatus::Inactive);
ASSERT_NO_THROW(client->update_user(make_numeric_identifier(created.id), true, replacement, false,
iggy::ffi::UserStatus::Active));
RenameTrackedUser(username, replacement);
iggy::ffi::UserInfoDetails username_updated{};
ASSERT_NO_THROW({ username_updated = client->get_user(make_numeric_identifier(created.id)); });
EXPECT_EQ(static_cast<std::string>(username_updated.username), replacement);
EXPECT_EQ(username_updated.status, iggy::ffi::UserStatus::Inactive);
}
TEST_F(LowLevelE2E_Client, UpdateUserAcceptsUsernameLengthBounds) {
RecordProperty("description", "Accepts exact three-byte and fifty-byte username boundaries.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string first_username = GetRandomName(50);
const std::string second_username = GetRandomName(50);
const std::string first_replacement = GetRandomName(3);
std::string second_replacement = GetRandomName(50);
second_replacement.resize(50, 'a');
ASSERT_EQ(second_replacement.size(), 50u);
iggy::ffi::UserInfoDetails first{};
iggy::ffi::UserInfoDetails second{};
ASSERT_NO_THROW({ first = CreateUser(client, first_username, "secret123", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW({ second = CreateUser(client, second_username, "secret123", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW(client->update_user(make_string_identifier(first_username), true, first_replacement, true,
iggy::ffi::UserStatus::Active));
RenameTrackedUser(first_username, first_replacement);
ASSERT_NO_THROW(client->update_user(make_string_identifier(second_username), true, second_replacement, true,
iggy::ffi::UserStatus::Active));
RenameTrackedUser(second_username, second_replacement);
iggy::ffi::UserInfoDetails fetched_first{};
iggy::ffi::UserInfoDetails fetched_second{};
ASSERT_NO_THROW({ fetched_first = client->get_user(make_string_identifier(first_replacement)); });
ASSERT_NO_THROW({ fetched_second = client->get_user(make_string_identifier(second_replacement)); });
EXPECT_EQ(fetched_first.id, first.id);
EXPECT_EQ(fetched_second.id, second.id);
EXPECT_EQ(static_cast<std::string>(fetched_first.username), first_replacement);
EXPECT_EQ(static_cast<std::string>(fetched_second.username), second_replacement);
}
TEST_F(LowLevelE2E_Client, UpdateUserRejectsUsernameOutsideLengthBounds) {
RecordProperty("description", "Rejects username sizes outside the SDK and server limits.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string source = GetRandomName(50);
iggy::ffi::UserInfoDetails created{};
ASSERT_NO_THROW({ created = CreateUser(client, source, "secret123", iggy::ffi::UserStatus::Active); });
const std::string invalid_usernames[] = {
"", "a", "aa", std::string(51, 'c'), std::string(255, 'd'), std::string(256, 'e')};
for (const auto &replacement : invalid_usernames) {
ASSERT_THROW(
client->update_user(make_string_identifier(source), true, replacement, true, iggy::ffi::UserStatus::Active),
std::exception);
}
iggy::ffi::UserInfoDetails fetched{};
ASSERT_NO_THROW({ fetched = client->get_user(make_string_identifier(source)); });
EXPECT_EQ(fetched.id, created.id);
EXPECT_EQ(fetched.status, iggy::ffi::UserStatus::Active);
EXPECT_EQ(static_cast<std::string>(fetched.username), source);
}
TEST_F(LowLevelE2E_Client, UpdateUserAcceptsNonAsciiAndNonAlphabeticUsername) {
RecordProperty("description", "Accepts representative non-ASCII and non-alphabetic usernames.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string suffix = GetRandomName(8);
const std::string sources[] = {GetRandomName(50), GetRandomName(50), GetRandomName(50)};
const std::string replacements[] = {"!@#_" + suffix, "ユーザー_" + suffix, "😀🚀_" + suffix};
ASSERT_LE(replacements[0].size(), 50u);
ASSERT_LE(replacements[1].size(), 50u);
ASSERT_LE(replacements[2].size(), 50u);
for (std::size_t index = 0; index < 3; ++index) {
iggy::ffi::UserInfoDetails created{};
ASSERT_NO_THROW({ created = CreateUser(client, sources[index], "secret123", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW(client->update_user(make_string_identifier(sources[index]), true, replacements[index], true,
iggy::ffi::UserStatus::Active));
RenameTrackedUser(sources[index], replacements[index]);
iggy::ffi::UserInfoDetails fetched{};
ASSERT_NO_THROW({ fetched = client->get_user(make_string_identifier(replacements[index])); });
EXPECT_EQ(fetched.id, created.id);
EXPECT_EQ(static_cast<std::string>(fetched.username), replacements[index]);
}
}
TEST_F(LowLevelE2E_Client, UpdateUserRejectsInvalidStatusWithoutRenamingTarget) {
RecordProperty("description", "Rejects invalid status codes atomically with username changes.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string username = GetRandomName(50);
iggy::ffi::UserInfoDetails created{};
ASSERT_NO_THROW({ created = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
const iggy::ffi::UserStatus statuses[] = {
static_cast<iggy::ffi::UserStatus>(0),
static_cast<iggy::ffi::UserStatus>(3),
static_cast<iggy::ffi::UserStatus>(std::numeric_limits<std::uint8_t>::max()),
};
for (const auto status : statuses) {
const std::string replacement = GetRandomName(50);
ASSERT_THROW(client->update_user(make_string_identifier(username), true, replacement, true, status),
std::exception);
ASSERT_THROW(client->get_user(make_string_identifier(replacement)), std::exception);
}
iggy::ffi::UserInfoDetails fetched{};
ASSERT_NO_THROW({ fetched = client->get_user(make_string_identifier(username)); });
EXPECT_EQ(fetched.id, created.id);
EXPECT_EQ(fetched.status, iggy::ffi::UserStatus::Active);
EXPECT_EQ(static_cast<std::string>(fetched.username), username);
}
TEST_F(LowLevelE2E_Client, UpdateUserRejectsDuplicateUsernameWithoutChangingStatus) {
RecordProperty("description", "Rejects duplicate usernames without partially applying the status update.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string target_username = GetRandomName(50);
const std::string conflict_username = GetRandomName(50);
iggy::ffi::UserInfoDetails target{};
iggy::ffi::UserInfoDetails conflict{};
ASSERT_NO_THROW({ target = CreateUser(client, target_username, "secret123", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW(
{ conflict = CreateUser(client, conflict_username, "secret123", iggy::ffi::UserStatus::Inactive); });
ASSERT_THROW(client->update_user(make_string_identifier(target_username), true, conflict_username, true,
iggy::ffi::UserStatus::Inactive),
std::exception);
iggy::ffi::UserInfoDetails fetched_target{};
iggy::ffi::UserInfoDetails fetched_conflict{};
ASSERT_NO_THROW({ fetched_target = client->get_user(make_string_identifier(target_username)); });
ASSERT_NO_THROW({ fetched_conflict = client->get_user(make_string_identifier(conflict_username)); });
EXPECT_EQ(fetched_target.id, target.id);
EXPECT_EQ(fetched_conflict.id, conflict.id);
EXPECT_EQ(fetched_target.status, iggy::ffi::UserStatus::Active);
EXPECT_EQ(fetched_conflict.status, iggy::ffi::UserStatus::Inactive);
}
TEST_F(LowLevelE2E_Client, UpdateUserAllowsCurrentUsernameWhileChangingStatus) {
RecordProperty("description", "Allows a same-username update that changes status.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string username = GetRandomName(50);
iggy::ffi::UserInfoDetails created{};
ASSERT_NO_THROW({ created = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW(
client->update_user(make_string_identifier(username), true, username, true, iggy::ffi::UserStatus::Inactive));
iggy::ffi::UserInfoDetails fetched{};
ASSERT_NO_THROW({ fetched = client->get_user(make_string_identifier(username)); });
EXPECT_EQ(fetched.id, created.id);
EXPECT_EQ(fetched.status, iggy::ffi::UserStatus::Inactive);
EXPECT_EQ(static_cast<std::string>(fetched.username), username);
}
TEST_F(LowLevelE2E_Client, UpdateUserPreservesPasswordPermissionsAndCreationData) {
RecordProperty("description", "Preserves password, permissions, ID, and creation timestamp after a rename.");
iggy::ffi::Client *root_client = GetLoggedInClient();
iggy::ffi::Client *valid_client = GetLoggedOutClient();
iggy::ffi::Client *old_client = GetLoggedOutClient();
const std::string username = GetRandomName(50);
const std::string replacement = GetRandomName(50);
iggy::ffi::Permissions permissions{};
permissions.global.read_users = true;
permissions.global.read_streams = true;
permissions.global.send_messages = true;
iggy::ffi::UserInfoDetails created{};
ASSERT_NO_THROW({
created = CreateUser(root_client, username, "known-secret", iggy::ffi::UserStatus::Active, true,
std::move(permissions));
});
ASSERT_NO_THROW(root_client->update_user(make_string_identifier(username), true, replacement, true,
iggy::ffi::UserStatus::Active));
RenameTrackedUser(username, replacement);
iggy::ffi::UserInfoDetails fetched{};
ASSERT_NO_THROW({ fetched = root_client->get_user(make_string_identifier(replacement)); });
EXPECT_EQ(fetched.id, created.id);
EXPECT_EQ(fetched.created_at, created.created_at);
EXPECT_TRUE(fetched.has_permissions);
EXPECT_TRUE(fetched.permissions.global.read_users);
EXPECT_TRUE(fetched.permissions.global.read_streams);
EXPECT_TRUE(fetched.permissions.global.send_messages);
ASSERT_NO_THROW(valid_client->connect());
ASSERT_NO_THROW(valid_client->login_user(replacement, "known-secret"));
ASSERT_NO_THROW(old_client->connect());
ASSERT_THROW(old_client->login_user(username, "known-secret"), std::exception);
}
TEST_F(LowLevelE2E_Client, UpdateUserToInactiveBlocksFreshLoginUntilReactivated) {
RecordProperty("description", "Blocks fresh login while inactive and restores it when active.");
iggy::ffi::Client *root_client = GetLoggedInClient();
iggy::ffi::Client *user_client = GetLoggedOutClient();
const std::string username = GetRandomName(50);
ASSERT_NO_THROW({ CreateUser(root_client, username, "known-secret", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW(root_client->update_user(make_string_identifier(username), true, username, true,
iggy::ffi::UserStatus::Inactive));
ASSERT_NO_THROW(user_client->connect());
ASSERT_THROW(user_client->login_user(username, "known-secret"), std::exception);
ASSERT_NO_THROW(root_client->update_user(make_string_identifier(username), true, username, true,
iggy::ffi::UserStatus::Active));
ASSERT_NO_THROW(user_client->login_user(username, "known-secret"));
}
TEST_F(LowLevelE2E_Client, UpdateUserRenameMakesOldUsernameReusable) {
RecordProperty("description", "Releases the old username for a new user after a successful rename.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string old_username = GetRandomName(50);
const std::string new_username = GetRandomName(50);
iggy::ffi::UserInfoDetails first{};
ASSERT_NO_THROW({ first = CreateUser(client, old_username, "secret123", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW(client->update_user(make_string_identifier(old_username), true, new_username, true,
iggy::ffi::UserStatus::Active));
RenameTrackedUser(old_username, new_username);
iggy::ffi::UserInfoDetails second{};
ASSERT_NO_THROW({ second = CreateUser(client, old_username, "secret123", iggy::ffi::UserStatus::Active); });
EXPECT_EQ(static_cast<std::string>(client->get_user(make_string_identifier(new_username)).username), new_username);
EXPECT_EQ(static_cast<std::string>(client->get_user(make_string_identifier(old_username)).username), old_username);
}
TEST_F(LowLevelE2E_Client, UpdateUserRejectsRenameToRootUsername) {
RecordProperty("description", "Rejects changing a non-root user's username to root's username.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string username = GetRandomName(50);
iggy::ffi::UserInfoDetails target{};
ASSERT_NO_THROW({ target = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
ASSERT_THROW(
client->update_user(make_string_identifier(username), true, "iggy", true, iggy::ffi::UserStatus::Inactive),
std::exception);
iggy::ffi::UserInfoDetails root{};
iggy::ffi::UserInfoDetails fetched{};
ASSERT_NO_THROW({ root = client->get_user(make_string_identifier("iggy")); });
ASSERT_NO_THROW({ fetched = client->get_user(make_string_identifier(username)); });
EXPECT_EQ(root.id, 0u);
EXPECT_EQ(static_cast<std::string>(root.username), "iggy");
EXPECT_EQ(root.status, iggy::ffi::UserStatus::Active);
EXPECT_EQ(fetched.id, target.id);
EXPECT_EQ(fetched.status, iggy::ffi::UserStatus::Active);
EXPECT_EQ(static_cast<std::string>(fetched.username), username);
}
TEST_F(LowLevelE2E_Client, ReadUsersPermissionDoesNotAllowUpdateUser) {
RecordProperty("description", "Rejects updates from a user with read_users but not manage_users.");
iggy::ffi::Client *root_client = GetLoggedInClient();
iggy::ffi::Client *caller_client = GetLoggedOutClient();
const std::string caller_username = GetRandomName(50);
const std::string target_username = GetRandomName(50);
iggy::ffi::Permissions permissions{};
permissions.global.read_users = true;
ASSERT_NO_THROW({
CreateUser(root_client, caller_username, "secret123", iggy::ffi::UserStatus::Active, true,
std::move(permissions));
});
iggy::ffi::UserInfoDetails target{};
ASSERT_NO_THROW({ target = CreateUser(root_client, target_username, "secret123", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW(caller_client->connect());
ASSERT_NO_THROW(caller_client->login_user(caller_username, "secret123"));
ASSERT_THROW(caller_client->update_user(make_string_identifier(target_username), true, GetRandomName(50), true,
iggy::ffi::UserStatus::Inactive),
std::exception);
ASSERT_THROW(caller_client->update_user(make_string_identifier(caller_username), true, GetRandomName(50), true,
iggy::ffi::UserStatus::Inactive),
std::exception);
iggy::ffi::UserInfoDetails fetched{};
ASSERT_NO_THROW({ fetched = root_client->get_user(make_string_identifier(target_username)); });
EXPECT_EQ(fetched.id, target.id);
EXPECT_EQ(fetched.status, iggy::ffi::UserStatus::Active);
}
TEST_F(LowLevelE2E_Client, ManageUsersPermissionAllowsUpdateWithoutReadUsers) {
RecordProperty("description", "Allows updates from a manager without read_users permission.");
iggy::ffi::Client *root_client = GetLoggedInClient();
iggy::ffi::Client *manager_client = GetLoggedOutClient();
const std::string manager_username = GetRandomName(50);
const std::string target_username = GetRandomName(50);
const std::string replacement = GetRandomName(50);
iggy::ffi::Permissions permissions{};
permissions.global.manage_users = true;
ASSERT_NO_THROW({
CreateUser(root_client, manager_username, "secret123", iggy::ffi::UserStatus::Active, true,
std::move(permissions));
});
iggy::ffi::UserInfoDetails target{};
ASSERT_NO_THROW({ target = CreateUser(root_client, target_username, "secret123", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW(manager_client->connect());
ASSERT_NO_THROW(manager_client->login_user(manager_username, "secret123"));
ASSERT_NO_THROW(manager_client->update_user(make_string_identifier(target_username), true, replacement, true,
iggy::ffi::UserStatus::Inactive));
RenameTrackedUser(target_username, replacement);
iggy::ffi::UserInfoDetails fetched{};
ASSERT_NO_THROW({ fetched = root_client->get_user(make_string_identifier(replacement)); });
EXPECT_EQ(fetched.id, target.id);
EXPECT_EQ(fetched.status, iggy::ffi::UserStatus::Inactive);
EXPECT_EQ(static_cast<std::string>(fetched.username), replacement);
}
TEST_F(LowLevelE2E_Client, GetUserBeforeLoginThrows) {
RecordProperty("description", "Rejects user lookup without an active authenticated session.");
iggy::ffi::Client *client = GetLoggedOutClient();
ASSERT_THROW(client->get_user(make_string_identifier("iggy")), std::exception);
ASSERT_NO_THROW(client->connect());
ASSERT_THROW(client->get_user(make_string_identifier("iggy")), std::exception);
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->logout_user());
ASSERT_THROW(client->get_user(make_string_identifier("iggy")), std::exception);
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->disconnect());
ASSERT_THROW(client->get_user(make_string_identifier("iggy")), std::exception);
}
TEST_F(LowLevelE2E_Client, GetUserByUsernameReturnsRootDetails) {
RecordProperty("description", "Returns deterministic root details for a username lookup.");
iggy::ffi::Client *client = GetLoggedInClient();
iggy::ffi::UserInfoDetails user{};
ASSERT_NO_THROW({ user = client->get_user(make_string_identifier("iggy")); });
EXPECT_EQ(user.id, 0u);
EXPECT_EQ(static_cast<std::string>(user.username), "iggy");
EXPECT_EQ(user.status, iggy::ffi::UserStatus::Active);
}
TEST_F(LowLevelE2E_Client, GetUserByNumericIdMatchesUsernameLookup) {
RecordProperty("description", "Returns equivalent root details for username and numeric identifiers.");
iggy::ffi::Client *client = GetLoggedInClient();
iggy::ffi::UserInfoDetails by_username{};
iggy::ffi::UserInfoDetails by_id{};
ASSERT_NO_THROW({ by_username = client->get_user(make_string_identifier("iggy")); });
ASSERT_NO_THROW({ by_id = client->get_user(make_numeric_identifier(0)); });
EXPECT_EQ(by_id.id, by_username.id);
EXPECT_EQ(by_id.status, by_username.status);
EXPECT_EQ(static_cast<std::string>(by_id.username), static_cast<std::string>(by_username.username));
EXPECT_EQ(by_id.permissions.global.manage_servers, by_username.permissions.global.manage_servers);
EXPECT_EQ(by_id.permissions.global.read_servers, by_username.permissions.global.read_servers);
EXPECT_EQ(by_id.permissions.global.manage_users, by_username.permissions.global.manage_users);
EXPECT_EQ(by_id.permissions.global.read_users, by_username.permissions.global.read_users);
EXPECT_EQ(by_id.permissions.global.manage_streams, by_username.permissions.global.manage_streams);
EXPECT_EQ(by_id.permissions.global.read_streams, by_username.permissions.global.read_streams);
EXPECT_EQ(by_id.permissions.global.manage_topics, by_username.permissions.global.manage_topics);
EXPECT_EQ(by_id.permissions.global.read_topics, by_username.permissions.global.read_topics);
EXPECT_EQ(by_id.permissions.global.poll_messages, by_username.permissions.global.poll_messages);
EXPECT_EQ(by_id.permissions.global.send_messages, by_username.permissions.global.send_messages);
EXPECT_EQ(by_id.permissions.streams.size(), by_username.permissions.streams.size());
}
TEST_F(LowLevelE2E_Client, GetUserWithUnknownIdentifierThrows) {
RecordProperty("description", "Rejects lookups for unknown username and numeric identifiers.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string unknown_user = GetRandomName(50);
ASSERT_THROW(client->get_user(make_string_identifier(unknown_user)), std::exception);
ASSERT_THROW(client->get_user(make_numeric_identifier(std::numeric_limits<std::uint32_t>::max())), std::exception);
}
TEST_F(LowLevelE2E_Client, GetUsersBeforeLoginThrows) {
RecordProperty("description", "Rejects listing users without an active authenticated session.");
iggy::ffi::Client *client = GetLoggedOutClient();
ASSERT_THROW(client->get_users(), std::exception);
ASSERT_NO_THROW(client->connect());
ASSERT_THROW(client->get_users(), std::exception);
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->logout_user());
ASSERT_THROW(client->get_users(), std::exception);
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->disconnect());
ASSERT_THROW(client->get_users(), std::exception);
}
TEST_F(LowLevelE2E_Client, GetUserAllowsSelfLookupWithoutReadUsersPermission) {
RecordProperty("description", "Allows a user without read_users permission to read its own details.");
iggy::ffi::Client *root_client = GetLoggedInClient();
const std::string username = GetRandomName(50);
iggy::ffi::Permissions permissions{};
permissions.global.read_users = false;
iggy::ffi::UserInfoDetails created_user{};
ASSERT_NO_THROW({
created_user =
CreateUser(root_client, username, "secret123", iggy::ffi::UserStatus::Active, true, std::move(permissions));
});
iggy::ffi::Client *user_client = GetLoggedOutClient();
ASSERT_NO_THROW(user_client->connect());
ASSERT_NO_THROW(user_client->login_user(username, "secret123"));
iggy::ffi::UserInfoDetails by_username{};
iggy::ffi::UserInfoDetails by_id{};
ASSERT_NO_THROW({ by_username = user_client->get_user(make_string_identifier(username)); });
ASSERT_NO_THROW({ by_id = user_client->get_user(make_numeric_identifier(created_user.id)); });
EXPECT_EQ(by_username.id, created_user.id);
EXPECT_EQ(by_id.id, created_user.id);
EXPECT_EQ(static_cast<std::string>(by_username.username), username);
EXPECT_EQ(static_cast<std::string>(by_id.username), username);
EXPECT_EQ(by_username.status, iggy::ffi::UserStatus::Active);
EXPECT_EQ(by_id.status, iggy::ffi::UserStatus::Active);
EXPECT_FALSE(by_username.permissions.global.read_users);
EXPECT_FALSE(by_id.permissions.global.read_users);
}
TEST_F(LowLevelE2E_Client, UserWithoutReadUsersPermissionCannotQueryOtherUsers) {
RecordProperty("description", "Rejects other-user lookup and listing without read_users permission.");
iggy::ffi::Client *root_client = GetLoggedInClient();
const std::string username = GetRandomName(50);
iggy::ffi::Permissions permissions{};
permissions.global.read_users = false;
ASSERT_NO_THROW({
CreateUser(root_client, username, "secret123", iggy::ffi::UserStatus::Active, true, std::move(permissions));
});
iggy::ffi::Client *user_client = GetLoggedOutClient();
ASSERT_NO_THROW(user_client->connect());
ASSERT_NO_THROW(user_client->login_user(username, "secret123"));
ASSERT_THROW(user_client->get_user(make_numeric_identifier(0)), std::exception);
ASSERT_THROW(user_client->get_users(), std::exception);
}
TEST_F(LowLevelE2E_Client, ReadUsersPermissionAllowsUserQueries) {
RecordProperty("description", "Allows user lookup and listing with only read_users permission.");
iggy::ffi::Client *root_client = GetLoggedInClient();
const std::string username = GetRandomName(50);
iggy::ffi::Permissions permissions{};
permissions.global.read_users = true;
ASSERT_NO_THROW({
CreateUser(root_client, username, "secret123", iggy::ffi::UserStatus::Active, true, std::move(permissions));
});
iggy::ffi::Client *user_client = GetLoggedOutClient();
ASSERT_NO_THROW(user_client->connect());
ASSERT_NO_THROW(user_client->login_user(username, "secret123"));
iggy::ffi::UserInfoDetails root{};
rust::Vec<iggy::ffi::UserInfo> users;
ASSERT_NO_THROW({ root = user_client->get_user(make_numeric_identifier(0)); });
ASSERT_NO_THROW({ users = user_client->get_users(); });
EXPECT_EQ(root.id, 0u);
EXPECT_EQ(static_cast<std::string>(root.username), "iggy");
bool found_self = false;
for (const auto &user : users) {
if (static_cast<std::string>(user.username) == username) {
found_self = true;
}
}
EXPECT_TRUE(found_self);
}
TEST_F(LowLevelE2E_Client, ManageUsersPermissionImpliesReadUsers) {
RecordProperty("description", "Allows user lookup and listing when manage_users is set without read_users.");
iggy::ffi::Client *root_client = GetLoggedInClient();
const std::string username = GetRandomName(50);
iggy::ffi::Permissions permissions{};
permissions.global.manage_users = true;
permissions.global.read_users = false;
ASSERT_NO_THROW({
CreateUser(root_client, username, "secret123", iggy::ffi::UserStatus::Active, true, std::move(permissions));
});
iggy::ffi::Client *user_client = GetLoggedOutClient();
ASSERT_NO_THROW(user_client->connect());
ASSERT_NO_THROW(user_client->login_user(username, "secret123"));
iggy::ffi::UserInfoDetails root{};
rust::Vec<iggy::ffi::UserInfo> users;
ASSERT_NO_THROW({ root = user_client->get_user(make_numeric_identifier(0)); });
ASSERT_NO_THROW({ users = user_client->get_users(); });
EXPECT_EQ(root.id, 0u);
EXPECT_EQ(static_cast<std::string>(root.username), "iggy");
bool found_self = false;
for (const auto &user : users) {
if (static_cast<std::string>(user.username) == username) {
found_self = true;
}
}
EXPECT_TRUE(found_self);
}
TEST_F(LowLevelE2E_Client, GetUsersContainsCreatedUser) {
RecordProperty("description", "Lists a created user with the input username and status.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string username = GetRandomName(50);
iggy::ffi::UserInfoDetails created_user{};
ASSERT_NO_THROW({ created_user = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
rust::Vec<iggy::ffi::UserInfo> users;
ASSERT_NO_THROW({ users = client->get_users(); });
bool found_user = false;
for (const auto &user : users) {
if (user.id == created_user.id) {
found_user = true;
EXPECT_EQ(static_cast<std::string>(user.username), username);
EXPECT_EQ(user.status, iggy::ffi::UserStatus::Active);
}
}
EXPECT_TRUE(found_user);
}
TEST_F(LowLevelE2E_Client, GetUsersReturnsAllCreatedUsersOrderedById) {
RecordProperty("description", "Returns every created user without pagination, ordered by ID over TCP.");
iggy::ffi::Client *client = GetLoggedInClient();
rust::Vec<iggy::ffi::UserInfo> users_before;
ASSERT_NO_THROW({ users_before = client->get_users(); });
const std::string first_username = GetRandomName(50);
const std::string second_username = GetRandomName(50);
const std::string third_username = GetRandomName(50);
iggy::ffi::UserInfoDetails first_user{};
iggy::ffi::UserInfoDetails second_user{};
iggy::ffi::UserInfoDetails third_user{};
ASSERT_NO_THROW({ first_user = CreateUser(client, first_username, "secret123", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW({ second_user = CreateUser(client, second_username, "secret123", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW({ third_user = CreateUser(client, third_username, "secret123", iggy::ffi::UserStatus::Active); });
rust::Vec<iggy::ffi::UserInfo> users_after;
ASSERT_NO_THROW({ users_after = client->get_users(); });
ASSERT_EQ(users_after.size(), users_before.size() + 3);
bool found_first = false;
bool found_second = false;
bool found_third = false;
for (std::size_t index = 0; index < users_after.size(); ++index) {
if (index > 0) {
EXPECT_LT(users_after[index - 1].id, users_after[index].id);
}
const auto &user = users_after[index];
const std::string username = static_cast<std::string>(user.username);
if (user.id == first_user.id && username == first_username) {
found_first = true;
}
if (user.id == second_user.id && username == second_username) {
found_second = true;
}
if (user.id == third_user.id && username == third_username) {
found_third = true;
}
}
EXPECT_TRUE(found_first);
EXPECT_TRUE(found_second);
EXPECT_TRUE(found_third);
}
TEST_F(LowLevelE2E_Client, GetUsersMatchesGetUserDetails) {
RecordProperty("description", "Returns consistent ID, username, and status from user query APIs.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string username = GetRandomName(50);
iggy::ffi::UserInfoDetails created_user{};
ASSERT_NO_THROW({ created_user = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
iggy::ffi::UserInfoDetails user_details{};
rust::Vec<iggy::ffi::UserInfo> users;
ASSERT_NO_THROW({ user_details = client->get_user(make_string_identifier(username)); });
ASSERT_NO_THROW({ users = client->get_users(); });
EXPECT_EQ(user_details.id, created_user.id);
EXPECT_EQ(static_cast<std::string>(user_details.username), username);
EXPECT_EQ(user_details.status, iggy::ffi::UserStatus::Active);
bool found_user = false;
for (const auto &user : users) {
if (user.id == user_details.id) {
found_user = true;
EXPECT_EQ(static_cast<std::string>(user.username), username);
EXPECT_EQ(user.status, iggy::ffi::UserStatus::Active);
}
}
EXPECT_TRUE(found_user);
}
TEST_F(LowLevelE2E_Client, DeletedUserDisappearsFromGetUserAndGetUsers) {
RecordProperty("description", "Removes a deleted user from detail and list queries.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string username = GetRandomName(50);
iggy::ffi::UserInfoDetails created_user{};
ASSERT_NO_THROW({ created_user = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW(client->delete_user(make_string_identifier(username)));
ForgetUser(username);
ASSERT_THROW(client->get_user(make_string_identifier(username)), std::exception);
rust::Vec<iggy::ffi::UserInfo> users;
ASSERT_NO_THROW({ users = client->get_users(); });
for (const auto &user : users) {
EXPECT_FALSE(user.id == created_user.id && static_cast<std::string>(user.username) == username);
}
}
TEST_F(LowLevelE2E_Client, DeleteUserRejectsUnauthenticatedClientWithoutDeletingTarget) {
RecordProperty("description", "Rejects user deletion without an active authenticated session.");
iggy::ffi::Client *root_client = GetLoggedInClient();
iggy::ffi::Client *client = GetLoggedOutClient();
const std::string username = GetRandomName(50);
iggy::ffi::UserInfoDetails created_user{};
ASSERT_NO_THROW({ created_user = CreateUser(root_client, username, "secret123", iggy::ffi::UserStatus::Active); });
ASSERT_THROW(client->delete_user(make_string_identifier(username)), std::exception);
ASSERT_NO_THROW(client->connect());
ASSERT_THROW(client->delete_user(make_string_identifier(username)), std::exception);
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->logout_user());
ASSERT_THROW(client->delete_user(make_string_identifier(username)), std::exception);
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->disconnect());
ASSERT_THROW(client->delete_user(make_string_identifier(username)), std::exception);
iggy::ffi::UserInfoDetails fetched_user{};
ASSERT_NO_THROW({ fetched_user = root_client->get_user(make_string_identifier(username)); });
EXPECT_EQ(fetched_user.id, created_user.id);
}
TEST_F(LowLevelE2E_Client, DeleteUserRejectsUnknownUsernameAndNumericId) {
RecordProperty("description", "Rejects deletion of unknown string and numeric user identifiers.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string unknown_user = GetRandomName(50);
constexpr std::uint32_t unknown_id = std::numeric_limits<std::uint32_t>::max();
ASSERT_THROW(client->delete_user(make_string_identifier(unknown_user)), std::exception);
ASSERT_THROW(client->delete_user(make_numeric_identifier(unknown_id)), std::exception);
}
TEST_F(LowLevelE2E_Client, DeleteUserRejectsRootWithoutChangingRoot) {
RecordProperty("description", "Rejects root deletion by username and numeric ID without changing root.");
iggy::ffi::Client *client = GetLoggedInClient();
iggy::ffi::UserInfoDetails before_by_username{};
iggy::ffi::UserInfoDetails before_by_id{};
ASSERT_NO_THROW({ before_by_username = client->get_user(make_string_identifier("iggy")); });
ASSERT_NO_THROW({ before_by_id = client->get_user(make_numeric_identifier(0)); });
ASSERT_EQ(before_by_username.id, 0u);
ASSERT_EQ(before_by_id.id, 0u);
ASSERT_THROW(client->delete_user(make_string_identifier("iggy")), std::exception);
ASSERT_THROW(client->delete_user(make_numeric_identifier(0)), std::exception);
iggy::ffi::UserInfoDetails after_by_username{};
iggy::ffi::UserInfoDetails after_by_id{};
ASSERT_NO_THROW({ after_by_username = client->get_user(make_string_identifier("iggy")); });
ASSERT_NO_THROW({ after_by_id = client->get_user(make_numeric_identifier(0)); });
for (const auto *root : {&after_by_username, &after_by_id}) {
EXPECT_EQ(root->id, 0u);
EXPECT_EQ(static_cast<std::string>(root->username), "iggy");
EXPECT_EQ(root->status, iggy::ffi::UserStatus::Active);
}
}
TEST_F(LowLevelE2E_Client, DeleteUserByNumericIdRemovesTarget) {
RecordProperty("description", "Deletes a user selected by its numeric ID.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string username = GetRandomName(50);
iggy::ffi::UserInfoDetails created_user{};
ASSERT_NO_THROW({ created_user = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW(client->delete_user(make_numeric_identifier(created_user.id)));
ForgetUser(username);
ASSERT_THROW(client->get_user(make_string_identifier(username)), std::exception);
rust::Vec<iggy::ffi::UserInfo> users;
ASSERT_NO_THROW({ users = client->get_users(); });
for (const auto &user : users) {
EXPECT_FALSE(user.id == created_user.id && static_cast<std::string>(user.username) == username);
}
}
TEST_F(LowLevelE2E_Client, DeleteUserRejectsRepeatedDeletion) {
RecordProperty("description", "Rejects deleting the same user twice.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string username = GetRandomName(50);
iggy::ffi::UserInfoDetails created_user{};
ASSERT_NO_THROW({ created_user = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW(client->delete_user(make_string_identifier(username)));
ForgetUser(username);
ASSERT_THROW(client->delete_user(make_string_identifier(username)), std::exception);
rust::Vec<iggy::ffi::UserInfo> users;
ASSERT_NO_THROW({ users = client->get_users(); });
for (const auto &user : users) {
EXPECT_FALSE(user.id == created_user.id && static_cast<std::string>(user.username) == username);
}
}
TEST_F(LowLevelE2E_Client, ReadUsersPermissionDoesNotAllowDeleteUser) {
RecordProperty("description", "Rejects other-user and self deletion with read_users but not manage_users.");
iggy::ffi::Client *root_client = GetLoggedInClient();
iggy::ffi::Client *caller_client = GetLoggedOutClient();
const std::string caller_name = GetRandomName(50);
const std::string target_name = GetRandomName(50);
iggy::ffi::Permissions permissions{};
permissions.global.read_users = true;
permissions.global.manage_users = false;
iggy::ffi::UserInfoDetails caller{};
iggy::ffi::UserInfoDetails target{};
ASSERT_NO_THROW({
caller = CreateUser(root_client, caller_name, "caller-secret", iggy::ffi::UserStatus::Active, true,
std::move(permissions));
});
ASSERT_NO_THROW({ target = CreateUser(root_client, target_name, "target-secret", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW(caller_client->connect());
ASSERT_NO_THROW(caller_client->login_user(caller_name, "caller-secret"));
ASSERT_THROW(caller_client->delete_user(make_string_identifier(target_name)), std::exception);
ASSERT_THROW(caller_client->delete_user(make_string_identifier(caller_name)), std::exception);
iggy::ffi::UserInfoDetails fetched_caller{};
iggy::ffi::UserInfoDetails fetched_target{};
ASSERT_NO_THROW({ fetched_caller = root_client->get_user(make_string_identifier(caller_name)); });
ASSERT_NO_THROW({ fetched_target = root_client->get_user(make_string_identifier(target_name)); });
EXPECT_EQ(fetched_caller.id, caller.id);
EXPECT_EQ(fetched_target.id, target.id);
}
TEST_F(LowLevelE2E_Client, ManageUsersPermissionAllowsDeleteUser) {
RecordProperty("description", "Allows deletion with manage_users even when read_users is false.");
iggy::ffi::Client *root_client = GetLoggedInClient();
iggy::ffi::Client *manager_client = GetLoggedOutClient();
const std::string manager_name = GetRandomName(50);
const std::string target_name = GetRandomName(50);
iggy::ffi::Permissions permissions{};
permissions.global.manage_users = true;
permissions.global.read_users = false;
iggy::ffi::UserInfoDetails target{};
ASSERT_NO_THROW({
CreateUser(root_client, manager_name, "manager-secret", iggy::ffi::UserStatus::Active, true,
std::move(permissions));
});
ASSERT_NO_THROW({ target = CreateUser(root_client, target_name, "target-secret", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW(manager_client->connect());
ASSERT_NO_THROW(manager_client->login_user(manager_name, "manager-secret"));
ASSERT_NO_THROW(manager_client->delete_user(make_string_identifier(target_name)));
ForgetUser(target_name);
ASSERT_THROW(root_client->get_user(make_string_identifier(target_name)), std::exception);
rust::Vec<iggy::ffi::UserInfo> users;
ASSERT_NO_THROW({ users = root_client->get_users(); });
for (const auto &user : users) {
EXPECT_FALSE(user.id == target.id && static_cast<std::string>(user.username) == target_name);
}
}
TEST_F(LowLevelE2E_Client, DeleteUserRemovesOnlyTheTarget) {
RecordProperty("description", "Deletes only the selected user and leaves another user usable.");
iggy::ffi::Client *root_client = GetLoggedInClient();
iggy::ffi::Client *survivor_client = GetLoggedOutClient();
const std::string target_name = GetRandomName(50);
const std::string survivor_name = GetRandomName(50);
iggy::ffi::UserInfoDetails target{};
iggy::ffi::UserInfoDetails survivor{};
ASSERT_NO_THROW({ target = CreateUser(root_client, target_name, "target-secret", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW(
{ survivor = CreateUser(root_client, survivor_name, "survivor-secret", iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW(root_client->delete_user(make_numeric_identifier(target.id)));
ForgetUser(target_name);
ASSERT_THROW(root_client->get_user(make_string_identifier(target_name)), std::exception);
rust::Vec<iggy::ffi::UserInfo> users;
ASSERT_NO_THROW({ users = root_client->get_users(); });
for (const auto &user : users) {
EXPECT_FALSE(user.id == target.id && static_cast<std::string>(user.username) == target_name);
}
iggy::ffi::UserInfoDetails fetched_survivor{};
ASSERT_NO_THROW({ fetched_survivor = root_client->get_user(make_string_identifier(survivor_name)); });
EXPECT_EQ(fetched_survivor.id, survivor.id);
EXPECT_EQ(static_cast<std::string>(fetched_survivor.username), survivor_name);
EXPECT_EQ(fetched_survivor.status, survivor.status);
ASSERT_NO_THROW(survivor_client->connect());
ASSERT_NO_THROW(survivor_client->login_user(survivor_name, "survivor-secret"));
}
TEST_F(LowLevelE2E_Client, DeleteInactiveUserSucceeds) {
RecordProperty("description", "Deletes an inactive user by username.");
iggy::ffi::Client *client = GetLoggedInClient();
const std::string username = GetRandomName(50);
iggy::ffi::UserInfoDetails created_user{};
ASSERT_NO_THROW({ created_user = CreateUser(client, username, "secret123", iggy::ffi::UserStatus::Inactive); });
ASSERT_NO_THROW(client->delete_user(make_string_identifier(username)));
ForgetUser(username);
ASSERT_THROW(client->get_user(make_string_identifier(username)), std::exception);
rust::Vec<iggy::ffi::UserInfo> users;
ASSERT_NO_THROW({ users = client->get_users(); });
for (const auto &user : users) {
EXPECT_FALSE(user.id == created_user.id && static_cast<std::string>(user.username) == username);
}
}
TEST_F(LowLevelE2E_Client, DeleteUserRevokesExistingSessions) {
RecordProperty("description", "Revokes an existing session and prevents fresh login after user deletion.");
iggy::ffi::Client *root_client = GetLoggedInClient();
iggy::ffi::Client *target_client = GetLoggedOutClient();
iggy::ffi::Client *fresh_client = GetLoggedOutClient();
const std::string username = GetRandomName(50);
const std::string password = "target-secret";
iggy::ffi::Permissions permissions{};
permissions.global.read_servers = true;
ASSERT_NO_THROW(
{ CreateUser(root_client, username, password, iggy::ffi::UserStatus::Active, true, std::move(permissions)); });
ASSERT_NO_THROW(target_client->connect());
ASSERT_NO_THROW(target_client->login_user(username, password));
iggy::ffi::Stats stats{};
ASSERT_NO_THROW({ stats = target_client->get_stats(); });
ASSERT_NO_THROW(root_client->delete_user(make_string_identifier(username)));
ForgetUser(username);
ASSERT_THROW(target_client->get_stats(), std::exception);
ASSERT_NO_THROW(fresh_client->connect());
ASSERT_THROW(fresh_client->login_user(username, password), std::exception);
}
TEST_F(LowLevelE2E_Client, DeletedUsernameCanBeRecreatedWithoutOldCredentialsOrPermissions) {
RecordProperty("description", "Recreates a deleted username without retaining old credentials or permissions.");
iggy::ffi::Client *root_client = GetLoggedInClient();
iggy::ffi::Client *new_password_client = GetLoggedOutClient();
iggy::ffi::Client *old_password_client = GetLoggedOutClient();
const std::string username = GetRandomName(50);
const std::string old_password = "old-secret";
const std::string new_password = "new-secret";
iggy::ffi::Permissions permissions{};
permissions.global.read_servers = true;
ASSERT_NO_THROW({
CreateUser(root_client, username, old_password, iggy::ffi::UserStatus::Active, true, std::move(permissions));
});
ASSERT_NO_THROW(root_client->delete_user(make_string_identifier(username)));
ForgetUser(username);
iggy::ffi::UserInfoDetails replacement{};
ASSERT_NO_THROW({ replacement = CreateUser(root_client, username, new_password, iggy::ffi::UserStatus::Active); });
EXPECT_EQ(static_cast<std::string>(replacement.username), username);
EXPECT_FALSE(replacement.has_permissions);
EXPECT_FALSE(replacement.permissions.global.read_servers);
EXPECT_TRUE(replacement.permissions.streams.empty());
iggy::ffi::UserInfoDetails fetched_replacement{};
ASSERT_NO_THROW({ fetched_replacement = root_client->get_user(make_string_identifier(username)); });
EXPECT_EQ(fetched_replacement.id, replacement.id);
ASSERT_NO_THROW(new_password_client->connect());
ASSERT_NO_THROW(new_password_client->login_user(username, new_password));
ASSERT_NO_THROW(old_password_client->connect());
ASSERT_THROW(old_password_client->login_user(username, old_password), std::exception);
}
TEST_F(LowLevelE2E_Client, ChangePasswordBeforeLoginThrows) {
RecordProperty("description",
"Rejects change_password before connect, after connect but before login, and after disconnect.");
iggy::ffi::Client *client = GetLoggedOutClient();
const auto user_id = make_string_identifier("iggy");
const std::string old_password = "iggy";
const std::string new_password = "iggy-updated-secret";
ASSERT_THROW(client->change_password(user_id, old_password, new_password), std::exception);
ASSERT_NO_THROW(client->connect());
ASSERT_THROW(client->change_password(user_id, old_password, new_password), std::exception);
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->disconnect());
ASSERT_THROW(client->change_password(user_id, old_password, new_password), std::exception);
}
TEST_F(LowLevelE2E_Client, ChangePasswordWithInvalidCurrentPasswordThrows) {
RecordProperty("description", "Rejects change_password when the provided current password is incorrect.");
iggy::ffi::Client *client = GetLoggedInClient();
const auto user_id = make_string_identifier("iggy");
const std::string wrong_password = "not-the-current-password";
const std::string new_password = "iggy-updated-secret";
ASSERT_THROW(client->change_password(user_id, wrong_password, new_password), std::exception);
ASSERT_NO_THROW(client->logout_user());
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
}
TEST_F(LowLevelE2E_Client, ChangePasswordWithInvalidNewPasswordThrows) {
RecordProperty("description",
"Rejects change_password when the replacement password violates client-side length bounds.");
iggy::ffi::Client *client = GetLoggedInClient();
const auto user_id = make_string_identifier("iggy");
const std::string old_password = "iggy";
const std::string too_short = "";
const std::string too_long(256, 'a');
ASSERT_THROW(client->change_password(user_id, old_password, too_short), std::exception);
ASSERT_THROW(client->change_password(user_id, old_password, too_long), std::exception);
ASSERT_NO_THROW(client->logout_user());
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
}
TEST_F(LowLevelE2E_Client, ChangePasswordForWrongUserThrows) {
RecordProperty("description", "Rejects change_password when targeting a user that does not exist.");
iggy::ffi::Client *client = GetLoggedInClient();
const auto wrong_user_id = make_string_identifier(GetRandomName());
const std::string current_password = "iggy";
const std::string replacement_secret = "iggy-updated-secret";
ASSERT_THROW(client->change_password(wrong_user_id, current_password, replacement_secret), std::exception);
ASSERT_NO_THROW(client->logout_user());
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
}
TEST_F(LowLevelE2E_Client, UserWithoutManageUsersCannotChangeAnotherUsersPassword) {
RecordProperty("description",
"Rejects another user's password change without manage_users and preserves the old credentials.");
iggy::ffi::Client *root_client = GetLoggedInClient();
iggy::ffi::Client *actor_client = GetLoggedOutClient();
iggy::ffi::Client *target_client = GetLoggedOutClient();
const std::string actor_name = GetRandomName(50);
const std::string target_name = GetRandomName(50);
const std::string target_password = "target-secret";
const std::string new_password = "replacement-secret";
ASSERT_NO_THROW({ CreateUser(root_client, actor_name, "actor-secret", iggy::ffi::UserStatus::Active); });
iggy::ffi::UserInfoDetails target{};
ASSERT_NO_THROW({ target = CreateUser(root_client, target_name, target_password, iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW(actor_client->connect());
ASSERT_NO_THROW(actor_client->login_user(actor_name, "actor-secret"));
ASSERT_THROW(actor_client->change_password(make_numeric_identifier(target.id), target_password, new_password),
std::exception);
ASSERT_NO_THROW(target_client->connect());
ASSERT_NO_THROW(target_client->login_user(target_name, target_password));
}
TEST_F(LowLevelE2E_Client, ManageUsersPermissionAllowsChangingAnotherUsersPassword) {
RecordProperty("description", "Allows a user with manage_users to change another user's password.");
iggy::ffi::Client *root_client = GetLoggedInClient();
iggy::ffi::Client *manager_client = GetLoggedOutClient();
iggy::ffi::Client *old_password_client = GetLoggedOutClient();
iggy::ffi::Client *new_password_client = GetLoggedOutClient();
const std::string manager_name = GetRandomName(50);
const std::string target_name = GetRandomName(50);
const std::string target_password = "target-secret";
const std::string new_password = "replacement-secret";
iggy::ffi::Permissions permissions{};
permissions.global.manage_users = true;
ASSERT_NO_THROW({
CreateUser(root_client, manager_name, "manager-secret", iggy::ffi::UserStatus::Active, true,
std::move(permissions));
});
iggy::ffi::UserInfoDetails target{};
ASSERT_NO_THROW({ target = CreateUser(root_client, target_name, target_password, iggy::ffi::UserStatus::Active); });
ASSERT_NO_THROW(manager_client->connect());
ASSERT_NO_THROW(manager_client->login_user(manager_name, "manager-secret"));
ASSERT_NO_THROW(manager_client->change_password(make_numeric_identifier(target.id), target_password, new_password));
ASSERT_NO_THROW(old_password_client->connect());
ASSERT_THROW(old_password_client->login_user(target_name, target_password), std::exception);
ASSERT_NO_THROW(new_password_client->connect());
ASSERT_NO_THROW(new_password_client->login_user(target_name, new_password));
}
TEST_F(LowLevelE2E_Client, ChangePasswordUpdatesCredentialsAndCanBeRestored) {
RecordProperty("description",
"Changes the password for the current user, updates login behavior, and restores the original "
"password before the test exits.");
iggy::ffi::Client *client = GetLoggedInClient();
iggy::ffi::Client *second_client = GetLoggedOutClient();
iggy::ffi::Client *third_client = GetLoggedOutClient();
const auto user_id = make_string_identifier("iggy");
const std::string old_password = "iggy";
const std::string new_password = "iggy-updated-secret";
bool password_changed = false;
ASSERT_NO_THROW(client->change_password(user_id, old_password, new_password));
password_changed = true;
EXPECT_THROW(second_client->login_user("iggy", old_password), std::exception);
EXPECT_NO_THROW(second_client->login_user("iggy", new_password));
if (password_changed) {
EXPECT_NO_THROW(client->change_password(user_id, new_password, old_password));
password_changed = false;
}
EXPECT_NO_THROW(third_client->login_user("iggy", old_password));
}
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_NE(client, nullptr);
ASSERT_NO_THROW(client->connect());
ASSERT_THROW(client->login_user("biggy", "biggy"), std::exception);
iggy::ffi::delete_client(client);
client = nullptr;
}
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_NE(client, nullptr);
TrackClient(client);
ASSERT_NO_THROW(client->connect());
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->disconnect());
ASSERT_THROW(client->get_me(), std::exception);
}
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_NE(client, nullptr);
TrackClient(client);
ASSERT_NO_THROW(client->disconnect());
}
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_NE(client, nullptr);
TrackClient(client);
ASSERT_NO_THROW(client->connect());
ASSERT_NO_THROW(client->disconnect());
ASSERT_THROW(client->get_stats(), std::exception);
}
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_NE(client, nullptr);
TrackClient(client);
ASSERT_NO_THROW(client->connect());
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->disconnect());
ASSERT_NO_THROW(client->connect());
ASSERT_THROW(client->get_me(), std::exception);
}
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_NE(client, nullptr);
TrackClient(client);
ASSERT_NO_THROW(client->connect());
ASSERT_THROW(client->login_user("biggy", "biggy"), std::exception);
ASSERT_NO_THROW(client->disconnect());
ASSERT_THROW(client->get_me(), std::exception);
}
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_NE(client, nullptr);
TrackClient(client);
ASSERT_NO_THROW(client->connect());
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->ping());
ASSERT_NO_THROW(client->shutdown());
ASSERT_THROW(client->get_me(), std::exception);
ASSERT_THROW(client->get_stats(), std::exception);
}
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_NE(client, nullptr);
TrackClient(client);
ASSERT_NO_THROW(client->shutdown());
}
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_NE(client, nullptr);
TrackClient(client);
ASSERT_NO_THROW(client->connect());
ASSERT_NO_THROW(client->shutdown());
ASSERT_THROW(client->get_stats(), std::exception);
}
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_NE(client, nullptr);
TrackClient(client);
ASSERT_NO_THROW(client->connect());
ASSERT_THROW(client->login_user("biggy", "biggy"), std::exception);
ASSERT_NO_THROW(client->shutdown());
ASSERT_THROW(client->get_me(), std::exception);
}
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_NE(client, nullptr);
TrackClient(client);
ASSERT_NO_THROW(client->connect());
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->shutdown());
ASSERT_NO_THROW(client->shutdown());
ASSERT_THROW(client->get_me(), std::exception);
}
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_NE(client, nullptr);
TrackClient(client);
ASSERT_NO_THROW(client->connect());
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->shutdown());
ASSERT_THROW(client->connect(), std::exception);
}
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_NE(client, nullptr);
TrackClient(client);
ASSERT_NO_THROW(client->shutdown());
ASSERT_THROW(client->login_user("iggy", "iggy"), std::exception);
}
TEST_F(LowLevelE2E_Client, GetClientsReflectsSessionRemovalAfterShutdown) {
RecordProperty("description",
"Removes a shut down authenticated session from subsequent get_clients and get_client results.");
iggy::ffi::Client *first_client = GetLoggedInClient();
iggy::ffi::Client *second_client = GetLoggedInClient();
iggy::ffi::ClientInfoDetails first_me{};
ASSERT_NO_THROW({ first_me = first_client->get_me(); });
ASSERT_NO_THROW(first_client->shutdown());
constexpr auto removal_timeout = std::chrono::seconds(5);
constexpr auto removal_poll_interval = std::chrono::milliseconds(10);
const auto deadline = std::chrono::steady_clock::now() + removal_timeout;
bool removed = false;
do {
const auto clients = second_client->get_clients();
removed = std::none_of(clients.begin(), clients.end(),
[&first_me](const auto &client) { return client.client_id == first_me.client_id; });
if (removed) {
break;
}
std::this_thread::sleep_for(removal_poll_interval);
} while (std::chrono::steady_clock::now() < deadline);
ASSERT_TRUE(removed);
ASSERT_THROW(second_client->get_client(first_me.client_id), std::exception);
}
TEST_F(LowLevelE2E_Client, GetClientsReflectsSessionRemovalAfterDisconnect) {
RecordProperty("description",
"Removes a disconnected authenticated session from subsequent get_clients and get_client results.");
iggy::ffi::Client *first_client = GetLoggedInClient();
iggy::ffi::Client *second_client = GetLoggedInClient();
iggy::ffi::ClientInfoDetails first_me{};
ASSERT_NO_THROW({ first_me = first_client->get_me(); });
ASSERT_NO_THROW(first_client->disconnect());
constexpr auto removal_timeout = std::chrono::seconds(5);
constexpr auto removal_poll_interval = std::chrono::milliseconds(10);
const auto deadline = std::chrono::steady_clock::now() + removal_timeout;
bool removed = false;
do {
const auto clients = second_client->get_clients();
removed = std::none_of(clients.begin(), clients.end(),
[&first_me](const auto &client) { return client.client_id == first_me.client_id; });
if (removed) {
break;
}
std::this_thread::sleep_for(removal_poll_interval);
} while (std::chrono::steady_clock::now() < deadline);
ASSERT_TRUE(removed);
ASSERT_THROW(second_client->get_client(first_me.client_id), std::exception);
}
TEST_F(LowLevelE2E_Client, GetClientsReflectsLoggedOutSessionAsUnauthenticated) {
RecordProperty("description", "Drops a logged out session from get_clients and reports it missing in get_client.");
iggy::ffi::Client *first_client = GetLoggedInClient();
iggy::ffi::Client *second_client = GetLoggedInClient();
iggy::ffi::ClientInfoDetails first_me{};
ASSERT_NO_THROW({ first_me = first_client->get_me(); });
// The VSR server drops the client-table entry on logout (an unauthenticated
// session is not tracked), unlike the legacy server which kept it visible
// without a user id.
ASSERT_NO_THROW(first_client->logout_user());
constexpr auto removal_timeout = std::chrono::seconds(5);
constexpr auto removal_poll_interval = std::chrono::milliseconds(10);
const auto deadline = std::chrono::steady_clock::now() + removal_timeout;
bool removed = false;
do {
const auto clients = second_client->get_clients();
removed = std::none_of(clients.begin(), clients.end(),
[&first_me](const auto &client) { return client.client_id == first_me.client_id; });
if (removed) {
break;
}
std::this_thread::sleep_for(removal_poll_interval);
} while (std::chrono::steady_clock::now() < deadline);
ASSERT_TRUE(removed);
ASSERT_THROW(second_client->get_client(first_me.client_id), std::exception);
}
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_NE(client, nullptr);
TrackClient(client);
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
}
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_NE(client, nullptr);
ASSERT_NO_THROW(client->connect());
iggy::ffi::delete_client(client);
client = nullptr;
}
TEST_F(LowLevelE2E_Client, DeleteWithoutDisconnect) {
RecordProperty("description", "Allows deleting a connected and authenticated client without disconnecting first.");
iggy::ffi::Client *client = nullptr;
ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
ASSERT_NE(client, nullptr);
ASSERT_NO_THROW(client->connect());
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
iggy::ffi::delete_client(client);
client = nullptr;
}
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_NE(client, nullptr);
ASSERT_NO_THROW(client->connect());
ASSERT_NO_THROW(client->connect());
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
iggy::ffi::delete_client(client);
client = nullptr;
iggy::ffi::delete_client(client);
}
TEST_F(LowLevelE2E_Client, RepeatedDisconnectCallsHaveStableBehavior) {
RecordProperty("description", "Keeps repeated disconnect calls stable across duplicate invocations.");
iggy::ffi::Client *client = nullptr;
ASSERT_NO_THROW({ client = iggy::ffi::new_connection({}); });
ASSERT_NE(client, nullptr);
TrackClient(client);
ASSERT_NO_THROW(client->connect());
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->disconnect());
ASSERT_NO_THROW(client->disconnect());
ASSERT_THROW(client->get_me(), std::exception);
}
TEST_F(LowLevelE2E_Client, DeleteNullConnectionIsNoop) {
RecordProperty("description", "Treats deleting a null client pointer as a no-op.");
iggy::ffi::Client *client = nullptr;
iggy::ffi::delete_client(client);
}
TEST_F(LowLevelE2E_Client, GetStatsBeforeLoginThrows) {
RecordProperty("description",
"Rejects get_stats before connect, after connect but before login, and after disconnect.");
iggy::ffi::Client *client = GetLoggedOutClient();
ASSERT_THROW(client->get_stats(), std::exception);
ASSERT_NO_THROW(client->connect());
ASSERT_THROW(client->get_stats(), std::exception);
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->disconnect());
ASSERT_THROW(client->get_stats(), std::exception);
}
// The VSR server has no unsaved-buffer primitive (writes are journaled at
// commit); FLUSH_UNSAVED_BUFFER denies typed with FeatureUnavailable even for
// resolvable targets.
TEST_F(LowLevelE2E_Client, FlushUnsavedBufferThrowsForExistingPartition) {
RecordProperty("description",
"Rejects flush_unsaved_buffer with the feature-unavailable error for an existing partition.");
const std::string stream_name = GetRandomName();
const std::string topic_name = GetRandomName();
iggy::ffi::Client *client = GetLoggedInClient();
ASSERT_NO_THROW(client->create_stream(stream_name));
auto stream = client->get_stream(make_string_identifier(stream_name));
TrackStream(stream.id);
ASSERT_NO_THROW(client->create_topic(make_numeric_identifier(stream.id), topic_name,
make_topic_create_options(1, "none", "never_expire", 0, "server_default")));
rust::Vec<iggy::ffi::IggyMessageToSend> messages;
messages.push_back(iggy::ffi::make_message(to_payload("flush-me"), rust::Vec<iggy::ffi::HeaderEntry>()));
ASSERT_NO_THROW(client->send_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0),
"partition_id", partition_id_bytes(0), std::move(messages)));
ASSERT_THROW(client->flush_unsaved_buffer(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, true),
std::exception);
}
TEST_F(LowLevelE2E_Client, FlushUnsavedBufferThrowsForExistingEmptyPartition) {
RecordProperty(
"description",
"Rejects flush_unsaved_buffer with the feature-unavailable error for a partition with no unsaved messages.");
const std::string stream_name = GetRandomName();
const std::string topic_name = GetRandomName();
iggy::ffi::Client *client = GetLoggedInClient();
ASSERT_NO_THROW(client->create_stream(stream_name));
auto stream = client->get_stream(make_string_identifier(stream_name));
TrackStream(stream.id);
ASSERT_NO_THROW(client->create_topic(make_numeric_identifier(stream.id), topic_name,
make_topic_create_options(1, "none", "never_expire", 0, "server_default")));
ASSERT_THROW(client->flush_unsaved_buffer(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, true),
std::exception);
}
TEST_F(LowLevelE2E_Client, FlushUnsavedBufferBeforeLoginThrows) {
RecordProperty("description",
"Throws when flush_unsaved_buffer is called before connect, after connect but before login, and "
"after disconnect.");
iggy::ffi::Client *client = GetLoggedOutClient();
ASSERT_THROW(client->flush_unsaved_buffer(make_numeric_identifier(1), make_numeric_identifier(1), 0, true),
std::exception);
ASSERT_NO_THROW(client->connect());
ASSERT_THROW(client->flush_unsaved_buffer(make_numeric_identifier(1), make_numeric_identifier(1), 0, true),
std::exception);
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->disconnect());
ASSERT_THROW(client->flush_unsaved_buffer(make_numeric_identifier(1), make_numeric_identifier(1), 0, true),
std::exception);
}
TEST_F(LowLevelE2E_Client, FlushUnsavedBufferOnNonExistentStreamThrows) {
RecordProperty("description", "Throws when flush_unsaved_buffer is called for a stream that does not exist.");
const std::string stream_name = GetRandomName();
const std::string topic_name = GetRandomName();
iggy::ffi::Client *client = GetLoggedInClient();
ASSERT_NO_THROW(client->create_stream(stream_name));
TrackStream(stream_name);
ASSERT_NO_THROW(client->create_topic(make_string_identifier(stream_name), topic_name,
make_topic_create_options(1, "none", "never_expire", 0, "server_default")));
ASSERT_THROW(
client->flush_unsaved_buffer(make_string_identifier(GetRandomName()), make_numeric_identifier(0), 0, true),
std::exception);
}
TEST_F(LowLevelE2E_Client, FlushUnsavedBufferOnNonExistentTopicThrows) {
RecordProperty("description", "Throws when flush_unsaved_buffer is called for a topic that does not exist.");
const std::string stream_name = GetRandomName();
const std::string topic_name = GetRandomName();
iggy::ffi::Client *client = GetLoggedInClient();
ASSERT_NO_THROW(client->create_stream(stream_name));
TrackStream(stream_name);
ASSERT_NO_THROW(client->create_topic(make_string_identifier(stream_name), topic_name,
make_topic_create_options(1, "none", "never_expire", 0, "server_default")));
ASSERT_THROW(client->flush_unsaved_buffer(make_string_identifier(stream_name),
make_string_identifier(GetRandomName()), 0, true),
std::exception);
}
TEST_F(LowLevelE2E_Client, FlushUnsavedBufferAfterStreamDeletedThrows) {
RecordProperty("description", "Throws when flush_unsaved_buffer is called after the stream has been deleted.");
const std::string stream_name = GetRandomName();
const std::string topic_name = GetRandomName();
iggy::ffi::Client *client = GetLoggedInClient();
ASSERT_NO_THROW(client->create_stream(stream_name));
auto stream = client->get_stream(make_string_identifier(stream_name));
TrackStream(stream.id);
ASSERT_NO_THROW(client->create_topic(make_numeric_identifier(stream.id), topic_name,
make_topic_create_options(1, "none", "never_expire", 0, "server_default")));
const std::uint32_t saved_stream_id = stream.id;
ASSERT_NO_THROW(client->delete_stream(make_numeric_identifier(saved_stream_id)));
ForgetTrackedStream(saved_stream_id);
ASSERT_THROW(
client->flush_unsaved_buffer(make_numeric_identifier(saved_stream_id), make_numeric_identifier(0), 0, true),
std::exception);
}
TEST_F(LowLevelE2E_Client, FlushUnsavedBufferAfterTopicDeletedThrows) {
RecordProperty("description", "Throws when flush_unsaved_buffer is called after the topic has been deleted.");
const std::string stream_name = GetRandomName();
const std::string topic_name = GetRandomName();
iggy::ffi::Client *client = GetLoggedInClient();
ASSERT_NO_THROW(client->create_stream(stream_name));
auto stream = client->get_stream(make_string_identifier(stream_name));
TrackStream(stream.id);
ASSERT_NO_THROW(client->create_topic(make_numeric_identifier(stream.id), topic_name,
make_topic_create_options(1, "none", "never_expire", 0, "server_default")));
ASSERT_NO_THROW(client->delete_topic(make_numeric_identifier(stream.id), make_string_identifier(topic_name)));
ASSERT_THROW(
client->flush_unsaved_buffer(make_numeric_identifier(stream.id), make_string_identifier(topic_name), 0, true),
std::exception);
}
TEST_F(LowLevelE2E_Client, FlushUnsavedBufferTwiceThrows) {
RecordProperty("description",
"Rejects flush_unsaved_buffer with the feature-unavailable error consistently across repeat calls.");
const std::string stream_name = GetRandomName();
const std::string topic_name = GetRandomName();
iggy::ffi::Client *client = GetLoggedInClient();
ASSERT_NO_THROW(client->create_stream(stream_name));
auto stream = client->get_stream(make_string_identifier(stream_name));
TrackStream(stream.id);
ASSERT_NO_THROW(client->create_topic(make_numeric_identifier(stream.id), topic_name,
make_topic_create_options(1, "none", "never_expire", 0, "server_default")));
rust::Vec<iggy::ffi::IggyMessageToSend> messages;
messages.push_back(iggy::ffi::make_message(to_payload("flush-twice"), rust::Vec<iggy::ffi::HeaderEntry>()));
ASSERT_NO_THROW(client->send_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0),
"partition_id", partition_id_bytes(0), std::move(messages)));
ASSERT_THROW(client->flush_unsaved_buffer(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, true),
std::exception);
ASSERT_THROW(client->flush_unsaved_buffer(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, true),
std::exception);
}
TEST_F(LowLevelE2E_Client, FlushUnsavedBufferWithInvalidPartitionIdsThrows) {
RecordProperty("description", "Throws when flush_unsaved_buffer is called for non-existent partition ids.");
const std::string stream_name = GetRandomName();
const std::string topic_name = GetRandomName();
iggy::ffi::Client *client = GetLoggedInClient();
ASSERT_NO_THROW(client->create_stream(stream_name));
auto stream = client->get_stream(make_string_identifier(stream_name));
TrackStream(stream.id);
ASSERT_NO_THROW(client->create_topic(make_numeric_identifier(stream.id), topic_name,
make_topic_create_options(1, "none", "never_expire", 0, "server_default")));
const std::uint32_t invalid_partition_ids[] = {1u, 9999u, static_cast<std::uint32_t>(-1)};
for (const std::uint32_t invalid_partition_id : invalid_partition_ids) {
SCOPED_TRACE(invalid_partition_id);
ASSERT_THROW(client->flush_unsaved_buffer(make_numeric_identifier(stream.id), make_numeric_identifier(0),
invalid_partition_id, true),
std::exception);
}
}
TEST_F(LowLevelE2E_Client, DeleteSegmentsBeforeLoginThrows) {
RecordProperty("description",
"Rejects delete_segments before connect, after connect but before login, and after disconnect.");
const std::string stream_name = GetRandomName();
const std::string topic_name = GetRandomName();
iggy::ffi::Client *setup_client = GetLoggedInClient();
ASSERT_NO_THROW(setup_client->create_stream(stream_name));
TrackStream(stream_name);
ASSERT_NO_THROW(
setup_client->create_topic(make_string_identifier(stream_name), topic_name,
make_topic_create_options(1, "none", "never_expire", 0, "server_default")));
iggy::ffi::Client *unauthenticated_client = GetLoggedOutClient();
ASSERT_THROW(unauthenticated_client->delete_segments(make_string_identifier(stream_name),
make_string_identifier(topic_name), 0, 1),
std::exception);
ASSERT_NO_THROW(unauthenticated_client->connect());
ASSERT_THROW(unauthenticated_client->delete_segments(make_string_identifier(stream_name),
make_string_identifier(topic_name), 0, 1),
std::exception);
ASSERT_NO_THROW(unauthenticated_client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(unauthenticated_client->disconnect());
ASSERT_THROW(unauthenticated_client->delete_segments(make_string_identifier(stream_name),
make_string_identifier(topic_name), 0, 1),
std::exception);
}
TEST_F(LowLevelE2E_Client, DeleteSegmentsOnNonExistentStreamThrows) {
RecordProperty("description", "Throws when deleting segments from a stream that does not exist.");
const std::string stream_name = GetRandomName();
const std::string topic_name = GetRandomName();
const std::string missing_stream_name = GetRandomName();
iggy::ffi::Client *client = GetLoggedInClient();
ASSERT_NO_THROW(client->create_stream(stream_name));
TrackStream(stream_name);
ASSERT_NO_THROW(client->create_topic(make_string_identifier(stream_name), topic_name,
make_topic_create_options(1, "none", "never_expire", 0, "server_default")));
ASSERT_THROW(
client->delete_segments(make_string_identifier(missing_stream_name), make_string_identifier(topic_name), 0, 1),
std::exception);
}
TEST_F(LowLevelE2E_Client, DeleteSegmentsOnNonExistentTopicThrows) {
RecordProperty("description", "Throws when deleting segments from a topic that does not exist.");
const std::string stream_name = GetRandomName();
const std::string topic_name = GetRandomName();
const std::string missing_topic_name = GetRandomName();
iggy::ffi::Client *client = GetLoggedInClient();
ASSERT_NO_THROW(client->create_stream(stream_name));
TrackStream(stream_name);
ASSERT_NO_THROW(client->create_topic(make_string_identifier(stream_name), topic_name,
make_topic_create_options(1, "none", "never_expire", 0, "server_default")));
ASSERT_THROW(
client->delete_segments(make_string_identifier(stream_name), make_string_identifier(missing_topic_name), 0, 1),
std::exception);
}
TEST_F(LowLevelE2E_Client, DeleteSegmentsOnNonExistentPartitionThrows) {
RecordProperty("description", "Throws when deleting segments from a partition that does not exist.");
const std::string stream_name = GetRandomName();
const std::string topic_name = GetRandomName();
iggy::ffi::Client *client = GetLoggedInClient();
ASSERT_NO_THROW(client->create_stream(stream_name));
TrackStream(stream_name);
ASSERT_NO_THROW(client->create_topic(make_string_identifier(stream_name), topic_name,
make_topic_create_options(1, "none", "never_expire", 0, "server_default")));
ASSERT_THROW(
client->delete_segments(make_string_identifier(stream_name), make_string_identifier(topic_name), 999, 1),
std::exception);
}
TEST_F(LowLevelE2E_Client, DeleteSegmentsWithZeroCountIsNoOp) {
RecordProperty("description", "Treats delete_segments with count 0 as a no-op.");
const std::string stream_name = GetRandomName();
const std::string topic_name = GetRandomName();
iggy::ffi::Client *client = GetLoggedInClient();
ASSERT_NO_THROW(client->create_stream(stream_name));
TrackStream(stream_name);
ASSERT_NO_THROW(client->create_topic(make_string_identifier(stream_name), topic_name,
make_topic_create_options(1, "none", "never_expire", 0, "server_default")));
std::uint32_t stream_id = 0;
std::uint32_t topic_id = 0;
ASSERT_NO_THROW({
const auto stream_details = client->get_stream(make_string_identifier(stream_name));
ASSERT_EQ(stream_details.topics.size(), 1u);
stream_id = stream_details.id;
topic_id = stream_details.topics.front().id;
});
rust::Vec<iggy::ffi::IggyMessageToSend> messages;
for (std::uint32_t i = 0; i < 5; ++i) {
messages.push_back(iggy::ffi::make_message(to_payload("zero-count-" + std::to_string(i)),
rust::Vec<iggy::ffi::HeaderEntry>()));
}
ASSERT_NO_THROW(client->send_messages(make_numeric_identifier(stream_id), make_numeric_identifier(topic_id),
"partition_id", partition_id_bytes(0), std::move(messages)));
iggy::ffi::Partition partition_before_delete{};
ASSERT_NO_THROW({
const auto topic_details =
client->get_topic(make_numeric_identifier(stream_id), make_numeric_identifier(topic_id));
for (const auto &partition : topic_details.partitions) {
if (partition.id == 0) {
partition_before_delete = partition;
break;
}
}
});
iggy::ffi::PolledMessages polled_before_delete{};
ASSERT_NO_THROW({
polled_before_delete =
client->poll_messages(make_numeric_identifier(stream_id), make_numeric_identifier(topic_id), 0, "consumer",
make_numeric_identifier(1005), "offset", 0, 1000, false);
});
ASSERT_NO_THROW(
client->delete_segments(make_string_identifier(stream_name), make_string_identifier(topic_name), 0, 0));
iggy::ffi::Partition partition_after_delete{};
ASSERT_NO_THROW({
const auto topic_details =
client->get_topic(make_numeric_identifier(stream_id), make_numeric_identifier(topic_id));
for (const auto &partition : topic_details.partitions) {
if (partition.id == 0) {
partition_after_delete = partition;
break;
}
}
});
iggy::ffi::PolledMessages polled_after_delete{};
ASSERT_NO_THROW({
polled_after_delete =
client->poll_messages(make_numeric_identifier(stream_id), make_numeric_identifier(topic_id), 0, "consumer",
make_numeric_identifier(1006), "offset", 0, 1000, false);
});
EXPECT_EQ(partition_after_delete.segments_count, partition_before_delete.segments_count);
EXPECT_EQ(partition_after_delete.current_offset, partition_before_delete.current_offset);
EXPECT_EQ(partition_after_delete.messages_count, partition_before_delete.messages_count);
EXPECT_EQ(partition_after_delete.size_bytes, partition_before_delete.size_bytes);
EXPECT_EQ(polled_after_delete.count, polled_before_delete.count);
ASSERT_EQ(polled_after_delete.messages.size(), polled_before_delete.messages.size());
for (std::size_t i = 0; i < polled_before_delete.messages.size(); ++i) {
EXPECT_EQ(polled_after_delete.messages[i].offset, polled_before_delete.messages[i].offset);
}
}
TEST_F(LowLevelE2E_Client, DeleteSegmentsWhenOnlyActiveSegmentRemainsIsNoOp) {
RecordProperty("description",
"Keeps the partition unchanged when delete_segments is called with only the active segment.");
const std::string stream_name = GetRandomName();
const std::string topic_name = GetRandomName();
iggy::ffi::Client *client = GetLoggedInClient();
ASSERT_NO_THROW(client->create_stream(stream_name));
TrackStream(stream_name);
ASSERT_NO_THROW(client->create_topic(make_string_identifier(stream_name), topic_name,
make_topic_create_options(1, "none", "never_expire", 0, "server_default")));
std::uint32_t stream_id = 0;
std::uint32_t topic_id = 0;
ASSERT_NO_THROW({
const auto stream_details = client->get_stream(make_string_identifier(stream_name));
ASSERT_EQ(stream_details.topics.size(), 1u);
stream_id = stream_details.id;
topic_id = stream_details.topics.front().id;
});
rust::Vec<iggy::ffi::IggyMessageToSend> messages;
for (std::uint32_t i = 0; i < 5; ++i) {
messages.push_back(iggy::ffi::make_message(to_payload("active-only-" + std::to_string(i)),
rust::Vec<iggy::ffi::HeaderEntry>()));
}
ASSERT_NO_THROW(client->send_messages(make_numeric_identifier(stream_id), make_numeric_identifier(topic_id),
"partition_id", partition_id_bytes(0), std::move(messages)));
iggy::ffi::Partition partition_before_delete{};
ASSERT_NO_THROW({
const auto topic_details =
client->get_topic(make_numeric_identifier(stream_id), make_numeric_identifier(topic_id));
for (const auto &partition : topic_details.partitions) {
if (partition.id == 0) {
partition_before_delete = partition;
break;
}
}
});
ASSERT_EQ(partition_before_delete.segments_count, 1u);
iggy::ffi::PolledMessages polled_before_delete{};
ASSERT_NO_THROW({
polled_before_delete =
client->poll_messages(make_numeric_identifier(stream_id), make_numeric_identifier(topic_id), 0, "consumer",
make_numeric_identifier(1007), "offset", 0, 1000, false);
});
ASSERT_NO_THROW(
client->delete_segments(make_string_identifier(stream_name), make_string_identifier(topic_name), 0, 1));
iggy::ffi::Partition partition_after_delete{};
ASSERT_NO_THROW({
const auto topic_details =
client->get_topic(make_numeric_identifier(stream_id), make_numeric_identifier(topic_id));
for (const auto &partition : topic_details.partitions) {
if (partition.id == 0) {
partition_after_delete = partition;
break;
}
}
});
iggy::ffi::PolledMessages polled_after_delete{};
ASSERT_NO_THROW({
polled_after_delete =
client->poll_messages(make_numeric_identifier(stream_id), make_numeric_identifier(topic_id), 0, "consumer",
make_numeric_identifier(1008), "offset", 0, 1000, false);
});
EXPECT_EQ(partition_after_delete.segments_count, partition_before_delete.segments_count);
EXPECT_EQ(partition_after_delete.current_offset, partition_before_delete.current_offset);
EXPECT_EQ(partition_after_delete.messages_count, partition_before_delete.messages_count);
EXPECT_EQ(partition_after_delete.size_bytes, partition_before_delete.size_bytes);
EXPECT_EQ(polled_after_delete.count, polled_before_delete.count);
ASSERT_EQ(polled_after_delete.messages.size(), polled_before_delete.messages.size());
for (std::size_t i = 0; i < polled_before_delete.messages.size(); ++i) {
EXPECT_EQ(polled_after_delete.messages[i].offset, polled_before_delete.messages[i].offset);
}
}
// TODO(slbotbm): add a test to create some streams, topics, partitions, and segments, send messages, and create
// consumer groups and verify it.
TEST_F(LowLevelE2E_Client, GetStatsReturnsServerStats) {
RecordProperty("description",
"Returns empty resource counts first, then reflects aggregated streams, topics, partitions, "
"consumer groups, and clients.");
const std::string first_stream_name = GetRandomName();
const std::string second_stream_name = GetRandomName();
const std::string first_topic_name = GetRandomName();
const std::string second_topic_name = GetRandomName();
const std::string third_topic_name = GetRandomName();
const std::string first_group_name = GetRandomName();
const std::string second_group_name = GetRandomName();
const std::string third_group_name = GetRandomName();
constexpr std::uint32_t additional_partitions_count = 2;
iggy::ffi::Client *client = GetLoggedInClient();
iggy::ffi::Client *second_client = nullptr;
iggy::ffi::Client *third_client = nullptr;
iggy::ffi::Stats empty_stats{};
iggy::ffi::Stats stats_after_create{};
ASSERT_NO_THROW({
empty_stats = client->get_stats();
EXPECT_NE(empty_stats.process_id, 0u);
EXPECT_GT(empty_stats.threads_count, 0u);
EXPECT_GT(empty_stats.total_memory, 0u);
EXPECT_LE(empty_stats.available_memory, empty_stats.total_memory);
EXPECT_GE(empty_stats.total_disk_space, empty_stats.free_disk_space);
EXPECT_FALSE(static_cast<std::string>(empty_stats.hostname).empty());
EXPECT_FALSE(static_cast<std::string>(empty_stats.os_name).empty());
EXPECT_FALSE(static_cast<std::string>(empty_stats.os_version).empty());
EXPECT_FALSE(static_cast<std::string>(empty_stats.kernel_version).empty());
EXPECT_FALSE(static_cast<std::string>(empty_stats.iggy_server_version).empty());
});
ASSERT_NO_THROW(client->create_stream(first_stream_name));
TrackStream(first_stream_name);
ASSERT_NO_THROW(client->create_stream(second_stream_name));
TrackStream(second_stream_name);
ASSERT_NO_THROW(client->create_topic(make_string_identifier(first_stream_name), first_topic_name,
make_topic_create_options(1, "none", "server_default", 0, "server_default")));
ASSERT_NO_THROW(client->create_topic(make_string_identifier(first_stream_name), second_topic_name,
make_topic_create_options(2, "none", "server_default", 0, "server_default")));
ASSERT_NO_THROW(client->create_topic(make_string_identifier(second_stream_name), third_topic_name,
make_topic_create_options(3, "none", "server_default", 0, "server_default")));
ASSERT_NO_THROW(client->create_partitions(make_string_identifier(first_stream_name),
make_string_identifier(first_topic_name), additional_partitions_count));
const auto first_group = client->create_consumer_group(make_string_identifier(first_stream_name),
make_string_identifier(first_topic_name), first_group_name);
const auto second_group = client->create_consumer_group(
make_string_identifier(first_stream_name), make_string_identifier(second_topic_name), second_group_name);
const auto third_group = client->create_consumer_group(make_string_identifier(second_stream_name),
make_string_identifier(third_topic_name), third_group_name);
ASSERT_NO_THROW({ second_client = GetLoggedInClient(); });
ASSERT_NE(second_client, nullptr);
ASSERT_NO_THROW({ third_client = GetLoggedInClient(); });
ASSERT_NE(third_client, nullptr);
const auto first_stream_details = client->get_stream(make_string_identifier(first_stream_name));
const auto second_stream_details = client->get_stream(make_string_identifier(second_stream_name));
const std::uint32_t expected_topics_count = first_stream_details.topics_count + second_stream_details.topics_count;
std::uint32_t first_topic_partitions = 0;
std::uint32_t second_topic_partitions = 0;
std::uint32_t third_topic_partitions = 0;
for (const auto &topic : first_stream_details.topics) {
if (topic.name == first_topic_name) {
first_topic_partitions = topic.partitions_count;
}
if (topic.name == second_topic_name) {
second_topic_partitions = topic.partitions_count;
}
}
for (const auto &topic : second_stream_details.topics) {
if (topic.name == third_topic_name) {
third_topic_partitions = topic.partitions_count;
}
}
const std::uint32_t expected_partitions_count =
first_topic_partitions + second_topic_partitions + third_topic_partitions;
ASSERT_NO_THROW({
stats_after_create = client->get_stats();
EXPECT_GE(stats_after_create.streams_count, empty_stats.streams_count + 2u);
EXPECT_GE(stats_after_create.topics_count, empty_stats.topics_count + expected_topics_count);
EXPECT_GE(stats_after_create.partitions_count, empty_stats.partitions_count + expected_partitions_count);
EXPECT_GE(stats_after_create.segments_count, empty_stats.segments_count + expected_partitions_count);
EXPECT_GE(stats_after_create.consumer_groups_count, empty_stats.consumer_groups_count + 3u);
EXPECT_GE(stats_after_create.clients_count, empty_stats.clients_count + 2u);
EXPECT_EQ(first_group.partitions_count, first_topic_partitions);
EXPECT_EQ(second_group.partitions_count, second_topic_partitions);
EXPECT_EQ(third_group.partitions_count, third_topic_partitions);
});
ASSERT_NO_THROW(client->delete_stream(make_string_identifier(second_stream_name)));
ForgetTrackedStream(second_stream_name);
ASSERT_NO_THROW(client->delete_stream(make_string_identifier(first_stream_name)));
ForgetTrackedStream(first_stream_name);
DeleteClient(third_client);
DeleteClient(second_client);
ASSERT_NO_THROW({
const auto stats = client->get_stats();
EXPECT_LE(stats.streams_count, stats_after_create.streams_count);
EXPECT_LE(stats.topics_count, stats_after_create.topics_count);
EXPECT_LE(stats.partitions_count, stats_after_create.partitions_count);
EXPECT_LE(stats.segments_count, stats_after_create.segments_count);
EXPECT_LE(stats.consumer_groups_count, stats_after_create.consumer_groups_count);
EXPECT_LE(stats.clients_count, stats_after_create.clients_count);
});
}
TEST_F(LowLevelE2E_Client, GetStatsIsStableAcrossBackToBackCalls) {
RecordProperty(
"description",
"Returns sane invariant fields across back-to-back get_stats calls on an idle authenticated client.");
iggy::ffi::Client *client = GetLoggedInClient();
iggy::ffi::Stats first_stats{};
iggy::ffi::Stats second_stats{};
ASSERT_NO_THROW({
first_stats = client->get_stats();
second_stats = client->get_stats();
});
EXPECT_NE(first_stats.process_id, 0u);
EXPECT_NE(second_stats.process_id, 0u);
EXPECT_EQ(second_stats.process_id, first_stats.process_id);
EXPECT_GT(first_stats.threads_count, 0u);
EXPECT_GT(second_stats.threads_count, 0u);
EXPECT_GT(first_stats.total_memory, 0u);
EXPECT_GT(second_stats.total_memory, 0u);
EXPECT_FALSE(static_cast<std::string>(first_stats.hostname).empty());
EXPECT_FALSE(static_cast<std::string>(second_stats.hostname).empty());
EXPECT_FALSE(static_cast<std::string>(first_stats.os_name).empty());
EXPECT_FALSE(static_cast<std::string>(second_stats.os_name).empty());
EXPECT_FALSE(static_cast<std::string>(first_stats.os_version).empty());
EXPECT_FALSE(static_cast<std::string>(second_stats.os_version).empty());
EXPECT_FALSE(static_cast<std::string>(first_stats.kernel_version).empty());
EXPECT_FALSE(static_cast<std::string>(second_stats.kernel_version).empty());
EXPECT_FALSE(static_cast<std::string>(first_stats.iggy_server_version).empty());
EXPECT_FALSE(static_cast<std::string>(second_stats.iggy_server_version).empty());
EXPECT_EQ(static_cast<std::string>(second_stats.hostname), static_cast<std::string>(first_stats.hostname));
EXPECT_EQ(static_cast<std::string>(second_stats.os_name), static_cast<std::string>(first_stats.os_name));
EXPECT_EQ(static_cast<std::string>(second_stats.os_version), static_cast<std::string>(first_stats.os_version));
EXPECT_EQ(static_cast<std::string>(second_stats.kernel_version),
static_cast<std::string>(first_stats.kernel_version));
EXPECT_EQ(static_cast<std::string>(second_stats.iggy_server_version),
static_cast<std::string>(first_stats.iggy_server_version));
EXPECT_EQ(second_stats.has_server_semver, first_stats.has_server_semver);
EXPECT_EQ(second_stats.iggy_server_semver, first_stats.iggy_server_semver);
EXPECT_GE(first_stats.clients_count, 1u);
EXPECT_GE(second_stats.clients_count, 1u);
}
TEST_F(LowLevelE2E_Client, GetMeBeforeLoginThrows) {
RecordProperty("description",
"Rejects get_me before connect, after connect but before login, and after disconnect.");
iggy::ffi::Client *client = GetLoggedOutClient();
ASSERT_THROW(client->get_me(), std::exception);
ASSERT_NO_THROW(client->connect());
ASSERT_THROW(client->get_me(), std::exception);
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->disconnect());
ASSERT_THROW(client->get_me(), std::exception);
}
TEST_F(LowLevelE2E_Client, GetMeReturnsCurrentClientDetails) {
RecordProperty("description", "Returns the current authenticated client details.");
iggy::ffi::Client *client = GetLoggedInClient();
ASSERT_NO_THROW({
const auto me = client->get_me();
EXPECT_NE(me.client_id, 0u);
EXPECT_TRUE(me.has_user_id);
EXPECT_FALSE(static_cast<std::string>(me.address).empty());
EXPECT_EQ(static_cast<std::string>(me.transport), "TCP");
EXPECT_EQ(me.consumer_groups_count, 0u);
EXPECT_TRUE(me.consumer_groups.empty());
});
}
TEST_F(LowLevelE2E_Client, GetMeReflectsConsumerGroupMembershipChanges) {
RecordProperty("description", "Reflects joined consumer groups in get_me and removes them again after leaving.");
const std::string stream_name = GetRandomName();
const std::string topic_name = GetRandomName();
const std::string group_name = GetRandomName();
iggy::ffi::Client *client = GetLoggedInClient();
ASSERT_NO_THROW(client->create_stream(stream_name));
TrackStream(stream_name);
ASSERT_NO_THROW(client->create_topic(make_string_identifier(stream_name), topic_name,
make_topic_create_options(1, "none", "server_default", 0, "server_default")));
const auto stream_details = client->get_stream(make_string_identifier(stream_name));
ASSERT_EQ(stream_details.topics.size(), 1u);
const auto created_group = client->create_consumer_group(make_string_identifier(stream_name),
make_string_identifier(topic_name), group_name);
std::size_t baseline_groups_size = 0;
std::uint32_t baseline_groups_count = 0;
ASSERT_NO_THROW({
const auto me = client->get_me();
baseline_groups_count = me.consumer_groups_count;
baseline_groups_size = me.consumer_groups.size();
});
ASSERT_NO_THROW(client->join_consumer_group(make_numeric_identifier(stream_details.id),
make_numeric_identifier(stream_details.topics[0].id),
make_numeric_identifier(created_group.id)));
ASSERT_NO_THROW({
const auto me = client->get_me();
EXPECT_GT(me.consumer_groups_count, baseline_groups_count);
EXPECT_GT(me.consumer_groups.size(), baseline_groups_size);
bool found_group = false;
for (const auto &group : me.consumer_groups) {
if (group.stream_id != stream_details.id || group.topic_id != stream_details.topics[0].id ||
group.group_id != created_group.id) {
continue;
}
found_group = true;
break;
}
EXPECT_TRUE(found_group);
});
ASSERT_NO_THROW(client->leave_consumer_group(make_numeric_identifier(stream_details.id),
make_numeric_identifier(stream_details.topics[0].id),
make_numeric_identifier(created_group.id)));
ASSERT_NO_THROW({
const auto me = client->get_me();
EXPECT_GE(me.consumer_groups_count, baseline_groups_count);
EXPECT_GE(me.consumer_groups.size(), baseline_groups_size);
bool found_group = false;
for (const auto &group : me.consumer_groups) {
if (group.stream_id != stream_details.id || group.topic_id != stream_details.topics[0].id ||
group.group_id != created_group.id) {
continue;
}
found_group = true;
break;
}
EXPECT_FALSE(found_group);
});
}
TEST_F(LowLevelE2E_Client, GetMeIsStableAcrossBackToBackCalls) {
RecordProperty("description", "Returns stable current-client details across back-to-back get_me calls.");
iggy::ffi::Client *client = GetLoggedInClient();
iggy::ffi::ClientInfoDetails first_me{};
iggy::ffi::ClientInfoDetails second_me{};
ASSERT_NO_THROW({
first_me = client->get_me();
second_me = client->get_me();
});
EXPECT_NE(first_me.client_id, 0u);
EXPECT_TRUE(first_me.has_user_id);
EXPECT_TRUE(second_me.has_user_id);
EXPECT_EQ(second_me.client_id, first_me.client_id);
EXPECT_EQ(second_me.has_user_id, first_me.has_user_id);
EXPECT_EQ(second_me.user_id, first_me.user_id);
EXPECT_EQ(static_cast<std::string>(second_me.address), static_cast<std::string>(first_me.address));
EXPECT_EQ(static_cast<std::string>(first_me.transport), "TCP");
EXPECT_EQ(static_cast<std::string>(second_me.transport), "TCP");
EXPECT_EQ(static_cast<std::string>(second_me.transport), static_cast<std::string>(first_me.transport));
EXPECT_EQ(second_me.consumer_groups_count, first_me.consumer_groups_count);
EXPECT_EQ(second_me.consumer_groups.size(), first_me.consumer_groups.size());
}
TEST_F(LowLevelE2E_Client, GetMeReturnsDistinctClientIdsForDifferentSessions) {
RecordProperty(
"description",
"Returns different client ids for separate authenticated sessions while keeping the same user identity.");
iggy::ffi::Client *first_client = GetLoggedInClient();
iggy::ffi::Client *second_client = GetLoggedInClient();
iggy::ffi::ClientInfoDetails first_me{};
iggy::ffi::ClientInfoDetails second_me{};
ASSERT_NO_THROW({
first_me = first_client->get_me();
second_me = second_client->get_me();
});
EXPECT_NE(first_me.client_id, 0u);
EXPECT_NE(second_me.client_id, 0u);
EXPECT_TRUE(first_me.has_user_id);
EXPECT_TRUE(second_me.has_user_id);
EXPECT_NE(second_me.client_id, first_me.client_id);
EXPECT_EQ(second_me.has_user_id, first_me.has_user_id);
EXPECT_EQ(second_me.user_id, first_me.user_id);
EXPECT_EQ(static_cast<std::string>(first_me.transport), "TCP");
EXPECT_EQ(static_cast<std::string>(second_me.transport), "TCP");
}
TEST_F(LowLevelE2E_Client, GetMeReturnsValidDetailsAfterReconnect) {
RecordProperty("description",
"Returns valid current-client details after reconnecting with a fresh authenticated session.");
iggy::ffi::Client *first_client = GetLoggedInClient();
iggy::ffi::ClientInfoDetails first_me{};
ASSERT_NO_THROW({ first_me = first_client->get_me(); });
EXPECT_NE(first_me.client_id, 0u);
EXPECT_TRUE(first_me.has_user_id);
DeleteClient(first_client);
iggy::ffi::Client *second_client = GetLoggedInClient();
iggy::ffi::ClientInfoDetails second_me{};
ASSERT_NO_THROW({ second_me = second_client->get_me(); });
EXPECT_NE(second_me.client_id, 0u);
EXPECT_TRUE(second_me.has_user_id);
EXPECT_EQ(second_me.has_user_id, first_me.has_user_id);
EXPECT_EQ(second_me.user_id, first_me.user_id);
EXPECT_EQ(static_cast<std::string>(first_me.transport), "TCP");
EXPECT_EQ(static_cast<std::string>(second_me.transport), "TCP");
EXPECT_EQ(static_cast<std::string>(second_me.transport), static_cast<std::string>(first_me.transport));
EXPECT_FALSE(static_cast<std::string>(second_me.address).empty());
EXPECT_EQ(second_me.consumer_groups_count, 0u);
EXPECT_TRUE(second_me.consumer_groups.empty());
}
TEST_F(LowLevelE2E_Client, GetClientBeforeLoginThrows) {
RecordProperty("description",
"Rejects get_client before connect, after connect but before login, and after disconnect.");
iggy::ffi::Client *client = GetLoggedOutClient();
ASSERT_THROW(client->get_client(1), std::exception);
ASSERT_NO_THROW(client->connect());
ASSERT_THROW(client->get_client(1), std::exception);
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->disconnect());
ASSERT_THROW(client->get_client(1), std::exception);
}
TEST_F(LowLevelE2E_Client, GetClientWithWrongClientIdThrows) {
RecordProperty("description", "Rejects querying invalid or non-existent client ids.");
iggy::ffi::Client *client = GetLoggedInClient();
std::uint32_t non_existent_client_id = 1u;
ASSERT_NO_THROW({
const auto clients = client->get_clients();
std::unordered_set<std::uint32_t> client_ids;
for (const auto &entry : clients) {
client_ids.insert(entry.client_id);
}
while (client_ids.find(non_existent_client_id) != client_ids.end()) {
++non_existent_client_id;
}
});
const std::uint32_t wrong_client_ids[] = {0u, non_existent_client_id};
for (const std::uint32_t wrong_client_id : wrong_client_ids) {
SCOPED_TRACE(wrong_client_id);
ASSERT_THROW(client->get_client(wrong_client_id), std::exception);
}
}
TEST_F(LowLevelE2E_Client, GetClientReturnsDetailsForMatchingClientId) {
RecordProperty("description", "Returns current client details when querying with the authenticated client id.");
iggy::ffi::Client *client = GetLoggedInClient();
iggy::ffi::ClientInfoDetails current_client{};
iggy::ffi::ClientInfoDetails looked_up_client{};
ASSERT_NO_THROW({
current_client = client->get_me();
looked_up_client = client->get_client(current_client.client_id);
});
EXPECT_NE(current_client.client_id, 0u);
EXPECT_TRUE(current_client.has_user_id);
EXPECT_TRUE(looked_up_client.has_user_id);
EXPECT_EQ(looked_up_client.client_id, current_client.client_id);
EXPECT_EQ(looked_up_client.has_user_id, current_client.has_user_id);
EXPECT_EQ(looked_up_client.user_id, current_client.user_id);
EXPECT_EQ(static_cast<std::string>(looked_up_client.address), static_cast<std::string>(current_client.address));
EXPECT_EQ(static_cast<std::string>(looked_up_client.transport), "TCP");
EXPECT_EQ(static_cast<std::string>(looked_up_client.transport), static_cast<std::string>(current_client.transport));
EXPECT_EQ(looked_up_client.consumer_groups_count, current_client.consumer_groups_count);
EXPECT_EQ(looked_up_client.consumer_groups.size(), current_client.consumer_groups.size());
}
TEST_F(LowLevelE2E_Client, GetClientIsStableAcrossBackToBackCalls) {
RecordProperty("description", "Returns stable client details across back-to-back get_client calls.");
iggy::ffi::Client *client = GetLoggedInClient();
iggy::ffi::ClientInfoDetails current_client{};
iggy::ffi::ClientInfoDetails first_lookup{};
iggy::ffi::ClientInfoDetails second_lookup{};
ASSERT_NO_THROW({
current_client = client->get_me();
first_lookup = client->get_client(current_client.client_id);
second_lookup = client->get_client(current_client.client_id);
});
EXPECT_NE(current_client.client_id, 0u);
EXPECT_TRUE(current_client.has_user_id);
EXPECT_TRUE(first_lookup.has_user_id);
EXPECT_TRUE(second_lookup.has_user_id);
EXPECT_EQ(first_lookup.client_id, current_client.client_id);
EXPECT_EQ(second_lookup.client_id, first_lookup.client_id);
EXPECT_EQ(first_lookup.has_user_id, current_client.has_user_id);
EXPECT_EQ(second_lookup.has_user_id, first_lookup.has_user_id);
EXPECT_EQ(second_lookup.user_id, first_lookup.user_id);
EXPECT_EQ(static_cast<std::string>(second_lookup.address), static_cast<std::string>(first_lookup.address));
EXPECT_EQ(static_cast<std::string>(first_lookup.transport), "TCP");
EXPECT_EQ(static_cast<std::string>(second_lookup.transport), "TCP");
EXPECT_EQ(static_cast<std::string>(second_lookup.transport), static_cast<std::string>(first_lookup.transport));
EXPECT_EQ(second_lookup.consumer_groups_count, first_lookup.consumer_groups_count);
EXPECT_EQ(second_lookup.consumer_groups.size(), first_lookup.consumer_groups.size());
}
TEST_F(LowLevelE2E_Client, GetClientsBeforeLoginThrows) {
RecordProperty("description",
"Rejects get_clients before connect, after connect but before login, and after disconnect.");
iggy::ffi::Client *client = GetLoggedOutClient();
ASSERT_THROW(client->get_clients(), std::exception);
ASSERT_NO_THROW(client->connect());
ASSERT_THROW(client->get_clients(), std::exception);
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->disconnect());
ASSERT_THROW(client->get_clients(), std::exception);
}
TEST_F(LowLevelE2E_Client, GetClientsReturnsActiveClientSessions) {
RecordProperty("description", "Returns the currently active authenticated client sessions.");
iggy::ffi::Client *first_client = GetLoggedInClient();
iggy::ffi::Client *second_client = GetLoggedInClient();
iggy::ffi::ClientInfoDetails first_me{};
iggy::ffi::ClientInfoDetails second_me{};
rust::Vec<iggy::ffi::ClientInfo> clients;
ASSERT_NO_THROW({
first_me = first_client->get_me();
second_me = second_client->get_me();
clients = first_client->get_clients();
});
ASSERT_GE(clients.size(), 2u);
bool found_first = false;
bool found_second = false;
for (const auto &client : clients) {
EXPECT_NE(client.client_id, 0u);
EXPECT_EQ(static_cast<std::string>(client.transport), "TCP");
if (client.client_id == first_me.client_id) {
found_first = true;
EXPECT_EQ(client.has_user_id, first_me.has_user_id);
EXPECT_EQ(client.user_id, first_me.user_id);
EXPECT_EQ(static_cast<std::string>(client.address), static_cast<std::string>(first_me.address));
EXPECT_EQ(client.consumer_groups_count, first_me.consumer_groups_count);
}
if (client.client_id == second_me.client_id) {
found_second = true;
EXPECT_EQ(client.has_user_id, second_me.has_user_id);
EXPECT_EQ(client.user_id, second_me.user_id);
EXPECT_EQ(static_cast<std::string>(client.address), static_cast<std::string>(second_me.address));
EXPECT_EQ(client.consumer_groups_count, second_me.consumer_groups_count);
}
}
EXPECT_TRUE(found_first);
EXPECT_TRUE(found_second);
}
TEST_F(LowLevelE2E_Client, GetClientsIsStableAcrossBackToBackCalls) {
RecordProperty("description", "Returns stable client lists across back-to-back get_clients calls.");
iggy::ffi::Client *first_client = GetLoggedInClient();
iggy::ffi::Client *second_client = GetLoggedInClient();
ASSERT_NE(second_client, nullptr);
iggy::ffi::ClientInfoDetails first_me{};
iggy::ffi::ClientInfoDetails second_me{};
rust::Vec<iggy::ffi::ClientInfo> first_clients;
rust::Vec<iggy::ffi::ClientInfo> second_clients;
ASSERT_NO_THROW({
first_me = first_client->get_me();
second_me = second_client->get_me();
first_clients = first_client->get_clients();
second_clients = first_client->get_clients();
});
ASSERT_GE(first_clients.size(), 2u);
ASSERT_GE(second_clients.size(), 2u);
const auto expect_entry_matches = [](const rust::Vec<iggy::ffi::ClientInfo> &clients,
const iggy::ffi::ClientInfoDetails &expected) {
bool found = false;
for (const auto &entry : clients) {
if (entry.client_id != expected.client_id) {
continue;
}
found = true;
EXPECT_EQ(entry.has_user_id, expected.has_user_id);
EXPECT_EQ(entry.user_id, expected.user_id);
EXPECT_EQ(static_cast<std::string>(entry.address), static_cast<std::string>(expected.address));
EXPECT_EQ(static_cast<std::string>(entry.transport), static_cast<std::string>(expected.transport));
EXPECT_EQ(entry.consumer_groups_count, expected.consumer_groups_count);
break;
}
EXPECT_TRUE(found);
};
expect_entry_matches(first_clients, first_me);
expect_entry_matches(first_clients, second_me);
expect_entry_matches(second_clients, first_me);
expect_entry_matches(second_clients, second_me);
}
TEST_F(LowLevelE2E_Client, GetClientsMatchesGetClientForReturnedIds) {
RecordProperty("description", "Returns list entries that agree with get_client for each returned client id.");
iggy::ffi::Client *first_client = GetLoggedInClient();
iggy::ffi::Client *second_client = GetLoggedInClient();
ASSERT_NE(second_client, nullptr);
rust::Vec<iggy::ffi::ClientInfo> clients;
ASSERT_NO_THROW({ clients = first_client->get_clients(); });
ASSERT_GE(clients.size(), 2u);
for (const auto &client : clients) {
SCOPED_TRACE(client.client_id);
iggy::ffi::ClientInfoDetails details{};
ASSERT_NO_THROW({ details = first_client->get_client(client.client_id); });
EXPECT_EQ(details.client_id, client.client_id);
EXPECT_EQ(details.has_user_id, client.has_user_id);
EXPECT_EQ(details.user_id, client.user_id);
EXPECT_EQ(static_cast<std::string>(details.address), static_cast<std::string>(client.address));
EXPECT_EQ(static_cast<std::string>(details.transport), static_cast<std::string>(client.transport));
EXPECT_EQ(details.consumer_groups_count, client.consumer_groups_count);
}
}
TEST_F(LowLevelE2E_Client, GetClientsReflectsAdditionalSession) {
RecordProperty("description", "Reflects a newly added authenticated session in subsequent get_clients results.");
iggy::ffi::Client *first_client = GetLoggedInClient();
rust::Vec<iggy::ffi::ClientInfo> clients_before;
ASSERT_NO_THROW({ clients_before = first_client->get_clients(); });
iggy::ffi::Client *second_client = GetLoggedInClient();
iggy::ffi::ClientInfoDetails second_me{};
rust::Vec<iggy::ffi::ClientInfo> clients_after;
ASSERT_NO_THROW({
second_me = second_client->get_me();
clients_after = first_client->get_clients();
});
bool found_before = false;
for (const auto &client : clients_before) {
if (client.client_id == second_me.client_id) {
found_before = true;
break;
}
}
EXPECT_FALSE(found_before);
bool found_after = false;
for (const auto &client : clients_after) {
if (client.client_id != second_me.client_id) {
continue;
}
found_after = true;
EXPECT_EQ(client.has_user_id, second_me.has_user_id);
EXPECT_EQ(client.user_id, second_me.user_id);
EXPECT_EQ(static_cast<std::string>(client.address), static_cast<std::string>(second_me.address));
EXPECT_EQ(static_cast<std::string>(client.transport), "TCP");
EXPECT_EQ(client.consumer_groups_count, second_me.consumer_groups_count);
break;
}
EXPECT_TRUE(found_after);
}
TEST_F(LowLevelE2E_Client, GetClusterMetadataBeforeLoginThrows) {
RecordProperty("description",
"Rejects get_cluster_metadata before connect, after connect but before login, and after disconnect, "
"and serves it once authenticated.");
iggy::ffi::Client *client = GetLoggedOutClient();
ASSERT_THROW(client->get_cluster_metadata(), std::exception);
ASSERT_NO_THROW(client->connect());
// The roster is private, so the read is auth-gated. No pre-login read is
// needed: a client that dialed a backup logs in there and the server
// forwards the register to the primary.
ASSERT_THROW(client->get_cluster_metadata(), std::exception);
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
iggy::ffi::ClusterMetadata metadata{};
ASSERT_NO_THROW({ metadata = client->get_cluster_metadata(); });
ASSERT_GE(metadata.nodes.size(), 1u);
ASSERT_NO_THROW(client->disconnect());
ASSERT_THROW(client->get_cluster_metadata(), std::exception);
}
TEST_F(LowLevelE2E_Client, GetClusterMetadataReturnsSingleNodeMetadata) {
RecordProperty("description",
"Returns the expected single-node cluster metadata shape from the default test server.");
iggy::ffi::Client *client = GetLoggedInClient();
iggy::ffi::ClusterMetadata metadata{};
ASSERT_NO_THROW({ metadata = client->get_cluster_metadata(); });
EXPECT_EQ(static_cast<std::string>(metadata.name), "single-node");
ASSERT_EQ(metadata.nodes.size(), 1u);
const auto &node = metadata.nodes[0];
EXPECT_FALSE(static_cast<std::string>(node.name).empty());
EXPECT_FALSE(static_cast<std::string>(node.ip).empty());
EXPECT_EQ(static_cast<std::string>(node.role), "leader");
EXPECT_EQ(static_cast<std::string>(node.status), "healthy");
EXPECT_NE(node.endpoints.tcp, 0u);
EXPECT_NE(node.endpoints.http, 0u);
}
TEST_F(LowLevelE2E_Client, GetClusterMetadataIsStableAcrossBackToBackCalls) {
RecordProperty("description", "Returns stable single-node cluster metadata across back-to-back calls.");
iggy::ffi::Client *client = GetLoggedInClient();
iggy::ffi::ClusterMetadata first_metadata{};
iggy::ffi::ClusterMetadata second_metadata{};
ASSERT_NO_THROW({
first_metadata = client->get_cluster_metadata();
second_metadata = client->get_cluster_metadata();
});
EXPECT_EQ(static_cast<std::string>(first_metadata.name), static_cast<std::string>(second_metadata.name));
ASSERT_EQ(first_metadata.nodes.size(), 1u);
ASSERT_EQ(second_metadata.nodes.size(), 1u);
const auto &first_node = first_metadata.nodes[0];
const auto &second_node = second_metadata.nodes[0];
EXPECT_EQ(static_cast<std::string>(first_node.name), static_cast<std::string>(second_node.name));
EXPECT_EQ(static_cast<std::string>(first_node.ip), static_cast<std::string>(second_node.ip));
EXPECT_EQ(static_cast<std::string>(first_node.role), static_cast<std::string>(second_node.role));
EXPECT_EQ(static_cast<std::string>(first_node.status), static_cast<std::string>(second_node.status));
EXPECT_EQ(first_node.endpoints.tcp, second_node.endpoints.tcp);
EXPECT_EQ(first_node.endpoints.quic, second_node.endpoints.quic);
EXPECT_EQ(first_node.endpoints.http, second_node.endpoints.http);
EXPECT_EQ(first_node.endpoints.websocket, second_node.endpoints.websocket);
}
TEST_F(LowLevelE2E_Client, PingSucceedsForNewConnection) {
RecordProperty("description", "Successfully pings the server from a fresh unauthenticated client session.");
iggy::ffi::Client *client = GetLoggedOutClient();
// The VSR client has no lazy connect; ping still needs no authentication.
ASSERT_NO_THROW(client->connect());
ASSERT_NO_THROW(client->ping());
}
TEST_F(LowLevelE2E_Client, HeartbeatIntervalReturnsDefaultValueForNewConnection) {
RecordProperty("description",
"Returns the default heartbeat interval in microseconds for a fresh unauthenticated client.");
constexpr std::uint64_t default_heartbeat_micros = 5'000'000ull;
iggy::ffi::Client *client = GetLoggedOutClient();
const auto heartbeat_interval = client->heartbeat_interval();
EXPECT_EQ(heartbeat_interval, default_heartbeat_micros);
}
TEST_F(LowLevelE2E_Client, HeartbeatIntervalReturnsConfiguredValueFromConnectionString) {
RecordProperty("description",
"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::from_connection_string("iggy://iggy:iggy@127.0.0.1:8090?heartbeat_interval=10s"); });
ASSERT_NE(client, nullptr);
TrackClient(client);
const auto heartbeat_interval = client->heartbeat_interval();
EXPECT_EQ(heartbeat_interval, configured_heartbeat_micros);
}
TEST_F(LowLevelE2E_Client, SnapshotBeforeLoginThrows) {
RecordProperty("description",
"Rejects snapshot before connect, after connect but before login, and after disconnect.");
iggy::ffi::Client *client = GetLoggedOutClient();
ASSERT_THROW(client->snapshot("deflated", make_snapshot_types({"test"})), std::exception);
ASSERT_NO_THROW(client->connect());
ASSERT_THROW(client->snapshot("deflated", make_snapshot_types({"test"})), std::exception);
ASSERT_NO_THROW(client->login_user("iggy", "iggy"));
ASSERT_NO_THROW(client->disconnect());
ASSERT_THROW(client->snapshot("deflated", make_snapshot_types({"test"})), std::exception);
}
TEST_F(LowLevelE2E_Client, SnapshotAllCombinedWithOtherTypeThrows) {
RecordProperty("description", "Rejects combining the all snapshot type with any other snapshot type.");
iggy::ffi::Client *client = GetLoggedInClient();
ASSERT_THROW(client->snapshot("deflated", make_snapshot_types({"all", "test"})), std::exception);
}
TEST_F(LowLevelE2E_Client, SnapshotWithEmptySnapshotTypesThrows) {
RecordProperty("description", "Rejects an empty snapshot type list in the wrapper before sending.");
iggy::ffi::Client *client = GetLoggedInClient();
rust::Vec<rust::String> snapshot_types;
ASSERT_THROW(client->snapshot("deflated", snapshot_types), std::exception);
}
TEST_F(LowLevelE2E_Client, SnapshotReturnsNonEmptyBytes) {
RecordProperty("description", "Returns a non-empty snapshot for a valid compression and snapshot type.");
iggy::ffi::Client *client = GetLoggedInClient();
rust::Vec<std::uint8_t> snapshot_bytes;
ASSERT_NO_THROW({ snapshot_bytes = client->snapshot("deflated", make_snapshot_types({"test"})); });
EXPECT_FALSE(snapshot_bytes.empty());
}
TEST_F(LowLevelE2E_Client, SnapshotWithInvalidCompressionThrows) {
RecordProperty("description",
"Rejects empty or invalid snapshot compression values in the wrapper before sending.");
iggy::ffi::Client *client = GetLoggedInClient();
ASSERT_THROW(client->snapshot("", make_snapshot_types({"test"})), std::exception);
ASSERT_THROW(client->snapshot("invalid-compression", make_snapshot_types({"test"})), std::exception);
}
TEST_F(LowLevelE2E_Client, SnapshotWithInvalidSnapshotTypeThrows) {
RecordProperty("description", "Rejects invalid snapshot type values in the wrapper before sending.");
iggy::ffi::Client *client = GetLoggedInClient();
ASSERT_THROW(client->snapshot("deflated", make_snapshot_types({"not-a-real-type"})), std::exception);
}
TEST_F(LowLevelE2E_Client, SendBinaryRequestPingReturnsEmptyBytes) {
RecordProperty("description", "Returns an empty response body for a raw ping command with an empty payload.");
constexpr std::uint32_t ping_command_code = 1;
iggy::ffi::Client *client = GetLoggedInClient();
rust::Vec<std::uint8_t> empty_payload;
rust::Vec<std::uint8_t> response;
ASSERT_NO_THROW({ response = client->send_binary_request(ping_command_code, empty_payload); });
EXPECT_TRUE(response.empty());
}
TEST_F(LowLevelE2E_Client, SendBinaryRequestGetStatsReturnsNonEmptyBytes) {
RecordProperty("description",
"Returns a non-empty response body for a raw get-stats command with an empty payload.");
constexpr std::uint32_t get_stats_command_code = 10;
iggy::ffi::Client *client = GetLoggedInClient();
rust::Vec<std::uint8_t> empty_payload;
rust::Vec<std::uint8_t> response;
ASSERT_NO_THROW({ response = client->send_binary_request(get_stats_command_code, empty_payload); });
EXPECT_FALSE(response.empty());
}
TEST_F(LowLevelE2E_Client, SendBinaryRequestLoginUserCodeThrows) {
RecordProperty("description",
"Rejects the login-user session-control code client-side before it reaches the server.");
constexpr std::uint32_t login_user_command_code = 38;
iggy::ffi::Client *client = GetLoggedInClient();
rust::Vec<std::uint8_t> empty_payload;
ASSERT_THROW(client->send_binary_request(login_user_command_code, empty_payload), std::exception);
}
TEST_F(LowLevelE2E_Client, SendBinaryRequestUnknownCommandCodeThrows) {
RecordProperty("description", "Rejects an unknown command code with an invalid-command error from the server.");
constexpr std::uint32_t unknown_command_code = 60000;
iggy::ffi::Client *client = GetLoggedInClient();
rust::Vec<std::uint8_t> empty_payload;
ASSERT_THROW(client->send_binary_request(unknown_command_code, empty_payload), std::exception);
}