feat(01-foundation-01): Alembic migrations with RLS and tenant isolation tests
- alembic.ini + migrations/env.py: async SQLAlchemy migration setup using asyncpg
- migrations/versions/001_initial_schema.py: creates tenants, agents, channel_connections, portal_users
- ENABLE + FORCE ROW LEVEL SECURITY on agents and channel_connections
- RLS policy: tenant_id = current_setting('app.current_tenant', TRUE)::uuid
- konstruct_app role created with SELECT/INSERT/UPDATE/DELETE on all tables
- packages/shared/shared/rls.py: idempotent configure_rls_hook, UUID-sanitized SET LOCAL
- tests/conftest.py: test_db_name (session-scoped), db_engine + db_session as konstruct_app
- tests/unit/test_normalize.py: 11 tests for KonstructMessage Slack normalization (CHAN-01)
- tests/unit/test_tenant_resolution.py: 7 tests for workspace_id → tenant resolution (TNNT-02)
- tests/unit/test_redis_namespacing.py: 15 tests for Redis key namespace isolation (TNNT-03)
- tests/integration/test_tenant_isolation.py: 7 tests proving RLS tenant isolation (TNNT-01)
- tenant_b cannot see tenant_a's agents or channel_connections
- FORCE ROW LEVEL SECURITY verified via pg_class.relforcerowsecurity
This commit is contained in:
140
tests/unit/test_redis_namespacing.py
Normal file
140
tests/unit/test_redis_namespacing.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
Unit tests for Redis key namespacing.
|
||||
|
||||
Tests TNNT-03: Per-tenant Redis namespace isolation for cache and session state.
|
||||
|
||||
Every key function must:
|
||||
1. Prepend {tenant_id}: to every key
|
||||
2. Never be callable without a tenant_id argument
|
||||
3. Produce predictable, stable key formats
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
from shared.redis_keys import (
|
||||
engaged_thread_key,
|
||||
idempotency_key,
|
||||
rate_limit_key,
|
||||
session_key,
|
||||
)
|
||||
|
||||
|
||||
class TestRedisKeyFormats:
|
||||
"""Tests for correct key format from all key constructor functions."""
|
||||
|
||||
def test_rate_limit_key_format(self) -> None:
|
||||
"""rate_limit_key must return '{tenant_id}:ratelimit:{channel}'."""
|
||||
key = rate_limit_key("tenant-a", "slack")
|
||||
assert key == "tenant-a:ratelimit:slack"
|
||||
|
||||
def test_rate_limit_key_different_channel(self) -> None:
|
||||
"""rate_limit_key must work for any channel type string."""
|
||||
key = rate_limit_key("tenant-a", "whatsapp")
|
||||
assert key == "tenant-a:ratelimit:whatsapp"
|
||||
|
||||
def test_idempotency_key_format(self) -> None:
|
||||
"""idempotency_key must return '{tenant_id}:dedup:{message_id}'."""
|
||||
key = idempotency_key("tenant-a", "msg-123")
|
||||
assert key == "tenant-a:dedup:msg-123"
|
||||
|
||||
def test_session_key_format(self) -> None:
|
||||
"""session_key must return '{tenant_id}:session:{thread_id}'."""
|
||||
key = session_key("tenant-a", "thread-456")
|
||||
assert key == "tenant-a:session:thread-456"
|
||||
|
||||
def test_engaged_thread_key_format(self) -> None:
|
||||
"""engaged_thread_key must return '{tenant_id}:engaged:{thread_id}'."""
|
||||
key = engaged_thread_key("tenant-a", "T12345")
|
||||
assert key == "tenant-a:engaged:T12345"
|
||||
|
||||
|
||||
class TestTenantIsolation:
|
||||
"""Tests that all key functions produce distinct namespaces per tenant."""
|
||||
|
||||
def test_rate_limit_keys_are_tenant_scoped(self) -> None:
|
||||
"""Two tenants with the same channel must produce different keys."""
|
||||
key_a = rate_limit_key("tenant-a", "slack")
|
||||
key_b = rate_limit_key("tenant-b", "slack")
|
||||
assert key_a != key_b
|
||||
assert key_a.startswith("tenant-a:")
|
||||
assert key_b.startswith("tenant-b:")
|
||||
|
||||
def test_idempotency_keys_are_tenant_scoped(self) -> None:
|
||||
"""Two tenants with the same message_id must produce different keys."""
|
||||
key_a = idempotency_key("tenant-a", "msg-999")
|
||||
key_b = idempotency_key("tenant-b", "msg-999")
|
||||
assert key_a != key_b
|
||||
assert key_a.startswith("tenant-a:")
|
||||
assert key_b.startswith("tenant-b:")
|
||||
|
||||
def test_session_keys_are_tenant_scoped(self) -> None:
|
||||
"""Two tenants with the same thread_id must produce different keys."""
|
||||
key_a = session_key("tenant-a", "thread-1")
|
||||
key_b = session_key("tenant-b", "thread-1")
|
||||
assert key_a != key_b
|
||||
|
||||
def test_engaged_thread_keys_are_tenant_scoped(self) -> None:
|
||||
"""Two tenants with same thread must produce different engaged keys."""
|
||||
key_a = engaged_thread_key("tenant-a", "thread-1")
|
||||
key_b = engaged_thread_key("tenant-b", "thread-1")
|
||||
assert key_a != key_b
|
||||
|
||||
def test_all_keys_include_tenant_id_prefix(self) -> None:
|
||||
"""Every key function must produce a key starting with the tenant_id."""
|
||||
tenant_id = "my-tenant-uuid"
|
||||
keys = [
|
||||
rate_limit_key(tenant_id, "slack"),
|
||||
idempotency_key(tenant_id, "msg-1"),
|
||||
session_key(tenant_id, "thread-1"),
|
||||
engaged_thread_key(tenant_id, "thread-1"),
|
||||
]
|
||||
for key in keys:
|
||||
assert key.startswith(f"{tenant_id}:"), (
|
||||
f"Key {key!r} does not start with tenant_id prefix '{tenant_id}:'"
|
||||
)
|
||||
|
||||
|
||||
class TestNoBarKeysIsPossible:
|
||||
"""Tests that prove no key function can be called without tenant_id."""
|
||||
|
||||
def test_rate_limit_key_requires_tenant_id(self) -> None:
|
||||
"""rate_limit_key signature requires tenant_id as first argument."""
|
||||
sig = inspect.signature(rate_limit_key)
|
||||
params = list(sig.parameters.keys())
|
||||
assert params[0] == "tenant_id"
|
||||
|
||||
def test_idempotency_key_requires_tenant_id(self) -> None:
|
||||
"""idempotency_key signature requires tenant_id as first argument."""
|
||||
sig = inspect.signature(idempotency_key)
|
||||
params = list(sig.parameters.keys())
|
||||
assert params[0] == "tenant_id"
|
||||
|
||||
def test_session_key_requires_tenant_id(self) -> None:
|
||||
"""session_key signature requires tenant_id as first argument."""
|
||||
sig = inspect.signature(session_key)
|
||||
params = list(sig.parameters.keys())
|
||||
assert params[0] == "tenant_id"
|
||||
|
||||
def test_engaged_thread_key_requires_tenant_id(self) -> None:
|
||||
"""engaged_thread_key signature requires tenant_id as first argument."""
|
||||
sig = inspect.signature(engaged_thread_key)
|
||||
params = list(sig.parameters.keys())
|
||||
assert params[0] == "tenant_id"
|
||||
|
||||
def test_calling_without_tenant_id_raises_type_error(self) -> None:
|
||||
"""Calling any key function with zero args must raise TypeError."""
|
||||
with pytest.raises(TypeError):
|
||||
rate_limit_key() # type: ignore[call-arg]
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
idempotency_key() # type: ignore[call-arg]
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
session_key() # type: ignore[call-arg]
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
engaged_thread_key() # type: ignore[call-arg]
|
||||
Reference in New Issue
Block a user