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:
158
tests/unit/test_tenant_resolution.py
Normal file
158
tests/unit/test_tenant_resolution.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Unit tests for tenant resolution logic.
|
||||
|
||||
Tests TNNT-02: Inbound messages are resolved to the correct tenant via
|
||||
channel metadata.
|
||||
|
||||
These tests verify the resolution logic in isolation — no live database needed.
|
||||
The production resolver queries channel_connections; here we mock that lookup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from shared.models.message import ChannelType
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal in-process tenant resolver for unit testing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ChannelConnectionRecord:
|
||||
"""Represents a row from the channel_connections table."""
|
||||
|
||||
def __init__(self, tenant_id: uuid.UUID, channel_type: ChannelType, workspace_id: str) -> None:
|
||||
self.tenant_id = tenant_id
|
||||
self.channel_type = channel_type
|
||||
self.workspace_id = workspace_id
|
||||
|
||||
|
||||
def resolve_tenant(
|
||||
workspace_id: str,
|
||||
channel_type: ChannelType,
|
||||
connections: list[ChannelConnectionRecord],
|
||||
) -> Optional[uuid.UUID]:
|
||||
"""
|
||||
Resolve a (workspace_id, channel_type) pair to a tenant_id.
|
||||
|
||||
This mirrors the logic in packages/router/tenant.py.
|
||||
Returns None if no matching connection is found.
|
||||
"""
|
||||
for conn in connections:
|
||||
if conn.workspace_id == workspace_id and conn.channel_type == channel_type:
|
||||
return conn.tenant_id
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def tenant_a_id() -> uuid.UUID:
|
||||
return uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tenant_b_id() -> uuid.UUID:
|
||||
return uuid.UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def connections(tenant_a_id: uuid.UUID, tenant_b_id: uuid.UUID) -> list[ChannelConnectionRecord]:
|
||||
return [
|
||||
ChannelConnectionRecord(tenant_a_id, ChannelType.SLACK, "T-WORKSPACE-A"),
|
||||
ChannelConnectionRecord(tenant_b_id, ChannelType.SLACK, "T-WORKSPACE-B"),
|
||||
ChannelConnectionRecord(tenant_b_id, ChannelType.TELEGRAM, "tg-chat-12345"),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTenantResolution:
|
||||
"""Tests for tenant resolution from channel workspace IDs."""
|
||||
|
||||
def test_slack_workspace_resolves_to_correct_tenant(
|
||||
self,
|
||||
connections: list[ChannelConnectionRecord],
|
||||
tenant_a_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Known Slack workspace_id must resolve to the correct tenant."""
|
||||
result = resolve_tenant("T-WORKSPACE-A", ChannelType.SLACK, connections)
|
||||
assert result == tenant_a_id
|
||||
|
||||
def test_second_slack_workspace_resolves_independently(
|
||||
self,
|
||||
connections: list[ChannelConnectionRecord],
|
||||
tenant_b_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Two different Slack workspaces must resolve to their respective tenants."""
|
||||
result = resolve_tenant("T-WORKSPACE-B", ChannelType.SLACK, connections)
|
||||
assert result == tenant_b_id
|
||||
|
||||
def test_unknown_workspace_id_returns_none(
|
||||
self,
|
||||
connections: list[ChannelConnectionRecord],
|
||||
) -> None:
|
||||
"""Unknown workspace_id must return None — not raise, not return wrong tenant."""
|
||||
result = resolve_tenant("T-UNKNOWN", ChannelType.SLACK, connections)
|
||||
assert result is None
|
||||
|
||||
def test_wrong_channel_type_does_not_match(
|
||||
self,
|
||||
connections: list[ChannelConnectionRecord],
|
||||
) -> None:
|
||||
"""Workspace ID from wrong channel type must not match."""
|
||||
# T-WORKSPACE-A is registered as SLACK — should not match TELEGRAM
|
||||
result = resolve_tenant("T-WORKSPACE-A", ChannelType.TELEGRAM, connections)
|
||||
assert result is None
|
||||
|
||||
def test_telegram_workspace_resolves_correctly(
|
||||
self,
|
||||
connections: list[ChannelConnectionRecord],
|
||||
tenant_b_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Telegram channel connections resolve independently from Slack."""
|
||||
result = resolve_tenant("tg-chat-12345", ChannelType.TELEGRAM, connections)
|
||||
assert result == tenant_b_id
|
||||
|
||||
def test_empty_connections_returns_none(self) -> None:
|
||||
"""Empty connection list must return None for any workspace."""
|
||||
result = resolve_tenant("T-ANY", ChannelType.SLACK, [])
|
||||
assert result is None
|
||||
|
||||
def test_resolution_is_channel_type_specific(
|
||||
self,
|
||||
connections: list[ChannelConnectionRecord],
|
||||
) -> None:
|
||||
"""
|
||||
The same workspace_id string registered on two different channel types
|
||||
must only match the correct channel type.
|
||||
|
||||
This prevents a Slack workspace ID from accidentally matching a
|
||||
Mattermost workspace with the same string value.
|
||||
"""
|
||||
same_id_connections = [
|
||||
ChannelConnectionRecord(
|
||||
uuid.UUID("cccccccc-cccc-cccc-cccc-cccccccccccc"),
|
||||
ChannelType.SLACK,
|
||||
"SHARED-ID",
|
||||
),
|
||||
ChannelConnectionRecord(
|
||||
uuid.UUID("dddddddd-dddd-dddd-dddd-dddddddddddd"),
|
||||
ChannelType.MATTERMOST,
|
||||
"SHARED-ID",
|
||||
),
|
||||
]
|
||||
slack_tenant = resolve_tenant("SHARED-ID", ChannelType.SLACK, same_id_connections)
|
||||
mm_tenant = resolve_tenant("SHARED-ID", ChannelType.MATTERMOST, same_id_connections)
|
||||
|
||||
assert slack_tenant != mm_tenant
|
||||
assert str(slack_tenant) == "cccccccc-cccc-cccc-cccc-cccccccccccc"
|
||||
assert str(mm_tenant) == "dddddddd-dddd-dddd-dddd-dddddddddddd"
|
||||
Reference in New Issue
Block a user