mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-03 20:40:00 -05:00
- 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>
549 lines
18 KiB
Python
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")
|