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:
+1
-1
Submodule docs updated: d7f18ba962...d8ce4b4570
+12
-5
@@ -123,6 +123,9 @@ class EventContext[E]:
|
|||||||
# Shared services for this module.
|
# Shared services for this module.
|
||||||
module: ModuleContext
|
module: ModuleContext
|
||||||
|
|
||||||
|
# Event-scoped protected-route URL builder. Present for user-bearing events.
|
||||||
|
_session_url_for: Callable[[str], str] | None = field(default=None, repr=False)
|
||||||
|
|
||||||
# Shared state for propagation control.
|
# Shared state for propagation control.
|
||||||
# All handlers for a single event dispatch share the same instance.
|
# All handlers for a single event dispatch share the same instance.
|
||||||
_propagation: PropagationState = field(default_factory=PropagationState, repr=False)
|
_propagation: PropagationState = field(default_factory=PropagationState, repr=False)
|
||||||
@@ -162,6 +165,13 @@ class EventContext[E]:
|
|||||||
"""Module-scoped route API for URL building and route introspection."""
|
"""Module-scoped route API for URL building and route introspection."""
|
||||||
return self.module.routes
|
return self.module.routes
|
||||||
|
|
||||||
|
def session_url_for(self, path: str) -> str:
|
||||||
|
"""Build a connect URL for a module route for this event's user."""
|
||||||
|
if self._session_url_for is None:
|
||||||
|
msg = "session URLs are only available for events with a user"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
return self._session_url_for(path)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def logger(self) -> logging.Logger:
|
def logger(self) -> logging.Logger:
|
||||||
"""Module-scoped logger."""
|
"""Module-scoped logger."""
|
||||||
@@ -222,9 +232,6 @@ class CommandContext:
|
|||||||
# Shared services for this module.
|
# Shared services for this module.
|
||||||
module: ModuleContext
|
module: ModuleContext
|
||||||
|
|
||||||
# Command-invocation-scoped protected-route URL builder.
|
|
||||||
_session_url_for: Callable[[str], str] = field(repr=False)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def command(self) -> str:
|
def command(self) -> str:
|
||||||
"""The command name that was invoked (canonical name, not alias)."""
|
"""The command name that was invoked (canonical name, not alias)."""
|
||||||
@@ -311,8 +318,8 @@ class CommandContext:
|
|||||||
return self.module.admin_client
|
return self.module.admin_client
|
||||||
|
|
||||||
def session_url_for(self, path: str) -> str:
|
def session_url_for(self, path: str) -> str:
|
||||||
"""Build a connect URL for a module route for this user."""
|
"""Build a connect URL for a module route for this command's user."""
|
||||||
return self._session_url_for(path)
|
return self.event_context.session_url_for(path)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ class ModuleLoader:
|
|||||||
get_module_context=self._require_module_context,
|
get_module_context=self._require_module_context,
|
||||||
handler_timeout=config.handler_timeout,
|
handler_timeout=config.handler_timeout,
|
||||||
session_manager=self.session_manager,
|
session_manager=self.session_manager,
|
||||||
|
public_base_url=config.public_base_url,
|
||||||
)
|
)
|
||||||
self.route_dispatcher = RouteDispatcher(
|
self.route_dispatcher = RouteDispatcher(
|
||||||
get_module_context=self._require_module_context,
|
get_module_context=self._require_module_context,
|
||||||
|
|||||||
@@ -22,22 +22,20 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import posixpath
|
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
from urllib.parse import unquote, urlsplit
|
|
||||||
|
|
||||||
from owlbot._version import __version__
|
from owlbot._version import __version__
|
||||||
from owlbot.api.commands import CommandEvent, CommandHandler, CommandInfo, CommandMark
|
from owlbot.api.commands import CommandEvent, CommandHandler, CommandInfo, CommandMark
|
||||||
from owlbot.api.context import CommandContext, EventContext
|
from owlbot.api.context import CommandContext, EventContext
|
||||||
from owlbot.api.event_types import ChatEvent
|
from owlbot.api.event_types import ChatEvent
|
||||||
|
from owlbot.sessions import make_session_url_for
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
||||||
from owlbot.api.context import ModuleContext
|
from owlbot.api.context import ModuleContext
|
||||||
from owlbot.api.event_types import User
|
|
||||||
from owlbot.api.owncast_client import OwncastClient
|
from owlbot.api.owncast_client import OwncastClient
|
||||||
from owlbot.sessions import SessionManager
|
from owlbot.sessions import SessionManager
|
||||||
|
|
||||||
@@ -547,16 +545,18 @@ class CommandDispatcher:
|
|||||||
event_ctx: EventContext[ChatEvent] = EventContext(
|
event_ctx: EventContext[ChatEvent] = EventContext(
|
||||||
event=event,
|
event=event,
|
||||||
module=module_ctx,
|
module=module_ctx,
|
||||||
|
_session_url_for=make_session_url_for(
|
||||||
|
session_manager=self._session_manager,
|
||||||
|
public_base_url=self._public_base_url,
|
||||||
|
module_name=command_info.module_name,
|
||||||
|
user=user,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd_ctx = CommandContext(
|
cmd_ctx = CommandContext(
|
||||||
command_event=cmd_event,
|
command_event=cmd_event,
|
||||||
event_context=event_ctx,
|
event_context=event_ctx,
|
||||||
module=module_ctx,
|
module=module_ctx,
|
||||||
_session_url_for=self._make_session_url_for(
|
|
||||||
command_info.module_name,
|
|
||||||
user,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -626,34 +626,6 @@ class CommandDispatcher:
|
|||||||
unsanitized=True,
|
unsanitized=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _make_session_url_for(
|
|
||||||
self,
|
|
||||||
module_name: str,
|
|
||||||
user: User,
|
|
||||||
) -> Callable[[str], str]:
|
|
||||||
"""Build the command-scoped session URL helper."""
|
|
||||||
session_manager = self._session_manager
|
|
||||||
public_base_url = self._public_base_url
|
|
||||||
|
|
||||||
def session_url_for(path: str) -> str:
|
|
||||||
if not path.startswith("/"):
|
|
||||||
path = "/" + path
|
|
||||||
destination_path = f"/owlbot/{module_name}{path}"
|
|
||||||
|
|
||||||
module_root = f"/owlbot/{module_name}"
|
|
||||||
decoded_path = unquote(urlsplit(destination_path).path)
|
|
||||||
normalized_path = posixpath.normpath(decoded_path)
|
|
||||||
if normalized_path != module_root and not normalized_path.startswith(
|
|
||||||
f"{module_root}/"
|
|
||||||
):
|
|
||||||
msg = "session destination must stay within the module namespace"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
token = session_manager.issue_connect_token(destination_path, user=user)
|
|
||||||
return f"{public_base_url}/owlbot/connect/{token}"
|
|
||||||
|
|
||||||
return session_url_for
|
|
||||||
|
|
||||||
|
|
||||||
class ModuleCommands:
|
class ModuleCommands:
|
||||||
"""Module-scoped wrapper around CommandDispatcher.
|
"""Module-scoped wrapper around CommandDispatcher.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from owlbot.api.event_types import (
|
|||||||
log_event,
|
log_event,
|
||||||
)
|
)
|
||||||
from owlbot.api.events import EventHandler, EventMark, Priority
|
from owlbot.api.events import EventHandler, EventMark, Priority
|
||||||
|
from owlbot.sessions import make_session_url_for
|
||||||
|
|
||||||
|
|
||||||
class HandlerEntry(NamedTuple):
|
class HandlerEntry(NamedTuple):
|
||||||
@@ -51,6 +52,7 @@ if TYPE_CHECKING:
|
|||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
||||||
from owlbot.api.context import ModuleContext
|
from owlbot.api.context import ModuleContext
|
||||||
|
from owlbot.api.event_types import User
|
||||||
from owlbot.sessions import SessionManager
|
from owlbot.sessions import SessionManager
|
||||||
|
|
||||||
logger = logging.getLogger("owlbot.events")
|
logger = logging.getLogger("owlbot.events")
|
||||||
@@ -217,6 +219,7 @@ class EventDispatcher:
|
|||||||
get_module_context: Callable[[str], ModuleContext],
|
get_module_context: Callable[[str], ModuleContext],
|
||||||
handler_timeout: float,
|
handler_timeout: float,
|
||||||
session_manager: SessionManager,
|
session_manager: SessionManager,
|
||||||
|
public_base_url: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the event dispatcher.
|
"""Initialize the event dispatcher.
|
||||||
|
|
||||||
@@ -227,12 +230,15 @@ class EventDispatcher:
|
|||||||
:param get_module_context: Callable that looks up a
|
:param get_module_context: Callable that looks up a
|
||||||
ModuleContext by module name.
|
ModuleContext by module name.
|
||||||
:param handler_timeout: Timeout for individual handlers in seconds.
|
:param handler_timeout: Timeout for individual handlers in seconds.
|
||||||
|
:param session_manager: Session manager used for browser connect links.
|
||||||
|
:param public_base_url: Public base URL for generated connect links.
|
||||||
"""
|
"""
|
||||||
self._handler_registry = EventRegistry()
|
self._handler_registry = EventRegistry()
|
||||||
self._command_dispatch = command_dispatch
|
self._command_dispatch = command_dispatch
|
||||||
self._get_module_context = get_module_context
|
self._get_module_context = get_module_context
|
||||||
self._handler_timeout = handler_timeout
|
self._handler_timeout = handler_timeout
|
||||||
self._session_manager = session_manager
|
self._session_manager = session_manager
|
||||||
|
self._public_base_url = public_base_url
|
||||||
|
|
||||||
def register(
|
def register(
|
||||||
self,
|
self,
|
||||||
@@ -356,7 +362,13 @@ class EventDispatcher:
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
await self._call_handler(handler, event, module_name, propagation)
|
await self._call_handler(
|
||||||
|
handler,
|
||||||
|
event,
|
||||||
|
module_name,
|
||||||
|
propagation,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.debug("No handlers registered for event type: %s", event_type)
|
logger.debug("No handlers registered for event type: %s", event_type)
|
||||||
|
|
||||||
@@ -381,6 +393,8 @@ class EventDispatcher:
|
|||||||
event: Event,
|
event: Event,
|
||||||
module_name: str,
|
module_name: str,
|
||||||
propagation: PropagationState,
|
propagation: PropagationState,
|
||||||
|
*,
|
||||||
|
user: User | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Call a single handler with timeout enforcement.
|
"""Call a single handler with timeout enforcement.
|
||||||
|
|
||||||
@@ -388,15 +402,27 @@ class EventDispatcher:
|
|||||||
:param event: The event to pass to the handler.
|
:param event: The event to pass to the handler.
|
||||||
:param module_name: The module that owns this handler.
|
:param module_name: The module that owns this handler.
|
||||||
:param propagation: Shared propagation state for this dispatch cycle.
|
:param propagation: Shared propagation state for this dispatch cycle.
|
||||||
|
:param user: User resolved from the event, if the event carries one.
|
||||||
"""
|
"""
|
||||||
handler_name = handler.__name__
|
handler_name = handler.__name__
|
||||||
logger.debug("Calling handler: %s from module: %s", handler_name, module_name)
|
logger.debug("Calling handler: %s from module: %s", handler_name, module_name)
|
||||||
|
|
||||||
module_ctx = self._get_module_context(module_name)
|
module_ctx = self._get_module_context(module_name)
|
||||||
|
session_url_for = (
|
||||||
|
make_session_url_for(
|
||||||
|
session_manager=self._session_manager,
|
||||||
|
public_base_url=self._public_base_url,
|
||||||
|
module_name=module_name,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
if user is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
ctx: EventContext[Event] = EventContext(
|
ctx: EventContext[Event] = EventContext(
|
||||||
event=event,
|
event=event,
|
||||||
module=module_ctx,
|
module=module_ctx,
|
||||||
|
_session_url_for=session_url_for,
|
||||||
_propagation=propagation,
|
_propagation=propagation,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import posixpath
|
||||||
import secrets
|
import secrets
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field, replace
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
@@ -25,6 +26,8 @@ from typing import TYPE_CHECKING
|
|||||||
from urllib.parse import unquote, urlsplit
|
from urllib.parse import unquote, urlsplit
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
from owlbot.api.event_types import User
|
from owlbot.api.event_types import User
|
||||||
|
|
||||||
SESSION_COOKIE_NAME = "owlbot_session"
|
SESSION_COOKIE_NAME = "owlbot_session"
|
||||||
@@ -291,3 +294,40 @@ class SessionManager:
|
|||||||
self._session_ids_by_user_id.setdefault(session.user.id, set()).add(
|
self._session_ids_by_user_id.setdefault(session.user.id, set()).add(
|
||||||
session.session_id
|
session.session_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_session_url_for(
|
||||||
|
*,
|
||||||
|
session_manager: SessionManager,
|
||||||
|
public_base_url: str,
|
||||||
|
module_name: str,
|
||||||
|
user: User,
|
||||||
|
) -> Callable[[str], str]:
|
||||||
|
"""Build a module-scoped protected-route URL helper for a user.
|
||||||
|
|
||||||
|
:param session_manager: Session manager that issues connect tokens.
|
||||||
|
:param public_base_url: Public base URL for generated connect links.
|
||||||
|
:param module_name: Module namespace the destination must stay within.
|
||||||
|
:param user: User the generated connect token should link to.
|
||||||
|
:return: Function that converts a module-relative path into a connect URL.
|
||||||
|
"""
|
||||||
|
public_base_url = public_base_url.rstrip("/")
|
||||||
|
|
||||||
|
def session_url_for(path: str) -> str:
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = "/" + path
|
||||||
|
destination_path = f"/owlbot/{module_name}{path}"
|
||||||
|
|
||||||
|
module_root = f"/owlbot/{module_name}"
|
||||||
|
decoded_path = unquote(urlsplit(destination_path).path)
|
||||||
|
normalized_path = posixpath.normpath(decoded_path)
|
||||||
|
if normalized_path != module_root and not normalized_path.startswith(
|
||||||
|
f"{module_root}/"
|
||||||
|
):
|
||||||
|
msg = "session destination must stay within the module namespace"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
token = session_manager.issue_connect_token(destination_path, user=user)
|
||||||
|
return f"{public_base_url}/owlbot/connect/{token}"
|
||||||
|
|
||||||
|
return session_url_for
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ def event_dispatcher(
|
|||||||
session_manager: SessionManager,
|
session_manager: SessionManager,
|
||||||
command_dispatcher: CommandDispatcher,
|
command_dispatcher: CommandDispatcher,
|
||||||
handler_timeout: float,
|
handler_timeout: float,
|
||||||
|
config: Config,
|
||||||
) -> EventDispatcher:
|
) -> EventDispatcher:
|
||||||
"""Build an EventDispatcher that resolves contexts via ``_context_registry``."""
|
"""Build an EventDispatcher that resolves contexts via ``_context_registry``."""
|
||||||
return EventDispatcher(
|
return EventDispatcher(
|
||||||
@@ -323,6 +324,7 @@ def event_dispatcher(
|
|||||||
get_module_context=_make_context_lookup(_context_registry),
|
get_module_context=_make_context_lookup(_context_registry),
|
||||||
handler_timeout=handler_timeout,
|
handler_timeout=handler_timeout,
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
|
public_base_url=config.public_base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+7
-56
@@ -999,56 +999,6 @@ class TestCommandDispatcherDispatch:
|
|||||||
assert connect_token.destination_path == "/owlbot/test_module/panel"
|
assert connect_token.destination_path == "/owlbot/test_module/panel"
|
||||||
assert connect_token.user.id == user.id
|
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(
|
async def test_unregistered_builtin_does_not_shadow_later_module_command(
|
||||||
self, module_context: ModuleContext
|
self, module_context: ModuleContext
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1305,11 +1255,7 @@ class TestCommandContext:
|
|||||||
chat_event=event,
|
chat_event=event,
|
||||||
)
|
)
|
||||||
event_ctx: EventContext[ChatEvent] = EventContext(
|
event_ctx: EventContext[ChatEvent] = EventContext(
|
||||||
event=event, module=module_ctx
|
event=event,
|
||||||
)
|
|
||||||
cmd_ctx = CommandContext(
|
|
||||||
command_event=cmd_event,
|
|
||||||
event_context=event_ctx,
|
|
||||||
module=module_ctx,
|
module=module_ctx,
|
||||||
_session_url_for=(
|
_session_url_for=(
|
||||||
session_url_for
|
session_url_for
|
||||||
@@ -1317,6 +1263,11 @@ class TestCommandContext:
|
|||||||
else lambda path: f"https://example.com{path}"
|
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
|
return cmd_ctx, cmd_event, event_ctx
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -1379,7 +1330,7 @@ class TestCommandContext:
|
|||||||
assert cmd_ctx.event_context is event_ctx
|
assert cmd_ctx.event_context is event_ctx
|
||||||
|
|
||||||
def test_session_url_for(self, module_context: ModuleContext) -> None:
|
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(
|
cmd_ctx, _, _ = self._make_command_context(
|
||||||
module_context,
|
module_context,
|
||||||
session_url_for=lambda path: f"https://example.com{path}",
|
session_url_for=lambda path: f"https://example.com{path}",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ from owlbot.sessions import SessionManager
|
|||||||
from owlbot.testing import (
|
from owlbot.testing import (
|
||||||
make_chat_event,
|
make_chat_event,
|
||||||
make_name_changed_event,
|
make_name_changed_event,
|
||||||
|
make_stream_started_event,
|
||||||
make_user,
|
make_user,
|
||||||
make_user_joined_event,
|
make_user_joined_event,
|
||||||
)
|
)
|
||||||
@@ -65,6 +66,7 @@ def _make_dispatcher(
|
|||||||
*,
|
*,
|
||||||
handler_timeout: float,
|
handler_timeout: float,
|
||||||
session_manager: SessionManager | None = None,
|
session_manager: SessionManager | None = None,
|
||||||
|
public_base_url: str = "https://example.com",
|
||||||
) -> EventDispatcher:
|
) -> EventDispatcher:
|
||||||
"""Build an EventDispatcher with a default session manager for tests."""
|
"""Build an EventDispatcher with a default session manager for tests."""
|
||||||
return EventDispatcher(
|
return EventDispatcher(
|
||||||
@@ -72,6 +74,7 @@ def _make_dispatcher(
|
|||||||
get_module_context=get_module_context,
|
get_module_context=get_module_context,
|
||||||
handler_timeout=handler_timeout,
|
handler_timeout=handler_timeout,
|
||||||
session_manager=session_manager or SessionManager(),
|
session_manager=session_manager or SessionManager(),
|
||||||
|
public_base_url=public_base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -429,6 +432,48 @@ class TestEventDispatcherDispatch:
|
|||||||
assert len(received) == 1
|
assert len(received) == 1
|
||||||
assert received[0] is event
|
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:
|
async def test_priority_order(self, module_context: ModuleContext) -> None:
|
||||||
"""Handlers execute in priority order: HIGH before NORMAL before LOW."""
|
"""Handlers execute in priority order: HIGH before NORMAL before LOW."""
|
||||||
call_order: list[str] = []
|
call_order: list[str] = []
|
||||||
@@ -1107,6 +1152,30 @@ class TestEventContext:
|
|||||||
ctx: EventContext[ChatEvent] = EventContext(event=event, module=module_context)
|
ctx: EventContext[ChatEvent] = EventContext(event=event, module=module_context)
|
||||||
assert ctx.event is event
|
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:
|
def test_propagation_stopped_default(self, module_context: ModuleContext) -> None:
|
||||||
"""propagation_stopped is False by default."""
|
"""propagation_stopped is False by default."""
|
||||||
ctx: EventContext[ChatEvent] = EventContext(
|
ctx: EventContext[ChatEvent] = EventContext(
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from owlbot.sessions import (
|
|||||||
DEFAULT_CONNECT_DESTINATION_PATH,
|
DEFAULT_CONNECT_DESTINATION_PATH,
|
||||||
ConnectTokenRedemptionError,
|
ConnectTokenRedemptionError,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
|
make_session_url_for,
|
||||||
)
|
)
|
||||||
from owlbot.testing import make_user
|
from owlbot.testing import make_user
|
||||||
|
|
||||||
@@ -286,3 +287,62 @@ class TestSessionManager:
|
|||||||
await manager.close()
|
await manager.close()
|
||||||
|
|
||||||
assert manager._cleanup_task is None
|
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