* feat: introduce hindsight-api-slim and hindsight-all-slim packages Closes #552 - Move all source code from hindsight-api/ to new hindsight-api-slim/ - hindsight-api-slim has heavy ML deps (torch, sentence-transformers, transformers, einops, flashrank, mlx, mlx-lm, safetensors) and pg0-embedded as optional extras: [local-ml], [embedded-db], [all] - hindsight-api becomes a zero-code meta-package depending on hindsight-api-slim[all] for full backward compatibility - Add hindsight-all-slim meta-package: hindsight-api-slim + client + embed - hindsight-all updated to depend on hindsight-api-slim[all] - pg0.py: lazy-import pg0 with clear ImportError pointing to [embedded-db] - Dockerfile: replace sed hack with proper uv sync --extra flags - Update release.yml, test.yml, lint.sh, release.sh, CLAUDE.md and all path references throughout the repo * refactor: rename hindsight/ directory to hindsight-all/ * docs: document hindsight-api-slim and hindsight-all-slim package variants Add package variants table and extras explanation to installation.md * docs: remove emojis from installation.md, use professional tone * docs: link Docker slim variant to pip package variants section * docs: consolidate Docker image variants into single table * ci: fix working-directory paths after package restructure - Replace all hindsight-api → hindsight-api-slim in test.yml - Replace hindsight → hindsight-all in test.yml - Add --extra embedded-db to test-embed API install step * ci: add local-ml and embedded-db extras to API sync steps These extras were previously implicit in the old hindsight-api package (which bundled everything). Now that hindsight-api-slim uses optional extras, we must explicitly request local-ml and embedded-db in CI. * ci: add API install step with embedded-db to test-embed smoke test The smoke test starts hindsight-api as a daemon, which requires pg0-embedded. Add a dedicated install step for hindsight-api-slim with embedded-db extra so the daemon can start successfully. * ci: remove --no-install-project when using optional extras When --no-install-project is combined with --extra, the optional deps are not installed because extras require the project to be active. Remove --no-install-project from steps that need local-ml or embedded-db. * ci: fix ordering of uv sync steps to preserve optional extras When uv sync runs for a different workspace member, it removes optional extras installed for other members. Fix by always running extra-requiring API sync last, after other workspace member syncs. Also remove --no-install-project from embedded-db sync in test-embed, as --no-install-project prevents optional extras from being active. * ci: add local-ml extra to test-embed API install for smoke test The smoke test starts the full API server which needs sentence-transformers for local embeddings (default provider). Add local-ml extra to the install. * ci: simplify extras with --all-extras and add slim pip smoke test - Replace explicit --extra local-ml --extra embedded-db with --all-extras for cleaner, more maintainable sync steps - Add test-pip-slim job: tests hindsight-api-slim[embedded-db] without local ML models, using Cohere for embeddings/reranking (mirrors Docker slim smoke test approach) * ci: simplify slim smoke test to health check only (mirrors Docker test)
834 lines
34 KiB
Python
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
|