diff --git a/core/sdk/src/prelude.rs b/core/sdk/src/prelude.rs index ec01c19200..34f8d14844 100644 --- a/core/sdk/src/prelude.rs +++ b/core/sdk/src/prelude.rs @@ -60,8 +60,8 @@ pub use iggy_common::{ QuicClientConfig, QuicClientConfigBuilder, QuicClientReconnectionConfig, SendMessages, Sizeable, SnapshotCompression, Stats, Stream, StreamDetails, StreamPermissions, SystemSnapshotType, TcpClientConfig, TcpClientConfigBuilder, TcpClientReconnectionConfig, - Topic, TopicDetails, TopicPermissions, TransportEndpoints, TransportProtocol, UserId, - UserStatus, Validatable, WebSocketClientConfig, WebSocketClientConfigBuilder, + Topic, TopicDetails, TopicPermissions, TransportEndpoints, TransportProtocol, UserId, UserInfo, + UserInfoDetails, UserStatus, Validatable, WebSocketClientConfig, WebSocketClientConfigBuilder, WebSocketClientReconnectionConfig, defaults, locking, }; pub use iggy_common::{ diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index 5b53669384..1bcd56193e 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -22,6 +22,7 @@ import asyncio import builtins import collections.abc import datetime +import enum import typing __all__ = [ @@ -39,6 +40,9 @@ __all__ = [ "StreamDetails", "Topic", "TopicDetails", + "UserInfo", + "UserInfoDetails", + "UserStatus", ] class AutoCommit: @@ -335,6 +339,92 @@ class IggyClient: Logs in the user with the given credentials. Returns `Ok(())` on success, or a PyRuntimeError on failure. """ + def get_user( + self, user_id: builtins.str | builtins.int + ) -> collections.abc.Awaitable[UserInfoDetails | None]: + r""" + Get the info about a specific user by unique ID or username. + + Args: + user_id: User identifier as `str | int`. + + Returns: + An awaitable that resolves to `UserInfoDetails` if the user exists, + or `None` otherwise. + + Raises: + PyValueError: If a string identifier is invalid. + PyRuntimeError: If the request fails. + """ + def get_users(self) -> collections.abc.Awaitable[list[UserInfo]]: + r""" + Get the info about all the users. + + Returns: + An awaitable that resolves to `list[UserInfo]`. + + Raises: + PyRuntimeError: If the request fails. + """ + def create_user( + self, + username: builtins.str, + password: builtins.str, + status: UserStatus | None = None, + ) -> collections.abc.Awaitable[UserInfoDetails]: + r""" + Create a new user. + + The user is created without permissions. + + Args: + username: Username as `str`. + password: Password as `str`. + status: User status as `UserStatus | None`; defaults to `UserStatus.Active`. + + Returns: + An awaitable that resolves to the created `UserInfoDetails`. + + Raises: + PyRuntimeError: If an argument is invalid or the request fails. + """ + def update_user( + self, + user_id: builtins.str | builtins.int, + username: builtins.str | None = None, + status: UserStatus | None = None, + ) -> collections.abc.Awaitable[None]: + r""" + Update a user by unique ID or username. + + Args: + user_id: User identifier as `str | int`. + username: New username as `str | None`; unchanged when `None`. + status: New status as `UserStatus | None`; unchanged when `None`. + + Returns: + An awaitable that resolves to `None` when the user is updated. + + Raises: + PyValueError: If a string identifier is invalid. + PyRuntimeError: If the request fails. + """ + def delete_user( + self, user_id: builtins.str | builtins.int + ) -> collections.abc.Awaitable[None]: + r""" + Delete a user by unique ID or username. + + Args: + user_id: User identifier as `str | int`. + + Returns: + An awaitable that resolves to `None` when the user is deleted. + + Raises: + PyValueError: If a string identifier is invalid. + PyRuntimeError: If the request fails. + """ def connect(self) -> collections.abc.Awaitable[None]: r""" Connects the IggyClient to its service. @@ -862,3 +952,64 @@ class TopicDetails: r""" Replication factor for the topic. """ + +@typing.final +class UserInfo: + @property + def id(self) -> builtins.int: + r""" + The unique identifier (numeric) of the user. + """ + @property + def created_at(self) -> builtins.int: + r""" + The timestamp when the user was created, in microseconds since the Unix epoch. + """ + @property + def status(self) -> UserStatus: + r""" + The status of the user. + """ + @property + def username(self) -> builtins.str: + r""" + The username of the user. + """ + +@typing.final +class UserInfoDetails: + @property + def id(self) -> builtins.int: + r""" + The unique identifier (numeric) of the user. + """ + @property + def created_at(self) -> builtins.int: + r""" + The timestamp when the user was created, in microseconds since the Unix epoch. + """ + @property + def status(self) -> UserStatus: + r""" + The status of the user. + """ + @property + def username(self) -> builtins.str: + r""" + The username of the user. + """ + +@typing.final +class UserStatus(enum.Enum): + r""" + The status of a user account. + """ + + Active = ... + r""" + The user account is active and can be used. + """ + Inactive = ... + r""" + The user account is inactive and cannot be used. + """ diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs index 2a36ef3335..6b22549213 100644 --- a/foreign/python/src/client.rs +++ b/foreign/python/src/client.rs @@ -37,6 +37,9 @@ use crate::receive_message::{PollingStrategy, ReceiveMessage}; use crate::send_message::SendMessage; use crate::stream::StreamDetails; use crate::topic::{Topic, TopicDetails}; +use crate::user::{ + UserInfo as PyUserInfo, UserInfoDetails as PyUserInfoDetails, UserStatus as PyUserStatus, +}; use tokio::sync::Mutex; /// A Python class representing the Iggy client. @@ -119,6 +122,147 @@ impl IggyClient { }) } + /// Get the info about a specific user by unique ID or username. + /// + /// Args: + /// user_id: User identifier as `str | int`. + /// + /// Returns: + /// An awaitable that resolves to `UserInfoDetails` if the user exists, + /// or `None` otherwise. + /// + /// Raises: + /// PyValueError: If a string identifier is invalid. + /// PyRuntimeError: If the request fails. + #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[UserInfoDetails | None]", imports=("collections.abc")))] + fn get_user<'a>(&self, py: Python<'a>, user_id: PyIdentifier) -> PyResult> { + let user_id = Identifier::try_from(user_id)?; + let inner = self.inner.clone(); + + future_into_py(py, async move { + let user = inner + .get_user(&user_id) + .await + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(user.map(PyUserInfoDetails::from)) + }) + } + + /// Get the info about all the users. + /// + /// Returns: + /// An awaitable that resolves to `list[UserInfo]`. + /// + /// Raises: + /// PyRuntimeError: If the request fails. + #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[list[UserInfo]]", imports=("collections.abc")))] + fn get_users<'a>(&self, py: Python<'a>) -> PyResult> { + let inner = self.inner.clone(); + + future_into_py(py, async move { + let users = inner + .get_users() + .await + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(users.into_iter().map(PyUserInfo::from).collect::>()) + }) + } + + /// Create a new user. + /// + /// The user is created without permissions. + /// + /// Args: + /// username: Username as `str`. + /// password: Password as `str`. + /// status: User status as `UserStatus | None`; defaults to `UserStatus.Active`. + /// + /// Returns: + /// An awaitable that resolves to the created `UserInfoDetails`. + /// + /// Raises: + /// PyRuntimeError: If an argument is invalid or the request fails. + #[pyo3(signature = (username, password, status=None))] + #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[UserInfoDetails]", imports=("collections.abc")))] + fn create_user<'a>( + &self, + py: Python<'a>, + username: String, + password: String, + #[gen_stub(override_type(type_repr = "UserStatus | None"))] status: Option, + ) -> PyResult> { + let status = status.map_or(UserStatus::Active, UserStatus::from); + let inner = self.inner.clone(); + + future_into_py(py, async move { + let user = inner + .create_user(&username, &password, status, None) + .await + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(PyUserInfoDetails::from(user)) + }) + } + + /// Update a user by unique ID or username. + /// + /// Args: + /// user_id: User identifier as `str | int`. + /// username: New username as `str | None`; unchanged when `None`. + /// status: New status as `UserStatus | None`; unchanged when `None`. + /// + /// Returns: + /// An awaitable that resolves to `None` when the user is updated. + /// + /// Raises: + /// PyValueError: If a string identifier is invalid. + /// PyRuntimeError: If the request fails. + #[pyo3(signature = (user_id, username=None, status=None))] + #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] + fn update_user<'a>( + &self, + py: Python<'a>, + user_id: PyIdentifier, + #[gen_stub(override_type(type_repr = "builtins.str | None"))] username: Option, + #[gen_stub(override_type(type_repr = "UserStatus | None"))] status: Option, + ) -> PyResult> { + let user_id = Identifier::try_from(user_id)?; + let status = status.map(UserStatus::from); + let inner = self.inner.clone(); + + future_into_py(py, async move { + inner + .update_user(&user_id, username.as_deref(), status) + .await + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(()) + }) + } + + /// Delete a user by unique ID or username. + /// + /// Args: + /// user_id: User identifier as `str | int`. + /// + /// Returns: + /// An awaitable that resolves to `None` when the user is deleted. + /// + /// Raises: + /// PyValueError: If a string identifier is invalid. + /// PyRuntimeError: If the request fails. + #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] + fn delete_user<'a>(&self, py: Python<'a>, user_id: PyIdentifier) -> PyResult> { + let user_id = Identifier::try_from(user_id)?; + let inner = self.inner.clone(); + + future_into_py(py, async move { + inner + .delete_user(&user_id) + .await + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(()) + }) + } + /// Connects the IggyClient to its service. /// Returns Ok(()) on successful connection or a PyRuntimeError on failure. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] diff --git a/foreign/python/src/lib.rs b/foreign/python/src/lib.rs index 8c78456062..f831fd703a 100644 --- a/foreign/python/src/lib.rs +++ b/foreign/python/src/lib.rs @@ -22,6 +22,7 @@ mod receive_message; mod send_message; mod stream; mod topic; +mod user; use client::IggyClient; use consumer::{ @@ -33,6 +34,7 @@ use receive_message::{PollingStrategy, ReceiveMessage}; use send_message::SendMessage; use stream::StreamDetails; use topic::{Topic, TopicDetails}; +use user::{UserInfo, UserInfoDetails, UserStatus}; /// A Python module implemented in Rust. #[pymodule] @@ -52,5 +54,8 @@ fn apache_iggy(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/foreign/python/src/user.rs b/foreign/python/src/user.rs new file mode 100644 index 0000000000..1f9e6ddb04 --- /dev/null +++ b/foreign/python/src/user.rs @@ -0,0 +1,131 @@ +// 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. + +use iggy::prelude::{ + UserInfo as RustUserInfo, UserInfoDetails as RustUserInfoDetails, UserStatus as RustUserStatus, +}; +use pyo3::prelude::*; +use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pyclass_enum, gen_stub_pymethods}; + +/// The status of a user account. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[gen_stub_pyclass_enum] +#[pyclass(eq, from_py_object)] +pub enum UserStatus { + /// The user account is active and can be used. + Active, + /// The user account is inactive and cannot be used. + Inactive, +} + +impl From for RustUserStatus { + fn from(status: UserStatus) -> Self { + match status { + UserStatus::Active => RustUserStatus::Active, + UserStatus::Inactive => RustUserStatus::Inactive, + } + } +} + +impl From for UserStatus { + fn from(status: RustUserStatus) -> Self { + match status { + RustUserStatus::Active => UserStatus::Active, + RustUserStatus::Inactive => UserStatus::Inactive, + } + } +} + +#[gen_stub_pyclass] +#[pyclass] +pub struct UserInfo { + pub(crate) inner: RustUserInfo, +} + +impl From for UserInfo { + fn from(user: RustUserInfo) -> Self { + Self { inner: user } + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl UserInfo { + /// The unique identifier (numeric) of the user. + #[getter] + pub fn id(&self) -> u32 { + self.inner.id + } + + /// The timestamp when the user was created, in microseconds since the Unix epoch. + #[getter] + pub fn created_at(&self) -> u64 { + self.inner.created_at.as_micros() + } + + /// The status of the user. + #[getter] + pub fn status(&self) -> UserStatus { + self.inner.status.into() + } + + /// The username of the user. + #[getter] + pub fn username(&self) -> String { + self.inner.username.to_string() + } +} + +#[gen_stub_pyclass] +#[pyclass] +pub struct UserInfoDetails { + pub(crate) inner: RustUserInfoDetails, +} + +impl From for UserInfoDetails { + fn from(user: RustUserInfoDetails) -> Self { + Self { inner: user } + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl UserInfoDetails { + /// The unique identifier (numeric) of the user. + #[getter] + pub fn id(&self) -> u32 { + self.inner.id + } + + /// The timestamp when the user was created, in microseconds since the Unix epoch. + #[getter] + pub fn created_at(&self) -> u64 { + self.inner.created_at.as_micros() + } + + /// The status of the user. + #[getter] + pub fn status(&self) -> UserStatus { + self.inner.status.into() + } + + /// The username of the user. + #[getter] + pub fn username(&self) -> String { + self.inner.username.to_string() + } +} diff --git a/foreign/python/tests/test_user.py b/foreign/python/tests/test_user.py new file mode 100644 index 0000000000..9e94a59350 --- /dev/null +++ b/foreign/python/tests/test_user.py @@ -0,0 +1,616 @@ +# 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. + +import pytest + +from apache_iggy import IggyClient, UserInfoDetails, UserStatus + +from .utils import get_server_config, wait_for_ping, wait_for_server + +# Server-side limits: usernames are 3-50 bytes, passwords 3-100 bytes. +MIN_USERNAME_BYTES = 3 +MAX_USERNAME_BYTES = 50 +MIN_PASSWORD_BYTES = 3 +MAX_PASSWORD_BYTES = 100 + + +def _unique_credentials(unique_name) -> tuple[str, str]: + username = unique_name(max_bytes=MAX_USERNAME_BYTES) + password = unique_name(max_bytes=MAX_PASSWORD_BYTES) + return username, password + + +class TestCreateUser: + """Test user creation via create_user.""" + + @pytest.mark.asyncio + async def test_create_and_get_user(self, iggy_client: IggyClient, unique_name): + """Test user creation returns details and the user is retrievable.""" + username, password = _unique_credentials(unique_name) + + created = await iggy_client.create_user(username, password, UserStatus.Active) + assert isinstance(created, UserInfoDetails) + assert isinstance(created.id, int) + assert created.username == username + assert created.status == UserStatus.Active + + user_by_name = await iggy_client.get_user(username) + assert user_by_name is not None + assert user_by_name.id == created.id + assert user_by_name.username == username + assert user_by_name.status == UserStatus.Active + + user_by_id = await iggy_client.get_user(created.id) + assert user_by_id is not None + assert user_by_id.id == created.id + assert user_by_id.username == username + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_create_user_defaults_to_active_status( + self, iggy_client: IggyClient, unique_name + ): + """Test create_user without an explicit status creates an active user.""" + username, password = _unique_credentials(unique_name) + + created = await iggy_client.create_user(username, password) + assert created.status == UserStatus.Active + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_create_inactive_user(self, iggy_client: IggyClient, unique_name): + """Test create_user with UserStatus.Inactive creates an inactive user.""" + username, password = _unique_credentials(unique_name) + + created = await iggy_client.create_user(username, password, UserStatus.Inactive) + assert created.status == UserStatus.Inactive + + user = await iggy_client.get_user(username) + assert user is not None + assert user.status == UserStatus.Inactive + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_duplicate_username_fails(self, iggy_client: IggyClient, unique_name): + """Test create_user rejects an already taken username.""" + username, password = _unique_credentials(unique_name) + + created = await iggy_client.create_user(username, password) + + with pytest.raises(RuntimeError): + await iggy_client.create_user(username, password) + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_created_user_can_login(self, iggy_client: IggyClient, unique_name): + """Test a freshly created user can authenticate with its credentials.""" + username, password = _unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + + host, port = get_server_config() + client = IggyClient(f"{host}:{port}") + await client.connect() + await wait_for_ping(client) + await client.login_user(username, password) + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_inactive_user_cannot_login( + self, iggy_client: IggyClient, unique_name + ): + """Test an inactive user is denied login even with correct credentials.""" + username, password = _unique_credentials(unique_name) + created = await iggy_client.create_user(username, password, UserStatus.Inactive) + + host, port = get_server_config() + client = IggyClient(f"{host}:{port}") + await client.connect() + with pytest.raises(RuntimeError): + await client.login_user(username, password) + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_deleted_user_cannot_login( + self, iggy_client: IggyClient, unique_name + ): + """Test a deleted user cannot start a fresh authenticated session.""" + username, password = _unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + await iggy_client.delete_user(created.id) + + host, port = get_server_config() + client = IggyClient(f"{host}:{port}") + await client.connect() + with pytest.raises(RuntimeError): + await client.login_user(username, password) + + @pytest.mark.parametrize( + "username", + ["a" * (MIN_USERNAME_BYTES - 1), "a" * (MAX_USERNAME_BYTES + 1)], + ids=["too-short", "too-long"], + ) + @pytest.mark.asyncio + async def test_create_user_rejects_out_of_bounds_username( + self, iggy_client: IggyClient, unique_name, username + ): + """Test create_user rejects usernames outside the 3-50 byte range.""" + with pytest.raises(RuntimeError): + await iggy_client.create_user( + username, unique_name(max_bytes=MAX_PASSWORD_BYTES) + ) + + @pytest.mark.parametrize( + "password", + ["a" * (MIN_PASSWORD_BYTES - 1), "a" * (MAX_PASSWORD_BYTES + 1)], + ids=["too-short", "too-long"], + ) + @pytest.mark.asyncio + async def test_create_user_rejects_out_of_bounds_password( + self, iggy_client: IggyClient, unique_name, password + ): + """Test create_user rejects passwords outside the 3-100 byte range.""" + with pytest.raises(RuntimeError): + await iggy_client.create_user( + unique_name(max_bytes=MAX_USERNAME_BYTES), password + ) + + @pytest.mark.parametrize( + "prefix", + ["ユーザー", "사용자", "ผู้ใช้", "🦀🚀"], + ids=["japanese", "korean", "thai", "emoji"], + ) + @pytest.mark.asyncio + async def test_create_user_accepts_multibyte_credentials( + self, iggy_client: IggyClient, unique_name, prefix + ): + """Test non-ascii credentials within the byte limits are accepted.""" + username = f"{prefix}{unique_name(min_bytes=4, max_bytes=8)}" + password = f"{prefix}{unique_name(min_bytes=4, max_bytes=8)}" + assert len(username.encode()) <= MAX_USERNAME_BYTES + + created = await iggy_client.create_user(username, password) + assert created.username == username + + fetched = await iggy_client.get_user(username) + assert fetched is not None + assert fetched.id == created.id + + host, port = get_server_config() + client = IggyClient(f"{host}:{port}") + await client.connect() + await client.login_user(username, password) + + await iggy_client.delete_user(created.id) + + +class TestGetUser: + """Test user retrieval via get_user.""" + + @pytest.mark.asyncio + async def test_get_root_user(self, iggy_client: IggyClient): + """Test the default root user is retrievable by username.""" + user = await iggy_client.get_user("iggy") + assert user is not None + assert user.username == "iggy" + assert user.status == UserStatus.Active + + @pytest.mark.asyncio + async def test_get_nonexistent_user_returns_none( + self, iggy_client: IggyClient, unique_name + ): + """Test getting a non-existent user by name or numeric id returns None.""" + user_by_name = await iggy_client.get_user( + unique_name(max_bytes=MAX_USERNAME_BYTES) + ) + assert user_by_name is None + + # Deleting a freshly created user guarantees its id is vacant. + username, password = _unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + await iggy_client.delete_user(created.id) + + user_by_id = await iggy_client.get_user(created.id) + assert user_by_id is None + + @pytest.mark.asyncio + async def test_get_user_rejects_empty_identifier_locally( + self, iggy_client: IggyClient + ): + """Test the empty string is rejected client-side before any server call.""" + with pytest.raises(ValueError): + iggy_client.get_user("") + + @pytest.mark.parametrize("user_id", [-1, 2**32], ids=["negative", "above-u32"]) + @pytest.mark.asyncio + async def test_get_user_rejects_out_of_range_numeric_id( + self, iggy_client: IggyClient, user_id + ): + """Test numeric ids outside the u32 range are rejected client-side.""" + with pytest.raises(TypeError): + iggy_client.get_user(user_id) + + @pytest.mark.asyncio + async def test_get_user_returns_same_result_when_called_repeatedly( + self, iggy_client: IggyClient, unique_name + ): + """Test repeated get_user calls return the same user view.""" + username, password = _unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + + first = await iggy_client.get_user(username) + second = await iggy_client.get_user(username) + assert first is not None + assert second is not None + assert first.id == second.id + assert first.username == second.username + assert first.status == second.status + + await iggy_client.delete_user(created.id) + + +class TestGetUsers: + """Test listing users via get_users.""" + + @pytest.mark.asyncio + async def test_get_users_lists_root_and_created_users( + self, iggy_client: IggyClient, unique_name + ): + """Test get_users returns the root user and newly created users.""" + first_username, first_password = _unique_credentials(unique_name) + second_username, second_password = _unique_credentials(unique_name) + + first = await iggy_client.create_user(first_username, first_password) + second = await iggy_client.create_user(second_username, second_password) + + users = await iggy_client.get_users() + usernames = [user.username for user in users] + assert "iggy" in usernames + assert first_username in usernames + assert second_username in usernames + assert all(isinstance(user.id, int) for user in users) + assert all(isinstance(user.status, UserStatus) for user in users) + + user_ids = [user.id for user in users] + assert user_ids == sorted(set(user_ids)), ( + "get_users must return users in strictly ascending id order" + ) + + await iggy_client.delete_user(first.id) + await iggy_client.delete_user(second.id) + + +class TestUpdateUser: + """Test user updates via update_user.""" + + @pytest.mark.asyncio + async def test_update_username(self, iggy_client: IggyClient, unique_name): + """Test update_user renames a user.""" + username, password = _unique_credentials(unique_name) + new_username = unique_name(max_bytes=MAX_USERNAME_BYTES) + + created = await iggy_client.create_user(username, password) + + await iggy_client.update_user(username, username=new_username) + + assert await iggy_client.get_user(username) is None + renamed = await iggy_client.get_user(new_username) + assert renamed is not None + assert renamed.id == created.id + assert renamed.username == new_username + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_update_status(self, iggy_client: IggyClient, unique_name): + """Test update_user changes the user status.""" + username, password = _unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + assert created.status == UserStatus.Active + + await iggy_client.update_user(created.id, status=UserStatus.Inactive) + + user = await iggy_client.get_user(created.id) + assert user is not None + assert user.status == UserStatus.Inactive + assert user.username == username + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_update_username_and_status_together( + self, iggy_client: IggyClient, unique_name + ): + """Test update_user applies a new username and status in one call.""" + username, password = _unique_credentials(unique_name) + new_username = unique_name(max_bytes=MAX_USERNAME_BYTES) + created = await iggy_client.create_user(username, password) + + await iggy_client.update_user( + created.id, username=new_username, status=UserStatus.Inactive + ) + + user = await iggy_client.get_user(created.id) + assert user is not None + assert user.username == new_username + assert user.status == UserStatus.Inactive + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_update_user_with_no_fields_is_a_noop( + self, iggy_client: IggyClient, unique_name + ): + """Test the server permits an update with neither username nor status.""" + username, password = _unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + + await iggy_client.update_user(created.id) + + user = await iggy_client.get_user(created.id) + assert user is not None + assert user.username == username + assert user.status == UserStatus.Active + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_update_user_applied_repeatedly_is_idempotent( + self, iggy_client: IggyClient, unique_name + ): + """Test applying the same update twice succeeds and keeps the state.""" + username, password = _unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + + await iggy_client.update_user(created.id, status=UserStatus.Inactive) + await iggy_client.update_user(created.id, status=UserStatus.Inactive) + + user = await iggy_client.get_user(created.id) + assert user is not None + assert user.status == UserStatus.Inactive + assert user.username == username + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_update_username_to_existing_username_fails( + self, iggy_client: IggyClient, unique_name + ): + """Test update_user rejects renaming to a username another user holds.""" + first_username, first_password = _unique_credentials(unique_name) + second_username, second_password = _unique_credentials(unique_name) + first = await iggy_client.create_user(first_username, first_password) + second = await iggy_client.create_user(second_username, second_password) + + with pytest.raises(RuntimeError): + await iggy_client.update_user(first.id, username=second_username) + + unchanged = await iggy_client.get_user(first.id) + assert unchanged is not None + assert unchanged.username == first_username + assert unchanged.status == UserStatus.Active + + await iggy_client.delete_user(first.id) + await iggy_client.delete_user(second.id) + + @pytest.mark.asyncio + async def test_update_nonexistent_user_fails( + self, iggy_client: IggyClient, unique_name + ): + """Test update_user raises for a non-existent user.""" + with pytest.raises(RuntimeError): + await iggy_client.update_user( + unique_name(max_bytes=MAX_USERNAME_BYTES), + status=UserStatus.Inactive, + ) + + @pytest.mark.parametrize( + "new_username", + ["a" * (MIN_USERNAME_BYTES - 1), "a" * (MAX_USERNAME_BYTES + 1), "あ" * 17], + ids=["too-short", "too-long", "multibyte-51-bytes"], + ) + @pytest.mark.asyncio + async def test_update_user_rejects_out_of_bounds_username( + self, iggy_client: IggyClient, unique_name, new_username + ): + """Test update_user rejects usernames outside the 3-50 byte range.""" + username, password = _unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + + with pytest.raises(RuntimeError): + await iggy_client.update_user(created.id, username=new_username) + + unchanged = await iggy_client.get_user(created.id) + assert unchanged is not None + assert unchanged.username == username + assert unchanged.status == UserStatus.Active + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_update_user_accepts_multibyte_username( + self, iggy_client: IggyClient, unique_name + ): + """Test update_user accepts a non-ascii username within the byte limit.""" + username, password = _unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + + new_username = f"사용자{unique_name(min_bytes=4, max_bytes=8)}" + await iggy_client.update_user(created.id, username=new_username) + + renamed = await iggy_client.get_user(created.id) + assert renamed is not None + assert renamed.username == new_username + + await iggy_client.delete_user(created.id) + + +class TestDeleteUser: + """Test user deletion via delete_user.""" + + @pytest.mark.asyncio + async def test_delete_user_by_name(self, iggy_client: IggyClient, unique_name): + """Test delete_user removes a user addressed by username.""" + username, password = _unique_credentials(unique_name) + await iggy_client.create_user(username, password) + + await iggy_client.delete_user(username) + + assert await iggy_client.get_user(username) is None + + @pytest.mark.asyncio + async def test_delete_user_by_numeric_id( + self, iggy_client: IggyClient, unique_name + ): + """Test delete_user removes a user addressed by numeric id.""" + username, password = _unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + + await iggy_client.delete_user(created.id) + + assert await iggy_client.get_user(created.id) is None + + @pytest.mark.asyncio + async def test_delete_nonexistent_user_fails( + self, iggy_client: IggyClient, unique_name + ): + """Test delete_user raises for a non-existent user.""" + with pytest.raises(RuntimeError): + await iggy_client.delete_user(unique_name(max_bytes=MAX_USERNAME_BYTES)) + + @pytest.mark.parametrize("root_identifier", ["iggy", 0], ids=["by-name", "by-id"]) + @pytest.mark.asyncio + async def test_delete_root_user_fails( + self, iggy_client: IggyClient, root_identifier + ): + """Test the root user cannot be deleted by username or numeric id.""" + with pytest.raises(RuntimeError): + await iggy_client.delete_user(root_identifier) + + root = await iggy_client.get_user("iggy") + assert root is not None + assert root.status == UserStatus.Active + + @pytest.mark.asyncio + async def test_delete_user_twice_fails(self, iggy_client: IggyClient, unique_name): + """Test deleting the same user twice fails on the second call.""" + username, password = _unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + + await iggy_client.delete_user(created.id) + + with pytest.raises(RuntimeError): + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_delete_inactive_user(self, iggy_client: IggyClient, unique_name): + """Test an inactive user can be deleted.""" + username, password = _unique_credentials(unique_name) + created = await iggy_client.create_user(username, password, UserStatus.Inactive) + + await iggy_client.delete_user(created.id) + + assert await iggy_client.get_user(created.id) is None + + @pytest.mark.asyncio + async def test_deleted_user_disappears_from_listings( + self, iggy_client: IggyClient, unique_name + ): + """Test a deleted user is absent from both get_user and get_users.""" + username, password = _unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + + await iggy_client.delete_user(created.id) + + assert await iggy_client.get_user(username) is None + assert await iggy_client.get_user(created.id) is None + users = await iggy_client.get_users() + assert created.id not in [user.id for user in users] + assert username not in [user.username for user in users] + + @pytest.mark.asyncio + async def test_deleted_user_live_session_loses_identity( + self, iggy_client: IggyClient, unique_name + ): + """Test a session owned by a deleted user can no longer act as that user.""" + # TODO: Re-enable once permission management lands in the Python SDK. + # With only unprivileged users available, this test cannot prove its claim. + pass + + @pytest.mark.asyncio + async def test_deleted_username_is_reusable_with_fresh_credentials( + self, iggy_client: IggyClient, unique_name + ): + """Test a deleted username can be recreated without old password state.""" + username = unique_name(max_bytes=MAX_USERNAME_BYTES) + password = unique_name(max_bytes=MAX_USERNAME_BYTES) + new_password = f"{password}x" + + first = await iggy_client.create_user(username, password) + await iggy_client.delete_user(first.id) + + recreated = await iggy_client.create_user(username, new_password) + + host, port = get_server_config() + client = IggyClient(f"{host}:{port}") + await client.connect() + with pytest.raises(RuntimeError): + await client.login_user(username, password) + await client.login_user(username, new_password) + + await iggy_client.delete_user(recreated.id) + + +@pytest.mark.parametrize( + "method_name", + [ + "get_user", + "get_users", + "create_user", + "update_user", + "delete_user", + ], +) +@pytest.mark.asyncio +async def test_user_management_requires_connection_and_auth(method_name, unique_name): + """Test user management methods fail before connecting and before login.""" + host, port = get_server_config() + wait_for_server(host, port) + + client = IggyClient(f"{host}:{port}") + username = unique_name(max_bytes=MAX_USERNAME_BYTES) + args_by_method = { + "get_user": (username,), + "get_users": (), + "create_user": (username, "secret"), + "update_user": (username,), + "delete_user": (username,), + } + method = getattr(client, method_name) + args = args_by_method[method_name] + + with pytest.raises(RuntimeError): + await method(*args) + + await client.connect() + with pytest.raises(RuntimeError): + await method(*args)