Added browser sessions and protected route support.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 2m48s
CI / Tests (Python 3.13) (push) Successful in 2m49s
CI / Tests (Python 3.14) (push) Successful in 2m43s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s

This commit is contained in:
2026-05-04 21:01:57 -04:00
parent f22d73857b
commit 5443aa86ce
22 changed files with 2057 additions and 57 deletions
+277
View File
@@ -37,9 +37,12 @@ from owlbot.registries.commands import (
CommandRegistry,
ModuleCommands,
)
from owlbot.sessions import SessionManager
from owlbot.testing import RecordingOwncastClient, make_chat_event, make_user
if TYPE_CHECKING:
from collections.abc import Callable
from owlbot.api.context import ModuleContext
from owlbot.api.event_types import ChatEvent
from owlbot.api.storage import ModuleStorage
@@ -444,15 +447,27 @@ class TestCommandDispatcherDispatch:
owncast_client: RecordingOwncastClient | None = None,
handler_timeout: float = 5.0,
command_prefix: str = "!",
session_manager: SessionManager | None = None,
public_base_url: str | None = None,
) -> tuple[CommandDispatcher, ModuleContext, RecordingOwncastClient]:
"""Build a CommandDispatcher wired to the given ModuleContext."""
stub = owncast_client or RecordingOwncastClient()
resolved_session_manager = (
session_manager if session_manager is not None else SessionManager()
)
resolved_public_base_url = (
public_base_url
if public_base_url is not None
else module_context.config.public_base_url
)
dispatcher = CommandDispatcher(
get_module_context=lambda _name: module_context,
owncast_client=stub,
handler_timeout=handler_timeout,
loaded_modules=set(),
command_prefix=command_prefix,
session_manager=resolved_session_manager,
public_base_url=resolved_public_base_url,
)
return dispatcher, module_context, stub
@@ -862,6 +877,250 @@ class TestCommandDispatcherDispatch:
for r in caplog.records
)
async def test_builtin_connect_sends_private_connect_link(
self, module_context: ModuleContext
) -> None:
"""!connect hides the chat line and whispers a one-time link."""
stub = RecordingOwncastClient()
session_manager = SessionManager()
dispatcher = CommandDispatcher(
get_module_context=lambda _name: module_context,
owncast_client=stub,
handler_timeout=5.0,
loaded_modules=set(),
session_manager=session_manager,
public_base_url=module_context.config.public_base_url,
)
user = make_user(id="user-123", display_name="Alice")
await dispatcher.dispatch(
make_chat_event(client_id=42, raw_body="!connect", user=user)
)
assert len(stub.calls) == 2
assert stub.calls[0].name == "set_message_visibility"
assert stub.calls[0].kwargs == {
"message_ids": ["test-msg-id"],
"visible": False,
}
assert stub.calls[1].name == "send_system_message_to_client"
assert stub.calls[1].kwargs["client_id"] == 42
assert stub.calls[1].kwargs["unsanitized"] is True
body = stub.calls[1].kwargs["body"]
url = body.split('href="', 1)[1].split('"', 1)[0]
token = url.rsplit("/", 1)[-1]
connect_token = session_manager.get_connect_token(token)
assert body == (
f'<a href="{url}">'
"<u>Click here to connect your Owncast session with Owlbot.</u>"
"</a>"
)
assert connect_token is not None
assert connect_token.destination_path == "/owlbot/connect"
assert connect_token.user.id == user.id
async def test_builtin_connect_can_be_reissued_immediately(
self, module_context: ModuleContext
) -> None:
"""!connect is not throttled by the generic built-in cooldown."""
stub = RecordingOwncastClient()
session_manager = SessionManager()
dispatcher = CommandDispatcher(
get_module_context=lambda _name: module_context,
owncast_client=stub,
handler_timeout=5.0,
loaded_modules=set(),
session_manager=session_manager,
public_base_url=module_context.config.public_base_url,
)
user = make_user(id="user-123", display_name="Alice")
await dispatcher.dispatch(
make_chat_event(client_id=42, raw_body="!connect", user=user)
)
await dispatcher.dispatch(
make_chat_event(client_id=42, raw_body="!connect", user=user)
)
assert len(stub.calls) == 4
assert [call.name for call in stub.calls] == [
"set_message_visibility",
"send_system_message_to_client",
"set_message_visibility",
"send_system_message_to_client",
]
assert all(
call.kwargs == {"message_ids": ["test-msg-id"], "visible": False}
for call in stub.calls[::2]
)
tokens: list[str] = []
for call in stub.calls[1::2]:
body = call.kwargs["body"]
url = body.split('href="', 1)[1].split('"', 1)[0]
tokens.append(url.rsplit("/", 1)[-1])
assert body == (
f'<a href="{url}">'
"<u>Click here to connect your Owncast session with Owlbot.</u>"
"</a>"
)
assert tokens[0] != tokens[1]
assert session_manager.get_connect_token(tokens[0]) is None
assert session_manager.get_connect_token(tokens[1]) is not None
async def test_command_context_session_url_for_uses_command_user(
self, module_context: ModuleContext
) -> None:
"""Dispatched command contexts can issue user-bound session URLs."""
session_manager = SessionManager()
captured_url = ""
user = make_user(id="user-123", is_authenticated=True)
async def handler(ctx: CommandContext) -> None:
nonlocal captured_url
captured_url = ctx.session_url_for("/panel")
dispatcher, _, _ = self._make_dispatcher(
module_context,
session_manager=session_manager,
public_base_url="https://example.com",
)
dispatcher.register(
"panel",
handler,
module_name=module_context.module_name,
)
await dispatcher.dispatch(make_chat_event(raw_body="!panel", user=user))
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
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:
"""Removing a built-in clears its fast-path handler entry too."""
builtin_calls: list[str] = []
module_calls: list[str] = []
async def builtin_handler(event: ChatEvent, owncast_client: Any) -> None:
builtin_calls.append("builtin")
async def module_handler(ctx: CommandContext) -> None:
module_calls.append(ctx.command)
dispatcher, _, _ = self._make_dispatcher(module_context)
dispatcher.register_builtin("test", builtin_handler)
assert dispatcher.unregister("test") is True
dispatcher.register("test", module_handler, module_name="mod_a")
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!test"))
assert builtin_calls == []
assert module_calls == ["test"]
async def test_failed_builtin_registration_does_not_poison_later_dispatch(
self, module_context: ModuleContext
) -> None:
"""A built-in conflict leaves no stale fast-path handler behind."""
builtin_calls: list[str] = []
module_calls: list[str] = []
async def module_handler(ctx: CommandContext) -> None:
module_calls.append(ctx.command)
async def builtin_handler(event: ChatEvent, owncast_client: Any) -> None:
builtin_calls.append("builtin")
dispatcher, _, _ = self._make_dispatcher(module_context)
dispatcher.register("test", module_handler, module_name="mod_a")
with pytest.raises(ValueError, match="conflicts with existing command 'test'"):
dispatcher.register_builtin("test", builtin_handler)
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!test"))
assert builtin_calls == []
assert module_calls == ["test"]
async def test_bulk_unregister_builtins_clears_fast_path_handler_map(
self, module_context: ModuleContext
) -> None:
"""Bulk-unregistering built-ins removes their fast-path handlers too."""
builtin_calls: list[str] = []
module_calls: list[str] = []
async def builtin_handler(event: ChatEvent, owncast_client: Any) -> None:
builtin_calls.append("builtin")
async def module_handler(ctx: CommandContext) -> None:
module_calls.append(ctx.command)
dispatcher, _, _ = self._make_dispatcher(module_context)
dispatcher.register_builtin("test", builtin_handler)
assert dispatcher.unregister_by_module("__builtin__") >= 1
dispatcher.register("test", module_handler, module_name="mod_a")
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!test"))
assert builtin_calls == []
assert module_calls == ["test"]
async def test_command_event_fields(self, module_context: ModuleContext) -> None:
"""Dispatched CommandContext carries correct CommandEvent fields."""
captured: list[CommandContext] = []
@@ -901,6 +1160,8 @@ class TestModuleCommands:
owncast_client=stub,
handler_timeout=5.0,
loaded_modules=set(),
session_manager=SessionManager(),
public_base_url=module_context.config.public_base_url,
)
mod_cmds = {name: ModuleCommands(dispatcher, name) for name in module_names}
return dispatcher, mod_cmds
@@ -1031,6 +1292,8 @@ class TestCommandContext:
def _make_command_context(
self,
module_ctx: ModuleContext,
*,
session_url_for: Callable[[str], str] | None = None,
) -> tuple[CommandContext, CommandEvent, EventContext[ChatEvent]]:
"""Build a CommandContext wrapping the given ModuleContext."""
event = make_chat_event(client_id=42, raw_body="!greet world")
@@ -1048,6 +1311,11 @@ class TestCommandContext:
command_event=cmd_event,
event_context=event_ctx,
module=module_ctx,
_session_url_for=(
session_url_for
if session_url_for is not None
else lambda path: f"https://example.com{path}"
),
)
return cmd_ctx, cmd_event, event_ctx
@@ -1109,3 +1377,12 @@ class TestCommandContext:
"""ctx.event_context is the EventContext passed at construction."""
cmd_ctx, _, event_ctx = self._make_command_context(module_context)
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."""
cmd_ctx, _, _ = self._make_command_context(
module_context,
session_url_for=lambda path: f"https://example.com{path}",
)
assert cmd_ctx.session_url_for("/panel") == "https://example.com/panel"
+118 -23
View File
@@ -41,7 +41,13 @@ from owlbot.registries.events import (
HandlerEntry,
ModuleEvents,
)
from owlbot.testing import make_chat_event, make_user_joined_event
from owlbot.sessions import SessionManager
from owlbot.testing import (
make_chat_event,
make_name_changed_event,
make_user,
make_user_joined_event,
)
if TYPE_CHECKING:
from owlbot.api.context import ModuleContext
@@ -53,6 +59,22 @@ def _unused_get_module_context(name: str) -> ModuleContext:
raise AssertionError("get_module_context should not be called")
def _make_dispatcher(
command_dispatch: Any,
get_module_context: Any,
*,
handler_timeout: float,
session_manager: SessionManager | None = None,
) -> EventDispatcher:
"""Build an EventDispatcher with a default session manager for tests."""
return EventDispatcher(
command_dispatch=command_dispatch,
get_module_context=get_module_context,
handler_timeout=handler_timeout,
session_manager=session_manager or SessionManager(),
)
class TestOnEventDecorator:
"""Tests the @on_event decorator from owlbot.api.events."""
@@ -396,7 +418,7 @@ class TestEventDispatcherDispatch:
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context,
handler_timeout=5.0,
@@ -423,7 +445,7 @@ class TestEventDispatcherDispatch:
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context,
handler_timeout=5.0,
@@ -449,7 +471,7 @@ class TestEventDispatcherDispatch:
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context,
handler_timeout=5.0,
@@ -467,7 +489,7 @@ class TestEventDispatcherDispatch:
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context,
handler_timeout=5.0,
@@ -489,7 +511,7 @@ class TestEventDispatcherDispatch:
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context,
handler_timeout=5.0,
@@ -517,7 +539,7 @@ class TestEventDispatcherDispatch:
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context,
handler_timeout=5.0,
@@ -541,7 +563,7 @@ class TestEventDispatcherDispatch:
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context,
handler_timeout=5.0,
@@ -570,7 +592,7 @@ class TestEventDispatcherDispatch:
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context,
handler_timeout=5.0,
@@ -603,7 +625,7 @@ class TestEventDispatcherStorage:
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context,
handler_timeout=5.0,
@@ -634,7 +656,7 @@ class TestEventDispatcherStorage:
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context,
handler_timeout=5.0,
@@ -664,7 +686,7 @@ class TestEventDispatcherStorage:
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context,
handler_timeout=5.0,
@@ -685,7 +707,7 @@ class TestEventDispatcherStorage:
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context,
handler_timeout=0.05,
@@ -713,7 +735,7 @@ class TestEventDispatcherStorage:
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context,
handler_timeout=0.05,
@@ -741,7 +763,7 @@ class TestEventDispatcherStorage:
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context,
handler_timeout=0.05,
@@ -786,7 +808,7 @@ class TestCommandDispatchPhase:
async def command_dispatch(event: ChatEvent) -> None:
command_calls.append(event)
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context,
handler_timeout=5.0,
@@ -809,7 +831,7 @@ class TestCommandDispatchPhase:
async def stopper(ctx: EventContext[Any]) -> None:
ctx.stop_propagation("spam")
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context,
handler_timeout=5.0,
@@ -830,7 +852,7 @@ class TestCommandDispatchPhase:
async def command_dispatch(event: ChatEvent) -> None:
raise RuntimeError("fail")
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context,
handler_timeout=5.0,
@@ -839,6 +861,79 @@ class TestCommandDispatchPhase:
await dispatcher.dispatch(EventType.CHAT, make_chat_event())
assert any("Command dispatch failed." in r.message for r in caplog.records)
async def test_user_event_refreshes_matching_sessions(
self, module_context: ModuleContext
) -> None:
"""User-bearing events refresh any existing Owlbot sessions for that user."""
session_manager = SessionManager()
original_user = make_user(id="user-123", display_name="Original")
session = session_manager.create_session(original_user)
refreshed_user = make_user(
id="user-123",
display_name="UpdatedName",
is_authenticated=True,
)
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,
)
await dispatcher.dispatch(
EventType.USER_JOINED,
make_user_joined_event(
user=refreshed_user, client_id=1, event_id="evt-001"
),
)
refreshed_session = session_manager.get_session(session.session_id)
assert refreshed_session is not None
assert refreshed_session.user.display_name == "UpdatedName"
assert refreshed_session.user.is_authenticated is True
async def test_name_change_event_refreshes_sessions_with_new_display_name(
self, module_context: ModuleContext
) -> None:
"""NAME_CHANGE refreshes sessions from the webhook user snapshot."""
session_manager = SessionManager()
original_user = make_user(id="user-123", display_name="Original")
session = session_manager.create_session(original_user)
renamed_user = make_user(
id="user-123",
display_name="UpdatedName",
is_authenticated=True,
)
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,
)
await dispatcher.dispatch(
EventType.NAME_CHANGE,
make_name_changed_event(
user=renamed_user,
new_name="UpdatedName",
client_id=1,
event_id="evt-002",
),
)
refreshed_session = session_manager.get_session(session.session_id)
assert refreshed_session is not None
assert refreshed_session.user.display_name == "UpdatedName"
assert refreshed_session.user.is_authenticated is True
class TestModuleEvents:
"""Tests ModuleEvents from owlbot.registries.events."""
@@ -849,7 +944,7 @@ class TestModuleEvents:
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=_unused_get_module_context,
handler_timeout=5.0,
@@ -868,7 +963,7 @@ class TestModuleEvents:
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=_unused_get_module_context,
handler_timeout=5.0,
@@ -890,7 +985,7 @@ class TestModuleEvents:
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=_unused_get_module_context,
handler_timeout=5.0,
@@ -914,7 +1009,7 @@ class TestModuleEvents:
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=_unused_get_module_context,
handler_timeout=5.0,
@@ -936,7 +1031,7 @@ class TestModuleEvents:
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = EventDispatcher(
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=_unused_get_module_context,
handler_timeout=5.0,
+79
View File
@@ -0,0 +1,79 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Focused regression tests for Task 5 module-loader auth wiring."""
from pathlib import Path
from ruamel.yaml import YAML
from owlbot.api.config import Config
from owlbot.api.http_client import HttpClient
from owlbot.module_loader import RESERVED_MODULE_NAMES, ModuleLoader
from owlbot.sessions import SessionManager
def _write_config(tmp_path: Path, *, command_prefix: str = "!") -> Path:
path = tmp_path / "config.yaml"
yaml = YAML(typ="safe")
with path.open("w") as f:
yaml.dump(
{
"owncast": {"url": "http://localhost:8080"},
"owlbot": {
"public_base_url": "http://localhost:8081",
"command_prefix": command_prefix,
},
},
f,
)
return path
def test_connect_is_reserved_module_name() -> None:
"""The built-in connect route namespace cannot be claimed by modules."""
assert "connect" in RESERVED_MODULE_NAMES
def test_module_loader_threads_one_shared_session_manager(tmp_path: Path) -> None:
"""ModuleLoader reuses the same session manager across its dispatch stack."""
config = Config(_write_config(tmp_path))
session_manager = SessionManager()
loader = ModuleLoader(
tmp_path / "modules",
config,
HttpClient(),
session_manager,
)
assert loader.session_manager is session_manager
assert loader.command_dispatcher._session_manager is session_manager
assert loader.event_dispatcher._session_manager is session_manager
assert loader.route_dispatcher._session_manager is session_manager
def test_module_loader_threads_configured_command_prefix_to_route_dispatcher(
tmp_path: Path,
) -> None:
"""ModuleLoader passes the configured command prefix to RouteDispatcher."""
config = Config(_write_config(tmp_path, command_prefix="/"))
session_manager = SessionManager()
loader = ModuleLoader(
tmp_path / "modules",
config,
HttpClient(),
session_manager,
)
assert loader.route_dispatcher._command_prefix == "/"
+393 -3
View File
@@ -35,6 +35,8 @@ from aiohttp.test_utils import make_mocked_request
from owlbot.api.context import RouteContext
from owlbot.api.routes import RouteInfo, RouteMark, on_route
from owlbot.registries.routes import ModuleRoutes, RouteDispatcher, RouteRegistry
from owlbot.sessions import SessionManager
from owlbot.testing import make_user
if TYPE_CHECKING:
from owlbot.api.context import ModuleContext
@@ -43,11 +45,17 @@ if TYPE_CHECKING:
def _make_dispatcher(
module_context: ModuleContext,
handler_timeout: float = 0.1,
*,
session_manager: SessionManager | None = None,
command_prefix: str = "!",
) -> RouteDispatcher:
"""Build a RouteDispatcher wired to the given ModuleContext."""
session_manager = session_manager or SessionManager()
return RouteDispatcher(
get_module_context=lambda _name: module_context,
handler_timeout=handler_timeout,
session_manager=session_manager,
command_prefix=command_prefix,
)
@@ -105,6 +113,23 @@ class TestOnRouteDecorator:
assert mark["methods"] == expected_methods
assert mark["streaming"] is expected_streaming
def test_sets_auth_requirements(self) -> None:
"""@on_route stores the route auth flags on the mark."""
@on_route(
"/secure",
requires_session=True,
requires_authenticated=True,
requires_moderator=True,
)
async def handler(ctx: Any) -> None:
pass
mark: RouteMark = handler._owlbot_route # type: ignore[attr-defined]
assert mark["requires_session"] is True
assert mark["requires_authenticated"] is True
assert mark["requires_moderator"] is True
def test_returns_function_unchanged(self) -> None:
"""@on_route returns the original function."""
@@ -164,6 +189,35 @@ class TestRouteInfo:
assert info.handler is _noop
assert info.module_name == "mod"
def test_auth_flags_default_false(self) -> None:
"""RouteInfo auth flags default to False."""
info = RouteInfo(
path="/x",
full_path="/owlbot/mod/x",
methods=frozenset({"GET"}),
handler=_noop,
module_name="mod",
)
assert info.requires_session is False
assert info.requires_authenticated is False
assert info.requires_moderator is False
def test_auth_flags_accessible(self) -> None:
"""RouteInfo stores route auth metadata."""
info = RouteInfo(
path="/x",
full_path="/owlbot/mod/x",
methods=frozenset({"GET"}),
handler=_noop,
module_name="mod",
requires_session=True,
requires_authenticated=True,
requires_moderator=True,
)
assert info.requires_session is True
assert info.requires_authenticated is True
assert info.requires_moderator is True
class TestRouteRegistry:
"""Tests RouteRegistry from owlbot.registries.routes."""
@@ -231,6 +285,21 @@ class TestRouteRegistry:
info = reg.register("/sse", _noop, module_name="mod", streaming=True)
assert info.streaming is True
def test_register_auth_flags(self) -> None:
"""register() stores route auth metadata on RouteInfo."""
reg = RouteRegistry()
info = reg.register(
"/secure",
_noop,
module_name="mod",
requires_session=True,
requires_authenticated=True,
requires_moderator=True,
)
assert info.requires_session is True
assert info.requires_authenticated is True
assert info.requires_moderator is True
def test_get_no_method_returns_list(self) -> None:
"""get() without method returns list of all handlers at path."""
reg = RouteRegistry()
@@ -495,6 +564,281 @@ class TestRouteRegistry:
class TestRouteDispatcherDispatch:
"""Tests RouteDispatcher.dispatch() from owlbot.registries.routes."""
async def test_requires_session_without_cookie_returns_401_with_guidance(
self, module_context: ModuleContext
) -> None:
"""A session-protected route returns guidance when no cookie is present."""
async def handler(ctx: RouteContext) -> web.Response:
return web.Response(text="ok")
dispatcher = _make_dispatcher(module_context, session_manager=SessionManager())
dispatcher.register(
"/secure",
handler,
module_name="mod",
requires_session=True,
)
request = make_mocked_request("GET", "/owlbot/mod/secure")
request.match_info["module_name"] = "mod"
request.match_info["path"] = "secure"
response = await dispatcher.dispatch(request)
assert response.status == HTTPStatus.UNAUTHORIZED
assert isinstance(response, web.Response)
assert response.text is not None
assert "!connect" in response.text
async def test_requires_authenticated_rejects_unauthenticated_session(
self, module_context: ModuleContext
) -> None:
"""An unauthenticated linked session is rejected before the handler runs."""
auth_manager = SessionManager()
session = auth_manager.create_session(make_user(id="guest"))
handler_called = False
async def handler(ctx: RouteContext) -> web.Response:
nonlocal handler_called
handler_called = True
return web.Response(text="ok")
dispatcher = _make_dispatcher(module_context, session_manager=auth_manager)
dispatcher.register(
"/secure",
handler,
module_name="mod",
requires_authenticated=True,
)
request = make_mocked_request(
"GET",
"/owlbot/mod/secure",
headers={"Cookie": f"owlbot_session={session.session_id}"},
)
request.match_info["module_name"] = "mod"
request.match_info["path"] = "secure"
response = await dispatcher.dispatch(request)
assert response.status == HTTPStatus.FORBIDDEN
assert handler_called is False
assert isinstance(response, web.Response)
assert response.text is not None
assert (
"You must be authenticated in Owncast to access this page." in response.text
)
assert (
"If you believe this is in error, try using !connect in chat "
"to reconnect your Owncast account." in response.text
)
async def test_requires_authenticated_guidance_uses_configured_command_prefix(
self, module_context: ModuleContext
) -> None:
"""Protected-route guidance uses the configured connect command prefix."""
auth_manager = SessionManager()
session = auth_manager.create_session(make_user(id="guest"))
dispatcher = _make_dispatcher(
module_context,
session_manager=auth_manager,
command_prefix="/",
)
dispatcher.register(
"/secure",
_noop,
module_name="mod",
requires_authenticated=True,
)
request = make_mocked_request(
"GET",
"/owlbot/mod/secure",
headers={"Cookie": f"owlbot_session={session.session_id}"},
)
request.match_info["module_name"] = "mod"
request.match_info["path"] = "secure"
response = await dispatcher.dispatch(request)
assert response.status == HTTPStatus.FORBIDDEN
assert isinstance(response, web.Response)
assert response.text is not None
assert "/connect in chat" in response.text
assert "!connect in chat" not in response.text
async def test_requires_session_with_stale_cookie_returns_guidance(
self, module_context: ModuleContext
) -> None:
"""A stale session cookie is treated as missing for a protected route."""
dispatcher = _make_dispatcher(module_context, session_manager=SessionManager())
dispatcher.register(
"/secure",
_noop,
module_name="mod",
requires_session=True,
)
request = make_mocked_request(
"GET",
"/owlbot/mod/secure",
headers={"Cookie": "owlbot_session=stale-session"},
)
request.match_info["module_name"] = "mod"
request.match_info["path"] = "secure"
response = await dispatcher.dispatch(request)
assert response.status == HTTPStatus.UNAUTHORIZED
assert isinstance(response, web.Response)
assert response.text is not None
assert "!connect" in response.text
async def test_requires_authenticated_with_stale_cookie_returns_guidance(
self, module_context: ModuleContext
) -> None:
"""A stale session cookie is treated as missing for stricter routes."""
dispatcher = _make_dispatcher(module_context, session_manager=SessionManager())
dispatcher.register(
"/secure",
_noop,
module_name="mod",
requires_authenticated=True,
)
request = make_mocked_request(
"GET",
"/owlbot/mod/secure",
headers={"Cookie": "owlbot_session=stale-session"},
)
request.match_info["module_name"] = "mod"
request.match_info["path"] = "secure"
response = await dispatcher.dispatch(request)
assert response.status == HTTPStatus.UNAUTHORIZED
assert isinstance(response, web.Response)
assert response.text is not None
assert "!connect" in response.text
async def test_valid_session_populates_route_context_session(
self, module_context: ModuleContext
) -> None:
"""Resolved sessions are exposed on RouteContext for handlers."""
auth_manager = SessionManager()
session = auth_manager.create_session(
make_user(id="alice", is_authenticated=True)
)
captured_session = None
async def handler(ctx: RouteContext) -> web.Response:
nonlocal captured_session
captured_session = ctx.session
return web.Response(text="ok")
dispatcher = _make_dispatcher(module_context, session_manager=auth_manager)
dispatcher.register(
"/secure",
handler,
module_name="mod",
requires_session=True,
)
request = make_mocked_request(
"GET",
"/owlbot/mod/secure",
headers={"Cookie": f"owlbot_session={session.session_id}"},
)
request.match_info["module_name"] = "mod"
request.match_info["path"] = "secure"
response = await dispatcher.dispatch(request)
assert response.status == HTTPStatus.OK
assert captured_session is session
async def test_requires_moderator_rejects_non_moderator_session(
self, module_context: ModuleContext
) -> None:
"""A non-moderator session is rejected before the handler runs."""
auth_manager = SessionManager()
session = auth_manager.create_session(
make_user(id="alice", is_authenticated=True, is_moderator=False)
)
handler_called = False
async def handler(ctx: RouteContext) -> web.Response:
nonlocal handler_called
handler_called = True
return web.Response(text="ok")
dispatcher = _make_dispatcher(module_context, session_manager=auth_manager)
dispatcher.register(
"/secure",
handler,
module_name="mod",
requires_moderator=True,
)
request = make_mocked_request(
"GET",
"/owlbot/mod/secure",
headers={"Cookie": f"owlbot_session={session.session_id}"},
)
request.match_info["module_name"] = "mod"
request.match_info["path"] = "secure"
response = await dispatcher.dispatch(request)
assert response.status == HTTPStatus.FORBIDDEN
assert handler_called is False
assert isinstance(response, web.Response)
assert response.text is not None
assert "Only moderators can access this page." in response.text
assert (
"If you believe this is in error, try using !connect in chat "
"to reconnect your Owncast account." in response.text
)
async def test_requires_moderator_allows_moderator_session(
self, module_context: ModuleContext
) -> None:
"""A moderator session reaches the protected handler."""
auth_manager = SessionManager()
session = auth_manager.create_session(
make_user(id="alice", is_authenticated=True, is_moderator=True)
)
handler_called = False
async def handler(ctx: RouteContext) -> web.Response:
nonlocal handler_called
handler_called = True
assert ctx.session is session
return web.Response(text="ok")
dispatcher = _make_dispatcher(module_context, session_manager=auth_manager)
dispatcher.register(
"/secure",
handler,
module_name="mod",
requires_moderator=True,
)
request = make_mocked_request(
"GET",
"/owlbot/mod/secure",
headers={"Cookie": f"owlbot_session={session.session_id}"},
)
request.match_info["module_name"] = "mod"
request.match_info["path"] = "secure"
response = await dispatcher.dispatch(request)
assert response.status == HTTPStatus.OK
assert handler_called is True
async def test_dispatch_dict_response(self, module_context: ModuleContext) -> None:
"""Handler returning dict produces JSON 200 response."""
@@ -986,6 +1330,21 @@ class TestRouteDispatcherDelegation:
dispatcher.register_from_module(mod, "fake")
assert len(dispatcher.get_by_module("fake")) == 1
def test_register_auth_flags(self, module_context: ModuleContext) -> None:
"""RouteDispatcher.register() passes auth flags through."""
dispatcher = _make_dispatcher(module_context)
info = dispatcher.register(
"/secure",
_noop,
module_name="mod",
requires_session=True,
requires_authenticated=True,
requires_moderator=True,
)
assert info.requires_session is True
assert info.requires_authenticated is True
assert info.requires_moderator is True
def test_unregister_by_module(self, module_context: ModuleContext) -> None:
"""RouteDispatcher.unregister_by_module() delegates to registry."""
dispatcher = _make_dispatcher(module_context)
@@ -999,12 +1358,21 @@ class TestModuleRoutes:
"""Tests ModuleRoutes from owlbot.registries.routes."""
def _make_mod_routes(
self, module_context: ModuleContext, *module_names: str
self,
module_context: ModuleContext,
*module_names: str,
) -> tuple[RouteDispatcher, dict[str, ModuleRoutes]]:
"""Build a dispatcher and ModuleRoutes wrappers for given modules."""
dispatcher = _make_dispatcher(module_context, handler_timeout=5.0)
dispatcher = _make_dispatcher(
module_context,
handler_timeout=5.0,
)
mod_routes = {
name: ModuleRoutes(dispatcher, name, "https://example.com")
name: ModuleRoutes(
dispatcher,
name,
"https://example.com",
)
for name in module_names
}
return dispatcher, mod_routes
@@ -1030,6 +1398,20 @@ class TestModuleRoutes:
info = mod_routes["mod_a"].register("/data", _noop, methods=["GET", "POST"])
assert info.methods == frozenset({"GET", "POST"})
def test_register_auth_flags(self, module_context: ModuleContext) -> None:
"""ModuleRoutes.register() passes auth flags through."""
_, mod_routes = self._make_mod_routes(module_context, "mod_a")
info = mod_routes["mod_a"].register(
"/secure",
_noop,
requires_session=True,
requires_authenticated=True,
requires_moderator=True,
)
assert info.requires_session is True
assert info.requires_authenticated is True
assert info.requires_moderator is True
def test_unregister_by_path(self, module_context: ModuleContext) -> None:
"""ModuleRoutes.unregister() removes all handlers at path."""
_, mod_routes = self._make_mod_routes(module_context, "mod_a")
@@ -1186,3 +1568,11 @@ class TestRouteContext:
)
ctx = RouteContext(request=request, module=module_context)
assert ctx.match_info == {}
def test_session_defaults_none(self, module_context: ModuleContext) -> None:
"""RouteContext.session defaults to None."""
request = make_mocked_request(
"GET", f"/owlbot/{module_context.module_name}/test"
)
ctx = RouteContext(request=request, module=module_context)
assert ctx.session is None
+288
View File
@@ -0,0 +1,288 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Regression tests for connect token and session management."""
from __future__ import annotations
from datetime import timedelta
import pytest
from owlbot.sessions import (
DEFAULT_CONNECT_DESTINATION_PATH,
ConnectTokenRedemptionError,
SessionManager,
)
from owlbot.testing import make_user
class TestSessionManager:
"""Focused tests for the in-memory session manager."""
def test_issue_connect_token_tracks_internal_destination(self) -> None:
"""Issued tokens retain the internal Owlbot destination path."""
manager = SessionManager()
token = manager.issue_connect_token(
"/owlbot/demo/panel",
user=make_user(id="alice", is_authenticated=True),
)
connect_token = manager.get_connect_token(token)
assert connect_token is not None
assert connect_token.destination_path == "/owlbot/demo/panel"
def test_issue_connect_token_defaults_to_connect_status_destination(self) -> None:
"""Issued tokens default to the built-in connect status page."""
manager = SessionManager()
token = manager.issue_connect_token(
user=make_user(id="alice", is_authenticated=True),
)
connect_token = manager.get_connect_token(token)
assert connect_token is not None
assert connect_token.destination_path == DEFAULT_CONNECT_DESTINATION_PATH
def test_issue_connect_token_replaces_pending_token_for_same_user(self) -> None:
"""Issuing a new token invalidates older pending tokens for that user."""
manager = SessionManager()
first_alice_token = manager.issue_connect_token(
"/owlbot/demo/first",
user=make_user(id="alice", is_authenticated=True),
)
bob_token = manager.issue_connect_token(
"/owlbot/demo/bob",
user=make_user(id="bob", is_authenticated=True),
)
second_alice_token = manager.issue_connect_token(
"/owlbot/demo/second",
user=make_user(id="alice", is_authenticated=True),
)
assert manager.get_connect_token(first_alice_token) is None
assert manager.get_connect_token(bob_token) is not None
connect_token = manager.get_connect_token(second_alice_token)
assert connect_token is not None
assert connect_token.destination_path == "/owlbot/demo/second"
assert first_alice_token not in manager._connect_tokens_by_token
assert manager._connect_token_by_user_id == {
"alice": second_alice_token,
"bob": bob_token,
}
@pytest.mark.parametrize(
"destination_path",
[
pytest.param("/outside/demo/panel", id="outside-owlbot"),
pytest.param("https://example.com/owlbot/demo/panel", id="absolute-url"),
pytest.param("/owlbot", id="missing-trailing-path"),
pytest.param("//example.com/owlbot/demo/panel", id="protocol-relative-url"),
],
)
def test_issue_connect_token_rejects_non_owlbot_destination(
self, destination_path: str
) -> None:
"""Connect tokens only allow redirects within Owlbot's own routes."""
manager = SessionManager()
with pytest.raises(ValueError, match="within /owlbot"):
manager.issue_connect_token(
destination_path,
user=make_user(id="alice", is_authenticated=True),
)
@pytest.mark.parametrize(
"destination_path",
[
pytest.param("/owlbot/demo/../admin", id="parent-segment"),
pytest.param("/owlbot/demo/./panel", id="current-segment"),
pytest.param("/owlbot/demo/%2e%2e/admin", id="encoded-parent-segment"),
pytest.param("/owlbot/demo/%2E/panel", id="encoded-current-segment"),
],
)
def test_issue_connect_token_rejects_dot_segment_destination(
self, destination_path: str
) -> None:
"""Connect-token redirects reject browser-resolved dot segments."""
manager = SessionManager()
with pytest.raises(ValueError, match="dot segments"):
manager.issue_connect_token(
destination_path,
user=make_user(id="alice", is_authenticated=True),
)
@pytest.mark.parametrize(
"destination_path",
[
pytest.param(r"/owlbot/demo\..\admin", id="backslash"),
pytest.param("/owlbot/demo/%5cadmin", id="encoded-backslash"),
],
)
def test_issue_connect_token_rejects_backslash_destination(
self, destination_path: str
) -> None:
"""Connect-token redirects reject browser-normalized backslashes."""
manager = SessionManager()
with pytest.raises(ValueError, match="URL path separators"):
manager.issue_connect_token(
destination_path,
user=make_user(id="alice", is_authenticated=True),
)
def test_redeem_connect_token_replaces_existing_session(self) -> None:
"""Redeeming a connect token can replace an existing browser session."""
manager = SessionManager()
existing = manager.create_session(make_user(id="guest"))
token = manager.issue_connect_token(
"/owlbot/demo/panel",
user=make_user(id="alice", is_authenticated=True),
)
redeemed = manager.redeem_connect_token(
token,
replacing_session_id=existing.session_id,
)
assert redeemed.user.id == "alice"
assert redeemed.session_id != token
assert manager.get_session(existing.session_id) is None
assert manager.get_session(redeemed.session_id) is redeemed
def test_redeem_connect_token_rejects_invalid_token(self) -> None:
"""Unknown connect tokens are rejected."""
manager = SessionManager()
with pytest.raises(ConnectTokenRedemptionError, match="invalid connect token"):
manager.redeem_connect_token("not-a-real-token")
def test_redeem_connect_token_rejects_expired_token(self) -> None:
"""Expired connect tokens are rejected and evicted."""
manager = SessionManager(connect_token_ttl=timedelta(seconds=0))
token = manager.issue_connect_token(
"/owlbot/demo/panel",
user=make_user(id="alice", is_authenticated=True),
)
with pytest.raises(ConnectTokenRedemptionError, match="connect token expired"):
manager.redeem_connect_token(token)
assert manager.get_connect_token(token) is None
assert token not in manager._connect_tokens_by_token
assert "alice" not in manager._connect_token_by_user_id
def test_redeem_connect_token_deletes_token_when_claimed(self) -> None:
"""Redeemed connect tokens are evicted immediately."""
manager = SessionManager()
token = manager.issue_connect_token(
"/owlbot/demo/panel",
user=make_user(id="alice", is_authenticated=True),
)
manager.redeem_connect_token(token)
assert manager.get_connect_token(token) is None
assert token not in manager._connect_tokens_by_token
assert "alice" not in manager._connect_token_by_user_id
with pytest.raises(ConnectTokenRedemptionError, match="invalid connect token"):
manager.redeem_connect_token(token)
def test_refresh_user_updates_all_sessions_for_matching_user(self) -> None:
"""Refreshing a user snapshot updates all active sessions for that user."""
manager = SessionManager()
first = manager.create_session(make_user(id="alice", display_name="Alice"))
second = manager.create_session(make_user(id="alice", display_name="Alice"))
updated_user = make_user(
id="alice",
display_name="Alice Renamed",
is_authenticated=True,
is_moderator=True,
)
manager.refresh_user(updated_user)
refreshed_first = manager.get_session(first.session_id)
refreshed_second = manager.get_session(second.session_id)
assert refreshed_first is not None
assert refreshed_second is not None
assert refreshed_first.user.display_name == "Alice Renamed"
assert refreshed_second.user.display_name == "Alice Renamed"
assert refreshed_first.is_authenticated is True
assert refreshed_second.is_moderator is True
def test_refresh_user_updates_pending_tokens_before_redemption(self) -> None:
"""Refreshing a user snapshot updates unredeemed connect tokens too."""
manager = SessionManager()
token = manager.issue_connect_token(
"/owlbot/demo/panel",
user=make_user(id="alice", display_name="Alice"),
)
updated_user = make_user(
id="alice",
display_name="Alice Renamed",
is_authenticated=True,
is_moderator=True,
)
manager.refresh_user(updated_user)
session = manager.redeem_connect_token(token)
assert session.user.display_name == "Alice Renamed"
assert session.is_authenticated is True
assert session.is_moderator is True
def test_get_session_drops_expired_session(self) -> None:
"""Expired sessions are removed on read."""
manager = SessionManager(session_ttl=timedelta(seconds=0))
session = manager.create_session(make_user(id="alice"))
assert manager.get_session(session.session_id) is None
def test_clear_expired_removes_expired_tokens_and_sessions(self) -> None:
"""Periodic cleanup removes expired tokens and sessions."""
manager = SessionManager(
connect_token_ttl=timedelta(seconds=0),
session_ttl=timedelta(seconds=0),
)
token = manager.issue_connect_token(
"/owlbot/demo/panel",
user=make_user(id="alice", is_authenticated=True),
)
session = manager.create_session(make_user(id="alice"))
manager.clear_expired()
assert manager.get_connect_token(token) is None
assert token not in manager._connect_tokens_by_token
assert "alice" not in manager._connect_token_by_user_id
assert manager.get_session(session.session_id) is None
async def test_start_and_close_cancel_cleanup_task_cleanly(self) -> None:
"""Background cleanup can be started and shut down cleanly."""
manager = SessionManager()
manager.start()
try:
assert manager._cleanup_task is not None
assert manager._cleanup_task.done() is False
assert manager._cleanup_task.get_name() == "Session Manager - Cleanup loop"
finally:
await manager.close()
assert manager._cleanup_task is None
+90 -1
View File
@@ -30,6 +30,7 @@ from owlbot.api.storage import ModuleStorage
from owlbot.api.templates import ModuleTemplates
from owlbot.builtin_modules import quotes as quotes_pkg
from owlbot.registries.events import EventDispatcher
from owlbot.sessions import DEFAULT_SESSION_TTL, SESSION_COOKIE_NAME, SessionManager
from owlbot.testing import (
make_chat_event,
make_user,
@@ -47,7 +48,12 @@ if TYPE_CHECKING:
from aiohttp.test_utils import TestClient
from aioresponses import aioresponses
from owlbot.api.context import CommandContext, EventContext, ModuleContext
from owlbot.api.context import (
CommandContext,
EventContext,
ModuleContext,
RouteContext,
)
from owlbot.api.event_types import UserJoinedEvent
from owlbot.registries.commands import CommandDispatcher
from owlbot.registries.routes import RouteDispatcher
@@ -618,3 +624,86 @@ class TestRouteClient:
resp = await route_client.get("/owlbot/test_module/noop")
assert resp.status == 204
async def test_connect_token_redemption_sets_browser_session(
self,
module_context: ModuleContext,
command_dispatcher: CommandDispatcher,
route_client: TestClient[Any, Any],
session_manager: SessionManager,
) -> None:
"""Redeeming a connect token persists the session cookie."""
session_urls: list[str] = []
async def handler(ctx: RouteContext) -> dict[str, str | bool]:
assert ctx.session is not None
return {
"user_id": ctx.session.user.id,
"authenticated": ctx.session.is_authenticated,
}
async def link_command(ctx: CommandContext) -> None:
session_urls.append(ctx.session_url_for("/secure"))
module_context.routes.register(
"/secure",
handler,
methods=["GET"],
requires_authenticated=True,
)
module_context.commands.register("secure", link_command)
await command_dispatcher.dispatch(
make_chat_event(
raw_body="!secure",
user=make_user(id="alice", is_authenticated=True),
)
)
session_url = session_urls[0]
redeem = await route_client.get(
session_url.removeprefix("http://localhost:8081"),
allow_redirects=False,
)
assert redeem.status == 302
session_cookie = redeem.cookies[SESSION_COOKIE_NAME]
assert session_cookie["max-age"] == str(
int(DEFAULT_SESSION_TTL.total_seconds())
)
assert session_cookie["path"] == "/owlbot"
assert session_cookie["httponly"] is True
assert session_cookie["samesite"] == "Lax"
resp = await route_client.get("/owlbot/test_module/secure")
assert resp.status == 200
assert await resp.json() == {"user_id": "alice", "authenticated": True}
session_manager.invalidate_session(session_cookie.value)
stale = await route_client.get("/owlbot/test_module/secure")
assert stale.status == 401
cleared_cookie = stale.cookies[SESSION_COOKIE_NAME]
assert cleared_cookie["path"] == "/owlbot"
assert cleared_cookie["max-age"] == "0"
browser_cookies = route_client.session.cookie_jar.filter_cookies(
route_client.make_url("/owlbot/test_module/secure")
)
assert SESSION_COOKIE_NAME not in browser_cookies
async def test_invalid_connect_token_returns_guidance_page(
self,
route_client: TestClient[Any, Any],
) -> None:
"""Invalid connect tokens return the shared guidance page with a 403 status."""
resp = await route_client.get(
"/owlbot/connect/not-a-real-token",
allow_redirects=False,
)
assert resp.status == 403
text = await resp.text()
assert "!connect" in text
assert "Powered by Owlbot v" in text