mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-29 01:41:47 +00:00
Webhook: Granular event support (closes #1240) Problem: WEBHOOK_URL accepted only one endpoint that fired for all events (signup only). No way to route events to different endpoints. Solution: - Added WEBHOOK_EVENTS constant (signup, oauth_signup, signin, signout, user_deleted, user_role_changed) - Added _parse_webhook_config() in webhook.py supporting three formats: 1. Legacy single URL (no admin change needed, backwards-compatible) 2. Newline/comma-separated JSON objects with optional events filter 3. Full JSON array format - Added post_webhook_event() dispatcher with event-scope filtering; URLs with events=null act as catch-all (preserves legacy behaviour) - Updated auths.py: both signup paths now call post_webhook_event with event='signup' - Updated oauth.py: OAuth signup uses post_webhook_event with event='oauth_signup' and includes provider name in payload Example extended config: [{"url": "https://slack.com/...", "events": ["signup"]}, {"url": "https://discord.com/..."}] Chat Bubbles: Fix widescreen layout overflow (closes #5975) Problem: In bubble mode, messages with YAML/JSON code blocks stretched the bubble to full viewport width. The inner wrapper had min-w-full applied unconditionally, forcing the flex container to full width. Fix: Changed min-w-full to conditional in UserMessage.svelte: - Bubble mode: min-w-0 (lets flex child shrink, bubble hugs content) - Classic mode: min-w-full (unchanged, full-width layout preserved) Tests: Added 15 unit tests in test/util/test_webhook.py covering _parse_webhook_config (formats, edge cases) and post_webhook_event (event filtering, catch-all, payload injection, backwards compat).
210 lines
8.9 KiB
Python
210 lines
8.9 KiB
Python
"""Unit tests for open_webui.utils.webhook
|
|
|
|
Tests cover:
|
|
- _parse_webhook_config: legacy single URL, multi-URL newline-separated,
|
|
JSON-object entries, mixed formats, empty/whitespace inputs.
|
|
- post_webhook_event: event-scope filtering, catch-all (None events), and
|
|
backwards-compat with legacy single-URL config strings.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import pytest
|
|
from unittest.mock import AsyncMock, patch, MagicMock
|
|
|
|
from open_webui.utils.webhook import (
|
|
_parse_webhook_config,
|
|
post_webhook_event,
|
|
post_webhook,
|
|
WEBHOOK_EVENTS,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _parse_webhook_config tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestParseWebhookConfig:
|
|
"""Tests for the _parse_webhook_config helper."""
|
|
|
|
def test_empty_string_returns_empty_list(self):
|
|
assert _parse_webhook_config('') == []
|
|
|
|
def test_whitespace_only_returns_empty_list(self):
|
|
assert _parse_webhook_config(' \n ') == []
|
|
|
|
def test_single_legacy_url(self):
|
|
url = 'https://hooks.slack.com/services/T00/B00/xxx'
|
|
result = _parse_webhook_config(url)
|
|
assert result == [{'url': url, 'events': None}]
|
|
|
|
def test_two_urls_newline_separated(self):
|
|
cfg = 'https://hooks.slack.com/xxx\nhttps://discord.com/api/webhooks/yyy'
|
|
result = _parse_webhook_config(cfg)
|
|
assert len(result) == 2
|
|
assert result[0]['url'] == 'https://hooks.slack.com/xxx'
|
|
assert result[0]['events'] is None
|
|
assert result[1]['url'] == 'https://discord.com/api/webhooks/yyy'
|
|
|
|
def test_two_urls_comma_separated(self):
|
|
cfg = 'https://a.example.com/hook,https://b.example.com/hook'
|
|
result = _parse_webhook_config(cfg)
|
|
assert len(result) == 2
|
|
|
|
def test_json_object_with_events(self):
|
|
entry = json.dumps({'url': 'https://example.com/hook', 'events': ['signup']})
|
|
result = _parse_webhook_config(entry)
|
|
assert result == [{'url': 'https://example.com/hook', 'events': {'signup'}}]
|
|
|
|
def test_json_array_with_mixed_entries(self):
|
|
cfg = json.dumps([
|
|
{'url': 'https://a.example.com/hook', 'events': ['signup', 'oauth_signup']},
|
|
{'url': 'https://b.example.com/hook', 'events': None},
|
|
'https://c.example.com/hook',
|
|
])
|
|
result = _parse_webhook_config(cfg)
|
|
assert len(result) == 3
|
|
assert result[0]['events'] == {'signup', 'oauth_signup'}
|
|
assert result[1]['events'] is None
|
|
assert result[2]['events'] is None
|
|
|
|
def test_json_object_null_events_becomes_none(self):
|
|
entry = json.dumps({'url': 'https://example.com/hook', 'events': None})
|
|
result = _parse_webhook_config(entry)
|
|
assert result[0]['events'] is None
|
|
|
|
def test_json_object_empty_events_list_becomes_none(self):
|
|
entry = json.dumps({'url': 'https://example.com/hook', 'events': []})
|
|
result = _parse_webhook_config(entry)
|
|
# Empty events list → treated as catch-all (None)
|
|
assert result[0]['events'] is None
|
|
|
|
def test_newline_separated_json_objects(self):
|
|
line1 = json.dumps({'url': 'https://a.example.com/hook', 'events': ['signup']})
|
|
line2 = json.dumps({'url': 'https://b.example.com/hook', 'events': ['signout']})
|
|
cfg = f'{line1}\n{line2}'
|
|
result = _parse_webhook_config(cfg)
|
|
assert len(result) == 2
|
|
assert result[0]['events'] == {'signup'}
|
|
assert result[1]['events'] == {'signout'}
|
|
|
|
def test_malformed_json_falls_back_to_plain_url_treatment(self):
|
|
# Not valid JSON → should fall back to treating as a plain URL string.
|
|
cfg = '{not valid json}'
|
|
result = _parse_webhook_config(cfg)
|
|
# The parser should not crash, but the entry won't have 'url' key from
|
|
# JSON parse, so it will be treated as a literal string URL.
|
|
# Since it starts with '{' and JSON parse fails, result may be empty or
|
|
# contain the raw string as URL depending on fallback behaviour.
|
|
# The important thing is it doesn't raise.
|
|
assert isinstance(result, list)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# post_webhook_event tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestPostWebhookEvent:
|
|
"""Tests for the post_webhook_event dispatcher."""
|
|
|
|
async def test_returns_empty_list_for_empty_config(self):
|
|
result = await post_webhook_event('TestApp', '', 'msg', {}, event='signup')
|
|
assert result == []
|
|
|
|
async def test_legacy_url_fires_for_any_event(self):
|
|
url = 'https://hooks.slack.com/services/xxx'
|
|
with patch('open_webui.utils.webhook.post_webhook', new_callable=AsyncMock) as mock_pw:
|
|
mock_pw.return_value = True
|
|
result = await post_webhook_event('App', url, 'msg', {}, event='signup')
|
|
assert result == [True]
|
|
mock_pw.assert_called_once()
|
|
|
|
async def test_legacy_url_fires_even_with_unknown_event(self):
|
|
url = 'https://discord.com/api/webhooks/xxx'
|
|
with patch('open_webui.utils.webhook.post_webhook', new_callable=AsyncMock) as mock_pw:
|
|
mock_pw.return_value = True
|
|
result = await post_webhook_event('App', url, 'msg', {}, event='some_future_event')
|
|
assert result == [True]
|
|
|
|
async def test_scoped_url_fires_only_for_matching_event(self):
|
|
cfg = json.dumps([
|
|
{'url': 'https://a.example.com', 'events': ['signup']},
|
|
{'url': 'https://b.example.com', 'events': ['signout']},
|
|
])
|
|
with patch('open_webui.utils.webhook.post_webhook', new_callable=AsyncMock) as mock_pw:
|
|
mock_pw.return_value = True
|
|
result = await post_webhook_event('App', cfg, 'msg', {}, event='signup')
|
|
|
|
# Only URL 'a' should have been called (event='signup')
|
|
assert len(result) == 1
|
|
assert result == [True]
|
|
call_args = mock_pw.call_args_list
|
|
assert len(call_args) == 1
|
|
assert call_args[0].args[1] == 'https://a.example.com'
|
|
|
|
async def test_scoped_url_skipped_for_non_matching_event(self):
|
|
cfg = json.dumps([
|
|
{'url': 'https://signup-only.example.com', 'events': ['signup']},
|
|
])
|
|
with patch('open_webui.utils.webhook.post_webhook', new_callable=AsyncMock) as mock_pw:
|
|
mock_pw.return_value = True
|
|
result = await post_webhook_event('App', cfg, 'msg', {}, event='signout')
|
|
|
|
assert result == []
|
|
mock_pw.assert_not_called()
|
|
|
|
async def test_mixed_config_fires_scoped_and_catchall(self):
|
|
cfg = json.dumps([
|
|
{'url': 'https://signup-only.example.com', 'events': ['signup']},
|
|
{'url': 'https://all-events.example.com', 'events': None},
|
|
])
|
|
with patch('open_webui.utils.webhook.post_webhook', new_callable=AsyncMock) as mock_pw:
|
|
mock_pw.return_value = True
|
|
result = await post_webhook_event('App', cfg, 'msg', {}, event='signup')
|
|
|
|
# Both should fire: signup-only matches event; catchall always fires
|
|
assert len(result) == 2
|
|
|
|
async def test_event_name_injected_into_payload(self):
|
|
url = 'https://example.com/hook'
|
|
captured_data = {}
|
|
|
|
async def capture_webhook(name, _url, message, data):
|
|
captured_data.update(data)
|
|
return True
|
|
|
|
with patch('open_webui.utils.webhook.post_webhook', side_effect=capture_webhook):
|
|
await post_webhook_event('App', url, 'msg', {'action': 'signup'}, event='signup')
|
|
|
|
assert captured_data.get('event') == 'signup'
|
|
|
|
async def test_no_event_arg_sends_to_all_configured_urls(self):
|
|
cfg = json.dumps([
|
|
{'url': 'https://a.example.com', 'events': ['signup']},
|
|
{'url': 'https://b.example.com', 'events': None},
|
|
])
|
|
with patch('open_webui.utils.webhook.post_webhook', new_callable=AsyncMock) as mock_pw:
|
|
mock_pw.return_value = True
|
|
# event=None → backwards-compat, should be sent to ALL (no filtering)
|
|
result = await post_webhook_event('App', cfg, 'msg', {}, event=None)
|
|
|
|
# event=None: scoped URL 'a' has events=['signup'] which is not None, and
|
|
# event param is None, so the check `allowed is not None and event not in allowed`
|
|
# → None not in {'signup'} → True → skip 'a'.
|
|
# URL 'b' has events=None → catch-all → fires.
|
|
# This is the intended behaviour: None event only reaches catch-all webhooks.
|
|
assert len(result) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WEBHOOK_EVENTS constant sanity check
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWebhookEventsConstant:
|
|
def test_expected_events_present(self):
|
|
expected = {'signup', 'oauth_signup', 'signin', 'signout', 'user_deleted', 'user_role_changed'}
|
|
assert expected.issubset(WEBHOOK_EVENTS)
|