598 lines
21 KiB
Python
598 lines
21 KiB
Python
|
|
"""Tests for notification-to-communication consolidation (B-NOTIF-* + B-NOTIF-TEST)."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import uuid
|
||
|
|
from datetime import UTC, datetime
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from sqlalchemy import select
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
|
||
|
|
from app.core.notifications import create_notification, post_system_message
|
||
|
|
from app.models.notification import Notification, NotificationPreference, NotificationType
|
||
|
|
from app.plugins.builtins.kommunikation.models import (
|
||
|
|
CommConversation,
|
||
|
|
CommMessage,
|
||
|
|
CommMessageBlock,
|
||
|
|
CommMessageRead,
|
||
|
|
CommParticipant,
|
||
|
|
)
|
||
|
|
from app.plugins.builtins.kommunikation.services import get_or_create_system_channel
|
||
|
|
from tests.conftest import seed_tenant_and_users
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
class TestSystemChannel:
|
||
|
|
"""B-NOTIF-SYS: System channel creation and properties."""
|
||
|
|
|
||
|
|
async def test_get_or_create_system_channel_creates_channel(self, db_session: AsyncSession):
|
||
|
|
"""get_or_create_system_channel creates a system channel with is_system=True."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant_id = seed["tenant_a"].id
|
||
|
|
|
||
|
|
conv = await get_or_create_system_channel(db_session, tenant_id)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
assert conv is not None
|
||
|
|
assert conv.is_system is True
|
||
|
|
assert conv.is_locked is True
|
||
|
|
assert conv.title == "System Channel"
|
||
|
|
assert conv.created_by_type == "system"
|
||
|
|
assert conv.tenant_id == tenant_id
|
||
|
|
|
||
|
|
async def test_get_or_create_system_channel_is_idempotent(self, db_session: AsyncSession):
|
||
|
|
"""Calling get_or_create_system_channel twice returns the same channel."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant_id = seed["tenant_a"].id
|
||
|
|
|
||
|
|
conv1 = await get_or_create_system_channel(db_session, tenant_id)
|
||
|
|
await db_session.flush()
|
||
|
|
conv2 = await get_or_create_system_channel(db_session, tenant_id)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
assert conv1.id == conv2.id
|
||
|
|
|
||
|
|
async def test_system_channel_has_all_users_as_participants(self, db_session: AsyncSession):
|
||
|
|
"""All tenant users are automatically added as participants."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant_id = seed["tenant_a"].id
|
||
|
|
|
||
|
|
conv = await get_or_create_system_channel(db_session, tenant_id)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
result = await db_session.execute(
|
||
|
|
select(CommParticipant).where(
|
||
|
|
CommParticipant.conversation_id == conv.id,
|
||
|
|
CommParticipant.left_at.is_(None),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
participants = result.scalars().all()
|
||
|
|
participant_ids = {p.participant_id for p in participants}
|
||
|
|
|
||
|
|
# admin_a, viewer_a, editor_a are all in tenant_a
|
||
|
|
assert seed["admin_a"].id in participant_ids
|
||
|
|
assert seed["viewer_a"].id in participant_ids
|
||
|
|
assert seed["editor_a"].id in participant_ids
|
||
|
|
|
||
|
|
async def test_system_channel_is_tenant_scoped(self, db_session: AsyncSession):
|
||
|
|
"""Different tenants get different system channels."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
|
||
|
|
conv_a = await get_or_create_system_channel(db_session, seed["tenant_a"].id)
|
||
|
|
conv_b = await get_or_create_system_channel(db_session, seed["tenant_b"].id)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
assert conv_a.id != conv_b.id
|
||
|
|
assert conv_a.tenant_id == seed["tenant_a"].id
|
||
|
|
assert conv_b.tenant_id == seed["tenant_b"].id
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
class TestPostSystemMessage:
|
||
|
|
"""B-NOTIF-EVT: post_system_message creates typed system messages."""
|
||
|
|
|
||
|
|
async def test_post_system_message_creates_comm_message(self, db_session: AsyncSession):
|
||
|
|
"""post_system_message creates a CommMessage in the system channel."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant_id = seed["tenant_a"].id
|
||
|
|
user_id = seed["admin_a"].id
|
||
|
|
|
||
|
|
msg = await post_system_message(
|
||
|
|
db_session, tenant_id, user_id,
|
||
|
|
message_type="info",
|
||
|
|
title="Test Notification",
|
||
|
|
body="This is a test body",
|
||
|
|
)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
assert msg is not None
|
||
|
|
assert msg.sender_type == "system"
|
||
|
|
assert "Test Notification" in msg.content
|
||
|
|
assert "This is a test body" in msg.content
|
||
|
|
assert msg.metadata_["notification_type"] == "info"
|
||
|
|
assert msg.metadata_["severity"] == "info"
|
||
|
|
assert msg.metadata_["target_user_id"] == str(user_id)
|
||
|
|
|
||
|
|
async def test_post_system_message_creates_text_block(self, db_session: AsyncSession):
|
||
|
|
"""post_system_message creates a text block with title and body."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant_id = seed["tenant_a"].id
|
||
|
|
user_id = seed["admin_a"].id
|
||
|
|
|
||
|
|
msg = await post_system_message(
|
||
|
|
db_session, tenant_id, user_id,
|
||
|
|
message_type="info",
|
||
|
|
title="Block Test",
|
||
|
|
body="Block body",
|
||
|
|
)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
result = await db_session.execute(
|
||
|
|
select(CommMessageBlock).where(
|
||
|
|
CommMessageBlock.message_id == msg.id,
|
||
|
|
).order_by(CommMessageBlock.sort_order)
|
||
|
|
)
|
||
|
|
blocks = result.scalars().all()
|
||
|
|
|
||
|
|
assert len(blocks) >= 1
|
||
|
|
text_block = blocks[0]
|
||
|
|
assert text_block.block_type == "text"
|
||
|
|
assert text_block.block_data["title"] == "Block Test"
|
||
|
|
assert text_block.block_data["body"] == "Block body"
|
||
|
|
|
||
|
|
async def test_post_system_message_with_entity_creates_action_card(self, db_session: AsyncSession):
|
||
|
|
"""post_system_message with entity_type/entity_id creates an action_card block."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant_id = seed["tenant_a"].id
|
||
|
|
user_id = seed["admin_a"].id
|
||
|
|
entity_id = seed["company_a"].id
|
||
|
|
|
||
|
|
msg = await post_system_message(
|
||
|
|
db_session, tenant_id, user_id,
|
||
|
|
message_type="info",
|
||
|
|
title="Entity Notification",
|
||
|
|
body="Check this entity",
|
||
|
|
entity_type="contact",
|
||
|
|
entity_id=entity_id,
|
||
|
|
)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
result = await db_session.execute(
|
||
|
|
select(CommMessageBlock).where(
|
||
|
|
CommMessageBlock.message_id == msg.id,
|
||
|
|
).order_by(CommMessageBlock.sort_order)
|
||
|
|
)
|
||
|
|
blocks = result.scalars().all()
|
||
|
|
|
||
|
|
assert len(blocks) == 2
|
||
|
|
assert blocks[0].block_type == "text"
|
||
|
|
assert blocks[1].block_type == "action_card"
|
||
|
|
assert blocks[1].block_data["entity_type"] == "contact"
|
||
|
|
assert blocks[1].block_data["entity_id"] == str(entity_id)
|
||
|
|
|
||
|
|
# Check metadata entity_ref
|
||
|
|
assert msg.metadata_["entity_ref"]["entity_type"] == "contact"
|
||
|
|
assert msg.metadata_["entity_ref"]["entity_id"] == str(entity_id)
|
||
|
|
|
||
|
|
async def test_post_system_message_severity_in_metadata(self, db_session: AsyncSession):
|
||
|
|
"""post_system_message stores severity in message metadata."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant_id = seed["tenant_a"].id
|
||
|
|
user_id = seed["admin_a"].id
|
||
|
|
|
||
|
|
msg = await post_system_message(
|
||
|
|
db_session, tenant_id, user_id,
|
||
|
|
message_type="alert",
|
||
|
|
title="Critical Alert",
|
||
|
|
body="Something happened",
|
||
|
|
severity="critical",
|
||
|
|
)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
assert msg.metadata_["severity"] == "critical"
|
||
|
|
assert msg.metadata_["notification_type"] == "alert"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
class TestCreateNotificationWrapper:
|
||
|
|
"""B-NOTIF-EVT: create_notification delegates to post_system_message."""
|
||
|
|
|
||
|
|
async def test_create_notification_delegates_to_post_system_message(self, db_session: AsyncSession):
|
||
|
|
"""create_notification creates both a CommMessage and a legacy Notification."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant_id = seed["tenant_a"].id
|
||
|
|
user_id = seed["admin_a"].id
|
||
|
|
|
||
|
|
notif = await create_notification(
|
||
|
|
db_session, tenant_id, user_id,
|
||
|
|
type="info",
|
||
|
|
title="Wrapper Test",
|
||
|
|
body="Wrapper body",
|
||
|
|
)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
# Legacy notification created
|
||
|
|
assert notif is not None
|
||
|
|
assert notif.title == "Wrapper Test"
|
||
|
|
assert notif.type == "info"
|
||
|
|
|
||
|
|
# CommMessage also created in system channel
|
||
|
|
result = await db_session.execute(
|
||
|
|
select(CommMessage).where(
|
||
|
|
CommMessage.tenant_id == tenant_id,
|
||
|
|
CommMessage.sender_type == "system",
|
||
|
|
)
|
||
|
|
)
|
||
|
|
comm_msgs = result.scalars().all()
|
||
|
|
assert len(comm_msgs) >= 1
|
||
|
|
assert any("Wrapper Test" in m.content for m in comm_msgs)
|
||
|
|
|
||
|
|
async def test_create_notification_returns_none_when_muted(self, db_session: AsyncSession):
|
||
|
|
"""create_notification returns None when user has disabled the type."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant_id = seed["tenant_a"].id
|
||
|
|
user_id = seed["admin_a"].id
|
||
|
|
|
||
|
|
# Create a notification type and disable it for the user
|
||
|
|
ntype = NotificationType(
|
||
|
|
type_key="muted_type",
|
||
|
|
plugin_name="test",
|
||
|
|
category="general",
|
||
|
|
label="Muted Type",
|
||
|
|
is_enabled_by_default=True,
|
||
|
|
)
|
||
|
|
db_session.add(ntype)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
pref = NotificationPreference(
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
user_id=user_id,
|
||
|
|
type_key="muted_type",
|
||
|
|
is_enabled=False,
|
||
|
|
)
|
||
|
|
db_session.add(pref)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
notif = await create_notification(
|
||
|
|
db_session, tenant_id, user_id,
|
||
|
|
type="muted_type",
|
||
|
|
title="Should Not Appear",
|
||
|
|
body="Should be muted",
|
||
|
|
)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
assert notif is None
|
||
|
|
|
||
|
|
# Verify no CommMessage was created with this title
|
||
|
|
result = await db_session.execute(
|
||
|
|
select(CommMessage).where(
|
||
|
|
CommMessage.tenant_id == tenant_id,
|
||
|
|
CommMessage.content.like("%Should Not Appear%"),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
comm_msgs = result.scalars().all()
|
||
|
|
assert len(comm_msgs) == 0
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
class TestNotificationPreferences:
|
||
|
|
"""B-NOTIF-PREF: Delivery preferences are respected."""
|
||
|
|
|
||
|
|
async def test_post_system_message_respects_muted_preference(self, db_session: AsyncSession):
|
||
|
|
"""post_system_message returns None when user has muted the type."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant_id = seed["tenant_a"].id
|
||
|
|
user_id = seed["admin_a"].id
|
||
|
|
|
||
|
|
ntype = NotificationType(
|
||
|
|
type_key="muted_alert",
|
||
|
|
plugin_name="test",
|
||
|
|
category="general",
|
||
|
|
label="Muted Alert",
|
||
|
|
is_enabled_by_default=True,
|
||
|
|
)
|
||
|
|
db_session.add(ntype)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
pref = NotificationPreference(
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
user_id=user_id,
|
||
|
|
type_key="muted_alert",
|
||
|
|
is_enabled=False,
|
||
|
|
)
|
||
|
|
db_session.add(pref)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
msg = await post_system_message(
|
||
|
|
db_session, tenant_id, user_id,
|
||
|
|
message_type="muted_alert",
|
||
|
|
title="Muted Message",
|
||
|
|
body="Should not be posted",
|
||
|
|
)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
assert msg is None
|
||
|
|
|
||
|
|
async def test_post_system_message_respects_disabled_by_default(self, db_session: AsyncSession):
|
||
|
|
"""post_system_message returns None when type is disabled by default and no explicit preference."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant_id = seed["tenant_a"].id
|
||
|
|
user_id = seed["admin_a"].id
|
||
|
|
|
||
|
|
ntype = NotificationType(
|
||
|
|
type_key="disabled_default",
|
||
|
|
plugin_name="test",
|
||
|
|
category="general",
|
||
|
|
label="Disabled By Default",
|
||
|
|
is_enabled_by_default=False,
|
||
|
|
)
|
||
|
|
db_session.add(ntype)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
msg = await post_system_message(
|
||
|
|
db_session, tenant_id, user_id,
|
||
|
|
message_type="disabled_default",
|
||
|
|
title="Should Not Appear",
|
||
|
|
body="Disabled by default",
|
||
|
|
)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
assert msg is None
|
||
|
|
|
||
|
|
async def test_post_system_message_with_enabled_preference(self, db_session: AsyncSession):
|
||
|
|
"""post_system_message creates message when preference is enabled."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant_id = seed["tenant_a"].id
|
||
|
|
user_id = seed["admin_a"].id
|
||
|
|
|
||
|
|
ntype = NotificationType(
|
||
|
|
type_key="enabled_type",
|
||
|
|
plugin_name="test",
|
||
|
|
category="general",
|
||
|
|
label="Enabled Type",
|
||
|
|
is_enabled_by_default=False,
|
||
|
|
)
|
||
|
|
db_session.add(ntype)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
pref = NotificationPreference(
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
user_id=user_id,
|
||
|
|
type_key="enabled_type",
|
||
|
|
is_enabled=True,
|
||
|
|
)
|
||
|
|
db_session.add(pref)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
msg = await post_system_message(
|
||
|
|
db_session, tenant_id, user_id,
|
||
|
|
message_type="enabled_type",
|
||
|
|
title="Enabled Message",
|
||
|
|
body="Should be posted",
|
||
|
|
)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
assert msg is not None
|
||
|
|
assert "Enabled Message" in msg.content
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
class TestUnreadBadge:
|
||
|
|
"""B-NOTIF-TEST: Unread badge via CommMessageRead."""
|
||
|
|
|
||
|
|
async def test_system_message_unread_for_new_user(self, db_session: AsyncSession):
|
||
|
|
"""New system messages are unread (no CommMessageRead entry)."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant_id = seed["tenant_a"].id
|
||
|
|
user_id = seed["admin_a"].id
|
||
|
|
|
||
|
|
msg = await post_system_message(
|
||
|
|
db_session, tenant_id, user_id,
|
||
|
|
message_type="info",
|
||
|
|
title="Unread Test",
|
||
|
|
body="Should be unread",
|
||
|
|
)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
# Check no CommMessageRead exists for this user+conversation
|
||
|
|
result = await db_session.execute(
|
||
|
|
select(CommMessageRead).where(
|
||
|
|
CommMessageRead.conversation_id == msg.conversation_id,
|
||
|
|
CommMessageRead.user_id == user_id,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
read_state = result.scalar_one_or_none()
|
||
|
|
assert read_state is None # No read state = unread
|
||
|
|
|
||
|
|
async def test_mark_system_message_read_creates_read_state(self, db_session: AsyncSession):
|
||
|
|
"""Marking a system message read creates CommMessageRead entry."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant_id = seed["tenant_a"].id
|
||
|
|
user_id = seed["admin_a"].id
|
||
|
|
|
||
|
|
msg = await post_system_message(
|
||
|
|
db_session, tenant_id, user_id,
|
||
|
|
message_type="info",
|
||
|
|
title="Read Test",
|
||
|
|
body="Will be marked read",
|
||
|
|
)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
# Create read state
|
||
|
|
read_state = CommMessageRead(
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
conversation_id=msg.conversation_id,
|
||
|
|
user_id=user_id,
|
||
|
|
last_read_msg_id=msg.id,
|
||
|
|
last_read_at=datetime.now(UTC),
|
||
|
|
)
|
||
|
|
db_session.add(read_state)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
# Verify read state exists
|
||
|
|
result = await db_session.execute(
|
||
|
|
select(CommMessageRead).where(
|
||
|
|
CommMessageRead.conversation_id == msg.conversation_id,
|
||
|
|
CommMessageRead.user_id == user_id,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
saved = result.scalar_one_or_none()
|
||
|
|
assert saved is not None
|
||
|
|
assert saved.last_read_msg_id == msg.id
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
class TestNotificationMigrationMapping:
|
||
|
|
"""B-NOTIF-MIG: Migration mapping from Notification to CommMessage."""
|
||
|
|
|
||
|
|
async def test_notification_fields_map_to_comm_message(self, db_session: AsyncSession):
|
||
|
|
"""Notification fields map correctly to CommMessage metadata and blocks."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant_id = seed["tenant_a"].id
|
||
|
|
user_id = seed["admin_a"].id
|
||
|
|
entity_id = seed["company_a"].id
|
||
|
|
|
||
|
|
# Create a notification via the wrapper (which creates both legacy + comm)
|
||
|
|
notif = await create_notification(
|
||
|
|
db_session, tenant_id, user_id,
|
||
|
|
type="mail_received",
|
||
|
|
title="New Mail",
|
||
|
|
body="You have a new mail",
|
||
|
|
entity_type="contact",
|
||
|
|
entity_id=entity_id,
|
||
|
|
)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
# Find the corresponding CommMessage
|
||
|
|
result = await db_session.execute(
|
||
|
|
select(CommMessage).where(
|
||
|
|
CommMessage.tenant_id == tenant_id,
|
||
|
|
CommMessage.sender_type == "system",
|
||
|
|
).order_by(CommMessage.created_at.desc())
|
||
|
|
)
|
||
|
|
comm_msg = result.scalars().first()
|
||
|
|
assert comm_msg is not None
|
||
|
|
|
||
|
|
# Field mapping checks
|
||
|
|
assert comm_msg.metadata_["notification_type"] == "mail_received"
|
||
|
|
assert comm_msg.metadata_["severity"] == "info"
|
||
|
|
assert comm_msg.metadata_["entity_ref"]["entity_type"] == "contact"
|
||
|
|
assert comm_msg.metadata_["entity_ref"]["entity_id"] == str(entity_id)
|
||
|
|
|
||
|
|
# Check blocks
|
||
|
|
blocks_result = await db_session.execute(
|
||
|
|
select(CommMessageBlock).where(
|
||
|
|
CommMessageBlock.message_id == comm_msg.id,
|
||
|
|
).order_by(CommMessageBlock.sort_order)
|
||
|
|
)
|
||
|
|
blocks = blocks_result.scalars().all()
|
||
|
|
assert len(blocks) == 2 # text + action_card
|
||
|
|
assert blocks[0].block_type == "text"
|
||
|
|
assert blocks[1].block_type == "action_card"
|
||
|
|
|
||
|
|
async def test_notification_read_at_maps_to_comm_message_read(self, db_session: AsyncSession):
|
||
|
|
"""Notification.read_at maps to CommMessageRead entry."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant_id = seed["tenant_a"].id
|
||
|
|
user_id = seed["admin_a"].id
|
||
|
|
|
||
|
|
notif = await create_notification(
|
||
|
|
db_session, tenant_id, user_id,
|
||
|
|
type="info",
|
||
|
|
title="Read Mapping Test",
|
||
|
|
body="Will be read",
|
||
|
|
)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
# Mark notification as read
|
||
|
|
notif.read_at = datetime.now(UTC)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
# Find the CommMessage
|
||
|
|
result = await db_session.execute(
|
||
|
|
select(CommMessage).where(
|
||
|
|
CommMessage.tenant_id == tenant_id,
|
||
|
|
CommMessage.sender_type == "system",
|
||
|
|
).order_by(CommMessage.created_at.desc())
|
||
|
|
)
|
||
|
|
comm_msg = result.scalars().first()
|
||
|
|
assert comm_msg is not None
|
||
|
|
|
||
|
|
# Create CommMessageRead (simulating what migration does)
|
||
|
|
read_state = CommMessageRead(
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
conversation_id=comm_msg.conversation_id,
|
||
|
|
user_id=user_id,
|
||
|
|
last_read_msg_id=comm_msg.id,
|
||
|
|
last_read_at=notif.read_at,
|
||
|
|
)
|
||
|
|
db_session.add(read_state)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
# Verify
|
||
|
|
read_result = await db_session.execute(
|
||
|
|
select(CommMessageRead).where(
|
||
|
|
CommMessageRead.conversation_id == comm_msg.conversation_id,
|
||
|
|
CommMessageRead.user_id == user_id,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
saved = read_result.scalar_one_or_none()
|
||
|
|
assert saved is not None
|
||
|
|
assert saved.last_read_at is not None
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
class TestSensitiveFieldsExcluded:
|
||
|
|
"""B-NOTIF-TEST: Sensitive fields are excluded from system messages."""
|
||
|
|
|
||
|
|
async def test_no_password_or_secret_in_message(self, db_session: AsyncSession):
|
||
|
|
"""System messages do not contain password hashes or secret values."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant_id = seed["tenant_a"].id
|
||
|
|
user_id = seed["admin_a"].id
|
||
|
|
|
||
|
|
msg = await post_system_message(
|
||
|
|
db_session, tenant_id, user_id,
|
||
|
|
message_type="info",
|
||
|
|
title="Security Test",
|
||
|
|
body="Normal body text",
|
||
|
|
)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
# Check message content and metadata for sensitive patterns
|
||
|
|
sensitive_patterns = ["password", "hash", "secret", "token", "api_key"]
|
||
|
|
content_lower = msg.content.lower()
|
||
|
|
metadata_str = str(msg.metadata_).lower()
|
||
|
|
|
||
|
|
for pattern in sensitive_patterns:
|
||
|
|
assert pattern not in content_lower, f"Sensitive pattern '{pattern}' found in content"
|
||
|
|
assert pattern not in metadata_str, f"Sensitive pattern '{pattern}' found in metadata"
|
||
|
|
|
||
|
|
async def test_blocks_exclude_sensitive_data(self, db_session: AsyncSession):
|
||
|
|
"""Message blocks do not contain sensitive data."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
tenant_id = seed["tenant_a"].id
|
||
|
|
user_id = seed["admin_a"].id
|
||
|
|
|
||
|
|
msg = await post_system_message(
|
||
|
|
db_session, tenant_id, user_id,
|
||
|
|
message_type="info",
|
||
|
|
title="Block Security Test",
|
||
|
|
body="Normal block text",
|
||
|
|
)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
result = await db_session.execute(
|
||
|
|
select(CommMessageBlock).where(
|
||
|
|
CommMessageBlock.message_id == msg.id,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
blocks = result.scalars().all()
|
||
|
|
|
||
|
|
for block in blocks:
|
||
|
|
block_str = str(block.block_data).lower()
|
||
|
|
assert "password" not in block_str
|
||
|
|
assert "secret" not in block_str
|
||
|
|
assert "token" not in block_str
|