Centralized session URL generation for commands and events.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 2m45s
CI / Tests (Python 3.13) (push) Successful in 2m53s
CI / Tests (Python 3.14) (push) Successful in 2m39s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 2m45s
CI / Tests (Python 3.13) (push) Successful in 2m53s
CI / Tests (Python 3.14) (push) Successful in 2m39s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
This commit is contained in:
+7
-56
@@ -999,56 +999,6 @@ class TestCommandDispatcherDispatch:
|
||||
assert connect_token.destination_path == "/owlbot/test_module/panel"
|
||||
assert connect_token.user.id == user.id
|
||||
|
||||
def test_command_session_url_for_accepts_module_relative_path(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""Command session URLs add the module namespace to relative paths."""
|
||||
session_manager = SessionManager()
|
||||
dispatcher, _, _ = self._make_dispatcher(
|
||||
module_context,
|
||||
session_manager=session_manager,
|
||||
public_base_url="https://example.com",
|
||||
)
|
||||
session_url_for = dispatcher._make_session_url_for(
|
||||
module_context.module_name,
|
||||
make_user(id="alice"),
|
||||
)
|
||||
|
||||
url = session_url_for("panel")
|
||||
token = url.rsplit("/", 1)[-1]
|
||||
connect_token = session_manager.get_connect_token(token)
|
||||
|
||||
assert connect_token is not None
|
||||
assert connect_token.destination_path == "/owlbot/test_module/panel"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
pytest.param("../panel", id="relative-parent"),
|
||||
pytest.param("/../panel", id="absolute-parent"),
|
||||
pytest.param("/../../admin", id="multi-parent"),
|
||||
pytest.param("nested/../../other", id="nested-parent"),
|
||||
pytest.param("/%2e%2e/panel", id="encoded-parent"),
|
||||
],
|
||||
)
|
||||
def test_command_session_url_for_rejects_paths_outside_module_namespace(
|
||||
self, module_context: ModuleContext, path: str
|
||||
) -> None:
|
||||
"""Module-provided session paths cannot escape their namespace."""
|
||||
session_manager = SessionManager()
|
||||
dispatcher, _, _ = self._make_dispatcher(
|
||||
module_context,
|
||||
session_manager=session_manager,
|
||||
public_base_url="https://example.com",
|
||||
)
|
||||
session_url_for = dispatcher._make_session_url_for(
|
||||
module_context.module_name,
|
||||
make_user(id="alice"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="module namespace"):
|
||||
session_url_for(path)
|
||||
|
||||
async def test_unregistered_builtin_does_not_shadow_later_module_command(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
@@ -1305,11 +1255,7 @@ class TestCommandContext:
|
||||
chat_event=event,
|
||||
)
|
||||
event_ctx: EventContext[ChatEvent] = EventContext(
|
||||
event=event, module=module_ctx
|
||||
)
|
||||
cmd_ctx = CommandContext(
|
||||
command_event=cmd_event,
|
||||
event_context=event_ctx,
|
||||
event=event,
|
||||
module=module_ctx,
|
||||
_session_url_for=(
|
||||
session_url_for
|
||||
@@ -1317,6 +1263,11 @@ class TestCommandContext:
|
||||
else lambda path: f"https://example.com{path}"
|
||||
),
|
||||
)
|
||||
cmd_ctx = CommandContext(
|
||||
command_event=cmd_event,
|
||||
event_context=event_ctx,
|
||||
module=module_ctx,
|
||||
)
|
||||
return cmd_ctx, cmd_event, event_ctx
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -1379,7 +1330,7 @@ class TestCommandContext:
|
||||
assert cmd_ctx.event_context is event_ctx
|
||||
|
||||
def test_session_url_for(self, module_context: ModuleContext) -> None:
|
||||
"""ctx.session_url_for() delegates to the command-scoped URL builder."""
|
||||
"""ctx.session_url_for() delegates to the underlying event context."""
|
||||
cmd_ctx, _, _ = self._make_command_context(
|
||||
module_context,
|
||||
session_url_for=lambda path: f"https://example.com{path}",
|
||||
|
||||
@@ -45,6 +45,7 @@ from owlbot.sessions import SessionManager
|
||||
from owlbot.testing import (
|
||||
make_chat_event,
|
||||
make_name_changed_event,
|
||||
make_stream_started_event,
|
||||
make_user,
|
||||
make_user_joined_event,
|
||||
)
|
||||
@@ -65,6 +66,7 @@ def _make_dispatcher(
|
||||
*,
|
||||
handler_timeout: float,
|
||||
session_manager: SessionManager | None = None,
|
||||
public_base_url: str = "https://example.com",
|
||||
) -> EventDispatcher:
|
||||
"""Build an EventDispatcher with a default session manager for tests."""
|
||||
return EventDispatcher(
|
||||
@@ -72,6 +74,7 @@ def _make_dispatcher(
|
||||
get_module_context=get_module_context,
|
||||
handler_timeout=handler_timeout,
|
||||
session_manager=session_manager or SessionManager(),
|
||||
public_base_url=public_base_url,
|
||||
)
|
||||
|
||||
|
||||
@@ -429,6 +432,48 @@ class TestEventDispatcherDispatch:
|
||||
assert len(received) == 1
|
||||
assert received[0] is event
|
||||
|
||||
async def test_user_event_context_can_issue_session_url(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""Event contexts for user-bearing events can issue protected route URLs."""
|
||||
session_manager = SessionManager()
|
||||
captured_url = ""
|
||||
user = make_user(id="user-123", is_authenticated=True)
|
||||
|
||||
async def handler(ctx: EventContext[Any]) -> None:
|
||||
nonlocal captured_url
|
||||
captured_url = ctx.session_url_for("/panel")
|
||||
|
||||
async def command_dispatch(event: ChatEvent) -> None:
|
||||
pass
|
||||
|
||||
dispatcher = _make_dispatcher(
|
||||
command_dispatch=command_dispatch,
|
||||
get_module_context=lambda _name: module_context,
|
||||
handler_timeout=5.0,
|
||||
session_manager=session_manager,
|
||||
public_base_url="https://example.com",
|
||||
)
|
||||
dispatcher.register(
|
||||
handler, (EventType.USER_JOINED,), module_context.module_name
|
||||
)
|
||||
|
||||
await dispatcher.dispatch(
|
||||
EventType.USER_JOINED,
|
||||
make_user_joined_event(
|
||||
user=user,
|
||||
client_id=1,
|
||||
event_id="evt-001",
|
||||
),
|
||||
)
|
||||
|
||||
token = captured_url.rsplit("/", 1)[-1]
|
||||
connect_token = session_manager.get_connect_token(token)
|
||||
assert captured_url.startswith("https://example.com/owlbot/connect/")
|
||||
assert connect_token is not None
|
||||
assert connect_token.destination_path == "/owlbot/test_module/panel"
|
||||
assert connect_token.user.id == user.id
|
||||
|
||||
async def test_priority_order(self, module_context: ModuleContext) -> None:
|
||||
"""Handlers execute in priority order: HIGH before NORMAL before LOW."""
|
||||
call_order: list[str] = []
|
||||
@@ -1107,6 +1152,30 @@ class TestEventContext:
|
||||
ctx: EventContext[ChatEvent] = EventContext(event=event, module=module_context)
|
||||
assert ctx.event is event
|
||||
|
||||
def test_session_url_for_delegates_to_event_builder(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""ctx.session_url_for() delegates to the event-scoped URL builder."""
|
||||
ctx: EventContext[ChatEvent] = EventContext(
|
||||
event=make_chat_event(),
|
||||
module=module_context,
|
||||
_session_url_for=lambda path: f"https://example.com{path}",
|
||||
)
|
||||
|
||||
assert ctx.session_url_for("/panel") == "https://example.com/panel"
|
||||
|
||||
def test_session_url_for_without_user_event_raises(
|
||||
self, module_context: ModuleContext
|
||||
) -> None:
|
||||
"""Events without a user do not have a session URL builder."""
|
||||
ctx: EventContext[Any] = EventContext(
|
||||
event=make_stream_started_event(),
|
||||
module=module_context,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="events with a user"):
|
||||
ctx.session_url_for("/panel")
|
||||
|
||||
def test_propagation_stopped_default(self, module_context: ModuleContext) -> None:
|
||||
"""propagation_stopped is False by default."""
|
||||
ctx: EventContext[ChatEvent] = EventContext(
|
||||
|
||||
@@ -24,6 +24,7 @@ from owlbot.sessions import (
|
||||
DEFAULT_CONNECT_DESTINATION_PATH,
|
||||
ConnectTokenRedemptionError,
|
||||
SessionManager,
|
||||
make_session_url_for,
|
||||
)
|
||||
from owlbot.testing import make_user
|
||||
|
||||
@@ -286,3 +287,62 @@ class TestSessionManager:
|
||||
await manager.close()
|
||||
|
||||
assert manager._cleanup_task is None
|
||||
|
||||
|
||||
class TestMakeSessionUrlFor:
|
||||
"""Tests for the shared protected-route URL builder."""
|
||||
|
||||
def test_accepts_module_relative_path(self) -> None:
|
||||
"""Session URLs add the module namespace to relative paths."""
|
||||
session_manager = SessionManager()
|
||||
session_url_for = make_session_url_for(
|
||||
session_manager=session_manager,
|
||||
public_base_url="https://example.com",
|
||||
module_name="test_module",
|
||||
user=make_user(id="alice"),
|
||||
)
|
||||
|
||||
url = session_url_for("panel")
|
||||
token = url.rsplit("/", 1)[-1]
|
||||
connect_token = session_manager.get_connect_token(token)
|
||||
|
||||
assert url.startswith("https://example.com/owlbot/connect/")
|
||||
assert connect_token is not None
|
||||
assert connect_token.destination_path == "/owlbot/test_module/panel"
|
||||
|
||||
def test_strips_public_base_url_trailing_slash(self) -> None:
|
||||
"""Generated connect URLs do not include a double slash before /owlbot."""
|
||||
session_manager = SessionManager()
|
||||
session_url_for = make_session_url_for(
|
||||
session_manager=session_manager,
|
||||
public_base_url="https://example.com/",
|
||||
module_name="test_module",
|
||||
user=make_user(id="alice"),
|
||||
)
|
||||
|
||||
assert session_url_for("/panel").startswith(
|
||||
"https://example.com/owlbot/connect/"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
pytest.param("../panel", id="relative-parent"),
|
||||
pytest.param("/../panel", id="absolute-parent"),
|
||||
pytest.param("/../../admin", id="multi-parent"),
|
||||
pytest.param("nested/../../other", id="nested-parent"),
|
||||
pytest.param("/%2e%2e/panel", id="encoded-parent"),
|
||||
],
|
||||
)
|
||||
def test_rejects_paths_outside_module_namespace(self, path: str) -> None:
|
||||
"""Module-provided session paths cannot escape their namespace."""
|
||||
session_manager = SessionManager()
|
||||
session_url_for = make_session_url_for(
|
||||
session_manager=session_manager,
|
||||
public_base_url="https://example.com",
|
||||
module_name="test_module",
|
||||
user=make_user(id="alice"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="module namespace"):
|
||||
session_url_for(path)
|
||||
|
||||
Reference in New Issue
Block a user