From 4828ca48e7cd804dd96452ee50893dc83800be0c Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Fri, 17 Jul 2026 22:38:31 +0800 Subject: [PATCH 01/10] feat(sdk): export UserInfo and UserInfoDetails from prelude The Python bindings need these types to expose user management, but only UserStatus was re-exported so far. --- core/sdk/src/prelude.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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::{ From 89312ad9ce8f903d6fd16663777fbf0e8197b4b3 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Fri, 17 Jul 2026 22:43:26 +0800 Subject: [PATCH 02/10] feat(python): expose UserStatus, UserInfo and UserInfoDetails These types are needed by the upcoming user management methods on IggyClient. Permissions stay unexposed for now, so UserInfoDetails carries the same fields as UserInfo until the follow-up that maps the Permissions structure. --- foreign/python/apache_iggy.pyi | 65 ++++++++++++++++ foreign/python/src/lib.rs | 5 ++ foreign/python/src/user.rs | 131 +++++++++++++++++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 foreign/python/src/user.rs diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index 5b53669384..a9a24f929d 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: @@ -862,3 +866,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/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() + } +} From a69fcd97ea133ea6bb16139dd4878c9525e35635 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Fri, 17 Jul 2026 22:57:09 +0800 Subject: [PATCH 03/10] feat(python): add user management methods to IggyClient Expose get_user, get_users, create_user, update_user and delete_user, wrapping the Rust SDK UserClient functions like the existing topic and consumer group bindings. create_user always passes no permissions; the Permissions type mapping is left to a follow-up together with update_permissions, change_password and logout_user. --- foreign/python/apache_iggy.pyi | 86 ++++++++++++++++++++ foreign/python/src/client.rs | 144 +++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+) diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index a9a24f929d..1bcd56193e 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -339,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. 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")))] From 7e574280f31b68f3902fd1d5c5fb9a8bf2385647 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Fri, 17 Jul 2026 22:57:09 +0800 Subject: [PATCH 04/10] test(python): add e2e tests for user management Cover create, get, list, update and delete against a live server, including default status, inactive users, login with created credentials, numeric and name identifiers, repeated listing stability, and pre-connection and pre-authentication failures. --- foreign/python/tests/test_user.py | 376 ++++++++++++++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 foreign/python/tests/test_user.py diff --git a/foreign/python/tests/test_user.py b/foreign/python/tests/test_user.py new file mode 100644 index 0000000000..d1e88acfb5 --- /dev/null +++ b/foreign/python/tests/test_user.py @@ -0,0 +1,376 @@ +# 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 characters, passwords 3-100. +MAX_USERNAME_BYTES = 50 + + +def _unique_credentials(unique_name) -> tuple[str, str]: + username = unique_name(max_bytes=MAX_USERNAME_BYTES) + password = unique_name(max_bytes=MAX_USERNAME_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 + assert created.created_at > 0 + + 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, match="User already exists"): + 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) + + +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 + + user_by_id = await iggy_client.get_user(999999) + assert user_by_id is None + + @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 + assert first.created_at == second.created_at + + 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(user.created_at > 0 for user in users) + assert all(isinstance(user.status, UserStatus) for user in users) + + await iggy_client.delete_user(first.id) + await iggy_client.delete_user(second.id) + + @pytest.mark.asyncio + async def test_get_users_returns_same_result_when_called_repeatedly( + self, iggy_client: IggyClient, unique_name + ): + """Test repeated get_users calls return a stable user view.""" + username, password = _unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + + first = await iggy_client.get_users() + second = await iggy_client.get_users() + + assert [user.id for user in first] == [user.id for user in second] + assert [user.username for user in first] == [user.username for user in second] + + await iggy_client.delete_user(created.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_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, match="User already exists"): + 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 + + 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, match="was not found"): + await iggy_client.update_user( + unique_name(max_bytes=MAX_USERNAME_BYTES), + status=UserStatus.Inactive, + ) + + +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, match="was not found"): + await iggy_client.delete_user(unique_name(max_bytes=MAX_USERNAME_BYTES)) + + +@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, match="Disconnected"): + await method(*args) + + await client.connect() + with pytest.raises(RuntimeError, match="Unauthenticated"): + await method(*args) From 3d899e7755b410b772691df41dde7afed7d48d75 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Sun, 19 Jul 2026 17:58:20 +0800 Subject: [PATCH 05/10] test(python): drop brittle assertions in user tests Server error messages are not a stable contract and created_at is server generated, so assert neither. --- foreign/python/tests/test_user.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/foreign/python/tests/test_user.py b/foreign/python/tests/test_user.py index d1e88acfb5..87191df1bd 100644 --- a/foreign/python/tests/test_user.py +++ b/foreign/python/tests/test_user.py @@ -44,7 +44,6 @@ async def test_create_and_get_user(self, iggy_client: IggyClient, unique_name): assert isinstance(created.id, int) assert created.username == username assert created.status == UserStatus.Active - assert created.created_at > 0 user_by_name = await iggy_client.get_user(username) assert user_by_name is not None @@ -92,7 +91,7 @@ async def test_duplicate_username_fails(self, iggy_client: IggyClient, unique_na created = await iggy_client.create_user(username, password) - with pytest.raises(RuntimeError, match="User already exists"): + with pytest.raises(RuntimeError): await iggy_client.create_user(username, password) await iggy_client.delete_user(created.id) @@ -151,7 +150,6 @@ async def test_get_user_returns_same_result_when_called_repeatedly( assert first.id == second.id assert first.username == second.username assert first.status == second.status - assert first.created_at == second.created_at await iggy_client.delete_user(created.id) @@ -176,7 +174,6 @@ async def test_get_users_lists_root_and_created_users( assert first_username in usernames assert second_username in usernames assert all(isinstance(user.id, int) for user in users) - assert all(user.created_at > 0 for user in users) assert all(isinstance(user.status, UserStatus) for user in users) await iggy_client.delete_user(first.id) @@ -284,7 +281,7 @@ async def test_update_username_to_existing_username_fails( first = await iggy_client.create_user(first_username, first_password) second = await iggy_client.create_user(second_username, second_password) - with pytest.raises(RuntimeError, match="User already exists"): + with pytest.raises(RuntimeError): await iggy_client.update_user(first.id, username=second_username) unchanged = await iggy_client.get_user(first.id) @@ -299,7 +296,7 @@ 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, match="was not found"): + with pytest.raises(RuntimeError): await iggy_client.update_user( unique_name(max_bytes=MAX_USERNAME_BYTES), status=UserStatus.Inactive, @@ -336,7 +333,7 @@ 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, match="was not found"): + with pytest.raises(RuntimeError): await iggy_client.delete_user(unique_name(max_bytes=MAX_USERNAME_BYTES)) @@ -368,9 +365,9 @@ async def test_user_management_requires_connection_and_auth(method_name, unique_ method = getattr(client, method_name) args = args_by_method[method_name] - with pytest.raises(RuntimeError, match="Disconnected"): + with pytest.raises(RuntimeError): await method(*args) await client.connect() - with pytest.raises(RuntimeError, match="Unauthenticated"): + with pytest.raises(RuntimeError): await method(*args) From d41fb7650770b19eaea06ddf069ebcca89dddcde Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Sun, 19 Jul 2026 17:58:34 +0800 Subject: [PATCH 06/10] test(python): make user tests deterministic on shared servers Replace the hardcoded nonexistent id with a freshly vacated one, drop the repeated get_users snapshot comparison that other clients can invalidate, and assert the strictly ascending id order instead. --- foreign/python/tests/test_user.py | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/foreign/python/tests/test_user.py b/foreign/python/tests/test_user.py index 87191df1bd..683ee80467 100644 --- a/foreign/python/tests/test_user.py +++ b/foreign/python/tests/test_user.py @@ -132,7 +132,12 @@ async def test_get_nonexistent_user_returns_none( ) assert user_by_name is None - user_by_id = await iggy_client.get_user(999999) + # 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 @@ -176,25 +181,14 @@ async def test_get_users_lists_root_and_created_users( 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) - @pytest.mark.asyncio - async def test_get_users_returns_same_result_when_called_repeatedly( - self, iggy_client: IggyClient, unique_name - ): - """Test repeated get_users calls return a stable user view.""" - username, password = _unique_credentials(unique_name) - created = await iggy_client.create_user(username, password) - - first = await iggy_client.get_users() - second = await iggy_client.get_users() - - assert [user.id for user in first] == [user.id for user in second] - assert [user.username for user in first] == [user.username for user in second] - - await iggy_client.delete_user(created.id) - class TestUpdateUser: """Test user updates via update_user.""" From a671663f51bdf46053dd3477b7a3f938f819c9c5 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Sun, 19 Jul 2026 17:58:39 +0800 Subject: [PATCH 07/10] test(python): cover user credential byte-limit boundaries The server validates username and password length in bytes (3-50 and 3-100 respectively), not characters. Exercise both bounds for create_user and update_user, including multibyte credentials that fit the byte limits and ones that exceed them while staying within the character count. --- foreign/python/tests/test_user.py | 137 +++++++++++++++++++++++++++++- 1 file changed, 135 insertions(+), 2 deletions(-) diff --git a/foreign/python/tests/test_user.py b/foreign/python/tests/test_user.py index 683ee80467..cbde4b9599 100644 --- a/foreign/python/tests/test_user.py +++ b/foreign/python/tests/test_user.py @@ -21,13 +21,16 @@ from .utils import get_server_config, wait_for_ping, wait_for_server -# Server-side limits: usernames are 3-50 characters, passwords 3-100. +# 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_USERNAME_BYTES) + password = unique_name(max_bytes=MAX_PASSWORD_BYTES) return username, password @@ -110,6 +113,96 @@ async def test_created_user_can_login(self, iggy_client: IggyClient, unique_name await iggy_client.delete_user(created.id) + @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) + + @pytest.mark.parametrize( + "username", + ["あ" * 17, "🦀" * 13], + ids=["japanese-51-bytes", "emoji-52-bytes"], + ) + @pytest.mark.asyncio + async def test_create_user_username_limit_is_bytes_not_characters( + self, iggy_client: IggyClient, unique_name, username + ): + """Test a username within 50 characters but over 50 bytes is rejected.""" + assert len(username) <= MAX_USERNAME_BYTES + assert len(username.encode()) > MAX_USERNAME_BYTES + + with pytest.raises(RuntimeError): + await iggy_client.create_user( + username, unique_name(max_bytes=MAX_PASSWORD_BYTES) + ) + + @pytest.mark.asyncio + async def test_create_user_password_limit_is_bytes_not_characters( + self, iggy_client: IggyClient, unique_name + ): + """Test a password within 100 characters but over 100 bytes is rejected.""" + password = "あ" * 34 + assert len(password) <= MAX_PASSWORD_BYTES + assert len(password.encode()) > MAX_PASSWORD_BYTES + + with pytest.raises(RuntimeError): + await iggy_client.create_user( + unique_name(max_bytes=MAX_USERNAME_BYTES), password + ) + class TestGetUser: """Test user retrieval via get_user.""" @@ -296,6 +389,46 @@ async def test_update_nonexistent_user_fails( 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.""" From da1d23a246e705270607d106a5da8cf2777992d4 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Sun, 19 Jul 2026 17:58:45 +0800 Subject: [PATCH 08/10] test(python): cover user lifecycle edge cases Inactive and deleted users cannot log in, invalid identifiers fail client-side without a server round trip, an update with no fields is accepted as a noop, and deletion covers root protection, double deletion, removal from listings, live session behavior, and username reuse with fresh credentials. --- foreign/python/tests/test_user.py | 164 ++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/foreign/python/tests/test_user.py b/foreign/python/tests/test_user.py index cbde4b9599..dba45d832e 100644 --- a/foreign/python/tests/test_user.py +++ b/foreign/python/tests/test_user.py @@ -113,6 +113,37 @@ async def test_created_user_can_login(self, iggy_client: IggyClient, unique_name 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)], @@ -233,6 +264,23 @@ async def test_get_nonexistent_user_returns_none( 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 @@ -340,6 +388,23 @@ async def test_update_username_and_status_together( 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 @@ -374,6 +439,7 @@ async def test_update_username_to_existing_username_fails( 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) @@ -463,6 +529,104 @@ async def test_delete_nonexistent_user_fails( 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. + + A regular user holds no permissions, so privileged calls are rejected + both before and after deletion. The observable change on the live + session is that the user can no longer see itself. + """ + username, password = _unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + + host, port = get_server_config() + session = IggyClient(f"{host}:{port}") + await session.connect() + await session.login_user(username, password) + assert await session.get_user(username) is not None + + await iggy_client.delete_user(created.id) + + assert await session.get_user(username) is None + with pytest.raises(RuntimeError): + await session.get_users() + + @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", From 198274a99c52abbb73139922bdcb68bb15e6d54f Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Tue, 21 Jul 2026 01:07:04 +0800 Subject: [PATCH 09/10] test(python): drop redundant byte-limit user tests --- foreign/python/tests/test_user.py | 32 ------------------------------- 1 file changed, 32 deletions(-) diff --git a/foreign/python/tests/test_user.py b/foreign/python/tests/test_user.py index dba45d832e..b16bd65a74 100644 --- a/foreign/python/tests/test_user.py +++ b/foreign/python/tests/test_user.py @@ -202,38 +202,6 @@ async def test_create_user_accepts_multibyte_credentials( await iggy_client.delete_user(created.id) - @pytest.mark.parametrize( - "username", - ["あ" * 17, "🦀" * 13], - ids=["japanese-51-bytes", "emoji-52-bytes"], - ) - @pytest.mark.asyncio - async def test_create_user_username_limit_is_bytes_not_characters( - self, iggy_client: IggyClient, unique_name, username - ): - """Test a username within 50 characters but over 50 bytes is rejected.""" - assert len(username) <= MAX_USERNAME_BYTES - assert len(username.encode()) > MAX_USERNAME_BYTES - - with pytest.raises(RuntimeError): - await iggy_client.create_user( - username, unique_name(max_bytes=MAX_PASSWORD_BYTES) - ) - - @pytest.mark.asyncio - async def test_create_user_password_limit_is_bytes_not_characters( - self, iggy_client: IggyClient, unique_name - ): - """Test a password within 100 characters but over 100 bytes is rejected.""" - password = "あ" * 34 - assert len(password) <= MAX_PASSWORD_BYTES - assert len(password.encode()) > MAX_PASSWORD_BYTES - - with pytest.raises(RuntimeError): - await iggy_client.create_user( - unique_name(max_bytes=MAX_USERNAME_BYTES), password - ) - class TestGetUser: """Test user retrieval via get_user.""" From a9bb97bcaf58363c1227d385b2502f9f434ea37d Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Tue, 21 Jul 2026 01:25:14 +0800 Subject: [PATCH 10/10] test(python): disable unprovable deleted-session test Without permission management in the Python SDK, this test passes even if the deleted session stays authenticated, so it cannot prove its claim. Reduce it to a no-op with a TODO to restore later. --- foreign/python/tests/test_user.py | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/foreign/python/tests/test_user.py b/foreign/python/tests/test_user.py index b16bd65a74..9e94a59350 100644 --- a/foreign/python/tests/test_user.py +++ b/foreign/python/tests/test_user.py @@ -551,26 +551,10 @@ async def test_deleted_user_disappears_from_listings( 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. - - A regular user holds no permissions, so privileged calls are rejected - both before and after deletion. The observable change on the live - session is that the user can no longer see itself. - """ - username, password = _unique_credentials(unique_name) - created = await iggy_client.create_user(username, password) - - host, port = get_server_config() - session = IggyClient(f"{host}:{port}") - await session.connect() - await session.login_user(username, password) - assert await session.get_user(username) is not None - - await iggy_client.delete_user(created.id) - - assert await session.get_user(username) is None - with pytest.raises(RuntimeError): - await session.get_users() + """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(