Files
ZY-Agent/backend/tests/test_api_key_schema.py
T
1445043649 52e9999a61 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>
2026-05-14 14:10:32 +08:00

157 lines
5.4 KiB
Python

"""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()