feat(persistence): PR8 T8.4 — ApiKeyRow ORM + 3 tests
Adds deerflow/persistence/api_key/{__init__,model}.py with ApiKeyRow:
- id: UUID36 PK
- service_account_id: FK service_accounts ON DELETE CASCADE
- key_prefix: String(16), UNIQUE — global uniqueness preserved even
after revoke so audit logs never reference an ambiguous prefix
- key_hash: String(128) sha-256 hex (plaintext returned only once at
create time)
- name / scopes / rate_limit_rpm / expires_at / last_used_at /
revoked_at — all nullable or default-providing
- created_at: UTC
- Index idx_api_keys_sa (service_account_id) — list keys for an SA
- Index idx_api_keys_active (key_prefix) WHERE revoked_at IS NULL —
partial index, dual-dialect via sqlite_where + postgresql_where,
shrinks the hot-path lookup index by excluding revoked keys
T8.4 tests:
- test_unique_key_prefix_enforced: column-level UNIQUE blocks two
rows from sharing key_prefix (active or revoked alike)
- test_active_index_declares_both_dialect_where_clauses: schema
introspection confirms idx_api_keys_active has both
`dialect_options.sqlite.where` and `dialect_options.postgresql.where`
set to `revoked_at IS NULL` — guards against accidental loss of the
dual-driver hint when the Index is edited later
- test_cascade_on_service_account_delete: deleting the parent SA
removes all api_keys rows
Registered in deerflow/persistence/models/__init__.py.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
"""Schema tests for ``ApiKeyRow`` (Stage 0 PR8).
|
||||
|
||||
PR8 is schema-only — no repository class, no API. Tests exercise raw
|
||||
ORM behaviour: column-level UNIQUE on key_prefix, the dual-dialect
|
||||
partial index DDL (sqlite_where + postgresql_where), and CASCADE on
|
||||
service_account delete.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from deerflow.persistence.api_key import ApiKeyRow
|
||||
from deerflow.persistence.service_account import ServiceAccountRow
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
async def _setup(tmp_path):
|
||||
from deerflow.persistence.engine import get_session_factory, init_engine
|
||||
|
||||
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
|
||||
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
||||
return get_session_factory()
|
||||
|
||||
|
||||
async def _cleanup():
|
||||
from deerflow.persistence.engine import close_engine
|
||||
|
||||
await close_engine()
|
||||
|
||||
|
||||
async def _seed_parents(sf) -> None:
|
||||
now = datetime.now(UTC)
|
||||
async with sf() as session:
|
||||
session.add(UserRow(id="u-alice", email="alice@example.com"))
|
||||
await session.commit()
|
||||
async with sf() as session:
|
||||
session.add(WorkspaceRow(id="w-1", name="Alice WS", slug="alice", owner_id="u-alice"))
|
||||
await session.commit()
|
||||
async with sf() as session:
|
||||
session.add(
|
||||
ServiceAccountRow(
|
||||
id="sa-1",
|
||||
workspace_id="w-1",
|
||||
name="bot",
|
||||
role="member",
|
||||
identity_mode="collapsed",
|
||||
status="active",
|
||||
created_by="u-alice",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _make_key(*, key_id: str, prefix: str, revoked_at: datetime | None = None) -> ApiKeyRow:
|
||||
now = datetime.now(UTC)
|
||||
return ApiKeyRow(
|
||||
id=key_id,
|
||||
service_account_id="sa-1",
|
||||
key_prefix=prefix,
|
||||
key_hash="0" * 64,
|
||||
name="default",
|
||||
scopes="",
|
||||
rate_limit_rpm=None,
|
||||
expires_at=None,
|
||||
last_used_at=None,
|
||||
revoked_at=revoked_at,
|
||||
created_at=now,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T8.4-1 — column-level UNIQUE on key_prefix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_unique_key_prefix_enforced(tmp_path):
|
||||
"""Two api_key rows sharing the same key_prefix raise IntegrityError."""
|
||||
sf = await _setup(tmp_path)
|
||||
try:
|
||||
await _seed_parents(sf)
|
||||
async with sf() as session:
|
||||
session.add(_make_key(key_id="ak-1", prefix="dfk_live_abc12345"))
|
||||
await session.commit()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with sf() as session:
|
||||
session.add(_make_key(key_id="ak-2", prefix="dfk_live_abc12345"))
|
||||
await session.commit()
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T8.4-2 — partial index DDL covers both SQLite and Postgres
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_active_index_declares_both_dialect_where_clauses():
|
||||
"""idx_api_keys_active must compile to a partial index on both drivers.
|
||||
|
||||
The plan locks ``sqlite_where`` *and* ``postgresql_where`` so the same
|
||||
Index emits a partial index regardless of backend (Stage 0 dev still
|
||||
runs SQLite locally; production is Postgres). Both ``dialect_options``
|
||||
entries must be present.
|
||||
"""
|
||||
active_idx = next((idx for idx in ApiKeyRow.__table__.indexes if idx.name == "idx_api_keys_active"), None)
|
||||
assert active_idx is not None, "idx_api_keys_active not declared"
|
||||
sqlite_where = active_idx.dialect_options.get("sqlite", {}).get("where")
|
||||
postgres_where = active_idx.dialect_options.get("postgresql", {}).get("where")
|
||||
assert sqlite_where is not None, "sqlite_where missing on idx_api_keys_active"
|
||||
assert postgres_where is not None, "postgresql_where missing on idx_api_keys_active"
|
||||
assert "revoked_at IS NULL" in str(sqlite_where)
|
||||
assert "revoked_at IS NULL" in str(postgres_where)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T8.4-3 — CASCADE on service_account delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_cascade_on_service_account_delete(tmp_path):
|
||||
"""Deleting the parent service_account removes all child api_keys."""
|
||||
sf = await _setup(tmp_path)
|
||||
try:
|
||||
await _seed_parents(sf)
|
||||
async with sf() as session:
|
||||
session.add(_make_key(key_id="ak-cascade-1", prefix="dfk_live_cascade1"))
|
||||
session.add(_make_key(key_id="ak-cascade-2", prefix="dfk_live_cascade2"))
|
||||
await session.commit()
|
||||
|
||||
async with sf() as session:
|
||||
await session.execute(delete(ServiceAccountRow).where(ServiceAccountRow.id == "sa-1"))
|
||||
await session.commit()
|
||||
|
||||
async with sf() as session:
|
||||
row1 = await session.get(ApiKeyRow, "ak-cascade-1")
|
||||
row2 = await session.get(ApiKeyRow, "ak-cascade-2")
|
||||
assert row1 is None
|
||||
assert row2 is None
|
||||
finally:
|
||||
await _cleanup()
|
||||
Reference in New Issue
Block a user