""" Unit tests for `agentpaas.api.middleware.auth.authenticate()`. audit/AUDIT_2026-06-05.md #34 baseline: the entire API-key login flow — including the legacy-hash auto-migration that writes to `api_keys.key_hash` — had ZERO direct test coverage. A typo in the hash format check or the tenant-status branch would have shipped to production with green CI. Tests cover: - missing key (empty / None) - "Bearer " prefix stripping - PBKDF2 happy path (returns TenantContext, updates last_used_at) - rejection of invalid key with no matching prefix - rejection of invalid key with matching prefix but wrong hash - SUSPENDED tenant → 403 - EXPIRED key → 401 - Legacy SHA-256 hash auto-migration → key still works AND the stored hash is rewritten to PBKDF2 format (this is the audit's headline SEC-01 risk that left dormant legacy hashes plaintext forever). """ from __future__ import annotations import os os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:") import json import secrets import pytest from agentpaas.api.middleware.auth import ( authenticate, AuthError, TenantContext, hash_key, _legacy_hash_key, ) from agentpaas.db.models import Database, gen_id, now_utc import agentpaas.db.session as _session_mod # ───────────────────────────────────────────────────────────────────────────── # Fixtures # ───────────────────────────────────────────────────────────────────────────── @pytest.fixture def db(): """Per-test in-memory SQLite. Same singleton-swap trick as test_api_endpoints — authenticate() calls get_db() internally.""" prev = _session_mod._db _session_mod._db = Database("sqlite:///:memory:") try: yield _session_mod._db finally: _session_mod._db = prev def _insert_tenant(db, name: str = "test-tenant", status: str = "active") -> str: tid = gen_id("tn_") db.execute( "INSERT INTO tenants (id, name, plan, status, created_at) " "VALUES (?, ?, 'free', ?, ?)", (tid, name, status, now_utc()), ) db.commit() return tid def _insert_user(db, tenant_id: str) -> str: uid = gen_id("usr_") db.execute( "INSERT INTO users (id, tenant_id, email, role, created_at) " "VALUES (?, ?, '', 'admin', ?)", (uid, tenant_id, now_utc()), ) db.commit() return uid def _insert_key(db, tenant_id: str, user_id: str, key_hash: str, scopes: list = None, status: str = "active", expires_at: str = "", prefix_override: str = "") -> tuple[str, str]: """Insert an api_keys row. Returns (key_id, key_prefix).""" key_id = gen_id("key_") raw = f"ap_{secrets.token_hex(16)}" db.execute( "INSERT INTO api_keys " "(id, tenant_id, user_id, key_hash, key_prefix, name, scopes, " " rate_limit, status, created_at, expires_at) " "VALUES (?, ?, ?, ?, ?, 'test-key', ?, 600, ?, ?, ?)", (key_id, tenant_id, user_id, key_hash, prefix_override or raw[:8], json.dumps(scopes or ["agents:*"]), status, now_utc(), expires_at), ) db.commit() return key_id, raw # ───────────────────────────────────────────────────────────────────────────── # Missing / malformed input # ───────────────────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_authenticate_empty_key_raises_missing(db): with pytest.raises(AuthError) as exc: await authenticate("") assert exc.value.code == 401 assert "missing" in exc.value.message.lower() @pytest.mark.asyncio async def test_authenticate_strips_bearer_prefix(db): """A real client sends `Authorization: Bearer ap_xxx`; FastAPI passes the whole header string. `authenticate()` must strip the prefix.""" tid = _insert_tenant(db) uid = _insert_user(db, tid) raw_key = f"ap_{secrets.token_hex(16)}" _insert_key(db, tid, uid, hash_key(raw_key), prefix_override=raw_key[:8]) # Without prefix ctx = await authenticate(raw_key) assert isinstance(ctx, TenantContext) assert ctx.tenant_id == tid # With prefix — must produce the same result ctx2 = await authenticate(f"Bearer {raw_key}") assert ctx2.tenant_id == tid # ───────────────────────────────────────────────────────────────────────────── # Rejection paths # ───────────────────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_authenticate_unknown_key_raises_401(db): with pytest.raises(AuthError) as exc: await authenticate("ap_unknown000000000000000000000000") assert exc.value.code == 401 assert "invalid" in exc.value.message.lower() @pytest.mark.asyncio async def test_authenticate_matching_prefix_wrong_hash_raises_401(db): """Tenant A has ap_aaa.., tenant B uses ap_aaa.. too (same prefix by coincidence). B's key shouldn't unlock A's tenant just because the prefix lookup pulls A's row out — the verify_key_hash call must detect the mismatch and fall through. This is the silent-bypass smell SEC-01 originally flagged.""" tid = _insert_tenant(db) uid = _insert_user(db, tid) real_key = "ap_aaaa11112222333344445555aaaaaaaa" _insert_key(db, tid, uid, hash_key(real_key), prefix_override="ap_aaaa") forged_key = "ap_aaaadeadbeefdeadbeefdeadbeefdead" # same prefix with pytest.raises(AuthError) as exc: await authenticate(forged_key) assert exc.value.code == 401 # ───────────────────────────────────────────────────────────────────────────── # Happy path: PBKDF2 key → TenantContext # ───────────────────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_authenticate_pbkdf2_happy_path(db): tid = _insert_tenant(db, name="acme") uid = _insert_user(db, tid) raw_key = f"ap_{secrets.token_hex(16)}" scopes = ["agents:read", "agents:write", "keys:*"] _insert_key(db, tid, uid, hash_key(raw_key), scopes=scopes, prefix_override=raw_key[:8]) ctx = await authenticate(raw_key) assert ctx.tenant_id == tid assert ctx.tenant_name == "acme" assert ctx.plan == "free" assert ctx.user_id == uid # Scopes survived the round trip through JSON. assert set(ctx.scopes) == set(scopes) # ───────────────────────────────────────────────────────────────────────────── # Tenant / key status # ───────────────────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_authenticate_suspended_tenant_raises_403(db): tid = _insert_tenant(db, status="suspended") uid = _insert_user(db, tid) raw_key = f"ap_{secrets.token_hex(16)}" _insert_key(db, tid, uid, hash_key(raw_key), prefix_override=raw_key[:8]) with pytest.raises(AuthError) as exc: await authenticate(raw_key) assert exc.value.code == 403 assert "suspended" in exc.value.message.lower() @pytest.mark.asyncio async def test_authenticate_expired_key_raises_401(db): tid = _insert_tenant(db) uid = _insert_user(db, tid) raw_key = f"ap_{secrets.token_hex(16)}" # Expired at 2020-01-01 — well in the past regardless of test clock. _insert_key(db, tid, uid, hash_key(raw_key), expires_at="2020-01-01T00:00:00+00:00", prefix_override=raw_key[:8]) with pytest.raises(AuthError) as exc: await authenticate(raw_key) assert exc.value.code == 401 assert "expired" in exc.value.message.lower() # ───────────────────────────────────────────────────────────────────────────── # audit SEC-01 headline: legacy hash auto-migration # ───────────────────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_authenticate_legacy_hash_auto_migrates_to_pbkdf2(db): """The single most important authenticate() branch: a key stored under the legacy fixed-salt SHA-256 hash must (1) still authenticate and (2) be transparently rewritten to PBKDF2 on success. Without this test the SEC-01 'partial fix' could silently regress — a future refactor of verify_key_hash could break the legacy fallback and CI would still be green.""" tid = _insert_tenant(db) uid = _insert_user(db, tid) raw_key = f"ap_{secrets.token_hex(16)}" legacy_hash = _legacy_hash_key(raw_key) key_id, _ = _insert_key(db, tid, uid, legacy_hash, prefix_override=raw_key[:8]) # Sanity: directly inserting a SHA-256 hex (not pbkdf2-prefixed) is # what we wanted. pre = db.fetchone("SELECT key_hash FROM api_keys WHERE id = ?", (key_id,)) assert not pre["key_hash"].startswith("pbkdf2:") ctx = await authenticate(raw_key) assert ctx.tenant_id == tid # Auto-migration: hash was rewritten in-place. post = db.fetchone("SELECT key_hash FROM api_keys WHERE id = ?", (key_id,)) assert post["key_hash"].startswith("pbkdf2:"), ( f"legacy hash was not auto-migrated; still {post['key_hash'][:20]}" ) # Migrated row should also still authenticate on the next call (the # entire point of the upgrade). ctx2 = await authenticate(raw_key) assert ctx2.tenant_id == tid