fix: plugin event dispatch crashes on first disabled plugin (#1231)

trigger_event in apps/connect/utils.py iterates pm.list_plugins() and
on disabled plugins emits a debug log that accesses dict items as if
they were attributes:

  logger.debug(f"Skipping disabled plugin id={plugin.key} name={plugin.name}")

Python evaluates f-string arguments eagerly even when the logger
discards the message at INFO level, so this raises AttributeError on
the first disabled plugin encountered. The exception bubbles out of
trigger_event with no try/except in the loop, aborting dispatch for
every plugin sorted after the disabled one.

Effect for users: any plugin subscribed to events via `events: [...]`
on an action silently never receives events whenever any
alphabetically-earlier plugin is disabled. Manual button actions still
work, masking the failure as a plugin bug rather than a dispatch one.

Fix: replace the two attribute accesses with dict access. Add a
regression test under apps/connect/tests/ that mocks PluginManager to
return [disabled, enabled-with-events], asserts the enabled plugin's
action is dispatched, and a sanity check that non-matching events
aren't dispatched. Verified the test fails against the original code
with the expected AttributeError and passes with the fix.
This commit is contained in:
R3XCHRIS 2026-05-09 16:16:14 +01:00
parent 84fbbfa3c4
commit 65e7b55200
3 changed files with 103 additions and 1 deletions

View file

View file

@ -0,0 +1,102 @@
"""
Regression tests for the plugin event dispatch loop in apps.connect.utils.
Previously, trigger_event accessed `plugin.key` / `plugin.name` (attribute
access) on dict items returned by PluginManager.list_plugins(). On the
first disabled plugin encountered, that f-string raised AttributeError
and because Python evaluates f-string arguments eagerly even when the
logger discards the message at INFO level, the exception bubbled out of
trigger_event with no try/except. Any enabled plugin sorted after a
disabled one then silently received zero events.
These tests guard against regression by:
1. Feeding trigger_event a plugins list with a disabled plugin BEFORE an
enabled-with-events plugin and asserting the enabled plugin's action
is still dispatched.
2. Sanity-checking that actions without a matching `events` entry are
not dispatched.
"""
from unittest.mock import MagicMock, patch
from django.test import SimpleTestCase
def _empty_subscription_chain():
"""Mock the EventSubscription.objects.filter(...).select_related(...)
chain to yield no subscriptions, so trigger_event proceeds straight to
the plugin loop."""
empty_qs = MagicMock()
empty_qs.count.return_value = 0
empty_qs.__iter__ = lambda self: iter([])
chain = MagicMock()
chain.select_related.return_value = empty_qs
return chain
class TriggerEventDispatchTests(SimpleTestCase):
def _run_trigger_event(self, plugins, event_name, payload):
pm = MagicMock()
pm.list_plugins.return_value = plugins
with patch(
"apps.connect.utils.PluginManager.get", return_value=pm
), patch(
"apps.connect.utils.EventSubscription.objects.filter",
return_value=_empty_subscription_chain(),
):
from apps.connect.utils import trigger_event
trigger_event(event_name, payload)
return pm
def test_disabled_plugin_does_not_abort_dispatch_for_later_enabled_plugin(self):
"""The original bug: AttributeError on a disabled plugin's debug log
aborted the loop, so any plugin iterated AFTER a disabled one never
received events."""
plugins = [
{
"key": "disabled-plugin",
"name": "Disabled Plugin",
"enabled": False,
"actions": [],
},
{
"key": "enabled-plugin",
"name": "Enabled Plugin",
"enabled": True,
"actions": [
{"id": "on_event", "events": ["channel_start"]},
],
},
]
pm = self._run_trigger_event(
plugins, "channel_start", {"channel_name": "TEST"}
)
pm.run_action.assert_called_once_with(
"enabled-plugin",
"on_event",
{"event": "channel_start", "payload": {"channel_name": "TEST"}},
)
def test_action_without_matching_event_is_not_dispatched(self):
"""Sanity check: actions whose `events` list doesn't include the
fired event, or which have no `events` key at all, are skipped."""
plugins = [
{
"key": "plugin",
"name": "Plugin",
"enabled": True,
"actions": [
{"id": "on_event", "events": ["channel_stop"]},
{"id": "manual_button"}, # no events key
],
},
]
pm = self._run_trigger_event(
plugins, "channel_start", {"channel_name": "TEST"}
)
pm.run_action.assert_not_called()

View file

@ -99,7 +99,7 @@ def trigger_event(event_name, payload):
logger.debug(f"Checking {len(plugins)} plugins for event '{event_name}'")
for plugin in plugins:
if not plugin["enabled"]:
logger.debug(f"Skipping disabled plugin id={plugin.key} name={plugin.name}")
logger.debug(f"Skipping disabled plugin id={plugin['key']} name={plugin['name']}")
continue
logger.debug(json.dumps(plugin))