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:
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),
|
||||
)
|
||||
Reference in New Issue
Block a user