fleet-memory/hindsight-api/tests/test_supabase_tenant.py
Jerry Henley e99ee0f243
Add Supabase tenant extension as built-in (#267)
Move the Supabase tenant extension into the hindsight-api package so users
can enable it with just an environment variable — no file copying or Docker
image modifications needed.

Key improvements over the original submission:
- JWKS-based local JWT verification (no network call per request) with
  automatic fallback to /auth/v1/user for legacy HS256 projects
- Service key is now optional (only needed for HS256 or health checks)
- UUID validation on user IDs before schema name construction
- Schema prefix validation against Postgres identifier rules
- Key rotation handling with automatic JWKS cache refresh
- Proper logging via Python logging module
- Tenant extension lifecycle hooks (on_startup/on_shutdown) wired into
  the server lifespan
- Public tenant_extension property on MemoryEngine
- 54 unit tests covering both verification modes, cache behavior, error
  paths, and the extension loader
- README updated to reflect JWKS-first architecture

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 10:16:47 +01:00

834 lines
34 KiB
Python

"""Tests for the Supabase Tenant Extension."""
import time
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import jwt as pyjwt
import pytest
from jwt import PyJWK
from hindsight_api.extensions.builtin.supabase_tenant import (
JWKS_CACHE_TTL_SECONDS,
JWKS_MIN_REFRESH_INTERVAL_SECONDS,
MIN_TOKEN_LENGTH,
SupabaseTenantExtension,
)
from hindsight_api.extensions.context import ExtensionContext
from hindsight_api.extensions.loader import load_extension
from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantContext, TenantExtension
from hindsight_api.models import RequestContext
# A valid UUID for test user IDs
VALID_UUID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
# Minimal JWKS response with one RSA key
MOCK_JWKS_RESPONSE = {
"keys": [
{
"kid": "test-key-1",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
"e": "AQAB",
}
]
}
def _make_extension(
supabase_url: str = "https://test.supabase.co",
service_key: str | None = "test-service-key",
schema_prefix: str | None = None,
) -> SupabaseTenantExtension:
"""Helper to create a SupabaseTenantExtension with test config."""
config = {
"supabase_url": supabase_url,
}
if service_key is not None:
config["supabase_service_key"] = service_key
if schema_prefix is not None:
config["schema_prefix"] = schema_prefix
return SupabaseTenantExtension(config)
def _make_mock_response(status_code: int = 200, json_data: dict | None = None) -> MagicMock:
"""Helper to create a mock httpx.Response."""
response = MagicMock(spec=httpx.Response)
response.status_code = status_code
response.json.return_value = json_data or {}
response.raise_for_status = MagicMock()
if status_code >= 400:
response.raise_for_status.side_effect = httpx.HTTPStatusError("error", request=MagicMock(), response=response)
return response
def _make_valid_token() -> str:
"""Return a token that passes the MIN_TOKEN_LENGTH check."""
return "a" * (MIN_TOKEN_LENGTH + 10)
def _setup_jwks_ext() -> tuple[SupabaseTenantExtension, AsyncMock]:
"""Create an extension in JWKS mode with mocked internals."""
ext = _make_extension()
mock_client = AsyncMock(spec=httpx.AsyncClient)
ext._http_client = mock_client
ext._use_jwks = True
ext._jwks_keys = {"test-key-1": MagicMock(spec=PyJWK)}
ext._jwks_keys["test-key-1"].key = "mock-public-key"
ext._jwks_last_fetched = time.monotonic()
return ext, mock_client
def _setup_legacy_ext() -> tuple[SupabaseTenantExtension, AsyncMock]:
"""Create an extension in legacy mode with mocked internals."""
ext = _make_extension()
mock_client = AsyncMock(spec=httpx.AsyncClient)
ext._http_client = mock_client
ext._use_jwks = False
return ext, mock_client
# ======================================================================
# Initialization
# ======================================================================
class TestSupabaseTenantExtensionInit:
"""Tests for extension initialization."""
def test_init_with_valid_config(self):
ext = _make_extension()
assert ext.supabase_url == "https://test.supabase.co"
assert ext.supabase_service_key == "test-service-key"
assert ext.schema_prefix == "user"
assert ext._initialized_schemas == set()
assert ext._http_client is None
assert ext._use_jwks is False
assert ext._jwks_keys == {}
def test_init_missing_supabase_url(self):
with pytest.raises(ValueError, match="HINDSIGHT_API_TENANT_SUPABASE_URL is required"):
SupabaseTenantExtension({})
def test_init_without_service_key(self):
"""Service key is optional — JWKS mode doesn't require it."""
ext = _make_extension(service_key=None)
assert ext.supabase_service_key is None
def test_init_default_schema_prefix(self):
ext = _make_extension()
assert ext.schema_prefix == "user"
def test_init_custom_schema_prefix(self):
ext = _make_extension(schema_prefix="tenant")
assert ext.schema_prefix == "tenant"
def test_init_strips_trailing_slash(self):
ext = _make_extension(supabase_url="https://test.supabase.co/")
assert ext.supabase_url == "https://test.supabase.co"
def test_init_rejects_invalid_schema_prefix(self):
"""Schema prefix with special characters should be rejected."""
with pytest.raises(ValueError, match="Invalid schema_prefix"):
_make_extension(schema_prefix='"; DROP TABLE')
def test_init_rejects_empty_schema_prefix(self):
with pytest.raises(ValueError, match="Invalid schema_prefix"):
_make_extension(schema_prefix="")
def test_init_rejects_schema_prefix_starting_with_digit(self):
with pytest.raises(ValueError, match="Invalid schema_prefix"):
_make_extension(schema_prefix="123abc")
def test_init_allows_underscore_prefix(self):
ext = _make_extension(schema_prefix="_internal")
assert ext.schema_prefix == "_internal"
def test_is_tenant_extension_subclass(self):
ext = _make_extension()
assert isinstance(ext, TenantExtension)
# ======================================================================
# Startup — JWKS initialization
# ======================================================================
class TestSupabaseTenantExtensionStartup:
"""Tests for on_startup behavior."""
@pytest.mark.asyncio
async def test_on_startup_creates_http_client(self):
ext = _make_extension()
mock_client = AsyncMock(spec=httpx.AsyncClient)
# JWKS fetch returns keys
mock_client.get.return_value = _make_mock_response(200, MOCK_JWKS_RESPONSE)
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
with patch("hindsight_api.extensions.builtin.supabase_tenant.PyJWK"):
await ext.on_startup()
assert ext._http_client is mock_client
@pytest.mark.asyncio
async def test_on_startup_fetches_jwks(self):
ext = _make_extension()
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get.return_value = _make_mock_response(200, MOCK_JWKS_RESPONSE)
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
with patch("hindsight_api.extensions.builtin.supabase_tenant.PyJWK") as mock_pyjwk:
mock_pyjwk.return_value = MagicMock(spec=PyJWK)
await ext.on_startup()
assert ext._use_jwks is True
# First call: JWKS fetch, second call: health check
assert mock_client.get.call_count == 2
jwks_call = mock_client.get.call_args_list[0]
assert jwks_call.args[0] == "https://test.supabase.co/auth/v1/.well-known/jwks.json"
@pytest.mark.asyncio
async def test_on_startup_falls_back_to_legacy_when_jwks_empty(self):
ext = _make_extension()
mock_client = AsyncMock(spec=httpx.AsyncClient)
# JWKS returns empty keys, health check succeeds
def mock_get(url, **kwargs):
if "jwks" in url:
return _make_mock_response(200, {"keys": []})
return _make_mock_response(200)
mock_client.get.side_effect = mock_get
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
await ext.on_startup()
assert ext._use_jwks is False
@pytest.mark.asyncio
async def test_on_startup_falls_back_to_legacy_when_jwks_fetch_fails(self):
ext = _make_extension()
mock_client = AsyncMock(spec=httpx.AsyncClient)
call_count = 0
def mock_get(url, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
# JWKS fetch fails
raise httpx.ConnectError("Connection refused")
# health check
return _make_mock_response(200)
mock_client.get.side_effect = mock_get
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
await ext.on_startup()
assert ext._use_jwks is False
@pytest.mark.asyncio
async def test_on_startup_raises_if_no_jwks_and_no_service_key(self):
ext = _make_extension(service_key=None)
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get.return_value = _make_mock_response(200, {"keys": []})
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
with pytest.raises(ValueError, match="HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY is required"):
await ext.on_startup()
@pytest.mark.asyncio
async def test_on_startup_health_check_with_service_key(self):
ext = _make_extension()
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get.return_value = _make_mock_response(200, MOCK_JWKS_RESPONSE)
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
with patch("hindsight_api.extensions.builtin.supabase_tenant.PyJWK"):
await ext.on_startup()
# Second call should be health check
health_call = mock_client.get.call_args_list[1]
assert health_call.args[0] == "https://test.supabase.co/auth/v1/health"
assert health_call.kwargs["headers"] == {"apikey": "test-service-key"}
@pytest.mark.asyncio
async def test_on_startup_skips_health_check_without_service_key(self):
ext = _make_extension(service_key=None)
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get.return_value = _make_mock_response(200, MOCK_JWKS_RESPONSE)
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
with patch("hindsight_api.extensions.builtin.supabase_tenant.PyJWK"):
await ext.on_startup()
# Only one call: JWKS fetch, no health check
assert mock_client.get.call_count == 1
# ======================================================================
# JWKS cache management
# ======================================================================
class TestJWKSCacheManagement:
"""Tests for JWKS key fetching, caching, and rotation handling."""
@pytest.mark.asyncio
async def test_get_signing_key_from_cache(self):
ext, _ = _setup_jwks_ext()
with patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header:
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
key = await ext._get_signing_key("fake-token")
assert key is ext._jwks_keys["test-key-1"]
@pytest.mark.asyncio
async def test_get_signing_key_refreshes_stale_cache(self):
ext, mock_client = _setup_jwks_ext()
# Make cache expired
ext._jwks_last_fetched = time.monotonic() - JWKS_CACHE_TTL_SECONDS - 1
new_key = MagicMock(spec=PyJWK)
mock_client.get.return_value = _make_mock_response(200, MOCK_JWKS_RESPONSE)
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.PyJWK", return_value=new_key),
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
key = await ext._get_signing_key("fake-token")
assert key is new_key
mock_client.get.assert_called_once()
@pytest.mark.asyncio
async def test_get_signing_key_handles_key_rotation(self):
"""When kid not in cache and cache is old enough, refresh once for key rotation."""
ext, mock_client = _setup_jwks_ext()
# Make cache just old enough to allow a refresh
ext._jwks_last_fetched = time.monotonic() - JWKS_MIN_REFRESH_INTERVAL_SECONDS - 1
rotated_key = MagicMock(spec=PyJWK)
mock_client.get.return_value = _make_mock_response(200, MOCK_JWKS_RESPONSE)
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.PyJWK", return_value=rotated_key),
):
mock_header.return_value = {"kid": "rotated-key-99", "alg": "RS256"}
# The refreshed JWKS won't have "rotated-key-99" either, so this should raise
with pytest.raises(AuthenticationError, match="Unable to find signing key"):
await ext._get_signing_key("fake-token")
# Should have attempted one refresh
mock_client.get.assert_called_once()
@pytest.mark.asyncio
async def test_get_signing_key_missing_kid_header(self):
ext, _ = _setup_jwks_ext()
with patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header:
mock_header.return_value = {"alg": "RS256"} # no kid
with pytest.raises(AuthenticationError, match="Token missing key ID"):
await ext._get_signing_key("fake-token")
@pytest.mark.asyncio
async def test_get_signing_key_refresh_network_error(self):
"""If JWKS refresh fails during key rotation, error should propagate."""
ext, mock_client = _setup_jwks_ext()
ext._jwks_last_fetched = time.monotonic() - JWKS_MIN_REFRESH_INTERVAL_SECONDS - 1
mock_client.get.side_effect = httpx.ConnectError("Connection refused")
with patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header:
mock_header.return_value = {"kid": "unknown-key", "alg": "RS256"}
with pytest.raises(Exception):
await ext._get_signing_key("fake-token")
# ======================================================================
# Authentication — JWKS mode
# ======================================================================
class TestAuthenticateJWKS:
"""Tests for JWKS-based JWT verification."""
@pytest.mark.asyncio
async def test_authenticate_valid_token(self):
ext, _ = _setup_jwks_ext()
mock_context = AsyncMock(spec=ExtensionContext)
mock_context.run_migration = AsyncMock()
ext._context = mock_context
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
mock_decode.return_value = {"sub": VALID_UUID, "aud": "authenticated"}
result = await ext.authenticate(RequestContext(api_key=_make_valid_token()))
assert isinstance(result, TenantContext)
expected_schema = "user_" + VALID_UUID.replace("-", "_")
assert result.schema_name == expected_schema
@pytest.mark.asyncio
async def test_authenticate_custom_prefix(self):
ext, _ = _setup_jwks_ext()
ext.schema_prefix = "org"
mock_context = AsyncMock(spec=ExtensionContext)
mock_context.run_migration = AsyncMock()
ext._context = mock_context
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
mock_decode.return_value = {"sub": VALID_UUID}
result = await ext.authenticate(RequestContext(api_key=_make_valid_token()))
assert result.schema_name.startswith("org_")
@pytest.mark.asyncio
async def test_authenticate_expired_token(self):
ext, _ = _setup_jwks_ext()
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch(
"hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode",
side_effect=pyjwt.ExpiredSignatureError(),
),
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
with pytest.raises(AuthenticationError, match="Token has expired"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_invalid_audience(self):
ext, _ = _setup_jwks_ext()
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch(
"hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode",
side_effect=pyjwt.InvalidAudienceError(),
),
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
with pytest.raises(AuthenticationError, match="Invalid token audience"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_invalid_issuer(self):
ext, _ = _setup_jwks_ext()
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch(
"hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode",
side_effect=pyjwt.InvalidIssuerError(),
),
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
with pytest.raises(AuthenticationError, match="Invalid token issuer"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_decode_error(self):
ext, _ = _setup_jwks_ext()
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch(
"hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode",
side_effect=pyjwt.DecodeError(),
),
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
with pytest.raises(AuthenticationError, match="Invalid token"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_missing_sub_claim(self):
ext, _ = _setup_jwks_ext()
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
mock_decode.return_value = {"email": "test@example.com"} # no sub
with pytest.raises(AuthenticationError, match="missing subject"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_empty_sub_claim(self):
"""Empty string sub claim should be treated as missing."""
ext, _ = _setup_jwks_ext()
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
mock_decode.return_value = {"sub": ""}
with pytest.raises(AuthenticationError, match="missing subject"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_generic_exception(self):
"""Unexpected exceptions during decode should be caught and wrapped."""
ext, _ = _setup_jwks_ext()
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch(
"hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode",
side_effect=RuntimeError("unexpected internal error"),
),
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
with pytest.raises(AuthenticationError, match="Token verification failed"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
# ======================================================================
# Authentication — Legacy mode
# ======================================================================
class TestAuthenticateLegacy:
"""Tests for legacy /auth/v1/user endpoint verification."""
@pytest.mark.asyncio
async def test_authenticate_valid_token(self):
ext, mock_client = _setup_legacy_ext()
mock_client.get.return_value = _make_mock_response(200, {"id": VALID_UUID})
mock_context = AsyncMock(spec=ExtensionContext)
mock_context.run_migration = AsyncMock()
ext._context = mock_context
result = await ext.authenticate(RequestContext(api_key=_make_valid_token()))
assert isinstance(result, TenantContext)
expected_schema = "user_" + VALID_UUID.replace("-", "_")
assert result.schema_name == expected_schema
@pytest.mark.asyncio
async def test_authenticate_calls_user_endpoint(self):
ext, mock_client = _setup_legacy_ext()
mock_client.get.return_value = _make_mock_response(200, {"id": VALID_UUID})
mock_context = AsyncMock(spec=ExtensionContext)
mock_context.run_migration = AsyncMock()
ext._context = mock_context
token = _make_valid_token()
await ext.authenticate(RequestContext(api_key=token))
mock_client.get.assert_called_once_with(
"https://test.supabase.co/auth/v1/user",
headers={
"Authorization": f"Bearer {token}",
"apikey": "test-service-key",
},
)
@pytest.mark.asyncio
async def test_authenticate_expired_token_401(self):
ext, mock_client = _setup_legacy_ext()
mock_client.get.return_value = _make_mock_response(401)
with pytest.raises(AuthenticationError, match="Invalid or expired token"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_supabase_error_500(self):
ext, mock_client = _setup_legacy_ext()
mock_client.get.return_value = _make_mock_response(500)
with pytest.raises(AuthenticationError, match="Authentication failed: 500"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_no_user_id(self):
ext, mock_client = _setup_legacy_ext()
mock_client.get.return_value = _make_mock_response(200, {"email": "test@example.com"})
with pytest.raises(AuthenticationError, match="no user ID found"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_timeout(self):
ext, mock_client = _setup_legacy_ext()
mock_client.get.side_effect = httpx.TimeoutException("Request timed out")
with pytest.raises(AuthenticationError, match="Authentication timeout"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_connection_error(self):
ext, mock_client = _setup_legacy_ext()
mock_client.get.side_effect = httpx.ConnectError("Connection refused")
with pytest.raises(AuthenticationError, match="Connection error"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
# ======================================================================
# Authentication — common (both modes)
# ======================================================================
class TestAuthenticateCommon:
"""Tests that apply regardless of verification mode."""
@pytest.mark.asyncio
async def test_authenticate_missing_token(self):
ext, _ = _setup_jwks_ext()
with pytest.raises(AuthenticationError, match="Missing Authorization header"):
await ext.authenticate(RequestContext(api_key=None))
@pytest.mark.asyncio
async def test_authenticate_empty_token(self):
ext, _ = _setup_jwks_ext()
with pytest.raises(AuthenticationError, match="Missing Authorization header"):
await ext.authenticate(RequestContext(api_key=""))
@pytest.mark.asyncio
async def test_authenticate_short_token(self):
ext, _ = _setup_jwks_ext()
with pytest.raises(AuthenticationError, match="Invalid token format"):
await ext.authenticate(RequestContext(api_key="short"))
@pytest.mark.asyncio
async def test_authenticate_not_initialized(self):
ext = _make_extension()
# _http_client is None by default
with pytest.raises(AuthenticationError, match="Extension not initialized"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_rejects_non_uuid_user_id(self):
"""User IDs that aren't valid UUIDs should be rejected for schema safety."""
ext, _ = _setup_jwks_ext()
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
mock_decode.return_value = {"sub": "not-a-uuid"}
with pytest.raises(AuthenticationError, match="Invalid user ID format"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_rejects_malicious_user_id(self):
"""User IDs with SQL injection attempts should be rejected."""
ext, _ = _setup_jwks_ext()
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
mock_decode.return_value = {"sub": "'; DROP TABLE users;--"}
with pytest.raises(AuthenticationError, match="Invalid user ID format"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
# ======================================================================
# Schema management
# ======================================================================
class TestSupabaseTenantExtensionSchemaManagement:
"""Tests for schema initialization and caching."""
@pytest.mark.asyncio
async def test_schema_initialized_on_first_access(self):
ext, _ = _setup_jwks_ext()
mock_context = AsyncMock(spec=ExtensionContext)
mock_context.run_migration = AsyncMock()
ext._context = mock_context
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
mock_decode.return_value = {"sub": VALID_UUID}
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
expected_schema = "user_" + VALID_UUID.replace("-", "_")
mock_context.run_migration.assert_called_once_with(expected_schema)
assert expected_schema in ext._initialized_schemas
@pytest.mark.asyncio
async def test_schema_cached_on_second_access(self):
ext, _ = _setup_jwks_ext()
mock_context = AsyncMock(spec=ExtensionContext)
mock_context.run_migration = AsyncMock()
ext._context = mock_context
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
mock_decode.return_value = {"sub": VALID_UUID}
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
# run_migration should only be called once
expected_schema = "user_" + VALID_UUID.replace("-", "_")
mock_context.run_migration.assert_called_once_with(expected_schema)
@pytest.mark.asyncio
async def test_schema_init_failure(self):
ext, _ = _setup_jwks_ext()
mock_context = AsyncMock(spec=ExtensionContext)
mock_context.run_migration = AsyncMock(side_effect=RuntimeError("Migration failed"))
ext._context = mock_context
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
mock_decode.return_value = {"sub": VALID_UUID}
with pytest.raises(AuthenticationError, match="Failed to initialize tenant"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
# Schema should NOT be cached on failure
expected_schema = "user_" + VALID_UUID.replace("-", "_")
assert expected_schema not in ext._initialized_schemas
# ======================================================================
# List tenants
# ======================================================================
class TestSupabaseTenantExtensionListTenants:
"""Tests for list_tenants behavior."""
@pytest.mark.asyncio
async def test_list_tenants_empty(self):
ext = _make_extension()
tenants = await ext.list_tenants()
assert tenants == []
@pytest.mark.asyncio
async def test_list_tenants_after_auth(self):
ext, _ = _setup_jwks_ext()
mock_context = AsyncMock(spec=ExtensionContext)
mock_context.run_migration = AsyncMock()
ext._context = mock_context
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
mock_decode.return_value = {"sub": VALID_UUID}
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
tenants = await ext.list_tenants()
assert len(tenants) == 1
assert isinstance(tenants[0], Tenant)
expected_schema = "user_" + VALID_UUID.replace("-", "_")
assert tenants[0].schema == expected_schema
# ======================================================================
# Shutdown
# ======================================================================
class TestSupabaseTenantExtensionShutdown:
"""Tests for on_shutdown behavior."""
@pytest.mark.asyncio
async def test_on_shutdown_closes_client(self):
ext = _make_extension()
mock_client = AsyncMock(spec=httpx.AsyncClient)
ext._http_client = mock_client
await ext.on_shutdown()
mock_client.aclose.assert_called_once()
assert ext._http_client is None
@pytest.mark.asyncio
async def test_on_shutdown_no_client(self):
ext = _make_extension()
# _http_client is None by default — should not raise
await ext.on_shutdown()
# ======================================================================
# Extension loader integration
# ======================================================================
class TestSupabaseTenantExtensionLoader:
"""Tests for loading via the extension loader."""
def test_load_via_extension_loader(self, monkeypatch):
monkeypatch.setenv(
"HINDSIGHT_API_TENANT_EXTENSION",
"hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension",
)
monkeypatch.setenv("HINDSIGHT_API_TENANT_SUPABASE_URL", "https://test.supabase.co")
monkeypatch.setenv("HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY", "test-key")
monkeypatch.setenv("HINDSIGHT_API_TENANT_SCHEMA_PREFIX", "custom")
ext = load_extension("TENANT", TenantExtension)
assert ext is not None
assert isinstance(ext, SupabaseTenantExtension)
assert ext.supabase_url == "https://test.supabase.co"
assert ext.supabase_service_key == "test-key"
assert ext.schema_prefix == "custom"
def test_load_without_service_key(self, monkeypatch):
"""Extension should load without service key — JWKS mode doesn't need it."""
monkeypatch.setenv(
"HINDSIGHT_API_TENANT_EXTENSION",
"hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension",
)
monkeypatch.setenv("HINDSIGHT_API_TENANT_SUPABASE_URL", "https://test.supabase.co")
monkeypatch.delenv("HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY", raising=False)
ext = load_extension("TENANT", TenantExtension)
assert ext is not None
assert isinstance(ext, SupabaseTenantExtension)
assert ext.supabase_service_key is None