mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-21 07:32:19 +02:00
Merge verified Odysseus fixes
This commit is contained in:
@@ -26,6 +26,7 @@ try:
|
||||
import sqlalchemy # noqa: F401
|
||||
import sqlalchemy.orm # noqa: F401
|
||||
import core.database # noqa: F401
|
||||
import src.database
|
||||
except ImportError:
|
||||
pass # not installed - the stubs below will handle it
|
||||
|
||||
|
||||
@@ -56,6 +56,20 @@ def test_explicit_web_search_promotes_to_agent():
|
||||
assert classify_tool_intent("use web search and find a recipe").category == "web"
|
||||
|
||||
|
||||
def test_workspace_agent_requests_promote_to_shell_workspace():
|
||||
prompts = [
|
||||
"fix the bug in this repo",
|
||||
"run the tests for this project",
|
||||
"debug the server logs",
|
||||
"run terminal-bench on this task",
|
||||
"inspect the traceback and patch the code",
|
||||
]
|
||||
for prompt in prompts:
|
||||
intent = classify_tool_intent(prompt)
|
||||
assert intent.needs_tools
|
||||
assert intent.category == "workspace"
|
||||
|
||||
|
||||
def test_explanatory_calendar_questions_stay_plain_chat():
|
||||
assert not message_needs_tools("How do I add an entry to my calendar?")
|
||||
assert not message_needs_tools("What about the built-in Odysseus calendar, is that linked to email?")
|
||||
|
||||
@@ -28,6 +28,24 @@ def test_provider_selection_is_inert_and_add_button_starts_device_flow():
|
||||
assert "_startProviderDeviceAuth(deviceAuthProvider" in add_block
|
||||
|
||||
|
||||
def test_google_add_omits_auto_refresh_mode_for_backend_manual_default():
|
||||
refresh_helper = _between(
|
||||
_ADMIN,
|
||||
"function _modelRefreshModeForApiEndpoint",
|
||||
"function _normalizeBaseUrl",
|
||||
)
|
||||
add_block = _between(
|
||||
_ADMIN,
|
||||
"el('adm-epAddBtn').addEventListener('click'",
|
||||
"async function _startProviderDeviceAuth",
|
||||
)
|
||||
|
||||
assert "generativelanguage.googleapis.com" in refresh_helper
|
||||
assert "return '';" in refresh_helper
|
||||
assert "_modelRefreshModeForApiEndpoint(url, endpointKind)" in add_block
|
||||
assert "if (refreshMode) fd.append('model_refresh_mode', refreshMode)" in add_block
|
||||
|
||||
|
||||
def test_device_auth_selection_disables_and_dims_api_test_button():
|
||||
form_block = _between(_ADMIN, "function _setApiFormForProvider()", "function _renderPickerMenu()")
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Regression test for the admin_wipe route shim (slice 2h, #4082/#4071).
|
||||
|
||||
The backward-compat shim at ``routes/admin_wipe_routes.py`` uses
|
||||
``sys.modules`` replacement so the legacy import path and the canonical
|
||||
``routes.admin_wipe.*`` path resolve to the *same* module object. This is
|
||||
required because ``test_admin_wipe_gallery.py`` does
|
||||
``import routes.admin_wipe_routes`` followed by
|
||||
``monkeypatch.setattr(routes.admin_wipe_routes, "SessionLocal", ...)`` and
|
||||
``"require_admin"`` — for those patches to take effect at runtime, the legacy
|
||||
module object and the canonical one must be identical.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
|
||||
import routes.admin_wipe_routes as _shim_admin_wipe # noqa: F401
|
||||
|
||||
|
||||
def test_legacy_and_canonical_admin_wipe_module_are_same_object():
|
||||
"""``import routes.admin_wipe_routes`` must alias the canonical module."""
|
||||
legacy = importlib.import_module("routes.admin_wipe_routes")
|
||||
canonical = importlib.import_module("routes.admin_wipe.admin_wipe_routes")
|
||||
assert legacy is canonical, (
|
||||
"routes.admin_wipe_routes shim must resolve to the canonical "
|
||||
"routes.admin_wipe.admin_wipe_routes module object"
|
||||
)
|
||||
@@ -68,3 +68,24 @@ def test_no_rounds_exhausted_on_normal_finish(monkeypatch):
|
||||
# A plain answer (no tool block) -> done-break on round 1 -> no event.
|
||||
events = _run_loop(monkeypatch, "All done, here is your answer.", max_rounds=2)
|
||||
assert not any(e.get("type") == "rounds_exhausted" for e in events), events
|
||||
|
||||
|
||||
def test_emits_intent_nudge_exhausted_when_cap_is_exhausted(monkeypatch):
|
||||
_patch_common(monkeypatch)
|
||||
|
||||
events = _run_loop(monkeypatch, "Let me check the logs", max_rounds=5)
|
||||
|
||||
guard = next((e for e in events if e.get("type") == "intent_nudge_exhausted"), None)
|
||||
assert guard is not None, events
|
||||
assert guard["reason"] == "intent_without_action_nudge_cap"
|
||||
assert guard["nudges"] == 2
|
||||
|
||||
|
||||
def test_emits_loop_breaker_triggered_when_loop_breaker_trips(monkeypatch):
|
||||
_patch_common(monkeypatch)
|
||||
|
||||
events = _run_loop(monkeypatch, "```bash\necho hi\n```", max_rounds=6)
|
||||
|
||||
guard = next((e for e in events if e.get("type") == "loop_breaker_triggered"), None)
|
||||
assert guard is not None, events
|
||||
assert guard["reason"] == "loop_breaker_stall"
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="POSIX mode bits (0o600) don't exist on Windows; safe_chmod no-ops there.",
|
||||
)
|
||||
def test_app_db_created_with_0600(tmp_path):
|
||||
"""app.db holds secrets — it must not be world-readable.
|
||||
|
||||
Note: under umask 077 a fresh sqlite file is born 0600 and this would pass
|
||||
even without the chmod; dev/CI umask is 022, where the chmod is what makes
|
||||
it pass. No umask machinery needed — just don't read a green here as proof
|
||||
on a 077 box.
|
||||
|
||||
A subprocess (not in-process patching) is used deliberately: the engine
|
||||
binds to DATABASE_URL at import time, so a fresh interpreter with its own
|
||||
DATABASE_URL is the clean way to exercise init_db() against a real on-disk
|
||||
file without rebinding the already-imported engine.
|
||||
"""
|
||||
db_file = tmp_path / "app.db"
|
||||
env = {**os.environ, "DATABASE_URL": f"sqlite:///{db_file}"}
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
# Importing core.database runs init_db() against the temp file-backed DB.
|
||||
# cwd=repo_root so `import core` resolves (the `-c` sys.path[0] is the CWD).
|
||||
subprocess.run(
|
||||
[sys.executable, "-c", "import core.database"],
|
||||
env=env,
|
||||
cwd=repo_root,
|
||||
check=True,
|
||||
)
|
||||
assert db_file.exists()
|
||||
mode = db_file.stat().st_mode & 0o777
|
||||
assert mode == 0o600, f"expected 0o600, got 0o{mode:o}"
|
||||
|
||||
# Upgrade path: an already-deployed DB sitting at 0644 must be re-corrected
|
||||
# on the next startup. The chmod is unconditional (not gated on create_all
|
||||
# having created the file), so this is the common path for existing installs.
|
||||
db_file.chmod(0o644)
|
||||
subprocess.run(
|
||||
[sys.executable, "-c", "import core.database"],
|
||||
env=env,
|
||||
cwd=repo_root,
|
||||
check=True,
|
||||
)
|
||||
assert db_file.stat().st_mode & 0o777 == 0o600, "existing 0644 DB not re-locked on startup"
|
||||
|
||||
|
||||
def test_normalize_sqlite_url_preserves_sqlite_uri_filename():
|
||||
"""URI filenames must reach SQLAlchemy unchanged for SQLite to parse."""
|
||||
from core.database import _normalize_sqlite_url
|
||||
|
||||
url = "sqlite:///file:/tmp/app.db?mode=rwc&uri=true"
|
||||
assert _normalize_sqlite_url(url) == url
|
||||
|
||||
|
||||
def test_sqlite_db_path_handles_driver_and_query_forms():
|
||||
"""The path fed to chmod must come from SQLAlchemy's parsed URL, not a naive
|
||||
replace("sqlite:///"). A driver-qualified URL (sqlite+pysqlite://) or one
|
||||
carrying query args (?cache=shared) would otherwise resolve to the wrong
|
||||
path and leave the real file world-readable. Pure logic — runs everywhere.
|
||||
"""
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
from core.database import _sqlite_db_path
|
||||
|
||||
# Plain forms (relative + absolute) resolve to the file path.
|
||||
assert _sqlite_db_path(make_url("sqlite:///data/app.db")) == "data/app.db"
|
||||
assert _sqlite_db_path(make_url("sqlite:////abs/app.db")) == "/abs/app.db"
|
||||
# A driver qualifier must not defeat detection...
|
||||
assert _sqlite_db_path(make_url("sqlite+pysqlite:///data/app.db")) == "data/app.db"
|
||||
# ...and query args must be stripped from the path.
|
||||
assert _sqlite_db_path(make_url("sqlite:///data/app.db?cache=shared")) == "data/app.db"
|
||||
assert _sqlite_db_path(make_url("sqlite+pysqlite:////abs/app.db?mode=ro")) == "/abs/app.db"
|
||||
# Nothing to lock for non-file-backed or non-sqlite databases.
|
||||
assert _sqlite_db_path(make_url("sqlite:///:memory:")) is None
|
||||
assert _sqlite_db_path(make_url("sqlite://")) is None
|
||||
assert _sqlite_db_path(make_url("postgresql+psycopg2://u:p@h/db")) is None
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="POSIX mode bits (0o600) don't exist on Windows; safe_chmod no-ops there.",
|
||||
)
|
||||
def test_app_db_sidecars_relocked(tmp_path):
|
||||
"""Stale SQLite sidecars (-wal/-shm) left by an older 0o644 install hold
|
||||
copies of DB pages, so startup must re-lock them too — not just app.db.
|
||||
|
||||
The default -journal is transient (SQLite deletes it after the create_all
|
||||
commit), so it isn't asserted on here; -wal/-shm persist and are the real
|
||||
exposure once WAL has ever been enabled.
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
db_file = tmp_path / "app.db"
|
||||
sqlite3.connect(db_file).close() # a real, pre-existing DB ...
|
||||
db_file.chmod(0o644)
|
||||
sidecars = [tmp_path / f"app.db{sfx}" for sfx in ("-wal", "-shm")]
|
||||
for s in sidecars:
|
||||
s.write_bytes(b"")
|
||||
s.chmod(0o644)
|
||||
|
||||
env = {**os.environ, "DATABASE_URL": f"sqlite:///{db_file}"}
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
subprocess.run(
|
||||
[sys.executable, "-c", "import core.database"],
|
||||
env=env,
|
||||
cwd=repo_root,
|
||||
check=True,
|
||||
)
|
||||
|
||||
assert db_file.stat().st_mode & 0o777 == 0o600
|
||||
for s in sidecars:
|
||||
assert s.stat().st_mode & 0o777 == 0o600, f"{s.name} not re-locked on startup"
|
||||
|
||||
|
||||
def test_sqlite_db_path_handles_file_uri_forms(tmp_path):
|
||||
"""SQLite URI filenames must chmod the real filesystem path, not the
|
||||
literal file: URI string. Memory URI databases should still be skipped."""
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
from core.database import _sqlite_db_path
|
||||
|
||||
db_file = tmp_path / "uri-app.db"
|
||||
|
||||
assert (
|
||||
_sqlite_db_path(make_url(f"sqlite+pysqlite:///file:{db_file}?mode=rwc&uri=true"))
|
||||
== str(db_file)
|
||||
)
|
||||
assert (
|
||||
_sqlite_db_path(make_url(f"sqlite:///file:{db_file}?cache=shared&uri=true"))
|
||||
== str(db_file)
|
||||
)
|
||||
|
||||
localhost_db = tmp_path / "localhost-uri.db"
|
||||
assert (
|
||||
_sqlite_db_path(
|
||||
make_url(
|
||||
f"sqlite+pysqlite:///file://localhost{localhost_db}"
|
||||
"?mode=rwc&uri=true"
|
||||
)
|
||||
)
|
||||
== str(localhost_db)
|
||||
)
|
||||
|
||||
non_uri_mode_db = tmp_path / "mode-query-file.db"
|
||||
assert (
|
||||
_sqlite_db_path(
|
||||
make_url(
|
||||
f"sqlite+pysqlite:///{non_uri_mode_db}?mode=memory"
|
||||
)
|
||||
)
|
||||
== str(non_uri_mode_db)
|
||||
)
|
||||
assert (
|
||||
_sqlite_db_path(make_url("sqlite+pysqlite:///file::memory:?cache=shared&uri=true"))
|
||||
is None
|
||||
)
|
||||
assert (
|
||||
_sqlite_db_path(make_url("sqlite+pysqlite:///file:memdb1?mode=memory&cache=shared&uri=true"))
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="POSIX mode bits (0o600) don't exist on Windows; safe_chmod no-ops there.",
|
||||
)
|
||||
def test_app_db_file_uri_created_with_0600(tmp_path):
|
||||
"""Import-time DB initialization must lock SQLite file: URI databases too."""
|
||||
db_file = tmp_path / "uri-app.db"
|
||||
env = {
|
||||
**os.environ,
|
||||
"DATABASE_URL": f"sqlite+pysqlite:///file:{db_file}?mode=rwc&uri=true",
|
||||
}
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
|
||||
subprocess.run(
|
||||
[sys.executable, "-c", "import core.database"],
|
||||
env=env,
|
||||
cwd=repo_root,
|
||||
check=True,
|
||||
)
|
||||
|
||||
assert db_file.exists()
|
||||
mode = db_file.stat().st_mode & 0o777
|
||||
assert mode == 0o600, f"expected 0o600, got 0o{mode:o}"
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="POSIX mode bits (0o600) don't exist on Windows; safe_chmod no-ops there.",
|
||||
)
|
||||
def test_app_db_localhost_file_uri_created_with_0600(tmp_path):
|
||||
"""A file://localhost URI must chmod the local path SQLite opens."""
|
||||
db_file = tmp_path / "localhost-uri.db"
|
||||
env = {
|
||||
**os.environ,
|
||||
"DATABASE_URL": (
|
||||
f"sqlite+pysqlite:///file://localhost{db_file}"
|
||||
"?mode=rwc&uri=true"
|
||||
),
|
||||
}
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
|
||||
subprocess.run(
|
||||
[sys.executable, "-c", "import core.database"],
|
||||
env=env,
|
||||
cwd=repo_root,
|
||||
check=True,
|
||||
)
|
||||
|
||||
assert db_file.exists()
|
||||
mode = db_file.stat().st_mode & 0o777
|
||||
assert mode == 0o600, f"expected 0o600, got 0o{mode:o}"
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="POSIX mode bits (0o600) don't exist on Windows; safe_chmod no-ops there.",
|
||||
)
|
||||
def test_app_db_non_uri_mode_query_created_with_0600(tmp_path):
|
||||
"""mode=memory without uri=true must not hide a real SQLite file."""
|
||||
db_file = tmp_path / "mode-query-file.db"
|
||||
env = {
|
||||
**os.environ,
|
||||
"DATABASE_URL": f"sqlite+pysqlite:///{db_file}?mode=memory",
|
||||
}
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
|
||||
subprocess.run(
|
||||
[sys.executable, "-c", "import core.database"],
|
||||
env=env,
|
||||
cwd=repo_root,
|
||||
check=True,
|
||||
)
|
||||
|
||||
assert db_file.exists()
|
||||
mode = db_file.stat().st_mode & 0o777
|
||||
assert mode == 0o600, f"expected 0o600, got 0o{mode:o}"
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="POSIX mode bits (0o600) don't exist on Windows; safe_chmod no-ops there.",
|
||||
)
|
||||
def test_app_db_plain_file_uri_created_with_0600(tmp_path):
|
||||
"""The documented sqlite:///file: URI form must remain protected."""
|
||||
db_file = tmp_path / "plain-uri-app.db"
|
||||
env = {
|
||||
**os.environ,
|
||||
"DATABASE_URL": f"sqlite:///file:{db_file}?mode=rwc&uri=true",
|
||||
}
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
|
||||
subprocess.run(
|
||||
[sys.executable, "-c", "import core.database"],
|
||||
env=env,
|
||||
cwd=repo_root,
|
||||
check=True,
|
||||
)
|
||||
|
||||
assert db_file.exists()
|
||||
mode = db_file.stat().st_mode & 0o777
|
||||
assert mode == 0o600, f"expected 0o600, got 0o{mode:o}"
|
||||
@@ -0,0 +1,75 @@
|
||||
import json
|
||||
|
||||
from src.attachment_refs import (
|
||||
attachment_ref,
|
||||
persistable_message_content,
|
||||
search_index_text,
|
||||
)
|
||||
|
||||
|
||||
def test_persistable_message_content_replaces_inline_media_with_attachment_ref():
|
||||
metadata = {
|
||||
"attachments": [
|
||||
{
|
||||
"id": "abc123.png",
|
||||
"name": "diagram.png",
|
||||
"mime": "image/png",
|
||||
"size": 42,
|
||||
"checksum_sha256": "sha256-digest",
|
||||
"created_at": "2026-07-09T12:00:00",
|
||||
"vision": "A small architecture diagram.",
|
||||
}
|
||||
]
|
||||
}
|
||||
content = [
|
||||
{"type": "text", "text": "Please inspect this."},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64," + ("A" * 5000)},
|
||||
},
|
||||
]
|
||||
|
||||
stored = persistable_message_content(content, metadata)
|
||||
|
||||
assert "base64" not in stored
|
||||
assert "A" * 100 not in stored
|
||||
assert "Please inspect this." in stored
|
||||
assert "Attachment: diagram.png" in stored
|
||||
assert "id=abc123.png" in stored
|
||||
assert "sha256=sha256-digest" in stored
|
||||
assert "A small architecture diagram." in stored
|
||||
|
||||
|
||||
def test_search_index_text_strips_legacy_serialized_data_url_blocks():
|
||||
legacy = json.dumps([
|
||||
{"type": "text", "text": "Find this useful caption"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/jpeg;base64," + ("B" * 4096)},
|
||||
},
|
||||
])
|
||||
|
||||
indexed = search_index_text(legacy)
|
||||
|
||||
assert indexed == "Find this useful caption\n[1 inline media payload omitted]"
|
||||
|
||||
|
||||
def test_attachment_ref_normalizes_hash_aliases():
|
||||
ref = attachment_ref({
|
||||
"id": "file-id",
|
||||
"original_name": "report.pdf",
|
||||
"mime": "application/pdf",
|
||||
"size": 99,
|
||||
"hash": "abc",
|
||||
"uploaded_at": "2026-07-09T12:00:00",
|
||||
})
|
||||
|
||||
assert ref == {
|
||||
"type": "attachment_ref",
|
||||
"attachment_id": "file-id",
|
||||
"name": "report.pdf",
|
||||
"mime": "application/pdf",
|
||||
"size": 99,
|
||||
"checksum_sha256": "abc",
|
||||
"created_at": "2026-07-09T12:00:00",
|
||||
}
|
||||
@@ -25,6 +25,46 @@ def _verify_args(path: Path):
|
||||
return SimpleNamespace(path=str(path), pretty=False)
|
||||
|
||||
|
||||
def test_backup_entry_skips_files_that_disappear():
|
||||
backup = _load_backup_cli()
|
||||
|
||||
class Vanished:
|
||||
name = "gone.tar.gz"
|
||||
|
||||
def is_file(self):
|
||||
return True
|
||||
|
||||
def stat(self):
|
||||
raise FileNotFoundError("gone")
|
||||
|
||||
def __str__(self):
|
||||
return "backups/gone.tar.gz"
|
||||
|
||||
assert backup._backup_entry(Vanished()) is None
|
||||
|
||||
|
||||
def test_backup_list_sorts_by_captured_mtime(monkeypatch):
|
||||
backup = _load_backup_cli()
|
||||
first = SimpleNamespace(name="older.tar.gz")
|
||||
second = SimpleNamespace(name="newer.tar.gz")
|
||||
monkeypatch.setattr(backup, "_BACKUP_DIR", SimpleNamespace(
|
||||
is_dir=lambda: True,
|
||||
iterdir=lambda: [first, second],
|
||||
))
|
||||
monkeypatch.setattr(backup, "_backup_entry", lambda p: {
|
||||
"name": p.name,
|
||||
"modified": "2026-10-25T01:45:00" if p is first else "2026-10-25T01:15:00",
|
||||
"_mtime": 100 if p is first else 200,
|
||||
})
|
||||
seen = []
|
||||
monkeypatch.setattr(backup, "emit", lambda payload, args: seen.append(payload))
|
||||
|
||||
backup.cmd_list(SimpleNamespace(pretty=False))
|
||||
|
||||
assert [entry["name"] for entry in seen[0]] == ["newer.tar.gz", "older.tar.gz"]
|
||||
assert all("_mtime" not in entry for entry in seen[0])
|
||||
|
||||
|
||||
def test_snapshot_rejects_output_inside_data_dir(tmp_path, monkeypatch):
|
||||
backup = _load_backup_cli()
|
||||
repo = tmp_path / "repo"
|
||||
|
||||
@@ -109,10 +109,9 @@ async def test_learn_sender_signatures_resolves_llm_for_task_owner(monkeypatch):
|
||||
def select(self, *_args, **_kwargs):
|
||||
return "OK", []
|
||||
|
||||
def search(self, *_args, **_kwargs):
|
||||
return "OK", [b"1 2 3"]
|
||||
|
||||
def fetch(self, _uid, _query):
|
||||
def uid(self, command, *_args):
|
||||
if command == "SEARCH":
|
||||
return "OK", [b"1 2 3"]
|
||||
return "OK", [(None, b"From: Writer <writer@example.com>\r\n\r\n")]
|
||||
|
||||
def logout(self):
|
||||
@@ -171,11 +170,10 @@ async def test_learn_sender_signatures_writes_owner_scoped_cache(monkeypatch, tm
|
||||
def select(self, *_args, **_kwargs):
|
||||
return "OK", []
|
||||
|
||||
def search(self, *_args, **_kwargs):
|
||||
return "OK", [b"1 2 3"]
|
||||
|
||||
def fetch(self, uid, query):
|
||||
if "HEADER.FIELDS" in query:
|
||||
def uid(self, command, uid=None, query=None):
|
||||
if command == "SEARCH":
|
||||
return "OK", [b"1 2 3"]
|
||||
if query and "HEADER.FIELDS" in query:
|
||||
return "OK", [(None, b"From: Writer <writer@example.com>\r\n\r\n")]
|
||||
return "OK", [
|
||||
(
|
||||
|
||||
@@ -36,6 +36,66 @@ def test_npx_package_from_args_prefers_package_after_y_flag(monkeypatch):
|
||||
) == "@playwright/mcp@latest"
|
||||
|
||||
|
||||
def test_browser_mcp_cache_requirement_is_opt_in(monkeypatch):
|
||||
monkeypatch.delenv("ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE", raising=False)
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
assert builtin_mcp.BROWSER_MCP_REQUIRE_CACHE is False
|
||||
|
||||
|
||||
def test_browser_mcp_cache_requirement_can_be_enabled(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE", "1")
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
assert builtin_mcp.BROWSER_MCP_REQUIRE_CACHE is True
|
||||
|
||||
|
||||
def test_browser_mcp_args_use_configured_browser_executable(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_EXECUTABLE", "/usr/bin/chromium")
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
args = builtin_mcp._browser_mcp_args(["-y", "@playwright/mcp@latest", "--headless"])
|
||||
|
||||
assert "--executable-path" in args
|
||||
assert "/usr/bin/chromium" in args
|
||||
assert "--isolated" in args
|
||||
assert "--no-sandbox" in args
|
||||
|
||||
|
||||
def test_browser_mcp_args_can_use_persistent_profile_when_requested(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_EXECUTABLE", "/usr/bin/chromium")
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_ISOLATED", "0")
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
args = builtin_mcp._browser_mcp_args(["-y", "@playwright/mcp@latest", "--headless"])
|
||||
|
||||
assert "--executable-path" in args
|
||||
assert "--isolated" not in args
|
||||
|
||||
|
||||
def test_browser_mcp_args_respect_explicit_user_data_dir(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_EXECUTABLE", "/usr/bin/chromium")
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
args = builtin_mcp._browser_mcp_args([
|
||||
"-y", "@playwright/mcp@latest", "--headless", "--user-data-dir", "/tmp/profile",
|
||||
])
|
||||
|
||||
assert "--user-data-dir" in args
|
||||
assert "--isolated" not in args
|
||||
|
||||
|
||||
def test_browser_mcp_args_can_keep_sandbox(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_EXECUTABLE", "/usr/bin/chromium")
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_NO_SANDBOX", "0")
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
args = builtin_mcp._browser_mcp_args(["-y", "@playwright/mcp@latest", "--headless"])
|
||||
|
||||
assert "--executable-path" in args
|
||||
assert "--no-sandbox" not in args
|
||||
|
||||
|
||||
def test_npx_cache_check_detects_scoped_package_in_npx_cache(monkeypatch, tmp_path):
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
package_json = (
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import os
|
||||
|
||||
from src.builtin_mcp import builtin_python_env
|
||||
|
||||
|
||||
def test_builtin_python_env_preserves_existing_pythonpath(monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"PYTHONPATH",
|
||||
os.pathsep.join(["/app/venv/lib/python3.13/site-packages", "/app", "/extra"]),
|
||||
)
|
||||
|
||||
env = builtin_python_env("/app")
|
||||
|
||||
assert env == {
|
||||
"PYTHONPATH": os.pathsep.join(["/app", "/app/venv/lib/python3.13/site-packages", "/extra"])
|
||||
}
|
||||
|
||||
|
||||
def test_builtin_python_env_uses_app_root_without_existing_pythonpath(monkeypatch):
|
||||
monkeypatch.delenv("PYTHONPATH", raising=False)
|
||||
|
||||
assert builtin_python_env("/srv/odysseus") == {"PYTHONPATH": "/srv/odysseus"}
|
||||
@@ -80,7 +80,7 @@ async def test_consolidate_memory_empty_owner_treats_each_owner_separately(monke
|
||||
message, ok = await action_consolidate_memory("")
|
||||
|
||||
assert ok is True
|
||||
assert "removed 1" in message
|
||||
assert "removed 1" in message.lower()
|
||||
assert len(prompts) == 2
|
||||
saved = {m["id"]: m for m in _read_memories(data_dir)}
|
||||
assert set(saved) == {"alice-long", "alice-short", "bob-keep"}
|
||||
@@ -114,3 +114,46 @@ async def test_consolidate_memory_specific_owner_does_not_absorb_ownerless_rows(
|
||||
assert set(saved) == {"alice-1", "legacy", "bob-1"}
|
||||
assert "owner" not in saved["legacy"]
|
||||
assert saved["bob-1"]["owner"] == "bob"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidate_memory_removes_near_duplicates_before_ai(monkeypatch, tmp_path):
|
||||
from src import constants
|
||||
from src import llm_core
|
||||
from src import task_endpoint
|
||||
action_consolidate_memory = _import_consolidate_action()
|
||||
|
||||
data_dir = _write_memories(
|
||||
tmp_path,
|
||||
[
|
||||
{"id": "a", "owner": "alice", "text": "User prefers bullet points when explaining.", "category": "preference"},
|
||||
{"id": "b", "owner": "alice", "text": "User prefers bulletpoints when explaining", "category": "preference", "pinned": True},
|
||||
{"id": "c", "owner": "alice", "text": "User likes local models.", "category": "preference"},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(constants, "DATA_DIR", str(data_dir))
|
||||
monkeypatch.setattr(
|
||||
task_endpoint,
|
||||
"resolve_task_candidates",
|
||||
lambda *args, **kwargs: [("http://llm", "model", {})],
|
||||
)
|
||||
|
||||
async def fake_llm_call_async(_candidates, **kwargs):
|
||||
items = json.loads(kwargs["messages"][0]["content"].split("MEMORIES:\n", 1)[1])
|
||||
return json.dumps({
|
||||
"keep": [
|
||||
{"id": item["id"], "text": item["text"], "category": item["category"]}
|
||||
for item in items
|
||||
],
|
||||
"drop": [],
|
||||
})
|
||||
|
||||
monkeypatch.setattr(llm_core, "llm_call_async_with_fallback", fake_llm_call_async)
|
||||
|
||||
message, ok = await action_consolidate_memory("alice")
|
||||
|
||||
assert ok is True
|
||||
assert "removed 1" in message.lower()
|
||||
saved = {m["id"]: m for m in _read_memories(data_dir)}
|
||||
assert set(saved) == {"b", "c"}
|
||||
assert saved["b"]["pinned"] is True
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Issue #4593 — the CalDAV DAVClient must be closed on every path.
|
||||
|
||||
`_sync_blocking` (src/caldav_sync.py) and `_writeback_blocking`
|
||||
(src/caldav_writeback.py) each open their own DAVClient. The client holds an
|
||||
HTTP session with pooled connections; if it is never closed those connections
|
||||
leak for the lifetime of the process. These tests pin that the client is
|
||||
closed on the discovery early-returns, the normal return, and the
|
||||
write-back paths, using a fake client so no network or `caldav` install is
|
||||
needed.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def _stub_sync_deps(monkeypatch):
|
||||
"""Make `_sync_blocking`'s lazy imports resolve without a real caldav/db."""
|
||||
err_mod = types.ModuleType("caldav.lib.error")
|
||||
|
||||
class AuthorizationError(Exception):
|
||||
pass
|
||||
|
||||
class NotFoundError(Exception):
|
||||
pass
|
||||
|
||||
err_mod.AuthorizationError = AuthorizationError
|
||||
err_mod.NotFoundError = NotFoundError
|
||||
monkeypatch.setitem(sys.modules, "caldav", types.ModuleType("caldav"))
|
||||
monkeypatch.setitem(sys.modules, "caldav.lib", types.ModuleType("caldav.lib"))
|
||||
monkeypatch.setitem(sys.modules, "caldav.lib.error", err_mod)
|
||||
|
||||
db_mod = types.ModuleType("core.database")
|
||||
db_mod.CalendarCal = MagicMock()
|
||||
db_mod.CalendarEvent = MagicMock()
|
||||
db_mod.CalendarDeletedEvent = MagicMock()
|
||||
db_mod.SessionLocal = MagicMock()
|
||||
if "core" not in sys.modules:
|
||||
monkeypatch.setitem(sys.modules, "core", types.ModuleType("core"))
|
||||
monkeypatch.setitem(sys.modules, "core.database", db_mod)
|
||||
|
||||
# Stub routes.calendar_routes so the lazy import of _ensure_positive_duration
|
||||
# inside _sync_blocking doesn't drag in dateutil / FastAPI / SQLAlchemy.
|
||||
routes_mod = types.ModuleType("routes")
|
||||
cal_routes_mod = types.ModuleType("routes.calendar_routes")
|
||||
cal_routes_mod._ensure_positive_duration = lambda start, end, all_day: end
|
||||
if "routes" not in sys.modules:
|
||||
monkeypatch.setitem(sys.modules, "routes", routes_mod)
|
||||
monkeypatch.setitem(sys.modules, "routes.calendar_routes", cal_routes_mod)
|
||||
|
||||
return AuthorizationError
|
||||
|
||||
|
||||
def test_sync_closes_client_on_discovery_auth_failure(monkeypatch):
|
||||
import src.caldav_sync as sync
|
||||
|
||||
AuthorizationError = _stub_sync_deps(monkeypatch)
|
||||
client = MagicMock()
|
||||
client.principal.side_effect = AuthorizationError("bad credentials")
|
||||
monkeypatch.setattr(sync, "_build_dav_client", lambda *a, **k: client)
|
||||
|
||||
result = sync._sync_blocking("alice", "https://dav.example.com/", "u", "p")
|
||||
|
||||
client.close.assert_called_once()
|
||||
assert any("Discovery failed" in e for e in result["errors"])
|
||||
|
||||
|
||||
def test_sync_closes_client_when_url_fallback_fails(monkeypatch):
|
||||
import src.caldav_sync as sync
|
||||
|
||||
_stub_sync_deps(monkeypatch)
|
||||
client = MagicMock()
|
||||
# principal() raises a generic error -> the URL-as-calendar fallback is
|
||||
# tried; make that fail too so the function hits the early return.
|
||||
client.principal.side_effect = RuntimeError("no principal endpoint")
|
||||
monkeypatch.setattr(sync, "_build_dav_client", lambda *a, **k: client)
|
||||
monkeypatch.setattr(
|
||||
sync, "_open_url_as_calendar",
|
||||
MagicMock(side_effect=RuntimeError("not a calendar")),
|
||||
)
|
||||
|
||||
result = sync._sync_blocking("alice", "https://dav.example.com/", "u", "p")
|
||||
|
||||
client.close.assert_called_once()
|
||||
assert result["errors"]
|
||||
|
||||
|
||||
def test_writeback_closes_client_when_no_calendars(monkeypatch):
|
||||
import src.caldav_sync as sync
|
||||
import src.caldav_writeback as wb
|
||||
|
||||
client = MagicMock()
|
||||
monkeypatch.setattr(sync, "_build_dav_client", lambda *a, **k: client)
|
||||
monkeypatch.setattr(wb, "_discover_calendars", lambda c: [])
|
||||
|
||||
result = wb._writeback_blocking(
|
||||
"caldav-1", {"uid": "evt-1"}, False, "https://dav.example.com/", "u", "p"
|
||||
)
|
||||
|
||||
client.close.assert_called_once()
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
def test_writeback_closes_client_on_success(monkeypatch):
|
||||
import src.caldav_sync as sync
|
||||
import src.caldav_writeback as wb
|
||||
|
||||
client = MagicMock()
|
||||
monkeypatch.setattr(sync, "_build_dav_client", lambda *a, **k: client)
|
||||
monkeypatch.setattr(wb, "_discover_calendars", lambda c: [MagicMock()])
|
||||
monkeypatch.setattr(wb, "push_event", lambda *a, **k: {"ok": True})
|
||||
|
||||
result = wb._writeback_blocking(
|
||||
"caldav-1", {"uid": "evt-1"}, False, "https://dav.example.com/", "u", "p"
|
||||
)
|
||||
|
||||
client.close.assert_called_once()
|
||||
assert result["ok"] is True
|
||||
|
||||
|
||||
def test_sync_closes_client_when_session_local_raises(monkeypatch):
|
||||
import src.caldav_sync as sync
|
||||
|
||||
AuthorizationError = _stub_sync_deps(monkeypatch)
|
||||
|
||||
# Give principal() a working response so discovery passes
|
||||
mock_principal = MagicMock()
|
||||
mock_cal = MagicMock()
|
||||
mock_cal.url = "https://dav.example.com/alice/home/"
|
||||
mock_principal.calendars.return_value = [mock_cal]
|
||||
|
||||
client = MagicMock()
|
||||
client.principal.return_value = mock_principal
|
||||
monkeypatch.setattr(sync, "_build_dav_client", lambda *a, **k: client)
|
||||
|
||||
# Make SessionLocal blow up before any DB work
|
||||
import sys
|
||||
sys.modules["core.database"].SessionLocal.side_effect = RuntimeError("DB unavailable")
|
||||
|
||||
with pytest.raises(RuntimeError, match="DB unavailable"):
|
||||
sync._sync_blocking("alice", "https://dav.example.com/", "u", "p")
|
||||
|
||||
client.close.assert_called_once()
|
||||
@@ -93,6 +93,10 @@ class _FakeClient:
|
||||
def calendar(self, url=None):
|
||||
return _FakeCalendar(url)
|
||||
|
||||
def close(self):
|
||||
# Mirror the real DAVClient: sync now closes the client on every path.
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _install_fake_caldav(monkeypatch):
|
||||
fake = types.ModuleType("caldav")
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Regression: CalDAV test_connection must trust the operator's CA bundle.
|
||||
|
||||
The pre-flight used httpx with trust_env=False, which ignored
|
||||
SSL_CERT_FILE/REQUESTS_CA_BUNDLE. Self-signed CalDAV servers that the
|
||||
real sync accepts (via caldav lib -> requests -> honors bundle) were
|
||||
rejected by the test with CERTIFICATE_VERIFY_FAILED.
|
||||
|
||||
These tests exercise the *route handler* directly (via ASGI TestClient)
|
||||
and capture the verify= kwarg passed to httpx.AsyncClient, ensuring the
|
||||
route code — not a test-side duplicate — builds the SSL context correctly.
|
||||
"""
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
# No module-level sys.modules stubbing here: conftest pre-imports the real
|
||||
# sqlalchemy/core.database, and stubbing extras (e.g. caldav) at collection
|
||||
# time leaks MagicMocks into later tests in the same process — it made
|
||||
# test_caldav_redirect_hardening's real DAVClient a mock that never sent
|
||||
# the PROPFIND. The route's lazy imports are patched per-request instead.
|
||||
|
||||
|
||||
def _fake_response(status_code=207, headers=None):
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.headers = headers or {}
|
||||
return resp
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.calendar_routes import setup_calendar_routes
|
||||
|
||||
with patch("routes.calendar_routes._require_user", return_value="test-owner"):
|
||||
router = setup_calendar_routes()
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _make_fake_async_client(captured):
|
||||
"""Return a fake httpx.AsyncClient class that captures constructor kwargs."""
|
||||
class FakeAsyncClient:
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
pass
|
||||
|
||||
async def request(self, *a, **kw):
|
||||
return _fake_response(207)
|
||||
|
||||
return FakeAsyncClient
|
||||
|
||||
|
||||
def _post_test(client, captured, env=None):
|
||||
"""POST /api/calendar/test with credentials in body so no DB lookup needed.
|
||||
|
||||
Patches httpx.AsyncClient at the real module level so the route's
|
||||
``import httpx; httpx.AsyncClient(...)`` picks up the fake class.
|
||||
Also stubs validate_caldav_url (lazy-imported from src.caldav_sync).
|
||||
"""
|
||||
fake_cls = _make_fake_async_client(captured)
|
||||
|
||||
# Stub the caldav_sync module so the lazy `from src.caldav_sync import validate_caldav_url`
|
||||
# inside the route body resolves to a pass-through.
|
||||
caldav_sync_stub = MagicMock()
|
||||
caldav_sync_stub.validate_caldav_url = lambda u: u
|
||||
|
||||
ctx_managers = [
|
||||
patch.object(httpx, "AsyncClient", fake_cls),
|
||||
patch.dict(sys.modules, {"src.caldav_sync": caldav_sync_stub}),
|
||||
patch("routes.calendar_routes._require_user", return_value="test-owner"),
|
||||
]
|
||||
if env is not None:
|
||||
ctx_managers.append(patch.dict(os.environ, env))
|
||||
|
||||
# Enter all context managers
|
||||
for cm in ctx_managers:
|
||||
cm.__enter__()
|
||||
try:
|
||||
return client.post(
|
||||
"/api/calendar/test",
|
||||
json={"url": "https://cal.example.com", "username": "u", "password": "p"},
|
||||
)
|
||||
finally:
|
||||
for cm in reversed(ctx_managers):
|
||||
cm.__exit__(None, None, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route-level tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_route_passes_ssl_context_with_correct_flags(client):
|
||||
"""The route must pass an ssl.SSLContext to httpx.AsyncClient(verify=...)
|
||||
with trust_env=False, follow_redirects=False, and VERIFY_X509_STRICT cleared."""
|
||||
captured = {}
|
||||
resp = _post_test(client, captured)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(captured.get("verify"), ssl.SSLContext), (
|
||||
f"verify= should be an ssl.SSLContext, got {type(captured.get('verify'))}"
|
||||
)
|
||||
assert captured.get("trust_env") is False
|
||||
assert captured.get("follow_redirects") is False
|
||||
ctx = captured["verify"]
|
||||
assert not (ctx.verify_flags & ssl.VERIFY_X509_STRICT), (
|
||||
"VERIFY_X509_STRICT must be cleared for self-signed CA compat"
|
||||
)
|
||||
|
||||
|
||||
def test_route_ssl_cert_file_takes_precedence(client, tmp_path):
|
||||
"""SSL_CERT_FILE is the exact bundle loaded when both variables are set."""
|
||||
bundle_a = tmp_path / "ssl-cert-file.pem"
|
||||
bundle_b = tmp_path / "requests-ca-bundle.pem"
|
||||
bundle_a.write_text("ssl-cert-file", encoding="utf-8")
|
||||
bundle_b.write_text("requests-ca-bundle", encoding="utf-8")
|
||||
|
||||
loaded = []
|
||||
|
||||
class FakeSSLContext:
|
||||
def __init__(self):
|
||||
self.verify_flags = ssl.VERIFY_X509_STRICT
|
||||
|
||||
def load_verify_locations(self, cafile=None, capath=None, cadata=None):
|
||||
loaded.append(
|
||||
{
|
||||
"cafile": cafile,
|
||||
"capath": capath,
|
||||
"cadata": cadata,
|
||||
}
|
||||
)
|
||||
|
||||
ssl_context = FakeSSLContext()
|
||||
captured = {}
|
||||
env = {
|
||||
"SSL_CERT_FILE": str(bundle_a),
|
||||
"REQUESTS_CA_BUNDLE": str(bundle_b),
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
ssl,
|
||||
"create_default_context",
|
||||
return_value=ssl_context,
|
||||
):
|
||||
resp = _post_test(client, captured, env=env)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
assert loaded == [
|
||||
{
|
||||
"cafile": str(bundle_a),
|
||||
"capath": None,
|
||||
"cadata": None,
|
||||
}
|
||||
]
|
||||
assert captured.get("verify") is ssl_context
|
||||
assert captured.get("trust_env") is False
|
||||
assert captured.get("follow_redirects") is False
|
||||
assert not (
|
||||
ssl_context.verify_flags & ssl.VERIFY_X509_STRICT
|
||||
)
|
||||
|
||||
|
||||
def test_route_missing_bundle_does_not_crash(client):
|
||||
"""A nonexistent CA bundle path must not crash -- fall back to system CAs."""
|
||||
captured = {}
|
||||
resp = _post_test(client, captured, env={"SSL_CERT_FILE": "/nonexistent/ca-bundle.pem"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
ctx = captured["verify"]
|
||||
assert isinstance(ctx, ssl.SSLContext)
|
||||
assert not (ctx.verify_flags & ssl.VERIFY_X509_STRICT)
|
||||
|
||||
|
||||
def test_route_empty_env_vars_use_system_defaults(client):
|
||||
"""Empty SSL_CERT_FILE and REQUESTS_CA_BUNDLE should not crash."""
|
||||
captured = {}
|
||||
resp = _post_test(client, captured, env={"SSL_CERT_FILE": "", "REQUESTS_CA_BUNDLE": ""})
|
||||
|
||||
assert resp.status_code == 200
|
||||
ctx = captured["verify"]
|
||||
assert isinstance(ctx, ssl.SSLContext)
|
||||
assert not (ctx.verify_flags & ssl.VERIFY_X509_STRICT)
|
||||
@@ -233,6 +233,10 @@ def test_build_uploaded_file_manifest_filters_and_nulls_unreadable_paths(monkeyp
|
||||
)
|
||||
|
||||
assert [item["id"] for item in manifest] == ["good", "outside", "missing"]
|
||||
assert manifest[0]["type"] == "attachment_ref"
|
||||
assert manifest[0]["attachment_id"] == "good"
|
||||
assert manifest[0]["uri"] == "odysseus://attachment/good"
|
||||
assert manifest[0]["read_policy"] == "owner_checked_upload"
|
||||
assert os.path.realpath(manifest[0]["path"]) == os.path.realpath(good)
|
||||
assert manifest[1]["path"] is None
|
||||
assert manifest[2]["path"] is None
|
||||
@@ -338,10 +342,10 @@ def test_clean_thinking_for_save_extracts_thought_tag():
|
||||
assert metadata["thinking"] == "internal reasoning"
|
||||
|
||||
|
||||
def test_save_assistant_response_preserves_actual_and_requested_model():
|
||||
def test_save_assistant_response_incognito_does_not_mutate_session_history():
|
||||
sess = _FakeSession("selected-model")
|
||||
|
||||
save_assistant_response(
|
||||
saved_id = save_assistant_response(
|
||||
sess,
|
||||
session_manager=None,
|
||||
session_id="s1",
|
||||
@@ -350,8 +354,24 @@ def test_save_assistant_response_preserves_actual_and_requested_model():
|
||||
incognito=True,
|
||||
)
|
||||
|
||||
assert sess.history[-1].metadata["requested_model"] == "selected-model"
|
||||
assert sess.history[-1].metadata["model"] == "actual-model"
|
||||
assert saved_id is None
|
||||
assert sess.history == []
|
||||
|
||||
|
||||
def test_add_user_message_incognito_does_not_mutate_session_history():
|
||||
sess = _FakeSession("selected-model")
|
||||
chat_handler = SimpleNamespace(update_session_name_if_needed=lambda *_args, **_kwargs: None)
|
||||
preprocessed = PreprocessedMessage(
|
||||
enhanced_message="secret",
|
||||
user_content="secret",
|
||||
text_for_context="secret",
|
||||
youtube_transcripts=[],
|
||||
attachment_meta=[],
|
||||
)
|
||||
|
||||
chat_helpers.add_user_message(sess, chat_handler, preprocessed, incognito=True)
|
||||
|
||||
assert sess.history == []
|
||||
|
||||
|
||||
class _SpinMsg:
|
||||
|
||||
@@ -60,6 +60,15 @@ def test_image_model_prefix_routes_to_image_generation_without_endpoint_lookup(m
|
||||
assert chat_routes._is_image_generation_session(_session(model="dall-e-3"))
|
||||
|
||||
|
||||
def test_namespaced_gpt_image_model_routes_to_image_generation_without_endpoint_lookup(monkeypatch):
|
||||
def fail_if_called():
|
||||
raise AssertionError("provider-prefixed image models should not need a DB lookup")
|
||||
|
||||
monkeypatch.setattr(chat_routes, "SessionLocal", fail_if_called)
|
||||
|
||||
assert chat_routes._is_image_generation_session(_session(model="openai/gpt-5-image"))
|
||||
|
||||
|
||||
def test_image_endpoint_does_not_catch_text_model_on_different_path(monkeypatch):
|
||||
db = _FakeDb([
|
||||
_endpoint("http://localhost:11434/v1/images", models=["sdxl-local"]),
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.chat_processor import ChatProcessor
|
||||
|
||||
|
||||
class _Memory:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
self.incremented = []
|
||||
|
||||
def load(self, owner=None):
|
||||
return list(self.rows)
|
||||
|
||||
def increment_uses(self, ids):
|
||||
self.incremented.extend(ids)
|
||||
|
||||
|
||||
class _Docs:
|
||||
rag_manager = None
|
||||
|
||||
|
||||
def _context_text(preface):
|
||||
return "\n".join(m.get("content", "") for m in preface)
|
||||
|
||||
|
||||
def _processor(rows):
|
||||
return ChatProcessor(memory_manager=_Memory(rows), personal_docs_manager=_Docs())
|
||||
|
||||
|
||||
def test_pinned_memory_does_not_inject_every_unrelated_fact():
|
||||
rows = [
|
||||
{
|
||||
"id": "identity",
|
||||
"text": "User's name is Felix.",
|
||||
"category": "identity",
|
||||
"pinned": True,
|
||||
"timestamp": 3,
|
||||
},
|
||||
{
|
||||
"id": "party",
|
||||
"text": "User is planning a birthday party with sack races.",
|
||||
"category": "fact",
|
||||
"pinned": True,
|
||||
"timestamp": 2,
|
||||
},
|
||||
{
|
||||
"id": "coffee",
|
||||
"text": "User likes dark roast coffee.",
|
||||
"category": "preference",
|
||||
"pinned": True,
|
||||
"timestamp": 1,
|
||||
},
|
||||
]
|
||||
|
||||
preface, _, _ = _processor(rows).build_context_preface(
|
||||
message="Explain how Python decorators work",
|
||||
session=SimpleNamespace(),
|
||||
use_rag=False,
|
||||
use_memory=True,
|
||||
)
|
||||
|
||||
text = _context_text(preface)
|
||||
assert "User's name is Felix." in text
|
||||
assert "birthday party with sack races" not in text
|
||||
assert "dark roast coffee" not in text
|
||||
|
||||
|
||||
def test_relevant_pinned_memory_is_still_injected():
|
||||
rows = [
|
||||
{
|
||||
"id": "coffee",
|
||||
"text": "User likes dark roast coffee.",
|
||||
"category": "preference",
|
||||
"pinned": True,
|
||||
"timestamp": 1,
|
||||
},
|
||||
{
|
||||
"id": "party",
|
||||
"text": "User is planning a birthday party with sack races.",
|
||||
"category": "fact",
|
||||
"pinned": True,
|
||||
"timestamp": 2,
|
||||
},
|
||||
]
|
||||
|
||||
preface, _, _ = _processor(rows).build_context_preface(
|
||||
message="likes coffee roast",
|
||||
session=SimpleNamespace(),
|
||||
use_rag=False,
|
||||
use_memory=True,
|
||||
)
|
||||
|
||||
text = _context_text(preface)
|
||||
assert "User likes dark roast coffee." in text
|
||||
assert "birthday party with sack races" not in text
|
||||
|
||||
|
||||
def test_pinned_memory_injection_is_capped_at_five():
|
||||
rows = [
|
||||
{
|
||||
"id": f"identity-{idx}",
|
||||
"text": f"User identity fact {idx} email marker.",
|
||||
"category": "identity",
|
||||
"pinned": True,
|
||||
"timestamp": idx,
|
||||
}
|
||||
for idx in range(10)
|
||||
]
|
||||
|
||||
processor = _processor(rows)
|
||||
processor.build_context_preface(
|
||||
message="Who is the user?",
|
||||
session=SimpleNamespace(),
|
||||
use_rag=False,
|
||||
use_memory=True,
|
||||
)
|
||||
|
||||
assert len(processor._last_used_memories) == 5
|
||||
|
||||
|
||||
def test_total_memory_injection_is_capped_at_five_across_pinned_and_recalled():
|
||||
rows = [
|
||||
{
|
||||
"id": f"identity-{idx}",
|
||||
"text": f"User identity fact {idx} email marker.",
|
||||
"category": "identity",
|
||||
"pinned": True,
|
||||
"timestamp": idx,
|
||||
}
|
||||
for idx in range(4)
|
||||
]
|
||||
rows.extend([
|
||||
{
|
||||
"id": f"coffee-{idx}",
|
||||
"text": f"User likes coffee roast {idx}.",
|
||||
"category": "preference",
|
||||
"pinned": False,
|
||||
"timestamp": idx,
|
||||
}
|
||||
for idx in range(6)
|
||||
])
|
||||
|
||||
processor = _processor(rows)
|
||||
processor.build_context_preface(
|
||||
message="likes coffee roast",
|
||||
session=SimpleNamespace(),
|
||||
use_rag=False,
|
||||
use_memory=True,
|
||||
)
|
||||
|
||||
assert len(processor._last_used_memories) <= 5
|
||||
assert sum(1 for m in processor._last_used_memories if m["type"] == "pinned") == 4
|
||||
@@ -79,6 +79,23 @@ def test_allow_web_search_reads_from_body_as_fallback():
|
||||
)
|
||||
|
||||
|
||||
def test_browser_form_followups_include_approval_and_send_phrases():
|
||||
"""Short approval replies after a form/browser turn must keep browser tools available."""
|
||||
source = _CHAT_ROUTES.read_text(encoding="utf-8")
|
||||
assert "approved" in source
|
||||
assert "proceed" in source
|
||||
assert "send(?:\\s+it)?" in source
|
||||
assert "submit(?:\\s+it)?" in source
|
||||
|
||||
|
||||
def test_agent_loop_expands_browser_mcp_tools_from_connected_server():
|
||||
"""Browser intent must not depend on stale hardcoded Playwright tool names."""
|
||||
source = (Path(__file__).resolve().parent.parent / "src" / "agent_loop.py").read_text(encoding="utf-8")
|
||||
assert "def _expand_browser_mcp_tools" in source
|
||||
assert "server_id\") == \"builtin_browser\"" in source
|
||||
assert "_relevant_tools = _expand_browser_mcp_tools(_relevant_tools, mcp_mgr)" in source
|
||||
|
||||
|
||||
def test_disabled_tools_respects_missing_vs_explicit_toggles():
|
||||
"""Bash still defers to privileges, but web is an explicit per-turn opt-in.
|
||||
"""
|
||||
@@ -102,6 +119,14 @@ def test_disabled_tools_respects_missing_vs_explicit_toggles():
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_auto_escalation_keeps_shell_tools():
|
||||
"""Workspace/shell auto-routing must not use the light typed-tool clamp."""
|
||||
source = _CHAT_ROUTES.read_text(encoding="utf-8")
|
||||
assert '_workspace_agent_intent = _tool_intent.category in {"shell", "workspace"}' in source
|
||||
assert "allow_bash = \"true\"" in source
|
||||
assert "if auto_escalated and not _workspace_agent_intent:" in source
|
||||
|
||||
|
||||
# ── Functional tests of the disabled-tools logic ───────────────
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Regression test for the cleanup route shim (slice 2g, #4082/#4071).
|
||||
|
||||
The backward-compat shim at ``routes/cleanup_routes.py`` uses ``sys.modules``
|
||||
replacement so the legacy import path and the canonical ``routes.cleanup.*``
|
||||
path resolve to the *same* module object. This is required because
|
||||
``test_cleanup_owner_scope.py`` uses string-targeted
|
||||
``monkeypatch.setattr("routes.cleanup_routes.get_cleanup_preview", ...)`` and
|
||||
``monkeypatch.delitem(sys.modules, "routes.cleanup_routes")`` + re-import —
|
||||
for those patches to take effect at runtime, the legacy module object and
|
||||
the canonical one must be identical.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
|
||||
import routes.cleanup_routes as _shim_cleanup # noqa: F401
|
||||
|
||||
|
||||
def test_legacy_and_canonical_cleanup_module_are_same_object():
|
||||
"""``import routes.cleanup_routes`` must alias the canonical module."""
|
||||
legacy = importlib.import_module("routes.cleanup_routes")
|
||||
canonical = importlib.import_module("routes.cleanup.cleanup_routes")
|
||||
assert legacy is canonical, (
|
||||
"routes.cleanup_routes shim must resolve to the canonical "
|
||||
"routes.cleanup.cleanup_routes module object"
|
||||
)
|
||||
@@ -199,7 +199,8 @@ async def test_documents_pagination_out_of_range_offset_returns_empty_page():
|
||||
assert result["next_offset"] is None
|
||||
|
||||
|
||||
def test_adopt_rejects_ssh_option_host_before_shell(monkeypatch):
|
||||
@pytest.mark.parametrize("host_field", ["host", "remote_host"])
|
||||
def test_adopt_rejects_ssh_option_host_before_shell(monkeypatch, host_field):
|
||||
calls = []
|
||||
|
||||
async def fail_if_shell_runs(*args, **kwargs):
|
||||
@@ -212,7 +213,7 @@ def test_adopt_rejects_ssh_option_host_before_shell(monkeypatch):
|
||||
body = {
|
||||
"tmux_session": "serve_abc123",
|
||||
"model": "org/model",
|
||||
"host": "-oProxyCommand=sh",
|
||||
host_field: "-oProxyCommand=sh",
|
||||
}
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Regression test for the compare route shim (slice 2i, #4082/#4071).
|
||||
|
||||
The backward-compat shim at ``routes/compare_routes.py`` uses ``sys.modules``
|
||||
replacement so the legacy import path and the canonical ``routes.compare.*``
|
||||
path resolve to the *same* module object. This is required because
|
||||
``test_endpoint_owner_scope_followup.py`` uses ``import routes.compare_routes
|
||||
as cr`` followed by ``monkeypatch.setattr(cr, "SessionLocal", ...)`` /
|
||||
``"_owned_endpoint_by_url"`` / ``"_owned_endpoint_by_id"`` — for those patches
|
||||
to take effect at runtime, the legacy module object and the canonical one
|
||||
must be identical.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
|
||||
import routes.compare_routes as _shim_compare # noqa: F401
|
||||
|
||||
|
||||
def test_legacy_and_canonical_compare_module_are_same_object():
|
||||
"""``import routes.compare_routes`` must alias the canonical module."""
|
||||
legacy = importlib.import_module("routes.compare_routes")
|
||||
canonical = importlib.import_module("routes.compare.compare_routes")
|
||||
assert legacy is canonical, (
|
||||
"routes.compare_routes shim must resolve to the canonical "
|
||||
"routes.compare.compare_routes module object"
|
||||
)
|
||||
@@ -4,10 +4,12 @@ Driven through `node --input-type=module` so we exercise the real JS without a
|
||||
full Vitest/Jest setup (same approach as test_reply_recipients_js.py). Skips
|
||||
when `node` is not installed rather than failing.
|
||||
|
||||
Locks in: empty composer recalls last user message; non-empty composer is
|
||||
untouched; multiline caret navigation is not hijacked; Shift/Alt/Ctrl/Meta+ArrowUp
|
||||
are ignored; IME composition does not trigger recall; last message is read from
|
||||
#chat-history (dataset.raw), not session sidebar metadata.
|
||||
Locks in: empty composer recalls user messages from the active conversation,
|
||||
repeated ArrowUp walks older prompts in that same chat; non-empty composer is
|
||||
untouched unless it contains the recalled prompt; multiline caret navigation is
|
||||
not hijacked; Shift/Alt/Ctrl/Meta+ArrowUp are ignored; IME composition does not
|
||||
trigger recall; messages are read from #chat-history (dataset.raw), not session
|
||||
sidebar metadata.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
@@ -36,6 +38,8 @@ function makeComposer(initial = '') {
|
||||
},
|
||||
dispatchKey(opts = {}) {
|
||||
let prevented = false;
|
||||
let stopped = false;
|
||||
let immediateStopped = false;
|
||||
const e = {
|
||||
key: opts.key ?? 'ArrowUp',
|
||||
shiftKey: !!opts.shiftKey,
|
||||
@@ -44,9 +48,11 @@ function makeComposer(initial = '') {
|
||||
metaKey: !!opts.metaKey,
|
||||
isComposing: !!opts.isComposing,
|
||||
preventDefault() { prevented = true; },
|
||||
stopPropagation() { stopped = true; },
|
||||
stopImmediatePropagation() { immediateStopped = true; },
|
||||
};
|
||||
for (const fn of listeners) fn(e);
|
||||
return prevented;
|
||||
return { prevented, stopped, immediateStopped };
|
||||
},
|
||||
};
|
||||
return composer;
|
||||
@@ -58,17 +64,20 @@ function runCase(body) {
|
||||
composer.selectionStart = body.caret;
|
||||
composer.selectionEnd = body.caretEnd ?? body.caret;
|
||||
}
|
||||
const last = body.last ?? 'previous message';
|
||||
const last = body.history ?? body.last ?? 'previous message';
|
||||
let resized = false;
|
||||
wireArrowUpRecall(composer, () => last, {
|
||||
autoResize: () => { resized = true; },
|
||||
});
|
||||
const prevented = composer.dispatchKey(body.event ?? {});
|
||||
const events = body.events ?? [body.event ?? {}];
|
||||
const handled = events.map(ev => composer.dispatchKey(ev));
|
||||
return {
|
||||
value: composer.value,
|
||||
selectionStart: composer.selectionStart,
|
||||
selectionEnd: composer.selectionEnd,
|
||||
prevented,
|
||||
prevented: handled.map(v => v.prevented),
|
||||
stopped: handled.map(v => v.stopped),
|
||||
immediateStopped: handled.map(v => v.immediateStopped),
|
||||
resized,
|
||||
};
|
||||
}
|
||||
@@ -100,7 +109,24 @@ def test_empty_composer_recalls_last_user_message():
|
||||
assert out["value"] == "hello again"
|
||||
assert out["selectionStart"] == len("hello again")
|
||||
assert out["selectionEnd"] == len("hello again")
|
||||
assert out["prevented"] is True
|
||||
assert out["prevented"] == [True]
|
||||
assert out["stopped"] == [True]
|
||||
assert out["immediateStopped"] == [True]
|
||||
assert out["resized"] is True
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_repeated_arrow_up_cycles_current_chat_prompts_newest_first():
|
||||
out = _run([{
|
||||
"initial": "",
|
||||
"history": ["third prompt", "second prompt", "first prompt"],
|
||||
"events": [{}, {}, {}, {}],
|
||||
}])[0]
|
||||
assert out["value"] == "first prompt"
|
||||
assert out["selectionStart"] == len("first prompt")
|
||||
assert out["prevented"] == [True, True, True, True]
|
||||
assert out["stopped"] == [True, True, True, True]
|
||||
assert out["immediateStopped"] == [True, True, True, True]
|
||||
assert out["resized"] is True
|
||||
|
||||
|
||||
@@ -108,7 +134,7 @@ def test_empty_composer_recalls_last_user_message():
|
||||
def test_non_empty_composer_does_not_recall():
|
||||
out = _run([{"initial": "draft in progress", "last": "ignored"}])[0]
|
||||
assert out["value"] == "draft in progress"
|
||||
assert out["prevented"] is False
|
||||
assert out["prevented"] == [False]
|
||||
assert out["resized"] is False
|
||||
|
||||
|
||||
@@ -116,7 +142,7 @@ def test_non_empty_composer_does_not_recall():
|
||||
def test_whitespace_only_composer_is_not_empty():
|
||||
out = _run([{"initial": " ", "last": "ignored"}])[0]
|
||||
assert out["value"] == " "
|
||||
assert out["prevented"] is False
|
||||
assert out["prevented"] == [False]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
@@ -126,7 +152,7 @@ def test_multiline_caret_navigation_preserved():
|
||||
out = _run([{"initial": text, "caret": len(text), "last": "ignored"}])[0]
|
||||
assert out["value"] == text
|
||||
assert out["selectionStart"] == len(text)
|
||||
assert out["prevented"] is False
|
||||
assert out["prevented"] == [False]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
@@ -139,21 +165,21 @@ def test_modified_arrow_up_ignored():
|
||||
]
|
||||
for out in _run(cases):
|
||||
assert out["value"] == ""
|
||||
assert out["prevented"] is False
|
||||
assert out["prevented"] == [False]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_ime_composition_does_not_trigger_recall():
|
||||
out = _run([{"initial": "", "event": {"isComposing": True}, "last": "ignored"}])[0]
|
||||
assert out["value"] == ""
|
||||
assert out["prevented"] is False
|
||||
assert out["prevented"] == [False]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_no_recall_when_last_message_missing():
|
||||
out = _run([{"initial": "", "last": ""}])[0]
|
||||
assert out["value"] == ""
|
||||
assert out["prevented"] is False
|
||||
assert out["prevented"] == [False]
|
||||
assert out["resized"] is False
|
||||
|
||||
|
||||
@@ -182,7 +208,10 @@ def test_wire_is_idempotent():
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_get_last_user_message_from_chat_history():
|
||||
js = f"""
|
||||
import {{ getLastUserMessageFromChatHistory }} from '{_HELPER_URL}';
|
||||
import {{
|
||||
getLastUserMessageFromChatHistory,
|
||||
getUserMessagesFromChatHistory,
|
||||
}} from '{_HELPER_URL}';
|
||||
|
||||
const chatBox = {{
|
||||
id: 'chat-history',
|
||||
@@ -202,6 +231,7 @@ def test_get_last_user_message_from_chat_history():
|
||||
console.log(JSON.stringify({{
|
||||
fromChat: getLastUserMessageFromChatHistory(doc),
|
||||
fromBox: getLastUserMessageFromChatHistory(chatBox),
|
||||
allFromChat: getUserMessagesFromChatHistory(doc),
|
||||
empty: getLastUserMessageFromChatHistory({{ getElementById: () => null }}),
|
||||
noUsers: getLastUserMessageFromChatHistory({{
|
||||
getElementById: () => ({{ querySelectorAll: () => [] }}),
|
||||
@@ -221,6 +251,7 @@ def test_get_last_user_message_from_chat_history():
|
||||
assert json.loads(proc.stdout.strip()) == {
|
||||
"fromChat": "last raw",
|
||||
"fromBox": "last raw",
|
||||
"allFromChat": ["last raw", "first"],
|
||||
"empty": "",
|
||||
"noUsers": "",
|
||||
}
|
||||
@@ -231,7 +262,7 @@ def test_integration_recalls_from_chat_history_dom():
|
||||
js = f"""
|
||||
import {{
|
||||
wireArrowUpRecall,
|
||||
getLastUserMessageFromChatHistory,
|
||||
getUserMessagesFromChatHistory,
|
||||
}} from '{_HELPER_URL}';
|
||||
|
||||
const chatBox = {{
|
||||
@@ -251,7 +282,7 @@ def test_integration_recalls_from_chat_history_dom():
|
||||
_arrowUpRecallWired: false,
|
||||
addEventListener(type, fn) {{ if (type === 'keydown') listeners.push(fn); }},
|
||||
}};
|
||||
wireArrowUpRecall(composer, () => getLastUserMessageFromChatHistory(doc));
|
||||
wireArrowUpRecall(composer, () => getUserMessagesFromChatHistory(doc));
|
||||
let prevented = false;
|
||||
listeners[0]({{
|
||||
key: 'ArrowUp',
|
||||
|
||||
@@ -89,6 +89,18 @@ def test_request_flags_vision():
|
||||
assert vision is True
|
||||
|
||||
|
||||
def test_request_flags_non_dict_last_message_does_not_crash():
|
||||
# A client can send a bare-string (non-dict) last element; before the
|
||||
# isinstance guard this raised AttributeError on last.get("role").
|
||||
assert copilot.request_flags(["hi"]) == (False, False)
|
||||
assert copilot.request_flags([{"role": "user"}, "trailing"]) == (False, False)
|
||||
|
||||
|
||||
def test_request_flags_empty_and_none():
|
||||
assert copilot.request_flags([]) == (False, False)
|
||||
assert copilot.request_flags(None) == (False, False)
|
||||
|
||||
|
||||
def test_apply_request_headers_mutates():
|
||||
h = {"X-GitHub-Api-Version": "v"}
|
||||
copilot.apply_request_headers(h, [{"role": "tool", "content": "x"}])
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Regression: extensionless image/audio uploads must get a valid MIME subtype.
|
||||
|
||||
The data-URL subtype was derived only from the stored file's extension
|
||||
(`image_format = ext[1:]`). A pasted screenshot or any file whose stored id
|
||||
carries no extension yields `ext == ""`, so the emitted URL was
|
||||
`data:image/;base64,...` — an empty MIME subtype (invalid per RFC 2046) that
|
||||
vision/audio endpoints reject, silently dropping the attachment. When the
|
||||
extension is missing, fall back to the resolved MIME subtype. Extensions that
|
||||
are present are unchanged.
|
||||
"""
|
||||
|
||||
|
||||
class _Handler:
|
||||
def __init__(self, uploads, image=False, audio=False):
|
||||
self.uploads = uploads
|
||||
self._image = image
|
||||
self._audio = audio
|
||||
|
||||
def resolve_upload(self, fid, owner=None):
|
||||
return self.uploads.get(fid)
|
||||
|
||||
def _inside_upload_dir(self, path):
|
||||
return True
|
||||
|
||||
def is_image_file(self, name, mime):
|
||||
return self._image and (mime or "").startswith("image/")
|
||||
|
||||
def is_audio_file(self, name, mime):
|
||||
return self._audio and (mime or "").startswith("audio/")
|
||||
|
||||
def is_document_file(self, name, mime):
|
||||
return False
|
||||
|
||||
|
||||
def _blocks(content, block_type):
|
||||
return [b for b in content if isinstance(b, dict) and b.get("type") == block_type]
|
||||
|
||||
|
||||
def test_extensionless_image_uses_mime_subtype(tmp_path):
|
||||
import src.document_processor as dp
|
||||
|
||||
p = tmp_path / ("a" * 32) # bare id, no extension
|
||||
p.write_bytes(b"\x89PNG\r\n\x1a\nfake")
|
||||
uploads = {"img": {"path": str(p), "name": "screenshot", "mime": "image/png"}}
|
||||
|
||||
content = dp.build_user_content("look", ["img"], str(tmp_path), _Handler(uploads, image=True), owner="t")
|
||||
imgs = _blocks(content, "image_url")
|
||||
assert imgs, content
|
||||
assert imgs[0]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
def test_extensionless_audio_uses_mime_subtype(tmp_path):
|
||||
import src.document_processor as dp
|
||||
|
||||
p = tmp_path / ("b" * 32)
|
||||
p.write_bytes(b"fakeaudio")
|
||||
uploads = {"aud": {"path": str(p), "name": "recording", "mime": "audio/mpeg"}}
|
||||
|
||||
content = dp.build_user_content("listen", ["aud"], str(tmp_path), _Handler(uploads, audio=True), owner="t")
|
||||
auds = _blocks(content, "audio")
|
||||
assert auds, content
|
||||
assert auds[0]["audio"]["url"].startswith("data:audio/mpeg;base64,")
|
||||
|
||||
|
||||
def test_extension_present_is_unchanged(tmp_path):
|
||||
import src.document_processor as dp
|
||||
|
||||
p = tmp_path / "pic.png"
|
||||
p.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
uploads = {"img": {"path": str(p), "name": "pic.png", "mime": "image/png"}}
|
||||
|
||||
content = dp.build_user_content("look", ["img"], str(tmp_path), _Handler(uploads, image=True), owner="t")
|
||||
imgs = _blocks(content, "image_url")
|
||||
assert imgs[0]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
@@ -12,6 +12,16 @@ def _bulk_action_source() -> str:
|
||||
return text[start:end]
|
||||
|
||||
|
||||
def _function_source(name: str) -> str:
|
||||
text = _EMAIL_LIBRARY.read_text(encoding="utf-8")
|
||||
start = text.index(f"function {name}")
|
||||
next_function = text.find("\nfunction ", start + 1)
|
||||
next_async = text.find("\nasync function ", start + 1)
|
||||
candidates = [idx for idx in (next_function, next_async) if idx != -1]
|
||||
end = min(candidates) if candidates else len(text)
|
||||
return text[start:end]
|
||||
|
||||
|
||||
def test_email_bulk_read_unread_calls_provider_write_routes():
|
||||
"""Bulk read/unread must persist to IMAP/provider, not only mutate UI state.
|
||||
|
||||
@@ -34,3 +44,33 @@ def test_email_bulk_read_unread_checks_backend_success_before_syncing_cache():
|
||||
assert "data?.success === false" in src
|
||||
assert "throw new Error(data?.error" in src
|
||||
assert "_libCacheWriteBack()" in src
|
||||
|
||||
|
||||
def test_email_context_changes_clear_bulk_selection_state():
|
||||
"""IMAP UIDs are folder/account scoped, so stale bulk selections must die.
|
||||
|
||||
Folder, account, filter, quick-filter, attachment, and search basis changes
|
||||
must exit select mode before the next list/search view can run bulk actions.
|
||||
"""
|
||||
text = _EMAIL_LIBRARY.read_text(encoding="utf-8")
|
||||
reset_src = _function_source("_resetBulkSelectionForContextChange")
|
||||
fresh_src = _function_source("_resetEmailListForFreshLoad")
|
||||
add_pill_src = _function_source("_addSearchPill")
|
||||
remove_pill_src = _function_source("_removeSearchPillAt")
|
||||
search_src = text[text.index("async function _doSearch()"):text.index("// Custom dropdown", text.index("async function _doSearch()"))]
|
||||
|
||||
assert "state._selectedUids.clear()" in reset_src
|
||||
assert "state._selectMode = false" in reset_src
|
||||
assert "_updateBulkBar()" in reset_src
|
||||
|
||||
assert "_resetBulkSelectionForContextChange()" in fresh_src
|
||||
assert "_resetBulkSelectionForContextChange({ rerender: true })" in add_pill_src
|
||||
assert "_resetBulkSelectionForContextChange({ rerender: true })" in remove_pill_src
|
||||
assert "_resetBulkSelectionForContextChange({ rerender: true })" in search_src
|
||||
|
||||
assert "state._libFolder = e.target.value;" in text
|
||||
assert "state._libFilter = e.target.value;" in text
|
||||
assert "state._libHasAttachments = !state._libHasAttachments;" in text
|
||||
assert "state._libAccountId = btn.dataset.accId || null;" in text
|
||||
assert text.count("_loadEmailsFresh();") >= 5
|
||||
assert "state._libSearchDraft = input.value;" in text
|
||||
|
||||
+219
-1
@@ -372,7 +372,15 @@ async def test_callback_valid_owner_writes_encrypted_tokens_to_intended_account(
|
||||
from core.database import EmailAccount
|
||||
|
||||
db, Factory = _make_db()
|
||||
_make_account(db, account_id="acct-v", owner="alice", imap_host="", smtp_host="")
|
||||
_make_account(
|
||||
db,
|
||||
account_id="acct-v",
|
||||
owner="alice",
|
||||
imap_host="",
|
||||
smtp_host="",
|
||||
imap_user="alice@nyu.edu",
|
||||
smtp_user="ALICE@NYU.EDU",
|
||||
)
|
||||
_make_account(db, account_id="acct-other", owner="alice") # must stay untouched
|
||||
db.close()
|
||||
|
||||
@@ -407,6 +415,166 @@ async def test_callback_valid_owner_writes_encrypted_tokens_to_intended_account(
|
||||
assert other.oauth_access_token is None, "tokens must only touch the intended account"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_rejects_token_for_a_different_mailbox_identity():
|
||||
"""Reconnecting with another Google identity must not replace the token
|
||||
while retaining the original IMAP/SMTP login names."""
|
||||
from routes.email_helpers import make_oauth_state
|
||||
from src.secret_storage import encrypt as _enc, decrypt as _dec
|
||||
from core.database import EmailAccount
|
||||
|
||||
db, Factory = _make_db()
|
||||
_make_account(
|
||||
db,
|
||||
account_id="acct-reconnect",
|
||||
owner="alice",
|
||||
imap_user="alice@example.edu",
|
||||
smtp_user="alice@example.edu",
|
||||
oauth_provider="google",
|
||||
oauth_access_token=_enc("ya29.existing_access"),
|
||||
oauth_refresh_token=_enc("1//existing_refresh"),
|
||||
)
|
||||
db.close()
|
||||
|
||||
token_resp = mock.MagicMock()
|
||||
token_resp.raise_for_status = mock.MagicMock()
|
||||
token_resp.json.return_value = {
|
||||
"access_token": "ya29.other_access",
|
||||
"refresh_token": "1//other_refresh",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
userinfo_resp = mock.MagicMock()
|
||||
userinfo_resp.is_success = True
|
||||
userinfo_resp.json.return_value = {
|
||||
"email": "other@example.edu",
|
||||
"name": "Other User",
|
||||
}
|
||||
|
||||
state = make_oauth_state("acct-reconnect", "alice")
|
||||
with mock.patch("httpx.post", return_value=token_resp), \
|
||||
mock.patch("httpx.get", return_value=userinfo_resp), \
|
||||
mock.patch("core.database.SessionLocal", Factory):
|
||||
resp = await _callback_endpoint()(
|
||||
code="4/code",
|
||||
state=state,
|
||||
error=None,
|
||||
request=_FakeRequest(),
|
||||
)
|
||||
|
||||
assert "email_oauth_error=identity_verification_failed" in _location(resp)
|
||||
verify_db = Factory()
|
||||
row = verify_db.query(EmailAccount).filter(
|
||||
EmailAccount.id == "acct-reconnect"
|
||||
).first()
|
||||
verify_db.close()
|
||||
assert _dec(row.oauth_access_token) == "ya29.existing_access"
|
||||
assert _dec(row.oauth_refresh_token) == "1//existing_refresh"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_rejects_reconnect_without_a_fresh_refresh_token():
|
||||
"""A same-identity access token cannot be paired with an unproven refresh
|
||||
token retained from a previously mixed row."""
|
||||
from routes.email_helpers import make_oauth_state
|
||||
from src.secret_storage import encrypt as _enc, decrypt as _dec
|
||||
from core.database import EmailAccount
|
||||
|
||||
db, Factory = _make_db()
|
||||
_make_account(
|
||||
db,
|
||||
account_id="acct-refresh-proof",
|
||||
owner="alice",
|
||||
imap_user="alice@example.edu",
|
||||
smtp_user="alice@example.edu",
|
||||
oauth_provider="google",
|
||||
oauth_access_token=_enc("ya29.existing_access"),
|
||||
oauth_refresh_token=_enc("1//refresh_for_other_identity"),
|
||||
)
|
||||
db.close()
|
||||
|
||||
token_resp = mock.MagicMock()
|
||||
token_resp.raise_for_status = mock.MagicMock()
|
||||
token_resp.json.return_value = {
|
||||
"access_token": "ya29.same_identity_access",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
|
||||
state = make_oauth_state("acct-refresh-proof", "alice")
|
||||
with mock.patch("httpx.post", return_value=token_resp), \
|
||||
mock.patch("httpx.get") as userinfo_get, \
|
||||
mock.patch("core.database.SessionLocal", Factory):
|
||||
resp = await _callback_endpoint()(
|
||||
code="4/code",
|
||||
state=state,
|
||||
error=None,
|
||||
request=_FakeRequest(),
|
||||
)
|
||||
|
||||
assert "email_oauth_error=token_exchange_failed" in _location(resp)
|
||||
userinfo_get.assert_not_called()
|
||||
verify_db = Factory()
|
||||
row = verify_db.query(EmailAccount).filter(
|
||||
EmailAccount.id == "acct-refresh-proof"
|
||||
).first()
|
||||
verify_db.close()
|
||||
assert _dec(row.oauth_access_token) == "ya29.existing_access"
|
||||
assert _dec(row.oauth_refresh_token) == "1//refresh_for_other_identity"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("userinfo_result", [None, {}, {"email": None}])
|
||||
async def test_callback_requires_verified_mailbox_identity(userinfo_result):
|
||||
"""A failed or incomplete userinfo lookup must not persist fresh tokens."""
|
||||
from routes.email_helpers import make_oauth_state
|
||||
from core.database import EmailAccount
|
||||
|
||||
db, Factory = _make_db()
|
||||
_make_account(
|
||||
db,
|
||||
account_id="acct-no-identity",
|
||||
owner="alice",
|
||||
imap_user="alice@example.edu",
|
||||
smtp_user="alice@example.edu",
|
||||
)
|
||||
db.close()
|
||||
|
||||
token_resp = mock.MagicMock()
|
||||
token_resp.raise_for_status = mock.MagicMock()
|
||||
token_resp.json.return_value = {
|
||||
"access_token": "ya29.unverified_access",
|
||||
"refresh_token": "1//unverified_refresh",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
if userinfo_result is None:
|
||||
userinfo_call = mock.Mock(side_effect=RuntimeError("userinfo unavailable"))
|
||||
else:
|
||||
userinfo_resp = mock.MagicMock()
|
||||
userinfo_resp.is_success = True
|
||||
userinfo_resp.json.return_value = userinfo_result
|
||||
userinfo_call = mock.Mock(return_value=userinfo_resp)
|
||||
|
||||
state = make_oauth_state("acct-no-identity", "alice")
|
||||
with mock.patch("httpx.post", return_value=token_resp), \
|
||||
mock.patch("httpx.get", userinfo_call), \
|
||||
mock.patch("core.database.SessionLocal", Factory):
|
||||
resp = await _callback_endpoint()(
|
||||
code="4/code",
|
||||
state=state,
|
||||
error=None,
|
||||
request=_FakeRequest(),
|
||||
)
|
||||
|
||||
assert "email_oauth_error=identity_verification_failed" in _location(resp)
|
||||
verify_db = Factory()
|
||||
row = verify_db.query(EmailAccount).filter(
|
||||
EmailAccount.id == "acct-no-identity"
|
||||
).first()
|
||||
verify_db.close()
|
||||
assert row.oauth_provider is None
|
||||
assert row.oauth_access_token is None
|
||||
assert row.oauth_refresh_token is None
|
||||
|
||||
|
||||
# ── Token refresh scenarios ───────────────────────────────────────
|
||||
|
||||
def test_get_valid_google_token_uses_cached_when_fresh():
|
||||
@@ -578,3 +746,53 @@ async def test_account_list_response_does_not_expose_token_values():
|
||||
assert acct["oauth_provider"] == "google" # status is exposed
|
||||
assert "oauth_access_token" not in acct # token value is not
|
||||
assert "oauth_refresh_token" not in acct
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_response_does_not_expose_oauth_storage_fields():
|
||||
"""The client-facing config route must not serialize the encrypted token
|
||||
fields returned by the internal transport helper."""
|
||||
from routes.email_routes import setup_email_routes
|
||||
from src.secret_storage import encrypt as _enc
|
||||
|
||||
encrypted_access = _enc("ya29.internal_access_token")
|
||||
encrypted_refresh = _enc("1//internal_refresh_token")
|
||||
internal_cfg = {
|
||||
"account_id": "acct-config",
|
||||
"account_name": "Google Workspace",
|
||||
"smtp_host": "smtp.gmail.com",
|
||||
"smtp_port": 587,
|
||||
"smtp_security": "starttls",
|
||||
"smtp_user": "alice@example.edu",
|
||||
"smtp_password": "",
|
||||
"imap_host": "imap.gmail.com",
|
||||
"imap_port": 993,
|
||||
"imap_user": "alice@example.edu",
|
||||
"imap_password": "",
|
||||
"imap_starttls": False,
|
||||
"from_address": "alice@example.edu",
|
||||
"oauth_provider": "google",
|
||||
"oauth_access_token": encrypted_access,
|
||||
"oauth_refresh_token": encrypted_refresh,
|
||||
"oauth_token_expiry": "4102444800",
|
||||
"display_name": "Alice",
|
||||
}
|
||||
|
||||
router = setup_email_routes()
|
||||
get_config = None
|
||||
for route in router.routes:
|
||||
if route.path == "/api/email/config" and "GET" in getattr(route, "methods", set()):
|
||||
get_config = route.endpoint
|
||||
break
|
||||
assert get_config is not None, "email config route not found"
|
||||
|
||||
with mock.patch("routes.email_routes._get_email_config", return_value=internal_cfg.copy()), \
|
||||
mock.patch("routes.email_routes._load_settings", return_value={}):
|
||||
result = await get_config(owner="alice")
|
||||
|
||||
assert result["oauth_provider"] == "google"
|
||||
assert "oauth_access_token" not in result
|
||||
assert "oauth_refresh_token" not in result
|
||||
assert "oauth_token_expiry" not in result
|
||||
assert encrypted_access not in json.dumps(result)
|
||||
assert encrypted_refresh not in json.dumps(result)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Regression coverage for Google OAuth configuration in Docker Compose."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
COMPOSE_PATHS = tuple(
|
||||
ROOT / name
|
||||
for name in (
|
||||
"docker-compose.yml",
|
||||
"docker-compose.gpu-nvidia.yml",
|
||||
"docker-compose.gpu-amd.yml",
|
||||
)
|
||||
)
|
||||
ENV_EXAMPLE_PATH = ROOT / ".env.example"
|
||||
|
||||
|
||||
def _env_example():
|
||||
if not ENV_EXAMPLE_PATH.exists():
|
||||
pytest.skip("this checkout does not include the optional .env.example file")
|
||||
return ENV_EXAMPLE_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _odysseus_environment(path):
|
||||
compose = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
return set(compose["services"]["odysseus"]["environment"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
(
|
||||
"GOOGLE_OAUTH_CLIENT_ID",
|
||||
"GOOGLE_OAUTH_CLIENT_SECRET",
|
||||
"GOOGLE_OAUTH_REDIRECT_URI",
|
||||
),
|
||||
)
|
||||
def test_google_oauth_setting_is_forwarded(key):
|
||||
expected = f"{key}=${{{key}:-}}"
|
||||
for path in COMPOSE_PATHS:
|
||||
assert expected in _odysseus_environment(path), path.name
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
(
|
||||
"GOOGLE_OAUTH_CLIENT_ID",
|
||||
"GOOGLE_OAUTH_CLIENT_SECRET",
|
||||
"GOOGLE_OAUTH_REDIRECT_URI",
|
||||
),
|
||||
)
|
||||
def test_google_oauth_setting_is_documented(key):
|
||||
assert f"# {key}=" in _env_example()
|
||||
|
||||
|
||||
def test_google_oauth_example_uses_a_neutral_secret_placeholder():
|
||||
env_example = _env_example()
|
||||
assert "GOOGLE_OAUTH_CLIENT_SECRET=replace-with-client-secret" in env_example
|
||||
assert "GOCSPX-" not in env_example
|
||||
|
||||
|
||||
def test_redirect_documentation_covers_https_and_reverse_proxies():
|
||||
oauth_section = _env_example().split("# Google OAuth2", 1)[1].split("# Misc", 1)[0]
|
||||
assert "HTTPS" in oauth_section
|
||||
assert "reverse-proxy" in oauth_section
|
||||
assert "exactly match an authorized redirect URI" in oauth_section
|
||||
@@ -539,6 +539,55 @@ async def test_pending_agent_draft_routes_do_not_expose_ownerless_rows(tmp_path,
|
||||
assert rows == [("draft-bob", "agent_draft"), ("draft-ownerless", "agent_draft")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_agent_draft_routes_block_cross_owner_actions(tmp_path, monkeypatch):
|
||||
import routes.email_helpers as email_helpers
|
||||
import routes.email_routes as email_routes
|
||||
|
||||
db_path = tmp_path / "scheduled_emails.db"
|
||||
monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
|
||||
monkeypatch.setattr(email_routes, "SCHEDULED_DB", db_path)
|
||||
email_helpers._init_scheduled_db()
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO scheduled_emails
|
||||
(id, to_addr, subject, body, attachments, send_at, created_at, status, account_id, owner)
|
||||
VALUES (?, ?, ?, ?, '[]', '9999-12-31T00:00:00', ?, 'agent_draft', ?, ?)
|
||||
""",
|
||||
[
|
||||
("draft-alice", "alice@example.com", "Alice", "alice body", "2026-01-01", "acct-a", "alice"),
|
||||
("draft-bob", "bob@example.com", "Bob", "bob body", "2026-01-02", "acct-b", "bob"),
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
router = email_routes.setup_email_routes()
|
||||
list_pending = _route_endpoint(router, "/api/email/pending", "GET")
|
||||
approve_pending = _route_endpoint(router, "/api/email/pending/{sid}/approve", "POST")
|
||||
cancel_pending = _route_endpoint(router, "/api/email/pending/{sid}", "DELETE")
|
||||
|
||||
alice_rows = await list_pending(owner="alice")
|
||||
assert [row["id"] for row in alice_rows["pending"]] == ["draft-alice"]
|
||||
|
||||
assert (await approve_pending("draft-bob", owner="alice"))["success"] is False
|
||||
assert (await cancel_pending("draft-bob", owner="alice"))["success"] is False
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, status, send_at FROM scheduled_emails ORDER BY id",
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
assert rows == [
|
||||
("draft-alice", "agent_draft", "9999-12-31T00:00:00"),
|
||||
("draft-bob", "agent_draft", "9999-12-31T00:00:00"),
|
||||
]
|
||||
|
||||
|
||||
def test_scheduled_poller_resolves_config_with_row_owner(tmp_path, monkeypatch):
|
||||
import routes.email_helpers as email_helpers
|
||||
import routes.email_pollers as email_pollers
|
||||
|
||||
@@ -51,7 +51,7 @@ def test_plan_mode_classifies_every_email_tool():
|
||||
from src.tool_security import plan_mode_disabled_tools
|
||||
|
||||
denied = plan_mode_disabled_tools()
|
||||
readonly = {"list_email_accounts", "list_emails", "read_email", "search_emails"}
|
||||
readonly = {"list_email_accounts", "list_emails", "read_email", "search_emails", "scan_email_unsubscribes"}
|
||||
for tool in sorted(BUILTIN_EMAIL_TOOLS):
|
||||
if tool in readonly:
|
||||
assert tool in PLAN_MODE_READONLY_TOOLS, f"{tool} must be explicit read-only"
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
"""Tests for Google OAuth2 support in the /api/email/accounts/test endpoint.
|
||||
|
||||
Covers the changes made to routes/email_routes.py:
|
||||
|
||||
- test_account_config: OAuth accounts must not require a stored password.
|
||||
- IMAP and SMTP test paths must use XOAUTH2 for Google accounts.
|
||||
- Password accounts must still use conn.login() / smtp.login().
|
||||
|
||||
These tests use only in-memory SQLite (via SQLAlchemy) and mock network
|
||||
objects — no live email server or real OAuth credentials are needed.
|
||||
"""
|
||||
|
||||
import time
|
||||
import unittest.mock as mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_orm_db():
|
||||
"""Return (Session, SessionFactory) backed by an isolated in-memory SQLite 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(), Factory
|
||||
|
||||
|
||||
def _make_orm_account(session, account_id="acct-1", owner="alice", **kwargs):
|
||||
from core.database import EmailAccount
|
||||
row = EmailAccount(
|
||||
id=account_id,
|
||||
owner=owner,
|
||||
name=kwargs.get("name", "Test"),
|
||||
from_address=kwargs.get("from_address", "me@nia.law"),
|
||||
imap_host=kwargs.get("imap_host", "imap.gmail.com"),
|
||||
imap_port=kwargs.get("imap_port", 993),
|
||||
imap_user=kwargs.get("imap_user", "me@nia.law"),
|
||||
imap_starttls=kwargs.get("imap_starttls", False),
|
||||
smtp_host=kwargs.get("smtp_host", "smtp.gmail.com"),
|
||||
smtp_port=kwargs.get("smtp_port", 587),
|
||||
smtp_security=kwargs.get("smtp_security", "starttls"),
|
||||
smtp_user=kwargs.get("smtp_user", "me@nia.law"),
|
||||
)
|
||||
for k, v in kwargs.items():
|
||||
if hasattr(row, k):
|
||||
setattr(row, k, v)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
# ── test_connection route: OAuth awareness ────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_oauth_account_uses_xoauth2_for_imap_and_smtp():
|
||||
"""The saved-account test must use XOAUTH2 for both mail protocols."""
|
||||
from src.secret_storage import encrypt as _enc
|
||||
from routes.email_routes import setup_email_routes
|
||||
|
||||
future_expiry = str(int(time.time()) + 7200)
|
||||
db, Factory = _make_orm_db()
|
||||
_make_orm_account(
|
||||
db, account_id="acct-oauth", owner="alice",
|
||||
oauth_provider="google",
|
||||
oauth_access_token=_enc("ya29.live"),
|
||||
oauth_refresh_token=_enc("1//refresh"),
|
||||
oauth_token_expiry=future_expiry,
|
||||
)
|
||||
db.close()
|
||||
|
||||
router = setup_email_routes()
|
||||
test_conn = None
|
||||
for route in router.routes:
|
||||
if route.path == "/api/email/accounts/test" and "POST" in getattr(route, "methods", set()):
|
||||
test_conn = route.endpoint
|
||||
break
|
||||
assert test_conn is not None, "test-connection route not found"
|
||||
|
||||
mock_imap_conn = mock.MagicMock()
|
||||
mock_smtp_conn = mock.MagicMock()
|
||||
|
||||
class _FakeReq:
|
||||
async def json(self):
|
||||
return {"account_id": "acct-oauth"}
|
||||
|
||||
with mock.patch("core.database.SessionLocal", Factory), \
|
||||
mock.patch("routes.email_routes._open_imap_connection", return_value=mock_imap_conn), \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP", return_value=mock_smtp_conn), \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP_SSL", return_value=mock_smtp_conn), \
|
||||
mock.patch("routes.email_routes._get_valid_google_token", return_value="ya29.live") as token_getter:
|
||||
result = await test_conn(req=_FakeReq(), owner="alice")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["imap"].get("ok") is True, \
|
||||
f"OAuth IMAP test must succeed, got: {result['imap']}"
|
||||
assert result["smtp"].get("ok") is True, \
|
||||
f"OAuth SMTP test must succeed, got: {result['smtp']}"
|
||||
mock_imap_conn.authenticate.assert_called_once()
|
||||
assert mock_imap_conn.authenticate.call_args[0][0] == "XOAUTH2"
|
||||
mock_imap_conn.login.assert_not_called()
|
||||
mock_smtp_conn.auth.assert_called_once()
|
||||
assert mock_smtp_conn.auth.call_args[0][0] == "XOAUTH2"
|
||||
mock_smtp_conn.login.assert_not_called()
|
||||
token_getter.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_password_account_still_uses_login():
|
||||
"""Existing password accounts must still go through the login() path."""
|
||||
from src.secret_storage import encrypt as _enc
|
||||
from routes.email_routes import setup_email_routes
|
||||
|
||||
db, Factory = _make_orm_db()
|
||||
_make_orm_account(
|
||||
db, account_id="acct-pw", owner="alice",
|
||||
imap_host="imap.example.com",
|
||||
imap_user="me@example.com",
|
||||
smtp_host="smtp.example.com",
|
||||
smtp_user="me@example.com",
|
||||
imap_password=_enc("hunter2"),
|
||||
)
|
||||
db.close()
|
||||
|
||||
router = setup_email_routes()
|
||||
test_conn = None
|
||||
for route in router.routes:
|
||||
if route.path == "/api/email/accounts/test" and "POST" in getattr(route, "methods", set()):
|
||||
test_conn = route.endpoint
|
||||
break
|
||||
|
||||
mock_imap_conn = mock.MagicMock()
|
||||
mock_smtp_conn = mock.MagicMock()
|
||||
|
||||
class _FakeReq:
|
||||
async def json(self):
|
||||
return {"account_id": "acct-pw"}
|
||||
|
||||
with mock.patch("core.database.SessionLocal", Factory), \
|
||||
mock.patch("routes.email_routes._open_imap_connection", return_value=mock_imap_conn), \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP", return_value=mock_smtp_conn), \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP_SSL", return_value=mock_smtp_conn):
|
||||
result = await test_conn(req=_FakeReq(), owner="alice")
|
||||
|
||||
assert result["ok"] is True
|
||||
mock_imap_conn.login.assert_called_once_with("me@example.com", "hunter2")
|
||||
mock_imap_conn.authenticate.assert_not_called()
|
||||
mock_smtp_conn.login.assert_called_once_with("me@example.com", "hunter2")
|
||||
mock_smtp_conn.auth.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_rejects_non_google_hosts_before_oauth_auth():
|
||||
"""Saved Google tokens must never be routed to edited custom hosts."""
|
||||
from src.secret_storage import encrypt as _enc
|
||||
from routes.email_routes import setup_email_routes
|
||||
|
||||
db, Factory = _make_orm_db()
|
||||
_make_orm_account(
|
||||
db,
|
||||
account_id="acct-oauth",
|
||||
owner="alice",
|
||||
oauth_provider="google",
|
||||
oauth_access_token=_enc("ya29.live"),
|
||||
oauth_refresh_token=_enc("1//refresh"),
|
||||
oauth_token_expiry=str(int(time.time()) + 7200),
|
||||
)
|
||||
db.close()
|
||||
|
||||
router = setup_email_routes()
|
||||
test_conn = next(
|
||||
route.endpoint
|
||||
for route in router.routes
|
||||
if route.path == "/api/email/accounts/test" and "POST" in getattr(route, "methods", set())
|
||||
)
|
||||
|
||||
class _FakeReq:
|
||||
async def json(self):
|
||||
return {
|
||||
"account_id": "acct-oauth",
|
||||
"imap_host": "collector.invalid",
|
||||
"smtp_host": "collector.invalid",
|
||||
}
|
||||
|
||||
with mock.patch("core.database.SessionLocal", Factory), \
|
||||
mock.patch("routes.email_routes._open_imap_connection") as open_imap, \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP") as open_smtp, \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP_SSL") as open_smtp_ssl, \
|
||||
mock.patch("routes.email_routes._get_valid_google_token") as token_getter:
|
||||
result = await test_conn(req=_FakeReq(), owner="alice")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "imap.gmail.com" in result["imap"]["error"]
|
||||
assert "smtp.gmail.com" in result["smtp"]["error"]
|
||||
open_imap.assert_not_called()
|
||||
open_smtp.assert_not_called()
|
||||
open_smtp_ssl.assert_not_called()
|
||||
token_getter.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_rejects_insecure_oauth_transports_before_auth():
|
||||
"""Google OAuth credentials must not be tested over plaintext transports."""
|
||||
from src.secret_storage import encrypt as _enc
|
||||
from routes.email_routes import setup_email_routes
|
||||
|
||||
db, Factory = _make_orm_db()
|
||||
_make_orm_account(
|
||||
db,
|
||||
account_id="acct-oauth",
|
||||
owner="alice",
|
||||
oauth_provider="google",
|
||||
oauth_access_token=_enc("ya29.live"),
|
||||
oauth_refresh_token=_enc("1//refresh"),
|
||||
oauth_token_expiry=str(int(time.time()) + 7200),
|
||||
)
|
||||
db.close()
|
||||
|
||||
router = setup_email_routes()
|
||||
test_conn = next(
|
||||
route.endpoint
|
||||
for route in router.routes
|
||||
if route.path == "/api/email/accounts/test" and "POST" in getattr(route, "methods", set())
|
||||
)
|
||||
|
||||
class _FakeReq:
|
||||
async def json(self):
|
||||
return {
|
||||
"account_id": "acct-oauth",
|
||||
"imap_port": 143,
|
||||
"imap_starttls": False,
|
||||
"smtp_port": 587,
|
||||
"smtp_security": "none",
|
||||
}
|
||||
|
||||
with mock.patch("core.database.SessionLocal", Factory), \
|
||||
mock.patch("routes.email_routes._open_imap_connection") as open_imap, \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP") as open_smtp, \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP_SSL") as open_smtp_ssl, \
|
||||
mock.patch("routes.email_routes._get_valid_google_token") as token_getter:
|
||||
result = await test_conn(req=_FakeReq(), owner="alice")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "TLS" in result["imap"]["error"]
|
||||
assert "TLS" in result["smtp"]["error"]
|
||||
open_imap.assert_not_called()
|
||||
open_smtp.assert_not_called()
|
||||
open_smtp_ssl.assert_not_called()
|
||||
token_getter.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_does_not_accept_inline_oauth_state():
|
||||
"""Only an owner-checked saved account may select the OAuth branch."""
|
||||
from routes.email_routes import setup_email_routes
|
||||
|
||||
router = setup_email_routes()
|
||||
test_conn = next(
|
||||
route.endpoint
|
||||
for route in router.routes
|
||||
if route.path == "/api/email/accounts/test" and "POST" in getattr(route, "methods", set())
|
||||
)
|
||||
|
||||
class _FakeReq:
|
||||
async def json(self):
|
||||
return {
|
||||
"imap_host": "imap.gmail.com",
|
||||
"imap_user": "me@example.com",
|
||||
"oauth_provider": "google",
|
||||
"oauth_access_token": "client-supplied-token",
|
||||
}
|
||||
|
||||
with mock.patch("routes.email_routes._open_imap_connection") as open_imap, \
|
||||
mock.patch("routes.email_routes._get_valid_google_token") as token_getter:
|
||||
result = await test_conn(req=_FakeReq(), owner="alice")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["imap"]["error"] == "Need IMAP host, username, and password"
|
||||
open_imap.assert_not_called()
|
||||
token_getter.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("imap_starttls", "imap_port"),
|
||||
[(False, 993), (True, 143)],
|
||||
)
|
||||
async def test_test_connection_verifies_imap_tls_before_loading_oauth_token(
|
||||
imap_starttls,
|
||||
imap_port,
|
||||
):
|
||||
"""Both Google IMAP TLS modes receive a certificate-verifying context;
|
||||
certificate rejection happens before the bearer token is loaded."""
|
||||
import ssl
|
||||
|
||||
from routes.email_routes import setup_email_routes
|
||||
from src.secret_storage import encrypt as _enc
|
||||
|
||||
db, Factory = _make_orm_db()
|
||||
_make_orm_account(
|
||||
db,
|
||||
account_id="acct-imap-tls",
|
||||
owner="alice",
|
||||
imap_port=imap_port,
|
||||
imap_starttls=imap_starttls,
|
||||
smtp_host="",
|
||||
oauth_provider="google",
|
||||
oauth_access_token=_enc("ya29.live"),
|
||||
oauth_refresh_token=_enc("1//refresh"),
|
||||
oauth_token_expiry=str(int(time.time()) + 7200),
|
||||
)
|
||||
db.close()
|
||||
|
||||
router = setup_email_routes()
|
||||
test_conn = next(
|
||||
route.endpoint
|
||||
for route in router.routes
|
||||
if route.path == "/api/email/accounts/test"
|
||||
and "POST" in getattr(route, "methods", set())
|
||||
)
|
||||
|
||||
class _FakeReq:
|
||||
async def json(self):
|
||||
return {"account_id": "acct-imap-tls"}
|
||||
|
||||
context = ssl.create_default_context()
|
||||
starttls_conn = mock.MagicMock()
|
||||
starttls_conn.starttls.side_effect = ssl.SSLCertVerificationError(
|
||||
"untrusted certificate"
|
||||
)
|
||||
with mock.patch("core.database.SessionLocal", Factory), \
|
||||
mock.patch(
|
||||
"routes.email_routes.ssl.create_default_context",
|
||||
return_value=context,
|
||||
), mock.patch(
|
||||
"routes.email_helpers.imaplib.IMAP4",
|
||||
return_value=starttls_conn,
|
||||
) as imap_cls, mock.patch(
|
||||
"routes.email_helpers.imaplib.IMAP4_SSL",
|
||||
side_effect=ssl.SSLCertVerificationError("untrusted certificate"),
|
||||
) as imap_ssl_cls, mock.patch(
|
||||
"routes.email_routes._get_valid_google_token"
|
||||
) as token_getter:
|
||||
result = await test_conn(req=_FakeReq(), owner="alice")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["imap"]["ok"] is False
|
||||
assert context.check_hostname is True
|
||||
assert context.verify_mode == ssl.CERT_REQUIRED
|
||||
token_getter.assert_not_called()
|
||||
if imap_starttls:
|
||||
assert starttls_conn.starttls.call_args.kwargs["ssl_context"] is context
|
||||
imap_ssl_cls.assert_not_called()
|
||||
else:
|
||||
assert imap_ssl_cls.call_args.kwargs["ssl_context"] is context
|
||||
imap_cls.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("smtp_security", "smtp_port"),
|
||||
[("ssl", 465), ("starttls", 587)],
|
||||
)
|
||||
async def test_test_connection_verifies_smtp_tls_before_loading_oauth_token(
|
||||
smtp_security,
|
||||
smtp_port,
|
||||
):
|
||||
"""Both Google SMTP TLS modes reject an invalid certificate before any
|
||||
XOAUTH2 credential is obtained or sent."""
|
||||
import ssl
|
||||
|
||||
from routes.email_routes import setup_email_routes
|
||||
from src.secret_storage import encrypt as _enc
|
||||
|
||||
db, Factory = _make_orm_db()
|
||||
_make_orm_account(
|
||||
db,
|
||||
account_id="acct-smtp-tls",
|
||||
owner="alice",
|
||||
imap_host="",
|
||||
smtp_port=smtp_port,
|
||||
smtp_security=smtp_security,
|
||||
oauth_provider="google",
|
||||
oauth_access_token=_enc("ya29.live"),
|
||||
oauth_refresh_token=_enc("1//refresh"),
|
||||
oauth_token_expiry=str(int(time.time()) + 7200),
|
||||
)
|
||||
db.close()
|
||||
|
||||
router = setup_email_routes()
|
||||
test_conn = next(
|
||||
route.endpoint
|
||||
for route in router.routes
|
||||
if route.path == "/api/email/accounts/test"
|
||||
and "POST" in getattr(route, "methods", set())
|
||||
)
|
||||
|
||||
class _FakeReq:
|
||||
async def json(self):
|
||||
return {"account_id": "acct-smtp-tls"}
|
||||
|
||||
context = ssl.create_default_context()
|
||||
starttls_smtp = mock.MagicMock()
|
||||
starttls_smtp.starttls.side_effect = ssl.SSLCertVerificationError(
|
||||
"untrusted certificate"
|
||||
)
|
||||
with mock.patch("core.database.SessionLocal", Factory), \
|
||||
mock.patch(
|
||||
"routes.email_routes.ssl.create_default_context",
|
||||
return_value=context,
|
||||
), mock.patch(
|
||||
"routes.email_routes.smtplib.SMTP",
|
||||
return_value=starttls_smtp,
|
||||
) as smtp_cls, mock.patch(
|
||||
"routes.email_routes.smtplib.SMTP_SSL",
|
||||
side_effect=ssl.SSLCertVerificationError("untrusted certificate"),
|
||||
) as smtp_ssl_cls, mock.patch(
|
||||
"routes.email_routes._get_valid_google_token"
|
||||
) as token_getter:
|
||||
result = await test_conn(req=_FakeReq(), owner="alice")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["smtp"]["ok"] is False
|
||||
assert context.check_hostname is True
|
||||
assert context.verify_mode == ssl.CERT_REQUIRED
|
||||
token_getter.assert_not_called()
|
||||
if smtp_security == "starttls":
|
||||
assert starttls_smtp.starttls.call_args.kwargs["context"] is context
|
||||
assert starttls_smtp.close.called
|
||||
smtp_ssl_cls.assert_not_called()
|
||||
else:
|
||||
assert smtp_ssl_cls.call_args.kwargs["context"] is context
|
||||
smtp_cls.assert_not_called()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Email move/flag must never fall back to sequence-number IMAP ops (#1874 sibling).
|
||||
|
||||
`imaplib`'s plain `store()` / `copy()` operate on message SEQUENCE NUMBERS, not
|
||||
UIDs. `_store_email_flag` / `_move_email_message` (used by the archive / delete /
|
||||
move / mark endpoints) had an `else` fallback that, when `_uid_exists` returned
|
||||
False, ran `conn.store(uid, …)` / `conn.copy(uid, …)` + `conn.expunge()` — i.e.
|
||||
it flagged/copied whichever message occupied sequence position == the UID value
|
||||
and then permanently expunged it. A stale cached UID (or a server whose UID
|
||||
probe misbehaves) therefore deleted an unrelated email.
|
||||
|
||||
The fix fails safe: when the UID isn't present, return False (callers surface
|
||||
"Email not found") and never touch a message by sequence number.
|
||||
|
||||
This is distinct from #1874, which fixes the auto-spam poller's `_imap_move` in
|
||||
`routes/email_helpers.py`; this covers the user-facing endpoints in
|
||||
`routes/email_routes.py`.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from routes import email_routes
|
||||
from routes.email_routes import _store_email_flag, _move_email_message
|
||||
|
||||
|
||||
class _FakeConn:
|
||||
"""Records IMAP calls. `uid_present` controls the FETCH-UID probe result.
|
||||
|
||||
The sequence-number commands (store/copy/expunge) raise if ever called —
|
||||
the whole point of the fix is that they must not be reached.
|
||||
"""
|
||||
def __init__(self, uid_present, uid_move_ok=True):
|
||||
self.uid_present = uid_present
|
||||
self.uid_move_ok = uid_move_ok
|
||||
self.uid_calls = []
|
||||
self.seqno_calls = []
|
||||
|
||||
def uid(self, command, *args):
|
||||
self.uid_calls.append((command.upper(), args))
|
||||
cmd = command.upper()
|
||||
if cmd == "FETCH":
|
||||
return ("OK", [b"1 (UID 5031)"] if self.uid_present else [])
|
||||
if cmd == "MOVE":
|
||||
return ("OK" if self.uid_move_ok else "NO", [b""])
|
||||
if cmd in ("COPY", "STORE"):
|
||||
return ("OK", [b""])
|
||||
return ("OK", [b""])
|
||||
|
||||
# Sequence-number APIs — must never be used with a UID.
|
||||
def store(self, *a):
|
||||
self.seqno_calls.append(("store", a)); return ("OK", [b""])
|
||||
|
||||
def copy(self, *a):
|
||||
self.seqno_calls.append(("copy", a)); return ("OK", [b""])
|
||||
|
||||
def expunge(self, *a):
|
||||
self.seqno_calls.append(("expunge", a)); return ("OK", [b""])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_folder_resolution(monkeypatch):
|
||||
# _move_email_message resolves the destination folder via the connection;
|
||||
# short-circuit it so the test focuses on the UID-vs-seqno behaviour.
|
||||
monkeypatch.setattr(email_routes, "_resolve_mail_folder", lambda conn, dest, role="": dest)
|
||||
|
||||
|
||||
def test_store_flag_missing_uid_fails_safe():
|
||||
conn = _FakeConn(uid_present=False)
|
||||
assert _store_email_flag(conn, "5031", "\\Deleted", add=True) is False
|
||||
assert conn.seqno_calls == [] # never touched a message by sequence number
|
||||
|
||||
|
||||
def test_move_missing_uid_fails_safe():
|
||||
conn = _FakeConn(uid_present=False)
|
||||
assert _move_email_message(conn, "5031", "Trash", role="trash") is False
|
||||
assert conn.seqno_calls == [] # no copy/store/expunge on a phantom seqno
|
||||
|
||||
|
||||
def test_store_flag_present_uid_uses_uid_store():
|
||||
conn = _FakeConn(uid_present=True)
|
||||
assert _store_email_flag(conn, "5031", "\\Seen", add=True) is True
|
||||
assert any(c[0] == "STORE" for c in conn.uid_calls)
|
||||
assert conn.seqno_calls == []
|
||||
|
||||
|
||||
def test_move_present_uid_uses_uid_move():
|
||||
conn = _FakeConn(uid_present=True, uid_move_ok=True)
|
||||
assert _move_email_message(conn, "5031", "Archive", role="archive") is True
|
||||
assert any(c[0] == "MOVE" for c in conn.uid_calls)
|
||||
assert conn.seqno_calls == []
|
||||
@@ -0,0 +1,85 @@
|
||||
from email.message import EmailMessage
|
||||
|
||||
from routes.email_routes import (
|
||||
_dedupe_unsubscribe_candidates,
|
||||
_email_unsubscribe_candidate_from_msg,
|
||||
_parse_list_unsubscribe_header,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_list_unsubscribe_mailto_and_url():
|
||||
methods = _parse_list_unsubscribe_header(
|
||||
'<mailto:list@example.com?subject=unsubscribe&body=remove%20me>, '
|
||||
'<https://example.com/unsubscribe/token>'
|
||||
)
|
||||
|
||||
assert methods == [
|
||||
{
|
||||
"kind": "mailto",
|
||||
"target": "list@example.com",
|
||||
"subject": "unsubscribe",
|
||||
"body": "remove me",
|
||||
"executable": True,
|
||||
},
|
||||
{
|
||||
"kind": "url",
|
||||
"target": "https://example.com/unsubscribe/token",
|
||||
"executable": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_unsubscribe_candidate_requires_unsubscribe_header():
|
||||
msg = EmailMessage()
|
||||
msg["From"] = "Shop <deals@example.com>"
|
||||
msg["Subject"] = "Limited time discount"
|
||||
msg["Precedence"] = "bulk"
|
||||
|
||||
assert _email_unsubscribe_candidate_from_msg(msg, "12", "INBOX") is None
|
||||
|
||||
|
||||
def test_unsubscribe_candidate_scores_bulk_newsletter():
|
||||
msg = EmailMessage()
|
||||
msg["From"] = "Shop <deals@example.com>"
|
||||
msg["Subject"] = "Limited time discount"
|
||||
msg["Precedence"] = "bulk"
|
||||
msg["List-Id"] = "Shop Deals <deals.example.com>"
|
||||
msg["List-Unsubscribe"] = "<mailto:unsubscribe@example.com?subject=unsubscribe>"
|
||||
|
||||
candidate = _email_unsubscribe_candidate_from_msg(
|
||||
msg,
|
||||
"12",
|
||||
"INBOX",
|
||||
spam_cached={"spam": True, "reason": "marketing blast"},
|
||||
)
|
||||
|
||||
assert candidate is not None
|
||||
assert candidate["uid"] == "12"
|
||||
assert candidate["can_execute"] is True
|
||||
assert candidate["recommended_method"]["kind"] == "mailto"
|
||||
assert "marketing blast" in candidate["reasons"]
|
||||
|
||||
|
||||
def test_dedupe_unsubscribe_candidates_collapses_same_list():
|
||||
first = EmailMessage()
|
||||
first["From"] = "Shop <deals@example.com>"
|
||||
first["Subject"] = "Sale one"
|
||||
first["List-Id"] = "Shop Deals <deals.example.com>"
|
||||
first["List-Unsubscribe"] = "<mailto:unsubscribe@example.com?subject=unsubscribe>"
|
||||
|
||||
second = EmailMessage()
|
||||
second["From"] = "Shop <deals@example.com>"
|
||||
second["Subject"] = "Sale two"
|
||||
second["List-Id"] = "Shop Deals <deals.example.com>"
|
||||
second["List-Unsubscribe"] = "<mailto:unsubscribe@example.com?subject=unsubscribe>"
|
||||
|
||||
candidates = [
|
||||
_email_unsubscribe_candidate_from_msg(first, "12", "INBOX"),
|
||||
_email_unsubscribe_candidate_from_msg(second, "13", "INBOX"),
|
||||
]
|
||||
|
||||
deduped = _dedupe_unsubscribe_candidates(candidates)
|
||||
|
||||
assert len(deduped) == 1
|
||||
assert deduped[0]["duplicate_count"] == 2
|
||||
assert deduped[0]["duplicate_uids"] == ["12", "13"]
|
||||
@@ -358,7 +358,7 @@ def test_compare_start_rejects_unowned_endpoint_id(monkeypatch):
|
||||
|
||||
|
||||
def test_compare_endpoint_key_lookup_is_owner_scoped():
|
||||
body = Path("routes/compare_routes.py").read_text(encoding="utf-8")
|
||||
body = Path("routes/compare/compare_routes.py").read_text(encoding="utf-8")
|
||||
start_body = body.split("def start_comparison", 1)[1].split("# Store comparison record", 1)[0]
|
||||
helper_body = body.split("def _owned_endpoint_by_url", 1)[1].split("class RecordVoteRequest", 1)[0]
|
||||
id_helper_body = body.split("def _owned_endpoint_by_id", 1)[1].split("class RecordVoteRequest", 1)[0]
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Harden hwfit model-catalog parsing against non-string field values.
|
||||
|
||||
`params_b` and `is_prequantized` read free-form fields straight off the HF
|
||||
catalog JSON. `parameter_count` is normally a string like "7B" and
|
||||
`quantization` a string like "FP8", but a catalog row can carry a non-string
|
||||
(e.g. an integer parameter_count, or a null/number quantization). The code
|
||||
called `pc.strip()` / `q.startswith(...)` directly, so one such row raised
|
||||
AttributeError and aborted the whole ranking pass (params_b/is_prequantized
|
||||
run for every model). Non-strings are now treated as unknown.
|
||||
"""
|
||||
from services.hwfit.models import params_b, is_prequantized
|
||||
|
||||
|
||||
def test_params_b_nonstring_count_does_not_raise():
|
||||
assert params_b({"parameter_count": 7}) == 0.0
|
||||
assert params_b({"parameter_count": ["7B"]}) == 0.0
|
||||
|
||||
|
||||
def test_params_b_valid_count_still_parses():
|
||||
assert params_b({"parameter_count": "7B"}) == 7.0
|
||||
assert params_b({"parameters_raw": 7_000_000_000}) == 7.0
|
||||
|
||||
|
||||
def test_is_prequantized_nonstring_quantization_does_not_raise():
|
||||
assert is_prequantized({"quantization": 8}) is False
|
||||
assert is_prequantized({"name": "plain-model", "quantization": 123}) is False
|
||||
|
||||
|
||||
def test_is_prequantized_still_detects_real_markers():
|
||||
assert is_prequantized({"name": "some-model-awq"}) is True
|
||||
assert is_prequantized({"quantization": "FP8-Mixed"}) is True
|
||||
@@ -1,7 +1,12 @@
|
||||
from services.hwfit.image_models import rank_image_models, IMAGE_MODEL_REGISTRY
|
||||
from services.hwfit import image_models
|
||||
|
||||
rank_image_models = image_models.rank_image_models
|
||||
IMAGE_MODEL_REGISTRY = image_models.IMAGE_MODEL_REGISTRY
|
||||
|
||||
|
||||
def test_rank_image_models_handles_non_dict_system():
|
||||
def test_rank_image_models_handles_non_dict_system(monkeypatch):
|
||||
monkeypatch.setattr(image_models, "_fetch_hf_image_collection_models", lambda: [])
|
||||
monkeypatch.setattr(image_models, "_discover_quant_repos", lambda *a, **k: {})
|
||||
# `system` is the detected-hardware dict; if detection failed and returned
|
||||
# None (or a non-dict), system.get(...) raised AttributeError. Treat a
|
||||
# non-dict system as "unknown hardware" (no GPU) rather than crashing.
|
||||
|
||||
@@ -1,15 +1,142 @@
|
||||
from services.hwfit.image_models import rank_image_models, IMAGE_MODEL_REGISTRY
|
||||
from services.hwfit import image_models
|
||||
|
||||
rank_image_models = image_models.rank_image_models
|
||||
IMAGE_MODEL_REGISTRY = image_models.IMAGE_MODEL_REGISTRY
|
||||
|
||||
SYS = {"gpu_vram_gb": 0, "has_gpu": False}
|
||||
|
||||
|
||||
def test_rank_image_models_handles_non_string_search():
|
||||
def _disable_hf_discovery(monkeypatch):
|
||||
monkeypatch.setattr(image_models, "_fetch_hf_image_collection_models", lambda: [])
|
||||
monkeypatch.setattr(image_models, "_discover_quant_repos", lambda *a, **k: {})
|
||||
|
||||
|
||||
def test_rank_image_models_handles_non_string_search(monkeypatch):
|
||||
_disable_hf_discovery(monkeypatch)
|
||||
# search is a CLI/API filter arg; a non-string made search.lower() raise
|
||||
# AttributeError. A non-string search should behave as "no filter".
|
||||
out = rank_image_models(SYS, search=123)
|
||||
assert len(out) == len(IMAGE_MODEL_REGISTRY)
|
||||
|
||||
|
||||
def test_rank_image_models_string_filter_still_applies():
|
||||
def test_rank_image_models_string_filter_still_applies(monkeypatch):
|
||||
_disable_hf_discovery(monkeypatch)
|
||||
out = rank_image_models(SYS, search="zzzznotarealmodelzzz")
|
||||
assert out == []
|
||||
|
||||
|
||||
def test_rank_image_models_uses_ram_budget_when_gpu_disabled(monkeypatch):
|
||||
model = {
|
||||
"id": "example-org/example-image-model",
|
||||
"name": "Example Image Model",
|
||||
"provider": "example-org",
|
||||
"params_b": 20.0,
|
||||
"vram_bf16": 42.0,
|
||||
"vram_fp8": 22.0,
|
||||
"vram_q4": 14.0,
|
||||
"default_quant": "FP8",
|
||||
"quant_repos": {},
|
||||
"capabilities": ["text-to-image"],
|
||||
"description": "Imported from test fixture.",
|
||||
"quality": 80,
|
||||
"speed": 50,
|
||||
}
|
||||
monkeypatch.setattr(image_models, "_fetch_hf_image_collection_models", lambda: [model])
|
||||
monkeypatch.setattr(image_models, "_discover_quant_repos", lambda *a, **k: {})
|
||||
|
||||
gpu_out = rank_image_models({"has_gpu": True, "gpu_vram_gb": 8, "available_ram_gb": 64}, search="Example Image")
|
||||
ram_out = rank_image_models({"has_gpu": False, "gpu_vram_gb": 0, "available_ram_gb": 64}, search="Example Image")
|
||||
|
||||
gpu_model = next(m for m in gpu_out if m["id"] == "example-org/example-image-model")
|
||||
ram_model = next(m for m in ram_out if m["id"] == "example-org/example-image-model")
|
||||
|
||||
assert gpu_model["fit"] == "no_fit"
|
||||
assert gpu_model["quant"] == "FP8"
|
||||
assert gpu_model["fit_budget"] == "gpu"
|
||||
assert ram_model["fit"] in {"good", "perfect"}
|
||||
assert ram_model["quant"] == "BF16"
|
||||
assert ram_model["fit_budget"] == "ram"
|
||||
|
||||
|
||||
def test_mlx_image_collection_models_only_show_on_apple(monkeypatch):
|
||||
mlx_model = {
|
||||
"id": "mlx-community/example-apple-image-model",
|
||||
"name": "Example Apple Image Model",
|
||||
"provider": "mlx-community",
|
||||
"params_b": 4.0,
|
||||
"vram_bf16": 10.0,
|
||||
"vram_fp8": None,
|
||||
"vram_q4": None,
|
||||
"default_quant": "BF16",
|
||||
"quant_repos": {},
|
||||
"capabilities": ["text-to-image"],
|
||||
"description": "Apple Silicon / MLX only.",
|
||||
"quality": 82,
|
||||
"speed": 88,
|
||||
"mlx_only": True,
|
||||
}
|
||||
monkeypatch.setattr(image_models, "_fetch_hf_image_collection_models", lambda: [mlx_model])
|
||||
monkeypatch.setattr(image_models, "_discover_quant_repos", lambda *a, **k: {})
|
||||
|
||||
cuda = rank_image_models({"has_gpu": True, "gpu_vram_gb": 48, "backend": "cuda"}, search="Example Apple")
|
||||
metal = rank_image_models(
|
||||
{"has_gpu": True, "gpu_vram_gb": 48, "backend": "metal", "unified_memory": True},
|
||||
search="Example Apple",
|
||||
)
|
||||
|
||||
assert cuda == []
|
||||
assert [m["id"] for m in metal] == ["mlx-community/example-apple-image-model"]
|
||||
|
||||
|
||||
def test_apple_image_mode_hides_non_mlx_models(monkeypatch):
|
||||
model = {
|
||||
"id": "example-org/example-image-model",
|
||||
"name": "Example Image Model",
|
||||
"provider": "example-org",
|
||||
"params_b": 4.0,
|
||||
"vram_bf16": 8.0,
|
||||
"vram_fp8": None,
|
||||
"vram_q4": None,
|
||||
"default_quant": "BF16",
|
||||
"quant_repos": {},
|
||||
"capabilities": ["text-to-image"],
|
||||
"description": "Imported from test fixture.",
|
||||
"quality": 80,
|
||||
"speed": 80,
|
||||
}
|
||||
monkeypatch.setattr(image_models, "_fetch_hf_image_collection_models", lambda: [model])
|
||||
monkeypatch.setattr(image_models, "_discover_quant_repos", lambda *a, **k: {})
|
||||
|
||||
metal = rank_image_models(
|
||||
{"has_gpu": True, "gpu_vram_gb": 48, "backend": "metal", "unified_memory": True},
|
||||
search="Example Image",
|
||||
)
|
||||
cuda = rank_image_models(
|
||||
{"has_gpu": True, "gpu_vram_gb": 48, "backend": "cuda"},
|
||||
search="Example Image",
|
||||
)
|
||||
|
||||
assert metal == []
|
||||
assert [m["id"] for m in cuda] == ["example-org/example-image-model"]
|
||||
|
||||
|
||||
def test_mlx_collection_imports_show_on_metal_not_cuda(monkeypatch):
|
||||
mlx_model = image_models._collection_item_to_model(
|
||||
{"id": "mlx-community/example-image-model-4bit"},
|
||||
"Example Apple image collection",
|
||||
mlx_only=True,
|
||||
)
|
||||
monkeypatch.setattr(image_models, "_fetch_hf_image_collection_models", lambda: [mlx_model])
|
||||
monkeypatch.setattr(image_models, "_discover_quant_repos", lambda *a, **k: {})
|
||||
|
||||
metal = rank_image_models(
|
||||
{"has_gpu": True, "gpu_vram_gb": 64, "backend": "metal", "unified_memory": True},
|
||||
search="example-image-model",
|
||||
)
|
||||
cuda = rank_image_models(
|
||||
{"has_gpu": True, "gpu_vram_gb": 64, "backend": "cuda"},
|
||||
search="example-image-model",
|
||||
)
|
||||
|
||||
assert [m["id"] for m in metal] == ["mlx-community/example-image-model-4bit"]
|
||||
assert cuda == []
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Regression: IMAP calls must use uid() not search()/fetch().
|
||||
|
||||
conn.search() / conn.fetch() operate on volatile positional sequence
|
||||
numbers that shift whenever messages are deleted or expunged. The
|
||||
sig-learner and daily-brief actions must use conn.uid("SEARCH", ...)
|
||||
and conn.uid("FETCH", ...) which address messages by their persistent
|
||||
RFC 3501 UID (§2.3.1.1, §6.4.8).
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
class _SpyImap:
|
||||
"""IMAP stub that records uid() calls and raises on search()/fetch()."""
|
||||
|
||||
def __init__(self, uid_list=b"1 2 3"):
|
||||
self._uid_list = uid_list
|
||||
self.uid_calls: list[tuple] = []
|
||||
|
||||
def select(self, *args, **kwargs):
|
||||
return "OK", []
|
||||
|
||||
def uid(self, command, *args):
|
||||
self.uid_calls.append((command,) + args)
|
||||
if command == "SEARCH":
|
||||
return "OK", [self._uid_list]
|
||||
if command == "FETCH":
|
||||
query = args[1] if len(args) > 1 else ""
|
||||
if "HEADER.FIELDS" in query:
|
||||
return "OK", [(None, b"From: Writer <writer@example.com>\r\n"
|
||||
b"Subject: Hello\r\n\r\n")]
|
||||
return "OK", [(None, b"Body text\r\n\r\nRegards,\r\nThe Writer\r\n")]
|
||||
return "OK", []
|
||||
|
||||
def search(self, *args):
|
||||
raise AssertionError("conn.search() called — must use conn.uid('SEARCH', ...) instead")
|
||||
|
||||
def fetch(self, *args):
|
||||
raise AssertionError("conn.fetch() called — must use conn.uid('FETCH', ...) instead")
|
||||
|
||||
def logout(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sig_learner_uses_uid_search(monkeypatch):
|
||||
"""_pull_headers must call conn.uid('SEARCH', ...) not conn.search()."""
|
||||
from routes import email_helpers
|
||||
from src import task_endpoint
|
||||
from src.builtin_actions import action_learn_sender_signatures
|
||||
|
||||
spy = _SpyImap()
|
||||
monkeypatch.setattr(email_helpers, "_imap_connect", lambda *a, **kw: spy)
|
||||
monkeypatch.setattr(task_endpoint, "resolve_task_candidates", lambda *a, **kw: [])
|
||||
|
||||
message, ok = await action_learn_sender_signatures("alice")
|
||||
|
||||
assert ok is False # no LLM candidates — stops before LLM, after IMAP
|
||||
assert any(c[0] == "SEARCH" for c in spy.uid_calls), "uid('SEARCH', ...) was not called"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sig_learner_uses_uid_fetch(monkeypatch):
|
||||
"""_pull_headers must call conn.uid('FETCH', ...) not conn.fetch()."""
|
||||
from routes import email_helpers
|
||||
from src import task_endpoint
|
||||
from src.builtin_actions import action_learn_sender_signatures
|
||||
|
||||
spy = _SpyImap()
|
||||
monkeypatch.setattr(email_helpers, "_imap_connect", lambda *a, **kw: spy)
|
||||
monkeypatch.setattr(task_endpoint, "resolve_task_candidates", lambda *a, **kw: [])
|
||||
|
||||
await action_learn_sender_signatures("alice")
|
||||
|
||||
assert any(c[0] == "FETCH" for c in spy.uid_calls), "uid('FETCH', ...) was not called"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_daily_brief_uses_uid_commands(monkeypatch):
|
||||
"""action_daily_brief email section must use uid() not search()/fetch()."""
|
||||
from core import database
|
||||
from core import auth as _auth_mod
|
||||
from routes import email_helpers
|
||||
from src.builtin_actions import action_daily_brief
|
||||
|
||||
class _Q:
|
||||
def filter(self, *a, **kw): return self
|
||||
def join(self, *a, **kw): return self
|
||||
def order_by(self, *a): return self
|
||||
def all(self): return []
|
||||
|
||||
class _Db:
|
||||
def query(self, *a): return _Q()
|
||||
def close(self): pass
|
||||
|
||||
class _FakeAuth:
|
||||
is_configured = False
|
||||
|
||||
monkeypatch.setattr(database, "SessionLocal", _Db)
|
||||
monkeypatch.setattr(_auth_mod, "AuthManager", lambda: _FakeAuth())
|
||||
|
||||
spy = _SpyImap(uid_list=b"10 20 30")
|
||||
monkeypatch.setattr(email_helpers, "_imap_connect", lambda *a, **kw: spy)
|
||||
|
||||
message, ok = await action_daily_brief("")
|
||||
|
||||
assert ok is True
|
||||
assert any(c[0] == "SEARCH" for c in spy.uid_calls), "uid('SEARCH', ...) was not called"
|
||||
assert any(c[0] == "FETCH" for c in spy.uid_calls), "uid('FETCH', ...) was not called"
|
||||
@@ -1,4 +1,7 @@
|
||||
"""Kimi Code User-Agent fallback list and 403 detection."""
|
||||
import pytest
|
||||
|
||||
from src import llm_core
|
||||
from src.llm_core import (
|
||||
KIMI_CODE_USER_AGENTS,
|
||||
KIMI_CODE_USER_AGENT,
|
||||
@@ -12,6 +15,35 @@ from src.llm_core import (
|
||||
)
|
||||
|
||||
|
||||
KIMI_CHAT_URL = "https://api.kimi.com/coding/v1/chat/completions"
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status, text="{}"):
|
||||
self.status_code = status
|
||||
self.content = text.encode()
|
||||
self.text = text
|
||||
|
||||
|
||||
class _FakeStreamResp(_Resp):
|
||||
async def aiter_lines(self):
|
||||
yield "data: [DONE]"
|
||||
|
||||
async def aread(self):
|
||||
return b""
|
||||
|
||||
|
||||
class _FakeStreamCtx:
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
|
||||
async def __aenter__(self):
|
||||
return self.response
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
return False
|
||||
|
||||
|
||||
class TestKimiCodeUserAgents:
|
||||
def test_default_is_first_fallback(self):
|
||||
assert KIMI_CODE_USER_AGENT == KIMI_CODE_USER_AGENTS[0]
|
||||
@@ -29,9 +61,8 @@ class TestKimiCodeUserAgents:
|
||||
|
||||
def test_ua_candidates_prefers_cache(self):
|
||||
_kimi_code_ua_cache.clear()
|
||||
url = "https://api.kimi.com/coding/v1/chat/completions"
|
||||
_remember_kimi_code_user_agent(url, "Kilo-Code/1.0")
|
||||
candidates = _kimi_code_ua_candidates(url)
|
||||
_remember_kimi_code_user_agent(KIMI_CHAT_URL, "Kilo-Code/1.0")
|
||||
candidates = _kimi_code_ua_candidates(KIMI_CHAT_URL)
|
||||
assert candidates[0] == "Kilo-Code/1.0"
|
||||
assert len(candidates) == len(KIMI_CODE_USER_AGENTS)
|
||||
_kimi_code_ua_cache.clear()
|
||||
@@ -48,22 +79,134 @@ class TestKimiCodeUserAgents:
|
||||
_kimi_code_ua_cache.clear()
|
||||
calls = []
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status, text=""):
|
||||
self.status_code = status
|
||||
self.content = text.encode()
|
||||
self.text = text
|
||||
|
||||
def fake_post(url, headers=None, **kwargs):
|
||||
calls.append(headers.get("User-Agent"))
|
||||
if headers.get("User-Agent") == KIMI_CODE_USER_AGENTS[0]:
|
||||
return _Resp(403, '{"error":{"type":"access_terminated_error"}}')
|
||||
return _Resp(200, "{}")
|
||||
|
||||
monkeypatch.setattr(llm_core.httpx, "get", lambda *a, **k: (_ for _ in ()).throw(RuntimeError()))
|
||||
monkeypatch.setattr("src.llm_core.httpx.post", fake_post)
|
||||
url = "https://api.kimi.com/coding/v1/chat/completions"
|
||||
r = httpx_post_kimi_aware(url, {"Authorization": "Bearer x"}, json={})
|
||||
r = httpx_post_kimi_aware(KIMI_CHAT_URL, {"Authorization": "Bearer x"}, json={})
|
||||
assert r.status_code == 200
|
||||
assert calls[0] == KIMI_CODE_USER_AGENTS[0]
|
||||
assert calls[1] == KIMI_CODE_USER_AGENTS[1]
|
||||
_kimi_code_ua_cache.clear()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_post_uses_async_probe_not_sync_httpx_get(self, monkeypatch):
|
||||
_kimi_code_ua_cache.clear()
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.get_user_agents = []
|
||||
self.post_user_agents = []
|
||||
|
||||
async def get(self, url, headers=None, **kwargs):
|
||||
self.get_user_agents.append(headers.get("User-Agent"))
|
||||
if headers.get("User-Agent") == KIMI_CODE_USER_AGENTS[0]:
|
||||
return _Resp(403, '{"error":{"type":"access_terminated_error"}}')
|
||||
return _Resp(200)
|
||||
|
||||
async def post(self, url, headers=None, **kwargs):
|
||||
self.post_user_agents.append(headers.get("User-Agent"))
|
||||
return _Resp(200)
|
||||
|
||||
def forbidden_sync_get(*args, **kwargs):
|
||||
raise AssertionError("async Kimi path must not call sync httpx.get")
|
||||
|
||||
client = FakeClient()
|
||||
monkeypatch.setattr(llm_core.httpx, "get", forbidden_sync_get)
|
||||
|
||||
r = await llm_core.httpx_post_kimi_aware_async(
|
||||
client,
|
||||
KIMI_CHAT_URL,
|
||||
{"Authorization": "Bearer x"},
|
||||
json={},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert client.get_user_agents == [KIMI_CODE_USER_AGENTS[0], KIMI_CODE_USER_AGENTS[1]]
|
||||
assert client.post_user_agents == [KIMI_CODE_USER_AGENTS[1]]
|
||||
assert _kimi_code_ua_cache[_kimi_code_base_key(KIMI_CHAT_URL)] == KIMI_CODE_USER_AGENTS[1]
|
||||
_kimi_code_ua_cache.clear()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_post_preserves_fallback_when_probe_fails(self, monkeypatch):
|
||||
_kimi_code_ua_cache.clear()
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.post_user_agents = []
|
||||
|
||||
async def get(self, url, headers=None, **kwargs):
|
||||
raise RuntimeError("models probe unavailable")
|
||||
|
||||
async def post(self, url, headers=None, **kwargs):
|
||||
self.post_user_agents.append(headers.get("User-Agent"))
|
||||
if headers.get("User-Agent") == KIMI_CODE_USER_AGENTS[0]:
|
||||
return _Resp(403, '{"error":{"type":"access_terminated_error"}}')
|
||||
return _Resp(200)
|
||||
|
||||
def forbidden_sync_get(*args, **kwargs):
|
||||
raise AssertionError("async Kimi path must not call sync httpx.get")
|
||||
|
||||
client = FakeClient()
|
||||
monkeypatch.setattr(llm_core.httpx, "get", forbidden_sync_get)
|
||||
|
||||
r = await llm_core.httpx_post_kimi_aware_async(
|
||||
client,
|
||||
KIMI_CHAT_URL,
|
||||
{"Authorization": "Bearer x"},
|
||||
json={},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert client.post_user_agents == [KIMI_CODE_USER_AGENTS[0], KIMI_CODE_USER_AGENTS[1]]
|
||||
assert _kimi_code_ua_cache[_kimi_code_base_key(KIMI_CHAT_URL)] == KIMI_CODE_USER_AGENTS[1]
|
||||
_kimi_code_ua_cache.clear()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_uses_async_kimi_probe_not_sync_httpx_get(self, monkeypatch):
|
||||
_kimi_code_ua_cache.clear()
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.get_user_agents = []
|
||||
self.stream_headers = []
|
||||
|
||||
async def get(self, url, headers=None, **kwargs):
|
||||
self.get_user_agents.append(headers.get("User-Agent"))
|
||||
if headers.get("User-Agent") == KIMI_CODE_USER_AGENTS[0]:
|
||||
return _Resp(403, '{"error":{"type":"access_terminated_error"}}')
|
||||
return _Resp(200)
|
||||
|
||||
def stream(self, method, url, **kwargs):
|
||||
self.stream_headers.append(kwargs.get("headers") or {})
|
||||
return _FakeStreamCtx(_FakeStreamResp(200))
|
||||
|
||||
def forbidden_sync_get(*args, **kwargs):
|
||||
raise AssertionError("streaming Kimi path must not call sync httpx.get")
|
||||
|
||||
client = FakeClient()
|
||||
monkeypatch.setattr(llm_core.httpx, "get", forbidden_sync_get)
|
||||
monkeypatch.setattr(llm_core, "_get_http_client", lambda: client)
|
||||
monkeypatch.setattr(llm_core, "_is_host_dead", lambda url: False)
|
||||
monkeypatch.setattr(llm_core, "note_model_activity", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(llm_core, "_clear_host_dead", lambda *args, **kwargs: None)
|
||||
|
||||
chunks = [
|
||||
chunk
|
||||
async for chunk in llm_core.stream_llm(
|
||||
KIMI_CHAT_URL,
|
||||
"kimi-for-coding",
|
||||
[{"role": "user", "content": "hi"}],
|
||||
headers={"Authorization": "Bearer x"},
|
||||
)
|
||||
]
|
||||
|
||||
assert chunks == ["data: [DONE]\n\n"]
|
||||
assert client.get_user_agents == [KIMI_CODE_USER_AGENTS[0], KIMI_CODE_USER_AGENTS[1]]
|
||||
assert client.stream_headers[0]["User-Agent"] == KIMI_CODE_USER_AGENTS[1]
|
||||
assert _kimi_code_ua_cache[_kimi_code_base_key(KIMI_CHAT_URL)] == KIMI_CODE_USER_AGENTS[1]
|
||||
_kimi_code_ua_cache.clear()
|
||||
|
||||
@@ -92,7 +92,7 @@ def test_strips_every_named_email_tool_fence():
|
||||
email_tools = [
|
||||
"list_email_accounts", "send_email", "list_emails", "read_email",
|
||||
"reply_to_email", "bulk_email", "archive_email", "delete_email",
|
||||
"mark_email_read",
|
||||
"mark_email_read", "scan_email_unsubscribes", "unsubscribe_email",
|
||||
]
|
||||
for tool in email_tools:
|
||||
fence = f"```{tool}\n{{}}\n```"
|
||||
|
||||
@@ -8,6 +8,8 @@ works while a different model silently answers).
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from src import llm_core
|
||||
|
||||
|
||||
@@ -55,6 +57,145 @@ def test_no_fallback_event_when_primary_succeeds(monkeypatch):
|
||||
assert not any('"fallback"' in c for c in chunks)
|
||||
|
||||
|
||||
def test_done_only_primary_invokes_fallback(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def per_model(model):
|
||||
calls.append(model)
|
||||
if model == "primary":
|
||||
return ["data: [DONE]\n\n"]
|
||||
return [
|
||||
'data: {"type": "model_actual", "requested_model": "backup", "model": "backup-v2"}\n\n',
|
||||
'data: {"delta": "backup answer"}\n\n',
|
||||
"data: [DONE]\n\n",
|
||||
]
|
||||
|
||||
chunks = _run_fallback(monkeypatch, per_model)
|
||||
assert calls == ["primary", "backup"]
|
||||
assert any('"delta": "backup answer"' in c for c in chunks)
|
||||
model_idx = next(i for i, c in enumerate(chunks) if '"model_actual"' in c)
|
||||
fallback_idx = next(i for i, c in enumerate(chunks) if '"fallback"' in c)
|
||||
answer_idx = next(i for i, c in enumerate(chunks) if '"delta": "backup answer"' in c)
|
||||
assert fallback_idx < model_idx < answer_idx
|
||||
|
||||
|
||||
def test_usage_then_done_primary_invokes_fallback_and_discards_usage(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def per_model(model):
|
||||
calls.append(model)
|
||||
if model == "primary":
|
||||
return [
|
||||
'data: {"type": "usage", "data": {"input_tokens": 4, "output_tokens": 0}}\n\n',
|
||||
"data: [DONE]\n\n",
|
||||
]
|
||||
return ['data: {"delta": "backup answer"}\n\n', "data: [DONE]\n\n"]
|
||||
|
||||
chunks = _run_fallback(monkeypatch, per_model)
|
||||
assert calls == ["primary", "backup"]
|
||||
assert not any('"type": "usage"' in c for c in chunks)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"output_chunk",
|
||||
[
|
||||
'data: {"delta": "visible text"}\n\n',
|
||||
'data: {"delta": "reasoning", "thinking": true}\n\n',
|
||||
],
|
||||
)
|
||||
def test_text_or_reasoning_output_prevents_fallback(monkeypatch, output_chunk):
|
||||
calls = []
|
||||
|
||||
def per_model(model):
|
||||
calls.append(model)
|
||||
return [output_chunk, "data: [DONE]\n\n"]
|
||||
|
||||
chunks = _run_fallback(monkeypatch, per_model)
|
||||
assert calls == ["primary"]
|
||||
assert output_chunk in chunks
|
||||
assert not any('"fallback"' in c for c in chunks)
|
||||
|
||||
|
||||
def test_whitespace_only_delta_prevents_fallback(monkeypatch):
|
||||
calls = []
|
||||
whitespace = 'data: {"delta": " "}\n\n'
|
||||
|
||||
def per_model(model):
|
||||
calls.append(model)
|
||||
return [whitespace, "data: [DONE]\n\n"]
|
||||
|
||||
chunks = _run_fallback(monkeypatch, per_model)
|
||||
assert calls == ["primary"]
|
||||
assert whitespace in chunks
|
||||
assert not any('"fallback"' in c for c in chunks)
|
||||
|
||||
|
||||
def test_completed_tool_call_output_prevents_fallback(monkeypatch):
|
||||
calls = []
|
||||
tool_calls = 'data: {"type": "tool_calls", "calls": [{"id": "c1", "name": "bash", "arguments": "{}"}]}\n\n'
|
||||
|
||||
def per_model(model):
|
||||
calls.append(model)
|
||||
return [tool_calls, "data: [DONE]\n\n"]
|
||||
|
||||
chunks = _run_fallback(monkeypatch, per_model)
|
||||
assert calls == ["primary"]
|
||||
assert tool_calls in chunks
|
||||
assert not any('"fallback"' in c for c in chunks)
|
||||
|
||||
|
||||
def test_tool_call_delta_is_forwarded_immediately_and_prevents_fallback(monkeypatch):
|
||||
calls = []
|
||||
advanced_past_delta = False
|
||||
tool_delta = 'data: {"type": "tool_call_delta", "index": 0, "arg_delta": "{\\"path\\":"}\n\n'
|
||||
tool_calls = 'data: {"type": "tool_calls", "calls": [{"id": "c1", "name": "write_file", "arguments": "{\\"path\\":\\"x\\"}"}]}\n\n'
|
||||
|
||||
async def fake_stream(url, model, messages, **kw):
|
||||
nonlocal advanced_past_delta
|
||||
calls.append(model)
|
||||
yield tool_delta
|
||||
advanced_past_delta = True
|
||||
yield tool_calls
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
monkeypatch.setattr(llm_core, "stream_llm", fake_stream)
|
||||
|
||||
async def run():
|
||||
stream = llm_core.stream_llm_with_fallback(
|
||||
[("u1", "primary", {}), ("u2", "backup", {})],
|
||||
[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
first = await anext(stream)
|
||||
assert first == tool_delta
|
||||
assert not advanced_past_delta
|
||||
chunks = [first]
|
||||
async for chunk in stream:
|
||||
chunks.append(chunk)
|
||||
return chunks
|
||||
|
||||
chunks = asyncio.run(run())
|
||||
assert calls == ["primary"]
|
||||
assert tool_calls in chunks
|
||||
assert not any('"type": "fallback"' in c for c in chunks)
|
||||
|
||||
|
||||
def test_empty_final_candidate_surfaces_terminal_error(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def per_model(model):
|
||||
calls.append(model)
|
||||
if model == "primary":
|
||||
return [] # clean EOF without substantive output
|
||||
return ["data: [DONE]\n\n"]
|
||||
|
||||
chunks = _run_fallback(monkeypatch, per_model)
|
||||
assert calls == ["primary", "backup"]
|
||||
errors = [c for c in chunks if c.startswith("event: error")]
|
||||
assert len(errors) == 1
|
||||
assert "All model candidates returned no substantive output" in errors[0]
|
||||
assert '"status": 502' in errors[0]
|
||||
|
||||
|
||||
def test_dedupe_candidates_keeps_first_of_each_route():
|
||||
"""(url, model) is the route key; later repeats are dropped, order preserved,
|
||||
the first tuple (with its headers) kept, malformed entries filtered."""
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from src import llm_core
|
||||
|
||||
|
||||
def _tool():
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search",
|
||||
"description": "search",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_openai_chat_tools_force_gpt5_reasoning_effort_none():
|
||||
payload = {"tools": [_tool()], "reasoning_effort": "high"}
|
||||
|
||||
llm_core._scrub_openai_chat_tool_reasoning(
|
||||
payload,
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
"gpt-5.6-luna",
|
||||
)
|
||||
|
||||
assert payload["reasoning_effort"] == "none"
|
||||
|
||||
|
||||
def test_openai_chat_tools_match_gpt5_variants():
|
||||
for model in ["gpt-5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "openai/gpt-5.6-luna"]:
|
||||
payload = {"tools": [_tool()], "reasoning_effort": "medium"}
|
||||
|
||||
llm_core._scrub_openai_chat_tool_reasoning(
|
||||
payload,
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
model,
|
||||
)
|
||||
|
||||
assert payload["reasoning_effort"] == "none"
|
||||
|
||||
|
||||
def test_openai_chat_no_tools_leaves_reasoning_effort_unchanged():
|
||||
payload = {"reasoning_effort": "high"}
|
||||
|
||||
llm_core._scrub_openai_chat_tool_reasoning(
|
||||
payload,
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
"gpt-5.6-luna",
|
||||
)
|
||||
|
||||
assert payload["reasoning_effort"] == "high"
|
||||
|
||||
|
||||
def test_non_openai_host_leaves_reasoning_effort_unchanged():
|
||||
payload = {"tools": [_tool()], "reasoning_effort": "high"}
|
||||
|
||||
llm_core._scrub_openai_chat_tool_reasoning(
|
||||
payload,
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
"openai/gpt-5.6-luna",
|
||||
)
|
||||
|
||||
assert payload["reasoning_effort"] == "high"
|
||||
|
||||
|
||||
def test_non_gpt5_model_leaves_reasoning_effort_unchanged():
|
||||
payload = {"tools": [_tool()], "reasoning_effort": "high"}
|
||||
|
||||
llm_core._scrub_openai_chat_tool_reasoning(
|
||||
payload,
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
"gpt-4.1",
|
||||
)
|
||||
|
||||
assert payload["reasoning_effort"] == "high"
|
||||
@@ -91,14 +91,14 @@ def test_local_minimax_mlx_payload_gets_stability_defaults(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(model_context, "is_local_endpoint", lambda _url: True)
|
||||
payload = {
|
||||
"model": "cookietimeh/MiniMax-M2.7-BF16-ultra-uncensored-heretic-mlx-4Bit",
|
||||
"model": "example-org/MiniMax-M2.7-BF16-mlx-4Bit",
|
||||
"temperature": 0.9,
|
||||
}
|
||||
|
||||
llm_core._apply_local_generation_stability(
|
||||
payload,
|
||||
"http://192.168.1.22:8091/v1/chat/completions",
|
||||
"cookietimeh/MiniMax-M2.7-BF16-ultra-uncensored-heretic-mlx-4Bit",
|
||||
"example-org/MiniMax-M2.7-BF16-mlx-4Bit",
|
||||
)
|
||||
|
||||
assert payload["temperature"] == 0.2
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""manage_tasks mutations must fail closed on owner-less / cross-owner tasks.
|
||||
|
||||
The edit/delete/pause/run actions of ``do_manage_tasks`` previously gated with
|
||||
``if owner and task.owner and task.owner != owner``. The middle term made the
|
||||
check a no-op whenever the task had no owner — the state a scheduled task is in
|
||||
when it was created in no-login mode (or via the localhost middleware bypass)
|
||||
before the periodic legacy-owner sweep reassigns it to the admin user. So any
|
||||
authenticated user's agent could edit, delete, pause, or *run* another tenant's
|
||||
owner-less task. The sibling ``list`` action already scopes with an exact
|
||||
``ScheduledTask.owner == owner`` filter, so the mutators were strictly more
|
||||
permissive than the reader.
|
||||
"""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
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.database as cdb
|
||||
from core.database import ScheduledTask
|
||||
from src.tools.system import do_manage_tasks
|
||||
|
||||
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_ENGINE = create_engine(
|
||||
f"sqlite:///{_TMPDB.name}",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
cdb.Base.metadata.create_all(_ENGINE)
|
||||
_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False)
|
||||
# do_manage_tasks does `from core.database import SessionLocal` at call time,
|
||||
# so patching the module attribute is enough to point it at the temp DB.
|
||||
cdb.SessionLocal = _TS
|
||||
|
||||
|
||||
def _seed(task_id, owner):
|
||||
db = _TS()
|
||||
try:
|
||||
db.add(ScheduledTask(
|
||||
id=task_id, owner=owner, name=task_id, prompt="original",
|
||||
task_type="llm", trigger_type="webhook", status="active",
|
||||
output_target="session",
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _get(task_id):
|
||||
db = _TS()
|
||||
try:
|
||||
return db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_denied_on_ownerless_task_for_authenticated_user():
|
||||
_seed("ownerless-edit", None)
|
||||
out = await do_manage_tasks(
|
||||
json.dumps({"action": "edit", "task_id": "ownerless-edit", "prompt": "pwned"}),
|
||||
owner="alice",
|
||||
)
|
||||
assert out["exit_code"] == 1 and out["error"] == "Access denied"
|
||||
assert _get("ownerless-edit").prompt == "original"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_denied_on_ownerless_task_for_authenticated_user():
|
||||
_seed("ownerless-del", None)
|
||||
out = await do_manage_tasks(
|
||||
json.dumps({"action": "delete", "task_id": "ownerless-del"}),
|
||||
owner="alice",
|
||||
)
|
||||
assert out["exit_code"] == 1 and out["error"] == "Access denied"
|
||||
assert _get("ownerless-del") is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_denied_on_ownerless_task_for_authenticated_user():
|
||||
_seed("ownerless-pause", None)
|
||||
out = await do_manage_tasks(
|
||||
json.dumps({"action": "pause", "task_id": "ownerless-pause"}),
|
||||
owner="alice",
|
||||
)
|
||||
assert out["exit_code"] == 1 and out["error"] == "Access denied"
|
||||
assert _get("ownerless-pause").status == "active"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_denied_on_ownerless_task_for_authenticated_user():
|
||||
_seed("ownerless-run", None)
|
||||
out = await do_manage_tasks(
|
||||
json.dumps({"action": "run", "task_id": "ownerless-run"}),
|
||||
owner="alice",
|
||||
)
|
||||
assert out["exit_code"] == 1 and out["error"] == "Access denied"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_denied_on_other_owners_task():
|
||||
_seed("bob-task", "bob")
|
||||
out = await do_manage_tasks(
|
||||
json.dumps({"action": "edit", "task_id": "bob-task", "prompt": "pwned"}),
|
||||
owner="alice",
|
||||
)
|
||||
assert out["exit_code"] == 1 and out["error"] == "Access denied"
|
||||
assert _get("bob-task").prompt == "original"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_allowed_for_matching_owner():
|
||||
_seed("alice-task", "alice")
|
||||
out = await do_manage_tasks(
|
||||
json.dumps({"action": "edit", "task_id": "alice-task", "prompt": "updated"}),
|
||||
owner="alice",
|
||||
)
|
||||
assert out["exit_code"] == 0
|
||||
assert _get("alice-task").prompt == "updated"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_allowed_in_no_login_mode():
|
||||
# owner is None when auth is disabled — single-user mode keeps full access
|
||||
# to shared (owner-less) tasks, exactly as `list` returns them unfiltered.
|
||||
_seed("shared-task", None)
|
||||
out = await do_manage_tasks(
|
||||
json.dumps({"action": "edit", "task_id": "shared-task", "prompt": "updated"}),
|
||||
owner=None,
|
||||
)
|
||||
assert out["exit_code"] == 0
|
||||
assert _get("shared-task").prompt == "updated"
|
||||
@@ -18,12 +18,24 @@ def node_available():
|
||||
pytest.skip("node binary not on PATH")
|
||||
|
||||
|
||||
def _run_markdown_case(markdown: str, render_expr: str = "mod.mdToHtml(input)"):
|
||||
def _run_markdown_case(markdown: str, render_expr: str = "mod.mdToHtml(input)", with_katex: bool = False):
|
||||
script = textwrap.dedent(
|
||||
r"""
|
||||
import fs from 'node:fs';
|
||||
|
||||
globalThis.window = { location: { origin: 'http://localhost' }, katex: null };
|
||||
if (__WITH_KATEX__) {
|
||||
// Minimal stand-in for the CDN katex global: wraps the source so tests
|
||||
// can assert what was (or wasn't) handed to KaTeX.
|
||||
const katexStub = {
|
||||
renderToString(src, opts) {
|
||||
const display = !!(opts && opts.displayMode);
|
||||
return `<span class="katex" data-display="${display}">${src}</span>`;
|
||||
},
|
||||
};
|
||||
globalThis.window.katex = katexStub;
|
||||
globalThis.katex = katexStub;
|
||||
}
|
||||
globalThis.document = {
|
||||
readyState: 'loading',
|
||||
addEventListener() {},
|
||||
@@ -77,7 +89,9 @@ def _run_markdown_case(markdown: str, render_expr: str = "mod.mdToHtml(input)"):
|
||||
const input = JSON.parse(process.argv[1]);
|
||||
console.log(JSON.stringify({ html: __RENDER_EXPR__ }));
|
||||
"""
|
||||
).replace("__RENDER_EXPR__", render_expr)
|
||||
).replace("__RENDER_EXPR__", render_expr).replace(
|
||||
"__WITH_KATEX__", "true" if with_katex else "false"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module", "-e", script, json.dumps(markdown)],
|
||||
cwd=_REPO,
|
||||
@@ -200,6 +214,33 @@ def test_inline_code_content_is_html_escaped(node_available):
|
||||
assert "<b>" not in html
|
||||
|
||||
|
||||
def test_currency_dollar_amounts_are_not_rendered_as_math(node_available):
|
||||
# "$5 to $10" used to pair the two dollar signs as inline-math delimiters
|
||||
# and render "5 to" through KaTeX. Pandoc-style rules now reject it: the
|
||||
# closing $ is preceded by a space and followed by a digit.
|
||||
html = _run_markdown_case(
|
||||
"The price rose from $5 to $10 overnight.", with_katex=True
|
||||
)
|
||||
|
||||
assert 'class="katex"' not in html
|
||||
assert "$5" in html
|
||||
assert "$10" in html
|
||||
|
||||
|
||||
def test_inline_math_still_renders_through_katex(node_available):
|
||||
html = _run_markdown_case("Pythagoras: $x^2 + y^2 = z^2$ holds.", with_katex=True)
|
||||
|
||||
assert '<span class="katex" data-display="false">x^2 + y^2 = z^2</span>' in html
|
||||
assert "$" not in html
|
||||
|
||||
|
||||
def test_display_math_still_renders_through_katex(node_available):
|
||||
html = _run_markdown_case("$$\\frac{a}{b}$$", with_katex=True)
|
||||
|
||||
assert 'data-display="true"' in html
|
||||
assert "$$" not in html
|
||||
|
||||
|
||||
def test_dotted_python_import_paths_are_not_autolinked(node_available):
|
||||
html = _run_markdown_case(
|
||||
"from imblearn.combine import SMOTETomek\n"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Pin _matchesCombo (static/js/keyboard-shortcuts.js) against a non-string
|
||||
keybind. Driven through `node --input-type=module` (same approach as
|
||||
tests/test_markdown_table_row_js.py); skips when `node` is missing.
|
||||
|
||||
Regression: keybinds are merged from the server response of
|
||||
`/api/auth/settings` (`{ ..._defaultKeybinds, ...s.keybinds }`). A corrupt
|
||||
or malformed `keybinds` value (e.g. a number instead of "ctrl+k") reached
|
||||
`combo.split('+')` and threw "combo.split is not a function", breaking the
|
||||
whole keydown handler. The guard treats any non-string combo as "no match".
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_REPO = Path(__file__).resolve().parent.parent
|
||||
_MOD = _REPO / "static" / "js" / "keyboard-shortcuts.js"
|
||||
_HAS_NODE = shutil.which("node") is not None
|
||||
|
||||
_EVENT = "{key:'k',ctrlKey:false,altKey:false,shiftKey:false,metaKey:false}"
|
||||
|
||||
|
||||
def _match(combo_js):
|
||||
js = f"""
|
||||
import {{ _matchesCombo }} from '{_MOD.as_posix()}';
|
||||
console.log(JSON.stringify(_matchesCombo({_EVENT}, {combo_js})));
|
||||
"""
|
||||
proc = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=js, capture_output=True, text=True, cwd=str(_REPO), timeout=30,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout.strip())
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_non_string_combo_is_no_match():
|
||||
assert _match("123") is False
|
||||
assert _match("{}") is False
|
||||
assert _match("null") is False
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_matching_combo_still_fires():
|
||||
assert _match("'k'") is True
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
from src import mcp_oauth
|
||||
|
||||
|
||||
@@ -79,3 +80,53 @@ def test_db_token_storage_round_trip():
|
||||
t = asyncio.run(go())
|
||||
assert t.access_token == "abc"
|
||||
assert srv.oauth_tokens is not None # persisted as JSON
|
||||
|
||||
|
||||
def _fake_storage(oauth_tokens):
|
||||
class FakeSrv:
|
||||
pass
|
||||
|
||||
srv = FakeSrv()
|
||||
srv.oauth_tokens = oauth_tokens
|
||||
|
||||
class FakeQuery:
|
||||
def filter(self, *a):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return srv
|
||||
|
||||
class FakeSession:
|
||||
def query(self, *a):
|
||||
return FakeQuery()
|
||||
|
||||
def commit(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
return srv, mcp_oauth.DbTokenStorage("srv-1", session_factory=lambda: FakeSession())
|
||||
|
||||
|
||||
def test_load_falls_back_to_empty_dict_for_non_dict_json():
|
||||
# A corrupted/migrated oauth_tokens column holding a JSON array, not an
|
||||
# object, must not crash _load()'s callers with AttributeError.
|
||||
_srv, storage = _fake_storage('["stale", "data"]')
|
||||
assert storage._load() == {}
|
||||
|
||||
|
||||
def test_get_tokens_returns_none_for_non_dict_oauth_tokens():
|
||||
_srv, storage = _fake_storage("42")
|
||||
|
||||
async def go():
|
||||
return await storage.get_tokens()
|
||||
|
||||
assert asyncio.run(go()) is None
|
||||
|
||||
|
||||
def test_update_recovers_from_non_dict_oauth_tokens():
|
||||
# _update() must not raise TypeError trying to item-assign into a list.
|
||||
srv, storage = _fake_storage('["stale", "data"]')
|
||||
storage._update("tokens", {"access_token": "new"})
|
||||
assert json.loads(srv.oauth_tokens) == {"tokens": {"access_token": "new"}}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""cmd_add (scripts/odysseus-memory) must tolerate a non-dict row in the
|
||||
existing store. Every other command funnels load_all() through
|
||||
`_memory_entries()` (which drops non-dicts), but cmd_add iterated the raw
|
||||
list in its dedup check: `any(e.get("id") == ... for e in all_entries)`
|
||||
crashed with AttributeError on a corrupt/hand-edited memory.json row that
|
||||
is not a dict. The isinstance check short-circuits before `.get`.
|
||||
"""
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _load_cli(monkeypatch):
|
||||
svc = types.ModuleType("services.memory.memory")
|
||||
svc.MemoryManager = MagicMock()
|
||||
monkeypatch.setitem(sys.modules, "services.memory.memory", svc)
|
||||
path = ROOT / "scripts" / "odysseus-memory"
|
||||
loader = importlib.machinery.SourceFileLoader("odysseus_memory_cli_add", str(path))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_cmd_add_tolerates_non_dict_existing_row(monkeypatch):
|
||||
cli = _load_cli(monkeypatch)
|
||||
cli._mgr = MagicMock()
|
||||
cli._mgr.add_entry.return_value = {"id": "m2", "text": "new"}
|
||||
cli._mgr.load_all.return_value = [
|
||||
{"id": "m1", "text": "existing"},
|
||||
"corrupt-row",
|
||||
None,
|
||||
]
|
||||
emitted = []
|
||||
monkeypatch.setattr(cli, "emit", lambda value, args: emitted.append(value))
|
||||
|
||||
cli.cmd_add(SimpleNamespace(text="new", category="fact", owner=None))
|
||||
|
||||
assert emitted == [{"id": "m2", "text": "new"}]
|
||||
cli._mgr.save.assert_called_once()
|
||||
@@ -0,0 +1,248 @@
|
||||
import src.model_capabilities as mc
|
||||
|
||||
|
||||
def surfaces(capability):
|
||||
return set(mc.display_surfaces_for(capability))
|
||||
|
||||
|
||||
def test_endpoint_type_llm_maps_to_explicit_chat_capability():
|
||||
capability = mc.capability_from_endpoint_type("llm")
|
||||
|
||||
assert capability.family == mc.FAMILY_CHAT
|
||||
assert capability.primary_task == mc.TASK_CHAT_COMPLETIONS
|
||||
assert capability.modalities.input == (mc.MODALITY_TEXT,)
|
||||
assert capability.modalities.output == (mc.MODALITY_TEXT,)
|
||||
assert capability.source == mc.SOURCE_ENDPOINT_CONFIG
|
||||
assert capability.confidence == mc.CONFIDENCE_EXPLICIT
|
||||
assert surfaces(capability) == {"chat"}
|
||||
|
||||
|
||||
def test_endpoint_type_image_maps_to_explicit_image_generation_capability():
|
||||
capability = mc.capability_from_endpoint_type("image")
|
||||
|
||||
assert capability.family == mc.FAMILY_IMAGE
|
||||
assert capability.primary_task == mc.TASK_IMAGE_GENERATE
|
||||
assert capability.modalities.output == (mc.MODALITY_IMAGE,)
|
||||
assert capability.capabilities == (mc.CAP_IMAGE_GENERATION,)
|
||||
assert capability.source == mc.SOURCE_ENDPOINT_CONFIG
|
||||
assert capability.confidence == mc.CONFIDENCE_EXPLICIT
|
||||
assert surfaces(capability) == {"image_generation"}
|
||||
|
||||
|
||||
def test_missing_or_unknown_endpoint_type_does_not_imply_chat():
|
||||
for model_type in (None, "", "openai-compatible", "text"):
|
||||
capability = mc.capability_from_endpoint_type(model_type)
|
||||
|
||||
assert capability.family == mc.FAMILY_UNKNOWN
|
||||
assert capability.primary_task == mc.TASK_UNKNOWN
|
||||
assert capability.source == mc.SOURCE_ENDPOINT_CONFIG
|
||||
assert mc.display_surfaces_for(capability) == ()
|
||||
|
||||
|
||||
def test_provider_record_normalizes_aliases_and_boolean_capability_maps():
|
||||
capability = mc.ModelCapability.from_dict(
|
||||
{
|
||||
"family": "llm",
|
||||
"modalities": {
|
||||
"input": ["text", "images", "docs", "images"],
|
||||
"output": "text",
|
||||
},
|
||||
"capabilities": {
|
||||
"tools": True,
|
||||
"unknown_vendor_flag": True,
|
||||
"vision": True,
|
||||
"tts": False,
|
||||
},
|
||||
"limits": {"max_context_tokens": 32768, "": "ignored"},
|
||||
"source": "provider_reader",
|
||||
"confidence": "provider_reported",
|
||||
}
|
||||
)
|
||||
|
||||
assert capability.family == mc.FAMILY_CHAT
|
||||
assert capability.modalities.input == (mc.MODALITY_TEXT, mc.MODALITY_IMAGE, mc.MODALITY_FILE)
|
||||
assert capability.modalities.output == (mc.MODALITY_TEXT,)
|
||||
assert capability.capabilities == (mc.CAP_TOOL_CALL, mc.CAP_VISION)
|
||||
assert capability.limits == (("max_context_tokens", 32768),)
|
||||
assert surfaces(capability) == {"chat", "vision_chat", "document_chat"}
|
||||
assert capability.to_dict() == {
|
||||
"family": mc.FAMILY_CHAT,
|
||||
"primary_task": mc.TASK_CHAT_COMPLETIONS,
|
||||
"modalities": {
|
||||
"input": [mc.MODALITY_TEXT, mc.MODALITY_IMAGE, mc.MODALITY_FILE],
|
||||
"output": [mc.MODALITY_TEXT],
|
||||
},
|
||||
"capabilities": [mc.CAP_TOOL_CALL, mc.CAP_VISION],
|
||||
"limits": {"max_context_tokens": 32768},
|
||||
"source": mc.SOURCE_PROVIDER_READER,
|
||||
"confidence": mc.CONFIDENCE_PROVIDER_REPORTED,
|
||||
}
|
||||
|
||||
|
||||
def test_unknown_or_malformed_capability_record_stays_unknown():
|
||||
assert mc.ModelCapability.from_dict(None).to_dict() == mc.unknown_capability().to_dict()
|
||||
|
||||
capability = mc.ModelCapability.build(
|
||||
family="not-real",
|
||||
primary_task=1234,
|
||||
input_modalities=object(),
|
||||
output_modalities=["text", "not-real"],
|
||||
capabilities=["vision", "not-real"],
|
||||
source="not-real",
|
||||
confidence="not-real",
|
||||
)
|
||||
|
||||
assert capability.family == mc.FAMILY_UNKNOWN
|
||||
assert capability.primary_task == "1234"
|
||||
assert capability.modalities.input == ()
|
||||
assert capability.modalities.output == (mc.MODALITY_TEXT,)
|
||||
assert capability.capabilities == (mc.CAP_VISION,)
|
||||
assert capability.source == mc.SOURCE_UNKNOWN
|
||||
assert capability.confidence == mc.CONFIDENCE_UNKNOWN
|
||||
assert mc.display_surfaces_for(capability) == ()
|
||||
|
||||
|
||||
def test_display_surface_queries_cover_core_model_categories():
|
||||
assert surfaces(
|
||||
mc.ModelCapability.build(
|
||||
family=mc.FAMILY_IMAGE,
|
||||
input_modalities=[mc.MODALITY_IMAGE],
|
||||
output_modalities=[mc.MODALITY_IMAGE],
|
||||
capabilities=[mc.CAP_INPAINTING],
|
||||
)
|
||||
) == {"image_editing"}
|
||||
|
||||
assert surfaces(mc.ModelCapability.build(family=mc.FAMILY_EMBEDDING)) == {"embeddings"}
|
||||
assert surfaces(mc.ModelCapability.build(family=mc.FAMILY_RERANK)) == {"rerank_scoring"}
|
||||
assert surfaces(mc.ModelCapability.build(family=mc.FAMILY_MODERATION)) == {"moderation_classification"}
|
||||
assert surfaces(mc.ModelCapability.build(family=mc.FAMILY_CLASSIFICATION)) == {"moderation_classification"}
|
||||
|
||||
|
||||
def test_audio_surface_matches_audio_input_or_output_when_capability_is_known():
|
||||
transcription = mc.ModelCapability.build(
|
||||
family=mc.FAMILY_AUDIO,
|
||||
primary_task=mc.TASK_AUDIO_TRANSCRIBE,
|
||||
input_modalities=[mc.MODALITY_AUDIO],
|
||||
output_modalities=[mc.MODALITY_TEXT],
|
||||
capabilities=[mc.CAP_TRANSCRIPTION],
|
||||
)
|
||||
synthesis = mc.ModelCapability.build(
|
||||
family=mc.FAMILY_AUDIO,
|
||||
primary_task=mc.TASK_AUDIO_SYNTHESIZE,
|
||||
input_modalities=[mc.MODALITY_TEXT],
|
||||
output_modalities=[mc.MODALITY_AUDIO],
|
||||
capabilities=[mc.CAP_TTS],
|
||||
)
|
||||
|
||||
assert surfaces(transcription) == {"audio_realtime"}
|
||||
assert surfaces(synthesis) == {"audio_realtime"}
|
||||
|
||||
|
||||
def test_capability_assertion_tracks_claimed_status_separately_from_capability_metadata():
|
||||
assertion = mc.CapabilityAssertion.build(
|
||||
capability="tools",
|
||||
status="claimed",
|
||||
source="provider_reader",
|
||||
confidence="provider_reported",
|
||||
evidence={"field": "supported_parameters"},
|
||||
)
|
||||
|
||||
assert assertion.capability == mc.CAP_TOOL_CALL
|
||||
assert assertion.status == mc.ASSERTION_CLAIMED
|
||||
assert assertion.source == mc.SOURCE_PROVIDER_READER
|
||||
assert assertion.confidence == mc.CONFIDENCE_PROVIDER_REPORTED
|
||||
assert assertion.to_dict() == {
|
||||
"capability": mc.CAP_TOOL_CALL,
|
||||
"status": mc.ASSERTION_CLAIMED,
|
||||
"source": mc.SOURCE_PROVIDER_READER,
|
||||
"confidence": mc.CONFIDENCE_PROVIDER_REPORTED,
|
||||
"evidence": {"field": "supported_parameters"},
|
||||
"tested_at": "",
|
||||
}
|
||||
|
||||
|
||||
def test_capability_probe_result_converts_pass_and_fail_to_assertions():
|
||||
passed = mc.CapabilityProbeResult.build(
|
||||
provider="openrouter",
|
||||
endpoint_id="ep-1",
|
||||
model_id="vendor/model",
|
||||
stable_model_id="openrouter|endpoint:ep-1|vendor/model",
|
||||
capability="tool_calls",
|
||||
status="pass",
|
||||
tested_at="2026-06-04T20:00:00Z",
|
||||
request_hash="abc123",
|
||||
response_fingerprint="fp-test",
|
||||
evidence={"contract": "single_fake_tool"},
|
||||
)
|
||||
failed = mc.CapabilityProbeResult.build(
|
||||
provider="openrouter",
|
||||
model_id="vendor/model",
|
||||
capability="vision",
|
||||
status="fail",
|
||||
)
|
||||
|
||||
pass_assertion = passed.to_assertion()
|
||||
fail_assertion = failed.to_assertion()
|
||||
|
||||
assert pass_assertion.capability == mc.CAP_TOOL_CALL
|
||||
assert pass_assertion.status == mc.ASSERTION_VERIFIED
|
||||
assert pass_assertion.source == mc.SOURCE_CAPABILITY_PROBE
|
||||
assert pass_assertion.confidence == mc.CONFIDENCE_EXPLICIT
|
||||
assert pass_assertion.tested_at == "2026-06-04T20:00:00Z"
|
||||
assert dict(pass_assertion.evidence)["request_hash"] == "abc123"
|
||||
assert dict(pass_assertion.evidence)["contract"] == "single_fake_tool"
|
||||
|
||||
assert fail_assertion.capability == mc.CAP_VISION
|
||||
assert fail_assertion.status == mc.ASSERTION_UNSUPPORTED
|
||||
assert fail_assertion.source == mc.SOURCE_CAPABILITY_PROBE
|
||||
|
||||
|
||||
def test_deterministic_controls_are_normalized_as_claims_not_capabilities():
|
||||
controls = mc.deterministic_controls_from_values(
|
||||
["temp", "top-p", "top-k", "seed", "unknown"],
|
||||
source=mc.SOURCE_PROVIDER_READER,
|
||||
)
|
||||
|
||||
assert [control.control for control in controls] == [
|
||||
mc.CONTROL_TEMPERATURE,
|
||||
mc.CONTROL_TOP_P,
|
||||
mc.CONTROL_TOP_K,
|
||||
mc.CONTROL_SEED,
|
||||
]
|
||||
assert {control.status for control in controls} == {mc.ASSERTION_CLAIMED}
|
||||
assert {control.source for control in controls} == {mc.SOURCE_PROVIDER_READER}
|
||||
|
||||
|
||||
def test_reasoning_control_mechanisms_normalize_known_provider_shapes():
|
||||
values = [
|
||||
"think_directive",
|
||||
"system_prompt_directive",
|
||||
"enable_thinking",
|
||||
"think_bool",
|
||||
"reasoning_object",
|
||||
"thinking_budget",
|
||||
"reasoning_effort",
|
||||
]
|
||||
|
||||
assert [mc.normalize_reasoning_control_mechanism(value) for value in values] == [
|
||||
mc.REASONING_CONTROL_MESSAGE_DIRECTIVE,
|
||||
mc.REASONING_CONTROL_SYSTEM_DIRECTIVE,
|
||||
mc.REASONING_CONTROL_TEMPLATE_KWARG,
|
||||
mc.REASONING_CONTROL_NATIVE_BOOL,
|
||||
mc.REASONING_CONTROL_STRUCTURED_OBJECT,
|
||||
mc.REASONING_CONTROL_BUDGET,
|
||||
mc.REASONING_CONTROL_EFFORT,
|
||||
]
|
||||
|
||||
|
||||
def test_reasoning_control_values_can_describe_provider_supported_auto():
|
||||
values = ["enabled", "disabled", "adaptive", "dynamic", "provider_auto"]
|
||||
|
||||
assert [mc.normalize_reasoning_control_value(value) for value in values] == [
|
||||
mc.REASONING_CONTROL_VALUE_ON,
|
||||
mc.REASONING_CONTROL_VALUE_OFF,
|
||||
mc.REASONING_CONTROL_VALUE_AUTO,
|
||||
mc.REASONING_CONTROL_VALUE_AUTO,
|
||||
mc.REASONING_CONTROL_VALUE_AUTO,
|
||||
]
|
||||
assert mc.normalize_reasoning_control_value("message_directive") == ""
|
||||
@@ -0,0 +1,646 @@
|
||||
import src.model_capabilities as mc
|
||||
import src.model_capability_readers as readers
|
||||
from src.model_capability_readers import generic_openai, google, llamacpp, lmstudio, ollama, openai, openrouter
|
||||
from src.model_capability_readers.base import (
|
||||
VENDOR_GENERIC_OPENAI,
|
||||
VENDOR_GOOGLE,
|
||||
VENDOR_LLAMACPP,
|
||||
VENDOR_LMSTUDIO,
|
||||
VENDOR_OLLAMA,
|
||||
VENDOR_OPENAI,
|
||||
VENDOR_OPENROUTER,
|
||||
detect_vendor,
|
||||
stable_model_id_for,
|
||||
)
|
||||
|
||||
|
||||
def surfaces(record):
|
||||
return set(mc.display_surfaces_for(record.capability))
|
||||
|
||||
|
||||
def test_detect_vendor_uses_endpoint_kind_then_host_and_common_local_ports():
|
||||
assert detect_vendor("https://example.test/v1", endpoint_kind="ollama") == VENDOR_OLLAMA
|
||||
assert detect_vendor("http://127.0.0.1:8080", endpoint_kind="llama_cpp") == VENDOR_LLAMACPP
|
||||
assert detect_vendor("https://openrouter.ai/api/v1") == VENDOR_OPENROUTER
|
||||
assert detect_vendor("https://api.openai.com/v1") == VENDOR_OPENAI
|
||||
assert detect_vendor("https://generativelanguage.googleapis.com/v1beta/openai") == VENDOR_GOOGLE
|
||||
assert detect_vendor("http://127.0.0.1:11434") == VENDOR_OLLAMA
|
||||
assert detect_vendor("http://127.0.0.1:1234") == VENDOR_LMSTUDIO
|
||||
assert detect_vendor("http://127.0.0.1:8080") == VENDOR_GENERIC_OPENAI
|
||||
assert detect_vendor("http://localhost:7000/v1") == VENDOR_GENERIC_OPENAI
|
||||
|
||||
|
||||
def test_generic_openai_reader_keeps_basic_model_payload_unknown():
|
||||
records = generic_openai.records_from_payload(
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": "gpt-example", "object": "model", "owned_by": "vendor"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert len(records) == 1
|
||||
record = records[0]
|
||||
assert record.vendor == VENDOR_GENERIC_OPENAI
|
||||
assert record.model_id == "gpt-example"
|
||||
assert record.capability.family == mc.FAMILY_UNKNOWN
|
||||
assert record.capability.source == mc.SOURCE_PROVIDER_READER
|
||||
assert record.capability.confidence == mc.CONFIDENCE_UNKNOWN
|
||||
assert record.stable_model_id == "generic_openai|global|gpt-example"
|
||||
assert record.capability_assertions == ()
|
||||
assert record.deterministic_controls == ()
|
||||
assert surfaces(record) == set()
|
||||
|
||||
|
||||
def test_stable_model_id_is_endpoint_scoped_for_local_or_configured_servers():
|
||||
assert stable_model_id_for("ollama", "qwen:latest", endpoint_id="7") == "ollama|endpoint:7|qwen:latest"
|
||||
assert stable_model_id_for("ollama", "qwen:latest", base_url="http://127.0.0.1:11434") != stable_model_id_for(
|
||||
"ollama",
|
||||
"qwen:latest",
|
||||
base_url="http://10.0.0.12:11434",
|
||||
)
|
||||
|
||||
|
||||
def test_registry_uses_openai_reader_for_openai_vendor():
|
||||
records = readers.records_from_payload({"data": [{"id": "shape-only-model"}]}, vendor=VENDOR_OPENAI)
|
||||
|
||||
assert len(records) == 1
|
||||
assert records[0].vendor == VENDOR_OPENAI
|
||||
assert records[0].stable_model_id == "openai|global|shape-only-model"
|
||||
assert records[0].capability.family == mc.FAMILY_UNKNOWN
|
||||
|
||||
|
||||
def test_openai_reader_keeps_official_model_shape_identity_only():
|
||||
records = openai.records_from_payload(
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "shape-only-model",
|
||||
"object": "model",
|
||||
"created": 1700000000,
|
||||
"owned_by": "openai",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert len(records) == 1
|
||||
record = records[0]
|
||||
assert record.vendor == VENDOR_OPENAI
|
||||
assert record.model_id == "shape-only-model"
|
||||
assert record.capability.family == mc.FAMILY_UNKNOWN
|
||||
assert record.capability.source == mc.SOURCE_PROVIDER_READER
|
||||
assert record.capability.confidence == mc.CONFIDENCE_UNKNOWN
|
||||
assert record.capability_assertions == ()
|
||||
assert record.deterministic_controls == ()
|
||||
assert surfaces(record) == set()
|
||||
|
||||
|
||||
def test_registry_passes_endpoint_context_to_vendor_reader():
|
||||
records = readers.records_from_payload(
|
||||
{"data": [{"id": "local.gguf", "owned_by": "llamacpp"}]},
|
||||
vendor=VENDOR_LLAMACPP,
|
||||
base_url="http://localhost:8000",
|
||||
)
|
||||
|
||||
assert len(records) == 1
|
||||
assert records[0].stable_model_id == stable_model_id_for(
|
||||
VENDOR_LLAMACPP,
|
||||
"local.gguf",
|
||||
base_url="http://localhost:8000",
|
||||
)
|
||||
|
||||
|
||||
def test_openrouter_reader_maps_rich_architecture_and_supported_parameters():
|
||||
records = openrouter.records_from_payload(
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "google/gemini-vision",
|
||||
"name": "Gemini Vision",
|
||||
"architecture": {"modality": "text+image->text"},
|
||||
"supported_parameters": [
|
||||
"tools",
|
||||
"response_format",
|
||||
"reasoning",
|
||||
"include_reasoning",
|
||||
"parallel_tool_calls",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"seed",
|
||||
],
|
||||
"context_length": 1048576,
|
||||
"top_provider": {"max_completion_tokens": 65536},
|
||||
},
|
||||
{
|
||||
"id": "black-forest-labs/flux",
|
||||
"architecture": {"input_modalities": ["text"], "output_modalities": ["image"]},
|
||||
},
|
||||
{
|
||||
"id": "vendor/image-edit-shape",
|
||||
"architecture": {"input_modalities": ["text", "image", "file"], "output_modalities": ["text", "image"]},
|
||||
"supported_parameters": ["structured_outputs", "web_search_options"],
|
||||
},
|
||||
{
|
||||
"id": "vendor/audio-shape",
|
||||
"architecture": {"input_modalities": ["text", "audio"], "output_modalities": ["text", "audio"]},
|
||||
"supported_voices": ["alloy"],
|
||||
"default_parameters": {"temperature": 0.7, "top_p": 0.9, "top_k": None},
|
||||
"per_request_limits": {"prompt_tokens": 12000, "completion_tokens": 4000, "requests": "2"},
|
||||
},
|
||||
{
|
||||
"id": "vendor/embedder",
|
||||
"architecture": {"modality": "text->embedding"},
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert [record.model_id for record in records] == [
|
||||
"google/gemini-vision",
|
||||
"black-forest-labs/flux",
|
||||
"vendor/image-edit-shape",
|
||||
"vendor/audio-shape",
|
||||
"vendor/embedder",
|
||||
]
|
||||
|
||||
vision = records[0]
|
||||
assert vision.capability.family == mc.FAMILY_CHAT
|
||||
assert vision.capability.modalities.input == (mc.MODALITY_TEXT, mc.MODALITY_IMAGE)
|
||||
assert vision.capability.capabilities == (
|
||||
mc.CAP_TOOL_CALL,
|
||||
mc.CAP_JSON_MODE,
|
||||
mc.CAP_REASONING,
|
||||
mc.CAP_VISION,
|
||||
)
|
||||
assert [(assertion.capability, assertion.status) for assertion in vision.capability_assertions] == [
|
||||
(mc.CAP_TOOL_CALL, mc.ASSERTION_CLAIMED),
|
||||
(mc.CAP_JSON_MODE, mc.ASSERTION_CLAIMED),
|
||||
(mc.CAP_REASONING, mc.ASSERTION_CLAIMED),
|
||||
(mc.CAP_VISION, mc.ASSERTION_CLAIMED),
|
||||
]
|
||||
assert [(control.control, control.status) for control in vision.deterministic_controls] == [
|
||||
(mc.CONTROL_TEMPERATURE, mc.ASSERTION_CLAIMED),
|
||||
(mc.CONTROL_TOP_P, mc.ASSERTION_CLAIMED),
|
||||
(mc.CONTROL_SEED, mc.ASSERTION_CLAIMED),
|
||||
]
|
||||
assert dict(vision.capability.limits) == {"context_tokens": 1048576, "output_tokens": 65536}
|
||||
assert surfaces(vision) == {"chat", "vision_chat"}
|
||||
|
||||
assert records[1].capability.family == mc.FAMILY_IMAGE
|
||||
assert records[1].capability.capabilities == (mc.CAP_IMAGE_GENERATION,)
|
||||
assert surfaces(records[1]) == {"image_generation"}
|
||||
|
||||
image_edit = records[2]
|
||||
assert image_edit.capability.family == mc.FAMILY_IMAGE
|
||||
assert image_edit.capability.modalities.input == (mc.MODALITY_TEXT, mc.MODALITY_IMAGE, mc.MODALITY_FILE)
|
||||
assert image_edit.capability.modalities.output == (mc.MODALITY_TEXT, mc.MODALITY_IMAGE)
|
||||
assert image_edit.capability.capabilities == (
|
||||
mc.CAP_STRUCTURED_OUTPUT,
|
||||
mc.CAP_WEB_SEARCH,
|
||||
mc.CAP_VISION,
|
||||
mc.CAP_FILES,
|
||||
mc.CAP_IMAGE_GENERATION,
|
||||
mc.CAP_IMAGE_EDITING,
|
||||
)
|
||||
assert surfaces(image_edit) == {"image_generation", "image_editing"}
|
||||
|
||||
audio = records[3]
|
||||
assert audio.capability.family == mc.FAMILY_AUDIO
|
||||
assert audio.capability.capabilities == (mc.CAP_AUDIO_INPUT, mc.CAP_AUDIO_OUTPUT, mc.CAP_TTS)
|
||||
assert dict(audio.capability.limits) == {
|
||||
"per_request_completion_tokens": 4000,
|
||||
"per_request_prompt_tokens": 12000,
|
||||
"per_request_requests": 2,
|
||||
}
|
||||
assert [control.control for control in audio.deterministic_controls] == [
|
||||
mc.CONTROL_TEMPERATURE,
|
||||
mc.CONTROL_TOP_P,
|
||||
]
|
||||
assert surfaces(audio) == {"audio_realtime"}
|
||||
|
||||
assert records[4].capability.family == mc.FAMILY_EMBEDDING
|
||||
assert surfaces(records[4]) == {"embeddings"}
|
||||
|
||||
|
||||
def test_google_reader_maps_provider_fields_without_claiming_unreported_modalities():
|
||||
records = google.records_from_payload(
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"name": "models/gemini-3.1-flash-image",
|
||||
"displayName": "Gemini 3.1 Flash Image",
|
||||
"supportedGenerationMethods": ["generateContent"],
|
||||
"inputTokenLimit": 1000000,
|
||||
"outputTokenLimit": 8192,
|
||||
"thinking": True,
|
||||
"temperature": 1.0,
|
||||
"topP": 0.95,
|
||||
"topK": 40,
|
||||
},
|
||||
{
|
||||
"name": "models/text-embedding-example",
|
||||
"supportedGenerationMethods": ["embedContent"],
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert len(records) == 2
|
||||
content = records[0]
|
||||
assert content.vendor == VENDOR_GOOGLE
|
||||
assert content.model_id == "gemini-3.1-flash-image"
|
||||
assert content.capability.family == mc.FAMILY_UNKNOWN
|
||||
assert content.capability.modalities.input == ()
|
||||
assert content.capability.modalities.output == ()
|
||||
assert content.capability.capabilities == (mc.CAP_REASONING,)
|
||||
assert dict(content.capability.limits) == {
|
||||
"context_tokens": 1000000,
|
||||
"input_tokens": 1000000,
|
||||
"output_tokens": 8192,
|
||||
}
|
||||
assert [control.control for control in content.deterministic_controls] == [
|
||||
mc.CONTROL_TEMPERATURE,
|
||||
mc.CONTROL_TOP_P,
|
||||
mc.CONTROL_TOP_K,
|
||||
]
|
||||
assert surfaces(content) == set()
|
||||
|
||||
embedding = records[1]
|
||||
assert embedding.capability.family == mc.FAMILY_EMBEDDING
|
||||
assert surfaces(embedding) == {"embeddings"}
|
||||
|
||||
|
||||
def test_google_ai_studio_mapping_does_not_infer_media_from_model_names():
|
||||
records = google.records_from_payload(
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"name": "models/imagen-4.0-generate-001",
|
||||
"displayName": "Imagen 4",
|
||||
"supportedGenerationMethods": ["predict"],
|
||||
},
|
||||
{
|
||||
"name": "models/veo-3.1-generate-preview",
|
||||
"displayName": "Veo 3.1",
|
||||
"supportedGenerationMethods": ["predictLongRunning"],
|
||||
},
|
||||
{
|
||||
"name": "models/gemini-3.1-flash-tts-preview",
|
||||
"supportedGenerationMethods": ["generateContent", "countTokens", "createCachedContent", "batchGenerateContent"],
|
||||
},
|
||||
{
|
||||
"name": "models/lyria-3-pro-preview",
|
||||
"displayName": "Lyria 3 Pro Preview",
|
||||
"supportedGenerationMethods": ["generateContent", "countTokens"],
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert len(records) == 4
|
||||
assert [record.capability.family for record in records] == [
|
||||
mc.FAMILY_UNKNOWN,
|
||||
mc.FAMILY_UNKNOWN,
|
||||
mc.FAMILY_UNKNOWN,
|
||||
mc.FAMILY_UNKNOWN,
|
||||
]
|
||||
assert all(record.capability.modalities.input == () for record in records)
|
||||
assert all(record.capability.modalities.output == () for record in records)
|
||||
assert all(surfaces(record) == set() for record in records)
|
||||
assert [control.control for control in records[2].deterministic_controls] == [
|
||||
mc.CONTROL_PROMPT_CACHING,
|
||||
mc.CONTROL_BATCH,
|
||||
]
|
||||
|
||||
|
||||
def test_google_ai_studio_mapping_keeps_unrecognized_predict_models_unknown():
|
||||
records = google.records_from_payload(
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"name": "models/vendor-future-media-001",
|
||||
"supportedGenerationMethods": ["predict"],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert len(records) == 1
|
||||
assert records[0].capability.family == mc.FAMILY_UNKNOWN
|
||||
assert surfaces(records[0]) == set()
|
||||
|
||||
|
||||
def test_ollama_reader_maps_show_capabilities_and_tags_are_unknown():
|
||||
vision = ollama.record_from_show_payload(
|
||||
"llava:latest",
|
||||
{
|
||||
"capabilities": ["completion", "vision", "tools"],
|
||||
"model_info": {"llama.context_length": 4096},
|
||||
},
|
||||
)
|
||||
embedding = ollama.record_from_show_payload(
|
||||
"nomic-embed-text:latest",
|
||||
{"capabilities": ["embedding"]},
|
||||
)
|
||||
tags = ollama.records_from_tags_payload({"models": [{"name": "qwen3:latest"}]})
|
||||
|
||||
assert vision is not None
|
||||
assert vision.capability.family == mc.FAMILY_CHAT
|
||||
assert vision.capability.modalities.input == (mc.MODALITY_TEXT, mc.MODALITY_IMAGE)
|
||||
assert vision.capability.capabilities == (mc.CAP_VISION, mc.CAP_TOOL_CALL)
|
||||
assert dict(vision.capability.limits) == {"context_tokens": 4096}
|
||||
assert surfaces(vision) == {"chat", "vision_chat"}
|
||||
|
||||
assert embedding is not None
|
||||
assert embedding.capability.family == mc.FAMILY_EMBEDDING
|
||||
assert surfaces(embedding) == {"embeddings"}
|
||||
|
||||
assert len(tags) == 1
|
||||
assert tags[0].capability.family == mc.FAMILY_UNKNOWN
|
||||
assert surfaces(tags[0]) == set()
|
||||
|
||||
|
||||
def test_ollama_reader_uses_show_shape_without_architecture_name_matching():
|
||||
record = ollama.record_from_show_payload(
|
||||
"local:latest",
|
||||
{
|
||||
"capabilities": ["completion", "thinking", "tools"],
|
||||
"parameters": "temperature 0.7\nnum_ctx 8192",
|
||||
"model_info": {
|
||||
"future_architecture.context_length": 32768,
|
||||
"future_architecture.embedding_length": 4096,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert record is not None
|
||||
assert record.capability.family == mc.FAMILY_CHAT
|
||||
assert record.capability.modalities.input == (mc.MODALITY_TEXT,)
|
||||
assert record.capability.modalities.output == (mc.MODALITY_TEXT,)
|
||||
assert record.capability.capabilities == (mc.CAP_REASONING, mc.CAP_TOOL_CALL)
|
||||
assert dict(record.capability.limits) == {"context_tokens": 8192}
|
||||
assert surfaces(record) == {"chat"}
|
||||
|
||||
|
||||
def test_ollama_reader_uses_generic_model_info_context_length_when_no_num_ctx():
|
||||
record = ollama.record_from_show_payload(
|
||||
"local:latest",
|
||||
{
|
||||
"capabilities": ["completion"],
|
||||
"model_info": {"future_architecture.context_length": 32768},
|
||||
},
|
||||
)
|
||||
|
||||
assert record is not None
|
||||
assert record.capability.family == mc.FAMILY_CHAT
|
||||
assert dict(record.capability.limits) == {"context_tokens": 32768}
|
||||
|
||||
|
||||
def test_lmstudio_reader_uses_native_v1_capabilities_when_present():
|
||||
records = lmstudio.records_from_payload(
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"type": "llm",
|
||||
"key": "google/gemma-vl",
|
||||
"display_name": "Gemma VL",
|
||||
"capabilities": {
|
||||
"vision": True,
|
||||
"trained_for_tool_use": True,
|
||||
"reasoning": {"allowed_options": ["off", "on"], "default": "on"},
|
||||
},
|
||||
"loaded_instances": [
|
||||
{"config": {"context_length": 8192}},
|
||||
{"config": {"context_length": 4096}},
|
||||
],
|
||||
"max_context_length": 262144,
|
||||
},
|
||||
{
|
||||
"type": "embedding",
|
||||
"key": "nomic/embed",
|
||||
},
|
||||
{"key": "shape-without-type"},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert len(records) == 3
|
||||
vision = records[0]
|
||||
assert vision.vendor == VENDOR_LMSTUDIO
|
||||
assert vision.model_id == "google/gemma-vl"
|
||||
assert vision.display_name == "Gemma VL"
|
||||
assert vision.capability.family == mc.FAMILY_CHAT
|
||||
assert vision.capability.modalities.input == (mc.MODALITY_TEXT, mc.MODALITY_IMAGE)
|
||||
assert vision.capability.capabilities == (mc.CAP_VISION, mc.CAP_TOOL_CALL, mc.CAP_REASONING)
|
||||
assert dict(vision.capability.limits) == {"context_tokens": 4096, "max_context_tokens": 262144}
|
||||
assert surfaces(vision) == {"chat", "vision_chat"}
|
||||
|
||||
assert records[1].capability.family == mc.FAMILY_EMBEDDING
|
||||
assert surfaces(records[1]) == {"embeddings"}
|
||||
|
||||
assert records[2].capability.family == mc.FAMILY_UNKNOWN
|
||||
assert surfaces(records[2]) == set()
|
||||
|
||||
|
||||
def test_lmstudio_reader_uses_legacy_native_v0_shape_for_family_and_limits():
|
||||
records = lmstudio.records_from_payload(
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "local-gemma",
|
||||
"type": "llm",
|
||||
"arch": "gemma3",
|
||||
"loaded_context_length": 16384,
|
||||
"max_context_length": 32768,
|
||||
},
|
||||
{
|
||||
"id": "text-embedding-local",
|
||||
"type": "embeddings",
|
||||
"max_context_length": 2048,
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert len(records) == 2
|
||||
chat = records[0]
|
||||
assert chat.vendor == VENDOR_LMSTUDIO
|
||||
assert chat.capability.family == mc.FAMILY_CHAT
|
||||
assert chat.capability.modalities.input == (mc.MODALITY_TEXT,)
|
||||
assert chat.capability.capabilities == ()
|
||||
assert dict(chat.capability.limits) == {"context_tokens": 16384, "max_context_tokens": 32768}
|
||||
assert surfaces(chat) == {"chat"}
|
||||
|
||||
assert records[1].capability.family == mc.FAMILY_EMBEDDING
|
||||
assert dict(records[1].capability.limits) == {"context_tokens": 2048}
|
||||
assert surfaces(records[1]) == {"embeddings"}
|
||||
|
||||
|
||||
def test_lmstudio_openai_compatible_model_list_remains_identity_only():
|
||||
records = lmstudio.records_from_payload(
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": "local-gemma-3-270m-it-qat-q4_k_m", "object": "model", "owned_by": "organization_owner"},
|
||||
{"id": "text-embedding-nomic-embed-text-v1.5", "object": "model", "owned_by": "organization_owner"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert len(records) == 2
|
||||
for record in records:
|
||||
assert record.vendor == VENDOR_LMSTUDIO
|
||||
assert record.capability.family == mc.FAMILY_UNKNOWN
|
||||
assert record.capability_assertions == ()
|
||||
assert surfaces(record) == set()
|
||||
|
||||
|
||||
def test_lmstudio_unexpected_native_endpoint_error_yields_no_records():
|
||||
assert lmstudio.records_from_payload({"error": "Unexpected endpoint or method. (GET /api/v1/models)"}) == ()
|
||||
|
||||
|
||||
def test_llamacpp_reader_merges_models_props_and_slots_payloads():
|
||||
models_payload = {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "gemma-4-E2B-it-Q8_0.gguf",
|
||||
"owned_by": "llamacpp",
|
||||
"meta": {
|
||||
"n_ctx_train": 131072,
|
||||
"n_params": 4647450147,
|
||||
"size": 5032532108,
|
||||
},
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
"name": "gemma-4-E2B-it-Q8_0.gguf",
|
||||
"model": "gemma-4-E2B-it-Q8_0.gguf",
|
||||
"capabilities": ["completion"],
|
||||
"details": {"format": "gguf"},
|
||||
}
|
||||
],
|
||||
}
|
||||
props_payload = {
|
||||
"model_alias": "gemma-4-E2B-it-Q8_0.gguf",
|
||||
"model_path": "/models/gemma-4-E2B-it-Q8_0.gguf",
|
||||
"build_info": "b1-c8ac02f",
|
||||
"total_slots": 4,
|
||||
"modalities": {"vision": False, "audio": False},
|
||||
"chat_template_caps": {
|
||||
"supports_object_arguments": True,
|
||||
"supports_parallel_tool_calls": True,
|
||||
"supports_preserve_reasoning": False,
|
||||
"supports_string_content": True,
|
||||
"supports_system_role": True,
|
||||
"supports_tool_calls": True,
|
||||
"supports_tools": True,
|
||||
"supports_typed_content": False,
|
||||
},
|
||||
"default_generation_settings": {
|
||||
"n_ctx": 16384,
|
||||
"params": {
|
||||
"temperature": 1.0,
|
||||
"top_p": 0.95,
|
||||
"seed": 4294967295,
|
||||
"stream": True,
|
||||
"samplers": ["top_p", "temperature"],
|
||||
},
|
||||
},
|
||||
}
|
||||
slots_payload = [
|
||||
{"id": 0, "n_ctx": 16384, "speculative": False, "is_processing": False},
|
||||
{"id": 1, "n_ctx": 16384, "speculative": False, "is_processing": False},
|
||||
{"id": 2, "n_ctx": 16384, "speculative": False, "is_processing": False},
|
||||
{"id": 3, "n_ctx": 16384, "speculative": False, "is_processing": False},
|
||||
]
|
||||
|
||||
records = llamacpp.records_from_payloads(
|
||||
models_payload=models_payload,
|
||||
props_payload=props_payload,
|
||||
slots_payload=slots_payload,
|
||||
base_url="http://localhost:8000",
|
||||
)
|
||||
|
||||
assert len(records) == 1
|
||||
record = records[0]
|
||||
assert record.vendor == VENDOR_LLAMACPP
|
||||
assert record.model_id == "gemma-4-E2B-it-Q8_0.gguf"
|
||||
assert record.stable_model_id == stable_model_id_for(
|
||||
VENDOR_LLAMACPP,
|
||||
"gemma-4-E2B-it-Q8_0.gguf",
|
||||
base_url="http://localhost:8000",
|
||||
)
|
||||
assert record.capability.family == mc.FAMILY_CHAT
|
||||
assert record.capability.modalities.input == (mc.MODALITY_TEXT,)
|
||||
assert record.capability.modalities.output == (mc.MODALITY_TEXT,)
|
||||
assert record.capability.capabilities == (mc.CAP_TOOL_CALL, mc.CAP_STREAMING)
|
||||
assert dict(record.capability.limits) == {
|
||||
"context_tokens": 16384,
|
||||
"model_bytes": 5032532108,
|
||||
"parallel_slots": 4,
|
||||
"parameters": 4647450147,
|
||||
"training_context_tokens": 131072,
|
||||
}
|
||||
assert surfaces(record) == {"chat"}
|
||||
|
||||
assertion_status = {assertion.capability: assertion.status for assertion in record.capability_assertions}
|
||||
assert assertion_status[mc.CAP_TOOL_CALL] == mc.ASSERTION_CLAIMED
|
||||
assert assertion_status[mc.CAP_STREAMING] == mc.ASSERTION_CLAIMED
|
||||
assert assertion_status[mc.CAP_VISION] == mc.ASSERTION_UNSUPPORTED
|
||||
assert assertion_status[mc.CAP_AUDIO_INPUT] == mc.ASSERTION_UNSUPPORTED
|
||||
assert mc.CAP_REASONING not in assertion_status
|
||||
|
||||
controls = {control.control: control.status for control in record.deterministic_controls}
|
||||
assert controls == {
|
||||
mc.CONTROL_TEMPERATURE: mc.ASSERTION_CLAIMED,
|
||||
mc.CONTROL_TOP_P: mc.ASSERTION_CLAIMED,
|
||||
mc.CONTROL_SEED: mc.ASSERTION_CLAIMED,
|
||||
mc.CONTROL_SYSTEM_PROMPT: mc.ASSERTION_CLAIMED,
|
||||
mc.CONTROL_TOOL_CHOICE: mc.ASSERTION_CLAIMED,
|
||||
}
|
||||
|
||||
|
||||
def test_llamacpp_openai_model_list_without_native_capability_shape_stays_unknown():
|
||||
records = llamacpp.records_from_payload(
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "local-chat.gguf",
|
||||
"owned_by": "llamacpp",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert len(records) == 1
|
||||
assert records[0].capability.family == mc.FAMILY_UNKNOWN
|
||||
assert records[0].capability.capabilities == ()
|
||||
assert records[0].capability_assertions == ()
|
||||
assert surfaces(records[0]) == set()
|
||||
|
||||
|
||||
def test_llamacpp_props_payload_reports_unsupported_modalities_without_model_list():
|
||||
records = llamacpp.records_from_payload(
|
||||
{
|
||||
"model_alias": "local.gguf",
|
||||
"modalities": {"vision": False, "audio": False},
|
||||
"chat_template_caps": {"supports_tools": False, "supports_preserve_reasoning": False},
|
||||
"default_generation_settings": {"n_ctx": 4096, "params": {"stream": True}},
|
||||
}
|
||||
)
|
||||
|
||||
assert len(records) == 1
|
||||
record = records[0]
|
||||
assert record.capability.family == mc.FAMILY_CHAT
|
||||
assert record.capability.capabilities == (mc.CAP_STREAMING,)
|
||||
assert {a.capability: a.status for a in record.capability_assertions} == {
|
||||
mc.CAP_STREAMING: mc.ASSERTION_CLAIMED,
|
||||
mc.CAP_VISION: mc.ASSERTION_UNSUPPORTED,
|
||||
mc.CAP_AUDIO_INPUT: mc.ASSERTION_UNSUPPORTED,
|
||||
}
|
||||
@@ -40,6 +40,6 @@ def test_history_compact_resolves_with_owner_scope():
|
||||
|
||||
|
||||
def test_note_reminder_synthesis_resolves_with_owner_scope():
|
||||
body = _function_source("routes/note_routes.py", "dispatch_reminder")
|
||||
body = _function_source("routes/note/note_routes.py", "dispatch_reminder")
|
||||
assert 'resolve_endpoint("utility", owner=owner or None)' in body
|
||||
assert 'resolve_endpoint("default", owner=owner or None)' in body
|
||||
|
||||
@@ -24,3 +24,21 @@ def test_header_indicator_has_title_tooltip():
|
||||
body = SRC[SRC.index("export function updateModelPicker()"):]
|
||||
assert re.search(r"label\.title\s*=\s*modelId\b", body), \
|
||||
"header model indicator needs a title tooltip (#1982)"
|
||||
|
||||
|
||||
def test_api_picker_dedupe_includes_endpoint_id():
|
||||
# API providers can expose the same model id intentionally. The chat picker
|
||||
# must not dedupe OpenRouter away just because OpenAI has the same id.
|
||||
assert "const isApiEndpoint = item.category && item.category !== 'local';" in SRC
|
||||
assert re.search(r"const seenKey = isApiEndpoint\s*\?", SRC), \
|
||||
"chat picker should dedupe API models by endpoint+model, not model id only"
|
||||
assert "${item.endpoint_id || item.url || item.endpoint_name || 'api'}::${mid}" in SRC
|
||||
|
||||
|
||||
def test_api_picker_groups_by_endpoint_name():
|
||||
# OpenRouter models often have ids like openai/* or google/*; browse mode
|
||||
# should still show them under the OpenRouter endpoint group.
|
||||
assert "function _providerGroupKey(m)" in SRC
|
||||
assert "m.category && m.category !== 'local' && m.epName" in SRC
|
||||
assert "`~endpoint:${m.epName}`" in SRC
|
||||
assert "_providerGroupName(provider)" in SRC
|
||||
|
||||
+310
-3
@@ -48,6 +48,9 @@ with preserve_import_state("core.database", "src.database", "core.session_manage
|
||||
_ping_endpoint,
|
||||
_parse_model_list,
|
||||
_normalize_refresh_mode,
|
||||
_normalize_endpoint_refresh_mode,
|
||||
_endpoint_refresh_mode,
|
||||
_is_google_api_base,
|
||||
_truthy,
|
||||
_speech_settings_using_endpoint,
|
||||
_clear_speech_settings_for_endpoint,
|
||||
@@ -436,6 +439,9 @@ class TestClassifyEndpoint:
|
||||
def test_public_api(self):
|
||||
assert _classify_endpoint("https://api.openai.com/v1") == "api"
|
||||
|
||||
def test_openrouter_api(self):
|
||||
assert _classify_endpoint("https://openrouter.ai/api/v1") == "api"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _classify_endpoint("") == "api"
|
||||
|
||||
@@ -464,6 +470,28 @@ class TestClassifyEndpoint:
|
||||
assert _normalize_refresh_mode("manual", "proxy") == "manual"
|
||||
assert _normalize_refresh_mode("auto", "api") == "auto"
|
||||
|
||||
def test_google_refresh_mode_defaults_manual_unless_explicit(self):
|
||||
base = "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
assert _normalize_endpoint_refresh_mode("", "api", base) == "manual"
|
||||
assert _normalize_endpoint_refresh_mode(None, "auto", base) == "manual"
|
||||
assert _normalize_endpoint_refresh_mode("auto", "api", base) == "auto"
|
||||
|
||||
def test_only_gemini_native_host_uses_google_models_api(self):
|
||||
assert _is_google_api_base("https://generativelanguage.googleapis.com/v1beta/openai") is True
|
||||
assert _is_google_api_base(
|
||||
"https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1/endpoints/openapi"
|
||||
) is False
|
||||
|
||||
def test_existing_google_endpoint_refresh_mode_defaults_manual(self):
|
||||
ep = SimpleNamespace(
|
||||
model_refresh_mode=None,
|
||||
endpoint_kind="api",
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
)
|
||||
assert _endpoint_refresh_mode(ep, "api") == "manual"
|
||||
ep.model_refresh_mode = "auto"
|
||||
assert _endpoint_refresh_mode(ep, "api") == "auto"
|
||||
|
||||
def test_parse_model_list_accepts_json_and_text(self):
|
||||
assert _parse_model_list('["a", "b", "a"]') == ["a", "b"]
|
||||
assert _parse_model_list("a, b\nc") == ["a", "b", "c"]
|
||||
@@ -568,6 +596,83 @@ class TestSetupProbeSafety:
|
||||
|
||||
assert _probe_endpoint("https://api.groq.com/openai/v1") == _PROVIDER_CURATED["groq"]
|
||||
|
||||
def test_google_probe_uses_native_paginated_models_api(self, monkeypatch):
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url, raising=False)
|
||||
monkeypatch.setattr(model_routes, "_normalize_base", lambda url: url.rstrip("/"))
|
||||
seen = []
|
||||
|
||||
def fake_get(url, headers=None, params=None, timeout=None, verify=None, **kwargs):
|
||||
seen.append((url, headers, params, timeout, verify))
|
||||
request = httpx.Request("GET", url)
|
||||
page_token = (params or {}).get("pageToken")
|
||||
if page_token:
|
||||
return httpx.Response(
|
||||
200,
|
||||
request=request,
|
||||
json={
|
||||
"models": [{
|
||||
"name": "models/gemini-page-two",
|
||||
"supportedGenerationMethods": ["generateContent"],
|
||||
}]
|
||||
},
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
request=request,
|
||||
json={
|
||||
"models": [
|
||||
{
|
||||
"name": "models/gemini-page-one",
|
||||
"supportedGenerationMethods": ["generateContent"],
|
||||
},
|
||||
{
|
||||
"baseModelId": "gemini-base-id",
|
||||
"name": "models/ignored-version",
|
||||
"supportedGenerationMethods": ["generateText"],
|
||||
},
|
||||
{
|
||||
"name": "models/imagen-4.0-generate-001",
|
||||
"supportedGenerationMethods": ["predict"],
|
||||
},
|
||||
{
|
||||
"name": "models/text-embedding-example",
|
||||
"supportedGenerationMethods": ["embedContent"],
|
||||
},
|
||||
{"name": "models/missing-method-metadata"},
|
||||
],
|
||||
"nextPageToken": "next-page",
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
|
||||
|
||||
assert _probe_endpoint("https://generativelanguage.googleapis.com/v1beta/openai", "google-key") == [
|
||||
"gemini-page-one",
|
||||
"gemini-base-id",
|
||||
"gemini-page-two",
|
||||
]
|
||||
assert [call[0] for call in seen] == [
|
||||
"https://generativelanguage.googleapis.com/v1beta/models",
|
||||
"https://generativelanguage.googleapis.com/v1beta/models",
|
||||
]
|
||||
assert seen[0][1] == {"Accept": "application/json", "x-goog-api-key": "google-key"}
|
||||
assert seen[0][2] == {"pageSize": 1000}
|
||||
assert seen[1][1] == {"Accept": "application/json", "x-goog-api-key": "google-key"}
|
||||
assert seen[1][2] == {"pageSize": 1000, "pageToken": "next-page"}
|
||||
|
||||
def test_google_probe_does_not_use_curated_fallback_on_failure(self, monkeypatch):
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url, raising=False)
|
||||
monkeypatch.setattr(model_routes, "_normalize_base", lambda url: url.rstrip("/"))
|
||||
|
||||
def fake_get(url, headers=None, params=None, timeout=None, verify=None, **kwargs):
|
||||
request = httpx.Request("GET", url)
|
||||
response = httpx.Response(401, request=request)
|
||||
raise httpx.HTTPStatusError("unauthorized", request=request, response=response)
|
||||
|
||||
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
|
||||
|
||||
assert _probe_endpoint("https://generativelanguage.googleapis.com/v1beta/openai", "bad-key") == []
|
||||
|
||||
def test_keyed_anthropic_probe_does_not_fallback_on_failure(self, monkeypatch):
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url, raising=False)
|
||||
monkeypatch.setattr(model_routes, "_normalize_base", lambda url: url.rstrip("/"))
|
||||
@@ -908,6 +1013,44 @@ def test_patch_models_pinned_does_not_clobber_hidden(monkeypatch):
|
||||
assert json.loads(ep.pinned_models) == ["deploy-1"]
|
||||
|
||||
|
||||
def test_patch_api_hidden_payload_converts_to_pinned(monkeypatch):
|
||||
ep = _make_endpoint(
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
cached_models=json.dumps(["m1", "m2", "m3"]),
|
||||
pinned_models=None,
|
||||
)
|
||||
db = _PinnedFakeDb([ep])
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
endpoint = _get_route("/api/model-endpoints/{ep_id}/models", "PATCH")
|
||||
|
||||
result = asyncio.run(endpoint("ep1", _PinnedFakeRequest(body={"hidden": ["m2"]})))
|
||||
|
||||
assert result["pinned_count"] == 2
|
||||
assert result["hidden_count"] == 0
|
||||
assert json.loads(ep.pinned_models) == ["m1", "m3"]
|
||||
assert ep.hidden_models is None
|
||||
|
||||
|
||||
def test_patch_api_hidden_empty_pins_all_cached_models(monkeypatch):
|
||||
ep = _make_endpoint(
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
cached_models=json.dumps(["m1", "m2", "m3"]),
|
||||
pinned_models=None,
|
||||
)
|
||||
db = _PinnedFakeDb([ep])
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
endpoint = _get_route("/api/model-endpoints/{ep_id}/models", "PATCH")
|
||||
|
||||
result = asyncio.run(endpoint("ep1", _PinnedFakeRequest(body={"hidden": []})))
|
||||
|
||||
assert result["pinned_count"] == 3
|
||||
assert result["hidden_count"] == 0
|
||||
assert json.loads(ep.pinned_models) == ["m1", "m2", "m3"]
|
||||
assert ep.hidden_models is None
|
||||
|
||||
|
||||
def test_get_models_returns_pinned_when_probe_empty(monkeypatch):
|
||||
ep = _make_endpoint(pinned_models=json.dumps(["deploy-1"]))
|
||||
db = _PinnedFakeDb([ep])
|
||||
@@ -923,6 +1066,26 @@ def test_get_models_returns_pinned_when_probe_empty(monkeypatch):
|
||||
assert result[0]["is_pinned"] is True
|
||||
|
||||
|
||||
def test_get_api_models_marks_picker_as_pinned_only(monkeypatch):
|
||||
ep = _make_endpoint(
|
||||
base_url="https://api.example.test/v1",
|
||||
cached_models=json.dumps(["openai/gpt-image-1", "anthropic/claude-sonnet-4"]),
|
||||
pinned_models=json.dumps(["openai/gpt-image-1"]),
|
||||
)
|
||||
db = _PinnedFakeDb([ep])
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
endpoint = _get_route("/api/model-endpoints/{ep_id}/models", "GET")
|
||||
|
||||
result = endpoint("ep1", _PinnedFakeRequest(), SimpleNamespace(headers={}))
|
||||
|
||||
by_id = {row["id"]: row for row in result}
|
||||
assert by_id["openai/gpt-image-1"]["picker_requires_pinning"] is True
|
||||
assert by_id["openai/gpt-image-1"]["is_pinned"] is True
|
||||
assert by_id["anthropic/claude-sonnet-4"]["picker_requires_pinning"] is True
|
||||
assert by_id["anthropic/claude-sonnet-4"]["is_pinned"] is False
|
||||
|
||||
|
||||
def test_reprobe_preserves_pinned_models(monkeypatch):
|
||||
ep = _make_endpoint(pinned_models=json.dumps(["deploy-1"]))
|
||||
db = _PinnedFakeDb([ep])
|
||||
@@ -1082,6 +1245,79 @@ def test_list_model_endpoints_returns_key_fingerprint(monkeypatch):
|
||||
assert result[1]["api_key_fingerprint"] == ""
|
||||
|
||||
|
||||
def test_list_api_endpoint_reports_inventory_count_when_none_pinned(monkeypatch):
|
||||
ep = _make_endpoint(
|
||||
base_url="https://api.example.test/v1",
|
||||
cached_models=json.dumps(["openai/gpt-image-1", "anthropic/claude-sonnet-4"]),
|
||||
pinned_models=None,
|
||||
)
|
||||
db = _PinnedFakeDb([ep])
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
endpoint = _get_route("/api/model-endpoints", "GET")
|
||||
|
||||
result = endpoint(_PinnedFakeRequest())
|
||||
|
||||
assert result[0]["models"] == []
|
||||
assert result[0]["model_count"] == 2
|
||||
assert result[0]["picker_requires_pinning"] is True
|
||||
assert result[0]["status"] == "online"
|
||||
|
||||
|
||||
def test_list_api_endpoint_returns_pinned_picker_models(monkeypatch):
|
||||
ep = _make_endpoint(
|
||||
base_url="https://api.example.test/v1",
|
||||
cached_models=json.dumps(["openai/gpt-image-1", "anthropic/claude-sonnet-4"]),
|
||||
pinned_models=json.dumps(["openai/gpt-image-1"]),
|
||||
)
|
||||
db = _PinnedFakeDb([ep])
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
endpoint = _get_route("/api/model-endpoints", "GET")
|
||||
|
||||
result = endpoint(_PinnedFakeRequest())
|
||||
|
||||
assert result[0]["models"] == ["openai/gpt-image-1"]
|
||||
assert result[0]["pinned_models"] == ["openai/gpt-image-1"]
|
||||
assert result[0]["model_count"] == 2
|
||||
|
||||
|
||||
def test_list_api_endpoint_pinned_models_ignore_stale_hidden_state(monkeypatch):
|
||||
ep = _make_endpoint(
|
||||
base_url="https://api.example.test/v1",
|
||||
cached_models=json.dumps(["openai/gpt-image-1", "anthropic/claude-sonnet-4"]),
|
||||
hidden_models=json.dumps(["openai/gpt-image-1"]),
|
||||
pinned_models=json.dumps(["openai/gpt-image-1"]),
|
||||
)
|
||||
db = _PinnedFakeDb([ep])
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
endpoint = _get_route("/api/model-endpoints", "GET")
|
||||
|
||||
result = endpoint(_PinnedFakeRequest())
|
||||
|
||||
assert result[0]["models"] == ["openai/gpt-image-1"]
|
||||
|
||||
|
||||
def test_list_api_endpoint_derives_pins_from_legacy_hidden_state(monkeypatch):
|
||||
ep = _make_endpoint(
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
cached_models=json.dumps(["m1", "m2", "m3"]),
|
||||
hidden_models=json.dumps(["m2"]),
|
||||
pinned_models=None,
|
||||
)
|
||||
db = _PinnedFakeDb([ep])
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
endpoint = _get_route("/api/model-endpoints", "GET")
|
||||
|
||||
result = endpoint(_PinnedFakeRequest())
|
||||
|
||||
assert result[0]["models"] == ["m1", "m3"]
|
||||
assert result[0]["pinned_models"] == ["m1", "m3"]
|
||||
assert json.loads(ep.pinned_models) == ["m1", "m3"]
|
||||
|
||||
|
||||
def test_post_creates_endpoint_with_pinned_models(monkeypatch):
|
||||
db = _PinnedFakeDb([]) # no existing row → fresh create path
|
||||
_patch_create_deps(monkeypatch, db)
|
||||
@@ -1101,6 +1337,26 @@ def test_post_creates_endpoint_with_pinned_models(monkeypatch):
|
||||
assert json.loads(db.added[0].pinned_models) == ["deploy-1", "deploy-2"]
|
||||
|
||||
|
||||
def test_post_google_endpoint_defaults_to_manual_refresh_when_mode_omitted(monkeypatch):
|
||||
db = _PinnedFakeDb([])
|
||||
_patch_create_deps(monkeypatch, db)
|
||||
monkeypatch.setattr(model_routes, "_probe_endpoint", lambda *args, **kwargs: ["gemini-test"])
|
||||
create = _get_route("/api/model-endpoints", "POST")
|
||||
|
||||
create(
|
||||
_PinnedFakeRequest(),
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
**_create_form_kwargs(
|
||||
api_key="google-key",
|
||||
endpoint_kind="api",
|
||||
model_refresh_mode="",
|
||||
),
|
||||
)
|
||||
|
||||
assert len(db.added) == 1
|
||||
assert db.added[0].model_refresh_mode == "manual"
|
||||
|
||||
|
||||
def test_post_dedupe_existing_merges_and_returns_pinned(monkeypatch):
|
||||
existing = _make_endpoint(
|
||||
base_url="http://host:1234/v1",
|
||||
@@ -1431,11 +1687,12 @@ def test_api_models_scopes_api_token_to_token_owner(monkeypatch):
|
||||
assert admin_checks == ["alice"]
|
||||
|
||||
|
||||
def test_api_models_returns_cached_proxy_models_without_refresh_probe(monkeypatch):
|
||||
def test_api_models_returns_only_pinned_proxy_models_without_refresh_probe(monkeypatch):
|
||||
row = _route_ep(
|
||||
"proxy",
|
||||
"http://100.117.136.97:34521/v1",
|
||||
cached_models=["cached-model"],
|
||||
cached_models=["cached-model", "other-model"],
|
||||
pinned_models=["cached-model"],
|
||||
endpoint_kind="proxy",
|
||||
api_key="fake-key",
|
||||
refresh_mode="manual",
|
||||
@@ -1457,10 +1714,60 @@ def test_api_models_returns_cached_proxy_models_without_refresh_probe(monkeypatc
|
||||
result = _route_endpoint(router, "/api/models")(_route_request())
|
||||
|
||||
assert result["items"][0]["models"] == ["cached-model"]
|
||||
assert result["items"][0]["models_extra"] == []
|
||||
assert result["items"][0]["category"] == "api"
|
||||
assert result["items"][0]["endpoint_kind"] == "proxy"
|
||||
assert "offline" not in result["items"][0]
|
||||
assert json.loads(row.cached_models) == ["cached-model"]
|
||||
assert json.loads(row.cached_models) == ["cached-model", "other-model"]
|
||||
|
||||
|
||||
def test_api_models_openrouter_uses_pinned_models_not_hidden(monkeypatch):
|
||||
row = _route_ep(
|
||||
"openrouter",
|
||||
"https://openrouter.ai/api/v1",
|
||||
cached_models=["openai/gpt-image-1", "anthropic/claude-sonnet-4"],
|
||||
pinned_models=["openai/gpt-image-1"],
|
||||
api_key="fake-key",
|
||||
)
|
||||
row.hidden_models = json.dumps(["openai/gpt-image-1"])
|
||||
db = _RouteDb([row])
|
||||
router = model_routes.setup_model_routes(model_discovery=None)
|
||||
|
||||
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "_auth_disabled", lambda: True)
|
||||
monkeypatch.setattr(model_routes, "build_chat_url", lambda base: f"{base}/chat/completions")
|
||||
monkeypatch.setattr(threading, "Thread", _NoopThread)
|
||||
|
||||
result = _route_endpoint(router, "/api/models")(_route_request())
|
||||
|
||||
assert result["items"][0]["endpoint_name"] == "openrouter"
|
||||
assert result["items"][0]["category"] == "api"
|
||||
assert result["items"][0]["models"] == ["openai/gpt-image-1"]
|
||||
|
||||
|
||||
def test_api_models_openrouter_derives_legacy_visible_models(monkeypatch):
|
||||
row = _route_ep(
|
||||
"openrouter",
|
||||
"https://openrouter.ai/api/v1",
|
||||
cached_models=["m1", "m2", "m3"],
|
||||
pinned_models=None,
|
||||
api_key="fake-key",
|
||||
)
|
||||
row.hidden_models = json.dumps(["m2"])
|
||||
db = _RouteDb([row])
|
||||
router = model_routes.setup_model_routes(model_discovery=None)
|
||||
|
||||
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "_auth_disabled", lambda: True)
|
||||
monkeypatch.setattr(model_routes, "build_chat_url", lambda base: f"{base}/chat/completions")
|
||||
monkeypatch.setattr(threading, "Thread", _NoopThread)
|
||||
|
||||
result = _route_endpoint(router, "/api/models")(_route_request())
|
||||
|
||||
assert result["items"][0]["endpoint_name"] == "openrouter"
|
||||
assert result["items"][0]["models"] == ["m1", "m3"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
def __init__(self, servers):
|
||||
self._servers = servers
|
||||
|
||||
def filter(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return self._servers
|
||||
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self, servers):
|
||||
self._servers = servers
|
||||
|
||||
def query(self, *_args, **_kwargs):
|
||||
return FakeQuery(self._servers)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_all_enabled_runs_concurrently(monkeypatch):
|
||||
from src.mcp_manager import McpManager
|
||||
|
||||
manager = McpManager()
|
||||
|
||||
servers = [
|
||||
SimpleNamespace(
|
||||
id=1,
|
||||
name="server1",
|
||||
transport="stdio",
|
||||
command="cmd1",
|
||||
args=json.dumps([]),
|
||||
env=json.dumps({}),
|
||||
url=None,
|
||||
),
|
||||
SimpleNamespace(
|
||||
id=2,
|
||||
name="server2",
|
||||
transport="stdio",
|
||||
command="cmd2",
|
||||
args=json.dumps([]),
|
||||
env=json.dumps({}),
|
||||
url=None,
|
||||
),
|
||||
SimpleNamespace(
|
||||
id=3,
|
||||
name="server3",
|
||||
transport="stdio",
|
||||
command="cmd3",
|
||||
args=json.dumps([]),
|
||||
env=json.dumps({}),
|
||||
url=None,
|
||||
),
|
||||
]
|
||||
|
||||
# Patch the SessionLocal used by connect_all_enabled().
|
||||
import src.mcp_manager as mcp_manager
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_manager,
|
||||
"SessionLocal",
|
||||
lambda: FakeDB(servers),
|
||||
)
|
||||
|
||||
async def fake_connect_with_timeout(_server):
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# We're testing that connect_all_enabled launches these concurrently,
|
||||
# not the implementation of connect_server().
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"_connect_with_timeout",
|
||||
fake_connect_with_timeout,
|
||||
)
|
||||
|
||||
start = time.perf_counter()
|
||||
|
||||
await manager.connect_all_enabled()
|
||||
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
# Sequential would take ~3 seconds.
|
||||
# Concurrent should take about 1 second.
|
||||
assert 0.9 <= elapsed < 2.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_all_enabled_timeout_does_not_block_other_servers(monkeypatch):
|
||||
import src.mcp_manager as mcp_manager
|
||||
from src.mcp_manager import McpManager
|
||||
|
||||
manager = McpManager()
|
||||
|
||||
servers = [
|
||||
SimpleNamespace(
|
||||
id=1,
|
||||
name="fast1",
|
||||
transport="stdio",
|
||||
command="cmd1",
|
||||
args=json.dumps([]),
|
||||
env=json.dumps({}),
|
||||
url=None,
|
||||
),
|
||||
SimpleNamespace(
|
||||
id=2,
|
||||
name="slow",
|
||||
transport="stdio",
|
||||
command="cmd2",
|
||||
args=json.dumps([]),
|
||||
env=json.dumps({}),
|
||||
url=None,
|
||||
),
|
||||
SimpleNamespace(
|
||||
id=3,
|
||||
name="fast2",
|
||||
transport="stdio",
|
||||
command="cmd3",
|
||||
args=json.dumps([]),
|
||||
env=json.dumps({}),
|
||||
url=None,
|
||||
),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_manager,
|
||||
"SessionLocal",
|
||||
lambda: FakeDB(servers),
|
||||
)
|
||||
|
||||
completed = []
|
||||
|
||||
async def fake_connect_server(server_id, **kwargs):
|
||||
if server_id == 2:
|
||||
# Simulate a hung connection.
|
||||
await asyncio.sleep(30)
|
||||
else:
|
||||
await asyncio.sleep(0.1)
|
||||
completed.append(server_id)
|
||||
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"connect_server",
|
||||
fake_connect_server,
|
||||
)
|
||||
|
||||
#
|
||||
# Don't actually wait 20 seconds during the test.
|
||||
# Replace asyncio.wait_for used by mcp_manager with a much shorter timeout.
|
||||
#
|
||||
real_wait_for = asyncio.wait_for
|
||||
|
||||
async def short_wait_for(awaitable, timeout):
|
||||
return await real_wait_for(awaitable, timeout=0.2)
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_manager.asyncio,
|
||||
"wait_for",
|
||||
short_wait_for,
|
||||
)
|
||||
|
||||
start = time.perf_counter()
|
||||
|
||||
await manager.connect_all_enabled()
|
||||
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
assert set(completed) == {1, 3}
|
||||
assert elapsed < 1
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Regression coverage for passwordless Google OAuth reminder senders."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from routes.note_routes import dispatch_reminder
|
||||
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_dispatch_reminder_sends_with_google_oauth_without_smtp_password():
|
||||
cfg = {
|
||||
"account_name": "Workspace",
|
||||
"smtp_host": "smtp.gmail.com",
|
||||
"smtp_port": 587,
|
||||
"smtp_security": "starttls",
|
||||
"smtp_user": "alice@example.edu",
|
||||
"smtp_password": "",
|
||||
"from_address": "alice@example.edu",
|
||||
"oauth_provider": "google",
|
||||
}
|
||||
sent = []
|
||||
|
||||
def fake_send(actual_cfg, sender, recipients, message):
|
||||
sent.append((actual_cfg, sender, recipients, message))
|
||||
|
||||
with (
|
||||
patch("src.settings.load_settings", return_value={}),
|
||||
patch("routes.email_routes._get_email_config", return_value=cfg),
|
||||
patch("routes.email_helpers._send_smtp_message", side_effect=fake_send),
|
||||
patch("core.database.SessionLocal", side_effect=AssertionError("fallback lookup is not needed")),
|
||||
):
|
||||
result = asyncio.run(dispatch_reminder(
|
||||
"Reminder: Submit report",
|
||||
"The report is due today.",
|
||||
note_id="",
|
||||
owner="alice@example.edu",
|
||||
queue_browser=False,
|
||||
settings_override={
|
||||
"reminder_channel": "email",
|
||||
"reminder_llm_synthesis": False,
|
||||
},
|
||||
))
|
||||
|
||||
assert result["email_sent"] is True
|
||||
assert result["email_error"] == ""
|
||||
assert len(sent) == 1
|
||||
actual_cfg, sender, recipients, message = sent[0]
|
||||
assert actual_cfg is cfg
|
||||
assert sender == "alice@example.edu"
|
||||
assert recipients == ["alice@example.edu"]
|
||||
assert "Subject: Reminder (Odysseus): Submit report" in message
|
||||
|
||||
|
||||
def test_reminder_settings_offer_oauth_smtp_accounts():
|
||||
source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8")
|
||||
helper = source[source.index("const smtpAccountReady"):source.index("const smtpAccountReady") + 260]
|
||||
|
||||
assert "account.has_smtp_password || account.oauth_provider === 'google'" in helper
|
||||
assert source.count(".filter(smtpAccountReady)") == 2
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Regression test for the note route shim (slice 2f, #4082/#4071).
|
||||
|
||||
The backward-compat shim at ``routes/note_routes.py`` uses ``sys.modules``
|
||||
replacement so the legacy import path and the canonical ``routes.note.*``
|
||||
path resolve to the *same* module object. This is required because
|
||||
``test_note_reminder_fire_scope.py`` and ``test_notes_fail_closed_auth.py``
|
||||
do ``import routes.note_routes as note_routes`` followed by
|
||||
``monkeypatch.setattr(note_routes, "SessionLocal", ...)`` — 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.note_routes as _shim_note # noqa: F401
|
||||
|
||||
|
||||
def test_legacy_and_canonical_note_module_are_same_object():
|
||||
"""``import routes.note_routes`` must alias the canonical module."""
|
||||
legacy = importlib.import_module("routes.note_routes")
|
||||
canonical = importlib.import_module("routes.note.note_routes")
|
||||
assert legacy is canonical, (
|
||||
"routes.note_routes shim must resolve to the canonical "
|
||||
"routes.note.note_routes module object"
|
||||
)
|
||||
|
||||
|
||||
def test_monkeypatch_via_legacy_alias_reaches_canonical(monkeypatch):
|
||||
"""Patching through the legacy alias must reach the canonical module.
|
||||
|
||||
Several note tests do ``import routes.note_routes as note_routes``
|
||||
followed by ``monkeypatch.setattr(note_routes, "SessionLocal", ...)``.
|
||||
For that to take effect at runtime, the legacy module object and the
|
||||
canonical one must be identical.
|
||||
"""
|
||||
legacy = importlib.import_module("routes.note_routes")
|
||||
canonical = importlib.import_module("routes.note.note_routes")
|
||||
|
||||
sentinel = object()
|
||||
monkeypatch.setattr(legacy, "setup_note_routes", sentinel)
|
||||
assert canonical.setup_note_routes is sentinel, (
|
||||
"monkeypatch via legacy alias did not reach the canonical module"
|
||||
)
|
||||
@@ -1,13 +1,9 @@
|
||||
"""A plain text message that merely *looks* like a JSON array of objects must
|
||||
NOT be silently re-parsed into a list on reload.
|
||||
"""Persistence contracts for JSON-like text and multimodal chat content.
|
||||
|
||||
_parse_msg_content de-serializes multimodal (image/audio) content back into a
|
||||
list of content blocks. The old heuristic accepted ANY string that started
|
||||
with "[{" and contained the substring '"type"'. A user who pasted an API
|
||||
schema / sample such as `[{"type": "object", "name": "foo"}]` therefore had
|
||||
their text message permanently corrupted into a Python list on the next
|
||||
session hydration. The fix restricts the round-trip to lists whose elements
|
||||
are all recognized content-block types (text/image_url/audio/...).
|
||||
Plain text that resembles a JSON content-block list must remain an exact
|
||||
string. Real provider multimodal blocks follow the durable attachment
|
||||
contract: readable text plus stable attachment metadata is persisted, while
|
||||
raw inline media bytes are omitted.
|
||||
"""
|
||||
import tempfile
|
||||
import uuid
|
||||
@@ -65,16 +61,48 @@ def test_jsonlike_user_string_not_corrupted(manager):
|
||||
assert reloaded.history[0].content == text
|
||||
|
||||
|
||||
def test_real_multimodal_content_still_round_trips(manager):
|
||||
def test_real_multimodal_content_persists_reference_without_base64(manager):
|
||||
sid = "sess-" + uuid.uuid4().hex[:8]
|
||||
_make_session(sid)
|
||||
attachment_id = "a" * 32 + ".png"
|
||||
multimodal = [
|
||||
{"type": "text", "text": "what is this?"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
|
||||
]
|
||||
msgs = [ChatMessage(role="user", content=multimodal)]
|
||||
metadata = {
|
||||
"attachments": [
|
||||
{
|
||||
"id": attachment_id,
|
||||
"name": "diagram.png",
|
||||
"mime": "image/png",
|
||||
"size": 4,
|
||||
"checksum_sha256": "sha256-digest",
|
||||
}
|
||||
]
|
||||
}
|
||||
msgs = [ChatMessage(role="user", content=multimodal, metadata=metadata)]
|
||||
assert manager.replace_messages(sid, msgs) is True
|
||||
|
||||
expected = (
|
||||
"what is this?\n"
|
||||
"[1 inline media payload omitted]\n"
|
||||
f"[Attachment: diagram.png | id={attachment_id} | mime=image/png | "
|
||||
"size=4 bytes | sha256=sha256-digest]"
|
||||
)
|
||||
|
||||
db = _TS()
|
||||
try:
|
||||
stored = db.query(cdb.ChatMessage).filter_by(session_id=sid).one()
|
||||
assert stored.content == expected
|
||||
assert "what is this?" in stored.content
|
||||
assert attachment_id in stored.content
|
||||
assert "data:image/png;base64,AAAA" not in stored.content
|
||||
assert "base64" not in stored.content
|
||||
assert "AAAA" not in stored.content
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
manager.sessions.clear()
|
||||
reloaded = manager.get_session(sid)
|
||||
assert reloaded.history[0].content == multimodal
|
||||
assert reloaded.history[0].content == expected
|
||||
assert reloaded.history[0].metadata["attachments"][0]["id"] == attachment_id
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Regression guard for #5559 — the KEYWORD index (load_personal_index, which
|
||||
PersonalDocsManager.refresh_index builds from) must skip hidden dirs, hidden
|
||||
files, and junk dirs at ANY depth, the same as the vector index. Both walkers
|
||||
share one pruning helper (src/index_walk) so they cannot drift again.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
from src.personal_docs import load_personal_index
|
||||
|
||||
|
||||
def _write(path, content="real content"):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def _indexed(root):
|
||||
return {rec["name"] for rec in load_personal_index(str(root))}
|
||||
|
||||
|
||||
def test_keyword_index_skips_hidden_and_junk_dirs(tmp_path):
|
||||
_write(tmp_path / "note.md")
|
||||
_write(tmp_path / "sub" / "deeper.md")
|
||||
_write(tmp_path / ".obsidian" / "workspace.json")
|
||||
_write(tmp_path / ".git" / "hooks.md")
|
||||
_write(tmp_path / "node_modules" / "lib" / "readme.md")
|
||||
_write(tmp_path / "__pycache__" / "cached.txt")
|
||||
_write(tmp_path / "venv" / "lib" / "site.txt")
|
||||
assert _indexed(tmp_path) == {"note.md", os.path.join("sub", "deeper.md")}
|
||||
|
||||
|
||||
def test_keyword_index_skips_hidden_files(tmp_path):
|
||||
_write(tmp_path / "visible.md")
|
||||
_write(tmp_path / ".hidden.md")
|
||||
_write(tmp_path / "sub" / ".secret.txt")
|
||||
assert _indexed(tmp_path) == {"visible.md"}
|
||||
|
||||
|
||||
def test_keyword_index_prunes_junk_at_depth(tmp_path):
|
||||
"""Pruning must apply at every level, not just the first (the vector test's
|
||||
fixtures only nested one level under the root)."""
|
||||
_write(tmp_path / "a" / "b" / "keep.md")
|
||||
_write(tmp_path / "a" / "b" / "node_modules" / "dep.md")
|
||||
_write(tmp_path / "a" / ".obsidian" / "deep.json")
|
||||
assert _indexed(tmp_path) == {os.path.join("a", "b", "keep.md")}
|
||||
|
||||
|
||||
def test_keyword_index_junk_match_is_case_insensitive(tmp_path):
|
||||
"""A case-variant junk dir must still be pruned (macOS default FS is
|
||||
case-insensitive, so `Node_Modules` and `node_modules` are the same dir)."""
|
||||
_write(tmp_path / "keep.md")
|
||||
_write(tmp_path / "Node_Modules" / "dep.md")
|
||||
assert _indexed(tmp_path) == {"keep.md"}
|
||||
|
||||
|
||||
def test_keyword_index_explicit_hidden_root_still_indexed(tmp_path):
|
||||
"""Children-only pruning: pointing indexing at a hidden dir gets its
|
||||
contents, minus nested hidden/junk."""
|
||||
root = tmp_path / ".notes"
|
||||
_write(root / "idea.md")
|
||||
_write(root / ".obsidian" / "plugin.json")
|
||||
assert {rec["name"] for rec in load_personal_index(str(root))} == {"idea.md"}
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Regression guard for #5559 — directory indexing must skip hidden directories,
|
||||
hidden files, and well-known junk directories.
|
||||
|
||||
VectorRAG.index_personal_documents walked the whole tree with no pruning, so
|
||||
pointing RAG at a real-world folder (an Obsidian vault, a git repo) swept in
|
||||
`.obsidian/` plugin JavaScript, `.git/` internals, `node_modules/`, etc. The
|
||||
junk multiplied indexing time and polluted retrieval.
|
||||
|
||||
These tests are hermetic — no chromadb; VectorRAG is created via __new__ (skip
|
||||
Chroma connect) with add_document stubbed to record which files get indexed.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
import src.rag_vector as rag_vector
|
||||
|
||||
|
||||
def _make_rag(recorded_sources):
|
||||
rag = rag_vector.VectorRAG.__new__(rag_vector.VectorRAG) # skip Chroma connect
|
||||
|
||||
def _record(text, metadata):
|
||||
recorded_sources.add(metadata["source"])
|
||||
return True
|
||||
|
||||
rag.add_document = _record
|
||||
return rag
|
||||
|
||||
|
||||
def _write(path, content="some real content"):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def test_index_skips_hidden_and_junk_directories(tmp_path):
|
||||
_write(tmp_path / "note.md")
|
||||
_write(tmp_path / "sub" / "deeper.md")
|
||||
_write(tmp_path / ".obsidian" / "plugins" / "plugin.js")
|
||||
_write(tmp_path / ".git" / "hooks.js")
|
||||
_write(tmp_path / "node_modules" / "lib" / "index.js")
|
||||
_write(tmp_path / "__pycache__" / "cached.py")
|
||||
_write(tmp_path / "venv" / "lib" / "site.py")
|
||||
|
||||
recorded = set()
|
||||
rag = _make_rag(recorded)
|
||||
result = rag.index_personal_documents(str(tmp_path))
|
||||
|
||||
assert result["success"] is True
|
||||
indexed = {os.path.relpath(p, str(tmp_path)) for p in recorded}
|
||||
assert indexed == {"note.md", os.path.join("sub", "deeper.md")}
|
||||
|
||||
|
||||
def test_index_skips_hidden_files(tmp_path):
|
||||
_write(tmp_path / "visible.md")
|
||||
_write(tmp_path / ".hidden.md")
|
||||
_write(tmp_path / "sub" / ".secret.txt")
|
||||
|
||||
recorded = set()
|
||||
rag = _make_rag(recorded)
|
||||
rag.index_personal_documents(str(tmp_path))
|
||||
|
||||
indexed = {os.path.relpath(p, str(tmp_path)) for p in recorded}
|
||||
assert indexed == {"visible.md"}
|
||||
|
||||
|
||||
def test_explicitly_passed_hidden_root_is_still_indexed(tmp_path):
|
||||
"""Pruning applies to children only — a user who deliberately points RAG at
|
||||
a hidden directory gets its contents, minus nested hidden/junk dirs."""
|
||||
root = tmp_path / ".notes"
|
||||
_write(root / "idea.md")
|
||||
_write(root / ".obsidian" / "plugin.js")
|
||||
|
||||
recorded = set()
|
||||
rag = _make_rag(recorded)
|
||||
rag.index_personal_documents(str(root))
|
||||
|
||||
indexed = {os.path.relpath(p, str(root)) for p in recorded}
|
||||
assert indexed == {"idea.md"}
|
||||
@@ -1,14 +1,9 @@
|
||||
"""replace_messages must JSON-serialize multimodal (list) content.
|
||||
"""replace_messages must persist readable, path-free multimodal history.
|
||||
|
||||
A chat with an image/audio attachment carries list content. When such a
|
||||
chat is compacted, the manual-compaction path calls replace_messages with
|
||||
the retained messages. replace_messages wrote message.content straight into
|
||||
the Text column, so SQLAlchemy bound the list\'s single-quoted repr. On
|
||||
reload _parse_msg_content only de-serializes a string that contains the
|
||||
double-quoted "type", so the repr failed the check and the message came
|
||||
back as a corrupted string blob - the attachment was destroyed. The
|
||||
sibling _persist_message json.dumps-es list content; replace_messages did
|
||||
not.
|
||||
Live model input may contain provider-specific media blocks and inline data
|
||||
URLs. Compaction uses replace_messages for the retained transcript, which must
|
||||
store readable text plus stable structured attachment references without
|
||||
copying raw base64 payloads into ChatMessage.content.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
@@ -27,6 +22,7 @@ def manager(monkeypatch):
|
||||
monkeypatch.setattr(sm, "SessionLocal", _TS)
|
||||
mgr = sm.SessionManager.__new__(sm.SessionManager)
|
||||
mgr.sessions = {}
|
||||
mgr.upload_handler = None
|
||||
return mgr
|
||||
|
||||
|
||||
@@ -41,33 +37,71 @@ def _make_session(sid, owner="alice"):
|
||||
db.close()
|
||||
|
||||
|
||||
def test_multimodal_content_round_trips_through_replace_messages(manager):
|
||||
def test_multimodal_content_persists_text_and_attachment_ref_without_payload(manager):
|
||||
sid = "sess-" + uuid.uuid4().hex[:8]
|
||||
_make_session(sid)
|
||||
|
||||
upload_id = "a" * 32 + ".png"
|
||||
multimodal = [
|
||||
{"type": "text", "text": "what is this?"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
|
||||
]
|
||||
msgs = [ChatMessage(role="user", content=multimodal)]
|
||||
msgs = [ChatMessage(
|
||||
role="user",
|
||||
content=multimodal,
|
||||
metadata={
|
||||
"attachments": [{
|
||||
"id": upload_id,
|
||||
"name": "diagram.png",
|
||||
"mime": "image/png",
|
||||
"size": 4,
|
||||
"checksum_sha256": "sha256-digest",
|
||||
}]
|
||||
},
|
||||
)]
|
||||
assert manager.replace_messages(sid, msgs) is True
|
||||
|
||||
expected = (
|
||||
"what is this?\n"
|
||||
"[1 inline media payload omitted]\n"
|
||||
f"[Attachment: diagram.png | id={upload_id} | mime=image/png | "
|
||||
"size=4 bytes | sha256=sha256-digest]"
|
||||
)
|
||||
|
||||
db = _TS()
|
||||
try:
|
||||
stored = db.query(cdb.ChatMessage).filter_by(session_id=sid).one()
|
||||
assert stored.content == expected
|
||||
assert "data:image/png;base64,AAAA" not in stored.content
|
||||
assert "base64" not in stored.content
|
||||
assert "AAAA" not in stored.content
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Drop the in-memory cache so the next read hydrates from the DB.
|
||||
manager.sessions.clear()
|
||||
reloaded = manager.get_session(sid)
|
||||
assert len(reloaded.history) == 1
|
||||
# Content must come back as the original list, not a repr string blob.
|
||||
assert reloaded.history[0].content == multimodal
|
||||
persisted = reloaded.history[0].content
|
||||
assert isinstance(persisted, str)
|
||||
assert persisted == expected
|
||||
assert reloaded.history[0].metadata["attachments"][0]["id"] == upload_id
|
||||
assert (
|
||||
reloaded.history[0].metadata["attachments"][0]["checksum_sha256"]
|
||||
== "sha256-digest"
|
||||
)
|
||||
|
||||
|
||||
def test_plain_string_content_still_round_trips(manager):
|
||||
def test_jsonlike_plain_string_content_still_round_trips(manager):
|
||||
sid = "sess-" + uuid.uuid4().hex[:8]
|
||||
_make_session(sid)
|
||||
msgs = [ChatMessage(role="user", content="just text")]
|
||||
text = '[{"type": "object", "name": "foo"}]'
|
||||
msgs = [ChatMessage(role="user", content=text)]
|
||||
assert manager.replace_messages(sid, msgs) is True
|
||||
manager.sessions.clear()
|
||||
reloaded = manager.get_session(sid)
|
||||
assert reloaded.history[0].content == "just text"
|
||||
assert isinstance(reloaded.history[0].content, str)
|
||||
assert reloaded.history[0].content == text
|
||||
|
||||
|
||||
def test_replace_messages_keeps_history_alias_for_context_messages(manager):
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Upload lifecycle guarantees for compaction's replace_messages path."""
|
||||
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import event
|
||||
|
||||
import core.database as cdb
|
||||
import core.session_manager as session_manager_module
|
||||
from core.models import ChatMessage
|
||||
from src.upload_handler import UploadHandler
|
||||
from tests.helpers.sqlite_db import make_temp_sqlite
|
||||
|
||||
|
||||
OLD_TIMESTAMP = "2000-01-01T00:00:00"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager_db(monkeypatch):
|
||||
SessionLocal, engine, tmpfile = make_temp_sqlite(cdb.Base.metadata)
|
||||
monkeypatch.setattr(session_manager_module, "SessionLocal", SessionLocal)
|
||||
manager = session_manager_module.SessionManager.__new__(
|
||||
session_manager_module.SessionManager
|
||||
)
|
||||
manager.sessions = {}
|
||||
manager.upload_handler = None
|
||||
try:
|
||||
yield manager, SessionLocal, engine
|
||||
finally:
|
||||
engine.dispose()
|
||||
tmpfile.close()
|
||||
try:
|
||||
os.unlink(tmpfile.name)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _seed_session(SessionLocal, *, owner="alice", content="existing durable history"):
|
||||
session_id = "replace-" + uuid.uuid4().hex
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(cdb.Session(
|
||||
id=session_id,
|
||||
owner=owner,
|
||||
name="Compaction reservation regression",
|
||||
model="test-model",
|
||||
endpoint_url="http://localhost:11434",
|
||||
archived=False,
|
||||
message_count=1,
|
||||
))
|
||||
db.add(cdb.ChatMessage(
|
||||
id="message-" + uuid.uuid4().hex,
|
||||
session_id=session_id,
|
||||
role="user",
|
||||
content=content,
|
||||
meta_data=json.dumps({"source": "before-replacement"}),
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
return session_id
|
||||
|
||||
|
||||
def _attachment_message(upload_id, text):
|
||||
return ChatMessage(
|
||||
role="user",
|
||||
content=text,
|
||||
metadata={
|
||||
"attachments": [{
|
||||
"id": upload_id,
|
||||
"name": f"{text}.txt",
|
||||
"mime": "text/plain",
|
||||
"size": len(text),
|
||||
}]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _durable_messages(SessionLocal, session_id):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return [
|
||||
(message.role, message.content, message.meta_data)
|
||||
for message in db.query(cdb.ChatMessage)
|
||||
.filter(cdb.ChatMessage.session_id == session_id)
|
||||
.order_by(cdb.ChatMessage.timestamp, cdb.ChatMessage.id)
|
||||
.all()
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_replace_messages_reserves_every_incoming_attachment_before_delete(
|
||||
manager_db,
|
||||
monkeypatch,
|
||||
):
|
||||
manager, SessionLocal, engine = manager_db
|
||||
session_id = _seed_session(SessionLocal)
|
||||
manager.upload_handler = object()
|
||||
incoming = [
|
||||
_attachment_message("1" * 32 + ".txt", "first"),
|
||||
_attachment_message("2" * 32 + ".txt", "second"),
|
||||
]
|
||||
message_mutations = []
|
||||
reservation_calls = []
|
||||
|
||||
def record_sql(_conn, _cursor, statement, _parameters, _context, _executemany):
|
||||
normalized = statement.lstrip().upper()
|
||||
if normalized.startswith(("DELETE FROM CHAT_MESSAGES", "INSERT INTO CHAT_MESSAGES")):
|
||||
message_mutations.append(normalized.split(maxsplit=1)[0])
|
||||
|
||||
def reserve(handler, owner, content, metadata):
|
||||
assert message_mutations == []
|
||||
reservation_calls.append((handler, owner, content, metadata))
|
||||
return None
|
||||
|
||||
event.listen(engine, "before_cursor_execute", record_sql)
|
||||
monkeypatch.setattr(
|
||||
session_manager_module,
|
||||
"reserve_message_upload_references",
|
||||
reserve,
|
||||
)
|
||||
try:
|
||||
assert manager.replace_messages(session_id, incoming) is True
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", record_sql)
|
||||
|
||||
assert [call[2] for call in reservation_calls] == ["first", "second"]
|
||||
assert all(call[0] is manager.upload_handler for call in reservation_calls)
|
||||
assert all(call[1] == "alice" for call in reservation_calls)
|
||||
assert message_mutations == ["DELETE", "INSERT"]
|
||||
|
||||
|
||||
def test_replace_messages_reservation_failure_leaves_durable_history_unchanged(
|
||||
manager_db,
|
||||
monkeypatch,
|
||||
):
|
||||
manager, SessionLocal, engine = manager_db
|
||||
session_id = _seed_session(SessionLocal)
|
||||
before = _durable_messages(SessionLocal, session_id)
|
||||
manager.upload_handler = object()
|
||||
missing_upload_id = "4" * 32 + ".txt"
|
||||
incoming = [
|
||||
_attachment_message("3" * 32 + ".txt", "available"),
|
||||
_attachment_message(missing_upload_id, "missing"),
|
||||
]
|
||||
calls = []
|
||||
message_mutations = []
|
||||
|
||||
def record_sql(_conn, _cursor, statement, _parameters, _context, _executemany):
|
||||
normalized = statement.lstrip().upper()
|
||||
if normalized.startswith(("DELETE FROM CHAT_MESSAGES", "INSERT INTO CHAT_MESSAGES")):
|
||||
message_mutations.append(normalized.split(maxsplit=1)[0])
|
||||
|
||||
def reserve(_handler, _owner, content, _metadata):
|
||||
calls.append(content)
|
||||
return missing_upload_id if content == "missing" else None
|
||||
|
||||
event.listen(engine, "before_cursor_execute", record_sql)
|
||||
monkeypatch.setattr(
|
||||
session_manager_module,
|
||||
"reserve_message_upload_references",
|
||||
reserve,
|
||||
)
|
||||
try:
|
||||
assert manager.replace_messages(session_id, incoming) is False
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", record_sql)
|
||||
|
||||
assert calls == ["available", "missing"]
|
||||
assert message_mutations == []
|
||||
assert _durable_messages(SessionLocal, session_id) == before
|
||||
assert [message.content for message in manager.sessions[session_id].history] == [
|
||||
"existing durable history"
|
||||
]
|
||||
assert all("_db_id" not in (message.metadata or {}) for message in incoming)
|
||||
|
||||
|
||||
def test_cleanup_cannot_delete_attachment_during_concurrent_compaction_replacement(
|
||||
manager_db,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
manager, SessionLocal, _engine = manager_db
|
||||
session_id = _seed_session(SessionLocal)
|
||||
base_dir = tmp_path / "base"
|
||||
upload_dir = tmp_path / "uploads"
|
||||
base_dir.mkdir()
|
||||
upload_dir.mkdir()
|
||||
handler = UploadHandler(str(base_dir), str(upload_dir))
|
||||
manager.upload_handler = handler
|
||||
|
||||
upload_id = "5" * 32 + ".txt"
|
||||
upload_hash = "6" * 64
|
||||
dated_dir = upload_dir / "2000" / "01" / "01"
|
||||
dated_dir.mkdir(parents=True)
|
||||
upload_path = dated_dir / upload_id
|
||||
upload_path.write_text("attachment retained by compaction", encoding="utf-8")
|
||||
upload_row = {
|
||||
"id": upload_id,
|
||||
"path": str(upload_path),
|
||||
"mime": "text/plain",
|
||||
"size": upload_path.stat().st_size,
|
||||
"name": "compaction.txt",
|
||||
"original_name": "compaction.txt",
|
||||
"hash": upload_hash,
|
||||
"checksum_sha256": upload_hash,
|
||||
"uploaded_at": OLD_TIMESTAMP,
|
||||
"created_at": OLD_TIMESTAMP,
|
||||
"last_accessed": OLD_TIMESTAMP,
|
||||
"owner": "alice",
|
||||
}
|
||||
(upload_dir / "uploads.json").write_text(
|
||||
json.dumps({f"alice:{upload_hash}": upload_row}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
handler._index_cache = None
|
||||
|
||||
reservation_write_entered = threading.Event()
|
||||
release_reservation_write = threading.Event()
|
||||
real_atomic_write = handler._atomic_write_json
|
||||
|
||||
def block_reservation_write(path, data, *, sync_backup=False):
|
||||
refreshed = any(
|
||||
isinstance(row, dict)
|
||||
and row.get("id") == upload_id
|
||||
and row.get("last_accessed") != OLD_TIMESTAMP
|
||||
for row in data.values()
|
||||
)
|
||||
if sync_backup and refreshed and not reservation_write_entered.is_set():
|
||||
reservation_write_entered.set()
|
||||
assert release_reservation_write.wait(5)
|
||||
return real_atomic_write(path, data, sync_backup=sync_backup)
|
||||
|
||||
monkeypatch.setattr(handler, "_atomic_write_json", block_reservation_write)
|
||||
incoming = [_attachment_message(upload_id, "retained after compaction")]
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||||
replace_future = pool.submit(manager.replace_messages, session_id, incoming)
|
||||
assert reservation_write_entered.wait(5)
|
||||
cleanup_future = pool.submit(handler.cleanup_old_uploads, set(), set())
|
||||
try:
|
||||
with pytest.raises(concurrent.futures.TimeoutError):
|
||||
cleanup_future.result(timeout=0.1)
|
||||
finally:
|
||||
release_reservation_write.set()
|
||||
|
||||
assert replace_future.result(timeout=5) is True
|
||||
assert cleanup_future.result(timeout=5) == 0
|
||||
|
||||
assert upload_path.is_file()
|
||||
assert handler.resolve_upload(upload_id, owner="alice") is not None
|
||||
durable = _durable_messages(SessionLocal, session_id)
|
||||
assert len(durable) == 1
|
||||
assert json.loads(durable[0][2])["attachments"][0]["id"] == upload_id
|
||||
@@ -354,6 +354,7 @@ async def test_build_chat_context_incognito_does_not_duplicate_current_user_mess
|
||||
monkeypatch.setitem(sys.modules, mod_name, MagicMock())
|
||||
|
||||
chat_helpers = importlib.import_module("routes.chat_helpers")
|
||||
chat_helpers._INCOGNITO_CONTEXTS.clear()
|
||||
|
||||
async def fake_preprocess(chat_handler, message, att_ids, sess, **kwargs):
|
||||
# **kwargs absorbs auto_opened_docs (added when PDF imports auto-create
|
||||
@@ -417,6 +418,68 @@ async def test_build_chat_context_incognito_does_not_duplicate_current_user_mess
|
||||
assert len(user_messages) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_chat_context_incognito_ignores_saved_session_history(monkeypatch):
|
||||
for mod_name in [
|
||||
"starlette.middleware",
|
||||
"starlette.middleware.base",
|
||||
"core.models",
|
||||
"core.database",
|
||||
"routes.prefs_routes",
|
||||
"routes.research_routes",
|
||||
"src.llm_core",
|
||||
"src.context_compactor",
|
||||
"src.model_context",
|
||||
"src.auth_helpers",
|
||||
]:
|
||||
if mod_name not in sys.modules:
|
||||
monkeypatch.setitem(sys.modules, mod_name, MagicMock())
|
||||
|
||||
chat_helpers = importlib.import_module("routes.chat_helpers")
|
||||
chat_helpers._INCOGNITO_CONTEXTS.clear()
|
||||
|
||||
async def fake_preprocess(chat_handler, message, att_ids, sess, **kwargs):
|
||||
return chat_helpers.PreprocessedMessage(
|
||||
enhanced_message=message,
|
||||
user_content=message,
|
||||
text_for_context=message,
|
||||
youtube_transcripts=[],
|
||||
attachment_meta=[],
|
||||
)
|
||||
|
||||
async def fake_maybe_compact(sess, endpoint_url, model, messages, headers, owner=None):
|
||||
return messages, 123, False
|
||||
|
||||
monkeypatch.setattr(chat_helpers, "preprocess", fake_preprocess)
|
||||
monkeypatch.setattr(chat_helpers, "extract_preset", lambda *_args, **_kwargs: chat_helpers.PresetInfo(0.7, 1024, None, None))
|
||||
monkeypatch.setattr(chat_helpers, "load_prefs_for_user", lambda user: {})
|
||||
monkeypatch.setattr(chat_helpers, "effective_user", lambda request: "tester")
|
||||
monkeypatch.setattr(chat_helpers, "normalize_model_id", lambda endpoint_url, model, **kwargs: None)
|
||||
monkeypatch.setattr(chat_helpers, "maybe_compact", fake_maybe_compact)
|
||||
monkeypatch.setattr(chat_helpers, "trim_for_context", lambda messages, context_length: messages)
|
||||
|
||||
sess = SimpleNamespace(
|
||||
endpoint_url="http://localhost:8000/v1",
|
||||
model="test-model",
|
||||
headers={},
|
||||
get_context_messages=lambda: [{"role": "user", "content": "older non-incognito secret"}],
|
||||
)
|
||||
chat_processor = SimpleNamespace(build_context_preface=lambda **kwargs: ([], [], []))
|
||||
|
||||
ctx = await chat_helpers.build_chat_context(
|
||||
sess=sess,
|
||||
request=SimpleNamespace(),
|
||||
chat_handler=SimpleNamespace(),
|
||||
chat_processor=chat_processor,
|
||||
message="fresh incognito turn",
|
||||
session_id="s-incog",
|
||||
incognito=True,
|
||||
)
|
||||
|
||||
assert {"role": "user", "content": "fresh incognito turn"} in ctx.messages
|
||||
assert all(m.get("content") != "older non-incognito secret" for m in ctx.messages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_agent_tools_require_admin(monkeypatch):
|
||||
auth_mod = _install_core_auth_stub(monkeypatch)
|
||||
@@ -648,6 +711,7 @@ async def test_public_agent_policy_blocks_sensitive_tools(monkeypatch):
|
||||
# here instead of silently shrinking the blocklist.
|
||||
bare_email_tools = (
|
||||
"list_email_accounts", "list_emails", "read_email", "search_emails",
|
||||
"scan_email_unsubscribes", "unsubscribe_email",
|
||||
"send_email", "reply_to_email", "draft_email", "draft_email_reply",
|
||||
"ai_draft_email_reply", "archive_email", "delete_email",
|
||||
"mark_email_read", "bulk_email", "download_attachment",
|
||||
@@ -758,6 +822,7 @@ async def test_disable_tool_email_covers_full_builtin_set(monkeypatch):
|
||||
# from the constant fails here instead of silently shrinking the toggle.
|
||||
bare_email_tools = (
|
||||
"list_email_accounts", "list_emails", "read_email", "search_emails",
|
||||
"scan_email_unsubscribes", "unsubscribe_email",
|
||||
"send_email", "reply_to_email", "draft_email", "draft_email_reply",
|
||||
"ai_draft_email_reply", "archive_email", "delete_email",
|
||||
"mark_email_read", "bulk_email", "download_attachment",
|
||||
@@ -930,7 +995,7 @@ async def test_plan_mode_blocks_mutating_email_aliases_without_mcp_inventory(mon
|
||||
denied = plan_mode_disabled_tools()
|
||||
|
||||
for tool_name in ("draft_email", "draft_email_reply", "ai_draft_email_reply",
|
||||
"download_attachment", "send_email", "delete_email"):
|
||||
"download_attachment", "send_email", "delete_email", "unsubscribe_email"):
|
||||
desc, result = await execute_tool_block(
|
||||
SimpleNamespace(tool_type=tool_name, content="{}"),
|
||||
owner="admin-user",
|
||||
@@ -949,6 +1014,17 @@ async def test_plan_mode_blocks_mutating_email_aliases_without_mcp_inventory(mon
|
||||
("mcp__email__search_emails", {"query": "x", "_odysseus_owner": "admin-user"}),
|
||||
]
|
||||
|
||||
mcp.calls.clear()
|
||||
desc, result = await execute_tool_block(
|
||||
SimpleNamespace(tool_type="scan_email_unsubscribes", content='{"limit": 1}'),
|
||||
owner="admin-user",
|
||||
disabled_tools=denied,
|
||||
)
|
||||
assert result["exit_code"] == 0
|
||||
assert mcp.calls == [
|
||||
("mcp__email__scan_email_unsubscribes", {"limit": 1, "_odysseus_owner": "admin-user"}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_email_dispatch_empty_content_calls_with_empty_args(monkeypatch):
|
||||
|
||||
@@ -4,7 +4,7 @@ Providers like Moonshot (Kimi K2.5/K2.6) require reasoning_content on
|
||||
assistant tool-call messages. Stripping it causes HTTP 400 in multi-turn
|
||||
tool calling when thinking mode is enabled.
|
||||
|
||||
See: https://github.com/pewdiepie-archdaemon/odysseus/issues/3118
|
||||
See: https://github.com/odysseus-dev/odysseus/issues/3118
|
||||
"""
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Regression: two concurrent callers of `_scheduled_poll_once` (the
|
||||
in-process 30s poller and the `odysseus-mail poll-scheduled` CLI, which the
|
||||
project's own docstrings warn can race on the same SQLite when
|
||||
ODYSSEUS_INPROCESS_POLLERS is left enabled alongside an external cron/systemd
|
||||
driver) must not both send the same scheduled email.
|
||||
|
||||
The old code selected pending rows, then only updated their status to 'sent'
|
||||
*after* the SMTP send completed - two overlapping calls can both SELECT the
|
||||
same 'pending' row before either UPDATEs it, so both send it. The fix adds
|
||||
an atomic claim step (`UPDATE ... SET status='sending' WHERE status='pending'`)
|
||||
before any work happens; only the caller whose UPDATE actually changes a row
|
||||
proceeds, the other sees rowcount == 0 and skips it.
|
||||
|
||||
This test drives two real threads through the real `_scheduled_poll_once`
|
||||
against a shared SQLite file, synchronized with a barrier so both reach the
|
||||
SELECT at (as close to) the same moment as possible, and asserts the send
|
||||
callback fired exactly once.
|
||||
"""
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def test_concurrent_pollers_do_not_double_send(tmp_path, monkeypatch):
|
||||
import routes.email_helpers as email_helpers
|
||||
import routes.email_pollers as email_pollers
|
||||
|
||||
db_path = tmp_path / "scheduled_emails.db"
|
||||
monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
|
||||
monkeypatch.setattr(email_pollers, "SCHEDULED_DB", db_path)
|
||||
email_helpers._init_scheduled_db()
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO scheduled_emails
|
||||
(id, to_addr, subject, body, attachments, send_at, created_at, status, account_id, owner)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
|
||||
""",
|
||||
(
|
||||
"sched-race-1",
|
||||
"recipient@example.com",
|
||||
"Subject",
|
||||
"Body",
|
||||
"[]",
|
||||
"2000-01-01T00:00:00",
|
||||
"1999-12-31T00:00:00",
|
||||
"acct-alice",
|
||||
"alice",
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
send_calls = []
|
||||
send_lock = threading.Lock()
|
||||
barrier = threading.Barrier(2)
|
||||
|
||||
def fake_get_email_config(account_id=None, owner=""):
|
||||
return {
|
||||
"from_address": "alice@example.com",
|
||||
"smtp_host": "smtp.example.com",
|
||||
"smtp_user": "alice@example.com",
|
||||
"smtp_password": "secret",
|
||||
}
|
||||
|
||||
def fake_send_smtp_message(*args, **kwargs):
|
||||
# Widen the window between the claim and the actual send so a
|
||||
# buggy (unclaimed) second poller has every opportunity to also
|
||||
# get past its SELECT and attempt to send.
|
||||
time.sleep(0.05)
|
||||
with send_lock:
|
||||
send_calls.append(threading.get_ident())
|
||||
|
||||
class FakeImap:
|
||||
def __init__(self, account_id=None, owner=""):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def append(self, folder, flags, date_time, message):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(email_pollers, "_get_email_config", fake_get_email_config)
|
||||
monkeypatch.setattr(email_pollers, "_send_smtp_message", fake_send_smtp_message)
|
||||
monkeypatch.setattr(email_pollers, "_imap", FakeImap)
|
||||
monkeypatch.setattr(email_pollers, "_detect_sent_folder", lambda imap: "Sent")
|
||||
monkeypatch.setattr(email_pollers, "_cleanup_compose_uploads", lambda attachments: None)
|
||||
|
||||
results = []
|
||||
|
||||
def _run():
|
||||
barrier.wait()
|
||||
results.append(email_pollers._scheduled_poll_once())
|
||||
|
||||
threads = [threading.Thread(target=_run) for _ in range(2)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=5)
|
||||
|
||||
assert len(send_calls) == 1, (
|
||||
f"expected exactly one send for the racing pollers, got {len(send_calls)}: "
|
||||
"the second poller must lose the atomic claim and skip the row"
|
||||
)
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
status = conn.execute(
|
||||
"SELECT status FROM scheduled_emails WHERE id=?", ("sched-race-1",)
|
||||
).fetchone()[0]
|
||||
conn.close()
|
||||
assert status == "sent"
|
||||
@@ -0,0 +1,77 @@
|
||||
import json
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from core.database import Base, ChatMessage, GalleryImage, Session
|
||||
from src import session_image_cleanup
|
||||
|
||||
|
||||
def test_cleanup_session_images_deactivates_gallery_rows_and_unlinks_files(tmp_path, monkeypatch):
|
||||
image_dir = tmp_path / "generated_images"
|
||||
image_dir.mkdir()
|
||||
linked_file = image_dir / "aaaaaaaaaaaa.png"
|
||||
event_file = image_dir / "bbbbbbbbbbbb.png"
|
||||
linked_file.write_bytes(b"linked")
|
||||
event_file.write_bytes(b"event")
|
||||
monkeypatch.setattr(session_image_cleanup, "GENERATED_IMAGES_DIR", str(image_dir))
|
||||
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'cleanup.db'}",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(Session(id="chat-1", name="Image chat", endpoint_url="http://local", model="image-model", owner="alice"))
|
||||
db.add(
|
||||
GalleryImage(
|
||||
id="img-linked",
|
||||
filename=linked_file.name,
|
||||
prompt="linked",
|
||||
owner="alice",
|
||||
session_id="chat-1",
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
GalleryImage(
|
||||
id="img-event",
|
||||
filename=event_file.name,
|
||||
prompt="event",
|
||||
owner="alice",
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
ChatMessage(
|
||||
id="msg-1",
|
||||
session_id="chat-1",
|
||||
role="assistant",
|
||||
content="Generated image",
|
||||
meta_data=json.dumps(
|
||||
{
|
||||
"tool_events": [
|
||||
{
|
||||
"image_id": "img-event",
|
||||
"image_url": f"/api/generated-image/{event_file.name}",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
removed = session_image_cleanup.cleanup_session_images("chat-1", db=db)
|
||||
|
||||
assert removed == 2
|
||||
assert not linked_file.exists()
|
||||
assert not event_file.exists()
|
||||
assert db.query(GalleryImage).filter_by(id="img-linked").first().is_active is False
|
||||
assert db.query(GalleryImage).filter_by(id="img-event").first().is_active is False
|
||||
finally:
|
||||
db.close()
|
||||
@@ -71,7 +71,7 @@ def test_fetch_bytes_rejects_cross_host_redirect(monkeypatch):
|
||||
monkeypatch.setattr("services.memory.skill_importer.httpx.Client", _Client)
|
||||
monkeypatch.setattr(
|
||||
"services.memory.skill_importer.check_outbound_url",
|
||||
lambda url: (True, ""),
|
||||
lambda url, **kwargs: (True, ""),
|
||||
)
|
||||
with pytest.raises(SkillImportError, match="redirect target"):
|
||||
_fetch_bytes("https://raw.githubusercontent.com/o/r/main/SKILL.md")
|
||||
@@ -91,7 +91,7 @@ def test_list_github_dir_accepts_api_github_response(monkeypatch):
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.memory.skill_importer.check_outbound_url",
|
||||
lambda url: (True, ""),
|
||||
lambda url, **kwargs: (True, ""),
|
||||
)
|
||||
|
||||
class _Resp:
|
||||
@@ -146,7 +146,7 @@ def _mock_httpx_client(monkeypatch, response):
|
||||
monkeypatch.setattr("services.memory.skill_importer.httpx.Client", _Client)
|
||||
monkeypatch.setattr(
|
||||
"services.memory.skill_importer.check_outbound_url",
|
||||
lambda url: (True, ""),
|
||||
lambda url, **kwargs: (True, ""),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Skill importer SSRF hardening: redirects must be re-validated per hop.
|
||||
|
||||
The importer follows redirects manually (`_get_checked`) and re-runs the SSRF
|
||||
guard on every hop with ``block_private=True``, matching the hardened web-fetch
|
||||
path in ``services/search/content.py:_get_public_url``. Previously it used
|
||||
``httpx``'s ``follow_redirects=True`` with the lenient guard on the *initial*
|
||||
URL only, so a ``3xx`` to an internal/metadata address was still connected to.
|
||||
|
||||
These tests are hermetic: every host is an IP literal, so ``check_outbound_url``
|
||||
resolves them locally (``getaddrinfo`` on a numeric address does no DNS) and no
|
||||
network access is required. The HTTP layer is faked so no real request is made.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from services.memory import skill_importer
|
||||
from services.memory.skill_importer import (
|
||||
SkillImportError,
|
||||
_check_fetch_url,
|
||||
_fetch_bytes,
|
||||
_get_checked,
|
||||
parse_skill_source,
|
||||
)
|
||||
|
||||
# Clearly-public, non-reserved IP literals for the initial (allowed) hop.
|
||||
PUBLIC_A = "https://1.1.1.1/skill"
|
||||
PUBLIC_B = "https://8.8.8.8/skill"
|
||||
# Internal redirect targets that must be refused before connection.
|
||||
LOOPBACK = "http://127.0.0.1/latest"
|
||||
METADATA = "http://169.254.169.254/latest/meta-data/"
|
||||
|
||||
|
||||
def _install_fake_client(monkeypatch, *, redirect_from, redirect_to):
|
||||
"""Replace httpx.Client so `redirect_from` 302s to `redirect_to`, and any
|
||||
other URL returns 200. No real socket is opened."""
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, url, status, location):
|
||||
self.url = url
|
||||
self.status_code = status
|
||||
self.headers = {"location": location} if location else {}
|
||||
self.content = b"ok"
|
||||
self.text = ""
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {}
|
||||
|
||||
class _Client:
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Safety invariant: the importer follows redirects by hand and
|
||||
# re-runs the SSRF guard per hop, so it MUST disable httpx's own
|
||||
# redirect following. Asserting ``follow_redirects is False`` here
|
||||
# (not merely accepting the kwarg) makes any regression to
|
||||
# ``follow_redirects=True`` fail these tests instead of passing
|
||||
# silently — httpx being faked would otherwise hide the change.
|
||||
assert kwargs.get("follow_redirects") is False, (
|
||||
"skill importer must construct httpx.Client with "
|
||||
"follow_redirects=False; got "
|
||||
f"{kwargs.get('follow_redirects')!r}"
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def get(self, url, headers=None):
|
||||
if url == redirect_from:
|
||||
return _Resp(url, 302, redirect_to)
|
||||
return _Resp(url, 200, None)
|
||||
|
||||
monkeypatch.setattr(skill_importer.httpx, "Client", _Client)
|
||||
|
||||
|
||||
# --- Guard unit: block_private=True refuses internal, allows public ----------
|
||||
|
||||
@pytest.mark.parametrize("url", [LOOPBACK, METADATA, "http://10.0.0.5/", "http://[::1]/"])
|
||||
def test_check_fetch_url_blocks_internal(url):
|
||||
with pytest.raises(SkillImportError):
|
||||
_check_fetch_url(url)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [PUBLIC_A, PUBLIC_B])
|
||||
def test_check_fetch_url_allows_public(url):
|
||||
# Should not raise for a public IP literal.
|
||||
_check_fetch_url(url)
|
||||
|
||||
|
||||
# --- Redirect revalidation: the core regression ------------------------------
|
||||
|
||||
@pytest.mark.parametrize("internal", [LOOPBACK, METADATA])
|
||||
def test_get_checked_blocks_redirect_to_internal(monkeypatch, internal):
|
||||
_install_fake_client(monkeypatch, redirect_from=PUBLIC_A, redirect_to=internal)
|
||||
with pytest.raises(SkillImportError, match="blocked"):
|
||||
_get_checked(PUBLIC_A)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("internal", [LOOPBACK, METADATA])
|
||||
def test_fetch_bytes_blocks_redirect_to_internal(monkeypatch, internal):
|
||||
# Higher-level: the public fetch helpers inherit the per-hop guard.
|
||||
_install_fake_client(monkeypatch, redirect_from=PUBLIC_A, redirect_to=internal)
|
||||
with pytest.raises(SkillImportError, match="blocked"):
|
||||
_fetch_bytes(PUBLIC_A)
|
||||
|
||||
|
||||
def test_skills_sh_entry_blocks_redirect_to_metadata(monkeypatch):
|
||||
# The skills.sh unwrap path (user-supplied host) must also revalidate hops.
|
||||
raw = "http://1.1.1.1/skills.sh" # contains "skills.sh", not "github.com"
|
||||
_install_fake_client(monkeypatch, redirect_from=raw, redirect_to=METADATA)
|
||||
with pytest.raises(SkillImportError, match="blocked"):
|
||||
parse_skill_source(raw)
|
||||
|
||||
|
||||
# --- Positive: a legitimate public->public redirect is still followed --------
|
||||
|
||||
def test_get_checked_follows_public_redirect(monkeypatch):
|
||||
_install_fake_client(monkeypatch, redirect_from=PUBLIC_A, redirect_to=PUBLIC_B)
|
||||
resp = _get_checked(PUBLIC_A)
|
||||
assert resp.status_code == 200
|
||||
assert str(resp.url) == PUBLIC_B
|
||||
@@ -0,0 +1,15 @@
|
||||
from services.research.research_handler import ResearchHandler
|
||||
|
||||
|
||||
def test_extract_sources_skips_non_dict_findings():
|
||||
# findings come from the DeepResearcher result list / cached JSON; a
|
||||
# malformed entry (None or a bare string) made f.get crash and drop every
|
||||
# real source.
|
||||
findings = [
|
||||
{"url": "https://a.com", "title": "A", "summary": "real analysis of the topic"},
|
||||
"junk-row",
|
||||
None,
|
||||
{"url": "https://b.com", "summary": "more genuine detail here"},
|
||||
]
|
||||
out = ResearchHandler._extract_sources(findings)
|
||||
assert [s["url"] for s in out] == ["https://a.com", "https://b.com"]
|
||||
@@ -24,7 +24,7 @@ import inspect
|
||||
|
||||
import src.tool_implementations as ti
|
||||
|
||||
# 33 do_* tool functions
|
||||
# Historical do_* tool functions.
|
||||
_EXPECTED = [
|
||||
"do_adopt_served_model", "do_api_call", "do_app_api", "do_cancel_download",
|
||||
"do_download_model", "do_edit_image", "do_list_cached_models",
|
||||
|
||||
@@ -32,7 +32,7 @@ def test_tell_in_web_query_does_not_force_email_tools():
|
||||
"""The #1707 repro: a web request that merely contains the word 'tell' must
|
||||
NOT drag in the email toolset."""
|
||||
ti = _index_without_embeddings()
|
||||
q = "visit https://www.youtube.com/user/PewDiePie and tell me the title of his latest video"
|
||||
q = "visit https://www.youtube.com/user/example and tell me the title of the latest video"
|
||||
tools = ti.get_tools_for_query(q)
|
||||
leaked = _EMAIL_TOOLS & tools
|
||||
assert not leaked, f"'tell me' must not force-include email tools, got {sorted(leaked)}"
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Regression: the tool-execution task inside stream_agent_loop must be
|
||||
cancelled (not orphaned) when the SSE consumer stops draining the generator
|
||||
early — e.g. a client disconnect mid tool-call.
|
||||
|
||||
The drain loop in stream_agent_loop:
|
||||
|
||||
_tool_task = asyncio.create_task(_run_tool())
|
||||
while True:
|
||||
evt = await _progress_q.get()
|
||||
if evt is None:
|
||||
break
|
||||
yield ...
|
||||
desc, result = await _tool_task
|
||||
|
||||
used to have no try/finally around it. If the generator is closed while
|
||||
suspended on `await _progress_q.get()` (which is exactly what Starlette does
|
||||
via `aclose()` when an SSE client disconnects), GeneratorExit is thrown at
|
||||
that point and `_tool_task` is abandoned mid-flight — never awaited, never
|
||||
cancelled. For a long-running `bash`/`python` tool this orphans the
|
||||
subprocess server-side with nothing left to reap it.
|
||||
|
||||
The fix wraps the drain loop in try/finally and cancels+awaits `_tool_task`
|
||||
on early exit. This test drives the real stream_agent_loop with a fake tool
|
||||
handler that sleeps until cancelled, closes the generator mid-tool-call (the
|
||||
same way a dropped SSE connection would), and asserts the fake handler
|
||||
actually observed cancellation.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import src.agent_loop as al
|
||||
|
||||
|
||||
def test_tool_task_cancelled_on_generator_close(monkeypatch):
|
||||
cancelled = {"v": False}
|
||||
|
||||
async def _slow_exec(block, *a, progress_cb=None, **k):
|
||||
if progress_cb:
|
||||
await progress_cb({"elapsed_s": 1, "tail": "running"})
|
||||
try:
|
||||
await asyncio.sleep(60)
|
||||
except asyncio.CancelledError:
|
||||
cancelled["v"] = True
|
||||
raise
|
||||
return ("bash", {"output": "ok", "exit_code": 0})
|
||||
|
||||
monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False)
|
||||
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
|
||||
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
|
||||
monkeypatch.setattr(al, "execute_tool_block", _slow_exec, raising=False)
|
||||
|
||||
native_calls = [{"name": "bash", "arguments": json.dumps({"command": "sleep 60"})}]
|
||||
|
||||
async def _fake_stream(_candidates, messages, **kwargs):
|
||||
yield f'data: {json.dumps({"delta": "Running it now."})}\n\n'
|
||||
yield f'data: {json.dumps({"type": "tool_calls", "calls": native_calls})}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False)
|
||||
|
||||
async def _run():
|
||||
gen = al.stream_agent_loop(
|
||||
"https://api.openai.com/v1", "gpt-4o",
|
||||
[{"role": "user", "content": "run sleep 60"}],
|
||||
max_rounds=2,
|
||||
relevant_tools={"bash"},
|
||||
)
|
||||
saw_tool_start = False
|
||||
saw_tool_progress = False
|
||||
async for chunk in gen:
|
||||
if '"type": "tool_start"' in chunk:
|
||||
saw_tool_start = True
|
||||
elif '"type": "tool_progress" ' in chunk or '"type": "tool_progress"' in chunk:
|
||||
saw_tool_progress = True
|
||||
break
|
||||
assert saw_tool_start, "expected a tool_start event before the tool ran"
|
||||
assert saw_tool_progress, "expected a tool_progress event once the fake tool started (task must exist by now)"
|
||||
# Simulate an SSE client disconnecting mid tool-call: close the
|
||||
# generator while it is suspended awaiting the next progress event.
|
||||
await gen.aclose()
|
||||
# Assert *inside* this coroutine, immediately after aclose() returns.
|
||||
# asyncio.run()'s own shutdown sequence cancels any tasks still
|
||||
# pending once _run() itself completes — checking after asyncio.run()
|
||||
# returns would pass even with the bug, because that unrelated
|
||||
# cleanup would cancel the orphaned task anyway and mask the fix.
|
||||
assert cancelled["v"] is True, (
|
||||
"tool task must be cancelled by stream_agent_loop's own cleanup "
|
||||
"on generator close, not left running until asyncio.run() tears "
|
||||
"down the loop"
|
||||
)
|
||||
|
||||
asyncio.run(_run())
|
||||
@@ -0,0 +1,17 @@
|
||||
from services.tts.tts_service import TTSService
|
||||
|
||||
|
||||
def test_available_tolerates_non_string_provider(tmp_path):
|
||||
"""A hand-edited/corrupt data/settings.json can store a non-string
|
||||
tts_provider (e.g. null or a number). available reads it and calls
|
||||
provider.startswith("endpoint:"), which raised AttributeError on a
|
||||
non-str. It must instead fall through and report unavailable."""
|
||||
service = TTSService(cache_dir=str(tmp_path))
|
||||
service._load_settings = lambda: {
|
||||
"tts_enabled": True,
|
||||
"tts_provider": 123,
|
||||
"tts_model": "tts-1",
|
||||
"tts_voice": "alloy",
|
||||
"tts_speed": "1",
|
||||
}
|
||||
assert service.available is False
|
||||
@@ -0,0 +1,831 @@
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from core.database import (
|
||||
Base,
|
||||
ChatMessage as DbChatMessage,
|
||||
CalendarCal,
|
||||
CalendarEvent,
|
||||
Document,
|
||||
DocumentVersion,
|
||||
GalleryImage,
|
||||
Note,
|
||||
Session as DbSession,
|
||||
)
|
||||
from src.upload_handler import (
|
||||
UploadCleanupSafetyError,
|
||||
UploadHandler,
|
||||
extract_internal_upload_ids,
|
||||
reserve_message_upload_references,
|
||||
reserve_upload_references,
|
||||
)
|
||||
from tests.helpers.sqlite_db import make_temp_sqlite
|
||||
|
||||
|
||||
OLD_TIMESTAMP = "2000-01-01T00:00:00"
|
||||
|
||||
|
||||
class _AdminAuth:
|
||||
is_configured = True
|
||||
|
||||
@staticmethod
|
||||
def is_admin(user):
|
||||
return user == "admin"
|
||||
|
||||
|
||||
class _AdminRequest:
|
||||
headers = {}
|
||||
state = SimpleNamespace(current_user="admin")
|
||||
app = SimpleNamespace(state=SimpleNamespace(auth_manager=_AdminAuth()))
|
||||
|
||||
|
||||
def _make_handler(tmp_path: Path) -> UploadHandler:
|
||||
base_dir = tmp_path / "base"
|
||||
upload_dir = tmp_path / "uploads"
|
||||
base_dir.mkdir()
|
||||
upload_dir.mkdir()
|
||||
return UploadHandler(str(base_dir), str(upload_dir))
|
||||
|
||||
|
||||
def _seed_old_uploads(handler: UploadHandler, rows: list[dict]) -> dict[str, Path]:
|
||||
dated_dir = Path(handler.upload_dir) / "2000" / "01" / "01"
|
||||
dated_dir.mkdir(parents=True)
|
||||
index = {}
|
||||
paths = {}
|
||||
for row in rows:
|
||||
upload_id = row["id"]
|
||||
path = dated_dir / upload_id
|
||||
path.write_bytes(row.get("bytes", upload_id.encode("ascii")))
|
||||
info = {
|
||||
"id": upload_id,
|
||||
"path": str(path),
|
||||
"mime": row.get("mime", "application/octet-stream"),
|
||||
"size": path.stat().st_size,
|
||||
"name": row.get("name", upload_id),
|
||||
"original_name": row.get("name", upload_id),
|
||||
"hash": row["hash"],
|
||||
"checksum_sha256": row["hash"],
|
||||
"uploaded_at": row.get("uploaded_at", OLD_TIMESTAMP),
|
||||
"created_at": row.get("created_at", OLD_TIMESTAMP),
|
||||
"last_accessed": row.get("last_accessed", OLD_TIMESTAMP),
|
||||
"owner": row.get("owner", "alice"),
|
||||
}
|
||||
index[f"{info['owner']}:{info['hash']}"] = info
|
||||
paths[upload_id] = path
|
||||
|
||||
Path(handler.upload_dir, "uploads.json").write_text(
|
||||
json.dumps(index),
|
||||
encoding="utf-8",
|
||||
)
|
||||
handler._index_cache = None
|
||||
return paths
|
||||
|
||||
|
||||
def _manual_cleanup_endpoint(handler: UploadHandler, monkeypatch):
|
||||
import fastapi.dependencies.utils as dependency_utils
|
||||
from routes.upload_routes import router, setup_upload_routes
|
||||
|
||||
monkeypatch.setattr(dependency_utils, "ensure_multipart_is_installed", lambda: None)
|
||||
before = len(router.routes)
|
||||
setup_upload_routes(handler)
|
||||
return {
|
||||
route.endpoint.__name__: route.endpoint
|
||||
for route in router.routes[before:]
|
||||
}["manual_cleanup"]
|
||||
|
||||
|
||||
def _reference_database(monkeypatch, *, upload_id: str, gallery_hash: str = None):
|
||||
from routes import upload_routes
|
||||
|
||||
SessionLocal, engine, tmpfile = make_temp_sqlite(Base.metadata)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(DbSession(
|
||||
id="session-1",
|
||||
name="Cleanup regression",
|
||||
endpoint_url="http://localhost",
|
||||
model="test-model",
|
||||
owner="alice",
|
||||
))
|
||||
db.add(DbChatMessage(
|
||||
id="message-1",
|
||||
session_id="session-1",
|
||||
role="user",
|
||||
content=f"[Attachment: retained.png | id={upload_id} | mime=image/png]",
|
||||
meta_data=json.dumps({
|
||||
"attachments": [{
|
||||
"id": upload_id,
|
||||
"name": "retained.png",
|
||||
"mime": "image/png",
|
||||
"size": 8,
|
||||
}]
|
||||
}),
|
||||
))
|
||||
if gallery_hash:
|
||||
db.add(GalleryImage(
|
||||
id="gallery-cleanup-reference",
|
||||
filename="abcdef123456.png",
|
||||
prompt="Chat upload",
|
||||
owner="alice",
|
||||
file_hash=gallery_hash,
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
monkeypatch.setattr(upload_routes, "SessionLocal", SessionLocal)
|
||||
return engine, tmpfile
|
||||
|
||||
|
||||
def test_admin_cleanup_preserves_referenced_upload_and_reconciles_deleted_row(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
handler = _make_handler(tmp_path)
|
||||
referenced_id = "a" * 32 + ".png"
|
||||
unreferenced_id = "b" * 32 + ".txt"
|
||||
gallery_id = "7" * 32 + ".png"
|
||||
gallery_hash = "7" * 64
|
||||
paths = _seed_old_uploads(handler, [
|
||||
{
|
||||
"id": referenced_id,
|
||||
"hash": "1" * 64,
|
||||
"mime": "image/png",
|
||||
},
|
||||
{
|
||||
"id": unreferenced_id,
|
||||
"hash": "2" * 64,
|
||||
"mime": "text/plain",
|
||||
},
|
||||
{
|
||||
"id": gallery_id,
|
||||
"hash": gallery_hash,
|
||||
"mime": "image/png",
|
||||
},
|
||||
])
|
||||
engine, tmpfile = _reference_database(
|
||||
monkeypatch,
|
||||
upload_id=referenced_id,
|
||||
gallery_hash=gallery_hash,
|
||||
)
|
||||
|
||||
try:
|
||||
response = asyncio.run(
|
||||
_manual_cleanup_endpoint(handler, monkeypatch)(_AdminRequest())
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
tmpfile.close()
|
||||
try:
|
||||
os.unlink(tmpfile.name)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
assert response == {"status": "success", "files_cleaned": 1}
|
||||
assert paths[referenced_id].is_file()
|
||||
referenced_info = handler.get_upload_info(referenced_id)
|
||||
assert referenced_info is not None
|
||||
assert handler.resolve_upload(referenced_id, owner="alice") is not None
|
||||
assert paths[gallery_id].is_file()
|
||||
assert handler.get_upload_info(gallery_id) is not None
|
||||
|
||||
assert not paths[unreferenced_id].exists()
|
||||
assert handler.get_upload_info(unreferenced_id) is None
|
||||
assert handler.resolve_upload(unreferenced_id, owner="alice") is None
|
||||
|
||||
live_index = json.loads(
|
||||
Path(handler.upload_dir, "uploads.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert {info["id"] for info in live_index.values()} == {
|
||||
referenced_id,
|
||||
gallery_id,
|
||||
}
|
||||
backup_index = json.loads(
|
||||
Path(handler.upload_dir, "uploads.json.bak").read_text(encoding="utf-8")
|
||||
)
|
||||
assert {info["id"] for info in backup_index.values()} == {
|
||||
referenced_id,
|
||||
gallery_id,
|
||||
}
|
||||
|
||||
# Recovery must not resurrect the deliberately deleted row.
|
||||
Path(handler.upload_dir, "uploads.json").write_text("{broken", encoding="utf-8")
|
||||
handler._index_cache = None
|
||||
assert handler.get_upload_info(unreferenced_id) is None
|
||||
assert paths[referenced_id].parent.is_dir()
|
||||
|
||||
|
||||
def test_cleanup_retains_upload_and_all_rows_when_index_rows_disagree(tmp_path):
|
||||
handler = _make_handler(tmp_path)
|
||||
upload_id = "c" * 32 + ".txt"
|
||||
path = _seed_old_uploads(handler, [{
|
||||
"id": upload_id,
|
||||
"hash": "1" * 64,
|
||||
"mime": "text/plain",
|
||||
"owner": "alice",
|
||||
}])[upload_id]
|
||||
index_path = Path(handler.upload_dir, "uploads.json")
|
||||
index = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
alice_row = next(iter(index.values()))
|
||||
index["bob:" + "2" * 64] = {
|
||||
**alice_row,
|
||||
"owner": "bob",
|
||||
"hash": "2" * 64,
|
||||
"checksum_sha256": "2" * 64,
|
||||
}
|
||||
index_path.write_text(json.dumps(index), encoding="utf-8")
|
||||
handler._index_cache = None
|
||||
|
||||
assert handler.cleanup_old_uploads(set(), set()) == 0
|
||||
assert path.is_file()
|
||||
assert json.loads(index_path.read_text(encoding="utf-8")) == index
|
||||
|
||||
|
||||
def test_cleanup_retains_lone_row_without_authoritative_lifecycle_metadata(tmp_path):
|
||||
handler = _make_handler(tmp_path)
|
||||
upload_id = "6" * 32 + ".txt"
|
||||
path = _seed_old_uploads(handler, [{
|
||||
"id": upload_id,
|
||||
"hash": "6" * 64,
|
||||
"mime": "text/plain",
|
||||
}])[upload_id]
|
||||
index_path = Path(handler.upload_dir, "uploads.json")
|
||||
index = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
row = next(iter(index.values()))
|
||||
for field in (
|
||||
"owner",
|
||||
"hash",
|
||||
"checksum_sha256",
|
||||
"uploaded_at",
|
||||
"created_at",
|
||||
"last_accessed",
|
||||
):
|
||||
row.pop(field)
|
||||
index_path.write_text(json.dumps(index), encoding="utf-8")
|
||||
handler._index_cache = None
|
||||
|
||||
assert handler.cleanup_old_uploads(set(), set()) == 0
|
||||
assert path.is_file()
|
||||
assert json.loads(index_path.read_text(encoding="utf-8")) == index
|
||||
|
||||
|
||||
def test_reservation_and_cleanup_are_serialized_without_dangling_references(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
# Writer wins: reservation holds the shared index lock, refreshes access,
|
||||
# then cleanup observes the refreshed row and preserves the file.
|
||||
writer_root = tmp_path / "writer-wins"
|
||||
writer_root.mkdir()
|
||||
writer_handler = _make_handler(writer_root)
|
||||
upload_id = "2" * 32 + ".txt"
|
||||
writer_path = _seed_old_uploads(writer_handler, [{
|
||||
"id": upload_id,
|
||||
"hash": "2" * 64,
|
||||
"mime": "text/plain",
|
||||
}])[upload_id]
|
||||
write_entered = threading.Event()
|
||||
release_write = threading.Event()
|
||||
real_atomic_write = writer_handler._atomic_write_json
|
||||
|
||||
def blocking_reservation_write(path, data, *, sync_backup=False):
|
||||
refreshed = any(
|
||||
isinstance(row, dict) and row.get("last_accessed") != OLD_TIMESTAMP
|
||||
for row in data.values()
|
||||
)
|
||||
if sync_backup and refreshed and not write_entered.is_set():
|
||||
write_entered.set()
|
||||
assert release_write.wait(5)
|
||||
return real_atomic_write(path, data, sync_backup=sync_backup)
|
||||
|
||||
monkeypatch.setattr(writer_handler, "_atomic_write_json", blocking_reservation_write)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||||
reserve_future = pool.submit(
|
||||
writer_handler.reserve_upload,
|
||||
upload_id,
|
||||
owner="alice",
|
||||
)
|
||||
assert write_entered.wait(5)
|
||||
cleanup_future = pool.submit(writer_handler.cleanup_old_uploads, set(), set())
|
||||
release_write.set()
|
||||
assert reserve_future.result(timeout=5) is not None
|
||||
assert cleanup_future.result(timeout=5) == 0
|
||||
assert writer_path.is_file()
|
||||
|
||||
# Cleanup wins: reservation cannot pass the same lock until the row and
|
||||
# bytes are gone, then fails so a caller cannot commit a dangling reference.
|
||||
cleanup_root = tmp_path / "cleanup-wins"
|
||||
cleanup_root.mkdir()
|
||||
cleanup_handler = _make_handler(cleanup_root)
|
||||
cleanup_path = _seed_old_uploads(cleanup_handler, [{
|
||||
"id": upload_id,
|
||||
"hash": "3" * 64,
|
||||
"mime": "text/plain",
|
||||
}])[upload_id]
|
||||
remove_entered = threading.Event()
|
||||
release_remove = threading.Event()
|
||||
real_remove = os.remove
|
||||
|
||||
def blocking_remove(candidate):
|
||||
if os.path.realpath(candidate) == os.path.realpath(cleanup_path):
|
||||
remove_entered.set()
|
||||
assert release_remove.wait(5)
|
||||
return real_remove(candidate)
|
||||
|
||||
monkeypatch.setattr("src.upload_handler.os.remove", blocking_remove)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||||
cleanup_future = pool.submit(cleanup_handler.cleanup_old_uploads, set(), set())
|
||||
assert remove_entered.wait(5)
|
||||
reserve_future = pool.submit(
|
||||
cleanup_handler.reserve_upload,
|
||||
upload_id,
|
||||
owner="alice",
|
||||
)
|
||||
release_remove.set()
|
||||
assert cleanup_future.result(timeout=5) == 1
|
||||
assert reserve_future.result(timeout=5) is None
|
||||
assert not cleanup_path.exists()
|
||||
|
||||
|
||||
def test_admin_cleanup_reference_discovery_failure_returns_503_without_deleting(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
from routes import upload_routes
|
||||
|
||||
handler = _make_handler(tmp_path)
|
||||
upload_id = "d" * 32 + ".png"
|
||||
path = _seed_old_uploads(handler, [
|
||||
{"id": upload_id, "hash": "4" * 64, "mime": "image/png"},
|
||||
])[upload_id]
|
||||
|
||||
def fail_reference_scan():
|
||||
raise RuntimeError("database unavailable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
upload_routes,
|
||||
"_collect_persisted_upload_references",
|
||||
fail_reference_scan,
|
||||
)
|
||||
endpoint = _manual_cleanup_endpoint(handler, monkeypatch)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(endpoint(_AdminRequest()))
|
||||
|
||||
assert exc.value.status_code == 503
|
||||
assert path.is_file()
|
||||
assert handler.get_upload_info(upload_id) is not None
|
||||
|
||||
|
||||
def test_cleanup_restores_index_when_file_removal_fails(tmp_path, monkeypatch):
|
||||
handler = _make_handler(tmp_path)
|
||||
upload_id = "e" * 32 + ".txt"
|
||||
path = _seed_old_uploads(handler, [
|
||||
{
|
||||
"id": upload_id,
|
||||
"hash": "5" * 64,
|
||||
"mime": "text/plain",
|
||||
},
|
||||
])[upload_id]
|
||||
|
||||
real_remove = os.remove
|
||||
|
||||
def fail_target_remove(candidate):
|
||||
if os.path.realpath(candidate) == os.path.realpath(path):
|
||||
raise PermissionError("file is in use")
|
||||
return real_remove(candidate)
|
||||
|
||||
monkeypatch.setattr("src.upload_handler.os.remove", fail_target_remove)
|
||||
|
||||
assert handler.cleanup_old_uploads(set(), set()) == 0
|
||||
assert path.is_file()
|
||||
assert handler.get_upload_info(upload_id) is not None
|
||||
assert any(
|
||||
info["id"] == upload_id
|
||||
for info in json.loads(
|
||||
Path(handler.upload_dir, "uploads.json").read_text(encoding="utf-8")
|
||||
).values()
|
||||
)
|
||||
assert any(
|
||||
info["id"] == upload_id
|
||||
for info in json.loads(
|
||||
Path(handler.upload_dir, "uploads.json.bak").read_text(encoding="utf-8")
|
||||
).values()
|
||||
)
|
||||
|
||||
|
||||
def test_admin_cleanup_with_corrupt_index_returns_503_and_fails_closed(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
from routes import upload_routes
|
||||
|
||||
handler = _make_handler(tmp_path)
|
||||
upload_id = "9" * 32 + ".png"
|
||||
path = _seed_old_uploads(handler, [
|
||||
{"id": upload_id, "hash": "9" * 64, "mime": "image/png"},
|
||||
])[upload_id]
|
||||
Path(handler.upload_dir, "uploads.json").write_text(
|
||||
'{"alice:broken": {',
|
||||
encoding="utf-8",
|
||||
)
|
||||
handler._index_cache = None
|
||||
|
||||
monkeypatch.setattr(
|
||||
upload_routes,
|
||||
"_collect_persisted_upload_references",
|
||||
lambda: (set(), set()),
|
||||
)
|
||||
endpoint = _manual_cleanup_endpoint(handler, monkeypatch)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(endpoint(_AdminRequest()))
|
||||
|
||||
assert exc.value.status_code == 503
|
||||
assert path.is_file()
|
||||
|
||||
|
||||
def test_cleanup_with_missing_live_index_fails_closed(tmp_path):
|
||||
handler = _make_handler(tmp_path)
|
||||
upload_id = "8" * 32 + ".png"
|
||||
dated_dir = Path(handler.upload_dir) / "2000" / "01" / "01"
|
||||
dated_dir.mkdir(parents=True)
|
||||
path = dated_dir / upload_id
|
||||
path.write_bytes(b"unindexed bytes")
|
||||
|
||||
with pytest.raises(UploadCleanupSafetyError):
|
||||
handler.cleanup_old_uploads(set(), set())
|
||||
|
||||
assert path.is_file()
|
||||
|
||||
|
||||
def test_reference_discovery_covers_all_durable_upload_stores(
|
||||
monkeypatch,
|
||||
):
|
||||
from routes import upload_routes
|
||||
|
||||
document_id = "f" * 32 + ".pdf"
|
||||
version_id = "1" * 32 + ".pdf"
|
||||
note_upload_id = "3" * 32 + ".png"
|
||||
note_color_id = "2" * 32 + ".png"
|
||||
calendar_upload_id = "4" * 32 + ".png"
|
||||
event_upload_id = "5" * 32 + ".png"
|
||||
event_description_id = "7" * 32 + ".txt"
|
||||
event_location_id = "8" * 32 + ".png"
|
||||
gallery_hash = "6" * 64
|
||||
SessionLocal, engine, tmpfile = make_temp_sqlite(Base.metadata)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(DbSession(
|
||||
id="session-2",
|
||||
name="Reference sources",
|
||||
endpoint_url="http://localhost",
|
||||
model="test-model",
|
||||
owner="alice",
|
||||
))
|
||||
db.add(Document(
|
||||
id="document-1",
|
||||
session_id="session-2",
|
||||
title="PDF",
|
||||
current_content=f'<!-- pdf_source upload_id="{document_id}" -->',
|
||||
owner="alice",
|
||||
))
|
||||
db.add(DocumentVersion(
|
||||
id="version-1",
|
||||
document_id="document-1",
|
||||
version_number=1,
|
||||
content=f'<!-- pdf_form_source upload_id="{version_id}" fields="1" -->',
|
||||
))
|
||||
db.add(GalleryImage(
|
||||
id="gallery-1",
|
||||
# Gallery filenames are normally generated 12-hex names, so this
|
||||
# record proves retention comes from its stored content hash.
|
||||
filename="abcdef123456.png",
|
||||
prompt="Chat upload",
|
||||
owner="alice",
|
||||
file_hash=gallery_hash,
|
||||
))
|
||||
db.add(Note(
|
||||
id="note-1",
|
||||
owner="alice",
|
||||
title="Photo note",
|
||||
image_url=f"/api/upload/{note_upload_id}",
|
||||
color=f"odysseus://attachment/{note_color_id}",
|
||||
))
|
||||
db.add(CalendarCal(
|
||||
id="calendar-1",
|
||||
owner="alice",
|
||||
name="Personal",
|
||||
color=f"/api/upload/{calendar_upload_id}",
|
||||
))
|
||||
db.add(CalendarEvent(
|
||||
uid="event-1",
|
||||
calendar_id="calendar-1",
|
||||
summary="Photo event",
|
||||
dtstart=datetime(2026, 7, 10, 12, 0),
|
||||
dtend=datetime(2026, 7, 10, 13, 0),
|
||||
color=f"/api/upload/{event_upload_id}",
|
||||
description=f"Notes: odysseus://attachment/{event_description_id}",
|
||||
location=f"/api/upload/{event_location_id}",
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
monkeypatch.setattr(upload_routes, "SessionLocal", SessionLocal)
|
||||
try:
|
||||
referenced_ids, referenced_hashes = (
|
||||
upload_routes._collect_persisted_upload_references()
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
tmpfile.close()
|
||||
try:
|
||||
os.unlink(tmpfile.name)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
assert {
|
||||
document_id,
|
||||
version_id,
|
||||
note_upload_id,
|
||||
note_color_id,
|
||||
calendar_upload_id,
|
||||
event_upload_id,
|
||||
event_description_id,
|
||||
event_location_id,
|
||||
} <= referenced_ids
|
||||
assert gallery_hash in referenced_hashes
|
||||
|
||||
|
||||
def test_write_reservation_extracts_only_explicit_internal_references():
|
||||
upload_id = "a" * 32 + ".png"
|
||||
checksum_like_text = "b" * 32
|
||||
|
||||
assert extract_internal_upload_ids(checksum_like_text) == set()
|
||||
assert extract_internal_upload_ids(f"sha={checksum_like_text}") == set()
|
||||
assert extract_internal_upload_ids({
|
||||
"image": f"/api/upload/{upload_id}",
|
||||
"nested": [f"odysseus://attachment/{upload_id}"],
|
||||
}) == {upload_id}
|
||||
assert extract_internal_upload_ids(
|
||||
f'<!-- pdf_source upload_id="{upload_id}" -->'
|
||||
) == {upload_id}
|
||||
assert extract_internal_upload_ids(
|
||||
f"[Attachment: photo.png | id={upload_id} | mime=image/png]"
|
||||
) == {upload_id}
|
||||
extensionless_id = "c" * 32
|
||||
assert extract_internal_upload_ids(
|
||||
f"See /api/upload/{extensionless_id}. Then continue."
|
||||
) == {extensionless_id}
|
||||
assert extract_internal_upload_ids(
|
||||
f"Attachment: odysseus://attachment/{extensionless_id}: ready"
|
||||
) == {extensionless_id}
|
||||
assert extract_internal_upload_ids(f"/api/upload/{upload_id}/extra") == set()
|
||||
|
||||
|
||||
def test_reservation_never_uses_admin_override(tmp_path):
|
||||
handler = _make_handler(tmp_path)
|
||||
upload_id = "c" * 32 + ".txt"
|
||||
_seed_old_uploads(handler, [{
|
||||
"id": upload_id,
|
||||
"hash": "c" * 64,
|
||||
"mime": "text/plain",
|
||||
"owner": "alice",
|
||||
}])
|
||||
|
||||
assert reserve_upload_references(
|
||||
handler,
|
||||
"alice",
|
||||
f"odysseus://attachment/{upload_id}",
|
||||
) is None
|
||||
assert reserve_upload_references(
|
||||
handler,
|
||||
"admin",
|
||||
f"odysseus://attachment/{upload_id}",
|
||||
) == upload_id
|
||||
assert handler.reserve_upload(
|
||||
upload_id,
|
||||
owner="admin",
|
||||
auth_manager=_AdminAuth(),
|
||||
allow_admin=False,
|
||||
) is None
|
||||
assert reserve_message_upload_references(
|
||||
handler,
|
||||
"admin",
|
||||
"legacy attachment metadata",
|
||||
{"attachments": [{"id": upload_id, "name": "owned.txt"}]},
|
||||
) == upload_id
|
||||
|
||||
|
||||
def test_remaining_durable_writers_reserve_before_commit(monkeypatch):
|
||||
import core.database as database
|
||||
import core.session_manager as session_manager_module
|
||||
import src.database as legacy_database
|
||||
from core.models import ChatMessage
|
||||
from core.session_manager import SessionManager
|
||||
from src import tool_utils
|
||||
from src.agent_tools.document_tools import EditDocumentTool
|
||||
from src.tools.calendar import do_manage_calendar
|
||||
from src.tools.notes import do_manage_notes
|
||||
|
||||
upload_id = "6" * 32 + ".png"
|
||||
|
||||
class RejectingHandler:
|
||||
def reserve_upload(self, _candidate, **_kwargs):
|
||||
return None
|
||||
|
||||
handler = RejectingHandler()
|
||||
monkeypatch.setattr(tool_utils, "_upload_handler", handler)
|
||||
|
||||
SessionLocal, engine, tmpfile = make_temp_sqlite(Base.metadata)
|
||||
monkeypatch.setattr(database, "SessionLocal", SessionLocal)
|
||||
monkeypatch.setattr(legacy_database, "SessionLocal", SessionLocal)
|
||||
monkeypatch.setattr(legacy_database, "Document", Document, raising=False)
|
||||
monkeypatch.setattr(
|
||||
legacy_database,
|
||||
"DocumentVersion",
|
||||
DocumentVersion,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(session_manager_module, "SessionLocal", SessionLocal)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(DbSession(
|
||||
id="writer-session",
|
||||
name="Writer coverage",
|
||||
endpoint_url="http://localhost",
|
||||
model="test-model",
|
||||
owner="alice",
|
||||
))
|
||||
db.add(Document(
|
||||
id="email-document",
|
||||
session_id="writer-session",
|
||||
title="New Email",
|
||||
language="email",
|
||||
current_content="To: team@example.test\nSubject: Status\n---\nOld body",
|
||||
version_count=1,
|
||||
owner="alice",
|
||||
))
|
||||
db.commit()
|
||||
|
||||
manager = SessionManager()
|
||||
manager.upload_handler = handler
|
||||
manager._persist_message(
|
||||
"writer-session",
|
||||
ChatMessage(
|
||||
"user",
|
||||
"attachment",
|
||||
metadata={"attachments": [{"id": upload_id}]},
|
||||
),
|
||||
)
|
||||
|
||||
document_result = asyncio.run(EditDocumentTool().execute(
|
||||
"<<<FIND>>>\n\n<<<REPLACE>>>\n"
|
||||
f"See /api/upload/{upload_id}\n<<<END>>>",
|
||||
{"doc_id": "email-document", "owner": "alice"},
|
||||
))
|
||||
assert document_result["exit_code"] == 1
|
||||
assert "no longer available" in document_result["error"]
|
||||
|
||||
calendar_result = asyncio.run(do_manage_calendar(
|
||||
json.dumps({
|
||||
"action": "create_event",
|
||||
"summary": "Attachment review",
|
||||
"dtstart": "2026-07-12T12:00:00",
|
||||
"description": f"See /api/upload/{upload_id}",
|
||||
}),
|
||||
owner="alice",
|
||||
))
|
||||
assert calendar_result["exit_code"] == 1
|
||||
assert "no longer available" in calendar_result["error"]
|
||||
|
||||
note_result = asyncio.run(do_manage_notes(
|
||||
json.dumps({
|
||||
"action": "add",
|
||||
"title": "Attachment note",
|
||||
"content": f"See /api/upload/{upload_id}",
|
||||
}),
|
||||
owner="alice",
|
||||
))
|
||||
assert note_result["exit_code"] == 1
|
||||
assert "no longer available" in note_result["error"]
|
||||
|
||||
verify = SessionLocal()
|
||||
try:
|
||||
assert verify.query(DbChatMessage).count() == 0
|
||||
stored_doc = verify.query(Document).filter(Document.id == "email-document").one()
|
||||
assert stored_doc.current_content.endswith("Old body")
|
||||
assert verify.query(CalendarEvent).count() == 0
|
||||
assert verify.query(Note).count() == 0
|
||||
finally:
|
||||
verify.close()
|
||||
finally:
|
||||
db.close()
|
||||
engine.dispose()
|
||||
tmpfile.close()
|
||||
try:
|
||||
os.unlink(tmpfile.name)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def test_note_calendar_and_document_routes_reserve_before_database_writes(monkeypatch):
|
||||
from routes.calendar_routes import EventCreate, setup_calendar_routes
|
||||
from routes import document_routes
|
||||
from routes.document_helpers import DocumentCreate
|
||||
from routes.note_routes import NoteCreate, setup_note_routes
|
||||
from src import auth_helpers
|
||||
|
||||
upload_id = "d" * 32 + ".png"
|
||||
|
||||
class RejectingHandler:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def reserve_upload(self, candidate, **kwargs):
|
||||
self.calls.append((candidate, kwargs))
|
||||
return None
|
||||
|
||||
request = SimpleNamespace(
|
||||
state=SimpleNamespace(current_user="alice", api_token=False),
|
||||
app=SimpleNamespace(state=SimpleNamespace()),
|
||||
)
|
||||
|
||||
note_handler = RejectingHandler()
|
||||
note_router = setup_note_routes(upload_handler=note_handler)
|
||||
create_note = next(
|
||||
route.endpoint for route in note_router.routes
|
||||
if route.endpoint.__name__ == "create_note"
|
||||
)
|
||||
with pytest.raises(HTTPException) as note_error:
|
||||
create_note(
|
||||
request,
|
||||
NoteCreate(image_url=f"/api/upload/{upload_id}"),
|
||||
)
|
||||
assert note_error.value.status_code == 409
|
||||
assert note_handler.calls == [
|
||||
(upload_id, {"owner": "alice", "allow_admin": False})
|
||||
]
|
||||
|
||||
calendar_handler = RejectingHandler()
|
||||
calendar_router = setup_calendar_routes(upload_handler=calendar_handler)
|
||||
create_event = next(
|
||||
route.endpoint for route in calendar_router.routes
|
||||
if route.endpoint.__name__ == "create_event"
|
||||
)
|
||||
with pytest.raises(HTTPException) as calendar_error:
|
||||
asyncio.run(create_event(
|
||||
request,
|
||||
EventCreate(
|
||||
summary="Photo",
|
||||
dtstart="2026-07-10T12:00:00",
|
||||
color=f"odysseus://attachment/{upload_id}",
|
||||
),
|
||||
))
|
||||
assert calendar_error.value.status_code == 409
|
||||
assert calendar_handler.calls == [
|
||||
(upload_id, {"owner": "alice", "allow_admin": False})
|
||||
]
|
||||
|
||||
class EmptyDb:
|
||||
@staticmethod
|
||||
def close():
|
||||
return None
|
||||
|
||||
document_handler = RejectingHandler()
|
||||
monkeypatch.setattr(document_routes, "SessionLocal", EmptyDb)
|
||||
monkeypatch.setattr(
|
||||
auth_helpers,
|
||||
"require_privilege",
|
||||
lambda _request, _privilege: "alice",
|
||||
)
|
||||
document_router = document_routes.setup_document_routes(
|
||||
SimpleNamespace(),
|
||||
document_handler,
|
||||
)
|
||||
create_document = next(
|
||||
route.endpoint for route in document_router.routes
|
||||
if route.endpoint.__name__ == "create_document"
|
||||
)
|
||||
with pytest.raises(HTTPException) as document_error:
|
||||
asyncio.run(create_document(
|
||||
request,
|
||||
DocumentCreate(
|
||||
language="markdown",
|
||||
content=f"",
|
||||
),
|
||||
))
|
||||
assert document_error.value.status_code == 409
|
||||
assert document_handler.calls == [
|
||||
(upload_id, {"owner": "alice", "allow_admin": False})
|
||||
]
|
||||
@@ -64,6 +64,33 @@ def test_strict_mode_blocks_private_and_loopback():
|
||||
assert ok is False and "private" in reason
|
||||
|
||||
|
||||
def test_strict_mode_blocks_cgnat_shared_space():
|
||||
# RFC 6598 shared/CGNAT space (100.64.0.0/10) is not globally routable.
|
||||
# A public redirect into it must be rejected under full SSRF lockdown,
|
||||
# even though ipaddress reports is_private=False for this range.
|
||||
CGNAT = _resolver({"svc.example": ["100.64.0.1"]})
|
||||
ok, reason = check_outbound_url("http://svc.example:8080", block_private=True, resolver=CGNAT)
|
||||
assert ok is False
|
||||
assert "blocked" in reason
|
||||
|
||||
|
||||
def test_strict_mode_blocks_non_global_ranges():
|
||||
# Strict mode is a full SSRF lockdown: only globally-routable public
|
||||
# addresses may be reached. Benchmarking (198.18.0.0/15) and TEST-NET
|
||||
# documentation space (192.0.2.0/24) are not globally routable.
|
||||
for ip in ("198.18.0.1", "192.0.2.10"):
|
||||
res = _resolver({"svc.example": [ip]})
|
||||
ok, reason = check_outbound_url("http://svc.example", block_private=True, resolver=res)
|
||||
assert ok is False, ip
|
||||
assert "blocked" in reason
|
||||
|
||||
|
||||
def test_strict_mode_still_allows_public_ip():
|
||||
# The lockdown must not reject a legitimate globally-routable target.
|
||||
ok, reason = check_outbound_url("https://example.com/v1", block_private=True, resolver=PUBLIC)
|
||||
assert ok is True, reason
|
||||
|
||||
|
||||
def test_unresolvable_host_blocked():
|
||||
ok, reason = check_outbound_url("http://does-not-resolve.invalid", resolver=PUBLIC)
|
||||
assert ok is False
|
||||
|
||||
@@ -125,6 +125,67 @@ async def test_read_write_edit_confined_e2e(ws, admin):
|
||||
assert not os.path.exists(escape)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_confined_e2e(ws, admin):
|
||||
with open(os.path.join(ws, "patchme.txt"), "w") as f:
|
||||
f.write("alpha\nbeta\ngamma\n")
|
||||
patch = """*** Begin Patch
|
||||
*** Update File: patchme.txt
|
||||
@@
|
||||
alpha
|
||||
-beta
|
||||
+BETA
|
||||
gamma
|
||||
*** Add File: added.txt
|
||||
+new file
|
||||
*** End Patch"""
|
||||
_, r = await execute_tool_block(_block("apply_patch", patch), owner="a", workspace=ws)
|
||||
assert r["exit_code"] == 0
|
||||
assert r["diff"]["added"] >= 2
|
||||
with open(os.path.join(ws, "patchme.txt")) as f:
|
||||
assert f.read() == "alpha\nBETA\ngamma\n"
|
||||
with open(os.path.join(ws, "added.txt")) as f:
|
||||
assert f.read() == "new file\n"
|
||||
|
||||
outside = tempfile.mkdtemp()
|
||||
outside_file = os.path.join(outside, "x.txt")
|
||||
with open(outside_file, "w") as f:
|
||||
f.write("x\n")
|
||||
escape_patch = f"""*** Begin Patch
|
||||
*** Update File: {outside_file}
|
||||
@@
|
||||
-x
|
||||
+y
|
||||
*** End Patch"""
|
||||
_, r = await execute_tool_block(_block("apply_patch", escape_patch), owner="a", workspace=ws)
|
||||
assert r["exit_code"] == 1 and "outside the workspace" in r["error"]
|
||||
with open(outside_file) as f:
|
||||
assert f.read() == "x\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_todowrite_persists_session_list(tmp_path, monkeypatch, admin):
|
||||
import src.agent_tools.coding_tools as coding_tools
|
||||
|
||||
monkeypatch.setattr(coding_tools, "_TODO_DIR", str(tmp_path))
|
||||
payload = {
|
||||
"todos": [
|
||||
{"content": "Inspect code", "status": "completed", "priority": "high"},
|
||||
{"content": "Patch code", "status": "in_progress", "priority": "high"},
|
||||
]
|
||||
}
|
||||
_, r = await execute_tool_block(
|
||||
_block("todowrite", json.dumps(payload)),
|
||||
session_id="chat/one",
|
||||
owner="a",
|
||||
workspace=str(tmp_path),
|
||||
)
|
||||
assert r["exit_code"] == 0
|
||||
assert "[>] Patch code" in r["output"]
|
||||
saved = json.load(open(tmp_path / "chat_one.json", encoding="utf-8"))
|
||||
assert saved["todos"][1]["status"] == "in_progress"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_and_ls_confined_e2e(ws, admin):
|
||||
with open(os.path.join(ws, "doc.txt"), "w") as f:
|
||||
@@ -231,7 +292,7 @@ async def test_binding_does_not_leak(ws, admin):
|
||||
# must still surface the file tools, otherwise the agent says it has no file
|
||||
# access (the bug this guards against).
|
||||
|
||||
def _sent_tool_names(monkeypatch, *, workspace):
|
||||
def _sent_tool_names(monkeypatch, *, workspace, message="look at the local project", force_keyword_fallback=False):
|
||||
import asyncio
|
||||
import src.agent_loop as al
|
||||
|
||||
@@ -240,6 +301,13 @@ def _sent_tool_names(monkeypatch, *, workspace):
|
||||
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
|
||||
# Isolate the selection logic from owner gating (tested separately).
|
||||
monkeypatch.setattr(al, "blocked_tools_for_owner", lambda owner: set(), raising=False)
|
||||
if force_keyword_fallback:
|
||||
import src.tool_index as ti
|
||||
|
||||
def _raise_get_tool_index():
|
||||
raise RuntimeError("skip vector retrieval")
|
||||
|
||||
monkeypatch.setattr(ti, "get_tool_index", _raise_get_tool_index, raising=False)
|
||||
|
||||
captured = []
|
||||
|
||||
@@ -253,7 +321,7 @@ def _sent_tool_names(monkeypatch, *, workspace):
|
||||
async def _run():
|
||||
gen = al.stream_agent_loop(
|
||||
"https://api.openai.com/v1", "gpt-test",
|
||||
[{"role": "user", "content": "look at the local project"}],
|
||||
[{"role": "user", "content": message}],
|
||||
max_rounds=1, relevant_tools=None, owner="admin", workspace=workspace,
|
||||
)
|
||||
return [c async for c in gen]
|
||||
@@ -276,12 +344,84 @@ def test_low_signal_with_workspace_surfaces_readonly_file_tools(monkeypatch):
|
||||
assert "python" not in names
|
||||
|
||||
|
||||
def test_workspace_coding_request_surfaces_edit_and_verify_tools(monkeypatch):
|
||||
names = _sent_tool_names(
|
||||
monkeypatch,
|
||||
workspace="/tmp",
|
||||
message="fix the failing frontend test in this repo",
|
||||
force_keyword_fallback=True,
|
||||
)
|
||||
assert "get_workspace" in names
|
||||
assert "read_file" in names
|
||||
assert "grep" in names
|
||||
assert "edit_file" in names
|
||||
assert "write_file" in names
|
||||
assert "apply_patch" in names
|
||||
assert "todowrite" in names
|
||||
assert "bash" in names
|
||||
assert "python" in names
|
||||
|
||||
|
||||
def test_low_signal_without_workspace_excludes_file_tools(monkeypatch):
|
||||
names = _sent_tool_names(monkeypatch, workspace=None)
|
||||
assert "read_file" not in names
|
||||
assert "get_workspace" not in names
|
||||
|
||||
|
||||
def test_explicit_workspace_request_without_workspace_stops(monkeypatch):
|
||||
import asyncio
|
||||
import src.agent_loop as al
|
||||
|
||||
monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False)
|
||||
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
|
||||
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
|
||||
monkeypatch.setattr(al, "blocked_tools_for_owner", lambda owner: set(), raising=False)
|
||||
|
||||
async def _should_not_stream(*args, **kwargs):
|
||||
raise AssertionError("LLM should not be called when explicit workspace is missing")
|
||||
yield ""
|
||||
|
||||
monkeypatch.setattr(al, "stream_llm_with_fallback", _should_not_stream, raising=False)
|
||||
|
||||
async def _run():
|
||||
gen = al.stream_agent_loop(
|
||||
"https://api.openai.com/v1", "gpt-test",
|
||||
[{"role": "user", "content": "In this workspace, fix a typo and verify it."}],
|
||||
max_rounds=1, relevant_tools=None, owner="admin", workspace=None,
|
||||
)
|
||||
return [c async for c in gen]
|
||||
|
||||
chunks = asyncio.run(_run())
|
||||
text = "".join(chunks)
|
||||
assert "No active workspace is set" in text
|
||||
assert "/workspace set /absolute/path" in text
|
||||
assert '"missing_workspace": true' in text
|
||||
|
||||
|
||||
def test_workspace_coding_mode_prompt_is_injected(monkeypatch):
|
||||
import src.agent_loop as al
|
||||
|
||||
monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False)
|
||||
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
|
||||
monkeypatch.setattr(al, "blocked_tools_for_owner", lambda owner: set(), raising=False)
|
||||
al._cached_base_prompt = None
|
||||
al._cached_base_prompt_key = None
|
||||
|
||||
messages, _ = al._build_system_prompt(
|
||||
messages=[{"role": "user", "content": "fix the bug"}],
|
||||
model="gpt-test",
|
||||
active_document=None,
|
||||
mcp_mgr=None,
|
||||
relevant_tools={"get_workspace", "read_file", "grep", "edit_file", "write_file", "apply_patch", "todowrite", "bash"},
|
||||
workspace="/tmp/example-repo",
|
||||
)
|
||||
system_text = "\n\n".join(m.get("content", "") for m in messages if m.get("role") == "system")
|
||||
assert "## Workspace coding mode" in system_text
|
||||
assert "Active workspace: `/tmp/example-repo`" in system_text
|
||||
assert "call `todowrite`" in system_text
|
||||
assert "Change repo files with `apply_patch`" in system_text
|
||||
|
||||
|
||||
# ── browse route is admin-gated ─────────────────────────────────────────
|
||||
|
||||
def test_browse_is_admin_gated(monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user