mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-03 20:40:00 -05:00
- Add test_hooks_lifecycle.py with tests for: - Implemented RPC returning correct hook list - OnActivate success/failure propagation - OnDeactivate best-effort semantics - OnConfigurationChange error handling - Add test_hooks_messages.py with tests for: - MessageWillBePosted allow/reject/modify/dismiss semantics - MessageWillBeUpdated with old/new post handling - Notification hooks (Posted/Updated/Deleted) - MessagesWillBeConsumed list filtering/modification - Fix @hook decorator to preserve async handlers - Use separate async wrapper for coroutine functions - Enables proper async detection in hook runner Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
561 lines
19 KiB
Python
561 lines
19 KiB
Python
# Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
# See LICENSE.txt for license information.
|
|
|
|
"""
|
|
Tests for message hook implementations in the hook servicer.
|
|
|
|
Tests verify:
|
|
- MessageWillBePosted semantics: allow/reject/modify/dismiss
|
|
- MessageWillBeUpdated semantics: allow/reject/modify
|
|
- Notification hooks: MessageHasBeenPosted/Updated/Deleted
|
|
- MessagesWillBeConsumed list processing
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock
|
|
|
|
from mattermost_plugin import Plugin, hook, HookName
|
|
from mattermost_plugin.servicers.hooks_servicer import (
|
|
PluginHooksServicerImpl,
|
|
DISMISS_POST_ERROR,
|
|
)
|
|
from mattermost_plugin.grpc import hooks_message_pb2
|
|
from mattermost_plugin.grpc import hooks_common_pb2
|
|
from mattermost_plugin.grpc import post_pb2
|
|
|
|
|
|
def make_test_post(message: str = "test message", post_id: str = "post123") -> post_pb2.Post:
|
|
"""Create a test Post protobuf message."""
|
|
return post_pb2.Post(
|
|
id=post_id,
|
|
message=message,
|
|
user_id="user123",
|
|
channel_id="channel123",
|
|
)
|
|
|
|
|
|
def make_plugin_context() -> hooks_common_pb2.PluginContext:
|
|
"""Create a test PluginContext."""
|
|
return hooks_common_pb2.PluginContext(
|
|
session_id="session123",
|
|
request_id="request123",
|
|
)
|
|
|
|
|
|
class TestMessageWillBePosted:
|
|
"""Tests for MessageWillBePosted hook."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allow_when_not_implemented(self) -> None:
|
|
"""Test that post is allowed when hook is not implemented."""
|
|
|
|
class EmptyPlugin(Plugin):
|
|
pass
|
|
|
|
plugin = EmptyPlugin()
|
|
servicer = PluginHooksServicerImpl(plugin)
|
|
context = MagicMock()
|
|
|
|
request = hooks_message_pb2.MessageWillBePostedRequest(
|
|
plugin_context=make_plugin_context(),
|
|
post=make_test_post(),
|
|
)
|
|
response = await servicer.MessageWillBePosted(request, context)
|
|
|
|
# Not implemented = allow unchanged
|
|
assert not response.HasField("modified_post")
|
|
assert response.rejection_reason == ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allow_unchanged(self) -> None:
|
|
"""Test allowing post unchanged by returning (None, '')."""
|
|
|
|
class TestPlugin(Plugin):
|
|
@hook(HookName.MessageWillBePosted)
|
|
def filter(self, ctx, post):
|
|
return None, ""
|
|
|
|
plugin = TestPlugin()
|
|
servicer = PluginHooksServicerImpl(plugin)
|
|
context = MagicMock()
|
|
|
|
request = hooks_message_pb2.MessageWillBePostedRequest(
|
|
plugin_context=make_plugin_context(),
|
|
post=make_test_post(),
|
|
)
|
|
response = await servicer.MessageWillBePosted(request, context)
|
|
|
|
assert not response.HasField("modified_post")
|
|
assert response.rejection_reason == ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reject_with_reason(self) -> None:
|
|
"""Test rejecting post by returning (None, 'reason')."""
|
|
|
|
class TestPlugin(Plugin):
|
|
@hook(HookName.MessageWillBePosted)
|
|
def filter(self, ctx, post):
|
|
return None, "Message contains spam"
|
|
|
|
plugin = TestPlugin()
|
|
servicer = PluginHooksServicerImpl(plugin)
|
|
context = MagicMock()
|
|
|
|
request = hooks_message_pb2.MessageWillBePostedRequest(
|
|
plugin_context=make_plugin_context(),
|
|
post=make_test_post("Buy cheap stuff now!"),
|
|
)
|
|
response = await servicer.MessageWillBePosted(request, context)
|
|
|
|
assert not response.HasField("modified_post")
|
|
assert response.rejection_reason == "Message contains spam"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_modify_post(self) -> None:
|
|
"""Test modifying post by returning (modified_post, '')."""
|
|
|
|
class TestPlugin(Plugin):
|
|
@hook(HookName.MessageWillBePosted)
|
|
def filter(self, ctx, post):
|
|
# Create modified post
|
|
modified = post_pb2.Post()
|
|
modified.CopyFrom(post)
|
|
modified.message = post.message.upper()
|
|
return modified, ""
|
|
|
|
plugin = TestPlugin()
|
|
servicer = PluginHooksServicerImpl(plugin)
|
|
context = MagicMock()
|
|
|
|
request = hooks_message_pb2.MessageWillBePostedRequest(
|
|
plugin_context=make_plugin_context(),
|
|
post=make_test_post("hello world"),
|
|
)
|
|
response = await servicer.MessageWillBePosted(request, context)
|
|
|
|
assert response.HasField("modified_post")
|
|
assert response.modified_post.message == "HELLO WORLD"
|
|
assert response.rejection_reason == ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dismiss_post(self) -> None:
|
|
"""Test dismissing post silently using DISMISS_POST_ERROR."""
|
|
|
|
class TestPlugin(Plugin):
|
|
@hook(HookName.MessageWillBePosted)
|
|
def filter(self, ctx, post):
|
|
return None, DISMISS_POST_ERROR
|
|
|
|
plugin = TestPlugin()
|
|
servicer = PluginHooksServicerImpl(plugin)
|
|
context = MagicMock()
|
|
|
|
request = hooks_message_pb2.MessageWillBePostedRequest(
|
|
plugin_context=make_plugin_context(),
|
|
post=make_test_post(),
|
|
)
|
|
response = await servicer.MessageWillBePosted(request, context)
|
|
|
|
assert not response.HasField("modified_post")
|
|
assert response.rejection_reason == DISMISS_POST_ERROR
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handler_exception_rejects_post(self) -> None:
|
|
"""Test that handler exceptions result in rejection."""
|
|
|
|
class TestPlugin(Plugin):
|
|
@hook(HookName.MessageWillBePosted)
|
|
def filter(self, ctx, post):
|
|
raise ValueError("Handler crashed")
|
|
|
|
plugin = TestPlugin()
|
|
servicer = PluginHooksServicerImpl(plugin)
|
|
context = MagicMock()
|
|
|
|
request = hooks_message_pb2.MessageWillBePostedRequest(
|
|
plugin_context=make_plugin_context(),
|
|
post=make_test_post(),
|
|
)
|
|
response = await servicer.MessageWillBePosted(request, context)
|
|
|
|
# Exception = rejection with error message
|
|
assert "Plugin error" in response.rejection_reason or "error" in response.rejection_reason.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receives_post_and_context(self) -> None:
|
|
"""Test that handler receives post and context."""
|
|
received = []
|
|
|
|
class TestPlugin(Plugin):
|
|
@hook(HookName.MessageWillBePosted)
|
|
def filter(self, ctx, post):
|
|
received.append((ctx, post))
|
|
return None, ""
|
|
|
|
plugin = TestPlugin()
|
|
servicer = PluginHooksServicerImpl(plugin)
|
|
context = MagicMock()
|
|
|
|
plugin_ctx = make_plugin_context()
|
|
post = make_test_post("test message")
|
|
request = hooks_message_pb2.MessageWillBePostedRequest(
|
|
plugin_context=plugin_ctx,
|
|
post=post,
|
|
)
|
|
await servicer.MessageWillBePosted(request, context)
|
|
|
|
assert len(received) == 1
|
|
ctx_received, post_received = received[0]
|
|
assert ctx_received.session_id == "session123"
|
|
assert post_received.message == "test message"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_handler_works(self) -> None:
|
|
"""Test that async handlers work correctly."""
|
|
|
|
class TestPlugin(Plugin):
|
|
@hook(HookName.MessageWillBePosted)
|
|
async def filter(self, ctx, post):
|
|
modified = post_pb2.Post()
|
|
modified.CopyFrom(post)
|
|
modified.message = "async modified"
|
|
return modified, ""
|
|
|
|
plugin = TestPlugin()
|
|
servicer = PluginHooksServicerImpl(plugin)
|
|
context = MagicMock()
|
|
|
|
request = hooks_message_pb2.MessageWillBePostedRequest(
|
|
plugin_context=make_plugin_context(),
|
|
post=make_test_post(),
|
|
)
|
|
response = await servicer.MessageWillBePosted(request, context)
|
|
|
|
assert response.modified_post.message == "async modified"
|
|
|
|
|
|
class TestMessageWillBeUpdated:
|
|
"""Tests for MessageWillBeUpdated hook."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allow_when_not_implemented(self) -> None:
|
|
"""Test that update is allowed when hook is not implemented."""
|
|
|
|
class EmptyPlugin(Plugin):
|
|
pass
|
|
|
|
plugin = EmptyPlugin()
|
|
servicer = PluginHooksServicerImpl(plugin)
|
|
context = MagicMock()
|
|
|
|
request = hooks_message_pb2.MessageWillBeUpdatedRequest(
|
|
plugin_context=make_plugin_context(),
|
|
new_post=make_test_post("new message"),
|
|
old_post=make_test_post("old message"),
|
|
)
|
|
response = await servicer.MessageWillBeUpdated(request, context)
|
|
|
|
assert not response.HasField("modified_post")
|
|
assert response.rejection_reason == ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receives_old_and_new_post(self) -> None:
|
|
"""Test that handler receives both old and new posts."""
|
|
received = []
|
|
|
|
class TestPlugin(Plugin):
|
|
@hook(HookName.MessageWillBeUpdated)
|
|
def filter(self, ctx, new_post, old_post):
|
|
received.append((new_post.message, old_post.message))
|
|
return None, ""
|
|
|
|
plugin = TestPlugin()
|
|
servicer = PluginHooksServicerImpl(plugin)
|
|
context = MagicMock()
|
|
|
|
request = hooks_message_pb2.MessageWillBeUpdatedRequest(
|
|
plugin_context=make_plugin_context(),
|
|
new_post=make_test_post("updated text"),
|
|
old_post=make_test_post("original text"),
|
|
)
|
|
await servicer.MessageWillBeUpdated(request, context)
|
|
|
|
assert len(received) == 1
|
|
assert received[0] == ("updated text", "original text")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reject_update(self) -> None:
|
|
"""Test rejecting an update."""
|
|
|
|
class TestPlugin(Plugin):
|
|
@hook(HookName.MessageWillBeUpdated)
|
|
def filter(self, ctx, new_post, old_post):
|
|
return None, "Updates not allowed"
|
|
|
|
plugin = TestPlugin()
|
|
servicer = PluginHooksServicerImpl(plugin)
|
|
context = MagicMock()
|
|
|
|
request = hooks_message_pb2.MessageWillBeUpdatedRequest(
|
|
plugin_context=make_plugin_context(),
|
|
new_post=make_test_post("new"),
|
|
old_post=make_test_post("old"),
|
|
)
|
|
response = await servicer.MessageWillBeUpdated(request, context)
|
|
|
|
assert response.rejection_reason == "Updates not allowed"
|
|
|
|
|
|
class TestMessageHasBeenPosted:
|
|
"""Tests for MessageHasBeenPosted notification 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_message_pb2.MessageHasBeenPostedRequest(
|
|
plugin_context=make_plugin_context(),
|
|
post=make_test_post(),
|
|
)
|
|
response = await servicer.MessageHasBeenPosted(request, context)
|
|
|
|
# Response is always empty for notification hooks
|
|
assert isinstance(response, hooks_message_pb2.MessageHasBeenPostedResponse)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handler_called(self) -> None:
|
|
"""Test that notification handler is called."""
|
|
call_count = [0]
|
|
|
|
class TestPlugin(Plugin):
|
|
@hook(HookName.MessageHasBeenPosted)
|
|
def on_post(self, ctx, post):
|
|
call_count[0] += 1
|
|
|
|
plugin = TestPlugin()
|
|
servicer = PluginHooksServicerImpl(plugin)
|
|
context = MagicMock()
|
|
|
|
request = hooks_message_pb2.MessageHasBeenPostedRequest(
|
|
plugin_context=make_plugin_context(),
|
|
post=make_test_post(),
|
|
)
|
|
await servicer.MessageHasBeenPosted(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.MessageHasBeenPosted)
|
|
def on_post(self, ctx, post):
|
|
raise RuntimeError("Handler crashed")
|
|
|
|
plugin = TestPlugin()
|
|
servicer = PluginHooksServicerImpl(plugin)
|
|
context = MagicMock()
|
|
|
|
request = hooks_message_pb2.MessageHasBeenPostedRequest(
|
|
plugin_context=make_plugin_context(),
|
|
post=make_test_post(),
|
|
)
|
|
# Should not raise - errors are logged but response succeeds
|
|
response = await servicer.MessageHasBeenPosted(request, context)
|
|
assert isinstance(response, hooks_message_pb2.MessageHasBeenPostedResponse)
|
|
|
|
|
|
class TestMessageHasBeenUpdated:
|
|
"""Tests for MessageHasBeenUpdated notification hook."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receives_old_and_new_post(self) -> None:
|
|
"""Test that handler receives both posts."""
|
|
received = []
|
|
|
|
class TestPlugin(Plugin):
|
|
@hook(HookName.MessageHasBeenUpdated)
|
|
def on_update(self, ctx, new_post, old_post):
|
|
received.append((new_post.message, old_post.message))
|
|
|
|
plugin = TestPlugin()
|
|
servicer = PluginHooksServicerImpl(plugin)
|
|
context = MagicMock()
|
|
|
|
request = hooks_message_pb2.MessageHasBeenUpdatedRequest(
|
|
plugin_context=make_plugin_context(),
|
|
new_post=make_test_post("new"),
|
|
old_post=make_test_post("old"),
|
|
)
|
|
await servicer.MessageHasBeenUpdated(request, context)
|
|
|
|
assert len(received) == 1
|
|
assert received[0] == ("new", "old")
|
|
|
|
|
|
class TestMessageHasBeenDeleted:
|
|
"""Tests for MessageHasBeenDeleted notification hook."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handler_called(self) -> None:
|
|
"""Test that deletion handler is called."""
|
|
deleted_posts = []
|
|
|
|
class TestPlugin(Plugin):
|
|
@hook(HookName.MessageHasBeenDeleted)
|
|
def on_delete(self, ctx, post):
|
|
deleted_posts.append(post.id)
|
|
|
|
plugin = TestPlugin()
|
|
servicer = PluginHooksServicerImpl(plugin)
|
|
context = MagicMock()
|
|
|
|
request = hooks_message_pb2.MessageHasBeenDeletedRequest(
|
|
plugin_context=make_plugin_context(),
|
|
post=make_test_post(post_id="deleted123"),
|
|
)
|
|
await servicer.MessageHasBeenDeleted(request, context)
|
|
|
|
assert "deleted123" in deleted_posts
|
|
|
|
|
|
class TestMessagesWillBeConsumed:
|
|
"""Tests for MessagesWillBeConsumed hook."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_original_when_not_implemented(self) -> None:
|
|
"""Test that original posts are returned when hook is not implemented."""
|
|
|
|
class EmptyPlugin(Plugin):
|
|
pass
|
|
|
|
plugin = EmptyPlugin()
|
|
servicer = PluginHooksServicerImpl(plugin)
|
|
context = MagicMock()
|
|
|
|
posts = [
|
|
make_test_post("post 1", "id1"),
|
|
make_test_post("post 2", "id2"),
|
|
]
|
|
request = hooks_message_pb2.MessagesWillBeConsumedRequest(posts=posts)
|
|
response = await servicer.MessagesWillBeConsumed(request, context)
|
|
|
|
assert len(response.posts) == 2
|
|
messages = [p.message for p in response.posts]
|
|
assert "post 1" in messages
|
|
assert "post 2" in messages
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_can_filter_posts(self) -> None:
|
|
"""Test that handler can filter posts from the list."""
|
|
|
|
class TestPlugin(Plugin):
|
|
@hook(HookName.MessagesWillBeConsumed)
|
|
def filter_posts(self, posts):
|
|
# Filter out posts with "secret" in message
|
|
return [p for p in posts if "secret" not in p.message.lower()]
|
|
|
|
plugin = TestPlugin()
|
|
servicer = PluginHooksServicerImpl(plugin)
|
|
context = MagicMock()
|
|
|
|
posts = [
|
|
make_test_post("public message", "id1"),
|
|
make_test_post("secret info", "id2"),
|
|
make_test_post("another public", "id3"),
|
|
]
|
|
request = hooks_message_pb2.MessagesWillBeConsumedRequest(posts=posts)
|
|
response = await servicer.MessagesWillBeConsumed(request, context)
|
|
|
|
assert len(response.posts) == 2
|
|
messages = [p.message for p in response.posts]
|
|
assert "public message" in messages
|
|
assert "another public" in messages
|
|
assert "secret info" not in messages
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_can_modify_posts(self) -> None:
|
|
"""Test that handler can modify posts."""
|
|
|
|
class TestPlugin(Plugin):
|
|
@hook(HookName.MessagesWillBeConsumed)
|
|
def modify_posts(self, posts):
|
|
result = []
|
|
for p in posts:
|
|
modified = post_pb2.Post()
|
|
modified.CopyFrom(p)
|
|
modified.message = "[REDACTED]" if "password" in p.message else p.message
|
|
result.append(modified)
|
|
return result
|
|
|
|
plugin = TestPlugin()
|
|
servicer = PluginHooksServicerImpl(plugin)
|
|
context = MagicMock()
|
|
|
|
posts = [
|
|
make_test_post("my password is 123", "id1"),
|
|
make_test_post("regular message", "id2"),
|
|
]
|
|
request = hooks_message_pb2.MessagesWillBeConsumedRequest(posts=posts)
|
|
response = await servicer.MessagesWillBeConsumed(request, context)
|
|
|
|
messages = [p.message for p in response.posts]
|
|
assert "[REDACTED]" in messages
|
|
assert "regular message" in messages
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_original_on_error(self) -> None:
|
|
"""Test that original posts are returned on handler error."""
|
|
|
|
class TestPlugin(Plugin):
|
|
@hook(HookName.MessagesWillBeConsumed)
|
|
def filter_posts(self, posts):
|
|
raise RuntimeError("Handler crashed")
|
|
|
|
plugin = TestPlugin()
|
|
servicer = PluginHooksServicerImpl(plugin)
|
|
context = MagicMock()
|
|
|
|
posts = [make_test_post("test", "id1")]
|
|
request = hooks_message_pb2.MessagesWillBeConsumedRequest(posts=posts)
|
|
response = await servicer.MessagesWillBeConsumed(request, context)
|
|
|
|
# Should return original posts on error
|
|
assert len(response.posts) == 1
|
|
assert response.posts[0].message == "test"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_original_when_handler_returns_none(self) -> None:
|
|
"""Test that original posts are returned if handler returns None."""
|
|
|
|
class TestPlugin(Plugin):
|
|
@hook(HookName.MessagesWillBeConsumed)
|
|
def filter_posts(self, posts):
|
|
return None
|
|
|
|
plugin = TestPlugin()
|
|
servicer = PluginHooksServicerImpl(plugin)
|
|
context = MagicMock()
|
|
|
|
posts = [make_test_post("test", "id1")]
|
|
request = hooks_message_pb2.MessagesWillBeConsumedRequest(posts=posts)
|
|
response = await servicer.MessagesWillBeConsumed(request, context)
|
|
|
|
assert len(response.posts) == 1
|
|
|
|
|
|
class TestDismissPostErrorConstant:
|
|
"""Tests for DISMISS_POST_ERROR constant."""
|
|
|
|
def test_constant_matches_go_value(self) -> None:
|
|
"""Test that DISMISS_POST_ERROR matches the Go constant."""
|
|
# This value must match server/public/plugin/hooks.go
|
|
assert DISMISS_POST_ERROR == "plugin.message_will_be_posted.dismiss_post"
|