blob: e3b54f11cd498ec8b9eade76a5dd59b211233bb6 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from typing import Any
import pytest
from apache_iggy import (
GlobalPermissions,
IggyClient,
Permissions,
StreamPermissions,
TopicPermissions,
)
from .utils import (
MAX_PASSWORD_BYTES,
MAX_USERNAME_BYTES,
MIN_PASSWORD_BYTES,
get_server_config,
login_fresh_client,
unique_credentials,
wait_for_ping,
)
GLOBAL_PERMISSION_FLAGS = [
"manage_servers",
"read_servers",
"manage_users",
"read_users",
"manage_streams",
"read_streams",
"manage_topics",
"read_topics",
"poll_messages",
"send_messages",
]
STREAM_PERMISSION_FLAGS = [
"manage_stream",
"read_stream",
"manage_topics",
"read_topics",
"poll_messages",
"send_messages",
]
TOPIC_PERMISSION_FLAGS = [
"manage_topic",
"read_topic",
"poll_messages",
"send_messages",
]
class TestPermissionsModel:
"""Test constructing the Permissions classes without a server."""
def test_default_permissions_deny_everything(self):
"""Test Permissions() has every global flag off and no streams."""
permissions = Permissions()
assert permissions.streams is None
global_permissions = permissions.global_permissions
assert global_permissions.manage_servers is False
assert global_permissions.read_servers is False
assert global_permissions.manage_users is False
assert global_permissions.read_users is False
assert global_permissions.manage_streams is False
assert global_permissions.read_streams is False
assert global_permissions.manage_topics is False
assert global_permissions.read_topics is False
assert global_permissions.poll_messages is False
assert global_permissions.send_messages is False
@pytest.mark.parametrize("flag", GLOBAL_PERMISSION_FLAGS)
def test_each_global_permission_flag_round_trips_alone(self, flag):
"""Test setting one flag flips only that flag."""
global_permissions = GlobalPermissions(**{flag: True})
for name in GLOBAL_PERMISSION_FLAGS:
assert getattr(global_permissions, name) is (name == flag)
@pytest.mark.parametrize("flag", STREAM_PERMISSION_FLAGS)
def test_each_stream_permission_flag_round_trips_alone(self, flag):
"""Test setting one flag flips only that flag."""
flags: dict[str, Any] = {flag: True}
stream_permissions = StreamPermissions(**flags)
for name in STREAM_PERMISSION_FLAGS:
assert getattr(stream_permissions, name) is (name == flag)
@pytest.mark.parametrize("flag", TOPIC_PERMISSION_FLAGS)
def test_each_topic_permission_flag_round_trips_alone(self, flag):
"""Test setting one flag flips only that flag."""
topic_permissions = TopicPermissions(**{flag: True})
for name in TOPIC_PERMISSION_FLAGS:
assert getattr(topic_permissions, name) is (name == flag)
def test_nested_stream_and_topic_permissions_are_preserved(self):
"""Test stream and topic permission dicts survive construction."""
permissions = Permissions(
global_permissions=GlobalPermissions(read_servers=True),
streams={
1: StreamPermissions(
read_stream=True,
topics={7: TopicPermissions(poll_messages=True)},
),
42: StreamPermissions(manage_stream=True),
},
)
streams = permissions.streams
assert streams is not None
assert set(streams) == {1, 42}
assert streams[1].read_stream is True
assert streams[1].manage_stream is False
topics = streams[1].topics
assert topics is not None
assert set(topics) == {7}
assert topics[7].poll_messages is True
assert topics[7].manage_topic is False
assert streams[42].manage_stream is True
assert streams[42].topics is None
def test_empty_permission_dicts_normalize_to_none(self):
"""Test empty stream and topic dicts are equivalent to None."""
assert Permissions(streams={}).streams is None
assert Permissions(streams={}) == Permissions()
assert StreamPermissions(topics={}).topics is None
assert StreamPermissions(topics={}) == StreamPermissions()
def test_stream_id_above_u32_is_rejected(self):
"""Test dict keys outside the u32 wire range fail at construction."""
with pytest.raises(OverflowError):
Permissions(streams={2**32: StreamPermissions(read_stream=True)})
with pytest.raises(OverflowError):
StreamPermissions(topics={2**32: TopicPermissions(read_topic=True)})
def test_permissions_equality(self):
"""Test structurally identical Permissions compare equal."""
first = Permissions(
global_permissions=GlobalPermissions(read_streams=True),
streams={3: StreamPermissions(poll_messages=True)},
)
second = Permissions(
global_permissions=GlobalPermissions(read_streams=True),
streams={3: StreamPermissions(poll_messages=True)},
)
different = Permissions(
global_permissions=GlobalPermissions(manage_streams=True)
)
assert first == second
assert first != different
class TestCreateUserWithPermissions:
"""Test create_user with the permissions argument."""
@pytest.mark.asyncio
async def test_create_user_with_global_permissions_round_trips(
self, iggy_client: IggyClient, unique_name
):
"""Test global permissions set at creation come back from get_user."""
username, password = unique_credentials(unique_name)
permissions = Permissions(
global_permissions=GlobalPermissions(read_users=True, read_streams=True),
streams={},
)
created = await iggy_client.create_user(
username, password, permissions=permissions
)
assert created.permissions == permissions
fetched = await iggy_client.get_user(created.id)
assert fetched is not None
fetched_permissions = fetched.permissions
assert fetched_permissions is not None
assert fetched_permissions == permissions
assert fetched_permissions.streams is None
await iggy_client.delete_user(created.id)
@pytest.mark.asyncio
async def test_create_user_with_nested_permissions_round_trips(
self, iggy_client: IggyClient, unique_name
):
"""Test per-stream and per-topic permissions survive the round trip."""
username, password = unique_credentials(unique_name)
stream_name = unique_name(prefix="perm-stream-")
await iggy_client.create_stream(stream_name)
stream = await iggy_client.get_stream(stream_name)
assert stream is not None
topic_name = unique_name(prefix="perm-topic-")
await iggy_client.create_topic(stream_name, topic_name, partitions_count=1)
topic = await iggy_client.get_topic(stream_name, topic_name)
assert topic is not None
permissions = Permissions(
streams={
stream.id: StreamPermissions(
read_stream=True,
poll_messages=True,
topics={topic.id: TopicPermissions(send_messages=True)},
)
}
)
created = await iggy_client.create_user(
username, password, permissions=permissions
)
assert created.permissions == permissions
fetched = await iggy_client.get_user(created.id)
assert fetched is not None
fetched_permissions = fetched.permissions
assert fetched_permissions is not None
assert fetched_permissions == permissions
streams = fetched_permissions.streams
assert streams is not None
assert streams[stream.id].read_stream is True
topics = streams[stream.id].topics
assert topics is not None
assert topics[topic.id].send_messages is True
await iggy_client.delete_user(created.id)
@pytest.mark.asyncio
async def test_user_with_manage_streams_can_create_stream(
self, iggy_client: IggyClient, unique_name
):
"""Test a granted global permission is enforced for the new user."""
username, password = unique_credentials(unique_name)
permissions = Permissions(
global_permissions=GlobalPermissions(manage_streams=True)
)
created = await iggy_client.create_user(
username, password, permissions=permissions
)
client = await login_fresh_client(username, password)
stream_name = unique_name(prefix="granted-")
await client.create_stream(stream_name)
stream = await iggy_client.get_stream(stream_name)
assert stream is not None
await iggy_client.delete_user(created.id)
@pytest.mark.asyncio
async def test_user_without_permissions_cannot_create_stream(
self, iggy_client: IggyClient, unique_name
):
"""Test a user created without permissions is denied stream creation."""
username, password = unique_credentials(unique_name)
created = await iggy_client.create_user(username, password)
client = await login_fresh_client(username, password)
with pytest.raises(RuntimeError):
await client.create_stream(unique_name(prefix="denied-"))
await iggy_client.delete_user(created.id)
@pytest.mark.asyncio
async def test_per_stream_permission_is_scoped_to_that_stream(
self, iggy_client: IggyClient, unique_name
):
"""Test read access to one stream does not leak to another stream."""
readable_name = unique_name(prefix="readable-")
hidden_name = unique_name(prefix="hidden-")
await iggy_client.create_stream(readable_name)
await iggy_client.create_stream(hidden_name)
readable = await iggy_client.get_stream(readable_name)
assert readable is not None
username, password = unique_credentials(unique_name)
permissions = Permissions(
streams={readable.id: StreamPermissions(read_stream=True)}
)
created = await iggy_client.create_user(
username, password, permissions=permissions
)
client = await login_fresh_client(username, password)
visible = await client.get_stream(readable_name)
assert visible is not None
assert visible.id == readable.id
with pytest.raises(RuntimeError):
await client.get_stream(hidden_name)
await iggy_client.delete_user(created.id)
class TestUpdatePermissions:
"""Test permission updates via update_permissions."""
@pytest.mark.asyncio
async def test_update_permissions_grants_and_round_trips(
self, iggy_client: IggyClient, unique_name
):
"""Test permissions granted after creation come back from get_user."""
username, password = unique_credentials(unique_name)
created = await iggy_client.create_user(username, password)
assert created.permissions is None
permissions = Permissions(
global_permissions=GlobalPermissions(read_streams=True)
)
await iggy_client.update_permissions(created.id, permissions)
fetched = await iggy_client.get_user(created.id)
assert fetched is not None
assert fetched.permissions == permissions
await iggy_client.delete_user(created.id)
@pytest.mark.asyncio
async def test_update_permissions_replaces_previous_permissions(
self, iggy_client: IggyClient, unique_name
):
"""Test update_permissions overwrites instead of merging."""
username, password = unique_credentials(unique_name)
initial = Permissions(
global_permissions=GlobalPermissions(read_streams=True, read_users=True)
)
created = await iggy_client.create_user(username, password, permissions=initial)
replacement = Permissions(
global_permissions=GlobalPermissions(read_servers=True)
)
await iggy_client.update_permissions(created.id, replacement)
fetched = await iggy_client.get_user(created.id)
assert fetched is not None
fetched_permissions = fetched.permissions
assert fetched_permissions is not None
assert fetched_permissions == replacement
assert fetched_permissions.global_permissions.read_streams is False
assert fetched_permissions.global_permissions.read_users is False
await iggy_client.delete_user(created.id)
@pytest.mark.asyncio
async def test_update_permissions_none_clears_permissions(
self, iggy_client: IggyClient, unique_name
):
"""Test update_permissions with None removes existing permissions."""
username, password = unique_credentials(unique_name)
permissions = Permissions(
global_permissions=GlobalPermissions(read_streams=True)
)
created = await iggy_client.create_user(
username, password, permissions=permissions
)
await iggy_client.update_permissions(created.id, None)
fetched = await iggy_client.get_user(created.id)
assert fetched is not None
assert fetched.permissions is None
await iggy_client.delete_user(created.id)
@pytest.mark.asyncio
async def test_update_permissions_takes_effect_for_new_session(
self, iggy_client: IggyClient, unique_name
):
"""Test a permission granted after creation is enforced on next login."""
username, password = unique_credentials(unique_name)
created = await iggy_client.create_user(username, password)
denied_client = await login_fresh_client(username, password)
with pytest.raises(RuntimeError):
await denied_client.create_stream(unique_name(prefix="before-grant-"))
await iggy_client.update_permissions(
created.id,
Permissions(global_permissions=GlobalPermissions(manage_streams=True)),
)
granted_client = await login_fresh_client(username, password)
stream_name = unique_name(prefix="after-grant-")
await granted_client.create_stream(stream_name)
assert await iggy_client.get_stream(stream_name) is not None
await iggy_client.delete_user(created.id)
@pytest.mark.asyncio
async def test_user_without_manage_users_cannot_update_permissions(
self, iggy_client: IggyClient, unique_name
):
"""Test a user lacking manage_users is denied updating another user."""
actor_username, actor_password = unique_credentials(unique_name)
actor = await iggy_client.create_user(actor_username, actor_password)
target_username, target_password = unique_credentials(unique_name)
target = await iggy_client.create_user(target_username, target_password)
actor_client = await login_fresh_client(actor_username, actor_password)
with pytest.raises(RuntimeError):
await actor_client.update_permissions(
target.id,
Permissions(global_permissions=GlobalPermissions(read_streams=True)),
)
await iggy_client.delete_user(actor.id)
await iggy_client.delete_user(target.id)
@pytest.mark.asyncio
async def test_user_with_manage_users_can_update_permissions(
self, iggy_client: IggyClient, unique_name
):
"""Test a user granted manage_users can update another user."""
actor_username, actor_password = unique_credentials(unique_name)
actor = await iggy_client.create_user(
actor_username,
actor_password,
permissions=Permissions(
global_permissions=GlobalPermissions(manage_users=True)
),
)
target_username, target_password = unique_credentials(unique_name)
target = await iggy_client.create_user(target_username, target_password)
granted = Permissions(global_permissions=GlobalPermissions(read_streams=True))
actor_client = await login_fresh_client(actor_username, actor_password)
await actor_client.update_permissions(target.id, granted)
fetched = await iggy_client.get_user(target.id)
assert fetched is not None
assert fetched.permissions == granted
await iggy_client.delete_user(actor.id)
await iggy_client.delete_user(target.id)
@pytest.mark.asyncio
async def test_failed_update_leaves_target_permissions_unchanged(
self, iggy_client: IggyClient, unique_name
):
"""Test a denied update does not alter the target's permissions."""
actor_username, actor_password = unique_credentials(unique_name)
actor = await iggy_client.create_user(actor_username, actor_password)
target_username, target_password = unique_credentials(unique_name)
initial = Permissions(global_permissions=GlobalPermissions(read_users=True))
target = await iggy_client.create_user(
target_username, target_password, permissions=initial
)
actor_client = await login_fresh_client(actor_username, actor_password)
with pytest.raises(RuntimeError):
await actor_client.update_permissions(
target.id,
Permissions(global_permissions=GlobalPermissions(manage_servers=True)),
)
fetched = await iggy_client.get_user(target.id)
assert fetched is not None
assert fetched.permissions == initial
await iggy_client.delete_user(actor.id)
await iggy_client.delete_user(target.id)
@pytest.mark.asyncio
async def test_update_permissions_nonexistent_user_fails(
self, iggy_client: IggyClient, unique_name
):
"""Test update_permissions raises for a non-existent user."""
with pytest.raises(RuntimeError):
await iggy_client.update_permissions(
unique_name(max_bytes=MAX_USERNAME_BYTES),
Permissions(global_permissions=GlobalPermissions(read_streams=True)),
)
class TestChangePassword:
"""Test password changes via change_password."""
@pytest.mark.asyncio
async def test_change_password_swaps_credentials(
self, iggy_client: IggyClient, unique_name
):
"""Test the new password works for login and the old one is rejected."""
username, password = unique_credentials(unique_name)
new_password = unique_name(max_bytes=MAX_PASSWORD_BYTES)
created = await iggy_client.create_user(username, password)
await iggy_client.change_password(created.id, password, new_password)
host, port = get_server_config()
client = IggyClient(f"{host}:{port}")
await client.connect()
await wait_for_ping(client)
with pytest.raises(RuntimeError):
await client.login_user(username, password)
await client.login_user(username, new_password)
await iggy_client.delete_user(created.id)
@pytest.mark.asyncio
async def test_change_password_rejects_wrong_current_password(
self, iggy_client: IggyClient, unique_name
):
"""Test change_password fails when the current password is wrong."""
username = unique_name(max_bytes=MAX_USERNAME_BYTES)
# Keep one byte of headroom so the wrong password stays within the
# server limit and the failure is about the mismatch, not the length.
password = unique_name(max_bytes=MAX_PASSWORD_BYTES - 1)
created = await iggy_client.create_user(username, password)
with pytest.raises(RuntimeError):
await iggy_client.change_password(
created.id, f"{password}x", unique_name(max_bytes=MAX_PASSWORD_BYTES)
)
client = await login_fresh_client(username, password)
await client.ping()
await iggy_client.delete_user(created.id)
@pytest.mark.asyncio
async def test_user_can_change_own_password(
self, iggy_client: IggyClient, unique_name
):
"""Test a user without permissions can change its own password."""
username, password = unique_credentials(unique_name)
new_password = unique_name(max_bytes=MAX_PASSWORD_BYTES)
created = await iggy_client.create_user(username, password)
client = await login_fresh_client(username, password)
await client.change_password(created.id, password, new_password)
relogin = await login_fresh_client(username, new_password)
await relogin.ping()
await iggy_client.delete_user(created.id)
@pytest.mark.asyncio
async def test_user_with_manage_users_can_change_other_password(
self, iggy_client: IggyClient, unique_name
):
"""Test a user granted manage_users can change another user's password."""
actor_username, actor_password = unique_credentials(unique_name)
actor = await iggy_client.create_user(
actor_username,
actor_password,
permissions=Permissions(
global_permissions=GlobalPermissions(manage_users=True)
),
)
target_username, target_password = unique_credentials(unique_name)
target = await iggy_client.create_user(target_username, target_password)
new_password = unique_name(max_bytes=MAX_PASSWORD_BYTES)
actor_client = await login_fresh_client(actor_username, actor_password)
await actor_client.change_password(target.id, target_password, new_password)
relogin = await login_fresh_client(target_username, new_password)
await relogin.ping()
await iggy_client.delete_user(actor.id)
await iggy_client.delete_user(target.id)
@pytest.mark.asyncio
async def test_failed_password_change_leaves_target_credentials_valid(
self, iggy_client: IggyClient, unique_name
):
"""Test a user lacking manage_users is denied and old credentials stay valid."""
actor_username, actor_password = unique_credentials(unique_name)
actor = await iggy_client.create_user(actor_username, actor_password)
target_username, target_password = unique_credentials(unique_name)
target = await iggy_client.create_user(target_username, target_password)
actor_client = await login_fresh_client(actor_username, actor_password)
with pytest.raises(RuntimeError):
await actor_client.change_password(
target.id, target_password, unique_name(max_bytes=MAX_PASSWORD_BYTES)
)
target_client = await login_fresh_client(target_username, target_password)
await target_client.ping()
await iggy_client.delete_user(actor.id)
await iggy_client.delete_user(target.id)
@pytest.mark.parametrize(
"new_password",
["a" * (MIN_PASSWORD_BYTES - 1), "a" * (MAX_PASSWORD_BYTES + 1)],
ids=["too-short", "too-long"],
)
@pytest.mark.asyncio
async def test_change_password_rejects_out_of_bounds_new_password(
self, iggy_client: IggyClient, unique_name, new_password
):
"""Test change_password rejects new passwords outside the 3-100 byte range."""
username, password = unique_credentials(unique_name)
created = await iggy_client.create_user(username, password)
with pytest.raises(RuntimeError):
await iggy_client.change_password(created.id, password, new_password)
client = await login_fresh_client(username, password)
await client.ping()
await iggy_client.delete_user(created.id)
@pytest.mark.asyncio
async def test_change_password_nonexistent_user_fails(
self, iggy_client: IggyClient, unique_name
):
"""Test change_password raises for a non-existent user."""
with pytest.raises(RuntimeError):
await iggy_client.change_password(
unique_name(max_bytes=MAX_USERNAME_BYTES),
unique_name(max_bytes=MAX_PASSWORD_BYTES),
unique_name(max_bytes=MAX_PASSWORD_BYTES),
)
class TestLogoutUser:
"""Test session termination via logout_user."""
@pytest.mark.asyncio
async def test_logout_user_ends_the_session(
self, iggy_client: IggyClient, unique_name
):
"""Test authenticated calls fail after logout."""
username, password = unique_credentials(unique_name)
created = await iggy_client.create_user(
username,
password,
permissions=Permissions(
global_permissions=GlobalPermissions(read_users=True)
),
)
client = await login_fresh_client(username, password)
assert await client.get_user(username) is not None
await client.logout_user()
with pytest.raises(RuntimeError):
await client.get_user(username)
await iggy_client.delete_user(created.id)
@pytest.mark.asyncio
async def test_logout_twice_fails(self, iggy_client: IggyClient, unique_name):
"""Test a second logout on the same session raises."""
username, password = unique_credentials(unique_name)
created = await iggy_client.create_user(username, password)
client = await login_fresh_client(username, password)
await client.logout_user()
with pytest.raises(RuntimeError):
await client.logout_user()
await iggy_client.delete_user(created.id)
@pytest.mark.asyncio
async def test_login_after_logout_restores_the_session(
self, iggy_client: IggyClient, unique_name
):
"""Test the same client can log in again after logging out."""
username, password = unique_credentials(unique_name)
created = await iggy_client.create_user(
username,
password,
permissions=Permissions(
global_permissions=GlobalPermissions(read_users=True)
),
)
client = await login_fresh_client(username, password)
await client.logout_user()
await client.login_user(username, password)
assert await client.get_user(username) is not None
await iggy_client.delete_user(created.id)