This is an automated email from the ASF dual-hosted git repository.
spetz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new 4a6f173d4 feat(python): add user management methods to IggyClient
(#3695)
4a6f173d4 is described below
commit 4a6f173d4d65b07a50f8c1ca9a3ac97b317ee201
Author: Ethan Lin <[email protected]>
AuthorDate: Tue Jul 21 02:40:12 2026 +0800
feat(python): add user management methods to IggyClient (#3695)
Adds the missing user management operations to the Python SDK, which
previously had no binding beyond `login_user` and forced callers to the
CLI or another SDK to provision users. Closes #3682
---
core/sdk/src/prelude.rs | 4 +-
foreign/python/apache_iggy.pyi | 151 ++++++++++
foreign/python/src/client.rs | 144 +++++++++
foreign/python/src/lib.rs | 5 +
foreign/python/src/user.rs | 131 ++++++++
foreign/python/tests/test_user.py | 616 ++++++++++++++++++++++++++++++++++++++
6 files changed, 1049 insertions(+), 2 deletions(-)
diff --git a/core/sdk/src/prelude.rs b/core/sdk/src/prelude.rs
index ec01c1920..34f8d1484 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 5b5366938..1bcd56193 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.
"""
+
[email protected]
+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.
+ """
+
[email protected]
+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.
+ """
+
[email protected]
+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 2a36ef333..6b2254921 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<Bound<'a, PyAny>> {
+ 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::<pyo3::exceptions::PyRuntimeError,
_>(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<Bound<'a, PyAny>> {
+ let inner = self.inner.clone();
+
+ future_into_py(py, async move {
+ let users = inner
+ .get_users()
+ .await
+ .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError,
_>(e.to_string()))?;
+ Ok(users.into_iter().map(PyUserInfo::from).collect::<Vec<_>>())
+ })
+ }
+
+ /// 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<PyUserStatus>,
+ ) -> PyResult<Bound<'a, PyAny>> {
+ 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::<pyo3::exceptions::PyRuntimeError,
_>(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<String>,
+ #[gen_stub(override_type(type_repr = "UserStatus | None"))] status:
Option<PyUserStatus>,
+ ) -> PyResult<Bound<'a, PyAny>> {
+ 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::<pyo3::exceptions::PyRuntimeError,
_>(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<Bound<'a, PyAny>> {
+ 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::<pyo3::exceptions::PyRuntimeError,
_>(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 8c7845606..f831fd703 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::<AutoCommitAfter>()?;
m.add_class::<AutoCommitWhen>()?;
m.add_class::<ReceiveMessageIterator>()?;
+ m.add_class::<UserStatus>()?;
+ m.add_class::<UserInfo>()?;
+ m.add_class::<UserInfoDetails>()?;
Ok(())
}
diff --git a/foreign/python/src/user.rs b/foreign/python/src/user.rs
new file mode 100644
index 000000000..1f9e6ddb0
--- /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<UserStatus> for RustUserStatus {
+ fn from(status: UserStatus) -> Self {
+ match status {
+ UserStatus::Active => RustUserStatus::Active,
+ UserStatus::Inactive => RustUserStatus::Inactive,
+ }
+ }
+}
+
+impl From<RustUserStatus> 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<RustUserInfo> 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<RustUserInfoDetails> 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 000000000..9e94a5935
--- /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)
+
+
[email protected](
+ "method_name",
+ [
+ "get_user",
+ "get_users",
+ "create_user",
+ "update_user",
+ "delete_user",
+ ],
+)
[email protected]
+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)