Files
saas_backend/tests/test_idempotency.py
T
2026-08-31 20:39:41 -04:00

256 lines
9.6 KiB
Python

"""Idempotency keys.
The interesting behaviour is all in the edges: a key reused for a *different*
request, a key claimed by a request still running, a key held by a request that
failed. Each of those has a wrong answer that looks reasonable.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from app.core.tenant_context import unscoped
from app.models.auth.user_model import User
from app.models.system.idempotency_model import IdempotencyRecord
from app.services.system import idempotency_service
PASSWORD = "CorrectHorse!9"
ACCESSES = ["admin.user.create", "admin.user.read", "admin.user.delete"]
@pytest.fixture
def workspace(db, tenant_factory, plan_factory, role_factory):
from types import SimpleNamespace
plan = plan_factory(max_users_allowed=50, accesses=ACCESSES)
tenant = tenant_factory(plan_id=plan.id)
role = role_factory(tenant=tenant, accesses=ACCESSES)
return SimpleNamespace(tenant=tenant, tenant_id=tenant.id, role=role)
@pytest.fixture
def headers(client, db, workspace, user_factory):
user_factory(tenant=workspace.tenant, role=workspace.role,
email="retrier@example.com")
with unscoped():
db.commit()
token = client.post("/api/auth/signin",
json={"email": "retrier@example.com",
"password": PASSWORD}).json()["access_token"]
return {"Authorization": f"Bearer {token}"}
def _create(client, headers, key=None, email="once@example.com"):
sent = dict(headers)
if key:
sent["Idempotency-Key"] = key
return client.post("/api/user/create", headers=sent,
json={"email": email, "password": PASSWORD,
"first_name": "A", "last_name": "B"})
def test_the_same_key_does_the_work_once(client, headers, db, workspace):
"""The case it exists for: a client posts, the connection drops, and it has
no way to know whether the work happened."""
key = str(uuid.uuid4())
first = _create(client, headers, key)
assert first.status_code == 201, first.text
second = _create(client, headers, key)
assert second.status_code == 201
assert second.json() == first.json(), "the retry got a different answer"
assert second.headers.get("Idempotent-Replay") == "true"
with unscoped():
assert db.query(User).filter(User.email == "once@example.com").count() == 1
def test_a_replay_is_marked_as_one(client, headers):
"""A client reconciling its own logs needs to tell "we did it" from "it was
already done"."""
key = str(uuid.uuid4())
first = _create(client, headers, key)
assert "Idempotent-Replay" not in first.headers
second = _create(client, headers, key)
assert second.headers["Idempotent-Replay"] == "true"
def test_without_a_key_nothing_changes(client, headers, db):
"""The header is opt-in. Two identical posts with no key are two requests,
which is what HTTP already promised."""
first = _create(client, headers, email="twice-a@example.com")
second = _create(client, headers, email="twice-b@example.com")
assert first.status_code == 201
assert second.status_code == 201
def test_different_keys_are_different_requests(client, headers, db):
assert _create(client, headers, str(uuid.uuid4()),
"one@example.com").status_code == 201
assert _create(client, headers, str(uuid.uuid4()),
"two@example.com").status_code == 201
def test_a_key_reused_for_a_different_request_is_refused(client, headers):
"""The dangerous case. Answering it with the first response would have a
client believe a request happened that never did — "charge £10" retried as
"charge £1000" would quietly return the £10 answer."""
key = str(uuid.uuid4())
assert _create(client, headers, key, "first@example.com").status_code == 201
clash = _create(client, headers, key, "second@example.com")
assert clash.status_code == 422
assert "different request" in clash.json()["detail"].lower()
def test_a_key_still_in_progress_is_told_to_wait(client, headers, db, workspace):
"""Two simultaneous requests: the second must not proceed. Simulated by
leaving the claim behind, which is exactly the state the first would be in."""
key = str(uuid.uuid4())
with unscoped():
user_id = db.query(User.id).filter(
User.email == "retrier@example.com"
).scalar()
db.add(IdempotencyRecord(
tenant_id=workspace.tenant_id,
user_id=user_id,
idempotency_key=key,
endpoint="POST /api/user/create",
request_hash=idempotency_service.hash_request(b"anything"),
state="in_progress",
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
))
db.commit()
response = _create(client, headers, key)
assert response.status_code in (409, 422)
def test_a_failed_request_does_not_hold_the_key(client, headers, db):
"""A 500 is the response a client most wants to retry. Caching it would turn
one bad moment into a permanent one."""
key = str(uuid.uuid4())
bad = client.post("/api/user/create",
headers={**headers, "Idempotency-Key": key},
json={"email": "weak@example.com", "password": "password",
"first_name": "A", "last_name": "B"})
assert bad.status_code == 400
with unscoped():
assert db.query(IdempotencyRecord).filter(
IdempotencyRecord.idempotency_key == key
).count() == 0, "a failed response held the key"
fixed = _create(client, headers, key, "weak@example.com")
assert fixed.status_code == 201
def test_an_expired_key_is_treated_as_fresh(client, headers, db, workspace):
"""The client has clearly moved on. Refusing would be a puzzle with no way
to resolve it."""
key = str(uuid.uuid4())
assert _create(client, headers, key, "stale@example.com").status_code == 201
with unscoped():
record = db.query(IdempotencyRecord).filter(
IdempotencyRecord.idempotency_key == key
).one()
record.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
db.commit()
again = _create(client, headers, key, "stale2@example.com")
assert again.status_code == 201
assert "Idempotent-Replay" not in again.headers
def test_an_absurdly_long_key_is_refused(client, headers):
response = _create(client, headers, "x" * 500)
assert response.status_code == 400
def test_the_key_is_ignored_on_reads(client, headers):
"""GET is already idempotent. A key on one would be a cache, which is a
different feature with different rules about staleness."""
key = str(uuid.uuid4())
first = client.get("/api/user/get", headers={**headers, "Idempotency-Key": key})
assert first.status_code == 200
assert "Idempotent-Replay" not in first.headers
def test_two_workspaces_can_use_the_same_key(client, headers, db,
tenant_factory, plan_factory,
role_factory, user_factory):
"""A key is not a secret, and two customers picking the same random string
must not collide — nor be able to read each other's response by guessing."""
key = str(uuid.uuid4())
mine = _create(client, headers, key, "mine@example.com")
assert mine.status_code == 201
other = tenant_factory(plan_id=plan_factory(max_users_allowed=5,
accesses=ACCESSES).id)
other_role = role_factory(tenant=other, accesses=ACCESSES)
user_factory(tenant=other, role=other_role, email="theirs@example.com")
with unscoped():
db.commit()
token = client.post("/api/auth/signin",
json={"email": "theirs@example.com",
"password": PASSWORD}).json()["access_token"]
theirs = _create(client, {"Authorization": f"Bearer {token}"}, key,
"notmine@example.com")
assert theirs.status_code == 201
assert theirs.json()["email"] == "notmine@example.com", (
"one workspace was served another's stored response"
)
def test_the_same_key_on_a_different_endpoint_is_a_different_request(
client, headers, db
):
key = str(uuid.uuid4())
created = _create(client, headers, key, "moved@example.com")
assert created.status_code == 201
listed = client.post("/api/user/create",
headers={**headers, "Idempotency-Key": key},
json={"email": "moved@example.com", "password": PASSWORD,
"first_name": "A", "last_name": "B"})
assert listed.json() == created.json()
def test_expired_keys_are_purged(db, workspace):
with unscoped():
db.add(IdempotencyRecord(
tenant_id=workspace.tenant_id,
idempotency_key="old",
endpoint="POST /api/thing",
request_hash="0" * 64,
state="completed",
expires_at=datetime.now(timezone.utc) - timedelta(days=2),
))
db.add(IdempotencyRecord(
tenant_id=workspace.tenant_id,
idempotency_key="current",
endpoint="POST /api/thing",
request_hash="0" * 64,
state="completed",
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
))
db.flush()
removed = idempotency_service.purge_expired(db)
assert removed == 1
remaining = {
r.idempotency_key for r in db.query(IdempotencyRecord).filter(
IdempotencyRecord.tenant_id == workspace.tenant_id
).all()
}
assert remaining == {"current"}