Files
odysseus/tests/test_auth_regressions.py
Ernest Hysa a91321d1d8 Scope core.* module stubs to the test, not the module (#1513)
Three test files (test_auth_regressions, test_auth_event_loop,
test_null_owner_gates) install stubs for core.database / core.auth /
src.endpoint_resolver at module-import time, so they outlive the
file and are still present in sys.modules when later-collected test
files try to import the real modules. The stubs are minimal (a
handful of MagicMock attrs) so the import chain that follows fails
with ImportError on the very next real import.

test_companion_pairing also leaks, with a twist: its _DBStub
subclass returns a MagicMock for *any* attribute including dunders,
so the next test that does `from core.database import *` reads
`__all__` as a MagicMock and dies with 'Item in __all__ must be
str, not MagicMock'.

Move the stub installation into an autouse fixture per file and
register each stub with monkeypatch.setitem so sys.modules is
restored to its pre-test state on teardown. Tighten _DBStub to
refuse dunder names so __all__ stays undefined. _CAPTURED is
cleared per test so the mint-token assertions see a fresh dict.

Before: 3 test files fail at collection time (test_chat_image_routing,
test_context_compactor, test_webhook_ssrf_resilience). After: 0
collection errors. 1365/1370 pass, 1 skip, 4 unrelated pre-existing
failures (verified against origin/main baseline).

Out of scope: test_task_scheduler_session_delivery::
test_session_delivery_survives_empty_database also fails in the
full suite due to order-dependent state from a different test
file. That's a separate leak with a different root cause.
2026-06-03 14:23:40 +09:00

376 lines
16 KiB
Python

"""Pin the auth-gate fixes from the 2026-05-19 v2 review so they
don't regress. Specifically:
- All `/api/research/*` endpoints reject anonymous callers.
- Task `create_task` blocks shell-executing action types for
non-admins (`run_local`, `run_script`, `ssh_command`).
- `pop_notifications(owner)` returns only the calling user's
notifications; ownerless legacy notifications are drained only by
anonymous/no-owner callers.
"""
import os
import sys
import types
import asyncio
import pytest
from types import SimpleNamespace
from unittest.mock import MagicMock
# Stub `core.database` / `core.auth` before the route modules import them.
# (Same trick as test_null_owner_gates.py — the real modules instantiate
# SQLAlchemy declarative classes at import-time which blow up under the
# conftest's `sqlalchemy.*` MagicMock stubs.)
def _ensure_stub(name: str, **attrs):
"""Create or augment a stub module with the given attributes.
Augments existing entries because earlier-run tests may have already
stubbed the same module with a different attribute set.
Also stubs the parent package and wires the child onto it as an
attribute. Without stubbing the parent we'd either (a) run the real
`core/__init__.py`, which transitively imports SQLAlchemy-using
modules and explodes under the conftest mocks, or (b) leave the
stub orphaned so `import core.auth; core.auth.AuthManager` raises
`AttributeError`."""
# Stub the parent package first if not already loaded. We point
# `__path__` at the real on-disk directory so submodules NOT
# stubbed here can still resolve via normal import machinery —
# but `core/__init__.py` is bypassed because the package is
# already in `sys.modules`, which is exactly what we want.
if "." in name:
parent_name, _, child_name = name.rpartition(".")
if parent_name not in sys.modules:
parent = types.ModuleType(parent_name)
# Find the real on-disk path so unstubbed submodules
# (core.middleware etc.) still load from disk.
real_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
*parent_name.split("."),
)
parent.__path__ = [real_path] if os.path.isdir(real_path) else []
sys.modules[parent_name] = parent
else:
parent = sys.modules[parent_name]
else:
parent = None
child_name = None
mod = sys.modules.get(name)
if mod is None:
mod = types.ModuleType(name)
sys.modules[name] = mod
for k, v in attrs.items():
if not hasattr(mod, k):
setattr(mod, k, v)
if parent is not None and not hasattr(parent, child_name):
setattr(parent, child_name, mod)
return mod
@pytest.fixture(autouse=True)
def _auth_regressions_stubs(monkeypatch):
db = _ensure_stub("core.database",
SessionLocal=MagicMock(), ScheduledTask=MagicMock(), TaskRun=MagicMock(),
ModelEndpoint=MagicMock(), Session=MagicMock(), ChatMessage=MagicMock(),
CalendarCal=MagicMock(), CalendarEvent=MagicMock(),
Document=MagicMock(), DocumentVersion=MagicMock(),
GalleryImage=MagicMock(), GalleryAlbum=MagicMock(), Note=MagicMock(),
McpServer=MagicMock(),
)
auth = _ensure_stub("core.auth", AuthManager=MagicMock())
ep = _ensure_stub("src.endpoint_resolver",
resolve_endpoint=MagicMock(return_value=("", "", {})),
normalize_base=MagicMock(),
build_chat_url=MagicMock(),
build_headers=MagicMock(),
)
monkeypatch.setitem(sys.modules, "core.database", db)
monkeypatch.setitem(sys.modules, "core.auth", auth)
monkeypatch.setitem(sys.modules, "src.endpoint_resolver", ep)
from fastapi import HTTPException
# ---------------------------------------------------------------------------
# Auth routes -- open signup setter
# ---------------------------------------------------------------------------
def _auth_route_endpoint(path: str, method: str):
from routes.auth_routes import setup_auth_routes
auth_manager = MagicMock()
router = setup_auth_routes(auth_manager)
for route in router.routes:
if getattr(route, "path", "") == path and method in getattr(route, "methods", set()):
return auth_manager, route.endpoint
raise AssertionError(f"{method} {path} route not registered")
def _fake_auth_request(token="session-token"):
from routes.auth_routes import SESSION_COOKIE
req = SimpleNamespace()
req.cookies = {SESSION_COOKIE: token}
req.client = SimpleNamespace(host="127.0.0.1")
return req
def test_set_signup_enabled_true_is_idempotent():
from routes.auth_routes import SetOpenRegistrationRequest
auth, target = _auth_route_endpoint("/api/auth/open-signup", "PUT")
auth.get_username_for_token.return_value = "admin"
auth.is_admin.return_value = True
request = _fake_auth_request()
auth.signup_enabled = False
out = asyncio.run(target(body=SetOpenRegistrationRequest(enabled=True),request=request))
assert out == {"ok": True, "signup_enabled": True}
assert auth.signup_enabled is True
out = asyncio.run(target(body=SetOpenRegistrationRequest(enabled=True), request=request))
assert out == {"ok": True, "signup_enabled": True}
assert auth.signup_enabled is True
def test_set_signup_enabled_false_is_idempotent():
from routes.auth_routes import SetOpenRegistrationRequest
auth, target = _auth_route_endpoint("/api/auth/open-signup", "PUT")
auth.get_username_for_token.return_value = "admin"
auth.is_admin.return_value = True
request = _fake_auth_request()
auth.signup_enabled = True
out = asyncio.run(target(body=SetOpenRegistrationRequest(enabled=False), request=request))
assert out == {"ok": True, "signup_enabled": False}
assert auth.signup_enabled is False
out = asyncio.run(target(body=SetOpenRegistrationRequest(enabled=False), request=request))
assert out == {"ok": True, "signup_enabled": False}
assert auth.signup_enabled is False
def test_set_signup_enabled_requires_admin():
from routes.auth_routes import SetOpenRegistrationRequest
auth, target = _auth_route_endpoint("/api/auth/open-signup", "PUT")
auth.get_username_for_token.return_value = "bob"
auth.is_admin.return_value = False
auth.signup_enabled = False
with pytest.raises(HTTPException) as exc:
asyncio.run(target(body=SetOpenRegistrationRequest(enabled=True), request=_fake_auth_request()))
assert exc.value.status_code == 403
assert auth.signup_enabled is False
# ---------------------------------------------------------------------------
# Research endpoints — `_require_user` rejects anonymous
# ---------------------------------------------------------------------------
def _build_research_router():
"""Construct the research router with a mock research_handler so we
can fish out the inner `_require_user` helper without booting the
full app."""
from routes.research_routes import setup_research_routes
rh = MagicMock()
setup_research_routes(rh)
# The helper lives inside the setup closure. Easiest way to exercise
# it: re-import the module and grab the symbol via its source.
# Instead, exercise it via the route helper that has request:Request.
return rh
def _fake_request(user=None):
"""Cheap stand-in for fastapi.Request — only `request.state.current_user`
matters to `get_current_user`."""
req = SimpleNamespace()
req.state = SimpleNamespace(current_user=user)
# Some endpoints touch .client too — provide a benign default.
req.client = SimpleNamespace(host="127.0.0.1")
return req
def test_research_status_rejects_anonymous():
"""research_status must 401 when no user is on the request state."""
# Build a fresh router and pluck its registered routes.
from routes.research_routes import setup_research_routes
rh = MagicMock()
rh.get_status.return_value = {"status": "running"} # would 200 if auth passed
router = setup_research_routes(rh)
# Find the function registered for /api/research/status/{session_id}
target = None
for route in router.routes:
if getattr(route, "path", "") == "/api/research/status/{session_id}":
target = route.endpoint
break
assert target is not None, "research_status route not registered"
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id="x", request=_fake_request(user=None)))
assert exc.value.status_code == 401
def test_research_status_accepts_authenticated():
from routes.research_routes import setup_research_routes
rh = MagicMock()
rh._active_tasks = {"x": {"owner": "alice", "status": "running"}}
rh.get_status.return_value = {"status": "running", "progress": {}}
router = setup_research_routes(rh)
target = next(r.endpoint for r in router.routes if getattr(r, "path", "") == "/api/research/status/{session_id}")
out = asyncio.run(target(session_id="x", request=_fake_request(user="alice")))
assert out == {"status": "running", "progress": {}}
def test_research_status_rejects_wrong_owner():
from routes.research_routes import setup_research_routes
rh = MagicMock()
rh._active_tasks = {"x": {"owner": "alice", "status": "running"}}
rh.get_status.return_value = {"status": "running", "progress": {}}
router = setup_research_routes(rh)
target = next(r.endpoint for r in router.routes if getattr(r, "path", "") == "/api/research/status/{session_id}")
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id="x", request=_fake_request(user="bob")))
assert exc.value.status_code == 404
def test_research_cancel_rejects_anonymous():
from routes.research_routes import setup_research_routes
rh = MagicMock()
router = setup_research_routes(rh)
target = next(r.endpoint for r in router.routes if getattr(r, "path", "") == "/api/research/cancel/{session_id}")
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id="x", request=_fake_request(user=None)))
assert exc.value.status_code == 401
def test_research_delete_rejects_anonymous():
from routes.research_routes import setup_research_routes
rh = MagicMock()
router = setup_research_routes(rh)
target = next(r.endpoint for r in router.routes if getattr(r, "path", "") == "/api/research/{session_id}")
# Note: `target` here is the most-recently registered route on this
# path which is the DELETE. Either /detail or /delete both match
# other paths — the {session_id} bare path is DELETE.
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id="x", request=_fake_request(user=None)))
assert exc.value.status_code == 401
def test_research_spinoff_rejects_anonymous():
"""spinoff must 401 before reading any research data."""
from routes.research_routes import setup_research_routes
rh = MagicMock()
router = setup_research_routes(rh, session_manager=MagicMock())
target = next(r.endpoint for r in router.routes if getattr(r, "path", "") == "/api/research/spinoff/{session_id}")
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id="x", request=_fake_request(user=None)))
assert exc.value.status_code == 401
def test_research_spinoff_rejects_wrong_owner():
"""A user must not be able to spin off (and thereby read) another user's
research report. The ownership gate must 404 before any data is read or a
new session is created. Regression for the cross-user disclosure IDOR."""
from routes.research_routes import setup_research_routes
sm = MagicMock()
rh = MagicMock()
rh._active_tasks = {"x": {"owner": "alice"}}
rh.get_result.return_value = "TOP SECRET REPORT"
router = setup_research_routes(rh, session_manager=sm)
target = next(r.endpoint for r in router.routes if getattr(r, "path", "") == "/api/research/spinoff/{session_id}")
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id="x", request=_fake_request(user="bob")))
assert exc.value.status_code == 404
# The attacker must never get a session created on their behalf.
sm.create_session.assert_not_called()
# ---------------------------------------------------------------------------
# pop_notifications owner filter
# ---------------------------------------------------------------------------
def test_pop_notifications_owner_filtered():
"""pop_notifications(owner='alice') must return only alice's items.
bob's and legacy ownerless items stay behind in the queue."""
# Build a minimal scheduler instance that we can hit directly.
# Reuse the real class so the test catches future regressions of
# the filter logic.
import sys, types
from unittest.mock import MagicMock as _MM
# `task_scheduler` pulls in lots of helpers — stub the ones it uses.
for s in ["src.builtin_actions", "src.ai_interaction", "src.endpoint_resolver",
"src.agent_loop", "src.session_manager"]:
if s not in sys.modules:
mod = types.ModuleType(s)
sys.modules[s] = mod
from src.task_scheduler import TaskScheduler
sch = TaskScheduler.__new__(TaskScheduler) # bypass __init__ network etc.
sch._pending_notifications = []
sch.add_notification("t1", "success", "id1", owner="alice")
sch.add_notification("t2", "error", "id2", owner="bob")
sch.add_notification("t3", "success", "id3", owner=None)
sch.add_notification("t4", "success", "id4", owner="alice")
alice = sch.pop_notifications(owner="alice")
alice_names = {n["task_name"] for n in alice}
# alice gets only her own rows; bob's row and legacy null-owner rows stay.
assert alice_names == {"t1", "t4"}
# bob's row and the legacy ownerless row are still queued.
remaining = sch._pending_notifications
assert {n["task_name"] for n in remaining} == {"t2", "t3"}
# Anonymous caller (owner=None) drains everything that's left.
rest = sch.pop_notifications(owner=None)
assert {n["task_name"] for n in rest} == {"t2", "t3"}
assert sch._pending_notifications == []
# ---------------------------------------------------------------------------
# Task action allowlist
# ---------------------------------------------------------------------------
def test_admin_only_actions_set_contains_shell_runners():
"""The constant defining shell-executing action types must include
the three risky entries. Catches accidental removal."""
from routes import task_routes
# `_ADMIN_ONLY_ACTIONS` is a closure constant. Easiest pin: re-read
# the source and check for the three risky entries + the admin gate
# wording.
src = open(task_routes.__file__, encoding="utf-8").read()
assert '"run_local"' in src
assert '"run_script"' in src
assert '"ssh_command"' in src
# And the gate is wired into both create and update paths.
assert "Action '" in src and "requires admin privileges" in src
def test_task_create_notification_default_allows_action_specific_defaults():
"""Omitted notifications_enabled should stay None so create_task can
default noisy/quiet built-ins differently."""
from routes.task_routes import TaskCreate
req = TaskCreate(task_type="action", action="check_email_urgency", schedule="cron", cron_expression="*/15 * * * *")
assert req.notifications_enabled is None
def test_ship_paused_housekeeping_stays_paused_by_default():
"""Built-ins marked ship_paused are intentionally opt-in even after
the user enables the rest of Tasks."""
from routes import task_routes
from src import task_scheduler
route_src = open(task_routes.__file__, encoding="utf-8").read()
scheduler_src = open(task_scheduler.__file__, encoding="utf-8").read()
assert '"ship_paused": True' in scheduler_src
assert 'defs.get("ship_paused")' in scheduler_src
assert 'defs.get("ship_paused")' in route_src
def test_task_payload_exposes_crew_member_id_for_ui_category():
from routes import task_routes
src = open(task_routes.__file__, encoding="utf-8").read()
assert '"crew_member_id"' in src