Security fixes: P0-P2 complete (22 fixes)
P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal 8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
"""Tests for the transactional outbox pattern.
|
||||
|
||||
Covers:
|
||||
- enqueue_outbox_event inserts rows with status='pending'
|
||||
- process_outbox_batch publishes events to the in-process bus
|
||||
- Retry logic with exponential backoff
|
||||
- Max attempts → 'failed' status
|
||||
- Empty batch returns 0
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
|
||||
|
||||
from app.core.event_bus import get_event_bus
|
||||
from app.core.outbox import enqueue_outbox_event, process_outbox_batch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_outbox_event_inserts_pending_row(db_session):
|
||||
"""enqueue_outbox_event inserts a row with status='pending'."""
|
||||
tenant_id = uuid.uuid4()
|
||||
|
||||
await enqueue_outbox_event(
|
||||
db_session, tenant_id, "contact.created",
|
||||
{"contact_id": "abc-123", "tenant_id": str(tenant_id)},
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
text("SELECT event_name, status, payload FROM event_outbox WHERE tenant_id = :tid"),
|
||||
{"tid": str(tenant_id)},
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
assert len(rows) == 1
|
||||
assert rows[0][0] == "contact.created"
|
||||
assert rows[0][1] == "pending"
|
||||
assert rows[0][2]["contact_id"] == "abc-123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_outbox_batch_publishes_events(
|
||||
db_session, session_factory: async_sessionmaker[AsyncSession],
|
||||
):
|
||||
"""process_outbox_batch publishes pending events and marks them 'published'."""
|
||||
tenant_id = uuid.uuid4()
|
||||
received_events: list[tuple[str, dict]] = []
|
||||
|
||||
async def _handler(payload: dict) -> None:
|
||||
received_events.append(("test.event", payload))
|
||||
|
||||
bus = get_event_bus()
|
||||
bus.subscribe("test.event", _handler)
|
||||
try:
|
||||
await enqueue_outbox_event(
|
||||
db_session, tenant_id, "test.event",
|
||||
{"key": "value"},
|
||||
)
|
||||
await db_session.flush()
|
||||
await db_session.commit()
|
||||
|
||||
# Use a separate session to simulate the worker
|
||||
async with session_factory() as worker_session:
|
||||
count = await process_outbox_batch(worker_session, batch_size=10)
|
||||
|
||||
assert count == 1
|
||||
assert len(received_events) == 1
|
||||
assert received_events[0][1]["key"] == "value"
|
||||
|
||||
# Verify the event is marked as published
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
text("SELECT status FROM event_outbox WHERE tenant_id = :tid"),
|
||||
{"tid": str(tenant_id)},
|
||||
)
|
||||
).fetchall()
|
||||
assert rows[0][0] == "published"
|
||||
finally:
|
||||
bus.unsubscribe("test.event", _handler)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_outbox_batch_empty_returns_zero(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
):
|
||||
"""process_outbox_batch returns 0 when no pending events exist."""
|
||||
async with session_factory() as worker_session:
|
||||
count = await process_outbox_batch(worker_session, batch_size=10)
|
||||
assert count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_outbox_batch_retry_on_failure(
|
||||
db_session, session_factory: async_sessionmaker[AsyncSession],
|
||||
):
|
||||
"""When a handler raises, the event is retried with exponential backoff."""
|
||||
tenant_id = uuid.uuid4()
|
||||
|
||||
async def _failing_handler(payload: dict) -> None:
|
||||
raise RuntimeError("Handler failure")
|
||||
|
||||
bus = get_event_bus()
|
||||
bus.subscribe("test.failing", _failing_handler)
|
||||
try:
|
||||
await enqueue_outbox_event(
|
||||
db_session, tenant_id, "test.failing",
|
||||
{"attempt": 1},
|
||||
)
|
||||
await db_session.flush()
|
||||
await db_session.commit()
|
||||
|
||||
async with session_factory() as worker_session:
|
||||
count = await process_outbox_batch(worker_session, batch_size=10)
|
||||
|
||||
assert count == 0 # nothing was successfully published
|
||||
|
||||
# Verify the event is back to 'pending' with attempts=1 and a retry time
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
text(
|
||||
"SELECT status, attempts, next_retry_at "
|
||||
"FROM event_outbox WHERE tenant_id = :tid"
|
||||
),
|
||||
{"tid": str(tenant_id)},
|
||||
)
|
||||
).fetchall()
|
||||
assert rows[0][0] == "pending"
|
||||
assert rows[0][1] == 1
|
||||
assert rows[0][2] is not None
|
||||
finally:
|
||||
bus.unsubscribe("test.failing", _failing_handler)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_outbox_batch_max_attempts_marks_failed(
|
||||
db_session, session_factory: async_sessionmaker[AsyncSession],
|
||||
):
|
||||
"""After max_attempts failures, the event is marked as 'failed'."""
|
||||
tenant_id = uuid.uuid4()
|
||||
|
||||
async def _always_fails(payload: dict) -> None:
|
||||
raise RuntimeError("Always fails")
|
||||
|
||||
bus = get_event_bus()
|
||||
bus.subscribe("test.maxfail", _always_fails)
|
||||
try:
|
||||
# Insert an event that already has attempts = max_attempts - 1
|
||||
await db_session.execute(
|
||||
text(
|
||||
"INSERT INTO event_outbox (tenant_id, event_name, payload, status, attempts, max_attempts) "
|
||||
"VALUES (:tid, 'test.maxfail', CAST(:payload AS JSONB), 'pending', 4, 5)"
|
||||
),
|
||||
{"tid": str(tenant_id), "payload": '{"k": "v"}'},
|
||||
)
|
||||
await db_session.flush()
|
||||
await db_session.commit()
|
||||
|
||||
async with session_factory() as worker_session:
|
||||
count = await process_outbox_batch(worker_session, batch_size=10)
|
||||
|
||||
assert count == 0
|
||||
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
text("SELECT status, attempts FROM event_outbox WHERE tenant_id = :tid"),
|
||||
{"tid": str(tenant_id)},
|
||||
)
|
||||
).fetchall()
|
||||
assert rows[0][0] == "failed"
|
||||
finally:
|
||||
bus.unsubscribe("test.maxfail", _always_fails)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_multiple_events_and_batch_size(
|
||||
db_session, session_factory: async_sessionmaker[AsyncSession],
|
||||
):
|
||||
"""Multiple events are enqueued and batch_size limits processing."""
|
||||
tenant_id = uuid.uuid4()
|
||||
received: list[str] = []
|
||||
|
||||
async def _handler(payload: dict) -> None:
|
||||
received.append(payload.get("idx", "?"))
|
||||
|
||||
bus = get_event_bus()
|
||||
bus.subscribe("test.batch", _handler)
|
||||
try:
|
||||
for i in range(5):
|
||||
await enqueue_outbox_event(
|
||||
db_session, tenant_id, "test.batch",
|
||||
{"idx": str(i)},
|
||||
)
|
||||
await db_session.flush()
|
||||
await db_session.commit()
|
||||
|
||||
async with session_factory() as worker_session:
|
||||
count = await process_outbox_batch(worker_session, batch_size=3)
|
||||
|
||||
assert count == 3
|
||||
assert len(received) == 3
|
||||
|
||||
# Process the remaining 2
|
||||
async with session_factory() as worker_session:
|
||||
count2 = await process_outbox_batch(worker_session, batch_size=3)
|
||||
assert count2 == 2
|
||||
assert len(received) == 5
|
||||
finally:
|
||||
bus.unsubscribe("test.batch", _handler)
|
||||
Reference in New Issue
Block a user