Merge remote-tracking branch 'origin/dev'

# Conflicts:
#	routes/contacts_routes.py
This commit is contained in:
pewdiepie-archdaemon
2026-07-07 00:51:34 +00:00
80 changed files with 5885 additions and 2383 deletions
+9
View File
@@ -50,6 +50,14 @@ AREAS: tuple[str, ...] = (
# Backward-compatible aggregate selectors for focused runs whose original
# monolithic files were split into more specific taxonomy sub-areas.
SUB_AREA_ALIASES: dict[str, tuple[str, ...]] = {
"service_health": (
"service_health_chromadb",
"service_health_search",
"service_health_ntfy",
"service_health_email",
"service_health_providers",
"service_health_collect",
),
"embedding": ("embedding", "embedding_memory"),
}
@@ -214,6 +222,7 @@ def build_parser(
"""Build the argument parser for the focused runner."""
if valid_sub_areas is None:
valid_sub_areas = discover_sub_areas()
valid_sub_areas = frozenset(valid_sub_areas) | frozenset(SUB_AREA_ALIASES)
parser = argparse.ArgumentParser(
prog="run_focus.py",
description=(
+5
View File
@@ -72,3 +72,8 @@ def test_parse_tool_args_lives_in_tool_utils_single_source():
assert _parse_tool_args('{"action":"add"}') == {"action": "add"}
# body-envelope unwrap still works
assert _parse_tool_args('{"body":{"action":"x"}}') == {"action": "x"}
# non-dict JSON values should return {}
assert _parse_tool_args('[1, 2]') == {}
assert _parse_tool_args('42') == {}
assert _parse_tool_args('"hello"') == {}
+5 -10
View File
@@ -334,16 +334,11 @@ def test_pop_notifications_owner_filtered():
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
from src.task_action_policy import ADMIN_ONLY_TASK_ACTIONS
assert "run_local" in ADMIN_ONLY_TASK_ACTIONS
assert "run_script" in ADMIN_ONLY_TASK_ACTIONS
assert "ssh_command" in ADMIN_ONLY_TASK_ACTIONS
def test_task_create_notification_default_allows_action_specific_defaults():
+26
View File
@@ -78,3 +78,29 @@ async def test_list_events_honors_range_aliases(start_key, end_key):
summaries = [event["summary"] for event in res["events"]]
assert summaries == ["Late June planning"]
assert "between 2126-06-01 and 2126-07-01" in res["response"]
async def test_list_events_rejects_partial_loose_range():
from src.tool_implementations import do_manage_calendar
owner = "calendar-partial-" + uuid.uuid4().hex[:8]
# Partial: has query and start, but no end
res = await do_manage_calendar(json.dumps({
"action": "list_events",
"query": "July",
"start_time": "2126-07-01T00:00:00Z",
}), owner=owner)
assert res.get("exit_code", 1) == 1, res
assert "list_events needs explicit start/end" in res.get("error", "")
# Partial: has query and end, but no start
res2 = await do_manage_calendar(json.dumps({
"action": "list_events",
"query": "July",
"end_time": "2126-07-31T23:59:59Z",
}), owner=owner)
assert res2.get("exit_code", 1) == 1, res2
assert "list_events needs explicit start/end" in res2.get("error", "")
+33
View File
@@ -78,3 +78,36 @@ async def test_update_event_dtstart_anchored_to_user_tz(tokyo_offset):
assert bool(ev.is_utc) is True
finally:
db.close()
async def test_list_events_accepts_start_time_end_time_aliases():
from src.tool_implementations import do_manage_calendar
owner = "list-" + uuid.uuid4().hex[:6]
created = await do_manage_calendar(json.dumps({
"action": "create_event",
"summary": "July planning",
"dtstart": "2026-07-15T12:00:00Z",
"dtend": "2026-07-15T13:00:00Z",
}), owner=owner)
assert created.get("exit_code", 0) == 0, created
listed = await do_manage_calendar(json.dumps({
"action": "list_events",
"start_time": "2026-07-01T00:00:00Z",
"end_time": "2026-08-01T00:00:00Z",
}), owner=owner)
assert listed.get("exit_code", 0) == 0, listed
assert "between 2026-07-01 and 2026-08-01" in listed["response"]
assert [event["summary"] for event in listed["events"]] == ["July planning"]
async def test_list_events_query_without_range_does_not_default_to_two_weeks():
from src.tool_implementations import do_manage_calendar
listed = await do_manage_calendar(json.dumps({
"action": "list_events",
"query": "July",
}), owner="list-" + uuid.uuid4().hex[:6])
assert listed.get("exit_code") == 1, listed
assert "explicit start/end" in listed["error"]
+22 -9
View File
@@ -14,6 +14,8 @@ from pathlib import Path
import pytest
from src.action_intents import classify_tool_intent
_CHAT_ROUTES = Path(__file__).resolve().parent.parent / "routes" / "chat_routes.py"
@@ -73,7 +75,7 @@ def test_allow_web_search_reads_from_body_as_fallback():
)
def test_disabled_tools_does_not_bash_when_allow_bash_is_none():
def test_disabled_tools_respects_missing_vs_explicit_toggles():
"""When allow_bash is not set (None), bash must NOT be unconditionally
added to disabled_tools. The per-user privilege check handles it.
"""
@@ -89,8 +91,8 @@ def test_disabled_tools_does_not_bash_when_allow_bash_is_none():
assert "allow_web_search is not None" in source, (
"disabled_tools check must guard against allow_web_search being None"
)
assert "_explicit_web_intent" in source and "not _explicit_web_intent" in source, (
"explicit web-search requests must override an off web toggle for that turn"
assert "and not _explicit_web_intent" not in source, (
"explicit allow_web_search=false must not be overridden by prompt web intent"
)
@@ -116,7 +118,6 @@ def _build_disabled_tools(
if (
allow_web_search is not None
and str(allow_web_search).lower() != "true"
and not explicit_web_intent
):
disabled_tools.add("web_search")
disabled_tools.add("web_fetch")
@@ -156,15 +157,27 @@ def test_json_body_allow_web_search_false_disables_web():
assert "web_fetch" in disabled
def test_explicit_web_intent_overrides_false_web_toggle_for_turn():
"""A stale/off web toggle must not remove web tools when the message
explicitly asks to use web search."""
@pytest.mark.parametrize(
"message",
[
"please use web search for current CVEs",
"search the web for current CVEs",
"can you look up the latest docs",
],
)
def test_explicit_false_disables_web_despite_prompt_web_intent(message):
"""Explicit allow_web_search=false is a hard deny even when the prompt
asks for web search."""
intent = classify_tool_intent(message)
assert intent is not None
assert intent.category == "web"
disabled = _build_disabled_tools(
allow_web_search="false",
explicit_web_intent=True,
)
assert "web_search" not in disabled
assert "web_fetch" not in disabled
assert "web_search" in disabled
assert "web_fetch" in disabled
def test_admin_user_gets_bash_enabled_by_default():
+24
View File
@@ -91,6 +91,30 @@ def test_grep_python_fallback_when_no_rg(repo, monkeypatch):
assert ".git/config" not in r["output"]
@pytest.mark.skipif(shutil.which("rg") is None, reason="targets the ripgrep fast-path")
def test_grep_skips_case_variant_sensitive_files_rg(repo):
"""The rg fast-path must exclude deny-listed key files case-insensitively.
A file whose name is a case variant of a sensitive pattern (e.g. ID_RSA vs
id_rsa, Known_Hosts vs known_hosts) points at the same secret on a
case-insensitive filesystem, so grep must not return its contents. The
Python fallback already folds case via _is_sensitive_path; a plain --glob
exclusion is case-sensitive, so it would leak these — this pins the rg path.
"""
token = "GREPSECRET_TOKEN_ZZZ"
with open(os.path.join(repo, "notes.txt"), "w") as f:
f.write(f"see {token}\n")
with open(os.path.join(repo, "ID_RSA"), "w") as f:
f.write(f"PRIVATE {token}\n")
with open(os.path.join(repo, "Known_Hosts"), "w") as f:
f.write(f"host {token}\n")
r = _run("grep", f'{{"pattern": "{token}", "path": "{repo}"}}')
assert r["exit_code"] == 0
assert "notes.txt" in r["output"] # ordinary matches still returned
assert "ID_RSA" not in r["output"] # case-variant key excluded
assert "Known_Hosts" not in r["output"]
# ── glob ──────────────────────────────────────────────────────────────────
def test_glob_py(repo):
+46
View File
@@ -0,0 +1,46 @@
"""Regression test for the contacts route shim (slice 2e, #4082/#4071).
The backward-compat shim at ``routes/contacts_routes.py`` uses ``sys.modules``
replacement so the legacy import path and the canonical ``routes.contacts.*``
path resolve to the *same* module object. This is required because:
* ``test_carddav_password_encryption.py`` uses string-targeted
``monkeypatch.setattr("routes.contacts_routes.SETTINGS_FILE", ...)`` which
must reach the canonical module to take effect;
* ``test_contacts_add_null_name.py`` / ``test_contacts_carddav_security.py``
use ``import routes.contacts_routes as cr`` + ``setattr(cr, ...)``;
* the module owns mutable state (``_contact_cache``) that must be shared
across import paths.
"""
import importlib
import routes.contacts_routes as _shim_contacts # noqa: F401
def test_legacy_and_canonical_contacts_module_are_same_object():
"""``import routes.contacts_routes`` must alias the canonical module."""
legacy = importlib.import_module("routes.contacts_routes")
canonical = importlib.import_module("routes.contacts.contacts_routes")
assert legacy is canonical, (
"routes.contacts_routes shim must resolve to the canonical "
"routes.contacts.contacts_routes module object"
)
def test_string_targeted_monkeypatch_reaches_canonical(monkeypatch):
"""String-targeted ``monkeypatch.setattr`` via the legacy path must reach
the canonical module.
``test_carddav_password_encryption.py`` patches
``"routes.contacts_routes.SETTINGS_FILE"`` as a fixture setup; for that
to take effect at runtime, the legacy module name and the canonical
module must be identical.
"""
canonical = importlib.import_module("routes.contacts.contacts_routes")
sentinel = object()
monkeypatch.setattr("routes.contacts_routes.setup_contacts_routes", sentinel)
assert canonical.setup_contacts_routes is sentinel, (
"string-targeted monkeypatch via legacy path did not reach the canonical module"
)
@@ -0,0 +1,117 @@
"""Cross-tenant access control for legacy owner-less email accounts.
`email_accounts` is the one owner-scoped table left out of the legacy-owner
migration backfill (core/database.py), so rows with owner NULL/"" persist on a
multi-user deploy — e.g. an account configured while auth was disabled, or an
imported legacy row. The HTTP route guards (`_assert_owns_account` and the
explicit-account_id path in `_get_email_config`) must scope such rows to a
mailbox match, exactly like the `_owner_or_matching_legacy_account` fallback and
the MCP `_account_visible_to_owner` gate. Otherwise any authenticated user can
read/send/update-credentials/delete another tenant's imported mailbox.
"""
from unittest import mock
import pytest
from fastapi import HTTPException
def _make_db():
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from core.database import Base
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
Factory = sessionmaker(bind=engine)
return Factory
def _make_account(Factory, account_id, owner, imap_user, from_address="", is_default=False):
from core.database import EmailAccount
db = Factory()
row = EmailAccount(
id=account_id,
owner=owner,
name="Test",
enabled=True,
is_default=is_default,
imap_host="imap.example.com",
imap_port=993,
imap_user=imap_user,
smtp_host="smtp.example.com",
smtp_port=587,
smtp_user=imap_user,
from_address=from_address or imap_user,
)
db.add(row)
db.commit()
db.close()
def test_assert_owns_account_rejects_ownerless_account_for_other_tenant():
"""The core regression: a legacy owner-less mailbox is NOT accessible to an
authenticated caller whose own mailbox does not match it."""
from routes.email_helpers import _assert_owns_account
Factory = _make_db()
# owner="" (created while auth was disabled); mailbox belongs to victim.
_make_account(Factory, "acct-legacy", owner="", imap_user="victim@corp.com")
with mock.patch("core.database.SessionLocal", Factory):
with pytest.raises(HTTPException) as exc:
_assert_owns_account("acct-legacy", "attacker")
assert exc.value.status_code == 404
def test_assert_owns_account_allows_owned_account():
from routes.email_helpers import _assert_owns_account
Factory = _make_db()
_make_account(Factory, "acct-bob", owner="bob", imap_user="bob@corp.com")
with mock.patch("core.database.SessionLocal", Factory):
_assert_owns_account("acct-bob", "bob") # no raise
def test_assert_owns_account_allows_ownerless_account_on_mailbox_match():
"""Legacy-claim path stays intact: the user whose mailbox matches an
owner-less account may still act on it (imap_user or from_address)."""
from routes.email_helpers import _assert_owns_account
Factory = _make_db()
_make_account(Factory, "acct-legacy", owner="", imap_user="alice@corp.com")
with mock.patch("core.database.SessionLocal", Factory):
_assert_owns_account("acct-legacy", "alice@corp.com") # no raise
def test_assert_owns_account_noop_for_single_user_mode():
"""owner == "" (unconfigured / single-user) accepts any account, unchanged."""
from routes.email_helpers import _assert_owns_account
Factory = _make_db()
_make_account(Factory, "acct-legacy", owner="", imap_user="whoever@corp.com")
with mock.patch("core.database.SessionLocal", Factory):
_assert_owns_account("acct-legacy", "") # no raise
def test_get_email_config_does_not_resolve_ownerless_account_for_other_tenant(monkeypatch):
"""`_get_email_config(account_id=..., owner=...)` must not serve an
owner-less account (and its decrypted creds) to a non-matching tenant."""
import routes.email_helpers as eh
Factory = _make_db()
_make_account(Factory, "acct-legacy", owner="", imap_user="victim@corp.com", is_default=True)
# Make the settings.json / env fallback empty and deterministic.
monkeypatch.setattr(eh, "_load_settings", lambda: {}, raising=False)
for var in ("IMAP_HOST", "SMTP_HOST", "IMAP_USER", "SMTP_USER"):
monkeypatch.delenv(var, raising=False)
with mock.patch("core.database.SessionLocal", Factory):
cfg = eh._get_email_config(account_id="acct-legacy", owner="attacker")
assert cfg.get("account_id") != "acct-legacy"
def test_get_email_config_resolves_ownerless_account_on_mailbox_match():
"""The mailbox owner still resolves their claimable legacy account by id."""
import routes.email_helpers as eh
Factory = _make_db()
_make_account(Factory, "acct-legacy", owner="", imap_user="alice@corp.com")
with mock.patch("core.database.SessionLocal", Factory):
cfg = eh._get_email_config(account_id="acct-legacy", owner="alice@corp.com")
assert cfg.get("account_id") == "acct-legacy"
+148
View File
@@ -0,0 +1,148 @@
"""Tests for the report-only changed-test guidance helper."""
from __future__ import annotations
import importlib.util
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SCRIPT_PATH = ROOT / ".github" / "scripts" / "focused_test_guidance.py"
def _load_helper():
spec = importlib.util.spec_from_file_location("focused_test_guidance", SCRIPT_PATH)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
guidance = _load_helper()
def test_parse_paths_supports_nul_delimited_git_output():
raw_paths = b"tests/test_alpha.py\0tests/path with spaces/test_beta.py\0"
assert guidance.parse_paths(raw_paths) == [
"tests/test_alpha.py",
"tests/path with spaces/test_beta.py",
]
def test_select_test_paths_ignores_paths_outside_tests():
paths = [
"src/test_alpha.py",
"tests/test_beta.py",
"./tests/unit/example_test.py",
"tests/../src/test_gamma.py",
]
assert guidance.select_test_paths(paths) == [
"tests/test_beta.py",
"tests/unit/example_test.py",
]
def test_select_test_paths_deduplicates_paths():
paths = ["tests/test_alpha.py", "./tests/test_alpha.py"]
assert guidance.select_test_paths(paths) == ["tests/test_alpha.py"]
def test_format_report_builds_command_for_changed_python_test():
report = guidance.format_report(["tests/test_beta.py"])
assert "- `tests/test_beta.py`" in report
assert "python3 -m pytest -q tests/test_beta.py" in report
assert "does not infer tests from source changes" in report
assert "Existing blocking CI remains the source of truth" in report
def test_format_report_lists_changed_non_python_test_path_without_command():
report = guidance.format_report(["tests/README.md"])
assert "- `tests/README.md`" in report
assert "No directly runnable pytest files changed." in report
assert "python3 -m pytest" not in report
def test_format_report_ignores_src_path():
report = guidance.format_report(["src/test_ignored.py"])
assert "src/test_ignored.py" not in report
assert "No changed paths under `tests/`." in report
def test_format_report_shell_quotes_path_with_spaces():
report = guidance.format_report(["tests/path with spaces/test_beta.py"])
assert "- `tests/path with spaces/test_beta.py`" in report
assert (
"python3 -m pytest -q 'tests/path with spaces/test_beta.py'"
) in report
def test_format_report_handles_no_changed_test_paths():
report = guidance.format_report([])
assert "No changed paths under `tests/`." in report
assert "No directly runnable pytest files changed." in report
def _git(repo: Path, *args: str) -> str:
return subprocess.check_output(["git", *args], cwd=repo, text=True).strip()
def _write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def test_changed_paths_from_merge_base_excludes_base_only_test_changes(tmp_path, monkeypatch):
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init")
_git(repo, "config", "user.email", "ci@example.test")
_git(repo, "config", "user.name", "CI Test")
_write(repo / "tests/test_shared.py", "def test_shared():\n assert True\n")
_git(repo, "add", "tests/test_shared.py")
_git(repo, "commit", "-m", "base")
ancestor = _git(repo, "rev-parse", "HEAD")
_git(repo, "checkout", "-b", "feature")
_write(repo / "tests/test_pr_delta.py", "def test_pr_delta():\n assert True\n")
_git(repo, "add", "tests/test_pr_delta.py")
_git(repo, "commit", "-m", "add pr test")
head_sha = _git(repo, "rev-parse", "HEAD")
_git(repo, "checkout", "-b", "dev", ancestor)
_write(repo / "tests/test_shared.py", "def test_shared():\n assert 1 == 1\n")
_git(repo, "add", "tests/test_shared.py")
_git(repo, "commit", "-m", "base-only test change")
base_sha = _git(repo, "rev-parse", "HEAD")
endpoint_paths = guidance.parse_paths(
subprocess.check_output(
[
"git",
"diff",
"--name-only",
"--diff-filter=ACMRT",
"-z",
base_sha,
head_sha,
"--",
"tests/",
],
cwd=repo,
)
)
assert "tests/test_shared.py" in endpoint_paths
monkeypatch.chdir(repo)
assert guidance.changed_paths_from_merge_base(base_sha, head_sha) == [
"tests/test_pr_delta.py"
]
+1 -1
View File
@@ -16,7 +16,7 @@ in this repo do).
import ast
from pathlib import Path
SRC = Path(__file__).resolve().parent.parent / "routes" / "history_routes.py"
SRC = Path(__file__).resolve().parent.parent / "routes" / "history" / "history_routes.py"
def _function_source(src_text, name):
@@ -25,7 +25,7 @@ from sqlalchemy.orm import sessionmaker
from core.database import Base, ChatMessage as DbChatMessage, Session as DbSession
HISTORY_ROUTES = Path(__file__).resolve().parent.parent / "routes" / "history_routes.py"
HISTORY_ROUTES = Path(__file__).resolve().parent.parent / "routes" / "history" / "history_routes.py"
def test_chatmessage_model_has_timestamp_not_created_at():
+43
View File
@@ -0,0 +1,43 @@
"""Regression test for the history route shim (slice 2d, #4082/#4071).
The backward-compat shim at ``routes/history_routes.py`` uses ``sys.modules``
replacement so the legacy import path and the canonical ``routes.history.*``
path resolve to the *same* module object. This is required because
``test_history_compact_tool_calls.py`` and ``test_fork_session_metadata.py``
do ``import routes.history_routes as history_routes`` followed by
``monkeypatch.setattr(history_routes, "_verify_session_owner", ...)`` — for
those patches to take effect at runtime, the legacy module object and the
canonical one must be identical. This test pins that contract.
"""
import importlib
import routes.history_routes as _shim_history # noqa: F401
def test_legacy_and_canonical_history_module_are_same_object():
"""``import routes.history_routes`` must alias the canonical module."""
legacy = importlib.import_module("routes.history_routes")
canonical = importlib.import_module("routes.history.history_routes")
assert legacy is canonical, (
"routes.history_routes shim must resolve to the canonical "
"routes.history.history_routes module object"
)
def test_monkeypatch_via_legacy_alias_reaches_canonical(monkeypatch):
"""Patching through the legacy alias must reach the canonical module.
Several history tests do ``import routes.history_routes as history_routes``
followed by ``monkeypatch.setattr(history_routes, "_verify_session_owner",
...)``. For that to take effect at runtime, the legacy module object and the
canonical one must be identical.
"""
legacy = importlib.import_module("routes.history_routes")
canonical = importlib.import_module("routes.history.history_routes")
sentinel = object()
monkeypatch.setattr(legacy, "setup_history_routes", sentinel)
assert canonical.setup_history_routes is sentinel, (
"monkeypatch via legacy alias did not reach the canonical module"
)
+56
View File
@@ -0,0 +1,56 @@
"""_imap_move must address messages by UID, not sequence number.
The auto-spam poller passes a real IMAP UID (from conn.uid("SEARCH", ...))
to _imap_move, but the function used conn.copy()/conn.store(), which operate
on message SEQUENCE NUMBERS. So a UID like 90521 was interpreted as sequence
number 90521 — moving/deleting the wrong message or silently no-oping. It
must use the UID commands.
"""
import sys
import types
import pytest
@pytest.fixture
def email_helpers(monkeypatch, tmp_path):
# Keep _init_scheduled_db (run at import) off the real data dir.
monkeypatch.setenv("ODYSSEUS_DATA_DIR", str(tmp_path))
import routes.email_helpers as eh
return eh
class _FakeIMAP:
def __init__(self):
self.calls = []
def select(self, mbox):
self.calls.append(("select", mbox)); return ("OK", [b""])
def copy(self, *a):
self.calls.append(("copy",) + a); return ("OK", [b""])
def store(self, *a):
self.calls.append(("store",) + a); return ("OK", [b""])
def uid(self, *a):
self.calls.append(("uid",) + a); return ("OK", [b""])
def expunge(self):
self.calls.append(("expunge",)); return ("OK", [b""])
def logout(self):
pass
def test_move_uses_uid_commands_not_seqnum(email_helpers, monkeypatch):
fake = _FakeIMAP()
monkeypatch.setattr(email_helpers, "_imap_connect", lambda *a, **k: fake)
ok = email_helpers._imap_move(b"90521", "Spam", src="INBOX")
assert ok is True
verbs = [c[0] for c in fake.calls]
uid_ops = [c[1] for c in fake.calls if c[0] == "uid"]
assert "COPY" in uid_ops and "STORE" in uid_ops
# the sequence-number commands must NOT be used to address a UID
assert "copy" not in verbs
assert "store" not in verbs
+99
View File
@@ -0,0 +1,99 @@
"""Regression: execute_api_call must run the outbound SSRF guard.
The api_call agent tool lets the LLM drive HTTP requests against a
user-configured integration base_url. Before this guard, a base_url (or a
hostname resolving) to the cloud metadata range was requested server-side
with the integration's auth headers attached. execute_api_call now validates
the joined URL with src.url_safety.check_outbound_url before connecting:
link-local/metadata is always rejected; RFC-1918/loopback only when
INTEGRATION_API_BLOCK_PRIVATE_IPS=true (LAN integrations are the primary
use case, so private stays allowed by default).
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src import integrations
def _integration(base_url):
return {
"id": "test_integ",
"name": "TestInteg",
"enabled": True,
"base_url": base_url,
"auth_type": "bearer",
"api_key": "secret-token",
"auth_header": "",
"auth_param": "",
"description": "",
"preset": "",
}
async def _call(base_url, path="/items"):
resp = MagicMock()
resp.status_code = 200
resp.headers = {"content-type": "application/json"}
resp.json.return_value = {"ok": True}
resp.text = '{"ok": true}'
client = AsyncMock()
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=None)
client.request = AsyncMock(return_value=resp)
with (
patch.object(integrations, "_find_integration",
return_value=_integration(base_url)),
patch("httpx.AsyncClient", return_value=client),
):
result = await integrations.execute_api_call("test_integ", "GET", path)
return result, client
@pytest.mark.asyncio
async def test_metadata_ip_base_url_is_rejected_without_requesting():
result, client = await _call("http://169.254.169.254")
assert result["exit_code"] == 1
assert "rejected" in result["error"].lower()
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_hostname_resolving_to_metadata_ip_is_rejected(monkeypatch):
"""DNS-based variant: an innocuous-looking hostname that resolves into
the link-local range must be caught by the resolver check."""
monkeypatch.setattr("src.url_safety._default_resolver",
lambda host: ["169.254.169.254"])
result, client = await _call("http://internal.attacker.example")
assert result["exit_code"] == 1
assert "rejected" in result["error"].lower()
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_public_ip_base_url_still_requests():
# Public literal — no DNS involved.
result, client = await _call("http://93.184.216.34")
assert result.get("exit_code") == 0
client.request.assert_called_once()
@pytest.mark.asyncio
async def test_private_base_url_allowed_by_default_blocked_with_knob(monkeypatch):
# Local-first default: LAN integrations (Home Assistant etc.) must work.
monkeypatch.delenv("INTEGRATION_API_BLOCK_PRIVATE_IPS", raising=False)
result, client = await _call("http://192.168.1.50")
assert result.get("exit_code") == 0
client.request.assert_called_once()
# Locked-down deployments opt in to a full private/loopback block.
monkeypatch.setenv("INTEGRATION_API_BLOCK_PRIVATE_IPS", "true")
result, client = await _call("http://192.168.1.50")
assert result["exit_code"] == 1
assert "rejected" in result["error"].lower()
client.request.assert_not_called()
@@ -83,6 +83,9 @@ async def _call(json_data, status=200):
with (
patch.object(integrations, "_find_integration", return_value=DUMMY_INTEGRATION),
patch("httpx.AsyncClient", return_value=mock_client),
# api.example.com doesn't resolve; the SSRF guard would fail closed.
# These tests are about truncation, so stub the guard open.
patch("src.url_safety.check_outbound_url", return_value=(True, "ok")),
):
return await integrations.execute_api_call("test_integ", "GET", "/items")
@@ -98,6 +101,9 @@ async def _call_with_integration(integration, path="/items"):
with (
patch.object(integrations, "_find_integration", return_value=integration),
patch("httpx.AsyncClient", return_value=mock_client),
# api.example.com doesn't resolve; the SSRF guard would fail closed.
# These tests are about URL joining, so stub the guard open.
patch("src.url_safety.check_outbound_url", return_value=(True, "ok")),
):
result = await integrations.execute_api_call("test_integ", "GET", path)
return result, mock_client
+116
View File
@@ -0,0 +1,116 @@
"""Tests for integration URL construction in execute_api_call.
Covers the trailing-slash regression from #5138: a bare "/" path must
resolve to the base URL itself, not base + "/". Discord webhook URLs
404 on the trailing-slash variant, so api_call against a
POST-to-base integration silently failed.
"""
import sys
import types
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Minimal stubs so src.integrations can be imported without heavy deps
# ---------------------------------------------------------------------------
for mod_name in ("core", "core.atomic_io", "core.platform_compat"):
if mod_name not in sys.modules:
sys.modules[mod_name] = types.ModuleType(mod_name)
core_atomic = sys.modules["core.atomic_io"]
if not hasattr(core_atomic, "atomic_write_json"):
core_atomic.atomic_write_json = lambda *a, **kw: None # type: ignore
core_compat = sys.modules["core.platform_compat"]
if not hasattr(core_compat, "safe_chmod"):
core_compat.safe_chmod = lambda *a, **kw: None # type: ignore
if "src.secret_storage" not in sys.modules:
stub = types.ModuleType("src.secret_storage")
stub.encrypt = lambda s: s # type: ignore
stub.decrypt = lambda s: s # type: ignore
stub.is_encrypted = lambda s: False # type: ignore
sys.modules["src.secret_storage"] = stub
if "src.constants" not in sys.modules:
stub_c = types.ModuleType("src.constants")
stub_c.DATA_DIR = "/tmp" # type: ignore
stub_c.INTEGRATIONS_FILE = "/tmp/integrations_test.json" # type: ignore
stub_c.SETTINGS_FILE = "/tmp/settings_test.json" # type: ignore
sys.modules["src.constants"] = stub_c
from src import integrations # noqa: E402
# ---------------------------------------------------------------------------
# _join_integration_url unit tests
# ---------------------------------------------------------------------------
WEBHOOK_BASE = "https://discord.com/api/webhooks/123/tokentokentoken"
@pytest.mark.parametrize(
"base,path,expected",
[
# Bare "/" (the minimum path execute_api_call accepts) must not
# grow a trailing slash — Discord webhooks 404 on it (#5138).
(WEBHOOK_BASE, "/", WEBHOOK_BASE),
(WEBHOOK_BASE + "/", "/", WEBHOOK_BASE),
(WEBHOOK_BASE, "", WEBHOOK_BASE),
# Normal paths keep joining exactly as before.
("http://api.example.com", "/items", "http://api.example.com/items"),
("http://api.example.com/", "/items", "http://api.example.com/items"),
("http://host/base", "/v1/me", "http://host/base/v1/me"),
# A deliberate trailing slash inside a non-empty path is preserved
# (e.g. linkding's /api/tags/, Home Assistant's /api/).
("http://host", "/api/tags/", "http://host/api/tags/"),
("http://host", "/api/", "http://host/api/"),
],
)
def test_join_integration_url(base, path, expected):
assert integrations._join_integration_url(base, path) == expected
# ---------------------------------------------------------------------------
# Behavioral test through execute_api_call
# ---------------------------------------------------------------------------
DISCORD_INTEGRATION = {
"id": "discord_test",
"name": "Discord Webhook",
"enabled": True,
"base_url": WEBHOOK_BASE,
"auth_type": "none",
"api_key": "",
"auth_header": "",
"auth_param": "",
"description": "",
"preset": "discord_webhook",
}
@pytest.mark.asyncio
async def test_api_call_root_path_has_no_trailing_slash():
mock_resp = MagicMock()
mock_resp.status_code = 204
mock_resp.headers = {"content-type": "text/plain"}
mock_resp.text = ""
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client.request = AsyncMock(return_value=mock_resp)
with (
patch.object(integrations, "_find_integration", return_value=DISCORD_INTEGRATION),
patch("httpx.AsyncClient", return_value=mock_client),
):
result = await integrations.execute_api_call(
"discord_test", "POST", "/", body={"content": "test"}
)
assert result.get("exit_code") == 0
requested_url = mock_client.request.call_args.args[1]
assert requested_url == WEBHOOK_BASE
+13
View File
@@ -23,3 +23,16 @@ def test_markdown_raw_html_sanitizer_strips_scriptable_css():
assert "if (name === 'style')" in src
assert r"javascript:|vbscript:|data:|expression\(" in src
assert "el.removeAttribute(attr.name);" in src
def test_email_rich_body_render_path_reuses_raw_html_sanitizer():
markdown_src = (_REPO / "static" / "js" / "markdown.js").read_text(encoding="utf-8")
document_src = (_REPO / "static" / "js" / "document.js").read_text(encoding="utf-8")
email_body_helper = document_src.split("function _emailBodyToHtml(text)", 1)[1].split(
" // Mirror the rich body's plain text", 1
)[0]
assert "export function sanitizeAllowedHtml(html)" in markdown_src
assert "sanitizeAllowedHtml," in markdown_src
assert "markdownModule.sanitizeAllowedHtml(t)" in email_body_helper
assert "return t;" not in email_body_helper
+116 -5
View File
@@ -16,7 +16,20 @@ pytest.importorskip("mcp")
import mcp_servers.email_server as es
def _init_accounts_db(path):
@pytest.fixture(autouse=True)
def _clear_mcp_email_owner_env(monkeypatch):
for key in es._OWNER_ENV_KEYS:
monkeypatch.delenv(key, raising=False)
es._ACCOUNT_CACHE.clear()
yield
es._ACCOUNT_CACHE.clear()
def _init_accounts_db(path, rows=None):
rows = rows or [
("acct-alice", "alice", "Alice Mail", 1, "alice@example.com", "alice@example.com", "alice@example.com", "2026-01-01"),
("acct-bob", "bob", "Bob Mail", 1, "bob@example.com", "bob@example.com", "bob@example.com", "2026-01-02"),
]
conn = sqlite3.connect(path)
conn.execute(
"""
@@ -50,10 +63,7 @@ def _init_accounts_db(path):
VALUES (?, ?, ?, ?, 1, 'imap.example.com', 993, ?, '', 1,
'smtp.example.com', 465, 'ssl', ?, '', ?, ?)
""",
[
("acct-alice", "alice", "Alice Mail", 1, "alice@example.com", "alice@example.com", "alice@example.com", "2026-01-01"),
("acct-bob", "bob", "Bob Mail", 1, "bob@example.com", "bob@example.com", "bob@example.com", "2026-01-02"),
],
rows,
)
conn.commit()
conn.close()
@@ -106,6 +116,37 @@ async def test_mcp_email_requires_owner_when_multiple_account_owners_exist(tmp_p
assert "requires an authenticated owner" in out[0].text
@pytest.mark.asyncio
async def test_mcp_email_requires_owner_when_any_account_owner_exists(tmp_path, monkeypatch):
db_path = tmp_path / "app.db"
_init_accounts_db(
db_path,
rows=[
("acct-alice", "alice", "Alice Mail", 1, "alice@example.com", "alice@example.com", "alice@example.com", "2026-01-01"),
],
)
monkeypatch.setattr(es, "APP_DB", str(db_path))
out = await es.call_tool("list_email_accounts", {})
assert "requires an authenticated owner" in out[0].text
assert "Alice Mail" not in out[0].text
@pytest.mark.asyncio
async def test_mcp_email_configured_owner_filters_accounts(tmp_path, monkeypatch):
db_path = tmp_path / "app.db"
_init_accounts_db(db_path)
monkeypatch.setattr(es, "APP_DB", str(db_path))
monkeypatch.setenv("ODYSSEUS_MCP_EMAIL_OWNER", "alice")
out = await es.call_tool("list_email_accounts", {})
text = out[0].text
assert "Alice Mail" in text
assert "Bob Mail" not in text
def test_mcp_email_scoped_owner_without_visible_account_skips_legacy_fallback(tmp_path, monkeypatch):
db_path = tmp_path / "app.db"
settings_path = tmp_path / "settings.json"
@@ -137,11 +178,81 @@ def test_mcp_email_scoped_owner_without_visible_account_skips_legacy_fallback(tm
es._ACCOUNT_CACHE.clear()
@pytest.mark.asyncio
async def test_mcp_email_owner_cannot_use_other_owner_account_for_list_read_send_or_draft(tmp_path, monkeypatch):
import src.constants as constants
db_path = tmp_path / "app.db"
scheduled_path = tmp_path / "scheduled_emails.db"
_init_accounts_db(db_path)
monkeypatch.setattr(es, "APP_DB", str(db_path))
monkeypatch.setattr(constants, "SCHEDULED_EMAILS_DB", str(scheduled_path))
monkeypatch.setattr(es, "_read_agent_email_confirm_setting", lambda: True)
calls = [
("list_emails", {"account": "Bob Mail"}),
("read_email", {"uid": "1", "account": "Bob Mail"}),
("send_email", {
"to": "recipient@example.com",
"subject": "Blocked",
"body": "Do not stage.",
"account": "Bob Mail",
}),
("draft_email", {
"to": "recipient@example.com",
"subject": "Blocked",
"body": "Do not draft.",
"account": "Bob Mail",
}),
]
for tool_name, args in calls:
out = await es.call_tool(tool_name, {**args, "_odysseus_owner": "alice"})
assert "Email account not found for selector" in out[0].text, tool_name
assert "Bob Mail" not in out[0].text or "Available accounts" in out[0].text
assert not scheduled_path.exists()
@pytest.mark.asyncio
async def test_mcp_send_email_stages_with_visible_owner_account_id(tmp_path, monkeypatch):
import src.constants as constants
app_db_path = tmp_path / "app.db"
scheduled_path = tmp_path / "scheduled_emails.db"
_init_accounts_db(app_db_path)
monkeypatch.setattr(es, "APP_DB", str(app_db_path))
monkeypatch.setattr(constants, "SCHEDULED_EMAILS_DB", str(scheduled_path))
monkeypatch.setattr(es, "_read_agent_email_confirm_setting", lambda: True)
out = await es.call_tool(
"send_email",
{
"to": "recipient@example.com",
"subject": "Review",
"body": "Please review.",
"account": "Alice Mail",
"_odysseus_owner": "alice",
},
)
assert "Draft staged for approval" in out[0].text
conn = sqlite3.connect(scheduled_path)
try:
row = conn.execute(
"SELECT owner, status, account_id FROM scheduled_emails"
).fetchone()
finally:
conn.close()
assert row == ("alice", "agent_draft", "acct-alice")
@pytest.mark.asyncio
async def test_mcp_send_email_stages_owner_scoped_pending_draft(tmp_path, monkeypatch):
import src.constants as constants
db_path = tmp_path / "scheduled_emails.db"
monkeypatch.setattr(es, "APP_DB", str(tmp_path / "missing-app.db"))
monkeypatch.setattr(constants, "SCHEDULED_EMAILS_DB", str(db_path))
monkeypatch.setattr(es, "_read_agent_email_confirm_setting", lambda: True)
+1 -1
View File
@@ -34,7 +34,7 @@ def test_task_parse_resolves_with_owner_scope():
def test_history_compact_resolves_with_owner_scope():
body = _function_source("routes/history_routes.py", "compact_session")
body = _function_source("routes/history/history_routes.py", "compact_session")
assert "owner = effective_user(request)" in body
assert 'resolve_endpoint("utility", owner=owner or None)' in body
+57
View File
@@ -0,0 +1,57 @@
"""The generated Ollama runner must print the install hint, not execute it.
The runner script emitted by /api/model/serve contained:
echo "ERROR: Ollama not found ... or `curl -fsSL .../install.sh | sh`."
Backticks inside double quotes are bash command substitution, so on any host
without ollama the script downloaded and ran the system-wide installer
(including remote SSH serve targets) instead of printing the hint. The hint
now lives in OLLAMA_MISSING_HINT, contains no substitution tokens, and is
emitted single-quoted.
"""
import os
import shutil
import subprocess
import pytest
from routes.cookbook_helpers import OLLAMA_MISSING_HINT, _bash_squote
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def test_hint_has_no_shell_expansion_tokens():
assert "`" not in OLLAMA_MISSING_HINT
assert "$(" not in OLLAMA_MISSING_HINT
def test_hint_still_tells_the_user_how_to_install():
assert "https://ollama.com/download" in OLLAMA_MISSING_HINT
assert "install.sh" in OLLAMA_MISSING_HINT
def test_no_runner_echo_line_uses_backticks_in_double_quotes():
# Source-level guard: generated-script echo lines must never carry
# backticks inside a double-quoted bash string again.
src = open(os.path.join(ROOT, "routes", "cookbook_routes.py"), encoding="utf-8").read()
offenders = [
line.strip()
for line in src.splitlines()
if "append(" in line and 'echo "' in line and "`" in line.split('echo "', 1)[1]
]
assert offenders == []
def test_single_quoted_echo_prints_hint_literally():
bash = shutil.which("bash")
if not bash:
pytest.skip("bash not available")
out = subprocess.run(
[bash, "-c", f"echo '{_bash_squote(OLLAMA_MISSING_HINT)}'"],
capture_output=True,
text=True,
timeout=30,
)
assert out.returncode == 0
assert out.stdout.strip() == OLLAMA_MISSING_HINT
+22
View File
@@ -0,0 +1,22 @@
import unittest
from unittest.mock import MagicMock, patch
from src.rag_manager import RAGManager
class TestRAGManagerSearchSignature(unittest.TestCase):
@patch('src.rag_manager.VectorRAG')
def test_search_signature_accepts_owner(self, mock_vector_rag_class):
# Create a mock instance for VectorRAG
mock_vector_rag = MagicMock()
mock_vector_rag_class.return_value = mock_vector_rag
# Initialize RAGManager
manager = RAGManager()
# Test call with owner parameter
manager.search("test query", k=3, owner="user1")
# Verify that search was called on the underlying vector_rag with the correct parameters
mock_vector_rag.search.assert_called_once_with("test query", 3, owner="user1")
if __name__ == '__main__':
unittest.main()
+117
View File
@@ -0,0 +1,117 @@
"""Regression: the reminder ntfy sender must run the same SSRF guard as the
webhook sender.
The webhook branch of dispatch_reminder validates its target with
src.url_safety.check_outbound_url before posting; the ntfy branch posted to
the integration's base_url with no check, so a base_url pointing at the cloud
metadata range (169.254.169.254) was fetched server-side — with the
integration's Authorization header attached — every time a reminder fired.
"""
import asyncio
from unittest.mock import MagicMock, patch
import httpx
from routes.note_routes import dispatch_reminder
def _ntfy_integration(base_url):
return [{
"preset": "ntfy",
"enabled": True,
"base_url": base_url,
"api_key": "secret-token",
"name": "ntfy",
}]
def _settings(**extra):
return {
"reminder_channel": "ntfy",
"reminder_llm_synthesis": False,
"reminder_ntfy_topic": "reminders",
**extra,
}
class _SpyAsyncClient:
"""Stands in for httpx.AsyncClient; records posts, returns success."""
calls = []
def __init__(self, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kw):
_SpyAsyncClient.calls.append(url)
resp = MagicMock()
resp.is_success = True
resp.status_code = 200
return resp
def _dispatch():
return asyncio.run(dispatch_reminder(
"Title", "Body", note_id="", queue_browser=True,
settings_override=_settings(),
))
def test_metadata_ip_ntfy_base_url_is_rejected_and_not_fetched():
_SpyAsyncClient.calls = []
with (
patch("src.integrations.load_integrations",
return_value=_ntfy_integration("http://169.254.169.254")),
patch.object(httpx, "AsyncClient", _SpyAsyncClient),
):
result = _dispatch()
assert _SpyAsyncClient.calls == [], "metadata address must never be fetched"
assert result["ntfy_sent"] is False
assert "rejected" in result["ntfy_error"].lower()
def test_public_ntfy_base_url_still_sends():
_SpyAsyncClient.calls = []
with (
# 93.184.216.34 is a public literal — no DNS resolution involved.
patch("src.integrations.load_integrations",
return_value=_ntfy_integration("http://93.184.216.34")),
patch.object(httpx, "AsyncClient", _SpyAsyncClient),
):
result = _dispatch()
assert _SpyAsyncClient.calls == ["http://93.184.216.34/reminders"]
assert result["ntfy_sent"] is True
assert result["ntfy_error"] == ""
def test_private_ntfy_base_url_blocked_only_with_env_knob(monkeypatch):
# Default (local-first): a LAN ntfy server is a normal setup and must work.
_SpyAsyncClient.calls = []
monkeypatch.delenv("REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS", raising=False)
with (
patch("src.integrations.load_integrations",
return_value=_ntfy_integration("http://192.168.1.50")),
patch.object(httpx, "AsyncClient", _SpyAsyncClient),
):
result = _dispatch()
assert result["ntfy_sent"] is True
# Locked-down deployments: the same knob the webhook branch honors.
_SpyAsyncClient.calls = []
monkeypatch.setenv("REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS", "true")
with (
patch("src.integrations.load_integrations",
return_value=_ntfy_integration("http://192.168.1.50")),
patch.object(httpx, "AsyncClient", _SpyAsyncClient),
):
result = _dispatch()
assert _SpyAsyncClient.calls == []
assert result["ntfy_sent"] is False
assert "rejected" in result["ntfy_error"].lower()
@@ -0,0 +1,565 @@
"""Path-confinement regression tests for research routes.
Covers the CodeQL py/path-injection alert cluster (#552-#567 and #594) in
routes/research/research_routes.py:
- _owns_in_memory disk fallback (alerts #552, #553)
- _assert_owns_research (alerts #554, #555)
- research_detail (alerts #556, #557)
- research_archive (alerts #558, #559, #560)
- research_delete (alerts #561, #562, #563)
- research_result_peek (alerts #564, #565)
- research_spinoff (alerts #566, #567)
"""
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
from routes.research_routes import setup_research_routes
from routes.research.research_routes import (
_find_owned_research_path,
_find_research_path,
_require_research_path,
)
@pytest.fixture(autouse=True)
def _redirect_research_dir(tmp_path, monkeypatch):
monkeypatch.setattr(
"routes.research_routes.DEEP_RESEARCH_DIR",
str(tmp_path / "deep_research"),
)
def _request(user: str):
return SimpleNamespace(state=SimpleNamespace(current_user=user))
def _route(router, path: str, method: str):
for route in router.routes:
if getattr(route, "path", "") != path:
continue
if method in getattr(route, "methods", set()):
return route.endpoint
raise AssertionError(f"{method} {path} route not registered")
def _write_research(data_dir, session_id: str, **data):
data_dir.mkdir(parents=True, exist_ok=True)
path = data_dir / f"{session_id}.json"
path.write_text(json.dumps(data), encoding="utf-8")
return path
def _research_handler():
handler = MagicMock()
handler._active_tasks = {}
return handler
# ---------------------------------------------------------------------------
# Helper-level tests
# ---------------------------------------------------------------------------
def test_find_returns_existing_trusted_research_path(tmp_path, monkeypatch):
data_dir = tmp_path / "deep_research"
expected = _write_research(data_dir, "rp-abc123de4567", owner="alice")
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
assert _find_research_path("rp-abc123de4567") == expected.resolve()
def test_find_returns_none_for_missing_valid_session_id(tmp_path, monkeypatch):
data_dir = tmp_path / "deep_research"
data_dir.mkdir()
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
assert _find_research_path("rp-missing12345") is None
def test_require_returns_404_for_missing_valid_session_id(tmp_path, monkeypatch):
data_dir = tmp_path / "deep_research"
data_dir.mkdir()
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
with pytest.raises(HTTPException) as exc:
_require_research_path("rp-missing12345")
assert exc.value.status_code == 404
@pytest.mark.parametrize("bad_id", [
"../escape",
"../../etc/passwd",
"/etc/passwd",
"safe/../../x",
"",
"rp_bad", # underscore not in allowed charset
"rp-bad.json", # dot not in allowed charset
"a" * 129, # exceeds length limit
])
def test_find_rejects_bad_session_ids_before_enumeration(monkeypatch, bad_id):
storage_root = MagicMock()
monkeypatch.setattr(
"routes.research.research_routes._research_storage_root",
MagicMock(return_value=storage_root),
)
with pytest.raises(HTTPException) as exc:
_find_research_path(bad_id)
assert exc.value.status_code == 400
storage_root.glob.assert_not_called()
def test_find_matches_names_from_trusted_enumeration_without_joining_input(
tmp_path, monkeypatch
):
"""Pin the CodeQL-friendly lookup: match a glob result, never root / input."""
data_dir = tmp_path / "deep_research"
expected = _write_research(data_dir, "rp-abc123de4567", owner="alice").resolve()
class EnumeratedRoot:
def glob(self, pattern):
assert pattern == "*.json"
return [expected]
def __fspath__(self):
return str(data_dir.resolve())
def __truediv__(self, _other):
raise AssertionError("user-derived path segment was joined to root")
monkeypatch.setattr(
"routes.research.research_routes._research_storage_root",
lambda: EnumeratedRoot(),
)
assert _find_research_path("rp-abc123de4567") == expected
def test_find_ignores_symlink_escape(tmp_path, monkeypatch):
"""A matching symlink that resolves outside is not a trusted file."""
data_dir = tmp_path / "deep_research"
outside = tmp_path / "outside"
data_dir.mkdir()
outside.mkdir()
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
target = outside / "rp-linktest1234.json"
target.write_text("{}", encoding="utf-8")
link = data_dir / "rp-linktest1234.json"
try:
link.symlink_to(target)
except (AttributeError, NotImplementedError, OSError) as e:
pytest.skip(f"symlinks unavailable: {e}")
assert _find_research_path("rp-linktest1234") is None
def test_find_owned_returns_path_for_matching_owner(tmp_path, monkeypatch):
data_dir = tmp_path / "deep_research"
expected = _write_research(data_dir, "rp-ownedalice1", owner="alice")
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
assert _find_owned_research_path("rp-ownedalice1", "alice") == expected.resolve()
def test_find_owned_returns_none_for_other_owner(tmp_path, monkeypatch):
data_dir = tmp_path / "deep_research"
_write_research(data_dir, "rp-ownedbybob12", owner="bob")
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
assert _find_owned_research_path("rp-ownedbybob12", "alice") is None
# ---------------------------------------------------------------------------
# Route-level tests — valid paths work
# ---------------------------------------------------------------------------
def test_detail_returns_data_for_owner(tmp_path):
data_dir = tmp_path / "deep_research"
_write_research(data_dir, "rp-validid12345", owner="alice", query="valid query")
router = setup_research_routes(_research_handler())
target = _route(router, "/api/research/detail/{session_id}", "GET")
out = asyncio.run(target(session_id="rp-validid12345", request=_request("alice")))
assert out["query"] == "valid query"
def test_detail_returns_404_for_missing_valid_id():
router = setup_research_routes(_research_handler())
target = _route(router, "/api/research/detail/{session_id}", "GET")
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id="rp-missing12345", request=_request("alice")))
assert exc.value.status_code == 404
def test_detail_hides_other_owners_research_with_404(tmp_path):
data_dir = tmp_path / "deep_research"
_write_research(data_dir, "rp-ownedbybob12", owner="bob")
router = setup_research_routes(_research_handler())
target = _route(router, "/api/research/detail/{session_id}", "GET")
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id="rp-ownedbybob12", request=_request("alice")))
assert exc.value.status_code == 404
# ---------------------------------------------------------------------------
# Route-level tests — traversal and injection rejected
# ---------------------------------------------------------------------------
_TRAVERSAL_IDS = [
"../escape",
"../../etc/passwd",
"/etc/passwd",
"safe/../../x",
"rp_under",
"a" * 129,
]
@pytest.mark.parametrize("bad_id", _TRAVERSAL_IDS)
def test_detail_rejects_traversal(bad_id):
router = setup_research_routes(_research_handler())
target = _route(router, "/api/research/detail/{session_id}", "GET")
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id=bad_id, request=_request("alice")))
assert exc.value.status_code == 400
@pytest.mark.parametrize("bad_id", _TRAVERSAL_IDS)
def test_archive_rejects_traversal(bad_id):
router = setup_research_routes(_research_handler())
target = _route(router, "/api/research/{session_id}/archive", "POST")
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id=bad_id, request=_request("alice"), archived=True))
assert exc.value.status_code == 400
@pytest.mark.parametrize("bad_id", _TRAVERSAL_IDS)
def test_delete_rejects_traversal(bad_id):
router = setup_research_routes(_research_handler())
target = _route(router, "/api/research/{session_id}", "DELETE")
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id=bad_id, request=_request("alice")))
assert exc.value.status_code == 400
# ---------------------------------------------------------------------------
# Route-level tests — traversal does not touch files outside DEEP_RESEARCH_DIR
# ---------------------------------------------------------------------------
def test_delete_traversal_does_not_delete_outside_file(tmp_path, monkeypatch):
data_dir = tmp_path / "deep_research"
data_dir.mkdir(parents=True)
outside = tmp_path / "sensitive.json"
outside.write_text('{"secret": true}', encoding="utf-8")
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
router = setup_research_routes(_research_handler())
target = _route(router, "/api/research/{session_id}", "DELETE")
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id="../sensitive", request=_request("alice")))
assert exc.value.status_code == 400
assert outside.exists(), "file outside DEEP_RESEARCH_DIR must not be deleted"
def test_archive_traversal_does_not_mutate_outside_file(tmp_path, monkeypatch):
data_dir = tmp_path / "deep_research"
data_dir.mkdir(parents=True)
outside = tmp_path / "sensitive.json"
outside.write_text('{"owner": "alice", "archived": false}', encoding="utf-8")
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
router = setup_research_routes(_research_handler())
target = _route(router, "/api/research/{session_id}/archive", "POST")
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id="../sensitive", request=_request("alice"), archived=True))
assert exc.value.status_code == 400
data = json.loads(outside.read_text(encoding="utf-8"))
assert data["archived"] is False, "file outside DEEP_RESEARCH_DIR must not be mutated"
def test_detail_traversal_does_not_read_outside_file(tmp_path, monkeypatch):
data_dir = tmp_path / "deep_research"
data_dir.mkdir(parents=True)
outside = tmp_path / "sensitive.json"
outside.write_text('{"owner": "alice", "result": "secret data"}', encoding="utf-8")
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
router = setup_research_routes(_research_handler())
target = _route(router, "/api/research/detail/{session_id}", "GET")
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id="../sensitive", request=_request("alice")))
assert exc.value.status_code == 400
# ---------------------------------------------------------------------------
# Route-level symlink escape test
# ---------------------------------------------------------------------------
def _write_outside_symlink(tmp_path, session_id: str, data: dict):
data_dir = tmp_path / "deep_research"
outside_dir = tmp_path / "outside"
data_dir.mkdir(parents=True)
outside_dir.mkdir()
outside_file = outside_dir / f"{session_id}.json"
outside_file.write_text(json.dumps(data), encoding="utf-8")
link = data_dir / f"{session_id}.json"
try:
link.symlink_to(outside_file)
except (AttributeError, NotImplementedError, OSError) as e:
pytest.skip(f"symlinks unavailable: {e}")
return data_dir, outside_file
def test_detail_rejects_symlink_escape(tmp_path, monkeypatch):
"""research_detail never reads a matching symlink outside the root."""
data_dir, _ = _write_outside_symlink(
tmp_path,
"rp-linktest5678",
{"owner": "alice", "result": "secret"},
)
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
router = setup_research_routes(_research_handler())
target = _route(router, "/api/research/detail/{session_id}", "GET")
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id="rp-linktest5678", request=_request("alice")))
assert exc.value.status_code == 404
def test_archive_does_not_write_through_symlink_escape(tmp_path, monkeypatch):
data_dir, outside_file = _write_outside_symlink(
tmp_path,
"rp-linkarchive1",
{"owner": "alice", "archived": False},
)
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
router = setup_research_routes(_research_handler())
target = _route(router, "/api/research/{session_id}/archive", "POST")
with pytest.raises(HTTPException) as exc:
asyncio.run(
target(
session_id="rp-linkarchive1",
request=_request("alice"),
archived=True,
)
)
assert exc.value.status_code == 404
assert json.loads(outside_file.read_text(encoding="utf-8"))["archived"] is False
def test_delete_does_not_unlink_symlink_escape(tmp_path, monkeypatch):
data_dir, outside_file = _write_outside_symlink(
tmp_path,
"rp-linkdelete12",
{"owner": "alice"},
)
link = data_dir / "rp-linkdelete12.json"
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
router = setup_research_routes(_research_handler())
target = _route(router, "/api/research/{session_id}", "DELETE")
out = asyncio.run(
target(session_id="rp-linkdelete12", request=_request("alice"))
)
assert out == {"deleted": False}
assert link.is_symlink()
assert outside_file.exists()
# ---------------------------------------------------------------------------
# Owner/session scoping cannot escape root
# ---------------------------------------------------------------------------
def test_owner_scoped_paths_stay_within_research_root(tmp_path, monkeypatch):
"""Owner-scoped persisted files resolve within DEEP_RESEARCH_DIR."""
data_dir = tmp_path / "deep_research"
data_dir.mkdir(parents=True)
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
root = data_dir.resolve()
for session_id in ("rp-abc123456789", "rp-000000000001", "abc-xyz-123"):
_write_research(data_dir, session_id, owner="alice")
path = _require_research_path(session_id)
assert path.resolve().is_relative_to(root), (
f"{session_id!r} produced path outside research root: {path}"
)
@pytest.mark.parametrize("bad_id", _TRAVERSAL_IDS)
def test_result_peek_rejects_traversal(bad_id):
router = setup_research_routes(_research_handler())
target = _route(router, "/api/research/result-peek/{session_id}", "POST")
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id=bad_id, request=_request("alice")))
assert exc.value.status_code == 400
@pytest.mark.parametrize("bad_id", _TRAVERSAL_IDS)
def test_spinoff_rejects_traversal(bad_id):
router = setup_research_routes(_research_handler())
target = _route(router, "/api/research/spinoff/{session_id}", "POST")
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id=bad_id, request=_request("alice")))
assert exc.value.status_code == 400
def test_result_peek_uses_single_disk_lookup_for_completed_result(tmp_path, monkeypatch):
data_dir = tmp_path / "deep_research"
path = _write_research(
data_dir,
"rp-peeksingle1",
owner="alice",
result="saved result",
sources=["s1"],
raw_findings=["f1"],
category="security",
).resolve()
calls = []
def fake_find_owned(session_id, user):
calls.append((session_id, user))
return path
monkeypatch.setattr(
"routes.research.research_routes._find_owned_research_path",
fake_find_owned,
)
handler = _research_handler()
handler.get_result.return_value = None
router = setup_research_routes(handler)
target = _route(router, "/api/research/result-peek/{session_id}", "POST")
out = asyncio.run(target(session_id="rp-peeksingle1", request=_request("alice")))
assert out["result"] == "saved result"
assert out["sources"] == ["s1"]
assert out["raw_findings"] == ["f1"]
assert out["category"] == "security"
assert calls == [("rp-peeksingle1", "alice")]
def test_spinoff_uses_single_disk_lookup_for_completed_result(tmp_path, monkeypatch):
data_dir = tmp_path / "deep_research"
path = _write_research(
data_dir,
"rp-spinsingle1",
owner="alice",
result="saved report",
sources=["s1", "s2"],
query="original query",
).resolve()
calls = []
def fake_find_owned(session_id, user):
calls.append((session_id, user))
return path
class FakeSession:
endpoint_url = ""
model = ""
headers = {}
def __init__(self):
self.messages = []
def add_message(self, message):
self.messages.append(message)
class FakeSessionManager:
def __init__(self):
self.created = None
def get_session(self, session_id):
raise KeyError(session_id)
def create_session(self, **kwargs):
self.created = FakeSession()
return self.created
def save_sessions(self):
pass
monkeypatch.setattr(
"routes.research.research_routes._find_owned_research_path",
fake_find_owned,
)
monkeypatch.setattr(
"routes.research.research_routes.resolve_endpoint",
lambda *_args, **_kwargs: ("http://endpoint/v1", "model", {}),
)
handler = _research_handler()
handler.get_result.return_value = None
handler.get_sources.return_value = []
session_manager = FakeSessionManager()
router = setup_research_routes(handler, session_manager=session_manager)
target = _route(router, "/api/research/spinoff/{session_id}", "POST")
out = asyncio.run(target(session_id="rp-spinsingle1", request=_request("alice")))
assert out["name"] == "Follow-up: original query"
assert out["source_count"] == 2
assert calls == [("rp-spinsingle1", "alice")]
assert session_manager.created is not None
assert session_manager.created.messages
def test_spinoff_reads_saved_query_for_done_active_task(tmp_path, monkeypatch):
session_id = "rp-activedone1"
data_dir = tmp_path / "deep_research"
_write_research(
data_dir,
session_id,
owner="alice",
result="saved report",
sources=["s1"],
query="completed query",
)
class FakeSession:
endpoint_url = ""
model = ""
headers = {}
def __init__(self):
self.messages = []
def add_message(self, message):
self.messages.append(message)
class FakeSessionManager:
def __init__(self):
self.created = None
def get_session(self, session_id):
raise KeyError(session_id)
def create_session(self, **kwargs):
self.created = FakeSession()
return self.created
def save_sessions(self):
pass
monkeypatch.setattr(
"routes.research.research_routes.resolve_endpoint",
lambda *_args, **_kwargs: ("http://endpoint/v1", "model", {}),
)
handler = _research_handler()
handler._active_tasks[session_id] = {"owner": "alice", "status": "done"}
handler.get_result.return_value = None
handler.get_sources.return_value = []
session_manager = FakeSessionManager()
router = setup_research_routes(handler, session_manager=session_manager)
target = _route(router, "/api/research/spinoff/{session_id}", "POST")
out = asyncio.run(target(session_id=session_id, request=_request("alice")))
assert out["name"] == "Follow-up: completed query"
assert out["source_count"] == 1
assert session_manager.created is not None
primer = session_manager.created.messages[0].content
assert "completed query" in primer
assert "(not recorded)" not in primer
+42
View File
@@ -448,3 +448,45 @@ def test_fast_lane_collects_only_unmarked_auth_concurrency_test():
assert _FAST_AUTH_CONCURRENCY_TEST in collected
for slow_test in _SLOW_AUTH_CONCURRENCY_TESTS:
assert slow_test not in collected, f"slow test was not deselected: {slow_test}"
def test_service_health_sub_area_command_includes_split_files():
assert _cmd(sub_area="service_health") == [
PY,
"-m",
"pytest",
"-m",
(
"(sub_service_health_chromadb or "
"sub_service_health_search or "
"sub_service_health_ntfy or "
"sub_service_health_email or "
"sub_service_health_providers or "
"sub_service_health_collect)"
),
]
def test_service_health_alias_is_accepted_by_run():
seen = []
def executor(cmd):
seen.append(cmd)
return 0
result = run(["--sub-area", "service_health"], executor=executor)
assert result == 0
assert len(seen) == 1
assert seen[0][1:] == [
"-m",
"pytest",
"-m",
(
"(sub_service_health_chromadb or "
"sub_service_health_search or "
"sub_service_health_ntfy or "
"sub_service_health_email or "
"sub_service_health_providers or "
"sub_service_health_collect)"
),
]
+31
View File
@@ -0,0 +1,31 @@
"""Regression: _extract_entities must find non-ASCII capitalized names.
The name extractor used the ASCII-only class [A-Z][a-zA-Z]+, so a query like
"İstanbul weather" or "Zürich hotels" yielded no name entities at all, and
"São Paulo" lost "São" — non-English/accented place and proper names were
silently dropped from query enhancement. Detection is now Unicode-aware;
ASCII behaviour (including camelCase mid-word capitals not counting as names)
is preserved.
"""
from services.search.query import _extract_entities
def _names(q):
return _extract_entities(q)["names"]
def test_non_ascii_names_are_extracted():
assert "İstanbul" in _names("İstanbul weather")
assert "Zürich" in _names("Zürich hotels")
assert set(_names("trip to São Paulo")) >= {"São", "Paulo"}
def test_ascii_names_unchanged():
assert _names("What did Alice do in 2024") == ["Alice"]
assert _names("news about OpenAI and Google") == ["OpenAI", "Google"]
def test_lowercase_camelcase_and_numbers_are_not_names():
assert _names("the iphone price") == []
assert _names("iPhone price") == [] # mid-word capital is not a name
assert _names("top 50 albums") == []
+295 -6
View File
@@ -894,7 +894,8 @@ def test_web_fetch_guard_fails_closed_on_empty_resolution(monkeypatch):
def test_web_fetch_guard_blocks_redirect_into_private(monkeypatch):
# A public URL that 302-redirects to an internal address must be blocked
# at the redirect hop, not followed.
# at the redirect hop, not followed. _get_public_url now uses
# httpx.Client(...).stream(...) so the test must mock that path.
import httpx
from src.search import content
@@ -905,14 +906,31 @@ def test_web_fetch_guard_blocks_redirect_into_private(monkeypatch):
status_code = 302
url = "http://public.example/start"
headers = {"location": "http://169.254.169.254/latest/meta-data/"}
encoding = "utf-8"
from contextlib import contextmanager
class _FakeStream:
def __enter__(self):
return _Resp()
@contextmanager
def _fake_stream(method, url, **kwargs):
yield _Resp()
def __exit__(self, *args):
return False
monkeypatch.setattr(httpx, "stream", _fake_stream)
class _FakeClient:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *args):
return False
def stream(self, method, url):
assert method == "GET"
assert url == "http://public.example/start"
return _FakeStream()
monkeypatch.setattr(httpx, "Client", _FakeClient)
with _pytest.raises(httpx.RequestError) as exc:
content._get_public_url("http://public.example/start", headers={}, timeout=5)
@@ -1224,3 +1242,274 @@ def test_visual_report_escapes_request_category():
# value must coerce rather than crash the render (html.escape needs a str).
out = generate_visual_report(question="q", report_markdown="## H", category=12345)
assert "category-12345" in out
# ── DNS rebinding (audit finding 8.1) ────────────────────────────────
# _resolve_public_ips resolves a URL's hostname once per hop and rejects
# private / metadata targets, but httpx would then re-resolve the
# hostname at connect time. The fix: the actual TCP connect is pinned
# to the resolved IP via a custom httpcore.NetworkBackend, while the
# URL / Host header / SNI stay on the original hostname.
import ipaddress as _ipaddr
import socket as _socket
import threading as _threading
import httpx as _httpx
def test_dns_rebinding_blocked_by_resolve_gate(monkeypatch):
from src.search import content
monkeypatch.setattr(content, "_resolve_hostname_ips",
lambda host: [_ipaddr.ip_address("10.0.0.5")])
with _pytest.raises(_httpx.RequestError) as exc:
content._resolve_public_ips("https://attacker.example/")
assert "non-public" in str(exc.value).lower()
def test_dns_rebinding_pinned_backend_connects_to_resolved_ip(monkeypatch):
"""``_PinnedBackend.connect_tcp`` must ignore the URL's host and
dial the pinned IP at the original port. This is the core of the
fix: httpcore's NetworkBackend contract lets us intercept the
connect before DNS lookup happens.
"""
from src.search import content
pinned_ip = _ipaddr.ip_address("93.184.216.34")
captured = {}
class _StubStream:
def close(self):
pass
class _StubBackend:
def connect_tcp(self, host, port, timeout=None, local_address=None, socket_options=None):
captured["host"] = host
captured["port"] = port
return _StubStream()
def connect_unix_socket(self, path, timeout=None, socket_options=None):
raise OSError("not used")
def sleep(self, seconds):
pass
backend = content._PinnedBackend(pinned_ip)
monkeypatch.setattr(backend, "_real", _StubBackend())
backend.connect_tcp("attacker.example", 443)
assert captured["host"] == "93.184.216.34", captured
assert captured["port"] == 443, captured
def test_dns_rebinding_pinned_transport_dials_pinned_ip(monkeypatch):
"""End-to-end: ``_PinnedTransport`` actually dials the pinned IP
when given a hostname, with the original URL's Host header
preserved. We stand up a local socket server on a free port and
make the transport connect there via the pinned backend.
"""
from src.search import content
import httpcore
# Stand up a TCP server that accepts one connection and records
# the request bytes it received, then returns a minimal HTTP/1.1
# response.
captured = {"request": b""}
server_sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
server_sock.bind(("127.0.0.1", 0))
server_sock.listen(1)
port = server_sock.getsockname()[1]
def serve_once():
conn, _ = server_sock.accept()
with conn:
conn.settimeout(2.0)
buf = b""
try:
while b"\r\n\r\n" not in buf:
chunk = conn.recv(4096)
if not chunk:
break
buf += chunk
except _socket.timeout:
pass
captured["request"] = buf
conn.sendall(
b"HTTP/1.1 200 OK\r\n"
b"Content-Length: 2\r\n"
b"Connection: close\r\n"
b"\r\n"
b"OK"
)
t = _threading.Thread(target=serve_once, daemon=True)
t.start()
# Pin the transport to 127.0.0.1:<port>. The caller hands it a URL
# with a fake hostname so we can verify the host header is sent
# while the TCP connect goes to the pinned IP.
pinned_ip = _ipaddr.ip_address("127.0.0.1")
transport = content._PinnedTransport(pinned_ip)
req = _httpx.Request(
"GET",
f"http://attacker.test:{port}/path?q=1",
headers={"host": "attacker.test"},
)
try:
with _httpx.Client(transport=transport, timeout=5) as client:
response = client.send(req)
assert response.status_code == 200, response.text
finally:
server_sock.close()
t.join(timeout=2)
request_bytes = captured["request"]
assert request_bytes, "server never received a request"
# Host header is the original hostname, not the IP. (httpx
# lowercases header names; compare case-insensitively.)
headers_blob = request_bytes.lower()
assert b"host: attacker.test" in headers_blob, request_bytes
# The path was preserved.
assert b"/path?q=1" in request_bytes, request_bytes
def test_dns_rebinding_pinned_transport_preserves_url_netloc(monkeypatch):
"""The URL the transport hands to the underlying httpcore layer
must still be the original ``https://example.com/...`` — never
rewritten to the pinned IP. SNI / vhost depend on this.
"""
from src.search import content
seen_url = {}
class _RecordingPool:
def handle_request(self, req):
seen_url["host"] = req.url.host.decode() if isinstance(req.url.host, bytes) else req.url.host
seen_url["scheme"] = req.url.scheme.decode() if isinstance(req.url.scheme, bytes) else req.url.scheme
seen_url["target"] = req.url.target.decode() if isinstance(req.url.target, bytes) else req.url.target
raise _httpx.ConnectError("intercepted")
def close(self):
pass
pinned_ip = _ipaddr.ip_address("93.184.216.34")
transport = content._PinnedTransport(pinned_ip)
transport._pool = _RecordingPool()
req = _httpx.Request("GET", "https://example.com/some/path?q=1")
with _pytest.raises(_httpx.ConnectError):
transport.handle_request(req)
assert seen_url["host"] == "example.com", seen_url
assert seen_url["scheme"] == "https", seen_url
assert seen_url["target"] == "/some/path?q=1", seen_url
def test_dns_rebinding_redirect_re_resolves_per_hop(monkeypatch):
"""Every redirect hop must call ``_resolve_public_ips`` again.
A redirect to a private-IP target must be blocked even when the
first hop was public.
"""
from src.search import content
seen = []
def fake_resolve(url):
seen.append(url)
if "private" in url:
raise _httpx.RequestError(f"Blocked non-public URL: {url}")
return [_ipaddr.ip_address("93.184.216.34")]
monkeypatch.setattr(content, "_resolve_public_ips", fake_resolve)
class _Resp:
status_code = 302
headers = {"location": "http://private.example/secret"}
encoding = "utf-8"
def __init__(self, url):
self.url = url
class _FakeStream:
def __init__(self, response):
self.response = response
def __enter__(self):
return self.response
def __exit__(self, *args):
return False
class _FakeClient:
def __init__(self, *a, **k):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def stream(self, method, url):
assert method == "GET"
return _FakeStream(_Resp(url))
monkeypatch.setattr(_httpx, "Client", _FakeClient)
with _pytest.raises(_httpx.RequestError) as exc:
content._get_public_url("http://public.example/start", headers={}, timeout=5)
assert "non-public" in str(exc.value).lower()
# Both hops were validated.
assert seen == ["http://public.example/start", "http://private.example/secret"], seen
def test_dns_rebinding_transport_uses_public_apis(monkeypatch):
"""Static guard: ``_PinnedTransport`` must use only the public
``httpx.BaseTransport`` / ``httpcore`` APIs. No subclassing of
``httpx.HTTPTransport`` (whose ``_pool`` slot we'd have to
overwrite), no reads of private ``httpcore.ConnectionPool``
attributes, and no imports from ``httpx._transports``.
"""
from src.search import content
import inspect
# 1) Subclass check: must be BaseTransport, not HTTPTransport.
mro_names = [c.__name__ for c in content._PinnedTransport.__mro__]
assert "BaseTransport" in mro_names, mro_names
assert "HTTPTransport" not in mro_names, (
"_PinnedTransport subclasses httpx.HTTPTransport. Subclass "
"httpx.BaseTransport instead and build the pool from scratch "
"with the public httpcore.ConnectionPool API."
)
# 2) No reads of private httpcore.ConnectionPool attrs.
src = inspect.getsource(content._PinnedTransport)
forbidden = (
"_ssl_context",
"_max_connections",
"_max_keepalive_connections",
"_keepalive_expiry",
"_http1",
"_http2",
"_network_backend",
)
leaked = [name for name in forbidden if name in src]
assert not leaked, (
f"_PinnedTransport reads private httpcore.ConnectionPool attrs: {leaked}. "
"Build the pool from the public httpcore.ConnectionPool API instead."
)
# 3) No imports from httpx's private transport module.
module_src = inspect.getsource(content)
forbidden_imports = ("from httpx._transports", "import httpx._transports")
leaked_imports = [s for s in forbidden_imports if s in module_src]
assert not leaked_imports, (
f"content.py imports from httpx's private transport module: {leaked_imports}. "
"Use only the public httpx and httpcore APIs."
)
-472
View File
@@ -1,472 +0,0 @@
"""Tests for src.service_health — the consolidated degraded-state report.
Imports the real module (conftest.py stubs the heavy deps). Network is never
touched: HTTP probes take an injected `http_get`, and the email/provider probes
take an injected `connect` / `probe`. Asserts the ok/degraded/down/disabled
mapping per subsystem, the overall rollup, and that no secrets leak into meta.
"""
import types
import pytest
from src import service_health as sh
def _resp(status_code):
return types.SimpleNamespace(status_code=status_code)
def _raise(*_a, **_k):
raise RuntimeError("connection refused")
# ── chromadb_health ──
class _Store:
def __init__(self, healthy):
self.healthy = healthy
def test_chromadb_both_healthy_ok():
s = sh.chromadb_health(_Store(True), _Store(True))
assert s["status"] == sh.OK
assert s["meta"] == {"rag": True, "memory": True}
def test_chromadb_one_down_degraded():
s = sh.chromadb_health(_Store(True), _Store(False))
assert s["status"] == sh.DEGRADED
def test_chromadb_both_unhealthy_down():
s = sh.chromadb_health(_Store(False), _Store(False))
assert s["status"] == sh.DOWN
def test_chromadb_both_absent_disabled():
s = sh.chromadb_health(None, None)
assert s["status"] == sh.DISABLED
def test_chromadb_one_absent_one_healthy_ok():
# An absent store is not a failure; the present one being healthy is ok.
s = sh.chromadb_health(_Store(True), None)
assert s["status"] == sh.OK
assert s["meta"]["memory"] is None
# ── searxng_health ──
def test_searxng_disabled_when_other_provider():
s = sh.searxng_health({"search_provider": "brave"})
assert s["status"] == sh.DISABLED
def test_searxng_ok_on_healthz():
s = sh.searxng_health(
{"search_provider": "searxng", "search_url": "http://sx:8080"},
http_get=lambda url, timeout: _resp(200),
)
assert s["status"] == sh.OK
assert s["meta"]["probed"] == "/healthz"
def test_searxng_ok_on_root_fallback():
def getter(url, timeout):
return _resp(404) if url.endswith("/healthz") else _resp(200)
s = sh.searxng_health(
{"search_provider": "searxng", "search_url": "http://sx:8080"},
http_get=getter,
)
assert s["status"] == sh.OK
assert s["meta"]["probed"] == "/"
def test_searxng_down_on_exception():
s = sh.searxng_health(
{"search_provider": "searxng", "search_url": "http://sx:8080"},
http_get=_raise,
)
assert s["status"] == sh.DOWN
def test_searxng_down_on_5xx():
s = sh.searxng_health(
{"search_provider": "searxng", "search_url": "http://sx:8080"},
http_get=lambda url, timeout: _resp(502),
)
assert s["status"] == sh.DOWN
# ── ntfy_health ──
def _ntfy_intg():
return [{"preset": "ntfy", "enabled": True, "base_url": "http://ntfy:80"}]
def test_ntfy_disabled_without_integration():
s = sh.ntfy_health([], {"reminder_channel": "ntfy"})
assert s["status"] == sh.DISABLED
def test_ntfy_ok():
s = sh.ntfy_health(_ntfy_intg(), {"reminder_channel": "ntfy"},
http_get=lambda url, timeout: _resp(200))
assert s["status"] == sh.OK
assert s["meta"]["base"] == "http://ntfy:80"
def test_ntfy_probes_v1_health_not_a_topic():
seen = {}
def getter(url, timeout):
seen["url"] = url
return _resp(200)
sh.ntfy_health(_ntfy_intg(), {"reminder_channel": "ntfy"}, http_get=getter)
# Non-intrusive: hits /v1/health, never publishes to a topic.
assert seen["url"].endswith("/v1/health")
def test_ntfy_down_on_exception():
s = sh.ntfy_health(_ntfy_intg(), {"reminder_channel": "ntfy"},
http_get=_raise)
assert s["status"] == sh.DOWN
# ── email_health ──
def _acct(name, host="imap.example.com"):
return {"account_id": name, "account_name": name, "imap_host": host,
"imap_password": "hunter2"}
class _Conn:
def logout(self):
pass
def test_email_disabled_without_accounts():
assert sh.email_health([])["status"] == sh.DISABLED
def test_email_ok_all_connect():
s = sh.email_health([_acct("a"), _acct("b")], connect=lambda _id: _Conn())
assert s["status"] == sh.OK
def test_email_degraded_some_fail():
def connect(account_id):
if account_id == "bad":
raise RuntimeError("auth failed")
return _Conn()
s = sh.email_health([_acct("good"), _acct("bad")], connect=connect)
assert s["status"] == sh.DEGRADED
def test_email_down_all_fail():
s = sh.email_health([_acct("a")], connect=_raise)
assert s["status"] == sh.DOWN
def test_email_account_without_host_marked_failed():
s = sh.email_health([_acct("a", host="")], connect=lambda _id: _Conn())
assert s["status"] == sh.DOWN
def test_email_meta_never_leaks_password():
s = sh.email_health([_acct("a")], connect=lambda _id: _Conn())
assert "hunter2" not in repr(s)
# ── providers_health ──
def _ep(name):
return {"name": name, "base_url": f"http://{name}:8000/v1", "api_key": "sk-secret"}
def test_providers_disabled_without_endpoints():
assert sh.providers_health([])["status"] == sh.DISABLED
def test_providers_ok_all_reachable():
s = sh.providers_health([_ep("a")],
probe=lambda base, key, timeout: ["m1", "m2"])
assert s["status"] == sh.OK
assert s["meta"]["endpoints"][0]["model_count"] == 2
def test_providers_degraded_some_empty():
def probe(base, key, timeout):
return ["m1"] if "good" in base else []
s = sh.providers_health([_ep("good"), _ep("bad")], probe=probe)
assert s["status"] == sh.DEGRADED
def test_providers_down_all_fail():
s = sh.providers_health([_ep("a")], probe=_raise)
assert s["status"] == sh.DOWN
def test_providers_meta_never_leaks_api_key():
s = sh.providers_health([_ep("a")],
probe=lambda base, key, timeout: ["m1"])
assert "sk-secret" not in repr(s)
# ── rollup ──
def test_rollup_picks_worst_non_disabled():
services = [
{"status": sh.OK}, {"status": sh.DISABLED},
{"status": sh.DEGRADED}, {"status": sh.OK},
]
assert sh._rollup(services) == sh.DEGRADED
def test_rollup_down_beats_degraded():
assert sh._rollup([{"status": sh.DEGRADED}, {"status": sh.DOWN}]) == sh.DOWN
def test_rollup_all_disabled_is_ok():
assert sh._rollup([{"status": sh.DISABLED}, {"status": sh.DISABLED}]) == sh.OK
# ── collect_service_health (async aggregate) ──
def test_collect_service_health_shape(monkeypatch):
import asyncio
# Avoid touching real data sources / network.
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
"settings": {"search_provider": "disabled"},
"integrations": [],
"accounts": [],
"endpoints": [],
})
out = asyncio.run(sh.collect_service_health(_Store(True), _Store(True)))
assert set(out) == {"overall", "services", "timestamp"}
names = {s["name"] for s in out["services"]}
assert names == {"chromadb", "searxng", "ntfy", "email", "providers"}
# Chroma healthy, everything else disabled → overall ok.
assert out["overall"] == sh.OK
# ── _safe_url: strip userinfo / query / fragment ──
@pytest.mark.parametrize("raw,expected", [
("http://user:pass@host:8080/path?api_key=secret#frag", "http://host:8080/path"),
("https://admin:hunter2@searx.example.com/", "https://searx.example.com"),
("http://ntfy.local:80?token=abc", "http://ntfy.local:80"),
("host:8080", "host:8080"),
("", ""),
(None, ""),
])
def test_safe_url_strips_secrets(raw, expected):
out = sh._safe_url(raw)
assert out == expected
for bad in ("pass", "secret", "hunter2", "abc", "token", "@"):
if raw and bad in raw and bad not in expected:
assert bad not in out
# ── _classify_error: controlled categories, never raw text ──
def test_classify_error_categories():
import socket
assert sh._classify_error(TimeoutError()) == "timeout"
assert sh._classify_error(socket.timeout()) == "timeout"
assert sh._classify_error(socket.gaierror()) == "dns_error"
assert sh._classify_error(ConnectionRefusedError()) == "connection_refused"
assert sh._classify_error(OSError("boom")) == "network_error"
assert sh._classify_error(ValueError("x")) == "error"
# ── Sanitization in subsystem output (blocker #2) ──
def test_searxng_meta_redacts_instance_url():
s = sh.searxng_health(
{"search_provider": "searxng",
"search_url": "http://user:s3cr3t@searx.local:8080/?token=zzz"},
http_get=lambda url, timeout: _resp(200),
)
blob = repr(s)
assert "s3cr3t" not in blob and "zzz" not in blob and "user:" not in blob
assert s["meta"]["instance"] == "http://searx.local:8080"
def test_searxng_down_uses_error_category_not_raw_exception():
def boom(url, timeout):
raise RuntimeError("failed connecting to http://user:pw@searx.local secret-token")
s = sh.searxng_health(
{"search_provider": "searxng", "search_url": "http://searx.local"},
http_get=boom,
)
assert s["status"] == sh.DOWN
assert s["meta"]["error"] == "error" # controlled category token
assert "secret-token" not in repr(s) and "pw@" not in repr(s)
def test_ntfy_meta_redacts_userinfo_in_base():
intg = [{"preset": "ntfy", "enabled": True,
"base_url": "https://user:topsecret@ntfy.example.com"}]
seen = {}
def getter(url, timeout):
seen["url"] = url # the probe itself may keep credentials
return _resp(200)
s = sh.ntfy_health(intg, {"reminder_channel": "ntfy"}, http_get=getter)
assert s["meta"]["base"] == "https://ntfy.example.com"
assert "topsecret" not in repr(s)
def test_providers_name_fallback_is_sanitized():
# No display name → falls back to the base_url, which must be sanitized.
ep = {"base_url": "http://user:k3y@prov.local:9000/v1?api_key=zzz", "api_key": "sk-x"}
s = sh.providers_health([ep], probe=lambda b, k, t: ["m1"])
entry = s["meta"]["endpoints"][0]
assert entry["name"] == "http://prov.local:9000/v1"
assert "k3y" not in repr(s) and "zzz" not in repr(s) and "sk-x" not in repr(s)
def test_providers_probe_exception_maps_to_category():
def boom(base, key, timeout):
raise RuntimeError(f"500 from {base} with key {key}") # would leak base+key
s = sh.providers_health([_ep("a")], probe=boom)
assert s["status"] == sh.DOWN
assert s["meta"]["endpoints"][0]["error"] == "error"
assert "sk-secret" not in repr(s) and "http://a" not in repr(s)
def test_email_connect_exception_maps_to_category():
def boom(account_id):
raise RuntimeError("login failed for user bob with password hunter2")
s = sh.email_health([_acct("a")], connect=boom)
assert s["status"] == sh.DOWN
assert s["meta"]["accounts"][0]["error"] == "error"
assert "hunter2" not in repr(s)
# ── Bounded wall-clock (blocker #1) ──
def test_providers_bounded_marks_slow_as_timeout(monkeypatch):
import time
monkeypatch.setattr(sh, "_FANOUT_BUDGET", 1)
def probe(base, key, timeout):
if "slow" in base:
time.sleep(10) # would blow the budget if unbounded
return ["m1"]
eps = [{"name": "fast", "base_url": "http://fast", "api_key": "k"},
{"name": "slow", "base_url": "http://slow", "api_key": "k"}]
t0 = time.monotonic()
out = sh.providers_health(eps, probe=probe)
elapsed = time.monotonic() - t0
assert elapsed < 4, f"providers_health not bounded: took {elapsed:.1f}s"
by = {e["name"]: e for e in out["meta"]["endpoints"]}
assert by["fast"]["ok"] is True
assert by["slow"]["ok"] is False and by["slow"]["error"] == "timeout"
assert out["status"] == sh.DEGRADED
def test_providers_bounded_with_many_slow_endpoints(monkeypatch):
import time
monkeypatch.setattr(sh, "_FANOUT_BUDGET", 1)
def probe(base, key, timeout):
time.sleep(10)
return ["m1"]
eps = [{"name": f"ep{i}", "base_url": f"http://ep{i}", "api_key": "k"}
for i in range(25)]
t0 = time.monotonic()
out = sh.providers_health(eps, probe=probe)
elapsed = time.monotonic() - t0
# 25 endpoints * sleep would be huge if sequential; bounded keeps it ~budget.
assert elapsed < 4, f"not bounded with many endpoints: {elapsed:.1f}s"
assert out["status"] == sh.DOWN
assert all(e["error"] == "timeout" for e in out["meta"]["endpoints"])
def test_email_bounded_marks_slow_as_timeout(monkeypatch):
import time
monkeypatch.setattr(sh, "_FANOUT_BUDGET", 1)
def connect(account_id):
if account_id == "slow":
time.sleep(10)
return _Conn()
accts = [_acct("fast"), _acct("slow")]
accts[1]["account_id"] = "slow"
t0 = time.monotonic()
out = sh.email_health(accts, connect=connect)
elapsed = time.monotonic() - t0
assert elapsed < 4, f"email_health not bounded: took {elapsed:.1f}s"
by = {a["name"]: a for a in out["meta"]["accounts"]}
assert by["slow"]["error"] == "timeout"
def test_collect_runs_subsystems_concurrently(monkeypatch):
# The aggregate is bounded by running the (internally-bounded) subsystems
# concurrently, so total wall-clock ≈ max(subsystem), not the sum. Each of
# the four network subsystems here sleeps ~0.6s; sequential would be ~2.4s.
import asyncio
import time
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
"settings": {}, "integrations": [], "accounts": [], "endpoints": [],
})
def slow(name):
def _fn(*_a, **_k):
time.sleep(0.6)
return {"name": name, "status": sh.OK, "detail": "", "meta": {}}
return _fn
monkeypatch.setattr(sh, "searxng_health", slow("searxng"))
monkeypatch.setattr(sh, "ntfy_health", slow("ntfy"))
monkeypatch.setattr(sh, "email_health", slow("email"))
monkeypatch.setattr(sh, "providers_health", slow("providers"))
t0 = time.monotonic()
out = asyncio.run(sh.collect_service_health(None, None))
elapsed = time.monotonic() - t0
assert elapsed < 1.5, f"subsystems not concurrent: took {elapsed:.1f}s"
assert {s["name"] for s in out["services"]} == {
"chromadb", "searxng", "ntfy", "email", "providers"}
def test_collect_aggregate_deadline_yields_controlled_result(monkeypatch):
# If the gather overruns the aggregate ceiling, the response is still a
# controlled {overall, services, timestamp} with each network subsystem
# marked down/timeout — never a hang or a raised exception.
import asyncio
import time
monkeypatch.setattr(sh, "_AGGREGATE_DEADLINE", 0.5)
monkeypatch.setattr(sh, "_SUBSYSTEM_DEADLINE", 0.4)
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
"settings": {}, "integrations": [], "accounts": [], "endpoints": [],
})
async def _slow_gather(*coros, **_k):
for c in coros: # close unawaited coros to avoid warnings
close = getattr(c, "close", None)
if close:
close()
await asyncio.sleep(5)
# Force the outer wait_for to trip by making gather itself slow.
monkeypatch.setattr(sh.asyncio, "gather", _slow_gather)
t0 = time.monotonic()
out = asyncio.run(sh.collect_service_health(None, None))
elapsed = time.monotonic() - t0
assert elapsed < 2, f"aggregate deadline did not bound: {elapsed:.1f}s"
assert set(out) == {"overall", "services", "timestamp"}
net = [s for s in out["services"] if s["name"] != "chromadb"]
assert all(s["status"] == sh.DOWN and s["meta"].get("error") == "timeout"
for s in net)
+37
View File
@@ -0,0 +1,37 @@
"""Tests for chromadb_health — ok/degraded/down/disabled classification."""
import pytest
from src import service_health as sh
class _Store:
def __init__(self, healthy):
self.healthy = healthy
def test_chromadb_both_healthy_ok():
s = sh.chromadb_health(_Store(True), _Store(True))
assert s["status"] == sh.OK
assert s["meta"] == {"rag": True, "memory": True}
def test_chromadb_one_down_degraded():
s = sh.chromadb_health(_Store(True), _Store(False))
assert s["status"] == sh.DEGRADED
def test_chromadb_both_unhealthy_down():
s = sh.chromadb_health(_Store(False), _Store(False))
assert s["status"] == sh.DOWN
def test_chromadb_both_absent_disabled():
s = sh.chromadb_health(None, None)
assert s["status"] == sh.DISABLED
def test_chromadb_one_absent_one_healthy_ok():
# An absent store is not a failure; the present one being healthy is ok.
s = sh.chromadb_health(_Store(True), None)
assert s["status"] == sh.OK
assert s["meta"]["memory"] is None
+139
View File
@@ -0,0 +1,139 @@
"""Tests for rollup logic, aggregate collection, and shared utility helpers (_safe_url, _classify_error)."""
import pytest
from src import service_health as sh
class _Store:
def __init__(self, healthy):
self.healthy = healthy
# ── rollup ──
def test_rollup_picks_worst_non_disabled():
services = [
{"status": sh.OK}, {"status": sh.DISABLED},
{"status": sh.DEGRADED}, {"status": sh.OK},
]
assert sh._rollup(services) == sh.DEGRADED
def test_rollup_down_beats_degraded():
assert sh._rollup([{"status": sh.DEGRADED}, {"status": sh.DOWN}]) == sh.DOWN
def test_rollup_all_disabled_is_ok():
assert sh._rollup([{"status": sh.DISABLED}, {"status": sh.DISABLED}]) == sh.OK
# ── collect_service_health (async aggregate) ──
def test_collect_service_health_shape(monkeypatch):
import asyncio
# Avoid touching real data sources / network.
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
"settings": {"search_provider": "disabled"},
"integrations": [],
"accounts": [],
"endpoints": [],
})
out = asyncio.run(sh.collect_service_health(_Store(True), _Store(True)))
assert set(out) == {"overall", "services", "timestamp"}
names = {s["name"] for s in out["services"]}
assert names == {"chromadb", "searxng", "ntfy", "email", "providers"}
# Chroma healthy, everything else disabled → overall ok.
assert out["overall"] == sh.OK
# ── _safe_url: strip userinfo / query / fragment ──
@pytest.mark.parametrize("raw,expected", [
("http://user:pass@host:8080/path?api_key=secret#frag", "http://host:8080/path"),
("https://admin:hunter2@searx.example.com/", "https://searx.example.com"),
("http://ntfy.local:80?token=abc", "http://ntfy.local:80"),
("host:8080", "host:8080"),
("", ""),
(None, ""),
])
def test_safe_url_strips_secrets(raw, expected):
out = sh._safe_url(raw)
assert out == expected
for bad in ("pass", "secret", "hunter2", "abc", "token", "@"):
if raw and bad in raw and bad not in expected:
assert bad not in out
# ── _classify_error: controlled categories, never raw text ──
def test_classify_error_categories():
import socket
assert sh._classify_error(TimeoutError()) == "timeout"
assert sh._classify_error(socket.timeout()) == "timeout"
assert sh._classify_error(socket.gaierror()) == "dns_error"
assert sh._classify_error(ConnectionRefusedError()) == "connection_refused"
assert sh._classify_error(OSError("boom")) == "network_error"
assert sh._classify_error(ValueError("x")) == "error"
# ── Concurrent collection and aggregate deadline ──
def test_collect_runs_subsystems_concurrently(monkeypatch):
# The aggregate is bounded by running the (internally-bounded) subsystems
# concurrently, so total wall-clock ≈ max(subsystem), not the sum. Each of
# the four network subsystems here sleeps ~0.6s; sequential would be ~2.4s.
import asyncio
import time
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
"settings": {}, "integrations": [], "accounts": [], "endpoints": [],
})
def slow(name):
def _fn(*_a, **_k):
time.sleep(0.6)
return {"name": name, "status": sh.OK, "detail": "", "meta": {}}
return _fn
monkeypatch.setattr(sh, "searxng_health", slow("searxng"))
monkeypatch.setattr(sh, "ntfy_health", slow("ntfy"))
monkeypatch.setattr(sh, "email_health", slow("email"))
monkeypatch.setattr(sh, "providers_health", slow("providers"))
t0 = time.monotonic()
out = asyncio.run(sh.collect_service_health(None, None))
elapsed = time.monotonic() - t0
assert elapsed < 1.5, f"subsystems not concurrent: took {elapsed:.1f}s"
assert {s["name"] for s in out["services"]} == {
"chromadb", "searxng", "ntfy", "email", "providers"}
def test_collect_aggregate_deadline_yields_controlled_result(monkeypatch):
# If the gather overruns the aggregate ceiling, the response is still a
# controlled {overall, services, timestamp} with each network subsystem
# marked down/timeout — never a hang or a raised exception.
import asyncio
import time
monkeypatch.setattr(sh, "_AGGREGATE_DEADLINE", 0.5)
monkeypatch.setattr(sh, "_SUBSYSTEM_DEADLINE", 0.4)
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
"settings": {}, "integrations": [], "accounts": [], "endpoints": [],
})
async def _slow_gather(*coros, **_k):
for c in coros: # close unawaited coros to avoid warnings
close = getattr(c, "close", None)
if close:
close()
await asyncio.sleep(5)
# Force the outer wait_for to trip by making gather itself slow.
monkeypatch.setattr(sh.asyncio, "gather", _slow_gather)
t0 = time.monotonic()
out = asyncio.run(sh.collect_service_health(None, None))
elapsed = time.monotonic() - t0
assert elapsed < 2, f"aggregate deadline did not bound: {elapsed:.1f}s"
assert set(out) == {"overall", "services", "timestamp"}
net = [s for s in out["services"] if s["name"] != "chromadb"]
assert all(s["status"] == sh.DOWN and s["meta"].get("error") == "timeout"
for s in net)
+80
View File
@@ -0,0 +1,80 @@
"""Tests for email_health — probe logic, status classification, sanitization, and bounded timeout."""
import pytest
from src import service_health as sh
def _raise(*_a, **_k):
raise RuntimeError("connection refused")
def _acct(name, host="imap.example.com"):
return {"account_id": name, "account_name": name, "imap_host": host,
"imap_password": "hunter2"}
class _Conn:
def logout(self):
pass
def test_email_disabled_without_accounts():
assert sh.email_health([])["status"] == sh.DISABLED
def test_email_ok_all_connect():
s = sh.email_health([_acct("a"), _acct("b")], connect=lambda _id: _Conn())
assert s["status"] == sh.OK
def test_email_degraded_some_fail():
def connect(account_id):
if account_id == "bad":
raise RuntimeError("auth failed")
return _Conn()
s = sh.email_health([_acct("good"), _acct("bad")], connect=connect)
assert s["status"] == sh.DEGRADED
def test_email_down_all_fail():
s = sh.email_health([_acct("a")], connect=_raise)
assert s["status"] == sh.DOWN
def test_email_account_without_host_marked_failed():
s = sh.email_health([_acct("a", host="")], connect=lambda _id: _Conn())
assert s["status"] == sh.DOWN
def test_email_meta_never_leaks_password():
s = sh.email_health([_acct("a")], connect=lambda _id: _Conn())
assert "hunter2" not in repr(s)
def test_email_connect_exception_maps_to_category():
def boom(account_id):
raise RuntimeError("login failed for user bob with password hunter2")
s = sh.email_health([_acct("a")], connect=boom)
assert s["status"] == sh.DOWN
assert s["meta"]["accounts"][0]["error"] == "error"
assert "hunter2" not in repr(s)
def test_email_bounded_marks_slow_as_timeout(monkeypatch):
import time
monkeypatch.setattr(sh, "_FANOUT_BUDGET", 1)
def connect(account_id):
if account_id == "slow":
time.sleep(10)
return _Conn()
accts = [_acct("fast"), _acct("slow")]
accts[1]["account_id"] = "slow"
t0 = time.monotonic()
out = sh.email_health(accts, connect=connect)
elapsed = time.monotonic() - t0
assert elapsed < 4, f"email_health not bounded: took {elapsed:.1f}s"
by = {a["name"]: a for a in out["meta"]["accounts"]}
assert by["slow"]["error"] == "timeout"
+62
View File
@@ -0,0 +1,62 @@
"""Tests for ntfy_health — probe logic, status classification, and sanitization."""
import types
import pytest
from src import service_health as sh
def _resp(status_code):
return types.SimpleNamespace(status_code=status_code)
def _raise(*_a, **_k):
raise RuntimeError("connection refused")
def _ntfy_intg():
return [{"preset": "ntfy", "enabled": True, "base_url": "http://ntfy:80"}]
def test_ntfy_disabled_without_integration():
s = sh.ntfy_health([], {"reminder_channel": "ntfy"})
assert s["status"] == sh.DISABLED
def test_ntfy_ok():
s = sh.ntfy_health(_ntfy_intg(), {"reminder_channel": "ntfy"},
http_get=lambda url, timeout: _resp(200))
assert s["status"] == sh.OK
assert s["meta"]["base"] == "http://ntfy:80"
def test_ntfy_probes_v1_health_not_a_topic():
seen = {}
def getter(url, timeout):
seen["url"] = url
return _resp(200)
sh.ntfy_health(_ntfy_intg(), {"reminder_channel": "ntfy"}, http_get=getter)
# Non-intrusive: hits /v1/health, never publishes to a topic.
assert seen["url"].endswith("/v1/health")
def test_ntfy_down_on_exception():
s = sh.ntfy_health(_ntfy_intg(), {"reminder_channel": "ntfy"},
http_get=_raise)
assert s["status"] == sh.DOWN
def test_ntfy_meta_redacts_userinfo_in_base():
intg = [{"preset": "ntfy", "enabled": True,
"base_url": "https://user:topsecret@ntfy.example.com"}]
seen = {}
def getter(url, timeout):
seen["url"] = url # the probe itself may keep credentials
return _resp(200)
s = sh.ntfy_health(intg, {"reminder_channel": "ntfy"}, http_get=getter)
assert s["meta"]["base"] == "https://ntfy.example.com"
assert "topsecret" not in repr(s)
+100
View File
@@ -0,0 +1,100 @@
"""Tests for providers_health — probe logic, status classification, sanitization, and bounded timeout."""
import pytest
from src import service_health as sh
def _raise(*_a, **_k):
raise RuntimeError("connection refused")
def _ep(name):
return {"name": name, "base_url": f"http://{name}:8000/v1", "api_key": "sk-secret"}
def test_providers_disabled_without_endpoints():
assert sh.providers_health([])["status"] == sh.DISABLED
def test_providers_ok_all_reachable():
s = sh.providers_health([_ep("a")],
probe=lambda base, key, timeout: ["m1", "m2"])
assert s["status"] == sh.OK
assert s["meta"]["endpoints"][0]["model_count"] == 2
def test_providers_degraded_some_empty():
def probe(base, key, timeout):
return ["m1"] if "good" in base else []
s = sh.providers_health([_ep("good"), _ep("bad")], probe=probe)
assert s["status"] == sh.DEGRADED
def test_providers_down_all_fail():
s = sh.providers_health([_ep("a")], probe=_raise)
assert s["status"] == sh.DOWN
def test_providers_meta_never_leaks_api_key():
s = sh.providers_health([_ep("a")],
probe=lambda base, key, timeout: ["m1"])
assert "sk-secret" not in repr(s)
def test_providers_name_fallback_is_sanitized():
# No display name → falls back to the base_url, which must be sanitized.
ep = {"base_url": "http://user:k3y@prov.local:9000/v1?api_key=zzz", "api_key": "sk-x"}
s = sh.providers_health([ep], probe=lambda b, k, t: ["m1"])
entry = s["meta"]["endpoints"][0]
assert entry["name"] == "http://prov.local:9000/v1"
assert "k3y" not in repr(s) and "zzz" not in repr(s) and "sk-x" not in repr(s)
def test_providers_probe_exception_maps_to_category():
def boom(base, key, timeout):
raise RuntimeError(f"500 from {base} with key {key}") # would leak base+key
s = sh.providers_health([_ep("a")], probe=boom)
assert s["status"] == sh.DOWN
assert s["meta"]["endpoints"][0]["error"] == "error"
assert "sk-secret" not in repr(s) and "http://a" not in repr(s)
def test_providers_bounded_marks_slow_as_timeout(monkeypatch):
import time
monkeypatch.setattr(sh, "_FANOUT_BUDGET", 1)
def probe(base, key, timeout):
if "slow" in base:
time.sleep(10) # would blow the budget if unbounded
return ["m1"]
eps = [{"name": "fast", "base_url": "http://fast", "api_key": "k"},
{"name": "slow", "base_url": "http://slow", "api_key": "k"}]
t0 = time.monotonic()
out = sh.providers_health(eps, probe=probe)
elapsed = time.monotonic() - t0
assert elapsed < 4, f"providers_health not bounded: took {elapsed:.1f}s"
by = {e["name"]: e for e in out["meta"]["endpoints"]}
assert by["fast"]["ok"] is True
assert by["slow"]["ok"] is False and by["slow"]["error"] == "timeout"
assert out["status"] == sh.DEGRADED
def test_providers_bounded_with_many_slow_endpoints(monkeypatch):
import time
monkeypatch.setattr(sh, "_FANOUT_BUDGET", 1)
def probe(base, key, timeout):
time.sleep(10)
return ["m1"]
eps = [{"name": f"ep{i}", "base_url": f"http://ep{i}", "api_key": "k"}
for i in range(25)]
t0 = time.monotonic()
out = sh.providers_health(eps, probe=probe)
elapsed = time.monotonic() - t0
# 25 endpoints * sleep would be huge if sequential; bounded keeps it ~budget.
assert elapsed < 4, f"not bounded with many endpoints: {elapsed:.1f}s"
assert out["status"] == sh.DOWN
assert all(e["error"] == "timeout" for e in out["meta"]["endpoints"])
+79
View File
@@ -0,0 +1,79 @@
"""Tests for searxng_health — probe logic, status classification, and sanitization."""
import types
import pytest
from src import service_health as sh
def _resp(status_code):
return types.SimpleNamespace(status_code=status_code)
def _raise(*_a, **_k):
raise RuntimeError("connection refused")
def test_searxng_disabled_when_other_provider():
s = sh.searxng_health({"search_provider": "brave"})
assert s["status"] == sh.DISABLED
def test_searxng_ok_on_healthz():
s = sh.searxng_health(
{"search_provider": "searxng", "search_url": "http://sx:8080"},
http_get=lambda url, timeout: _resp(200),
)
assert s["status"] == sh.OK
assert s["meta"]["probed"] == "/healthz"
def test_searxng_ok_on_root_fallback():
def getter(url, timeout):
return _resp(404) if url.endswith("/healthz") else _resp(200)
s = sh.searxng_health(
{"search_provider": "searxng", "search_url": "http://sx:8080"},
http_get=getter,
)
assert s["status"] == sh.OK
assert s["meta"]["probed"] == "/"
def test_searxng_down_on_exception():
s = sh.searxng_health(
{"search_provider": "searxng", "search_url": "http://sx:8080"},
http_get=_raise,
)
assert s["status"] == sh.DOWN
def test_searxng_down_on_5xx():
s = sh.searxng_health(
{"search_provider": "searxng", "search_url": "http://sx:8080"},
http_get=lambda url, timeout: _resp(502),
)
assert s["status"] == sh.DOWN
def test_searxng_meta_redacts_instance_url():
s = sh.searxng_health(
{"search_provider": "searxng",
"search_url": "http://user:s3cr3t@searx.local:8080/?token=zzz"},
http_get=lambda url, timeout: _resp(200),
)
blob = repr(s)
assert "s3cr3t" not in blob and "zzz" not in blob and "user:" not in blob
assert s["meta"]["instance"] == "http://searx.local:8080"
def test_searxng_down_uses_error_category_not_raw_exception():
def boom(url, timeout):
raise RuntimeError("failed connecting to http://user:pw@searx.local secret-token")
s = sh.searxng_health(
{"search_provider": "searxng", "search_url": "http://searx.local"},
http_get=boom,
)
assert s["status"] == sh.DOWN
assert s["meta"]["error"] == "error" # controlled category token
assert "secret-token" not in repr(s) and "pw@" not in repr(s)
+11
View File
@@ -0,0 +1,11 @@
"""Regression: session routes must not call datetime.utcnow() (#1116)."""
import inspect
import routes.session_routes as sr
def test_session_routes_module_does_not_reference_utcnow():
source = inspect.getsource(sr)
assert "datetime.utcnow()" not in source
assert "_dt.utcnow()" not in source
+49
View File
@@ -137,6 +137,55 @@ def test_no_session_manager_is_handled(monkeypatch):
assert "error" in res or "results" in res
class _FakeSession:
def __init__(self, owner, name, history):
self.owner = owner
self.name = name
self.endpoint_url = "http://x"
self.model = "fixture-tool-model" # offline path: returns transcript, no network
self._history = history
self.added = []
def get_context_messages(self):
return list(self._history)
def add_message(self, m):
self.added.append(m)
class _FakeMgr:
def __init__(self, sessions):
self._s = sessions
def get_session(self, sid):
return self._s.get(sid)
def test_send_to_session_blocks_null_owner_for_authenticated_caller(monkeypatch):
# An authenticated caller must not reach a null-owner (legacy / auth-was-off)
# session: list_sessions and manage_session already hide those, so this path
# was the inconsistency — it let an agent read/write a session the other
# tools exclude. Mirrors the calendar owner=None hardening.
null_sess = _FakeSession(None, "Secret", [{"role": "user", "content": "PIN 4321"}])
bob_sess = _FakeSession("bob", "Bob", [{"role": "user", "content": "bob secret"}])
monkeypatch.setattr(st, "get_session_manager",
lambda: _FakeMgr({"nsid": null_sess, "bsid": bob_sess}))
# authenticated alice: null-owner session is not-found and its history is not leaked
r = asyncio.run(st.send_to_session("nsid\nhello", owner="alice"))
assert r.get("error", "").endswith("not found")
assert "4321" not in str(r)
assert null_sess.added == [] # nothing written into it either
# authenticated alice still cannot reach another real user's session
r2 = asyncio.run(st.send_to_session("bsid\nhello", owner="alice"))
assert r2.get("error", "").endswith("not found")
# auth disabled (no owner): single-user still reaches the null-owner session
r3 = asyncio.run(st.send_to_session("nsid\nhello", owner=None))
assert r3.get("offline_transcript") is True
def test_dispatched_via_registry_not_dispatch_ai_tool():
source = (Path(__file__).resolve().parent.parent / "src" / "tool_execution.py").read_text(encoding="utf-8")
assert 'elif tool in ("create_session", "list_sessions", "send_to_session", "manage_session"):' in source
+350
View File
@@ -0,0 +1,350 @@
"""Task CRUD must not let non-admins schedule Cookbook serve actions."""
import sys
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
from tests.helpers.import_state import clear_fake_database_modules
clear_fake_database_modules()
import core.auth as core_auth
import core.database as cdb
import routes.task_routes as task_routes
from core.database import ScheduledTask
from core.database import TaskRun
from src.task_scheduler import TaskScheduler
_REAL_DATABASE_ATTRS = {
"Base": cdb.Base,
"SessionLocal": cdb.SessionLocal,
"ScheduledTask": ScheduledTask,
"TaskRun": TaskRun,
}
if hasattr(cdb, "engine"):
_REAL_DATABASE_ATTRS["engine"] = cdb.engine
def _restore_module_binding(monkeypatch, name, module):
monkeypatch.setitem(sys.modules, name, module)
parent_name, _, attr = name.rpartition(".")
parent = sys.modules.get(parent_name)
if parent is not None:
monkeypatch.setattr(parent, attr, module, raising=False)
@pytest.fixture()
def task_db(monkeypatch, tmp_path):
_restore_module_binding(monkeypatch, "core.database", cdb)
for attr, value in _REAL_DATABASE_ATTRS.items():
monkeypatch.setattr(cdb, attr, value, raising=False)
engine = create_engine(
f"sqlite:///{tmp_path / 'tasks.db'}",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
cdb.Base.metadata.create_all(engine)
testing_session = sessionmaker(bind=engine, autoflush=False, autocommit=False)
monkeypatch.setattr(task_routes, "SessionLocal", testing_session)
monkeypatch.setattr(cdb, "SessionLocal", testing_session)
return testing_session
@pytest.fixture()
def configured_auth(monkeypatch):
_restore_module_binding(monkeypatch, "core.auth", core_auth)
monkeypatch.setenv("AUTH_ENABLED", "true")
class FakeAuthManager:
is_configured = True
def is_admin(self, user):
return user == "admin"
monkeypatch.setattr(core_auth, "AuthManager", FakeAuthManager)
@pytest.fixture()
def builtin_action_info(monkeypatch):
mod = sys.modules.get("src.builtin_actions")
if mod is None:
import src.builtin_actions as mod
monkeypatch.setattr(
mod,
"BUILTIN_ACTION_INFO",
{
"summarize_emails": "Summarize emails",
"cookbook_serve": "Serve Cookbook model",
},
raising=False,
)
def _req(user):
return SimpleNamespace(state=SimpleNamespace(current_user=user))
def _endpoint(method, path):
router = task_routes.setup_task_routes(MagicMock())
for route in router.routes:
if getattr(route, "path", None) == path and method in getattr(route, "methods", set()):
return route.endpoint
raise RuntimeError(f"{method} {path} not found")
def _cookbook_create_req():
return task_routes.TaskCreate(
name="Serve test model",
prompt="{}",
task_type="action",
action="cookbook_serve",
trigger_type="webhook",
)
def _seed_action_task(
session_factory,
task_id,
owner,
action="summarize_emails",
*,
task_type="action",
webhook_token=None,
next_run=None,
):
db = session_factory()
try:
task = ScheduledTask(
id=task_id,
owner=owner,
name=task_id,
prompt="{}",
task_type=task_type,
action=action,
trigger_type="webhook",
status="active",
output_target="session",
webhook_token=webhook_token,
next_run=next_run,
)
db.add(task)
db.commit()
finally:
db.close()
@pytest.mark.asyncio
async def test_non_admin_cannot_create_cookbook_serve_task(task_db, configured_auth):
create_task = _endpoint("POST", "/api/tasks")
with pytest.raises(HTTPException) as exc:
await create_task(_req("alice"), _cookbook_create_req())
assert exc.value.status_code == 403
db = task_db()
try:
assert db.query(ScheduledTask).count() == 0
finally:
db.close()
@pytest.mark.asyncio
async def test_non_admin_cannot_update_task_to_cookbook_serve(task_db, configured_auth):
_seed_action_task(task_db, "alice-task", "alice")
update_task = _endpoint("PUT", "/api/tasks/{task_id}")
with pytest.raises(HTTPException) as exc:
await update_task(
_req("alice"),
"alice-task",
task_routes.TaskUpdate(action="cookbook_serve"),
)
assert exc.value.status_code == 403
db = task_db()
try:
task = db.query(ScheduledTask).filter(ScheduledTask.id == "alice-task").first()
assert task.action == "summarize_emails"
finally:
db.close()
@pytest.mark.asyncio
async def test_non_admin_cannot_update_task_type_to_activate_existing_cookbook_serve(
task_db, configured_auth
):
_seed_action_task(
task_db,
"alice-task",
"alice",
action="cookbook_serve",
task_type="llm",
)
update_task = _endpoint("PUT", "/api/tasks/{task_id}")
with pytest.raises(HTTPException) as exc:
await update_task(
_req("alice"),
"alice-task",
task_routes.TaskUpdate(task_type="action"),
)
assert exc.value.status_code == 403
db = task_db()
try:
task = db.query(ScheduledTask).filter(ScheduledTask.id == "alice-task").first()
assert task.task_type == "llm"
assert task.action == "cookbook_serve"
finally:
db.close()
@pytest.mark.asyncio
async def test_non_admin_cannot_manually_run_existing_cookbook_serve_task(
task_db, configured_auth
):
_seed_action_task(task_db, "alice-task", "alice", action="cookbook_serve")
scheduler = SimpleNamespace(run_task_now=MagicMock())
router = task_routes.setup_task_routes(scheduler)
for route in router.routes:
if getattr(route, "path", None) == "/api/tasks/{task_id}/run":
run_task = route.endpoint
break
else:
raise RuntimeError("POST /api/tasks/{task_id}/run not found")
with pytest.raises(HTTPException) as exc:
await run_task(_req("alice"), "alice-task")
assert exc.value.status_code == 403
scheduler.run_task_now.assert_not_called()
@pytest.mark.asyncio
async def test_webhook_rejects_stale_non_admin_cookbook_serve_task(
task_db, configured_auth
):
_seed_action_task(
task_db,
"alice-task",
"alice",
action="cookbook_serve",
webhook_token="secret",
)
webhook_trigger = _endpoint("POST", "/api/tasks/{task_id}/webhook/{token}")
with pytest.raises(HTTPException) as exc:
await webhook_trigger("alice-task", "secret")
assert exc.value.status_code == 403
db = task_db()
try:
task = db.query(ScheduledTask).filter(ScheduledTask.id == "alice-task").first()
assert task.status == "paused"
assert task.next_run is None
finally:
db.close()
@pytest.mark.asyncio
async def test_scheduler_pauses_stale_non_admin_cookbook_serve_task(
task_db, configured_auth
):
due = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(minutes=1)
_seed_action_task(
task_db,
"alice-task",
"alice",
action="cookbook_serve",
next_run=due,
)
db = task_db()
try:
db.add(TaskRun(id="run-1", task_id="alice-task", status="queued"))
db.commit()
finally:
db.close()
scheduler = TaskScheduler.__new__(TaskScheduler)
scheduler._task_handles = {}
await scheduler._execute_task_locked(
"alice-task",
"run-1",
gate_foreground=False,
release_executing=False,
)
db = task_db()
try:
task = db.query(ScheduledTask).filter(ScheduledTask.id == "alice-task").first()
run = db.query(TaskRun).filter(TaskRun.id == "run-1").first()
assert task.status == "paused"
assert task.next_run is None
assert run.status == "error"
assert run.error == "Action 'cookbook_serve' requires admin privileges"
finally:
db.close()
@pytest.mark.asyncio
async def test_non_admin_action_metadata_hides_cookbook_serve(
configured_auth, builtin_action_info
):
list_actions = _endpoint("GET", "/api/tasks/meta/actions")
out = await list_actions(_req("alice"))
action_names = {action["name"] for action in out["actions"]}
assert "cookbook_serve" not in action_names
@pytest.mark.asyncio
async def test_admin_can_create_cookbook_serve_task(task_db, configured_auth):
create_task = _endpoint("POST", "/api/tasks")
out = await create_task(_req("admin"), _cookbook_create_req())
assert out["action"] == "cookbook_serve"
db = task_db()
try:
task = db.query(ScheduledTask).filter(ScheduledTask.id == out["id"]).first()
assert task.owner == "admin"
finally:
db.close()
@pytest.mark.asyncio
async def test_admin_action_metadata_includes_cookbook_serve(
configured_auth, builtin_action_info
):
list_actions = _endpoint("GET", "/api/tasks/meta/actions")
out = await list_actions(_req("admin"))
action_names = {action["name"] for action in out["actions"]}
assert "cookbook_serve" in action_names
@pytest.mark.asyncio
async def test_auth_disabled_single_user_can_create_cookbook_serve_task(
monkeypatch, task_db
):
monkeypatch.setenv("AUTH_ENABLED", "false")
create_task = _endpoint("POST", "/api/tasks")
out = await create_task(_req(None), _cookbook_create_req())
assert out["action"] == "cookbook_serve"
db = task_db()
try:
task = db.query(ScheduledTask).filter(ScheduledTask.id == out["id"]).first()
assert task.owner is None
finally:
db.close()
+21
View File
@@ -57,6 +57,27 @@ def test_non_sensitive_path():
assert not _is_sensitive_path("/home/user/projects/file.py")
def test_sensitive_case_insensitive():
"""On case-insensitive filesystems (Windows, default macOS) a case-variant
name resolves to the same protected file, so the deny-list must match
regardless of case. Built with os.path.join so the separator is right on
both POSIX and Windows.
"""
from src.tool_execution import _is_sensitive_path
# sensitive directory, varied case
assert _is_sensitive_path(os.path.join("home", "u", ".SSH", "authorized_keys"))
assert _is_sensitive_path(os.path.join("home", "u", ".Gnupg", "pubring.kbx"))
# sensitive filename, varied case
assert _is_sensitive_path(os.path.join("ws", "AUTHORIZED_KEYS"))
assert _is_sensitive_path(os.path.join("ws", "Id_Rsa"))
assert _is_sensitive_path(os.path.join("ws", ".ENV"))
assert _is_sensitive_path(os.path.join("ws", ".Env"))
# both dir and file varied
assert _is_sensitive_path(os.path.join("home", "u", ".SSH", "AUTHORIZED_KEYS"))
# an ordinary file with none of the sensitive names is still allowed
assert not _is_sensitive_path(os.path.join("ws", "Readme.md"))
# ── Unit tests on _resolve_tool_path ─────────────────────────────────
def test_blocks_etc_shadow():
+51
View File
@@ -13,6 +13,57 @@ import pytest
from src.constants import WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES
from services.search import content as content_mod
import pytest as _pytest_for_client_stream_compat
@_pytest_for_client_stream_compat.fixture(autouse=True)
def _client_stream_compat_for_pinned_fetch(monkeypatch):
"""Adapt old size-cap tests to the current pinned Client.stream path.
These tests monkeypatch httpx.stream(...) to return fake responses. The
production fetcher now uses httpx.Client(...).stream(...) so it can pass a
pinned transport. When a test has replaced httpx.stream, route Client.stream
through that fake. When it has not, fall back to a real Client so unrelated
behavior in this file is not changed.
"""
import httpx
real_client_cls = httpx.Client
original_stream = httpx.stream
class _ClientProxy:
def __init__(self, *args, **kwargs):
self._args = args
self._kwargs = kwargs
self._real_cm = None
self._real_client = None
def __enter__(self):
if httpx.stream is original_stream:
self._real_cm = real_client_cls(*self._args, **self._kwargs)
self._real_client = self._real_cm.__enter__()
return self._real_client
return self
def __exit__(self, *args):
if self._real_cm is not None:
return self._real_cm.__exit__(*args)
return False
def stream(self, method, url):
if self._real_client is not None:
return self._real_client.stream(method, url)
kwargs = {
"headers": self._kwargs.get("headers"),
"timeout": self._kwargs.get("timeout"),
"follow_redirects": self._kwargs.get("follow_redirects"),
}
return httpx.stream(method, url, **kwargs)
monkeypatch.setattr(httpx, "Client", _ClientProxy)
class _FakeStream:
"""Stands in for the httpx.stream(...) context manager."""
+156
View File
@@ -0,0 +1,156 @@
"""Regression: webhook delivery must pin the TCP connect to the SSRF-approved IP.
validate_webhook_url resolves the host to accept/reject, but the delivery
connect previously re-resolved independently — a DNS record flipping between
the two lookups (rebinding) could slip an internal IP past the check. _deliver
now resolves+validates once via _validated_public_ips and pins the connect to
that IP through _PinnedAsyncTransport. These tests drive the real transport
against local servers so the pin is exercised end-to-end, not mocked away.
"""
import asyncio
import http.server
import ipaddress
import socketserver
import threading
import pytest
from tests.helpers.import_state import clear_module, preserve_import_state
import os
import sys
from unittest.mock import patch
with patch.dict(os.environ, {"DATABASE_URL": "sqlite:///:memory:"}), \
preserve_import_state("src.database", "core.database"):
clear_module("src.database")
_core_database = sys.modules.get("core.database")
if _core_database is not None and not getattr(_core_database, "__file__", None):
del sys.modules["core.database"]
import src.webhook_manager as wm
# ---------------------------------------------------------------------------
# _validated_public_ips
# ---------------------------------------------------------------------------
def test_validated_public_ips_rejects_metadata_literal():
with pytest.raises(ValueError):
wm._validated_public_ips("http://169.254.169.254/")
def test_validated_public_ips_rejects_loopback_literal():
with pytest.raises(ValueError):
wm._validated_public_ips("http://127.0.0.1/")
def test_validated_public_ips_returns_public_literal():
ips = wm._validated_public_ips("http://93.184.216.34/")
assert ips == [ipaddress.ip_address("93.184.216.34")]
def test_validated_public_ips_rejects_hostname_resolving_private(monkeypatch):
# Rebinding shape: a hostname that (now) resolves into loopback space.
monkeypatch.setattr(wm, "_resolve_hostname_ips",
lambda h: [ipaddress.ip_address("127.0.0.1")])
with pytest.raises(ValueError):
wm._validated_public_ips("http://evil.rebind.example/")
# ---------------------------------------------------------------------------
# End-to-end: the pinned transport actually routes to the pinned IP
# ---------------------------------------------------------------------------
def _serve(handler):
srv = socketserver.TCPServer(("127.0.0.1", 0), handler)
port = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()
return srv, port
def test_pinned_transport_connects_to_pinned_ip():
"""A request whose URL host is a throwaway hostname is still delivered to
the pinned loopback IP — proving the socket destination comes from the pin,
not from resolving the URL host."""
hits = []
class _Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self): # noqa: N802
length = int(self.headers.get("Content-Length", 0))
self.rfile.read(length)
hits.append(self.path)
self.send_response(204)
self.end_headers()
def log_message(self, *a):
pass
srv, port = _serve(_Handler)
try:
ip = ipaddress.ip_address("127.0.0.1")
transport = wm._PinnedAsyncTransport(ip)
async def go():
async with __import__("httpx").AsyncClient(
transport=transport, timeout=5, follow_redirects=False,
) as client:
# Host "unresolvable.invalid" would never resolve; the pin is
# what makes this reach the loopback server on `port`.
return await client.post(
f"http://unresolvable.invalid:{port}/hook", content=b"{}",
)
resp = asyncio.run(go())
assert resp.status_code == 204
assert hits == ["/hook"]
finally:
srv.shutdown()
def test_deliver_pins_to_validated_ip_end_to_end(monkeypatch):
"""Full _deliver path: a hostname that validation resolves to loopback is
pinned to loopback and the local server receives the signed POST."""
received = {}
class _Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self): # noqa: N802
length = int(self.headers.get("Content-Length", 0))
received["body"] = self.rfile.read(length)
received["event"] = self.headers.get("X-Odysseus-Event")
self.send_response(200)
self.end_headers()
def log_message(self, *a):
pass
srv, port = _serve(_Handler)
class _Query:
def filter(self, *a, **k): return self
def update(self, values): return None
class _Db:
def query(self, _m): return _Query()
def commit(self): pass
def rollback(self): pass
def close(self): pass
# Make both the validation resolve and the pin target loopback, and treat
# loopback as allowed for this test (production blocks it — here we only
# want to prove the pin routes to the validated IP).
monkeypatch.setattr(wm, "SessionLocal", lambda: _Db())
monkeypatch.setattr(wm, "_is_private_url", lambda url: False)
monkeypatch.setattr(wm, "_resolve_hostname_ips",
lambda h: [ipaddress.ip_address("127.0.0.1")])
monkeypatch.setattr(wm, "_ip_is_private", lambda a: False)
manager = wm.WebhookManager()
try:
asyncio.run(manager._deliver(
"hook-1", f"http://webhook.test:{port}/cb", "s3cret",
"webhook.test", {"ok": True},
))
assert received.get("event") == "webhook.test"
assert b'"ok": true' in received["body"]
finally:
srv.shutdown()
+16 -13
View File
@@ -96,26 +96,29 @@ async def test_webhook_delivery_uses_naive_utc_timestamps(monkeypatch):
class _Response:
status_code = 204
class _Client:
def __init__(self):
self.content = ""
async def post(self, _url, content, headers):
self.content = content
assert headers["X-Odysseus-Event"] == "webhook.test"
return _Response()
db = _Db()
client = _Client()
monkeypatch.setattr(wm, "SessionLocal", lambda: db)
manager = wm.WebhookManager()
await manager._client.aclose()
manager._client = client
# Replace the pinned-transport send seam so no real socket is opened. The
# public-IP literal below still exercises _validated_public_ips (which pins
# the connect); the captured content proves the body/headers are built.
captured = {}
async def _fake_send(url, body, headers, ip):
captured["content"] = body
captured["ip"] = str(ip)
assert headers["X-Odysseus-Event"] == "webhook.test"
return _Response()
monkeypatch.setattr(manager, "_send_request", _fake_send)
await manager._deliver("hook-1", "http://93.184.216.34/", None, "webhook.test", {"ok": True})
body = json.loads(client.content)
# The delivery must have pinned to the literal public IP from the URL.
assert captured["ip"] == "93.184.216.34"
body = json.loads(captured["content"])
payload_timestamp = datetime.fromisoformat(body["timestamp"])
assert payload_timestamp.tzinfo is None
assert db.updates[0]["last_triggered_at"].tzinfo is None
+32
View File
@@ -167,6 +167,38 @@ async def test_glob_confined_e2e(ws, admin):
assert r["exit_code"] == 0 and "No files" in r["output"]
@pytest.mark.asyncio
async def test_glob_skips_sensitive_files_in_workspace(ws, admin):
"""glob must not enumerate deny-listed sensitive files that live inside the
workspace. read_file/write_file/edit_file refuse them and grep skips them,
so glob surfacing their paths is an enumeration oracle for prompt-injection.
"""
with open(os.path.join(ws, "keep.py"), "w") as f:
f.write("x")
with open(os.path.join(ws, ".env"), "w") as f:
f.write("AWS_SECRET=xxx")
with open(os.path.join(ws, "id_rsa"), "w") as f: # non-dotfile key at root
f.write("KEY")
os.makedirs(os.path.join(ws, ".ssh"), exist_ok=True)
with open(os.path.join(ws, ".ssh", "authorized_keys"), "w") as f:
f.write("ssh-rsa AAAA")
# A recursive wildcard returns ordinary files but none of the sensitive
# ones. The pattern "**/*" contains no secret names, so a secret basename
# appearing in the output is a real leak (not the echoed not-found pattern).
_, r = await execute_tool_block(_block("glob", json.dumps({"pattern": "**/*"})), owner="a", workspace=ws)
assert r["exit_code"] == 0
assert "keep.py" in r["output"]
for leak in (".env", "id_rsa", "authorized_keys"):
assert leak not in r["output"], f"glob leaked sensitive file: {leak}"
# Directly targeting a sensitive file (literal fast-path and wildcard) must
# come back as the not-found message, never a match with the file's path.
for pat in (".env", "**/id_rsa", "**/authorized_keys"):
_, r = await execute_tool_block(_block("glob", json.dumps({"pattern": pat})), owner="a", workspace=ws)
assert r["exit_code"] == 0 and "No files" in r["output"]
@pytest.mark.asyncio
async def test_subprocess_cwd_is_workspace_e2e(ws, admin):
"""python tool runs with cwd = workspace (OS-agnostic probe)."""