| # 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 ( |
| GlobalPermissions, |
| IggyClient, |
| Permissions, |
| UserInfoDetails, |
| UserStatus, |
| ) |
| |
| from .utils import ( |
| MAX_PASSWORD_BYTES, |
| MAX_USERNAME_BYTES, |
| MIN_PASSWORD_BYTES, |
| MIN_USERNAME_BYTES, |
| get_server_config, |
| unique_credentials, |
| wait_for_ping, |
| wait_for_server, |
| ) |
| |
| |
| 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.permissions is None |
| |
| 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 |
| assert user_by_name.permissions is None |
| |
| 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.""" |
| username, password = unique_credentials(unique_name) |
| created = await iggy_client.create_user( |
| username, |
| password, |
| permissions=Permissions( |
| global_permissions=GlobalPermissions(read_users=True) |
| ), |
| ) |
| |
| host, port = get_server_config() |
| client = IggyClient(f"{host}:{port}") |
| await client.connect() |
| await wait_for_ping(client) |
| await client.login_user(username, password) |
| users_before = await client.get_users() |
| assert any(user.id == created.id for user in users_before) |
| |
| await iggy_client.delete_user(created.id) |
| |
| with pytest.raises(RuntimeError): |
| await client.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", |
| [ |
| "get_user", |
| "get_users", |
| "create_user", |
| "update_user", |
| "delete_user", |
| "update_permissions", |
| "change_password", |
| "logout_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,), |
| "update_permissions": (username, None), |
| "change_password": (username, "secret", "secret2"), |
| "logout_user": (), |
| } |
| 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) |