Files
roboco/tests/unit/services/test_playbook_slug_race.py
T

87 lines
3.2 KiB
Python
Raw Normal View History

2026-06-29 05:38:21 +02:00
"""``PlaybookService.draft`` slug TOCTOU must not 500.
Two concurrent same-title drafts both miss the pre-check; the loser's flush
hits the ``playbooks.slug`` UNIQUE constraint. The fix wraps the insert in a
savepoint and converts ``IntegrityError`` into a clean ``ConflictError`` (409),
so the loser is told to retry with a distinct title — it must NOT silently
reuse the winner's row (that would drop the loser's content).
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.models.playbook import PlaybookCreate
from roboco.services.base import ConflictError
from roboco.services.playbook import PlaybookService
from sqlalchemy.exc import IntegrityError
def _integrity_error() -> IntegrityError:
return IntegrityError(
"INSERT INTO playbooks ...",
{},
Exception("duplicate key value violates unique constraint playbooks_slug_key"),
)
def _svc(*, flush_side_effect: Any = None) -> tuple[PlaybookService, AsyncMock]:
"""Build a PlaybookService on a mock session that simulates the race.
``begin_nested`` returns an async context manager (the savepoint); the
default AsyncMock magic-method config makes ``async with`` work and
``__aexit__`` returns falsy so an exception raised in the body propagates
(mirroring a real savepoint, which rolls back and re-raises).
"""
session = AsyncMock()
session.add = MagicMock()
if flush_side_effect is not None:
session.flush = AsyncMock(side_effect=flush_side_effect)
else:
session.flush = AsyncMock()
session.begin_nested = MagicMock(return_value=AsyncMock())
svc = PlaybookService(session)
return svc, session
def _create(title: str = "Retry flaky pg") -> PlaybookCreate:
return PlaybookCreate(
title=title,
problem="connection resets intermittently",
procedure="1. retry with backoff",
tags=["backend"],
scope="org",
)
@pytest.mark.asyncio
async def test_draft_slug_race_raises_conflict_not_integrity_error() -> None:
"""Concurrent same-title loser: pre-check misses (None), flush raises
IntegrityError on the UNIQUE slug — draft must convert it to a clean
ConflictError, not let it propagate as an unhandled 500."""
svc, session = _svc(flush_side_effect=_integrity_error())
# Pre-check misses the row (the race window: the other draft is uncommitted).
object.__setattr__(svc, "_get_by_slug", AsyncMock(return_value=None))
with pytest.raises(ConflictError):
await svc.draft(_create(title="Colliding Title"), created_by=MagicMock())
# The insert was isolated in a savepoint so the loser's failed insert does
# not poison the caller's pending transaction.
session.begin_nested.assert_called_once()
@pytest.mark.asyncio
async def test_draft_happy_path_still_inserts() -> None:
"""No race: pre-check misses, flush succeeds — the savepoint path is used
and the row is added (regression guard for the savepoint wrap)."""
svc, session = _svc()
object.__setattr__(svc, "_get_by_slug", AsyncMock(return_value=None))
await svc.draft(_create(title="Clean Title"), created_by=MagicMock())
session.add.assert_called_once()
session.begin_nested.assert_called_once()