From 9d7a67b2d23e158fc2772addb6b68eb78ecf0b86 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Thu, 12 Feb 2026 15:34:31 +0100 Subject: [PATCH] fix: add collision retry for userid generation --- src/fastapi_oidc_op/userid.py | 16 +++++++++++++ tests/test_userid.py | 45 ++++++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/fastapi_oidc_op/userid.py b/src/fastapi_oidc_op/userid.py index 6f5b871..fd3bce2 100644 --- a/src/fastapi_oidc_op/userid.py +++ b/src/fastapi_oidc_op/userid.py @@ -2,6 +2,8 @@ import secrets from proquint import uint2quint +from fastapi_oidc_op.store.protocols import UserRepository + def generate_userid() -> str: """Generate a unique user identifier in proquint format. @@ -9,3 +11,17 @@ def generate_userid() -> str: Returns a 32-bit proquint string like 'lusab-bansen'. """ return uint2quint(secrets.randbits(32)) + + +async def generate_unique_userid(user_repo: UserRepository, max_retries: int = 10) -> str: + """Generate a userid that does not already exist in the repository. + + Calls generate_userid() and checks for collisions via user_repo.get_by_userid(). + Retries up to max_retries times before raising RuntimeError. + """ + for _ in range(max_retries): + candidate = generate_userid() + existing = await user_repo.get_by_userid(candidate) + if existing is None: + return candidate + raise RuntimeError(f"Failed to generate unique userid after {max_retries} retries") diff --git a/tests/test_userid.py b/tests/test_userid.py index f46aaa8..a10ff89 100644 --- a/tests/test_userid.py +++ b/tests/test_userid.py @@ -1,4 +1,9 @@ -from fastapi_oidc_op.userid import generate_userid +from unittest.mock import AsyncMock + +import pytest + +from fastapi_oidc_op.models import User +from fastapi_oidc_op.userid import generate_unique_userid, generate_userid def test_generate_userid_format() -> None: @@ -18,3 +23,41 @@ def test_generate_userid_uniqueness() -> None: def test_generate_userid_is_lowercase() -> None: userid = generate_userid() assert userid == userid.lower() + + +@pytest.mark.anyio +async def test_generate_unique_userid_no_collision() -> None: + """Returns a userid when no collision occurs.""" + user_repo = AsyncMock() + user_repo.get_by_userid.return_value = None + + result = await generate_unique_userid(user_repo) + + parts = result.split("-") + assert len(parts) == 2 + user_repo.get_by_userid.assert_called_once_with(result) + + +@pytest.mark.anyio +async def test_generate_unique_userid_retries_on_collision() -> None: + """Retries when a collision is detected and succeeds on second attempt.""" + existing_user = User(userid="taken-xxxxx", username="existing") + user_repo = AsyncMock() + user_repo.get_by_userid.side_effect = [existing_user, None] + + result = await generate_unique_userid(user_repo) + + assert user_repo.get_by_userid.call_count == 2 + parts = result.split("-") + assert len(parts) == 2 + + +@pytest.mark.anyio +async def test_generate_unique_userid_raises_after_max_retries() -> None: + """Raises RuntimeError when max retries are exhausted.""" + existing_user = User(userid="taken-xxxxx", username="existing") + user_repo = AsyncMock() + user_repo.get_by_userid.return_value = existing_user + + with pytest.raises(RuntimeError, match="Failed to generate unique userid"): + await generate_unique_userid(user_repo)