Files
leocrm/tests/test_api_tokens.py
Agent Zero abbe7a18fc fix(audit): P0-P3 audit fixes — 838 ruff errors → 0, 30 F821 bugs fixed, 118 files changed
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner
- P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var
- P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup
- P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns
- P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs)
- P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import
- P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default
- P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed
- P2: 28 frontend TODOs (hardcoded constants, deprecated notification API)
- P3: dead code, duplicates, deprecated imports, private attr, __import__ inline
- P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n)
- ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix)
- F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String)
- Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
2026-08-16 01:17:18 +02:00

221 lines
7.7 KiB
Python

"""Tests for Phase 5 — API Token Service and Delegation Token.
Covers:
- API Token: create, verify, revoke, list
- API Token: expired token rejected
- API Token: revoked token rejected
- API Token: inactive user rejected
- Delegation Token: create, verify, expiry, audience check
- Delegation Token: tampered token rejected
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.api_token import create_api_token, verify_api_token, revoke_api_token, list_api_tokens, _hash_token
from app.core.delegation_token import create_delegation_token, verify_delegation_token, DELEGATION_AUDIENCE
from app.models.auth import ApiToken
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
async def _seed_tenant_and_user(db: AsyncSession) -> dict:
from app.core.auth import hash_password
tenant = Tenant(name="Test Tenant", slug="test-tenant-phase5")
db.add(tenant)
await db.flush()
user = User(
email="phase5@example.com",
name="Phase5 User",
password_hash=hash_password("TestPass123!"),
is_active=True,
preferences={},
)
db.add(user)
await db.flush()
ut = UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin", status="active")
db.add(ut)
await db.flush()
return {"tenant": tenant, "user": user}
# ─── API Token Tests ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_create_api_token_returns_plaintext(db_session: AsyncSession):
"""create_api_token returns the plaintext token once."""
seed = await _seed_tenant_and_user(db_session)
result = await create_api_token(
db_session, seed["tenant"].id, seed["user"].id, name="Test Token",
)
assert "token" in result
assert len(result["token"]) > 20 # URL-safe token
assert result["name"] == "Test Token"
@pytest.mark.asyncio
async def test_verify_api_token_valid(db_session: AsyncSession):
"""verify_api_token returns user context for a valid token."""
seed = await _seed_tenant_and_user(db_session)
result = await create_api_token(
db_session, seed["tenant"].id, seed["user"].id, name="Test Token",
)
user_data = await verify_api_token(db_session, result["token"])
assert user_data is not None
assert user_data["user_id"] == str(seed["user"].id)
assert user_data["tenant_id"] == str(seed["tenant"].id)
assert user_data["_auth_method"] == "api_token"
@pytest.mark.asyncio
async def test_verify_api_token_invalid(db_session: AsyncSession):
"""verify_api_token returns None for an invalid token."""
user_data = await verify_api_token(db_session, "invalid-token-string")
assert user_data is None
@pytest.mark.asyncio
async def test_revoke_api_token(db_session: AsyncSession):
"""revoked tokens are rejected by verify_api_token."""
seed = await _seed_tenant_and_user(db_session)
result = await create_api_token(
db_session, seed["tenant"].id, seed["user"].id, name="To Revoke",
)
token_id = uuid.UUID(result["id"])
revoked = await revoke_api_token(db_session, seed["tenant"].id, token_id)
assert revoked is True
# Token should no longer verify
user_data = await verify_api_token(db_session, result["token"])
assert user_data is None
@pytest.mark.asyncio
async def test_verify_api_token_expired(db_session: AsyncSession):
"""expired tokens are rejected."""
seed = await _seed_tenant_and_user(db_session)
expires_at = datetime.now(UTC) - timedelta(seconds=1) # Already expired
result = await create_api_token(
db_session, seed["tenant"].id, seed["user"].id, name="Expired",
expires_at=expires_at,
)
user_data = await verify_api_token(db_session, result["token"])
assert user_data is None
@pytest.mark.asyncio
async def test_list_api_tokens(db_session: AsyncSession):
"""list_api_tokens returns tokens without hashes."""
seed = await _seed_tenant_and_user(db_session)
await create_api_token(
db_session, seed["tenant"].id, seed["user"].id, name="Token 1",
)
await create_api_token(
db_session, seed["tenant"].id, seed["user"].id, name="Token 2",
)
tokens = await list_api_tokens(db_session, seed["tenant"].id, seed["user"].id)
assert len(tokens) == 2
assert "token" not in tokens[0] # No plaintext in list
assert "token_hash" not in tokens[0] # No hash in list
@pytest.mark.asyncio
async def test_verify_api_token_inactive_user(db_session: AsyncSession):
"""inactive users are rejected."""
seed = await _seed_tenant_and_user(db_session)
# Deactivate user
seed["user"].is_active = False
await db_session.flush()
result = await create_api_token(
db_session, seed["tenant"].id, seed["user"].id, name="Inactive User",
)
user_data = await verify_api_token(db_session, result["token"])
assert user_data is None
# ─── Delegation Token Tests ──────────────────────────────────
def test_create_delegation_token_returns_string():
"""create_delegation_token returns a signed string."""
token = create_delegation_token(
user_id="user-123", tenant_id="tenant-456",
)
assert isinstance(token, str)
assert "." in token # payload.signature format
def test_verify_delegation_token_valid():
"""verify_delegation_token returns payload for a valid token."""
token = create_delegation_token(
user_id="user-123", tenant_id="tenant-456",
)
payload = verify_delegation_token(token)
assert payload is not None
assert payload["user_id"] == "user-123"
assert payload["tenant_id"] == "tenant-456"
assert payload["audience"] == DELEGATION_AUDIENCE
assert "expires_at" in payload
assert "token_id" in payload
def test_verify_delegation_token_invalid():
"""verify_delegation_token returns None for invalid token."""
payload = verify_delegation_token("invalid.token")
assert payload is None
def test_verify_delegation_token_tampered():
"""tampered tokens are rejected."""
token = create_delegation_token(
user_id="user-123", tenant_id="tenant-456",
)
# Tamper with the payload part
parts = token.split(".")
tampered = parts[0] + "x." + parts[1]
payload = verify_delegation_token(tampered)
assert payload is None
def test_verify_delegation_token_wrong_audience():
"""tokens with wrong audience are rejected."""
from app.core.delegation_token import _sign
import json
from datetime import UTC, datetime, timedelta
import uuid as uuid_mod
now = datetime.now(UTC)
payload = {
"user_id": "user-123",
"tenant_id": "tenant-456",
"agent_id": "test",
"audience": "wrong-audience",
"expires_at": (now + timedelta(seconds=30)).isoformat(),
"token_id": str(uuid_mod.uuid4()),
}
token = _sign(payload)
result = verify_delegation_token(token)
assert result is None
def test_delegation_token_max_lifetime():
"""token lifetime is capped at MAX_TOKEN_LIFETIME."""
from app.core.delegation_token import MAX_TOKEN_LIFETIME
token = create_delegation_token(
user_id="user-123", tenant_id="tenant-456",
lifetime_seconds=3600, # Request 1 hour
)
payload = verify_delegation_token(token)
assert payload is not None
# Should be capped at 60 seconds
expires_at = datetime.fromisoformat(payload["expires_at"])
now = datetime.now(UTC)
lifetime = (expires_at - now).total_seconds()
assert lifetime <= MAX_TOKEN_LIFETIME + 5 # Allow small timing variance