- 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
159 lines
5.6 KiB
Python
159 lines
5.6 KiB
Python
"""
|
|
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"
|