| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257 |
- """
- 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
|