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:
2026-03-23 09:57:29 -06:00
parent 5714acf741
commit 47e78627fd
13 changed files with 1364 additions and 4 deletions

0
tests/unit/__init__.py Normal file
View File

View File

@@ -0,0 +1,143 @@
"""
Unit tests for KonstructMessage normalization from Slack event payloads.
Tests CHAN-01: Channel Gateway normalizes messages from all channels into
unified KonstructMessage format.
These tests exercise normalization logic without requiring a live database.
"""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from shared.models.message import ChannelType, KonstructMessage, MessageContent, SenderInfo
def make_slack_event() -> dict:
"""Minimal valid Slack message event payload."""
return {
"type": "message",
"channel": "C12345ABC",
"user": "U98765XYZ",
"text": "Hey @bot can you help me?",
"ts": "1711234567.123456",
"thread_ts": "1711234567.123456",
"team": "T11223344",
"blocks": [],
"event_ts": "1711234567.123456",
}
def normalize_slack_event(payload: dict) -> KonstructMessage:
"""
Minimal normalizer for test purposes.
The real normalizer lives in packages/gateway/channels/slack.py.
This function mirrors the expected output for unit testing the model.
"""
ts = float(payload["ts"])
timestamp = datetime.fromtimestamp(ts, tz=timezone.utc)
return KonstructMessage(
channel=ChannelType.SLACK,
channel_metadata={
"workspace_id": payload["team"],
"channel_id": payload["channel"],
"event_ts": payload["event_ts"],
},
sender=SenderInfo(
user_id=payload["user"],
display_name=payload["user"], # Display name resolved later by Slack API
),
content=MessageContent(
text=payload["text"],
mentions=[u for u in payload["text"].split() if u.startswith("@")],
),
timestamp=timestamp,
thread_id=payload.get("thread_ts"),
reply_to=None if payload.get("thread_ts") == payload.get("ts") else payload.get("thread_ts"),
)
class TestKonstructMessageNormalization:
"""Tests for Slack event normalization to KonstructMessage."""
def test_channel_type_is_slack(self) -> None:
"""ChannelType must be set to 'slack' for Slack events."""
msg = normalize_slack_event(make_slack_event())
assert msg.channel == ChannelType.SLACK
assert msg.channel == "slack"
def test_sender_info_extracted(self) -> None:
"""SenderInfo user_id must match Slack user field."""
payload = make_slack_event()
msg = normalize_slack_event(payload)
assert msg.sender.user_id == "U98765XYZ"
assert msg.sender.is_bot is False
def test_content_text_preserved(self) -> None:
"""MessageContent.text must contain original Slack message text."""
payload = make_slack_event()
msg = normalize_slack_event(payload)
assert msg.content.text == "Hey @bot can you help me?"
def test_thread_id_from_thread_ts(self) -> None:
"""thread_id must be populated from Slack's thread_ts field."""
payload = make_slack_event()
payload["thread_ts"] = "1711234500.000001" # Different from ts — it's a reply
msg = normalize_slack_event(payload)
assert msg.thread_id == "1711234500.000001"
def test_thread_id_none_when_no_thread(self) -> None:
"""thread_id must be None when the message is not in a thread."""
payload = make_slack_event()
del payload["thread_ts"]
msg = normalize_slack_event(payload)
assert msg.thread_id is None
def test_channel_metadata_contains_workspace_id(self) -> None:
"""channel_metadata must contain workspace_id (Slack team ID)."""
payload = make_slack_event()
msg = normalize_slack_event(payload)
assert "workspace_id" in msg.channel_metadata
assert msg.channel_metadata["workspace_id"] == "T11223344"
def test_channel_metadata_contains_channel_id(self) -> None:
"""channel_metadata must contain the Slack channel ID."""
payload = make_slack_event()
msg = normalize_slack_event(payload)
assert msg.channel_metadata["channel_id"] == "C12345ABC"
def test_tenant_id_is_none_before_resolution(self) -> None:
"""tenant_id must be None immediately after normalization (Router populates it)."""
msg = normalize_slack_event(make_slack_event())
assert msg.tenant_id is None
def test_message_has_uuid_id(self) -> None:
"""KonstructMessage must have a UUID id assigned at construction."""
import uuid
msg = normalize_slack_event(make_slack_event())
# Should not raise
parsed = uuid.UUID(msg.id)
assert str(parsed) == msg.id
def test_timestamp_is_utc_datetime(self) -> None:
"""timestamp must be a timezone-aware datetime in UTC."""
msg = normalize_slack_event(make_slack_event())
assert msg.timestamp.tzinfo is not None
assert msg.timestamp.tzinfo == timezone.utc
def test_pydantic_validation_rejects_invalid_channel(self) -> None:
"""KonstructMessage must reject unknown ChannelType values."""
with pytest.raises(Exception): # pydantic.ValidationError
KonstructMessage(
channel="fax_machine", # type: ignore[arg-type]
channel_metadata={},
sender=SenderInfo(user_id="u1", display_name="User"),
content=MessageContent(text="hello"),
timestamp=datetime.now(timezone.utc),
)

View 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]

View 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"