mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-12 12:37:32 +00:00
Merge remote-tracking branch 'origin/dev'
# Conflicts: # routes/contacts_routes.py
This commit is contained in:
@@ -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=(
|
||||
|
||||
@@ -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"') == {}
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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") == []
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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():
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
Reference in New Issue
Block a user