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:
0
tests/unit/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
143
tests/unit/test_normalize.py
Normal file
143
tests/unit/test_normalize.py
Normal 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),
|
||||
)
|
||||
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]
|
||||
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