mattermost/python-sdk/tests/test_hooks_user_channel.py
Nick Misasi 4813f47b3f test(07-03): add comprehensive tests for remaining hooks
- Add test_hooks_command_config.py: ExecuteCommand and
  ConfigurationWillBeSaved hook tests
- Add test_hooks_user_channel.py: User lifecycle, channel/team,
  and SAML login hook tests
- Add test_hooks_notifications.py: Reaction, notification, and
  preferences hook tests
- Add test_hooks_system.py: System, WebSocket, cluster, shared
  channels, and support data hook tests
- All 65 new tests verify correct hook semantics including:
  - Fire-and-forget vs return-value hooks
  - Rejection string semantics for UserWillLogIn
  - Modify/reject semantics for notifications
  - Error handling for all hook types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 11:41:41 -05:00

549 lines
18 KiB
Python

# Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
# See LICENSE.txt for license information.
"""
Tests for user and channel hook implementations in the hook servicer.
Tests verify:
- User lifecycle hooks: UserHasBeenCreated, UserWillLogIn, UserHasLoggedIn, UserHasBeenDeactivated
- Channel/Team hooks: ChannelHasBeenCreated, UserHasJoined/LeftChannel, UserHasJoined/LeftTeam
- OnSAMLLogin hook
"""
import pytest
from unittest.mock import MagicMock
from mattermost_plugin import Plugin, hook, HookName
from mattermost_plugin.servicers.hooks_servicer import PluginHooksServicerImpl
from mattermost_plugin.grpc import hooks_user_channel_pb2
from mattermost_plugin.grpc import hooks_common_pb2
from mattermost_plugin.grpc import user_pb2
from mattermost_plugin.grpc import channel_pb2
from mattermost_plugin.grpc import team_pb2
from mattermost_plugin.grpc import api_channel_post_pb2
from mattermost_plugin.grpc import api_user_team_pb2
def make_plugin_context() -> hooks_common_pb2.PluginContext:
"""Create a test PluginContext."""
return hooks_common_pb2.PluginContext(
session_id="session123",
request_id="request123",
)
def make_test_user(user_id: str = "user123") -> user_pb2.User:
"""Create a test User protobuf message."""
return user_pb2.User(
id=user_id,
username="testuser",
email="test@example.com",
)
def make_test_channel(channel_id: str = "channel123") -> channel_pb2.Channel:
"""Create a test Channel protobuf message."""
return channel_pb2.Channel(
id=channel_id,
name="test-channel",
display_name="Test Channel",
team_id="team123",
type=channel_pb2.CHANNEL_TYPE_OPEN,
)
def make_channel_member(user_id: str = "user123", channel_id: str = "channel123") -> api_channel_post_pb2.ChannelMember:
"""Create a test ChannelMember protobuf message."""
return api_channel_post_pb2.ChannelMember(
user_id=user_id,
channel_id=channel_id,
)
def make_team_member(user_id: str = "user123", team_id: str = "team123") -> team_pb2.TeamMember:
"""Create a test TeamMember protobuf message."""
return team_pb2.TeamMember(
user_id=user_id,
team_id=team_id,
)
class TestUserHasBeenCreated:
"""Tests for UserHasBeenCreated hook."""
@pytest.mark.asyncio
async def test_success_when_not_implemented(self) -> None:
"""Test notification succeeds when hook is not implemented."""
class EmptyPlugin(Plugin):
pass
plugin = EmptyPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_user_channel_pb2.UserHasBeenCreatedRequest(
plugin_context=make_plugin_context(),
user=make_test_user(),
)
response = await servicer.UserHasBeenCreated(request, context)
# Fire-and-forget - always succeeds
assert isinstance(response, hooks_user_channel_pb2.UserHasBeenCreatedResponse)
@pytest.mark.asyncio
async def test_handler_called(self) -> None:
"""Test that notification handler is called."""
call_count = [0]
class TestPlugin(Plugin):
@hook(HookName.UserHasBeenCreated)
def on_user_created(self, ctx, user):
call_count[0] += 1
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_user_channel_pb2.UserHasBeenCreatedRequest(
plugin_context=make_plugin_context(),
user=make_test_user(),
)
await servicer.UserHasBeenCreated(request, context)
assert call_count[0] == 1
@pytest.mark.asyncio
async def test_exception_doesnt_fail_response(self) -> None:
"""Test that handler exceptions don't prevent response."""
class TestPlugin(Plugin):
@hook(HookName.UserHasBeenCreated)
def on_user_created(self, ctx, user):
raise RuntimeError("Handler crashed")
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_user_channel_pb2.UserHasBeenCreatedRequest(
plugin_context=make_plugin_context(),
user=make_test_user(),
)
# Should not raise - errors are logged but response succeeds
response = await servicer.UserHasBeenCreated(request, context)
assert isinstance(response, hooks_user_channel_pb2.UserHasBeenCreatedResponse)
class TestUserWillLogIn:
"""Tests for UserWillLogIn hook."""
@pytest.mark.asyncio
async def test_allow_when_not_implemented(self) -> None:
"""Test that login is allowed when hook is not implemented."""
class EmptyPlugin(Plugin):
pass
plugin = EmptyPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_user_channel_pb2.UserWillLogInRequest(
plugin_context=make_plugin_context(),
user=make_test_user(),
)
response = await servicer.UserWillLogIn(request, context)
# Not implemented = allow
assert response.rejection_reason == ""
@pytest.mark.asyncio
async def test_allow_login(self) -> None:
"""Test allowing login by returning empty string."""
class TestPlugin(Plugin):
@hook(HookName.UserWillLogIn)
def check_login(self, ctx, user):
return "" # Allow
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_user_channel_pb2.UserWillLogInRequest(
plugin_context=make_plugin_context(),
user=make_test_user(),
)
response = await servicer.UserWillLogIn(request, context)
assert response.rejection_reason == ""
@pytest.mark.asyncio
async def test_reject_login(self) -> None:
"""Test rejecting login by returning a reason string."""
class TestPlugin(Plugin):
@hook(HookName.UserWillLogIn)
def check_login(self, ctx, user):
return "Account is suspended"
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_user_channel_pb2.UserWillLogInRequest(
plugin_context=make_plugin_context(),
user=make_test_user(),
)
response = await servicer.UserWillLogIn(request, context)
assert response.rejection_reason == "Account is suspended"
@pytest.mark.asyncio
async def test_exception_rejects_login(self) -> None:
"""Test that handler exceptions reject the login."""
class TestPlugin(Plugin):
@hook(HookName.UserWillLogIn)
def check_login(self, ctx, user):
raise ValueError("Login check failed")
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_user_channel_pb2.UserWillLogInRequest(
plugin_context=make_plugin_context(),
user=make_test_user(),
)
response = await servicer.UserWillLogIn(request, context)
# Exception = rejection
assert "Plugin error" in response.rejection_reason
@pytest.mark.asyncio
async def test_receives_user(self) -> None:
"""Test that handler receives the user object."""
received = []
class TestPlugin(Plugin):
@hook(HookName.UserWillLogIn)
def check_login(self, ctx, user):
received.append(user.username)
return ""
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_user_channel_pb2.UserWillLogInRequest(
plugin_context=make_plugin_context(),
user=make_test_user(),
)
await servicer.UserWillLogIn(request, context)
assert len(received) == 1
assert received[0] == "testuser"
class TestUserHasLoggedIn:
"""Tests for UserHasLoggedIn hook."""
@pytest.mark.asyncio
async def test_handler_called(self) -> None:
"""Test that handler is called after login."""
logged_in_users = []
class TestPlugin(Plugin):
@hook(HookName.UserHasLoggedIn)
def on_login(self, ctx, user):
logged_in_users.append(user.id)
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_user_channel_pb2.UserHasLoggedInRequest(
plugin_context=make_plugin_context(),
user=make_test_user("logged_in_user"),
)
await servicer.UserHasLoggedIn(request, context)
assert "logged_in_user" in logged_in_users
class TestUserHasBeenDeactivated:
"""Tests for UserHasBeenDeactivated hook."""
@pytest.mark.asyncio
async def test_handler_called(self) -> None:
"""Test that handler is called when user is deactivated."""
deactivated_users = []
class TestPlugin(Plugin):
@hook(HookName.UserHasBeenDeactivated)
def on_deactivate(self, ctx, user):
deactivated_users.append(user.id)
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_user_channel_pb2.UserHasBeenDeactivatedRequest(
plugin_context=make_plugin_context(),
user=make_test_user("deactivated_user"),
)
await servicer.UserHasBeenDeactivated(request, context)
assert "deactivated_user" in deactivated_users
class TestChannelHasBeenCreated:
"""Tests for ChannelHasBeenCreated hook."""
@pytest.mark.asyncio
async def test_success_when_not_implemented(self) -> None:
"""Test notification succeeds when hook is not implemented."""
class EmptyPlugin(Plugin):
pass
plugin = EmptyPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_user_channel_pb2.ChannelHasBeenCreatedRequest(
plugin_context=make_plugin_context(),
channel=make_test_channel(),
)
response = await servicer.ChannelHasBeenCreated(request, context)
assert isinstance(response, hooks_user_channel_pb2.ChannelHasBeenCreatedResponse)
@pytest.mark.asyncio
async def test_handler_called(self) -> None:
"""Test that handler is called when channel is created."""
created_channels = []
class TestPlugin(Plugin):
@hook(HookName.ChannelHasBeenCreated)
def on_channel_created(self, ctx, channel):
created_channels.append(channel.name)
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_user_channel_pb2.ChannelHasBeenCreatedRequest(
plugin_context=make_plugin_context(),
channel=make_test_channel(),
)
await servicer.ChannelHasBeenCreated(request, context)
assert "test-channel" in created_channels
class TestUserHasJoinedChannel:
"""Tests for UserHasJoinedChannel hook."""
@pytest.mark.asyncio
async def test_handler_called(self) -> None:
"""Test that handler is called when user joins channel."""
join_events = []
class TestPlugin(Plugin):
@hook(HookName.UserHasJoinedChannel)
def on_join(self, ctx, member, actor):
join_events.append((member.user_id, member.channel_id, actor))
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_user_channel_pb2.UserHasJoinedChannelRequest(
plugin_context=make_plugin_context(),
channel_member=make_channel_member("joining_user", "target_channel"),
)
await servicer.UserHasJoinedChannel(request, context)
assert len(join_events) == 1
user_id, channel_id, actor = join_events[0]
assert user_id == "joining_user"
assert channel_id == "target_channel"
assert actor is None # No actor = self-join
@pytest.mark.asyncio
async def test_receives_actor_when_present(self) -> None:
"""Test that handler receives actor when another user invites."""
join_events = []
class TestPlugin(Plugin):
@hook(HookName.UserHasJoinedChannel)
def on_join(self, ctx, member, actor):
join_events.append((member.user_id, actor.id if actor else None))
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_user_channel_pb2.UserHasJoinedChannelRequest(
plugin_context=make_plugin_context(),
channel_member=make_channel_member("joining_user", "target_channel"),
actor=make_test_user("inviter_user"),
)
await servicer.UserHasJoinedChannel(request, context)
assert len(join_events) == 1
user_id, actor_id = join_events[0]
assert user_id == "joining_user"
assert actor_id == "inviter_user"
class TestUserHasLeftChannel:
"""Tests for UserHasLeftChannel hook."""
@pytest.mark.asyncio
async def test_handler_called(self) -> None:
"""Test that handler is called when user leaves channel."""
leave_events = []
class TestPlugin(Plugin):
@hook(HookName.UserHasLeftChannel)
def on_leave(self, ctx, member, actor):
leave_events.append(member.user_id)
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_user_channel_pb2.UserHasLeftChannelRequest(
plugin_context=make_plugin_context(),
channel_member=make_channel_member("leaving_user", "source_channel"),
)
await servicer.UserHasLeftChannel(request, context)
assert "leaving_user" in leave_events
class TestUserHasJoinedTeam:
"""Tests for UserHasJoinedTeam hook."""
@pytest.mark.asyncio
async def test_handler_called(self) -> None:
"""Test that handler is called when user joins team."""
join_events = []
class TestPlugin(Plugin):
@hook(HookName.UserHasJoinedTeam)
def on_join_team(self, ctx, member, actor):
join_events.append((member.user_id, member.team_id))
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_user_channel_pb2.UserHasJoinedTeamRequest(
plugin_context=make_plugin_context(),
team_member=make_team_member("joining_user", "target_team"),
)
await servicer.UserHasJoinedTeam(request, context)
assert len(join_events) == 1
assert join_events[0] == ("joining_user", "target_team")
class TestUserHasLeftTeam:
"""Tests for UserHasLeftTeam hook."""
@pytest.mark.asyncio
async def test_handler_called(self) -> None:
"""Test that handler is called when user leaves team."""
leave_events = []
class TestPlugin(Plugin):
@hook(HookName.UserHasLeftTeam)
def on_leave_team(self, ctx, member, actor):
leave_events.append(member.user_id)
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_user_channel_pb2.UserHasLeftTeamRequest(
plugin_context=make_plugin_context(),
team_member=make_team_member("leaving_user", "source_team"),
)
await servicer.UserHasLeftTeam(request, context)
assert "leaving_user" in leave_events
class TestOnSAMLLogin:
"""Tests for OnSAMLLogin hook."""
@pytest.mark.asyncio
async def test_allow_when_not_implemented(self) -> None:
"""Test that SAML login is allowed when hook is not implemented."""
class EmptyPlugin(Plugin):
pass
plugin = EmptyPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_user_channel_pb2.OnSAMLLoginRequest(
plugin_context=make_plugin_context(),
user=make_test_user(),
assertion=hooks_user_channel_pb2.SamlAssertionInfoJson(assertion_json=b'{}'),
)
response = await servicer.OnSAMLLogin(request, context)
# Not implemented = allow
assert not response.HasField("error")
@pytest.mark.asyncio
async def test_reject_login(self) -> None:
"""Test rejecting SAML login by returning an error."""
class TestPlugin(Plugin):
@hook(HookName.OnSAMLLogin)
def check_saml(self, ctx, user, assertion):
return "SAML login not allowed for this user"
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_user_channel_pb2.OnSAMLLoginRequest(
plugin_context=make_plugin_context(),
user=make_test_user(),
assertion=hooks_user_channel_pb2.SamlAssertionInfoJson(assertion_json=b'{}'),
)
response = await servicer.OnSAMLLogin(request, context)
assert response.HasField("error")
assert "SAML login not allowed" in response.error.message
@pytest.mark.asyncio
async def test_allow_login(self) -> None:
"""Test allowing SAML login by returning None."""
class TestPlugin(Plugin):
@hook(HookName.OnSAMLLogin)
def check_saml(self, ctx, user, assertion):
return None # Allow
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_user_channel_pb2.OnSAMLLoginRequest(
plugin_context=make_plugin_context(),
user=make_test_user(),
assertion=hooks_user_channel_pb2.SamlAssertionInfoJson(assertion_json=b'{}'),
)
response = await servicer.OnSAMLLogin(request, context)
assert not response.HasField("error")