Files
konstruct/tests/unit/test_tenant_resolution.py
Adolfo Delorenzo 47e78627fd 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
2026-03-23 09:57:29 -06:00

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"